package workspace

import (
	"crypto/sha256"
	"encoding/hex"
	"hash"
	"os"
	"os/exec"
	"path/filepath"
	"testing"

	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/filemode"
	"github.com/go-git/go-git/v5/plumbing/object"
	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/desktop/internal/types"
)

// hexSHA256 returns the hex SHA-256 digest of the encoder output. The helper
// exists so tests can compute the expected digest of branchFileTokenInputs /
// workingFileTokenInputs without going through the public token helpers
// (which truncate the working digest to hint length).
func hexSHA256(t *testing.T, encode func(h hash.Hash)) string {
	t.Helper()
	digest := sha256.New()
	encode(digest)
	return hex.EncodeToString(digest.Sum(nil))
}

// TestBranchFileTokenInputsDigestStabilityAndCoverage is the unit test for the
// opaque branch per-file token: it exercises branchFileTokenInputs.writeTo
// directly so the test does not depend on git, then asserts:
//  1. Determinism: equal inputs → equal digests.
//  2. Coverage: every documented input field participates — mutating any
//     single field produces a different digest.
//  3. Hex charset: the digest is hex-encoded SHA-256.
//
// This replaces the previous format-string assertions (Contains("|"), etc):
// the public token is now an opaque hash, so only the digest's response to
// input mutations is observable.
func TestBranchFileTokenInputsDigestStabilityAndCoverage(t *testing.T) {
	base := branchFileTokenInputs{
		Path:        "main.go",
		OldPath:     "",
		ChangeType:  "M",
		OldBlobHash: "aaa",
		OldMode:     "0100644",
		NewBlobHash: "bbb",
		NewMode:     "0100644",
	}

	baseDigest := hexSHA256(t, func(h hash.Hash) { base.writeTo(h) })
	require.Len(t, baseDigest, sha256.Size*2, "digest must be full SHA-256 hex")
	_, err := hex.DecodeString(baseDigest)
	require.NoError(t, err, "digest must be valid hex")

	// Determinism: same inputs → same digest.
	require.Equal(t, baseDigest,
		hexSHA256(t, func(h hash.Hash) { base.writeTo(h) }),
		"digest must be deterministic for equal inputs")

	// Coverage: each documented field must participate. Mutate one field at a
	// time and assert the digest changed.
	mutations := []struct {
		name  string
		apply func(branchFileTokenInputs) branchFileTokenInputs
	}{
		{"Path", func(i branchFileTokenInputs) branchFileTokenInputs { i.Path = "other.go"; return i }},
		{"OldPath", func(i branchFileTokenInputs) branchFileTokenInputs { i.OldPath = "renamed-from.go"; return i }},
		{"ChangeType", func(i branchFileTokenInputs) branchFileTokenInputs { i.ChangeType = "R"; return i }},
		{"OldBlobHash", func(i branchFileTokenInputs) branchFileTokenInputs { i.OldBlobHash = "AAA"; return i }},
		{"OldMode", func(i branchFileTokenInputs) branchFileTokenInputs { i.OldMode = "0100755"; return i }},
		{"NewBlobHash", func(i branchFileTokenInputs) branchFileTokenInputs { i.NewBlobHash = "BBB"; return i }},
		{"NewMode", func(i branchFileTokenInputs) branchFileTokenInputs { i.NewMode = "0100755"; return i }},
	}
	for _, m := range mutations {
		mutated := m.apply(base)
		got := hexSHA256(t, func(h hash.Hash) { mutated.writeTo(h) })
		require.NotEqual(t, baseDigest, got,
			"digest must change when %s changes (got same digest for %+v vs %+v)",
			m.name, base, mutated)
	}
}

