package workspace

import (
	"container/list"
	"log/slog"
	"sync"
	"time"

	"golang.org/x/sync/singleflight"

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

const (
	// defaultWorkingStateCacheCapacity bounds how many repos the cache tracks
	// at once. A single review session touches one repo; the cap exists so an
	// agent that wanders across many workspaces does not retain stale entries
	// for repos it is no longer reviewing.
	defaultWorkingStateCacheCapacity = 8

	// defaultWorkingStateCacheTTL bounds how long a working scan is considered
	// fresh. Working state is not immutable (the user edits files, runs git
	// add, etc.), so a TTL is mandatory — unlike the commit-pair branch cache,
	// equal keys at different times do not guarantee equal content. The TTL
	// trades at most one stale read per window for far fewer filesystem walks.
	defaultWorkingStateCacheTTL = 30 * time.Second

	// defaultWorkingStateRefreshConcurrency bounds the number of async refresh
	// goroutines the cache will spawn process-wide. Stale-while-revalidate is
	// non-blocking, so without a global bound a burst of stale calls across
	// many repos could spawn one goroutine each and saturate the daemon's
	// filesystem scanner pool. The semaphore is held from spawn until the load
	// completes (or panics); late arrivals that find the semaphore full drop
	// the refresh and rely on the next stale caller to retry.
	defaultWorkingStateRefreshConcurrency = 4
)

// workingStateCacheKey identifies a working scan by its CANONICAL repo root
// path (realpath, no symlink alias). Working state is per-worktree and
// independent of the chosen base/head refs — two review contexts that point
// at the same worktree share the same scan, and two callers arriving via
// different alias paths to the same physical worktree also share.
type workingStateCacheKey string

// newWorkingStateCacheKey wraps a canonical repo root as a cache key. The
// caller MUST pass a root that has already been normalized (filepath.Clean +
// filepath.Abs + EvalSymlinks) — no extra trimming or cleaning is applied
// here. In particular, TrimSpace is intentionally NOT used: two real
// worktrees whose canonical roots differ only by trailing whitespace
// ("/repo" vs "/repo ") are distinct filesystem locations and MUST get
// distinct cache keys. Trimming would silently conflate them.
func newWorkingStateCacheKey(root string) workingStateCacheKey {
	return workingStateCacheKey(root)
}

// workingState is the cached projection of a working scan: the per-file
// staged/unstaged entries plus the time the scan completed. Callers derive
// the working token and the staged/unstaged counts from this slice;
// ReviewFingerprint.ObservedAt uses loadedAt so the frontend sees the scan
// time rather than the cache-serve time.
type workingState struct {
	files    []types.ReviewFile
	loadedAt time.Time
}

// workingStateLookup is the result of a cache access. Fresh is true when the
// returned state is within TTL; false when it is stale (returned via
// stale-while-revalidate) and a refresh has been kicked off in the background.
type workingStateLookup struct {
	state workingState
	fresh bool
}

type workingStateCacheEntry struct {
	key       workingStateCacheKey
	state     workingState
	expiresAt time.Time
}

// workingStateCache is a process-wide TTL+LRU keyed by canonical repo root.
// It collapses short bursts of fingerprint polls onto a single working scan
// via stale-while-revalidate: a TTL hit returns immediately, a TTL miss with
// a stale entry returns immediately and kicks a deduped async refresh, and
// only a true cold miss blocks on a synchronous load.
//
// Concurrency model:
//   - singleflight collapses concurrent cold-miss callers onto one load.
//   - a per-key inflight registry (independent of LRU entries, so LRU
//     eviction cannot bypass it) dedupes concurrent async spawns for the
//     same key.
//   - a global semaphore bounds total concurrent async refreshes process-
//     wide so a burst across many repos cannot saturate the daemon.
//
// The inflight registry + semaphore grab happen atomically under mu so a
// goroutine cannot register inflight and then fail to take the semaphore
// (which would deadlock future spawns for the same key).
type workingStateCache struct {
	cap int
	ttl time.Duration
	now func() time.Time

	mu         sync.Mutex
	items      map[workingStateCacheKey]*list.Element
	order      *list.List
	inflight   map[workingStateCacheKey]struct{}
	refreshSem chan struct{}
	flights    singleflight.Group

	// onAsyncRecheckHit, if non-nil, is invoked inside runAsyncRefresh's
	// singleflight closure AFTER a fresh-recheck succeeds but BEFORE the
	// closure returns. Production code leaves this nil. Tests set it to
	// insert a deterministic pause so a racing cold-miss caller can join
	// the same flight while the leader is parked, exposing the
	// shared-flight return-value contract: a zero-value return here would
	// silently surface to the cold-miss follower (via singleflight's
	// result-pass-through) as empty files, fresh=false, no error.
	//
	// Per-cache-instance (NOT a package global) so concurrent tests in the
	// same package cannot interfere. The hook receives the captured state
	// the closure is about to return; tests can assert it matches the
	// pre-eviction entry to pin the "leader captures E1 then parks" phase.
	onAsyncRecheckHit func(key workingStateCacheKey, state workingState)
}

var defaultWorkingStateCache = newWorkingStateCache(defaultWorkingStateCacheCapacity, defaultWorkingStateCacheTTL, time.Now)

func newWorkingStateCache(capacity int, ttl time.Duration, now func() time.Time) *workingStateCache {
	if capacity <= 0 {
		capacity = defaultWorkingStateCacheCapacity
	}
	if ttl <= 0 {
		ttl = defaultWorkingStateCacheTTL
	}
	if now == nil {
		now = time.Now
	}
	return &workingStateCache{
		cap:        capacity,
		ttl:        ttl,
		now:        now,
		items:      make(map[workingStateCacheKey]*list.Element),
		order:      list.New(),
		inflight:   make(map[workingStateCacheKey]struct{}),
		refreshSem: make(chan struct{}, defaultWorkingStateRefreshConcurrency),
	}
}

// lookup returns the cached state and a fresh flag. The state is non-empty
// when the entry exists (regardless of TTL); fresh is true only when the
// entry is within TTL. Expired entries are NOT evicted here —
// stale-while-revalidate needs them — but the caller may still want to know
// they are stale. The ok flag is false only when no entry exists at all.
func (c *workingStateCache) lookup(key workingStateCacheKey) (state workingState, fresh bool, ok bool) {
	if c == nil {
		return workingState{}, false, false
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	el, exists := c.items[key]
	if !exists {
		return workingState{}, false, false
	}
	entry := el.Value.(*workingStateCacheEntry)
	fresh = c.now().Before(entry.expiresAt)
	// Move to front on any access (fresh or stale) so the LRU order reflects
	// actual demand rather than freshness.
	c.order.MoveToFront(el)
	return entry.state, fresh, true
}

// store caches state for key with a fresh expiry, evicting the LRU entry when
// the cache is at capacity. A no-op when c is nil. Note: store does NOT touch
// the inflight registry — that is independent of LRU entries so eviction
// (which happens here) cannot bypass the spawn-bound.
func (c *workingStateCache) store(key workingStateCacheKey, state workingState) {
	if c == nil {
		return
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if el, ok := c.items[key]; ok {
		entry := el.Value.(*workingStateCacheEntry)
		entry.state = state
		entry.expiresAt = c.now().Add(c.ttl)
		c.order.MoveToFront(el)
		return
	}
	entry := &workingStateCacheEntry{
		key:       key,
		state:     state,
		expiresAt: c.now().Add(c.ttl),
	}
	el := c.order.PushFront(entry)
	c.items[key] = el
	for c.order.Len() > c.cap {
		oldest := c.order.Back()
		if oldest == nil {
			break
		}
		evicted := oldest.Value.(*workingStateCacheEntry)
		c.order.Remove(oldest)
		delete(c.items, evicted.key)
	}
}

// invalidate removes one cached state for key. A no-op when c is nil or key is
// absent. Test-only; production callers go through getOrRefresh and rely on
// TTL.
func (c *workingStateCache) invalidate(key workingStateCacheKey) {
	if c == nil {
		return
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if el, ok := c.items[key]; ok {
		c.order.Remove(el)
		delete(c.items, key)
	}
}

// len returns the current entry count. Test-only.
func (c *workingStateCache) len() int {
	if c == nil {
		return 0
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.order.Len()
}

// getOrRefresh is the single entry point used by ReviewFingerprint.
//
// Fresh hit (within TTL): return (state, fresh=true) immediately.
//
// Stale hit (entry present but TTL expired): return the stale state with
// fresh=false immediately, and kick off a deduped async refresh so the next
// caller sees fresh data. The fingerprint path is non-blocking — the frontend
// re-fetches the snapshot when tokens change, so one cycle of stale working
// token is acceptable.
//
// Cold miss (no entry): synchronously load via singleflight so concurrent
// callers (e.g. two review contexts in the same repo) share one scan. Returns
// (state, fresh=true) when the load completes.
//
// Errors from a cold-miss load are returned to the caller and are NOT cached.
// Errors from an async refresh are swallowed — the next caller will retry.
//
// Both cold-miss and async refresh return workingStateLookup from the
// singleflight closure. This is intentional: the same singleflight key may be
// shared across async and cold paths (e.g. async spawn → LRU evicts entry →
// cold miss piles onto the in-flight async load), and the cold path's
// `value.(workingStateLookup)` type assertion must not panic when the leader
// was an async goroutine. Unified result type guarantees the assertion holds
// regardless of which caller won the flight.
//
// Panics from load() are converted to typed errors inside safeLoad (which
// runs INSIDE the singleflight closure), so they reach cold-miss callers as
// ordinary errors and are swallowed by the async path. singleflight.Do would
// otherwise re-panic the loader's panic to all waiters — including a cold-miss
// caller that joined the flight after an async goroutine started it. The
// safeLoad wrapper closes that hole for both paths simultaneously because
// both share the same closure.
func (c *workingStateCache) getOrRefresh(key workingStateCacheKey, load func() (workingState, error)) (workingStateLookup, error) {
	if c == nil {
		state, err := safeLoadWorkingState(load)
		if err != nil {
			return workingStateLookup{}, err
		}
		state.loadedAt = time.Now().UTC()
		return workingStateLookup{state: state, fresh: true}, nil
	}

	if state, fresh, ok := c.lookup(key); ok {
		if fresh {
			return workingStateLookup{state: state, fresh: true}, nil
		}
		// Stale: kick async refresh, return stale immediately.
		c.trySpawnAsyncRefresh(key, load)
		return workingStateLookup{state: state, fresh: false}, nil
	}

	// Cold miss: synchronous singleflight load.
	value, err, _ := c.flights.Do(string(key), func() (any, error) {
		// Re-check after acquiring the flight — another caller may have just
		// stored a fresh entry. Return the FULL lookup (state + fresh=true)
		// here, not a zero-value workingStateLookup{}: a cold-miss caller may
		// join this same flight after a racing async refresh stored and then
		// had its entry LRU-evicted, and singleflight.Do would hand them
		// whatever we return. Returning a zero-value lookup would silently
		// give that cold-miss caller an empty file list with fresh=false and
		// no error. Returning the actual cached state closes the hole.
		if state, fresh, ok := c.lookup(key); ok && fresh {
			return workingStateLookup{state: state, fresh: true}, nil
		}
		state, err := safeLoadWorkingState(load)
		if err != nil {
			return workingStateLookup{}, err
		}
		state.loadedAt = c.now()
		c.store(key, state)
		return workingStateLookup{state: state, fresh: true}, nil
	})
	if err != nil {
		return workingStateLookup{}, err
	}
	return value.(workingStateLookup), nil
}

// trySpawnAsyncRefresh attempts to start a deduped background refresh for key.
// It performs three steps atomically under mu:
//  1. Check the per-key inflight registry; bail if a refresh is already
//     running for this key.
//  2. Reserve a slot in the global refresh semaphore; bail if the semaphore
//     is full (the next stale caller will retry on the next poll cycle).
//  3. Register the key in the inflight registry.
//
// Only after all three succeed does it spawn the goroutine. Holding mu across
// the semaphore grab guarantees the registry and semaphore cannot diverge —
// if it were split, a failed semaphore grab would leave the registry marked
// inflight and block all future spawns for that key.
//
// The non-blocking channel send means mu is held only briefly, never across
// a goroutine boundary.
func (c *workingStateCache) trySpawnAsyncRefresh(key workingStateCacheKey, load func() (workingState, error)) {
	if c == nil {
		return
	}
	c.mu.Lock()
	if _, inflight := c.inflight[key]; inflight {
		c.mu.Unlock()
		return
	}
	select {
	case c.refreshSem <- struct{}{}:
		c.inflight[key] = struct{}{}
		c.mu.Unlock()
		go c.runAsyncRefresh(key, load)
	default:
		c.mu.Unlock()
	}
}

// runAsyncRefresh is the goroutine body for a background refresh. It joins the
// shared singleflight (so a concurrent cold-miss caller shares the load) and
// stores the result on success. The defer block unconditionally releases the
// semaphore slot and clears the inflight registry. The load itself runs under
// safeLoadWorkingState (inside the singleflight closure), which converts any
// panic into a typed error — so the async goroutine never propagates a panic
// to singleflight waiters, and the cold-miss caller joining the same flight
// receives an ordinary error instead of a re-panic.
func (c *workingStateCache) runAsyncRefresh(key workingStateCacheKey, load func() (workingState, error)) {
	defer func() {
		if r := recover(); r != nil {
			// Paranoia: safeLoadWorkingState should already have recovered the
			// load panic inside the singleflight closure. If we ever get here
			// the recovered value is NOT logged and the cache key is NOT
			// logged either: panic payloads may carry filesystem paths or
			// file content, and the canonical key IS a filesystem path. Only
			// a fixed category string hits operator logs.
			slog.Error("AgentTeam: review fingerprint working state async refresh panicked outside safeLoad")
		}
		c.mu.Lock()
		delete(c.inflight, key)
		c.mu.Unlock()
		<-c.refreshSem
	}()

	_, _, _ = c.flights.Do(string(key), func() (any, error) {
		// Re-check after acquiring the flight: a racing cold-miss or earlier
		// refresh may have just stored a fresh entry. MUST return the FULL
		// lookup (state + fresh=true), not a zero-value workingStateLookup{}:
		// between this fresh-recheck and the flight cleaning up, the entry
		// could be LRU-evicted; a cold-miss caller joining this same flight
		// would then receive whatever we return here, and a zero-value lookup
		// (empty files, fresh=false, no error) would silently break them.
		// Returning the actual cached state on every success exit unifies the
		// shared-flight contract.
		if state, fresh, ok := c.lookup(key); ok && fresh {
			if c.onAsyncRecheckHit != nil {
				c.onAsyncRecheckHit(key, state)
			}
			return workingStateLookup{state: state, fresh: true}, nil
		}
		state, err := safeLoadWorkingState(load)
		if err != nil {
			// The key (canonical repo root) is intentionally NOT logged
			// here: a typed workingStateLoadPanic flows through this same
			// branch, and associating the error with the key in operator
			// logs would publish the repo root — the exact path-leak the
			// panic discipline was designed to prevent. The error itself
			// carries fixed text only, so logging err is safe.
			slog.Debug("AgentTeam: review fingerprint working state async refresh failed",
				"err", err,
			)
			return workingStateLookup{}, err
		}
		state.loadedAt = c.now()
		c.store(key, state)
		return workingStateLookup{state: state, fresh: true}, nil
	})
}

// safeLoadWorkingState wraps load with defer recover, converting any panic
// into a workingStateLoadPanic error. It MUST run inside the singleflight
// closure so that singleflight.Do sees an ordinary error return rather than
// re-panicking the loader's panic to all flight waiters (including cold-miss
// callers that joined an in-flight async refresh).
//
// The recovered panic value AND the cache key are intentionally discarded:
// panic payloads may carry filesystem paths or file content reported by the
// underlying scanner, and the cache key IS the canonical repo root — another
// filesystem path. Neither value is retained on the returned error, and
// neither is logged anywhere in this package. The returned error is a pure
// sentinel: Error() returns fixed text, and the struct carries no fields.
func safeLoadWorkingState(load func() (workingState, error)) (state workingState, err error) {
	defer func() {
		if r := recover(); r != nil {
			_ = r
			err = &workingStateLoadPanic{}
			state = workingState{}
		}
	}()
	return load()
}

// workingStateLoadPanic is the typed sentinel error returned by
// safeLoadWorkingState when the underlying loader panicked. The struct is
// deliberately field-less: the recovered panic value is discarded (it may
// carry file content or paths reported by the scanner), and the cache key
// (canonical repo root) is also NOT retained — both are filesystem paths the
// caller did not expect to publish, and earlier revisions that retained the
// key for "in-package diagnostics" ended up leaking it via slog calls.
// Error() returns fixed text so the error is safe to cross any boundary.
type workingStateLoadPanic struct{}

func (e *workingStateLoadPanic) Error() string {
	return "working state load panicked"
}
