package workspace

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

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

func TestWorkingStateCacheLookupFreshThenStale(t *testing.T) {
	now := time.Now()
	cache := newWorkingStateCache(4, time.Second, func() time.Time { return now })

	key := newWorkingStateCacheKey("/repo")
	state := workingState{files: []types.ReviewFile{{Path: "a"}}}
	cache.store(key, state)

	got, fresh, ok := cache.lookup(key)
	if !ok {
		t.Fatalf("lookup after store returned miss")
	}
	if !fresh {
		t.Fatalf("lookup within TTL returned stale")
	}
	if len(got.files) != 1 || got.files[0].Path != "a" {
		t.Fatalf("lookup returned %+v, want %+v", got, state)
	}

	// Advance past TTL: entry is still there, but stale.
	now = now.Add(2 * time.Second)
	got, fresh, ok = cache.lookup(key)
	if !ok {
		t.Fatalf("lookup after TTL returned no entry — stale entry should be retained")
	}
	if fresh {
		t.Fatalf("lookup after TTL returned fresh")
	}
	if len(got.files) != 1 || got.files[0].Path != "a" {
		t.Fatalf("stale lookup returned %+v, want old value", got)
	}
}

func TestWorkingStateCacheLRUEviction(t *testing.T) {
	now := time.Now()
	cache := newWorkingStateCache(2, time.Minute, func() time.Time { return now })

	cache.store(newWorkingStateCacheKey("/r1"), workingState{})
	cache.store(newWorkingStateCacheKey("/r2"), workingState{})
	cache.store(newWorkingStateCacheKey("/r3"), workingState{})

	if cache.len() != 2 {
		t.Fatalf("len after eviction = %d, want 2", cache.len())
	}
	if _, _, ok := cache.lookup(newWorkingStateCacheKey("/r1")); ok {
		t.Fatalf("oldest entry should have been evicted")
	}
}

func TestWorkingStateCacheStoreOverwritesAndRenewsExpiry(t *testing.T) {
	now := time.Now()
	cache := newWorkingStateCache(2, time.Second, func() time.Time { return now })

	key := newWorkingStateCacheKey("/repo")
	cache.store(key, workingState{files: []types.ReviewFile{{Path: "old"}}})
	cache.store(key, workingState{files: []types.ReviewFile{{Path: "new"}}})

	got, fresh, ok := cache.lookup(key)
	if !ok || !fresh {
		t.Fatalf("lookup after overwrite: ok=%v fresh=%v", ok, fresh)
	}
	if len(got.files) != 1 || got.files[0].Path != "new" {
		t.Fatalf("got = %+v", got)
	}
	if cache.len() != 1 {
		t.Fatalf("len = %d, want 1", cache.len())
	}
}

func TestWorkingStateCacheGetOrRefreshFreshHitDoesNotLoad(t *testing.T) {
	now := time.Now()
	cache := newWorkingStateCache(2, time.Minute, func() time.Time { return now })

	key := newWorkingStateCacheKey("/repo")
	cache.store(key, workingState{files: []types.ReviewFile{{Path: "a"}}})

	var loads atomic.Int32
	load := func() (workingState, error) {
		loads.Add(1)
		return workingState{files: []types.ReviewFile{{Path: "x"}}}, nil
	}

	got, err := cache.getOrRefresh(key, load)
	if err != nil {
		t.Fatalf("getOrRefresh error: %v", err)
	}
	if !got.fresh {
		t.Fatalf("fresh hit returned fresh=false")
	}
	if got.state.files[0].Path != "a" {
		t.Fatalf("fresh hit returned %+v, want cached value", got)
	}
	if loads.Load() != 0 {
		t.Fatalf("fresh hit invoked load %d times, want 0", loads.Load())
	}
}

