package workspace

import (
	"errors"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

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

// fingerprintDepsBuilder builds an isolated reviewFingerprintDeps for tests.
// Every test gets fresh caches and a counting working-state loader; branch
// state is stubbed so the tests do not need a real *git.Repository.
type fingerprintDepsBuilder struct {
	now          func() time.Time
	workingTTL   time.Duration
	workingState workingState
}

func newFingerprintDepsBuilder() *fingerprintDepsBuilder {
	return &fingerprintDepsBuilder{
		now:        func() time.Time { return time.Unix(1_700_000_000, 0) },
		workingTTL: time.Minute,
		workingState: workingState{
			files: []types.ReviewFile{{Path: "main.go", Staged: true}},
		},
	}
}

func (b *fingerprintDepsBuilder) withWorkingTTL(ttl time.Duration) *fingerprintDepsBuilder {
	b.workingTTL = ttl
	return b
}

func (b *fingerprintDepsBuilder) build(
	t *testing.T,
	repoForRoot func(root string) reviewRepoState,
	workingLoads *atomic.Int32,
) reviewFingerprintDeps {
	t.Helper()
	workingCache := newWorkingStateCache(defaultWorkingStateCacheCapacity, b.workingTTL, b.now)
	branchStub := func(repo reviewRepoState) (branchSignature, error) {
		return branchSignature{token: "branch-stub", fileCount: 1}, nil
	}
	workingStub := func(repo reviewRepoState) (workingState, error) {
		workingLoads.Add(1)
		return b.workingState, nil
	}
	loadRepoState := func(agentDir, baseRef, headRef, comparisonMode, compareRef string) (reviewRepoState, types.ReviewSummary, error) {
		root := strings.TrimSpace(agentDir)
		if root == "" {
			return reviewRepoState{}, types.ReviewSummary{}, errors.New("agentDir required")
		}
		return repoForRoot(root), types.ReviewSummary{}, nil
	}
	return reviewFingerprintDeps{
		loadRepoState:       loadRepoState,
		loadBranchSignature: branchStub,
		loadWorkingState:    workingStub,
		workingCache:        workingCache,
		now:                 b.now,
	}
}

func makeFingerprintRepoState(root string) reviewRepoState {
	return reviewRepoState{
		root:           root,
		resolvedRoot:   root,
		baseRef:        "main",
		headRef:        "feat",
		headCommit:     "head-commit",
		mergeBase:      "merge-base",
		comparisonMode: "common_base",
	}
}

// TestReviewFingerprintSameWorktreeDifferentRefsSharesScan proves the cache
// key is the canonical worktree root: two review contexts (different refs)
// pointing at the same worktree collapse to one working scan via cache hit.
func TestReviewFingerprintSameWorktreeDifferentRefsSharesScan(t *testing.T) {
	b := newFingerprintDepsBuilder()
	var loads atomic.Int32
	deps := b.build(t, makeFingerprintRepoState, &loads)

	if _, err := reviewFingerprintWithDeps("/repo", "main", "feat-a", "common_base", "", deps); err != nil {
		t.Fatalf("first call error: %v", err)
	}
	if _, err := reviewFingerprintWithDeps("/repo", "main", "feat-b", "common_base", "", deps); err != nil {
		t.Fatalf("second call error: %v", err)
	}
	if got := loads.Load(); got != 1 {
		t.Fatalf("working scans = %d, want 1 (same worktree, different refs must share)", got)
	}
}

// TestReviewFingerprintDifferentWorktreesSameRepoIsolatesScans proves two
// distinct worktree roots (e.g. two linked worktrees of one repository) do
// NOT share working state. Each gets its own scan.
func TestReviewFingerprintDifferentWorktreesSameRepoIsolatesScans(t *testing.T) {
	b := newFingerprintDepsBuilder()
	var loads atomic.Int32
	deps := b.build(t, makeFingerprintRepoState, &loads)

	if _, err := reviewFingerprintWithDeps("/repo", "main", "feat", "common_base", "", deps); err != nil {
		t.Fatalf("first call error: %v", err)
	}
	if _, err := reviewFingerprintWithDeps("/repo.worktrees/feat", "main", "feat", "common_base", "", deps); err != nil {
		t.Fatalf("second call error: %v", err)
	}
	if got := loads.Load(); got != 2 {
		t.Fatalf("working scans = %d, want 2 (different worktrees must NOT share)", got)
	}
}

// TestReviewFingerprintStaleWhileRevalidateNonBlocking verifies the full
// stale-while-revalidate contract at the ReviewFingerprint layer:
//   - within TTL: scan runs once, response is fresh
//   - after TTL: response is non-blocking and stale (WorkingStateFresh=false)
//   - concurrent stale requests collapse to one background refresh
//   - after the refresh lands, the next call returns fresh with the new token
func TestReviewFingerprintStaleWhileRevalidateNonBlocking(t *testing.T) {
	now := time.Unix(1_700_000_000, 0)
	b := newFingerprintDepsBuilder().
		withWorkingTTL(time.Second)
	b.now = func() time.Time { return now }
	b.workingState = workingState{files: []types.ReviewFile{{Path: "v1.go"}}}

	var loads atomic.Int32
	refreshStarted := make(chan struct{})
	refreshRelease := make(chan struct{})
	firstLoadDone := make(chan struct{})

	deps := b.build(t, makeFingerprintRepoState, &loads)
	// Override the working loader to block on the second invocation so we
	// can prove the async refresh path is non-blocking.
	deps.loadWorkingState = func(repo reviewRepoState) (workingState, error) {
		n := loads.Add(1)
		if n == 1 {
			// First call: cold miss, return immediately.
			close(firstLoadDone)
			return workingState{files: []types.ReviewFile{{Path: "v1.go"}}}, nil
		}
		// Subsequent calls (async refresh): block until released, return new value.
		close(refreshStarted)
		<-refreshRelease
		return workingState{files: []types.ReviewFile{{Path: "v2.go"}}}, nil
	}

	// 1. Cold miss: synchronous, fresh.
	fp, err := reviewFingerprintWithDeps("/repo", "main", "feat", "common_base", "", deps)
	if err != nil {
		t.Fatalf("cold miss error: %v", err)
	}
	if !fp.WorkingStateFresh {
		t.Fatalf("cold miss returned WorkingStateFresh=false")
	}
	<-firstLoadDone

	// 2. Advance past TTL.
	now = now.Add(2 * time.Second)

	// 3. Multiple concurrent stale calls: all must return immediately with
	//    WorkingStateFresh=false, and exactly one background refresh is kicked.
	const callers = 3
	var wg sync.WaitGroup
	results := make([]types.ReviewFingerprint, callers)
	errs := make([]error, callers)
	start := make(chan struct{})
	for i := 0; i < callers; i++ {
		wg.Add(1)
		go func(idx int) {
			defer wg.Done()
			<-start
			fp, err := reviewFingerprintWithDeps("/repo", "main", "feat", "common_base", "", deps)
			results[idx] = fp
			errs[idx] = err
		}(i)
	}
	close(start)

	// All callers should return promptly even though the refresh load is
	// blocking on refreshRelease.
	waitDone := make(chan struct{})
	go func() {
		wg.Wait()
		close(waitDone)
	}()
	select {
	case <-waitDone:
	case <-time.After(time.Second):
		t.Fatalf("stale calls did not return within 1s — refresh is blocking the response")
	}

	for i, err := range errs {
		if err != nil {
			t.Fatalf("caller %d error: %v", i, err)
		}
		if results[i].WorkingStateFresh {
			t.Fatalf("caller %d returned WorkingStateFresh=true on stale read", i)
		}
	}

	// 4. Exactly one background refresh should be in flight (singleflight).
	select {
	case <-refreshStarted:
	case <-time.After(time.Second):
		t.Fatalf("async refresh did not start within 1s")
	}
	// At this point the cold miss already counted (1) plus the single
	// in-flight async refresh (1) = 2 load invocations.
	if got := loads.Load(); got != 2 {
		t.Fatalf("loads during stale window = %d, want 2 (cold miss + 1 async refresh)", got)
	}

	// 5. Release the refresh; the cache should land v2.
	close(refreshRelease)
	deadline := time.Now().Add(time.Second)
	for time.Now().Before(deadline) {
		if state, fresh, ok := deps.workingCache.lookup(newWorkingStateCacheKey("/repo")); ok && fresh {
			if len(state.files) == 1 && state.files[0].Path == "v2.go" {
				break
			}
		}
		time.Sleep(5 * time.Millisecond)
	}
	state, fresh, ok := deps.workingCache.lookup(newWorkingStateCacheKey("/repo"))
	if !ok || !fresh || len(state.files) != 1 || state.files[0].Path != "v2.go" {
		t.Fatalf("async refresh did not land v2: state=%+v fresh=%v ok=%v", state, fresh, ok)
	}

	// 6. One more call should now be a fresh hit — no extra load.
	before := loads.Load()
	fp2, err := reviewFingerprintWithDeps("/repo", "main", "feat", "common_base", "", deps)
	if err != nil {
		t.Fatalf("post-refresh call error: %v", err)
	}
	if !fp2.WorkingStateFresh {
		t.Fatalf("post-refresh call returned WorkingStateFresh=false")
	}
	if got := loads.Load(); got != before {
		t.Fatalf("fresh hit invoked loader: before=%d after=%d", before, got)
	}
}

// TestReviewFingerprintRepoStateStatusShortCircuits verifies that when the
// repo state returns a non-empty Status (e.g. "not_git"), no branch or
// working scan runs at all.
func TestReviewFingerprintRepoStateStatusShortCircuits(t *testing.T) {
	b := newFingerprintDepsBuilder()
	var loads atomic.Int32
	deps := b.build(t, makeFingerprintRepoState, &loads)
	deps.loadRepoState = func(agentDir, baseRef, headRef, comparisonMode, compareRef string) (reviewRepoState, types.ReviewSummary, error) {
		return reviewRepoState{}, types.ReviewSummary{Status: "not_git", Message: "no repo"}, nil
	}

	fp, err := reviewFingerprintWithDeps("/repo", "main", "feat", "common_base", "", deps)
	if err != nil {
		t.Fatalf("error: %v", err)
	}
	if fp.WorkingStateFresh {
		t.Fatalf("not_git response should have WorkingStateFresh=false")
	}
	if got := loads.Load(); got != 0 {
		t.Fatalf("working scans = %d, want 0 on not_git", got)
	}
}

// TestReviewFingerprintNextRefreshAtFloor verifies the NextRefreshAt semantic
// contract: the value is max(serveNow + TTL, observedAt + TTL), so a stale
// cache hit (observedAt far in the past) still returns a NextRefreshAt that
// is at least one TTL into the future from the serve time. Without the floor,
// a stale hit would set NextRefreshAt = observedAt + TTL, which is already in
// the past and triggers a frontend hot-agentteam.
//
// Three scenarios are asserted at the same wallclock serve time T:
//   - T0  cold miss: observedAt = T          → NextRefreshAt = T + TTL
//   - T10 stale hit, 10s old: observedAt = T-10s → NextRefreshAt = T + TTL (NOT T-10s + TTL)
//   - T30 stale hit, 30s old: observedAt = T-30s → NextRefreshAt = T + TTL
func TestReviewFingerprintNextRefreshAtFloor(t *testing.T) {
	serveTime := time.Unix(1_700_000_000, 0).UTC()
	ttl := reviewFingerprintDefaultTTL

	scenarios := []struct {
		name        string
		observedAge time.Duration // how stale the cache entry is at serve time
	}{
		{"T0 cold (observedAt = serveTime)", 0},
		{"T10 stale (observedAt = serveTime - 10s)", 10 * time.Second},
		{"T30 stale (observedAt = serveTime - 30s)", 30 * time.Second},
	}
	for _, sc := range scenarios {
		t.Run(sc.name, func(t *testing.T) {
			observedAt := serveTime.Add(-sc.observedAge)
			// Pre-seed the working cache so the fingerprint call is a cache
			// hit at the given observedAt; the cache's own TTL clock does
			// not matter for NextRefreshAt math, only the stored loadedAt.
			stubState := workingState{
				files:    []types.ReviewFile{{Path: "v1.go"}},
				loadedAt: observedAt,
			}
			workingCache := newWorkingStateCache(defaultWorkingStateCacheCapacity, time.Hour, func() time.Time { return serveTime })
			// Force-insert the entry so the next lookup is a hit. The entry's
			// loadedAt is the observedAt we want to test; the cache's TTL clock
			// is anchored at serveTime with a 1h TTL, so the entry is "fresh"
			// and the loader never runs.
			key := newWorkingStateCacheKey("/repo")
			workingCache.store(key, stubState)

			deps := reviewFingerprintDeps{
				loadRepoState: func(agentDir, baseRef, headRef, comparisonMode, compareRef string) (reviewRepoState, types.ReviewSummary, error) {
					return makeFingerprintRepoState(strings.TrimSpace(agentDir)), types.ReviewSummary{}, nil
				},
				loadBranchSignature: func(repo reviewRepoState) (branchSignature, error) {
					return branchSignature{token: "stub", fileCount: 0, baseCommit: "base-stub"}, nil
				},
				loadWorkingState: func(repo reviewRepoState) (workingState, error) {
					t.Fatalf("loadWorkingState must not run on a pre-seeded cache hit")
					return workingState{}, nil
				},
				workingCache: workingCache,
				now:          func() time.Time { return serveTime },
			}

			fp, err := reviewFingerprintWithDeps("/repo", "main", "feat", "common_base", "", deps)
			require.NoError(t, err, "fingerprint call failed")
			require.Equal(t, observedAt, fp.ObservedAt,
				"ObservedAt must equal the cache entry's loadedAt (%v), got %v", observedAt, fp.ObservedAt)
			require.Equal(t, serveTime.Add(ttl), fp.NextRefreshAt,
				"NextRefreshAt must be exactly serveTime + TTL (one TTL into the future); got %v want %v",
				fp.NextRefreshAt, serveTime.Add(ttl))
			require.True(t, fp.NextRefreshAt.After(serveTime),
				"NextRefreshAt must be strictly later than the serve time (no hot-agentteam), got NextRefreshAt=%v serveTime=%v",
				fp.NextRefreshAt, serveTime)
		})
	}
}
