// Package git runs git in a workspace to mirror its tree to a remote.
// The token is passed inline in fetch/push URLs and never persisted to
// .git/config. All ops are caller-orchestrated and best-effort.
package git

import (
	"bytes"
	"context"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
)

// Repo describes a workspace git mirror. RemoteURL may be a local path (tests)
// or a tokenized https URL (https://x-access-token:TOKEN@github.com/owner/name.git).
type Repo struct {
	Dir         string
	RemoteURL   string
	Branch      string
	AuthorName  string
	AuthorEmail string
}

func run(ctx context.Context, dir string, args ...string) (string, error) {
	cmd := exec.CommandContext(ctx, "git", args...)
	cmd.Dir = dir
	cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=true")
	var out bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &out
	err := cmd.Run()
	return out.String(), err
}

// RemoteBranchSHA returns the remote branch HEAD sha, or "" if the branch
// doesn't exist (empty repo).
func RemoteBranchSHA(ctx context.Context, url, branch string) (string, error) {
	cmd := exec.CommandContext(ctx, "git", "ls-remote", url, "refs/heads/"+branch)
	cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=true")
	var out bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &out
	if err := cmd.Run(); err != nil {
		return "", fmt.Errorf("ls-remote: %s", out.String())
	}
	line := strings.TrimSpace(out.String())
	if line == "" {
		return "", nil
	}
	return strings.Fields(line)[0], nil
}

// Ensure initialises the repo (if needed) and sets the committer identity.
//
// It checks for the workspace's OWN .git directory rather than `git rev-parse
// --git-dir`, because rev-parse walks up to an enclosing parent repository. When
// the workspace lives inside another git checkout (e.g. the builder's own repo
// in dev, or any parent repo), the walk-up would skip init and make every git
// op target the parent — so `git add -A` would stage nothing (the workspace is
// usually gitignored there) and the push would silently no-op. Initialising the
// workspace's own repo guarantees git operates on the workspace tree.
func (r *Repo) Ensure(ctx context.Context) error {
	if _, err := os.Stat(filepath.Join(r.Dir, ".git")); err != nil {
		if out, err := run(ctx, r.Dir, "init", "-b", r.Branch); err != nil {
			return fmt.Errorf("git init: %s", out)
		}
	}
	_, _ = run(ctx, r.Dir, "config", "user.name", r.AuthorName)
	_, _ = run(ctx, r.Dir, "config", "user.email", r.AuthorEmail)
	return nil
}

// EnsureGitignore makes sure the workspace .gitignore contains each pattern
// (appending any that are missing) and untracks any already-committed paths the
// patterns now ignore. This keeps builder-internal artifacts (node_modules,
// dist, .revisions, agent notes) and secrets (.env) out of the linked
// repository, and lets a previously-polluted repo self-heal on the next push.
func (r *Repo) EnsureGitignore(ctx context.Context, patterns []string) error {
	path := filepath.Join(r.Dir, ".gitignore")
	existing := ""
	if b, err := os.ReadFile(path); err == nil {
		existing = string(b)
	}
	present := map[string]bool{}
	for _, line := range strings.Split(existing, "\n") {
		present[strings.TrimSpace(line)] = true
	}
	var missing []string
	for _, p := range patterns {
		if !present[p] {
			missing = append(missing, p)
		}
	}
	if len(missing) > 0 {
		var b strings.Builder
		b.WriteString(existing)
		if existing != "" && !strings.HasSuffix(existing, "\n") {
			b.WriteString("\n")
		}
		b.WriteString("# Added by Webby\n")
		b.WriteString(strings.Join(missing, "\n"))
		b.WriteString("\n")
		if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
			return fmt.Errorf("write .gitignore: %w", err)
		}
	}
	// Self-heal: untrack anything already committed that is now ignored.
	for _, p := range patterns {
		_, _ = run(ctx, r.Dir, "rm", "-r", "--cached", "--ignore-unmatch", strings.TrimSuffix(p, "/"))
	}
	return nil
}