// TestWorkingFileTokenInputsDigestStabilityAndCoverage is the unit test for the
// opaque working per-file token. Mirrors the branch test: exercises
// workingFileTokenInputs.writeTo directly and asserts determinism + coverage
// of every input field including the missing-side sentinels.
func TestWorkingFileTokenInputsDigestStabilityAndCoverage(t *testing.T) {
	base := workingFileTokenInputs{
		Path:          "main.go",
		OldPath:       "old-main.go",
		ChangeType:    "R",
		HeadBlobHash:  "head-hash",
		HeadMode:      "0100644",
		IndexBlobHash: "index-hash",
		IndexMode:     "0100644",
		WtSize:        100,
		WtMtimeNS:     1_700_000_000_000_000_000,
		WtMode:        "0644",
		WtInode:       12345,
	}

	baseDigest := hexSHA256(t, func(h hash.Hash) { base.writeTo(h) })
	require.Len(t, baseDigest, sha256.Size*2, "digest must be full SHA-256 hex")
	_, err := hex.DecodeString(baseDigest)
	require.NoError(t, err, "digest must be valid hex")

	require.Equal(t, baseDigest,
		hexSHA256(t, func(h hash.Hash) { base.writeTo(h) }),
		"digest must be deterministic for equal inputs")

	mutations := []struct {
		name  string
		apply func(workingFileTokenInputs) workingFileTokenInputs
	}{
		{"Path", func(i workingFileTokenInputs) workingFileTokenInputs { i.Path = "other.go"; return i }},
		{"OldPath", func(i workingFileTokenInputs) workingFileTokenInputs { i.OldPath = "older.go"; return i }},
		{"ChangeType", func(i workingFileTokenInputs) workingFileTokenInputs { i.ChangeType = "M"; return i }},
		{"HeadBlobHash", func(i workingFileTokenInputs) workingFileTokenInputs { i.HeadBlobHash = "HEAD2"; return i }},
		{"HeadMode", func(i workingFileTokenInputs) workingFileTokenInputs { i.HeadMode = "0100755"; return i }},
		{"HeadMissing", func(i workingFileTokenInputs) workingFileTokenInputs {
			i.HeadMissing = true
			i.HeadBlobHash = ""
			i.HeadMode = ""
			return i
		}},
		{"IndexBlobHash", func(i workingFileTokenInputs) workingFileTokenInputs { i.IndexBlobHash = "INDEX2"; return i }},
		{"IndexMode", func(i workingFileTokenInputs) workingFileTokenInputs { i.IndexMode = "0100755"; return i }},
		{"IndexMissing", func(i workingFileTokenInputs) workingFileTokenInputs {
			i.IndexMissing = true
			i.IndexBlobHash = ""
			i.IndexMode = ""
			return i
		}},
		{"WtSize", func(i workingFileTokenInputs) workingFileTokenInputs { i.WtSize = 200; return i }},
		{"WtMtimeNS", func(i workingFileTokenInputs) workingFileTokenInputs { i.WtMtimeNS = i.WtMtimeNS + 1; return i }},
		{"WtMode", func(i workingFileTokenInputs) workingFileTokenInputs { i.WtMode = "0755"; return i }},
		{"WtInode", func(i workingFileTokenInputs) workingFileTokenInputs { i.WtInode = 99999; return i }},
		{"WtMissing", func(i workingFileTokenInputs) workingFileTokenInputs {
			i.WtMissing = true
			i.WtSize = 0
			i.WtMtimeNS = 0
			i.WtMode = ""
			i.WtInode = 0
			return i
		}},
	}
	for _, m := range mutations {
		mutated := m.apply(base)
		got := hexSHA256(t, func(h hash.Hash) { mutated.writeTo(h) })
		require.NotEqual(t, baseDigest, got,
			"digest must change when %s changes (got same digest for %+v vs %+v)",
			m.name, base, mutated)
	}
}

