package workspace

import (
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"github.com/stretchr/testify/require"
)

// TestComputeBranchSignatureUsesResolvedCommitPairNotLiveRefs is the TOCTOU
// regression test for the direct-mode resolve seam.
//
// Scenario:
//  1. Repo has main (commit C0) and feature (commit C1).
//  2. computeBranchSignature at T0 resolves (base=C0, head=C1) and caches
//     signature S0 under key {C0, C1}.
//  3. Concurrent push moves BOTH refs: main now points at C2, feature at C3.
//  4. computeBranchSignature at T1 resolves (base=C2, head=C3) and computes
//     a new signature S1 under key {C2, C3}.
//
// What we're pinning: the resolved commit pair is the SOLE input to the cache
// key, DiffTree loader, and aggregate token. A late caller that arrives after
// the refs have moved MUST observe the new pair's signature, not a stale entry
// served under an old pair's cache key. Without the single-resolve seam
// (resolveReviewBranchCommitPair + loadReviewBranchChangesAtCommits), a
// concurrent push between resolve and DiffTree could store a new diff under
// an old cache key.
//
// This test cannot deterministically reproduce the in-flight push between
// resolve and DiffTree without instrumenting go-git, but it pins the
// INVARIANT the seam maintains: the cache key is derived from the resolved
// commit hashes, so a ref movement that produces a new pair always misses
// the old cache entry and forces a fresh resolve + load.
func TestComputeBranchSignatureUsesResolvedCommitPairNotLiveRefs(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	// Isolate the cache so this test does not pollute process-wide state and
	// is not polluted by other tests in the package.
	origDefault := defaultBranchSignatureCache
	t.Cleanup(func() { defaultBranchSignatureCache = origDefault })
	defaultBranchSignatureCache = newBranchSignatureCache(8)

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)

	// main = C0 (initial commit). Create feature off main with one extra
	// commit (C1) so the diff is non-empty.
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "feature.txt"), []byte("feature1\n"), 0o644))
	runGit(t, repoDir, "add", "feature.txt")
	runGit(t, repoDir, "checkout", "-b", "feature")
	runGit(t, repoDir, "commit", "-m", "feature1")
	runGit(t, repoDir, "checkout", "main")

	c0 := strings.TrimSpace(runGitOutput(t, repoDir, "rev-parse", "main"))
	c1 := strings.TrimSpace(runGitOutput(t, repoDir, "rev-parse", "feature"))

	repo := openReviewRepoForTest(t, repoDir)
	state := reviewRepoState{
		repo:           repo,
		baseRef:        "main",
		headRef:        "feature",
		comparisonMode: "direct",
	}

	// T0: compute signature for (C0, C1).
	sig0, err := computeBranchSignature(state)
	require.NoError(t, err)
	require.NotEmpty(t, sig0.token, "signature token must be non-empty for non-empty diff")
	require.Equal(t, c0, sig0.baseCommit, "baseCommit must be the resolved main hash")
	require.Equal(t, 1, sig0.fileCount, "exactly one file (feature.txt) should differ")

	// The cache must hold an entry under key {C0, C1}.
	_, ok := defaultBranchSignatureCache.lookup(branchSignatureKey{baseCommit: c0, headCommit: c1})
	require.True(t, ok, "cache must hold entry for the original pair after compute")

	// T1: move BOTH refs. main fast-forwards to a new commit (C2), feature
	// fast-forwards to another new commit (C3). Simulates a concurrent push
	// that moves both refs out from under us.
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "main.txt"), []byte("main2\n"), 0o644))
	runGit(t, repoDir, "add", "main.txt")
	runGit(t, repoDir, "commit", "-m", "main2") // main now at C2

	runGit(t, repoDir, "checkout", "feature")
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "feature.txt"), []byte("feature2\n"), 0o644))
	runGit(t, repoDir, "add", "feature.txt")
	runGit(t, repoDir, "commit", "-m", "feature2") // feature now at C3
	runGit(t, repoDir, "checkout", "main")

	c2 := strings.TrimSpace(runGitOutput(t, repoDir, "rev-parse", "main"))
	c3 := strings.TrimSpace(runGitOutput(t, repoDir, "rev-parse", "feature"))
	require.NotEqual(t, c0, c2, "main must have moved")
	require.NotEqual(t, c1, c3, "feature must have moved")

	// T1: compute signature again with the SAME ref names but new resolved
	// commits. The new resolve must produce key {C2, C3}, which misses the
	// old cache entry {C0, C1} and forces a fresh load.
	sig1, err := computeBranchSignature(state)
	require.NoError(t, err)
	require.NotEqual(t, sig0.token, sig1.token,
		"signature must change when both refs resolve to a new commit pair")
	require.Equal(t, c2, sig1.baseCommit, "second compute must use the new resolved main hash")

	// Old cache entry must still be intact (LRU evicts by recency, not by
	// ref movement); a new entry under {C2, C3} must also be present.
	_, okOld := defaultBranchSignatureCache.lookup(branchSignatureKey{baseCommit: c0, headCommit: c1})
	require.True(t, okOld, "old pair's cache entry must survive (cache is keyed by commit, not ref)")
	_, okNew := defaultBranchSignatureCache.lookup(branchSignatureKey{baseCommit: c2, headCommit: c3})
	require.True(t, okNew, "new pair's cache entry must be stored after second compute")
}