func TestWorkingStateCacheGetOrRefreshStaleReturnsStaleAndTriggersAsyncRefresh(t *testing.T) {
	now := time.Now()
	cache := newWorkingStateCache(2, time.Second, func() time.Time { return now })

	key := newWorkingStateCacheKey("/repo")
	cache.store(key, workingState{files: []types.ReviewFile{{Path: "old"}}})

	refreshStarted := make(chan struct{})
	refreshRelease := make(chan struct{})
	var loads atomic.Int32
	load := func() (workingState, error) {
		loads.Add(1)
		close(refreshStarted)
		<-refreshRelease
		return workingState{files: []types.ReviewFile{{Path: "new"}}}, nil
	}

	// Advance past TTL so the stored entry is stale but still present.
	now = now.Add(2 * time.Second)

	got, err := cache.getOrRefresh(key, load)
	if err != nil {
		t.Fatalf("stale getOrRefresh error: %v", err)
	}
	if got.fresh {
		t.Fatalf("stale hit returned fresh=true")
	}
	if got.state.files[0].Path != "old" {
		t.Fatalf("stale hit returned %+v, want old value", got)
	}

	// The async refresh has been kicked off; it should be in-flight now.
	<-refreshStarted

	// A second call while the refresh is in-flight should also return stale
	// (the cache has not been updated yet) and should NOT spawn a second load.
	got2, err := cache.getOrRefresh(key, load)
	if err != nil {
		t.Fatalf("second stale getOrRefresh error: %v", err)
	}
	if got2.fresh {
		t.Fatalf("second stale hit returned fresh=true")
	}
	if got2.state.files[0].Path != "old" {
		t.Fatalf("second stale hit returned %+v, want old value", got2)
	}

	close(refreshRelease)
	// Wait for the async refresh to land. Poll bound: it should complete well
	// within a second.
	deadline := time.Now().Add(time.Second)
	for time.Now().Before(deadline) {
		if state, _, ok := cache.lookup(key); ok && len(state.files) > 0 && state.files[0].Path == "new" {
			break
		}
		time.Sleep(5 * time.Millisecond)
	}
	state, _, ok := cache.lookup(key)
	if !ok || len(state.files) == 0 || state.files[0].Path != "new" {
		t.Fatalf("async refresh did not update cache: %+v", state)
	}

	// Two stale getOrRefresh calls should have triggered only one async load.
	if loads.Load() != 1 {
		t.Fatalf("async refresh invoked load %d times, want 1", loads.Load())
	}
}

func TestWorkingStateCacheGetOrRefreshColdMissLoadsSynchronously(t *testing.T) {
	now := time.Now()
	cache := newWorkingStateCache(2, time.Second, func() time.Time { return now })

	key := newWorkingStateCacheKey("/repo")
	var loads atomic.Int32
	load := func() (workingState, error) {
		loads.Add(1)
		return workingState{files: []types.ReviewFile{{Path: "a"}}}, nil
	}

	got, err := cache.getOrRefresh(key, load)
	if err != nil {
		t.Fatalf("cold miss error: %v", err)
	}
	if !got.fresh {
		t.Fatalf("cold miss returned fresh=false")
	}
	if got.state.files[0].Path != "a" {
		t.Fatalf("cold miss returned %+v", got)
	}
	if loads.Load() != 1 {
		t.Fatalf("cold miss invoked load %d times, want 1", loads.Load())
	}
}

func TestWorkingStateCacheGetOrRefreshColdMissDoesNotCacheError(t *testing.T) {
	now := time.Now()
	cache := newWorkingStateCache(2, time.Minute, func() time.Time { return now })

	key := newWorkingStateCacheKey("/repo")
	var loads atomic.Int32
	load := func() (workingState, error) {
		n := loads.Add(1)
		if n == 1 {
			return workingState{}, errors.New("transient")
		}
		return workingState{files: []types.ReviewFile{{Path: "a"}}}, nil
	}

	if _, err := cache.getOrRefresh(key, load); err == nil {
		t.Fatalf("first call should fail")
	}
	got, err := cache.getOrRefresh(key, load)
	if err != nil {
		t.Fatalf("second call error: %v", err)
	}
	if !got.fresh || len(got.state.files) != 1 {
		t.Fatalf("second call got = %+v", got)
	}
	if loads.Load() != 2 {
		t.Fatalf("errors should not be cached; loads = %d", loads.Load())
	}
}