// TestReviewBranchFileTokenIsOpaqueSHA256 confirms the public helper produces
// a full 64-hex SHA-256 digest and is deterministic. The actual byte content
// of the digest is verified in the inputs-level test above.
func TestReviewBranchFileTokenIsOpaqueSHA256(t *testing.T) {
	file := types.ReviewFile{Path: "a.go", ChangeType: "M"}
	from := object.TreeEntry{Hash: mustPlumbingHash("from"), Mode: filemode.Regular}
	to := object.TreeEntry{Hash: mustPlumbingHash("to"), Mode: filemode.Regular}

	tok1 := reviewBranchFileToken(file, from, to)
	require.Len(t, tok1, sha256.Size*2, "branch per-file token must be full SHA-256 hex")
	_, err := hex.DecodeString(tok1)
	require.NoError(t, err, "branch per-file token must be valid hex")
	require.Equal(t, tok1, reviewBranchFileToken(file, from, to),
		"branch per-file token must be deterministic for equal inputs")

	// Inputs that should not appear in the opaque digest (no leakage of the
	// raw format). The token is a hex digest, so it must contain only [0-9a-f].
	for _, c := range tok1 {
		require.True(t,
			(c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'),
			"branch per-file token must be hex-only, found %q in %q", c, tok1)
	}
}

// TestReviewWorkingFileHintTokenIsOpaqueHex16 confirms the public helper
// produces a 16-hex truncated digest (hint strength). Coverage is verified
// in the inputs-level test above; this asserts only the public surface.
func TestReviewWorkingFileHintTokenIsOpaqueHex16(t *testing.T) {
	dir := t.TempDir()
	path := "main.go"
	require.NoError(t, os.WriteFile(filepath.Join(dir, path), []byte("body"), 0o644))
	head := &object.TreeEntry{Hash: mustPlumbingHash("head"), Mode: filemode.Regular}
	index := &object.TreeEntry{Hash: mustPlumbingHash("index"), Mode: filemode.Regular}

	file := types.ReviewFile{Path: path, ChangeType: "M"}
	tok1 := reviewWorkingFileHintToken(dir, file, head, index)
	require.Len(t, tok1, reviewFingerprintTokenHexLen,
		"working per-file token must be %d hex chars (hint strength)", reviewFingerprintTokenHexLen)
	_, err := hex.DecodeString(tok1)
	require.NoError(t, err, "working per-file token must be valid hex")
	require.Equal(t, tok1, reviewWorkingFileHintToken(dir, file, head, index),
		"working per-file token must be deterministic for equal inputs")
}

// TestReviewBranchTokenAuthoritativeCoversFromSide proves the From-side
// coverage: the branch token changes when the BASE/OLD side of a diff
// changes — even when the HEAD/NEW side is byte-identical. reviewer3
// explicitly asked for this regression to be precise: not "rename vs modify"
// but "same To, different From".
//
// Construction: two review contexts share the same head commit (so ch.To is
// identical) but have different base commits (so ch.From differs). Per-file
// opaque token must capture both sides, and the branch token derived from the
// per-file tokens must differ between the two contexts.
func TestReviewBranchTokenAuthoritativeCoversFromSide(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

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

	runGit(t, repoDir, "checkout", "-b", "base1")
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("base1 content\n"), 0o644))
	runGit(t, repoDir, "add", "tracked.txt")
	runGit(t, repoDir, "commit", "-m", "base1 content")

	runGit(t, repoDir, "checkout", "main")
	runGit(t, repoDir, "checkout", "-b", "base2")
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("base2 content\n"), 0o644))
	runGit(t, repoDir, "add", "tracked.txt")
	runGit(t, repoDir, "commit", "-m", "base2 content")

	runGit(t, repoDir, "checkout", "main")
	runGit(t, repoDir, "checkout", "-b", "head-branch")
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("head content\n"), 0o644))
	runGit(t, repoDir, "add", "tracked.txt")
	runGit(t, repoDir, "commit", "-m", "head content")

	files1, err := ReviewFiles(repoDir, "branch", "base1", "head-branch", "direct", "")
	require.NoError(t, err)
	require.Len(t, files1, 1)

	files2, err := ReviewFiles(repoDir, "branch", "base2", "head-branch", "direct", "")
	require.NoError(t, err)
	require.Len(t, files2, 1)

	// Per-file opaque tokens must differ — the From side participates in the
	// digest alongside To.
	require.NotEqual(t, files1[0].Token, files2[0].Token,
		"per-file token must differ when From side differs (even with identical To)")
	// And both must remain valid full SHA-256 hex digests.
	for i, f := range [][]types.ReviewFile{files1, files2} {
		require.Len(t, f[0].Token, sha256.Size*2,
			"per-file branch token %d must be full SHA-256 hex", i+1)
		_, err := hex.DecodeString(f[0].Token)
		require.NoError(t, err, "per-file branch token %d must be valid hex", i+1)
	}

	// Branch tokens must also differ (the From-side difference propagates).
	tok1 := reviewBranchToken("base1-hash", "head-hash", files1)
	tok2 := reviewBranchToken("base2-hash", "head-hash", files2)
	require.NotEqual(t, tok1, tok2,
		"branch token must differ when base/old side differs (even with identical head/new side)")

	require.Len(t, tok1, sha256.Size*2,
		"authoritative branch token must be full SHA-256 (64 hex chars), got %d", len(tok1))
	_, err = hex.DecodeString(tok1)
	require.NoError(t, err, "authoritative branch token must be valid hex")
}

// TestReviewBranchTokenAuthoritativeStableForSameCommitPair confirms the
// other half of the authoritative contract: equal commits + equal file lists
// → equal tokens.
func TestReviewBranchTokenAuthoritativeStableForSameCommitPair(t *testing.T) {
	files := []types.ReviewFile{
		{Path: "a.txt", ChangeType: "M", Token: "opaque-hash-a"},
		{Path: "b.txt", ChangeType: "A", Token: "opaque-hash-b"},
	}
	tok1 := reviewBranchToken("base-hash", "head-hash", files)
	tok2 := reviewBranchToken("base-hash", "head-hash", files)
	require.Equal(t, tok1, tok2, "authoritative branch token must be stable for equal inputs")

	tok3 := reviewBranchToken("base-hash", "DIFFERENT-head-hash", files)
	require.NotEqual(t, tok1, tok3, "branch token must change when head commit changes")
}

