package workspace

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"strings"
	"sync"
	"time"

	"github.com/go-git/go-git/v5"
)

// This file hosts the git-CLI acceleration layer for the review hot paths.
//
// go-git reimplements git in pure Go, which is correct but materially slower
// than the native C git binary on the two heaviest review operations: the full
// worktree status walk and the all-refs read. For a large repository these
// dominate review-page load time (multi-second). Running the native git CLI
// for these paths cuts them to milliseconds.
//
// Every accelerated path is OPTIONAL: when the git binary is not on PATH or a
// CLI invocation fails, the caller falls back to the original go-git
// implementation. The CLI output is normalized back into the exact types the
// go-git path produces (git.Status, ref name lists, ahead/behind counts) so the
// downstream code is identical regardless of which engine served the request.

// gitCLIStatusTimeout bounds a single `git status` CLI call. It is a backstop
// against a pathological hang (NFS stall, deadlocked index); normal operation
// completes in well under a second, so this never fires in practice.
const gitCLIStatusTimeout = 20 * time.Second

// gitCLIDiffTimeout bounds a single `git diff <path>` CLI call. The native git
// path is used to render compact diffs for very large files without falling
// back to the quadratic in-process LCS implementation; the timeout prevents a
// pathological diff from blocking review rendering indefinitely.
const gitCLIDiffTimeout = 20 * time.Second

// gitCLIQuickTimeout bounds the lightweight ref/rev-list calls.
const gitCLIQuickTimeout = 10 * time.Second

var (
	gitCLIProbeOnce sync.Once
	gitCLIProbeOK   bool
)

// gitCLIAvailable reports whether a usable git CLI is on PATH. The result is
// probed once per process and cached: the binary does not appear or disappear
// mid-run, and re-running LookPath on the hot path would defeat the purpose of
// the acceleration. A present binary is sufficient — we do not shell out to
// `git --version`, which would add a process spawn to every call.
func gitCLIAvailable() bool {
	gitCLIProbeOnce.Do(func() {
		path, err := exec.LookPath("git")
		gitCLIProbeOK = err == nil && path != ""
	})
	return gitCLIProbeOK
}

// runGitCLI executes git -C dir with the given args and returns stdout. It is
// the single execution seam for the accelerated paths so behavior (working
// directory, locking, timeout, stderr capture) stays consistent. A non-zero
// exit or timeout yields an error; callers fall back to go-git.
//
// --no-optional-locks keeps `git status` from refreshing/writing the index
// cache, so the read-only review scan never contends with the user's own
// concurrent git operations.
func runGitCLI(ctx context.Context, dir string, args ...string) ([]byte, error) {
	fullArgs := append([]string{"-C", dir, "--no-optional-locks"}, args...)
	cmd := exec.CommandContext(ctx, "git", fullArgs...)
	if dir != "" {
		cmd.Dir = dir
	}
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
	}
	return stdout.Bytes(), nil
}

type gitCLILimitedBuffer struct {
	bytes.Buffer
	limit    int
	exceeded bool
}

func (buf *gitCLILimitedBuffer) Write(p []byte) (int, error) {
	if buf.limit <= 0 {
		buf.exceeded = true
		return len(p), nil
	}
	remaining := buf.limit - buf.Buffer.Len()
	if remaining <= 0 {
		buf.exceeded = true
		return len(p), nil
	}
	if len(p) > remaining {
		_, _ = buf.Buffer.Write(p[:remaining])
		buf.exceeded = true
		return len(p), nil
	}
	return buf.Buffer.Write(p)
}

func runGitCLILimited(ctx context.Context, dir string, limit int, args ...string) ([]byte, bool, error) {
	fullArgs := append([]string{"-C", dir, "--no-optional-locks"}, args...)
	cmd := exec.CommandContext(ctx, "git", fullArgs...)
	if dir != "" {
		cmd.Dir = dir
	}
	stdout := &gitCLILimitedBuffer{limit: limit}
	var stderr bytes.Buffer
	cmd.Stdout = stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return nil, false, fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
	}
	return stdout.Bytes(), stdout.exceeded, nil
}

func reviewStagedDiffTextViaGitCLI(repo reviewRepoState, path string) (string, bool) {
	return reviewStagedDiffTextViaGitCLIContext(context.Background(), repo, path)
}

func reviewStagedDiffTextViaGitCLIContext(ctx context.Context, repo reviewRepoState, path string) (string, bool) {
	return reviewDiffTextViaGitCLIContext(ctx, repo, path, []string{"diff", "--cached", "--no-ext-diff", "--no-color", "--no-renames"})
}