func TestWorkingStateCacheColdMissSameKeySharesSingleScan(t *testing.T) {
	now := time.Now()
	cache := newWorkingStateCache(4, time.Second, func() time.Time { return now })

	// Two concurrent cold-miss callers with the SAME key (i.e. same repo root,
	// different review contexts). They must share one scan via singleflight.
	key := newWorkingStateCacheKey("/repo")
	ready := make(chan struct{})
	release := make(chan struct{})
	var loads atomic.Int32
	load := func() (workingState, error) {
		if loads.Add(1) == 1 {
			close(ready)
			<-release
		}
		return workingState{files: []types.ReviewFile{{Path: "a"}}}, nil
	}

	const callers = 2
	var wg sync.WaitGroup
	start := make(chan struct{})
	results := make([]workingStateLookup, callers)
	errs := make([]error, callers)
	for i := 0; i < callers; i++ {
		wg.Add(1)
		go func(idx int) {
			defer wg.Done()
			<-start
			got, err := cache.getOrRefresh(key, load)
			results[idx] = got
			errs[idx] = err
		}(i)
	}
	close(start)

	<-ready
	close(release)
	wg.Wait()

	for i, err := range errs {
		if err != nil {
			t.Fatalf("caller %d error: %v", i, err)
		}
		if !results[i].fresh {
			t.Fatalf("caller %d got fresh=false", i)
		}
	}
	if loads.Load() != 1 {
		t.Fatalf("two same-key cold misses invoked load %d times, want 1", loads.Load())
	}
}

// TestWorkingStateCacheKeyPreservesWhitespaceInRoot asserts that the cache
// key does NOT trim surrounding whitespace from the canonical root. Two real
// worktrees whose canonical roots differ only by trailing whitespace
// ("/repo" vs "/repo ") are distinct filesystem locations and MUST get
// distinct cache keys — otherwise a scan of one worktree would silently be
// served for the other. The canonical root is expected to already be
// normalized upstream (Clean + Abs + EvalSymlinks); newWorkingStateCacheKey
// applies no further trimming.
func TestWorkingStateCacheKeyPreservesWhitespaceInRoot(t *testing.T) {
	cases := []struct {
		name string
		a    string
		b    string
	}{
		{name: "trailing space", a: "/repo", b: "/repo "},
		{name: "leading space", a: "/repo", b: " /repo"},
		{name: "trailing newline", a: "/repo", b: "/repo\n"},
		{name: "trailing tab", a: "/repo", b: "/repo\t"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			ka := newWorkingStateCacheKey(tc.a)
			kb := newWorkingStateCacheKey(tc.b)
			if ka == kb {
				t.Fatalf("newWorkingStateCacheKey collapsed %q and %q to the same key %q — distinct worktrees must get distinct keys", tc.a, tc.b, ka)
			}
		})
	}
}

func TestWorkingStateCacheInvalidate(t *testing.T) {
	now := time.Now()
	cache := newWorkingStateCache(4, time.Minute, func() time.Time { return now })

	key := newWorkingStateCacheKey("/repo")
	cache.store(key, workingState{})
	cache.invalidate(key)

	if _, _, ok := cache.lookup(key); ok {
		t.Fatalf("lookup after invalidate returned hit")
	}
}

func TestWorkingStateCacheNilSafe(t *testing.T) {
	var cache *workingStateCache
	key := newWorkingStateCacheKey("/repo")

	if _, _, ok := cache.lookup(key); ok {
		t.Fatalf("nil cache lookup should miss")
	}

	cache.store(key, workingState{})
	if cache != nil {
		t.Fatalf("nil cache should stay nil after store")
	}

	called := false
	got, err := cache.getOrRefresh(key, func() (workingState, error) {
		called = true
		return workingState{files: []types.ReviewFile{{Path: "a"}}}, nil
	})
	if err != nil {
		t.Fatalf("nil cache getOrRefresh error: %v", err)
	}
	if !called {
		t.Fatalf("nil cache should invoke load directly")
	}
	if !got.fresh {
		t.Fatalf("nil cache should report fresh=true")
	}
}