// TestReviewWorkingFileTokenCoversHEADIndexWorktree proves the three-side
// coverage end-to-end via ReviewFiles: a change in worktree bytes OR index
// state OR HEAD must each produce a different opaque per-file token.
func TestReviewWorkingFileTokenCoversHEADIndexWorktree(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

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

	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("working-v1\n"), 0o644))

	files1, err := ReviewFiles(repoDir, "working", "", "", "", "")
	require.NoError(t, err)
	require.Len(t, files1, 1)
	tok1 := files1[0].Token
	require.NotEmpty(t, tok1)
	require.Len(t, tok1, reviewFingerprintTokenHexLen, "working per-file token must be hint-strength hex16")

	// (a) Worktree change only: edit the file. HEAD/index unchanged.
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("working-v2-different\n"), 0o644))
	files2, err := ReviewFiles(repoDir, "working", "", "", "", "")
	require.NoError(t, err)
	require.Len(t, files2, 1)
	require.NotEqual(t, tok1, files2[0].Token,
		"token must change when worktree content changes (HEAD+index unchanged)")
	tok2 := files2[0].Token

	// (b) Index change only: stage the file. HEAD unchanged, worktree bytes
	// unchanged (same as step a); only the index moves.
	runGit(t, repoDir, "add", "tracked.txt")
	files3, err := ReviewFiles(repoDir, "working", "", "", "", "")
	require.NoError(t, err)
	require.Len(t, files3, 1)
	require.NotEqual(t, tok2, files3[0].Token,
		"token must change when index changes (worktree bytes + HEAD unchanged)")

	// (c) HEAD-only mutation is exercised in
	// TestReviewWorkingFileTokenHEADOnlyChange below.

	// Stability: re-read without any change → token stable.
	files4, err := ReviewFiles(repoDir, "working", "", "", "", "")
	require.NoError(t, err)
	require.Len(t, files4, 1)
	require.Equal(t, files3[0].Token, files4[0].Token,
		"token must be stable when HEAD/index/worktree are all unchanged")
}

// TestReviewWorkingFileTokenHEADOnlyChange proves the HEAD coverage half of
// the three-side contract: when HEAD moves but index and worktree bytes stay
// identical, the per-file working token must still change.
//
// Construction: build two commits c1 ("base\n") and c2 ("v2\n") for the same
// path; stage "v3\n" and capture token1 (head=c2's "v2\n" blob, index="v3\n"
// blob, wt=stat of "v3\n" file). Then `git reset --soft HEAD~1` moves HEAD to
// c1 ("base\n") without touching index or worktree, and token2 must differ.
//
// Because the token is opaque, the assertion is "token changed" — we cannot
// split segments anymore. The "isolate which input moved" property is
// covered by TestWorkingFileTokenInputsDigestStabilityAndCoverage.
func TestReviewWorkingFileTokenHEADOnlyChange(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir) // HEAD = c1 with "base\n" for tracked.txt

	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("v2\n"), 0o644))
	runGit(t, repoDir, "add", "tracked.txt")
	runGit(t, repoDir, "commit", "-m", "c2 with v2")

	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("v3\n"), 0o644))
	runGit(t, repoDir, "add", "tracked.txt")

	files1, err := ReviewFiles(repoDir, "working", "", "", "", "")
	require.NoError(t, err)
	require.Len(t, files1, 1, "staged change must surface in working files")
	tok1 := files1[0].Token

	runGit(t, repoDir, "reset", "--soft", "HEAD~1")

	files2, err := ReviewFiles(repoDir, "working", "", "", "", "")
	require.NoError(t, err)
	require.Len(t, files2, 1, "file must still surface after soft reset (still staged vs new HEAD)")
	tok2 := files2[0].Token

	// The two opaque tokens must differ — HEAD moved from c2's blob to c1's
	// blob. index and worktree bytes are byte-identical.
	require.NotEqual(t, tok1, tok2,
		"per-file working token MUST change when HEAD moves (index + worktree identical)")
}

// TestReviewWorkingTokenHintStrengthIsTruncated confirms the aggregate working
// token (derived from per-file hint tokens via reviewWorkingToken) is 16-hex
// truncated — hint strength, not authoritative.
func TestReviewWorkingTokenHintStrengthIsTruncated(t *testing.T) {
	files := []types.ReviewFile{
		{Path: "a.txt", Token: "abcdef0123456789"},
	}
	tok := reviewWorkingToken(files)
	require.Len(t, tok, reviewFingerprintTokenHexLen,
		"aggregate working token must be %d hex chars (hint strength), got %d",
		reviewFingerprintTokenHexLen, len(tok))
	_, err := hex.DecodeString(tok)
	require.NoError(t, err, "working token must be valid hex")
}

