package workspace

import (
	"context"
	"errors"
	"os"
	"os/exec"
	"path/filepath"
	"testing"
	"time"

	"github.com/go-git/go-git/v5"
	"github.com/stretchr/testify/require"
)

func TestReviewDiffTextViaGitCLIUsesCallerContext(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}
	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)
	repo, _, err := loadReviewRepoState(repoDir, "", "", "", "")
	require.NoError(t, err)

	parentCtx, cancel := context.WithCancel(context.Background())
	received := make(chan context.Context, 1)
	done := make(chan struct{})
	go func() {
		defer close(done)
		_, _ = reviewDiffTextViaGitCLIWithRunner(
			parentCtx,
			repo,
			"tracked.txt",
			[]string{"diff"},
			func(ctx context.Context, _ string, _ int, _ ...string) ([]byte, bool, error) {
				received <- ctx
				<-ctx.Done()
				return nil, false, ctx.Err()
			},
		)
	}()

	runnerCtx := <-received
	cancel()
	select {
	case <-done:
	case <-time.After(time.Second):
		t.Fatal("git diff runner did not observe caller cancellation")
	}
	require.ErrorIs(t, runnerCtx.Err(), context.Canceled)
}

func TestReviewDiffTextViaGitCLIKeepsTwentySecondLazyTimeout(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}
	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)
	repo, _, err := loadReviewRepoState(repoDir, "", "", "", "")
	require.NoError(t, err)

	var remaining time.Duration
	_, _ = reviewDiffTextViaGitCLIWithRunner(
		context.Background(),
		repo,
		"tracked.txt",
		[]string{"diff"},
		func(ctx context.Context, _ string, _ int, _ ...string) ([]byte, bool, error) {
			deadline, ok := ctx.Deadline()
			require.True(t, ok)
			remaining = time.Until(deadline)
			return nil, false, errors.New("stop")
		},
	)
	require.Greater(t, remaining, 19*time.Second)
	require.LessOrEqual(t, remaining, gitCLIDiffTimeout)
}

// TestParsePorcelainV2Status locks the porcelain v2 -z byte layout the parser
// depends on (captured from a real git), covering ordinary, rename, untracked,
// deleted, path-with-spaces, and intent-to-add (`.A`) records.
func TestParsePorcelainV2Status(t *testing.T) {
	// NUL-joined records matching the format verified empirically against git.
	input := []byte(
		"1 M. N... 100644 100644 100644 " +
			"61780798228d17af2d34fce4cfbdf35556832472 37be7cc4e1e1e932f0e4a34132b76c957387be2a mod.txt\x00" +
			"1 .M N... 100644 100644 100644 " +
			"f2ad6c76f0115a6ba5b00456a849810e7ec0af20 f2ad6c76f0115a6ba5b00456a849810e7ec0af20 name with spaces.txt\x00" +
			"2 R. N... 100644 100644 100644 " +
			"4bcfe98e640c8284511312660fb8709b0afa888e 4bcfe98e640c8284511312660fb8709b0afa888e R100 renamed.txt\x00torename.txt\x00" +
			"1 D. N... 100644 000000 000000 " +
			"d905d9da82c97264ab6f4920e20242e088850ce9 0000000000000000000000000000000000000000 todel.txt\x00" +
			"1 .A N... 000000 000000 100644 " +
			"0000000000000000000000000000000000000000 0000000000000000000000000000000000000000 intent.txt\x00" +
			"? brandnew.txt\x00",
	)

	result := parsePorcelainV2Status(input)
	status := result.status

	cases := []struct {
		path              string
		staging, worktree git.StatusCode
	}{
		{"mod.txt", git.Modified, git.Unmodified},              // staged modify
		{"name with spaces.txt", git.Unmodified, git.Modified}, // unstaged modify, spaces in name
		{"renamed.txt", git.Renamed, git.Unmodified},           // rename (new path; old consumed)
		{"todel.txt", git.Deleted, git.Unmodified},             // staged delete
		{"intent.txt", git.Unmodified, git.Added},              // intent-to-add (.A)
		{"brandnew.txt", git.Untracked, git.Untracked},         // untracked (go-git marks both sides)
	}
	for _, c := range cases {
		fs, ok := status[c.path]
		require.Truef(t, ok, "missing entry for %q", c.path)
		require.Equalf(t, c.staging, fs.Staging, "staging code for %q", c.path)
		require.Equalf(t, c.worktree, fs.Worktree, "worktree code for %q", c.path)
	}
	// The rename's old path must NOT survive as a separate entry (it was
	// consumed as the trailing segment of the `2 ` record).
	_, hasOld := status["torename.txt"]
	require.False(t, hasOld, "rename old path should not appear as its own entry")
	require.Equal(t, "torename.txt", result.oldPathByNewPath["renamed.txt"])
	require.Len(t, status, len(cases))
}

