package workspace

import (
	"crypto/sha256"
	"encoding/hex"
	"sort"
	"time"

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

const (
	// reviewFingerprintDefaultTTL is the cadence the daemon recommends the
	// frontend wait between fingerprint polls. It is a hint, not a cap: the
	// frontend may poll less often (e.g. when the document is hidden) or
	// more often (e.g. on explicit user action), but should not poll
	// significantly faster than this without a reason. A review changes only
	// on push/edit (not second-by-second), so 30s is fresh enough for
	// background live-refresh while cutting poll load and ERL contention 3x.
	reviewFingerprintDefaultTTL = 30 * time.Second

	// reviewFingerprintTokenHexLen is the number of hex characters retained
	// from each SHA-256 digest for HINT-strength tokens. 16 hex chars (64 bits)
	// is enough collision resistance for change detection across the small
	// per-repo token space while keeping responses compact. Hint tokens are
	// explicitly non-authoritative — the frontend must still revalidate.
	reviewFingerprintTokenHexLen = 16

	// reviewFingerprintAuthoritativeTokenHexLen is the digest length for
	// AUTHORITATIVE-strength tokens (RepositoryToken, BranchToken, per-file
	// branch token). Full SHA-256 — equal tokens MUST guarantee byte-equal
	// diff content, so the digest cannot be truncated. The wire cost is
	// negligible because authoritative tokens only change when the underlying
	// git state changes.
	reviewFingerprintAuthoritativeTokenHexLen = sha256.Size * 2
)

// ReviewFingerprint returns a compact fingerprint of the current review
// state. The frontend polls it on a low cadence and only re-fetches the
// full ReviewSnapshot when one of the tokens changes.
//
// Branch state is served from a process-wide commit-pair LRU: repeated
// polls with the same head do not re-run DiffTree. Working state is served
// from a short-TTL repo-root LRU: bursts of polls within the window reuse
// one worktree scan, but the cache expires fast enough that in-flight edits
// surface on the next poll. The working token is still a stat-based hint,
// not authoritative — the TTL bounds staleness, it does not eliminate it.
func ReviewFingerprint(agentDir string, baseRef string, headRef string, comparisonMode string, compareRef string) (types.ReviewFingerprint, error) {
	return reviewFingerprintWithDeps(agentDir, baseRef, headRef, comparisonMode, compareRef, defaultReviewFingerprintDeps)
}

// reviewFingerprintDeps is the dependency seam used by ReviewFingerprint.
// Production wires defaultReviewFingerprintDeps; tests construct isolated
// instances with their own caches and counting loaders so they never touch
// package-level mutable state.
//
// loadBranchSignature owns the entire branch path (resolve + cache + DiffTree)
// so tests can stub the branch path without needing a real *git.Repository.
// loadWorkingState is the inner working scan only; the dispatcher wraps it
// with the cache so tests still exercise the real cache + singleflight +
// stale-while-revalidate path.
type reviewFingerprintDeps struct {
	loadRepoState       func(agentDir string, baseRef string, headRef string, comparisonMode string, compareRef string) (reviewRepoState, types.ReviewSummary, error)
	loadBranchSignature func(repo reviewRepoState) (branchSignature, error)
	loadWorkingState    func(repo reviewRepoState) (workingState, error)
	workingCache        *workingStateCache
	now                 func() time.Time
}

// defaultReviewFingerprintDeps binds the production loaders and process-wide
// caches. Tests that need isolation must construct their own deps struct.
var defaultReviewFingerprintDeps = reviewFingerprintDeps{
	loadRepoState:       loadReviewRepoState,
	loadBranchSignature: computeBranchSignature,
	loadWorkingState:    loadWorkingStateDefault,
	workingCache:        defaultWorkingStateCache,
	now:                 func() time.Time { return time.Now().UTC() },
}

// loadWorkingStateDefault is the production loader for working state: it
// walks the worktree via reviewWorkingFiles.
func loadWorkingStateDefault(repo reviewRepoState) (workingState, error) {
	files, err := reviewWorkingFiles(repo)
	if err != nil {
		return workingState{}, err
	}
	return workingState{files: files}, nil
}

func reviewFingerprintWithDeps(agentDir string, baseRef string, headRef string, comparisonMode string, compareRef string, deps reviewFingerprintDeps) (types.ReviewFingerprint, error) {
	repo, summary, err := deps.loadRepoState(agentDir, baseRef, headRef, comparisonMode, compareRef)
	if err != nil {
		return types.ReviewFingerprint{}, err
	}
	if summary.Status != "" {
		now := deps.now
		if now == nil {
			now = func() time.Time { return time.Now().UTC() }
		}
		serveNow := now().UTC()
		return types.ReviewFingerprint{
			BaseRef:        repo.baseRef,
			HeadRef:        repo.headRef,
			ComparisonMode: repo.comparisonMode,
			ObservedAt:     serveNow,
			NextRefreshAt:  serveNow.Add(reviewFingerprintDefaultTTL),
		}, nil
	}

	branchSig, err := deps.loadBranchSignature(repo)
	if err != nil {
		return types.ReviewFingerprint{}, err
	}

	working, err := dispatchWorkingState(repo, deps)
	if err != nil {
		return types.ReviewFingerprint{}, err
	}
	workingFiles := working.state.files

	stagedCount, unstagedCount := 0, 0
	for _, f := range workingFiles {
		if f.Staged {
			stagedCount++
		}
		if f.Unstaged {
			unstagedCount++
		}
	}

	// ObservedAt reflects when the underlying working scan completed, not
	// when the cache served this fingerprint. On a stale-while-revalidate
	// read this lets the frontend see how stale the working token really is
	// (and decide whether to re-fetch immediately rather than wait for the
	// next poll). loadedAt is populated by the cache layer on every store;
	// fall back to wallclock only when the state was never cached (which
	// means it was just loaded synchronously and time.Now is correct).
	now := deps.now
	if now == nil {
		now = func() time.Time { return time.Now().UTC() }
	}
	serveNow := now().UTC()
	observedAt := working.state.loadedAt
	if observedAt.IsZero() {
		observedAt = serveNow
	} else {
		observedAt = observedAt.UTC()
	}
	// NextRefreshAt must be at least one TTL past the serve time, even when
	// the cache is stale (observedAt far in the past). Without the floor, a
	// stale-while-revalidate hit would set NextRefreshAt = observedAt + TTL,
	// which can be in the past and trigger a frontend hot-agentteam. Both bounds
	// use the same TTL: the cadence is a property of the daemon, not of how
	// stale the underlying scan is.
	nextRefreshAt := serveNow.Add(reviewFingerprintDefaultTTL)
	if candidate := observedAt.Add(reviewFingerprintDefaultTTL); candidate.After(nextRefreshAt) {
		nextRefreshAt = candidate
	}
	return types.ReviewFingerprint{
		HeadCommit:        branchSig.headCommit,
		BaseRef:           repo.baseRef,
		HeadRef:           repo.headRef,
		ComparisonMode:    repo.comparisonMode,
		RepositoryToken:   reviewRepositoryToken(repo.resolvedRoot, repo.comparisonMode, branchSig.baseCommit),
		BranchToken:       branchSig.token,
		WorkingToken:      reviewWorkingToken(workingFiles),
		WorkingStrength:   types.ReviewFingerprintWorkingStrengthHint,
		ObservedAt:        observedAt,
		NextRefreshAt:     nextRefreshAt,
		WorkingStateFresh: working.fresh,
		BranchFileCount:   branchSig.fileCount,
		StagedFileCount:   stagedCount,
		UnstagedFileCount: unstagedCount,
	}, nil
}

// dispatchWorkingState looks up the working cache and runs the deps' working
// loader on a miss. Splitting this out of reviewFingerprintWithDeps keeps the
// cache interactions in one place and gives tests a single call site to
// reason about. The cache key uses repo.resolvedRoot — the realpath of the
// worktree filesystem — so two callers arriving via different alias paths to
// the same worktree share one cache entry.
func dispatchWorkingState(repo reviewRepoState, deps reviewFingerprintDeps) (workingStateLookup, error) {
	key := newWorkingStateCacheKey(repo.resolvedRoot)
	return deps.workingCache.getOrRefresh(key, func() (workingState, error) {
		return deps.loadWorkingState(repo)
	})
}

// reviewBranchSignatureResolveHook is a test-only seam fired AFTER
// resolveReviewBranchCommitPair returns the resolved commit pair and BEFORE
// the cache getOrCreate closure consumes it. Production code leaves this nil
// (zero overhead — the branch is never taken). Tests set it to move refs in
// the resolve→load gap so the TOCTOU invariant — "the loader anchors at the
// resolved hashes, not the live refs" — can be verified deterministically
// rather than via nondeterministic concurrent goroutine racing.
var reviewBranchSignatureResolveHook func(baseCommit, headCommit string)

// computeBranchSignature resolves the diff commit pair to immutable hashes
// and returns the cached signature, computing it via
// loadReviewBranchChangesAtCommits on a miss. The resolved hashes are used
// for THREE things: the cache key, the DiffTree loader input, and the
// aggregate token input. All three MUST see the same pair — going through
// loadReviewBranchChanges (which re-resolves symbolic refs) would reopen a
// TOCTOU window where a moving branch stores a new pair's diff under an old
// pair's cache key/token.
//
// Direct-mode refs may be symbolic, so they are resolved to hashes before
// they touch the cache key — without that, a moving branch name would
// silently poison the cache. The resolved hashes are also passed to
// reviewBranchToken so the authoritative token matches the cache key (in
// direct mode the cache key is the resolved base commit, NOT repo.mergeBase;
// using mergeBase in the token would make the token insensitive to base-side
// changes that move baseRef without moving mergeBase).
func computeBranchSignature(repo reviewRepoState) (branchSignature, error) {
	// Single resolve seam: symbolic refs become immutable commit hashes ONCE
	// here, and this pair is the canonical input for cache key, DiffTree
	// loader, RepositoryToken, and BranchToken. Re-resolving inside the
	// loader would reopen a TOCTOU window where a moving branch stores a
	// new pair's diff under an old pair's cache key/token.
	baseCommit, headCommit, err := resolveReviewBranchCommitPair(repo)
	if err != nil {
		return branchSignature{}, err
	}
	// Test-only TOCTOU barrier hook. Production leaves this nil. Tests set
	// it to move refs in the gap between resolve and the cache getOrCreate
	// closure, deterministically reproducing the concurrent-push window the
	// single-resolve seam is designed to close. Without this hook the
	// invariant ("the loader sees the resolved hashes, not the live refs")
	// cannot be tested without nondeterministic goroutine racing.
	if reviewBranchSignatureResolveHook != nil {
		reviewBranchSignatureResolveHook(baseCommit, headCommit)
	}
	key := branchSignatureKey{baseCommit: baseCommit, headCommit: headCommit}
	return defaultBranchSignatureCache.getOrCreate(key, func() (branchSignature, error) {
		// Pull the branch diff from the shared commit-pair cache so the
		// DiffTree runs once across snapshot/files/fingerprint, then derive
		// the authoritative branch token from the cached per-file list.
		diff, err := getBranchDiffAtCommits(repo, baseCommit, headCommit)
		if err != nil {
			return branchSignature{}, err
		}
		return branchSignature{
			token:      reviewBranchToken(baseCommit, headCommit, diff.files),
			fileCount:  len(diff.files),
			baseCommit: baseCommit,
			headCommit: headCommit,
		}, nil
	})
}

// reviewRepositoryToken derives an opaque, AUTHORITATIVE token that changes
// whenever the repository identity (canonical worktree root, comparison mode)
// or resolved base commit changes. Equal tokens guarantee the comparison is
// anchored at the same root and base — the head commit participates via
// BranchToken and is intentionally not repeated here.
//
// The base commit MUST be the resolved hash shared with BranchToken — NOT
// repo.mergeBase. In direct mode, baseRef may be symbolic and may move
// without moving mergeBase, so hashing mergeBase would make the repository
// token insensitive to exactly the base-side changes the token is supposed
// to detect. The caller (reviewFingerprintWithDeps) obtains baseCommit from
// branchSignature, which resolves it once via reviewResolveToCommitHash.
func reviewRepositoryToken(resolvedRoot, comparisonMode, baseCommit string) string {
	digest := sha256.New()
	writeTokenString(digest, repositoryTokenDomainV1)
	writeTokenString(digest, resolvedRoot)
	writeTokenString(digest, comparisonMode)
	writeTokenString(digest, baseCommit)
	return hex.EncodeToString(digest.Sum(nil))[:reviewFingerprintAuthoritativeTokenHexLen]
}

// reviewBranchToken derives an opaque, AUTHORITATIVE token for branch file
// state. Inputs are the immutable diff commit pair (resolved to full hashes
// — NOT symbolic refs and NOT mergeBase, which can move independently of
// baseRef in direct mode) plus the per-file From+To blob/mode tokens. Equal
// branch tokens guarantee byte-equal branch diff content.
//
// The token is full SHA-256 (64 hex chars) — not truncated — because it is
// authoritative: the frontend may skip a snapshot re-fetch when the token
// matches, so any truncation would introduce a (small but real) chance of a
// false-positive "no change" verdict.
//
// Files are sorted stably by (path, oldPath, changeType) so two entries at
// the same path — for example a rename plus a modify at the same path —
// have a deterministic order in the byte stream.
func reviewBranchToken(baseCommit, headCommit string, files []types.ReviewFile) string {
	sorted := make([]types.ReviewFile, len(files))
	copy(sorted, files)
	sort.SliceStable(sorted, func(i, j int) bool {
		if sorted[i].Path != sorted[j].Path {
			return sorted[i].Path < sorted[j].Path
		}
		if sorted[i].OldPath != sorted[j].OldPath {
			return sorted[i].OldPath < sorted[j].OldPath
		}
		return sorted[i].ChangeType < sorted[j].ChangeType
	})
	digest := sha256.New()
	writeTokenString(digest, branchAggregateTokenDomainV1)
	writeTokenString(digest, baseCommit)
	writeTokenString(digest, headCommit)
	writeTokenUint64(digest, uint64(len(sorted)))
	for _, f := range sorted {
		writeTokenString(digest, f.Path)
		writeTokenString(digest, f.OldPath)
		writeTokenString(digest, f.ChangeType)
		writeTokenString(digest, f.Token)
	}
	return hex.EncodeToString(digest.Sum(nil))[:reviewFingerprintAuthoritativeTokenHexLen]
}

// reviewWorkingToken derives an opaque, NON-authoritative token for working
// file state. Inputs are the per-file stat hint tokens; equal working
// tokens strongly suggest no change but cannot guarantee it, because mtime
// is involved (see ReviewFile.Token).
func reviewWorkingToken(files []types.ReviewFile) string {
	sorted := make([]types.ReviewFile, len(files))
	copy(sorted, files)
	sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Path < sorted[j].Path })
	digest := sha256.New()
	writeTokenString(digest, workingAggregateTokenDomainV1)
	writeTokenUint64(digest, uint64(len(sorted)))
	for _, f := range sorted {
		writeTokenString(digest, f.Path)
		writeTokenString(digest, f.StagedChangeType)
		writeTokenString(digest, f.UnstagedChangeType)
		writeTokenString(digest, f.Token)
	}
	return hex.EncodeToString(digest.Sum(nil))[:reviewFingerprintTokenHexLen]
}
