package workspace

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

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

// TestReviewParseImmutableHashRejectsInvalidInputs pins the strict parser's
// rejection contract: anything that is not exactly 40 hex chars (SHA-1) and
// not the zero hash fails. Symbolic refs, partial hashes, and the zero hash
// itself all fail at this layer — the immutable loader path never reaches
// CommitObject for those inputs, so a moving branch cannot silently
// re-resolve through the strict seam.
func TestReviewParseImmutableHashRejectsInvalidInputs(t *testing.T) {
	cases := []struct {
		name  string
		input string
	}{
		{"empty string", ""},
		{"symbolic ref HEAD", "HEAD"},
		{"symbolic ref main", "main"},
		{"symbolic ref with slashes", "refs/heads/main"},
		{"short hash 7 chars", "abc1234"},
		{"short hash 8 chars", "abc12345"},
		{"too short by one", strings.Repeat("a", 39)},
		{"too long by one", strings.Repeat("a", 41)},
		{"non-hex letter", "g" + strings.Repeat("0", 39)},
		{"non-hex symbol", "!" + strings.Repeat("0", 39)},
		{"interior space", "aaaaaaa a" + strings.Repeat("0", 31)},
		{"leading space", " " + strings.Repeat("0", 39)},
		{"trailing newline", strings.Repeat("a", 39) + "\n"},
		{"zero hash (40 zeros)", strings.Repeat("0", 40)},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			_, err := reviewParseImmutableHash(tc.input)
			require.Errorf(t, err, "expected error for input %q", tc.input)
		})
	}
}

// TestReviewParseImmutableHashAcceptsValidSHA1 confirms the happy path: a
// 40-hex SHA-1 with at least one non-zero digit parses successfully. Both
// lowercase and uppercase hex are accepted because plumbing.NewHash accepts
// both — the parser's role is to enforce length + hex + non-zero, not to
// canonicalize case.
func TestReviewParseImmutableHashAcceptsValidSHA1(t *testing.T) {
	t.Run("lowercase", func(t *testing.T) {
		input := strings.Repeat("a", 40)
		h, err := reviewParseImmutableHash(input)
		require.NoError(t, err)
		require.False(t, h.IsZero(), "parsed hash must not be zero")
		require.Equal(t, input, h.String())
	})
	t.Run("uppercase", func(t *testing.T) {
		input := strings.Repeat("A", 40)
		h, err := reviewParseImmutableHash(input)
		require.NoError(t, err)
		require.False(t, h.IsZero())
	})
	t.Run("digits and letters", func(t *testing.T) {
		input := "0123456789abcdef0123456789ABCDEF01234567"
		h, err := reviewParseImmutableHash(input)
		require.NoError(t, err)
		require.False(t, h.IsZero())
	})
}

// TestReviewParseImmutableHashErrorMessageDoesNotEchoInput pins the
// error-message discipline: the strict seam is reached only after the
// caller's own resolution, so the offending value is at best a downstream
// hash but at worst an arbitrary string. Echoing it would risk leaking an
// uncontrolled ref/path fragment. The error must mention only the failure
// mode (length / hex / zero) and, for length, the observed length value —
// never the raw input.
func TestReviewParseImmutableHashErrorMessageDoesNotEchoInput(t *testing.T) {
	t.Run("length error excludes raw input", func(t *testing.T) {
		input := "possibly-sensitive-ref-fragment"
		_, err := reviewParseImmutableHash(input)
		require.Error(t, err)
		require.NotContainsf(t, err.Error(), input,
			"length error must not echo the raw input: %q", err.Error())
	})
	t.Run("hex error excludes raw input", func(t *testing.T) {
		input := "z" + strings.Repeat("0", 39)
		_, err := reviewParseImmutableHash(input)
		require.Error(t, err)
		require.NotContainsf(t, err.Error(), input,
			"hex error must not echo the raw input: %q", err.Error())
	})
}

// openReviewRepoForTest opens a git repo with the same options as the
// production review code path (DetectDotGit + EnableDotGitCommonDir) so the
// returned *git.Repository behaves identically to what reviewRepoState.repo
// holds in production.
func openReviewRepoForTest(t *testing.T, repoDir string) *git.Repository {
	t.Helper()
	r, err := git.PlainOpenWithOptions(repoDir, &git.PlainOpenOptions{
		DetectDotGit:          true,
		EnableDotGitCommonDir: true,
	})
	require.NoError(t, err)
	return r
}

var _ = openReviewRepoForTest // keep helper even if only some tests use it

// TestReviewTreeForImmutableCommitHashRejectsNonCommitObject exercises the
// object-existence layer that the parser deliberately does not cover. A
// 40-hex string that PARSES successfully but does not name a commit object
// must produce a distinct error from a parse failure. The parser only
// validates format; CommitObject is the authoritative type check.
func TestReviewTreeForImmutableCommitHashRejectsNonCommitObject(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

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

	// Resolve the HEAD commit's tree hash. It is a valid 40-hex SHA-1 but
	// names a tree object, not a commit object. The strict loader must
	// reject it at the CommitObject step rather than silently returning
	// the tree.
	headTreeHash := strings.TrimSpace(runGitOutput(t, repoDir, "rev-parse", "HEAD^{tree}"))
	require.Len(t, headTreeHash, 40, "tree hash must be 40 hex chars")

	_, err := reviewTreeForImmutableCommitHash(repo, headTreeHash)
	require.Errorf(t, err, "tree hash must not be accepted as a commit")
}

// TestResolveReviewBranchCommitPairProducesImmutableHashes proves the single
// resolve seam turns symbolic refs (HEAD, branch names) into immutable
// commit hashes that downstream consumers (cache key, DiffTree, aggregate
// token) can use without re-resolution. The output pair must be 40 hex chars
// and not equal to the input ref names.
func TestResolveReviewBranchCommitPairProducesImmutableHashes(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

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

	// Add a second commit on main so base != head has meaning.
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("second\n"), 0o644))
	runGit(t, repoDir, "add", "tracked.txt")
	runGit(t, repoDir, "commit", "-m", "second")

	// Make a feature branch off the initial commit so base/head point at
	// different commits.
	runGit(t, repoDir, "branch", "feature", "HEAD~1")

	repo := openReviewRepoForTest(t, repoDir)
	mainHead := strings.TrimSpace(runGitOutput(t, repoDir, "rev-parse", "main"))
	featureHead := strings.TrimSpace(runGitOutput(t, repoDir, "rev-parse", "feature"))

	state := reviewRepoState{
		repo:           repo,
		baseRef:        "main",
		headRef:        "feature",
		comparisonMode: "direct",
	}

	baseCommit, headCommit, err := resolveReviewBranchCommitPair(state)
	require.NoError(t, err)

	require.Equal(t, mainHead, baseCommit, "base should resolve to main's commit hash")
	require.Equal(t, featureHead, headCommit, "head should resolve to feature's commit hash")

	for _, hash := range []string{baseCommit, headCommit} {
		require.Len(t, hash, 40, "resolved commit hash must be 40 hex chars: %q", hash)
		for _, r := range hash {
			require.Truef(t, (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f'),
				"resolved commit hash must be lowercase hex: %q", hash)
		}
	}

	require.NotEqual(t, baseCommit, headCommit,
		"base and head should resolve to different commits in this fixture")

	// Resolving again must produce the same pair (idempotent on a static repo).
	base2, head2, err := resolveReviewBranchCommitPair(state)
	require.NoError(t, err)
	require.Equal(t, baseCommit, base2)
	require.Equal(t, headCommit, head2)
}