// TestReviewRepositoryTokenUsesResolvedRootAndFullSHA256 confirms the
// repository identity token is full SHA-256 and keys off the canonical root,
// comparison mode, and resolved base commit. Mutating any of the three
// inputs must produce a different digest.
func TestReviewRepositoryTokenUsesResolvedRootAndFullSHA256(t *testing.T) {
	tok := reviewRepositoryToken("/private/var/folders/real/repo", "common_base", "base-commit-hash")
	require.Len(t, tok, sha256.Size*2,
		"repository token must be full SHA-256 (64 hex chars), got %d", len(tok))
	_, err := hex.DecodeString(tok)
	require.NoError(t, err, "repository token must be valid hex")

	for _, mutation := range []struct {
		name           string
		resolvedRoot   string
		comparisonMode string
		baseCommit     string
	}{
		{"resolvedRoot", "/private/var/folders/DIFFERENT/repo", "common_base", "base-commit-hash"},
		{"comparisonMode", "/private/var/folders/real/repo", "direct", "base-commit-hash"},
		{"baseCommit", "/private/var/folders/real/repo", "common_base", "DIFFERENT-base-commit"},
	} {
		got := reviewRepositoryToken(mutation.resolvedRoot, mutation.comparisonMode, mutation.baseCommit)
		require.NotEqual(t, tok, got,
			"repository token must change when %s changes", mutation.name)
	}
}

// mustPlumbingHash hashes a string with SHA-1 (git blob hash semantics) and
// returns the resulting plumbing.Hash. The input is only feed material — its
// length does not need to match a real hash; the resulting Hash is always
// 20 bytes.
func mustPlumbingHash(seed string) plumbing.Hash {
	return plumbing.ComputeHash(plumbing.BlobObject, []byte(seed))
}

// encodedTokenBytes runs the encoder against a fresh SHA-256 and returns the
// raw bytes that were written (before Sum). This lets a test assert on the
// actual byte stream so a regression that produces identical bytes for
// different inputs is caught at the source, not just at the digest.
func encodedTokenBytes(t *testing.T, encode func(h hash.Hash)) []byte {
	t.Helper()
	digest := sha256.New()
	encode(digest)
	return digest.Sum(nil)
}

// TestBranchFileTokenInputsLengthPrefixIsAmbiguityFree is the canonical
// regression for the A/B collision reviewer3 raised against the previous
// fmt.Fprintln encoder:
//
//	A: Path="x\noldPath y",  OldPath="z"
//	B: Path="x",             OldPath="y\noldPath z"
//
// Under fmt.Fprintln both A and B produced the same byte stream
// ("path x\noldPath y\noldPath z\n...") because the newline inside Path
// mimicked the start of the next Fprintln line. Under length-prefix encoding
// the two inputs MUST produce different byte streams and different digests.
//
// The test also covers the parallel cases for NUL, spaces, and the empty
// string, plus the cross-field scenario where a value of one field mimics
// the prefix of the next field's content.
func TestBranchFileTokenInputsLengthPrefixIsAmbiguityFree(t *testing.T) {
	cases := []struct {
		name string
		a, b branchFileTokenInputs
	}{
		{
			name: "newline in Path vs split Path+OldPath (reviewer3 A/B)",
			a:    branchFileTokenInputs{Path: "x\noldPath y", OldPath: "z"},
			b:    branchFileTokenInputs{Path: "x", OldPath: "y\noldPath z"},
		},
		{
			name: "newline in ChangeType vs split ChangeType+OldBlobHash",
			a:    branchFileTokenInputs{ChangeType: "M\noldBlob junk", OldBlobHash: "x"},
			b:    branchFileTokenInputs{ChangeType: "M", OldBlobHash: "junk\noldBlob x"},
		},
		{
			name: "NUL byte in Path vs split Path+OldPath",
			a:    branchFileTokenInputs{Path: "x\x00oldPath y", OldPath: "z"},
			b:    branchFileTokenInputs{Path: "x", OldPath: "y\x00oldPath z"},
		},
		{
			name: "spaces in OldBlobHash vs split OldBlobHash+OldMode",
			a:    branchFileTokenInputs{OldBlobHash: "hash with spaces", OldMode: "0100644"},
			b:    branchFileTokenInputs{OldBlobHash: "hash", OldMode: "with spaces 0100644"},
		},
		{
			name: "empty string in Path vs missing Path",
			a:    branchFileTokenInputs{Path: "", OldPath: "y"},
			b:    branchFileTokenInputs{Path: "y", OldPath: ""},
		},
		{
			name: "empty string in OldBlobHash vs missing OldBlobHash",
			a:    branchFileTokenInputs{OldBlobHash: "", OldMode: "0100644"},
			b:    branchFileTokenInputs{OldBlobHash: "0100644", OldMode: ""},
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			aBytes := encodedTokenBytes(t, func(h hash.Hash) { tc.a.writeTo(h) })
			bBytes := encodedTokenBytes(t, func(h hash.Hash) { tc.b.writeTo(h) })
			require.NotEqual(t, aBytes, bBytes,
				"A=%+v and B=%+v must produce different byte streams under length-prefix encoding", tc.a, tc.b)
			aDigest := hexSHA256(t, func(h hash.Hash) { tc.a.writeTo(h) })
			bDigest := hexSHA256(t, func(h hash.Hash) { tc.b.writeTo(h) })
			require.NotEqual(t, aDigest, bDigest,
				"A=%+v and B=%+v must produce different SHA-256 digests under length-prefix encoding", tc.a, tc.b)
		})
	}
}