// TestReviewStatusViaGitCLIMatchesGoGit proves the CLI acceleration produces a
// status byte-identical (path set + per-file Staging/Worktree codes) to go-git's
// worktree status for the common change shapes, so the migration is real and the
// downstream flags cannot diverge between engines.
func TestReviewStatusViaGitCLIMatchesGoGit(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}
	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)

	// Unstaged modify of the tracked file.
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("tracked-mod\n"), 0o644))
	// Staged add of a new file.
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "staged.txt"), []byte("staged\n"), 0o644))
	runGit(t, repoDir, "add", "staged.txt")
	// New untracked file.
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "untracked.txt"), []byte("u\n"), 0o644))
	// Intent-to-add file.
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "intent.txt"), []byte("i\n"), 0o644))
	runGit(t, repoDir, "add", "-N", "intent.txt")

	cliResult, err := reviewStatusViaGitCLI(repoDir)
	require.NoError(t, err)
	cliStatus := cliResult.status

	r, err := git.PlainOpen(repoDir)
	require.NoError(t, err)
	wt, err := r.Worktree()
	require.NoError(t, err)
	goGitStatus, err := wt.StatusWithOptions(git.StatusOptions{Strategy: git.Preload})
	require.NoError(t, err)

	// Normalize intent-to-add to go-git's shape (the production path does this
	// in reviewWorkingFiles) before comparing, since porcelain reports it
	// differently from go-git.
	intent, err := reviewIntentToAddPaths(r)
	require.NoError(t, err)
	for path := range intent {
		if _, ok := cliStatus[path]; ok {
			cliStatus[path] = &git.FileStatus{Staging: git.Added, Worktree: git.Modified}
		}
	}

	require.Equal(t, len(goGitStatus), len(cliStatus),
		"entry count mismatch: cli=%v gogit=%v", cliStatus, goGitStatus)
	for path, want := range goGitStatus {
		got, ok := cliStatus[path]
		require.Truef(t, ok, "cli missing path %q (gogit had it)", path)
		require.Equalf(t, want.Staging, got.Staging, "staging mismatch for %q", path)
		require.Equalf(t, want.Worktree, got.Worktree, "worktree mismatch for %q", path)
	}
}

func TestCollapseExactWorkingRenamesPairsAddedAndDeletedBlobs(t *testing.T) {
	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)
	require.NoError(t, os.Rename(
		filepath.Join(repoDir, "tracked.txt"),
		filepath.Join(repoDir, "renamed.txt"),
	))
	runGit(t, repoDir, "add", "-A")

	r, err := git.PlainOpen(repoDir)
	require.NoError(t, err)
	wt, err := r.Worktree()
	require.NoError(t, err)
	status, err := wt.StatusWithOptions(git.StatusOptions{Strategy: git.Preload})
	require.NoError(t, err)

	result, err := collapseExactWorkingRenames(r, status)
	require.NoError(t, err)
	require.Equal(t, "tracked.txt", result.oldPathByNewPath["renamed.txt"])
	require.Equal(t, git.Renamed, result.status["renamed.txt"].Staging)
	require.Equal(t, git.Unmodified, result.status["renamed.txt"].Worktree)
	_, hasOld := result.status["tracked.txt"]
	require.False(t, hasOld)
}

// TestReviewAheadBehindViaCLIMatchesGoGit verifies the CLI ahead/behind counts
// agree with the go-git traversal on a divergent history.
func TestReviewAheadBehindViaCLIMatchesGoGit(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}
	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)

	// Divergent history: main advances by 1, feature advances by 2.
	runGit(t, repoDir, "branch", "feature")
	runGit(t, repoDir, "commit", "--allow-empty", "-m", "main2")
	runGit(t, repoDir, "checkout", "-q", "feature")
	runGit(t, repoDir, "commit", "--allow-empty", "-m", "feat1")
	runGit(t, repoDir, "commit", "--allow-empty", "-m", "feat2")

	r, err := git.PlainOpen(repoDir)
	require.NoError(t, err)

	cliAhead, cliBehind, err := reviewAheadBehindViaCLI(repoDir, "main", "feature")
	require.NoError(t, err)
	goGitAhead, goGitBehind, err := reviewAheadBehindViaGoGit(r, "main", "feature")
	require.NoError(t, err)

	require.Equal(t, goGitAhead, cliAhead, "ahead count")
	require.Equal(t, goGitBehind, cliBehind, "behind count")
	require.Equal(t, 2, cliAhead)
	require.Equal(t, 1, cliBehind)
}

// TestReviewDiscoveredRefsViaCLIMatchesGoGit verifies the CLI ref listing
// agrees with go-git's reference iterator (same set of branch/remote/tag short
// names, modulo ordering).
func TestReviewDiscoveredRefsViaCLIMatchesGoGit(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}
	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)
	runGit(t, repoDir, "commit", "--allow-empty", "-m", "c1")
	runGit(t, repoDir, "branch", "feature-a")
	runGit(t, repoDir, "branch", "feature-b")
	runGit(t, repoDir, "tag", "v1.0")

	r, err := git.PlainOpen(repoDir)
	require.NoError(t, err)

	cliRefs, err := reviewDiscoveredRefsViaCLI(repoDir)
	require.NoError(t, err)
	goGitRefs, err := reviewDiscoveredRefsViaGoGit(r)
	require.NoError(t, err)

	require.ElementsMatch(t, goGitRefs, cliRefs)
	// Sanity: the branches and tag we created are present in both.
	for _, want := range []string{"feature-a", "feature-b", "v1.0"} {
		require.Contains(t, cliRefs, want)
	}
}