// TestComputeBranchSignatureIsStableAcrossRefMovementForSamePair proves the
// inverse direction: if the refs move but still resolve to the SAME commit
// pair (e.g., a force-push to an identical commit, or a symbolic-ref switch
// that points at the same hash), the cached signature is reused. This is
// the load-sharing payoff from keying by commit hash instead of ref name.
func TestComputeBranchSignatureIsStableAcrossRefMovementForSamePair(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	origDefault := defaultBranchSignatureCache
	t.Cleanup(func() { defaultBranchSignatureCache = origDefault })
	defaultBranchSignatureCache = newBranchSignatureCache(8)

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "feature.txt"), []byte("feature1\n"), 0o644))
	runGit(t, repoDir, "add", "feature.txt")
	runGit(t, repoDir, "checkout", "-b", "feature")
	runGit(t, repoDir, "commit", "-m", "feature1")
	runGit(t, repoDir, "checkout", "main")

	c0 := strings.TrimSpace(runGitOutput(t, repoDir, "rev-parse", "main"))
	c1 := strings.TrimSpace(runGitOutput(t, repoDir, "rev-parse", "feature"))

	repo := openReviewRepoForTest(t, repoDir)
	state := reviewRepoState{
		repo:           repo,
		baseRef:        "main",
		headRef:        "feature",
		comparisonMode: "direct",
	}

	sig0, err := computeBranchSignature(state)
	require.NoError(t, err)

	// Create an alternate branch name that points at the same commit as
	// feature. The resolve seam turns "feature-alias" into the same hash,
	// so the cache lookup hits.
	runGit(t, repoDir, "branch", "feature-alias", c1)
	state.headRef = "feature-alias"

	sig1, err := computeBranchSignature(state)
	require.NoError(t, err)
	require.Equal(t, sig0.token, sig1.token,
		"signature must be stable when refs resolve to the same commit pair")
	require.Equal(t, c0, sig1.baseCommit)

	// Cache must hold exactly ONE entry — the resolve seam collapsed both
	// lookups onto the same key.
	require.Equal(t, 1, defaultBranchSignatureCache.len(),
		"two ref names resolving to the same hash must share one cache entry")
}