func reviewUnstagedDiffTextViaGitCLI(repo reviewRepoState, path string) (string, bool) {
	return reviewUnstagedDiffTextViaGitCLIContext(context.Background(), repo, path)
}

func reviewUnstagedDiffTextViaGitCLIContext(ctx context.Context, repo reviewRepoState, path string) (string, bool) {
	return reviewDiffTextViaGitCLIContext(ctx, repo, path, []string{"diff", "--no-ext-diff", "--no-color", "--no-renames"})
}

func reviewBranchDiffTextViaGitCLI(repo reviewRepoState, path string) (string, bool) {
	return reviewBranchDiffTextViaGitCLIContext(context.Background(), repo, path)
}

func reviewBranchDiffTextViaGitCLIContext(ctx context.Context, repo reviewRepoState, path string) (string, bool) {
	baseRef, headRef := reviewBranchDiffCommitHashes(repo)
	if strings.TrimSpace(baseRef) == "" || strings.TrimSpace(headRef) == "" {
		return "", false
	}
	return reviewDiffTextViaGitCLIContext(ctx, repo, path, []string{"diff", "--no-ext-diff", "--no-color", "--no-renames", baseRef, headRef})
}

func reviewDiffTextViaGitCLI(repo reviewRepoState, path string, args []string) (string, bool) {
	return reviewDiffTextViaGitCLIContext(context.Background(), repo, path, args)
}

func reviewDiffTextViaGitCLIContext(ctx context.Context, repo reviewRepoState, path string, args []string) (string, bool) {
	return reviewDiffTextViaGitCLIWithRunner(ctx, repo, path, args, runGitCLILimited)
}

type reviewGitDiffRunner func(context.Context, string, int, ...string) ([]byte, bool, error)

func reviewDiffTextViaGitCLIWithRunner(
	ctx context.Context,
	repo reviewRepoState,
	path string,
	args []string,
	run reviewGitDiffRunner,
) (string, bool) {
	if ctx == nil {
		ctx = context.Background()
	}
	if path == "" || !gitCLIAvailable() {
		return "", false
	}
	root, ok := reviewRepoWorktreeRoot(repo.repo)
	if !ok || root == "" {
		return "", false
	}
	cliCtx, cancel := context.WithTimeout(ctx, gitCLIDiffTimeout)
	defer cancel()
	args = append(args, "--", path)
	out, exceeded, err := run(cliCtx, root, reviewDiffMaxBytes+1, args...)
	if err != nil || len(out) == 0 {
		return "", false
	}
	if exceeded || len(out) > reviewDiffMaxBytes {
		return reviewDiffTooLargeMarker, true
	}
	text := string(out)
	if strings.Contains(text, "\x00") || reviewGitDiffOutputIsBinary(text) {
		return reviewDiffBinaryMarker, true
	}
	return text, true
}

func reviewGitDiffOutputIsBinary(text string) bool {
	for _, line := range strings.Split(text, "\n") {
		if strings.HasPrefix(line, "Binary files ") && strings.HasSuffix(line, " differ") {
			return true
		}
	}
	return false
}

type reviewPorcelainStatus struct {
	status           git.Status
	oldPathByNewPath map[string]string
}

// reviewStatusViaGitCLI runs `git status --porcelain=v2 -z --renames
// --untracked-files=all` and parses the result into a go-git git.Status so the
// existing downstream logic (token computation, staged/unstaged/change-type
// derivation) consumes it unchanged.
//
// Flags were chosen to match go-git's worktree-status semantics as closely as
// the native CLI allows:
//   - --renames: collapse a detected rename into the destination path and
//     retain the source path as ReviewFile.OldPath. The go-git fallback still
//     returns add/delete because its worktree status API does not expose rename
//     pairs; native git is the authoritative accelerated path when available.
//   - --untracked-files=all: expand untracked directories into individual
//     files, matching go-git's per-file granularity.
//   - info/exclude, .gitignore, and worktree-local excludes are honored by
//     native git automatically (go-git applies info/exclude manually); no
//     pathspec plumbing is needed.
func reviewStatusViaGitCLI(workingRoot string) (reviewPorcelainStatus, error) {
	if !gitCLIAvailable() {
		return reviewPorcelainStatus{}, errors.New("git CLI unavailable")
	}
	ctx, cancel := context.WithTimeout(context.Background(), gitCLIStatusTimeout)
	defer cancel()
	args := []string{
		"status",
		"--renames",
		"--untracked-files=all",
		"--porcelain=v2",
		"-z",
	}
	// Point native git at the same info/exclude file the go-git path applies
	// manually (via -c core.excludesFile), so both engines honor the same local
	// excludes. This is required for linked worktrees, where native git reads
	// info/exclude from the common dir but go-git reads the worktree gitdir's
	// copy; without it the two would exclude different files. Overriding
	// core.excludesFile also drops the user's global ignore file, which go-git
	// never reads either — keeping the two byte-identical.
	if excludeFile, err := reviewExcludeFilePath(workingRoot); err == nil && excludeFile != "" {
		if info, statErr := os.Stat(excludeFile); statErr == nil && !info.IsDir() {
			args = append([]string{"-c", "core.excludesFile=" + excludeFile}, args...)
		}
	}
	out, err := runGitCLI(ctx, workingRoot, args...)
	if err != nil {
		return reviewPorcelainStatus{}, err
	}
	return parsePorcelainV2Status(out), nil
}