// TestWorkingFileTokenInputsLengthPrefixIsAmbiguityFree mirrors the branch
// test for the working side: any value containing newlines / spaces / NULs
// in HeadBlobHash, IndexBlobHash, WtMode, or Path must not be reinterpreted
// as a different field split.
func TestWorkingFileTokenInputsLengthPrefixIsAmbiguityFree(t *testing.T) {
	cases := []struct {
		name string
		a, b workingFileTokenInputs
	}{
		{
			name: "newline in Path vs split Path+HeadBlobHash",
			a:    workingFileTokenInputs{Path: "x\nhead y", HeadBlobHash: "z"},
			b:    workingFileTokenInputs{Path: "x", HeadBlobHash: "y\nhead z"},
		},
		{
			name: "newline in HeadBlobHash vs split HeadBlobHash+HeadMode",
			a:    workingFileTokenInputs{HeadBlobHash: "h\nheadMode 0100644", HeadMode: "x"},
			b:    workingFileTokenInputs{HeadBlobHash: "h", HeadMode: "0100644\nheadMode x"},
		},
		{
			name: "NUL in IndexBlobHash vs split IndexBlobHash+IndexMode",
			a:    workingFileTokenInputs{IndexBlobHash: "i\x00mode 0100644", IndexMode: "x"},
			b:    workingFileTokenInputs{IndexBlobHash: "i", IndexMode: "0100644\x00mode x"},
		},
		{
			name: "HeadMissing=true with non-empty HeadBlobHash vs HeadMissing=false",
			a:    workingFileTokenInputs{HeadMissing: true, HeadBlobHash: "leftover"},
			b:    workingFileTokenInputs{HeadMissing: false, HeadBlobHash: "leftover", HeadMode: "0100644"},
		},
		{
			name: "WtMissing=true with non-zero WtSize vs WtMissing=false",
			a:    workingFileTokenInputs{WtMissing: true, WtSize: 9999},
			b:    workingFileTokenInputs{WtMissing: false, WtSize: 9999, WtMtimeNS: 1, WtMode: "0644", WtInode: 1},
		},
		{
			name: "newline in WtMode vs split WtMode+WtInode bytes",
			a:    workingFileTokenInputs{WtMode: "0644\nwtInode junk", WtInode: 5},
			b:    workingFileTokenInputs{WtMode: "0644", WtInode: 0x35353436090a7774},
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			aBytes := encodedTokenBytes(t, func(h hash.Hash) { tc.a.writeTo(h) })
			bBytes := encodedTokenBytes(t, func(h hash.Hash) { tc.b.writeTo(h) })
			require.NotEqual(t, aBytes, bBytes,
				"A=%+v and B=%+v must produce different byte streams under length-prefix encoding", tc.a, tc.b)
			aDigest := hexSHA256(t, func(h hash.Hash) { tc.a.writeTo(h) })
			bDigest := hexSHA256(t, func(h hash.Hash) { tc.b.writeTo(h) })
			require.NotEqual(t, aDigest, bDigest,
				"A=%+v and B=%+v must produce different SHA-256 digests under length-prefix encoding", tc.a, tc.b)
		})
	}
}