// TestComputeBranchSignatureBarrierBetweenResolveAndLoadDeterministic is the
// barrier regression test codex6 required. Unlike the two-call movement test
// above, this verifies the resolve→load gap WITHIN A SINGLE CALL: the hook
// fires after resolveReviewBranchCommitPair returns the resolved pair and
// before the cache getOrCreate closure runs the DiffTree load. Moving the
// base ref in that gap MUST NOT change which commits the DiffTree anchors at
// — the loader receives the resolved hashes by value, not re-resolved refs.
//
// Sequence:
//  1. main = C0, feature = C1.
//  2. Set hook that moves main → C2 (new commit) during the gap.
//  3. computeBranchSignature resolves (C0, C1); hook moves main to C2; load
//     runs anchored at (C0, C1) — NOT (C2, C1).
//  4. Assert: signature is cached under {C0, C1} (not {C2, C1}), and the
//     cache holds NO entry for {C2, C1}. The loader never saw C2.
func TestComputeBranchSignatureBarrierBetweenResolveAndLoadDeterministic(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	origDefault := defaultBranchSignatureCache
	t.Cleanup(func() {
		defaultBranchSignatureCache = origDefault
		reviewBranchSignatureResolveHook = nil
	})
	defaultBranchSignatureCache = newBranchSignatureCache(8)

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "feature.txt"), []byte("feature1\n"), 0o644))
	runGit(t, repoDir, "add", "feature.txt")
	runGit(t, repoDir, "checkout", "-b", "feature")
	runGit(t, repoDir, "commit", "-m", "feature1")
	runGit(t, repoDir, "checkout", "main")

	c0 := strings.TrimSpace(runGitOutput(t, repoDir, "rev-parse", "main"))
	c1 := strings.TrimSpace(runGitOutput(t, repoDir, "rev-parse", "feature"))

	repo := openReviewRepoForTest(t, repoDir)
	state := reviewRepoState{
		repo:           repo,
		baseRef:        "main",
		headRef:        "feature",
		comparisonMode: "direct",
	}

	// Pre-create the C2 commit on main so the hook can fast-forward main to
	// it without running git during the hook (the hook fires inside the
	// computeBranchSignature call and must not touch the filesystem beyond
	// a ref update; the commit is created up front and the hook only moves
	// the ref).
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "main2.txt"), []byte("main2\n"), 0o644))
	runGit(t, repoDir, "add", "main2.txt")
	runGit(t, repoDir, "commit", "-m", "main2")
	c2 := strings.TrimSpace(runGitOutput(t, repoDir, "rev-parse", "main"))
	require.NotEqual(t, c0, c2, "pre-created C2 must differ from C0")
	// Reset main back to C0 so the resolve at the start of compute sees C0.
	runGit(t, repoDir, "reset", "--hard", c0)

	movedDuringHook := false
	reviewBranchSignatureResolveHook = func(resolvedBase, resolvedHead string) {
		// The hook receives the resolved pair. It must be (C0, C1), NOT the
		// live refs that will be moved. This proves the resolve ran BEFORE
		// the hook and produced the original pair.
		require.Equal(t, c0, resolvedBase, "hook must observe the resolved base hash C0, not a re-resolved value")
		require.Equal(t, c1, resolvedHead, "hook must observe the resolved head hash C1")
		// Move main to C2 during the gap. This simulates a concurrent push.
		runGit(t, repoDir, "update-ref", "refs/heads/main", c2, c0)
		movedDuringHook = true
	}

	sig, err := computeBranchSignature(state)
	require.NoError(t, err)
	require.True(t, movedDuringHook, "hook must have fired during computeBranchSignature")
	require.Equal(t, c0, sig.baseCommit, "signature must be anchored at the resolved C0, not the post-hook live main C2")

	// The cache entry MUST be under {C0, C1} — the resolved pair — even
	// though main now points at C2.
	_, okResolved := defaultBranchSignatureCache.lookup(branchSignatureKey{baseCommit: c0, headCommit: c1})
	require.True(t, okResolved, "cache must hold entry under the resolved pair {C0, C1}")

	// And NOT under {C2, C1} — the loader never saw C2 because it consumed
	// the resolved hashes by value.
	_, okMoved := defaultBranchSignatureCache.lookup(branchSignatureKey{baseCommit: c2, headCommit: c1})
	require.False(t, okMoved, "cache must NOT hold entry under the post-hook live pair {C2, C1} — loader used resolved hashes")
}