// parsePorcelainV2Status parses `git status --porcelain=v2 -z` output into a
// go-git git.Status (map[path]*FileStatus). With -z, every record is
// NUL-terminated and paths are emitted verbatim (no C-quoting), so paths
// containing spaces or other special characters survive intact. A rename
// record (`2 ...`) is followed by a separate NUL-terminated segment holding the
// old path, which is consumed and discarded (go-git reports renames as a
// delete+add pair when --no-renames is in effect, so the old path is not needed
// here; the branch is retained for safety in case the flag is ever dropped).
//
// porcelain v2 uses '.' for "unmodified" in the XY field (unlike v1's space);
// both are mapped to go-git's Unmodified. Control-character paths are not
// filtered here — they travel through as literal bytes and are rejected by the
// caller's validateReviewTreePath, preserving the ErrReviewControlCharPath
// sentinel behavior of the go-git path.
func parsePorcelainV2Status(out []byte) reviewPorcelainStatus {
	status := make(git.Status)
	oldPathByNewPath := make(map[string]string)
	segments := strings.Split(string(out), "\x00")
	for i := 0; i < len(segments); i++ {
		seg := segments[i]
		if seg == "" {
			continue
		}
		switch {
		case strings.HasPrefix(seg, "1 "):
			// Ordinary: `1 XY sub mH mI mW hH hI path` — 9 fields. SplitN with
			// limit 9 keeps the path (which may contain spaces) intact as the
			// trailing field rather than splitting on its internal spaces.
			fields := strings.SplitN(seg, " ", 9)
			if len(fields) < 9 {
				continue
			}
			xy := fields[1]
			path := fields[8]
			if path == "" || len(xy) < 2 {
				continue
			}
			status[path] = &git.FileStatus{
				Staging:  porcelainStatusCode(xy[0]),
				Worktree: porcelainStatusCode(xy[1]),
			}
		case strings.HasPrefix(seg, "u "):
			// Unmerged: `u XY sub m1 m2 m3 mW h1 h2 h3 path` — 11 fields.
			fields := strings.SplitN(seg, " ", 11)
			if len(fields) < 11 {
				continue
			}
			xy := fields[1]
			path := fields[10]
			if path == "" || len(xy) < 2 {
				continue
			}
			status[path] = &git.FileStatus{
				Staging:  porcelainStatusCode(xy[0]),
				Worktree: porcelainStatusCode(xy[1]),
			}
		case strings.HasPrefix(seg, "2 "):
			// Rename/copy: header `2 XY sub mH mI mW hH hI Rscore newpath` —
			// 10 fields — followed by a separate segment holding the old path.
			fields := strings.SplitN(seg, " ", 10)
			if len(fields) < 10 {
				continue
			}
			xy := fields[1]
			newPath := fields[9]
			if newPath != "" && len(xy) >= 2 {
				status[newPath] = &git.FileStatus{
					Staging:  porcelainStatusCode(xy[0]),
					Worktree: porcelainStatusCode(xy[1]),
				}
			}
			if i+1 < len(segments) {
				i++
				if newPath != "" && segments[i] != "" {
					oldPathByNewPath[newPath] = segments[i]
				}
			}
		case strings.HasPrefix(seg, "? "):
			path := seg[2:]
			if path != "" {
				// go-git marks untracked files with Untracked on BOTH the
				// staging and worktree sides; mirror that so the status is
				// byte-identical to the go-git path.
				status[path] = &git.FileStatus{
					Staging:  git.Untracked,
					Worktree: git.Untracked,
				}
			}
		case strings.HasPrefix(seg, "! "):
			// Ignored entry (only emitted with --ignored, which we never pass).
			// Skip defensively.
		}
	}
	return reviewPorcelainStatus{
		status:           status,
		oldPathByNewPath: oldPathByNewPath,
	}
}