// TestReviewBranchTokenAggregateLengthPrefixIsAmbiguityFree proves the
// branch aggregate token (baseCommit + headCommit + per-file fields) cannot
// be ambiguated by adversarial inputs at any level: a value containing the
// length-prefix bytes of one field cannot be smuggled into another field's
// content to produce the same overall byte stream.
//
// Each case constructs two distinct inputs that would collide under the
// previous fmt.Fprintln encoder; the length-prefix encoder must keep them
// distinct at both byte-stream and digest levels.
func TestReviewBranchTokenAggregateLengthPrefixIsAmbiguityFree(t *testing.T) {
	cases := []struct {
		name         string
		baseA, headA string
		filesA       []types.ReviewFile
		baseB, headB string
		filesB       []types.ReviewFile
	}{
		{
			name:   "newline smuggled into baseCommit vs split baseCommit+headCommit",
			baseA:  "base\nhead junk",
			headA:  "x",
			filesA: []types.ReviewFile{{Path: "a.go", ChangeType: "M", Token: "t"}},
			baseB:  "base",
			headB:  "junk\nhead x",
			filesB: []types.ReviewFile{{Path: "a.go", ChangeType: "M", Token: "t"}},
		},
		{
			name:   "newline in file Path vs split Path+OldPath",
			baseA:  "base",
			headA:  "head",
			filesA: []types.ReviewFile{{Path: "x\noldPath y", OldPath: "z", ChangeType: "M", Token: "t"}},
			baseB:  "base",
			headB:  "head",
			filesB: []types.ReviewFile{{Path: "x", OldPath: "y\noldPath z", ChangeType: "M", Token: "t"}},
		},
		{
			name:   "newline in per-file Token vs split Token+next-file-Path",
			baseA:  "base",
			headA:  "head",
			filesA: []types.ReviewFile{{Path: "a", ChangeType: "M", Token: "t1\nb"}, {Path: "z", ChangeType: "M", Token: "t2"}},
			baseB:  "base",
			headB:  "head",
			filesB: []types.ReviewFile{{Path: "a", ChangeType: "M", Token: "t1"}, {Path: "b\nz", ChangeType: "M", Token: "t2"}},
		},
		{
			name:   "fileCount boundary: one file with newline in Token vs two files",
			baseA:  "base",
			headA:  "head",
			filesA: []types.ReviewFile{{Path: "a", ChangeType: "M", Token: "t1\nbx\nM\nt2"}},
			baseB:  "base",
			headB:  "head",
			filesB: []types.ReviewFile{{Path: "a", ChangeType: "M", Token: "t1"}, {Path: "bx", ChangeType: "M", Token: "t2"}},
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			tokA := reviewBranchToken(tc.baseA, tc.headA, tc.filesA)
			tokB := reviewBranchToken(tc.baseB, tc.headB, tc.filesB)
			require.NotEqual(t, tokA, tokB,
				"branch aggregate token must differ for A=(base=%q head=%q files=%+v) vs B=(base=%q head=%q files=%+v)",
				tc.baseA, tc.headA, tc.filesA, tc.baseB, tc.headB, tc.filesB)
			require.Len(t, tokA, sha256.Size*2, "branch aggregate token must be full SHA-256 hex")
			require.Len(t, tokB, sha256.Size*2, "branch aggregate token must be full SHA-256 hex")
		})
	}
}

// TestReviewRepositoryTokenLengthPrefixIsAmbiguityFree proves the repository
// token's three string inputs (resolvedRoot, comparisonMode, baseCommit)
// cannot be ambiguated by values containing newlines, NULs, or spaces.
func TestReviewRepositoryTokenLengthPrefixIsAmbiguityFree(t *testing.T) {
	type repoInputs struct {
		resolvedRoot string
		mode         string
		base         string
	}
	cases := []struct {
		name string
		a, b repoInputs
	}{
		{
			name: "newline in resolvedRoot vs split root+mode",
			a:    repoInputs{resolvedRoot: "/var/x\nmode junk", mode: "x", base: "b"},
			b:    repoInputs{resolvedRoot: "/var/x", mode: "junk\nmode x", base: "b"},
		},
		{
			name: "newline in comparisonMode vs split mode+baseCommit",
			a:    repoInputs{resolvedRoot: "/var/x", mode: "direct\nbase junk", base: "y"},
			b:    repoInputs{resolvedRoot: "/var/x", mode: "direct", base: "junk\nbase y"},
		},
		{
			name: "NUL in resolvedRoot vs split root+mode",
			a:    repoInputs{resolvedRoot: "/var/x\x00mode junk", mode: "x", base: "b"},
			b:    repoInputs{resolvedRoot: "/var/x", mode: "junk\x00mode x", base: "b"},
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			tokA := reviewRepositoryToken(tc.a.resolvedRoot, tc.a.mode, tc.a.base)
			tokB := reviewRepositoryToken(tc.b.resolvedRoot, tc.b.mode, tc.b.base)
			require.NotEqual(t, tokA, tokB,
				"repository token must differ for A=(%q,%q,%q) vs B=(%q,%q,%q)",
				tc.a.resolvedRoot, tc.a.mode, tc.a.base, tc.b.resolvedRoot, tc.b.mode, tc.b.base)
		})
	}
}