// BaseOnRemote fetches the remote branch and repoints HEAD at it without
// touching the working tree, so the next commit is a child of the remote tip
// (fast-forward push). Call only when the remote branch exists.
func (r *Repo) BaseOnRemote(ctx context.Context) error {
	if out, err := run(ctx, r.Dir, "fetch", r.RemoteURL, r.Branch); err != nil {
		return fmt.Errorf("fetch: %s", out)
	}
	if out, err := run(ctx, r.Dir, "reset", "--soft", "FETCH_HEAD"); err != nil {
		return fmt.Errorf("reset --soft: %s", out)
	}
	// Remove FETCH_HEAD so the tokenized fetch URL is not persisted on disk.
	_ = os.Remove(filepath.Join(r.Dir, ".git", "FETCH_HEAD"))
	return nil
}

// CommitAll stages everything and commits. Returns committed=false when there's
// nothing to commit.
func (r *Repo) CommitAll(ctx context.Context, message string) (bool, error) {
	if out, err := run(ctx, r.Dir, "add", "-A"); err != nil {
		return false, fmt.Errorf("add: %s", out)
	}
	if _, err := run(ctx, r.Dir, "diff", "--cached", "--quiet"); err == nil {
		return false, nil // exit 0 => nothing staged
	}
	if out, err := run(ctx, r.Dir, "commit", "-m", message); err != nil {
		return false, fmt.Errorf("commit: %s", out)
	}
	return true, nil
}

// StageAll stages all changes. Returns hasChanges=false when the index is clean
// (nothing to commit).
func (r *Repo) StageAll(ctx context.Context) (bool, error) {
	if out, err := run(ctx, r.Dir, "add", "-A"); err != nil {
		return false, fmt.Errorf("add: %s", out)
	}
	if _, err := run(ctx, r.Dir, "diff", "--cached", "--quiet"); err == nil {
		return false, nil // exit 0 => nothing staged
	}
	return true, nil
}

// StagedDiffStat returns `git diff --cached --stat` (compact change summary) for
// commit-message generation. Empty string when nothing is staged.
func (r *Repo) StagedDiffStat(ctx context.Context) (string, error) {
	out, err := run(ctx, r.Dir, "diff", "--cached", "--stat")
	if err != nil {
		return "", fmt.Errorf("diff --cached --stat: %s", out)
	}
	return strings.TrimSpace(out), nil
}

// Commit creates a commit with the given message. Caller must have staged via
// StageAll and confirmed there are changes.
func (r *Repo) Commit(ctx context.Context, message string) error {
	if out, err := run(ctx, r.Dir, "commit", "-m", message); err != nil {
		return fmt.Errorf("commit: %s", out)
	}
	return nil
}

// Push fast-forward pushes HEAD to the remote branch (no force).
func (r *Repo) Push(ctx context.Context) error {
	if out, err := run(ctx, r.Dir, "push", r.RemoteURL, "HEAD:refs/heads/"+r.Branch); err != nil {
		return fmt.Errorf("push: %s", out)
	}
	return nil
}

// HeadSHA returns the current HEAD sha.
func (r *Repo) HeadSHA(ctx context.Context) (string, error) {
	out, err := run(ctx, r.Dir, "rev-parse", "HEAD")
	if err != nil {
		return "", fmt.Errorf("rev-parse: %s", out)
	}
	return strings.TrimSpace(out), nil
}

// Log returns the last n commits as "sha subject" lines (read-only). Empty when
// there is no history.
func (r *Repo) Log(ctx context.Context, n int) (string, error) {
	out, err := run(ctx, r.Dir, "log", fmt.Sprintf("-%d", n), "--pretty=format:%h %s")
	if err != nil {
		return "", nil
	}
	return strings.TrimSpace(out), nil
}