// porcelainStatusCode maps a porcelain v2 XY status byte to the go-git
// StatusCode the downstream code compares against. v2 emits '.' for
// unmodified; both '.' and ' ' map to go-git's Unmodified (' '). Type-changed
// ('T') has no go-git constant and is mapped to Modified so the downstream
// flags still classify it as a change (reviewStatusCodeToChangeType then
// resolves it to "M").
func porcelainStatusCode(c byte) git.StatusCode {
	switch c {
	case ' ', '.':
		return git.Unmodified
	case 'M', 'T':
		return git.Modified
	case 'A':
		return git.Added
	case 'D':
		return git.Deleted
	case 'R':
		return git.Renamed
	case 'C':
		return git.Copied
	case '?':
		return git.Untracked
	case 'U':
		return git.UpdatedButUnmerged
	default:
		return git.Unmodified
	}
}

// reviewRepoWorktreeRoot returns the worktree filesystem root for a go-git
// repository, used as the -C working directory for CLI calls. The second result
// is false when the root cannot be determined (e.g. bare repo), in which case
// callers skip the CLI path and use go-git.
func reviewRepoWorktreeRoot(r *git.Repository) (string, bool) {
	wt, err := r.Worktree()
	if err != nil {
		return "", false
	}
	return wt.Filesystem.Root(), true
}

// reviewDiscoveredRefsViaCLI lists branch, remote-tracking, and tag refs by
// short name via `git for-each-ref`, mirroring reviewAvailableRefs' go-git
// iteration (which skips origin/HEAD; the caller filters and orders).
func reviewDiscoveredRefsViaCLI(root string) ([]string, error) {
	if !gitCLIAvailable() {
		return nil, errors.New("git CLI unavailable")
	}
	ctx, cancel := context.WithTimeout(context.Background(), gitCLIQuickTimeout)
	defer cancel()
	out, err := runGitCLI(ctx, root,
		"for-each-ref",
		"--format=%(refname:short)",
		"refs/heads", "refs/remotes", "refs/tags",
	)
	if err != nil {
		return nil, err
	}
	discovered := make([]string, 0, 64)
	for _, line := range strings.Split(string(out), "\n") {
		short := strings.TrimSpace(line)
		if short == "" || short == "origin/HEAD" {
			continue
		}
		discovered = append(discovered, short)
	}
	return discovered, nil
}

// reviewOriginHEADTargetViaCLI resolves the origin/HEAD symbolic ref to the
// default branch name (e.g. "main"), or returns "" when origin/HEAD is absent
// or not symbolic — mirroring reviewDefaultBranch's first probe.
func reviewOriginHEADTargetViaCLI(root string) (string, error) {
	if !gitCLIAvailable() {
		return "", errors.New("git CLI unavailable")
	}
	ctx, cancel := context.WithTimeout(context.Background(), gitCLIQuickTimeout)
	defer cancel()
	// --quiet: exit non-zero (no stderr noise) when the ref is missing.
	out, err := runGitCLI(ctx, root, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
	if err != nil {
		return "", nil // missing origin/HEAD is not an error here
	}
	target := strings.TrimSpace(string(out))
	const prefix = "refs/remotes/origin/"
	if !strings.HasPrefix(target, prefix) {
		return "", nil
	}
	return strings.TrimPrefix(target, prefix), nil
}

// reviewAheadBehindViaCLI counts ahead/behind via `git rev-list --count
// base..head` and `head..base`, matching the go-git merge-base-derived counts
// for the common (non-criss-cross) ancestry case.
func reviewAheadBehindViaCLI(root, baseRef, headRef string) (ahead int, behind int, err error) {
	if !gitCLIAvailable() {
		return 0, 0, errors.New("git CLI unavailable")
	}
	ctx, cancel := context.WithTimeout(context.Background(), gitCLIQuickTimeout)
	defer cancel()
	aheadOut, err := runGitCLI(ctx, root, "rev-list", "--count", baseRef+".."+headRef)
	if err != nil {
		return 0, 0, err
	}
	behindOut, err := runGitCLI(ctx, root, "rev-list", "--count", headRef+".."+baseRef)
	if err != nil {
		return 0, 0, err
	}
	ahead, okA := parseNonNegativeInt(strings.TrimSpace(string(aheadOut)))
	behind, okB := parseNonNegativeInt(strings.TrimSpace(string(behindOut)))
	if !okA || !okB {
		return 0, 0, fmt.Errorf("rev-list --count: unexpected output")
	}
	return ahead, behind, nil
}

// parseNonNegativeInt parses a base-10 non-negative integer, returning ok=false
// on any malformed input.
func parseNonNegativeInt(s string) (int, bool) {
	if s == "" {
		return 0, false
	}
	n := 0
	for _, r := range s {
		if r < '0' || r > '9' {
			return 0, false
		}
		n = n*10 + int(r-'0')
	}
	return n, true
}