// TestReviewBranchTokenDomainTagDistinguishesTokenTypes proves that even if
// two different token types happened to feed identical field bytes, the
// domain tag at the start of each encoder forces different digests. This is
// the schema-isolation guarantee codex6 asked for: a future field reordering
// cannot silently alias two token types.
func TestReviewBranchTokenDomainTagDistinguishesTokenTypes(t *testing.T) {
	// Construct inputs that produce identical post-tag bytes for branch-file
	// vs branch-aggregate encoders (everything empty / equal). The domain
	// tag is the only differentiator; without it the byte streams would be
	// identical.
	emptyFile := branchFileTokenInputs{}
	fileBytes := encodedTokenBytes(t, func(h hash.Hash) { emptyFile.writeTo(h) })

	// branch-aggregate with empty base/head and zero files writes only:
	// domain + baseCommit(empty) + headCommit(empty) + fileCount(0). The
	// field layout is different from branchFileTokenInputs (different
	// arity), so even the bodies differ — but the domain tags differ too.
	emptyAggBytes := encodedTokenBytes(t, func(h hash.Hash) {
		// Inline the equivalent of reviewBranchToken's writes without the
		// hex truncation, so we can compare raw bytes.
		writeTokenString(h, branchAggregateTokenDomainV1)
		writeTokenString(h, "")
		writeTokenString(h, "")
		writeTokenUint64(h, 0)
	})
	require.NotEqual(t, fileBytes, emptyAggBytes,
		"branch-file and branch-aggregate tokens must never share byte streams even for empty inputs")

	// Different domain tags themselves must produce different bytes:
	domains := []string{
		branchFileTokenDomainV1,
		workingFileTokenDomainV2,
		branchAggregateTokenDomainV1,
		workingAggregateTokenDomainV1,
		repositoryTokenDomainV1,
	}
	seen := make(map[string][]byte, len(domains))
	for _, d := range domains {
		b := encodedTokenBytes(t, func(h hash.Hash) { writeTokenString(h, d) })
		for prev, prevBytes := range seen {
			require.NotEqual(t, b, prevBytes,
				"domain tags %q and %q must produce different byte streams", d, prev)
		}
		seen[d] = b
	}
}

// TestReviewBranchTokenStableSortsByPathOldPathChangeType proves the branch
// aggregate sort is total: two inputs with the same file set but shuffled
// order produce the same token, AND two files at the same path with
// different OldPath / ChangeType stay distinguishable.
func TestReviewBranchTokenStableSortsByPathOldPathChangeType(t *testing.T) {
	files := []types.ReviewFile{
		{Path: "b.go", OldPath: "", ChangeType: "M", Token: "tb"},
		{Path: "a.go", OldPath: "", ChangeType: "M", Token: "ta"},
		{Path: "a.go", OldPath: "old.go", ChangeType: "R", Token: "tr"},
	}
	shuffled := []types.ReviewFile{
		{Path: "a.go", OldPath: "old.go", ChangeType: "R", Token: "tr"},
		{Path: "a.go", OldPath: "", ChangeType: "M", Token: "ta"},
		{Path: "b.go", OldPath: "", ChangeType: "M", Token: "tb"},
	}
	tok := reviewBranchToken("base", "head", files)
	tokShuffled := reviewBranchToken("base", "head", shuffled)
	require.Equal(t, tok, tokShuffled,
		"branch aggregate token must be stable regardless of input order")

	// If two files share Path but differ on ChangeType, swapping their order
	// is a NOOP for the aggregate (the sort canonicalizes), but changing the
	// ChangeType must produce a different token.
	different := []types.ReviewFile{
		{Path: "a.go", OldPath: "old.go", ChangeType: "A", Token: "tr"},
		{Path: "a.go", OldPath: "", ChangeType: "M", Token: "ta"},
		{Path: "b.go", OldPath: "", ChangeType: "M", Token: "tb"},
	}
	tokDifferent := reviewBranchToken("base", "head", different)
	require.NotEqual(t, tok, tokDifferent,
		"branch aggregate token must change when a file's ChangeType changes (same Path, different changeType)")
}