// TestComputeBranchSignatureBaseOnlyMovementProducesDistinctEntry covers the
// base-only movement regression: moving ONLY the base ref (head unchanged)
// produces a distinct cache key and a distinct signature. This catches a
// regression where base-side ref movement was ignored because the cache key
// or token accidentally used mergeBase (which can stay stable when only
// baseRef moves in direct mode) instead of the resolved base commit.
func TestComputeBranchSignatureBaseOnlyMovementProducesDistinctEntry(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	origDefault := defaultBranchSignatureCache
	t.Cleanup(func() { defaultBranchSignatureCache = origDefault })
	defaultBranchSignatureCache = newBranchSignatureCache(8)

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "feature.txt"), []byte("feature1\n"), 0o644))
	runGit(t, repoDir, "add", "feature.txt")
	runGit(t, repoDir, "checkout", "-b", "feature")
	runGit(t, repoDir, "commit", "-m", "feature1")
	runGit(t, repoDir, "checkout", "main")

	c0 := strings.TrimSpace(runGitOutput(t, repoDir, "rev-parse", "main"))
	c1 := strings.TrimSpace(runGitOutput(t, repoDir, "rev-parse", "feature"))

	repo := openReviewRepoForTest(t, repoDir)
	state := reviewRepoState{
		repo:           repo,
		baseRef:        "main",
		headRef:        "feature",
		comparisonMode: "direct",
	}

	sig0, err := computeBranchSignature(state)
	require.NoError(t, err)
	require.Equal(t, c0, sig0.baseCommit)

	// Move ONLY base: fast-forward main to a new commit. feature stays at C1.
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "base2.txt"), []byte("base2\n"), 0o644))
	runGit(t, repoDir, "add", "base2.txt")
	runGit(t, repoDir, "commit", "-m", "base2")
	c2 := strings.TrimSpace(runGitOutput(t, repoDir, "rev-parse", "main"))
	require.NotEqual(t, c0, c2)

	sig1, err := computeBranchSignature(state)
	require.NoError(t, err)
	require.Equal(t, c2, sig1.baseCommit, "base-only movement must resolve to the new base hash")
	require.NotEqual(t, sig0.token, sig1.token,
		"base-only movement must produce a distinct signature — token must be sensitive to base-side changes")

	// Two distinct cache entries: {C0,C1} and {C2,C1}. Head is the same.
	_, okOld := defaultBranchSignatureCache.lookup(branchSignatureKey{baseCommit: c0, headCommit: c1})
	require.True(t, okOld)
	_, okNew := defaultBranchSignatureCache.lookup(branchSignatureKey{baseCommit: c2, headCommit: c1})
	require.True(t, okNew)
}

// TestComputeBranchSignatureHeadOnlyMovementProducesDistinctEntry covers the
// symmetric case: moving ONLY the head ref (base unchanged) produces a
// distinct cache key and signature.
func TestComputeBranchSignatureHeadOnlyMovementProducesDistinctEntry(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	origDefault := defaultBranchSignatureCache
	t.Cleanup(func() { defaultBranchSignatureCache = origDefault })
	defaultBranchSignatureCache = newBranchSignatureCache(8)

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "feature.txt"), []byte("feature1\n"), 0o644))
	runGit(t, repoDir, "add", "feature.txt")
	runGit(t, repoDir, "checkout", "-b", "feature")
	runGit(t, repoDir, "commit", "-m", "feature1")
	runGit(t, repoDir, "checkout", "main")

	c0 := strings.TrimSpace(runGitOutput(t, repoDir, "rev-parse", "main"))
	c1 := strings.TrimSpace(runGitOutput(t, repoDir, "rev-parse", "feature"))

	repo := openReviewRepoForTest(t, repoDir)
	state := reviewRepoState{
		repo:           repo,
		baseRef:        "main",
		headRef:        "feature",
		comparisonMode: "direct",
	}

	sig0, err := computeBranchSignature(state)
	require.NoError(t, err)
	require.Equal(t, c0, sig0.baseCommit)

	// Move ONLY head: add a commit to feature. main stays at C0.
	runGit(t, repoDir, "checkout", "feature")
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "feature.txt"), []byte("feature2\n"), 0o644))
	runGit(t, repoDir, "add", "feature.txt")
	runGit(t, repoDir, "commit", "-m", "feature2")
	runGit(t, repoDir, "checkout", "main")
	c3 := strings.TrimSpace(runGitOutput(t, repoDir, "rev-parse", "feature"))
	require.NotEqual(t, c1, c3)

	sig1, err := computeBranchSignature(state)
	require.NoError(t, err)
	require.Equal(t, c0, sig1.baseCommit, "base is unchanged")
	require.NotEqual(t, sig0.token, sig1.token,
		"head-only movement must produce a distinct signature")

	_, okOld := defaultBranchSignatureCache.lookup(branchSignatureKey{baseCommit: c0, headCommit: c1})
	require.True(t, okOld)
	_, okNew := defaultBranchSignatureCache.lookup(branchSignatureKey{baseCommit: c0, headCommit: c3})
	require.True(t, okNew)
}
