package mcp

import (
	"container/list"
	"context"
	"fmt"
	"net/url"
	"path/filepath"
	"regexp"
	"strings"
	"sync"

	"golang.org/x/sync/singleflight"

	"github.com/townsendmerino/ken/internal/search"
	"github.com/townsendmerino/ken/internal/structural"
)

// DefaultCacheSize is the LRU bound when KEN_MCP_CACHE_SIZE is unset.
const DefaultCacheSize = 16

// RepoBundle is the per-repo data the cache holds: the retrieval-side
// WatchedIndex (BM25 + dense + reranker) plus the Stage-8 structural
// index over the same corpus. Structural may be nil when the
// structural build was skipped or failed — tool handlers must
// defend against nil so a structural-tool call on a corpus the
// extractor doesn't support (or that failed to parse) degrades to
// a clean error rather than panicking.
//
// Lifecycle: the cache owns both indices. Index.Close() is called
// on eviction to stop the watcher goroutine; Structural needs no
// teardown (it's plain maps that GC).
type RepoBundle struct {
	Index      *search.WatchedIndex
	Structural *structural.Index
}

// Builder constructs the per-repo state for an already-normalized
// source identifier (either a canonical http(s) URL or an absolute
// filesystem path). The returned cleanup is called when the entry
// is evicted from the cache — used to rm -rf temp clone dirs;
// pass nil for local-path entries.
//
// As of v0.3, the returned *search.WatchedIndex wraps the index plus
// a file-watcher goroutine. The cache calls (*WatchedIndex).Close()
// on eviction (and on Cache.Close()) to stop the watcher before
// invoking the user-supplied cleanup; without this the goroutine
// outlives the cache entry and the temp clone dir gets rm-rf'd while
// the watcher holds inotify fds pointing into it.
//
// As of Stage 8 the Builder also returns a *structural.Index built
// eagerly over the same corpus directory. Eager-build was the
// planning-instance steer: the structural build's wall is in the
// noise next to the embedding pass the WatchedIndex already pays
// for, and the resulting "ix.Structural() returns immediately"
// property avoids lazy-build/singleflight synchronization for the
// Track 2 tools.
type Builder func(ctx context.Context, source string) (*RepoBundle, func(), error)

type cacheEntry struct {
	key     string
	bundle  *RepoBundle
	cleanup func()
}

// Cache is the per-process repo→Index cache that backs the MCP server.
// Concurrent uncached requests for the same key dedupe via singleflight,
// and entries are LRU-evicted at the configured bound.
type Cache struct {
	mu     sync.Mutex
	max    int
	ll     *list.List // front = most recently used
	items  map[string]*list.Element
	build  Builder
	sf     singleflight.Group
	closed bool // M8: set under mu by Close(); checked by builders to avoid use-after-close
}

// NewCache creates a cache bound to max entries (≤0 ⇒ DefaultCacheSize).
//
// Stability: 1.0-stable. The signature + [Cache]'s public method set
// (Get / GetBundle / Len / Capacity / Close) are committed across
// 1.0+ minors. The [Builder] callback shape is also stable.
func NewCache(max int, build Builder) *Cache {
	if max <= 0 {
		max = DefaultCacheSize
	}
	return &Cache{max: max, ll: list.New(), items: map[string]*list.Element{}, build: build}
}

// Len is the number of cached entries (used by tests).
func (c *Cache) Len() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return len(c.items)
}

// Capacity returns the LRU bound. Used by the `status` MCP tool to
// surface the cache's configured size; pairs with Len for the
// in-use / capacity display.
func (c *Cache) Capacity() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.max
}

// Purge evicts every cached entry — stopping each watcher (wix.Close())
// and running its cleanup (rm -rf for temp clones) — but, unlike Close,
// leaves the cache OPEN so subsequent Get() calls rebuild on demand.
//
// Used by ken-mcp's auto-fetch upgrade path: once the embedding model
// lands in the background, purging forces the next Get to rebuild each
// repo's index WITH embeddings (hybrid) instead of continuing to serve
// the bm25 index built while the model was absent (search reads the
// index's own mode per query, so a rebuilt hybrid index upgrades search
// automatically). In-flight queries that already hold a bundle keep
// reading their consistent snapshot; only new Get calls rebuild — same
// safety profile as LRU eviction.
func (c *Cache) Purge() {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.closed {
		return
	}
	for e := c.ll.Front(); e != nil; e = e.Next() {
		ent := e.Value.(*cacheEntry)
		_ = ent.bundle.Index.Close()
		if ent.cleanup != nil {
			ent.cleanup()
		}
	}
	c.items = map[string]*list.Element{}
	c.ll.Init()
}

// scpishURL catches `user@host:path` SCP-form git URLs (semble's MCP
// rejects these; only http(s) is allowed via the MCP boundary).
var scpishURL = regexp.MustCompile(`^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:.+`)

// NormalizeKey canonicalizes a user-supplied repo string into a cache
// key. https/http URLs keep their scheme but get lowercased host + a
// trailing ".git" and "/" stripped; other URL-shaped inputs (anything
// containing "://" or matching SCP-form `user@host:path`) are
// rejected with an error, matching semble/mcp.py's http(s)-only
// guard. Inputs with no `://` are treated as local paths and
// resolved to an absolute path (existence is not checked here;
// defer to the Builder).
//
// L1 hardening: previously this was an allow-list-then-default-to-
// local-path pattern, which meant `file:///etc`, `ftp://host/`, or
// any other unknown scheme silently degraded to a local-path resolve
// (producing a junky absolute path that confused the Builder error).
// The scheme allow-list is now the security boundary: anything URL-
// shaped that isn't https/http is rejected with a typed error.
//
// Stability: best-effort (NOT part of the 1.0 hard-committed
// surface). External consumers writing custom Cache Builders can
// use it; semantics may evolve if the source-key scheme grows
// (e.g. SSH URLs, registry-shaped paths).
func NormalizeKey(source string) (string, bool, error) {
	src := strings.TrimSpace(source)
	if src == "" {
		return "", false, fmt.Errorf("repo: empty source")
	}
	lower := strings.ToLower(src)
	switch {
	case strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "http://"):
		u, err := url.Parse(src)
		if err != nil {
			return "", true, fmt.Errorf("repo: parse %q: %w", src, err)
		}
		u.Host = strings.ToLower(u.Host)
		p := strings.TrimSuffix(u.Path, "/")
		p = strings.TrimSuffix(p, ".git")
		u.Path = p
		u.Fragment, u.RawQuery = "", ""
		return u.String(), true, nil
	case strings.Contains(src, "://"):
		// Any URL-shaped input with a non-http(s) scheme — ssh://,
		// git://, git+ssh://, file://, ftp://, anything else — is
		// rejected. Tighter than the prior named-scheme allow-list:
		// unknown schemes no longer fall through to filepath.Abs.
		return "", true, fmt.Errorf("repo: only https://, http://, or local paths are accepted (got %q)", src)
	case scpishURL.MatchString(src):
		return "", true, fmt.Errorf("repo: SCP-form git URLs are not accepted (got %q); use https://", src)
	default:
		abs, err := filepath.Abs(src)
		if err != nil {
			return "", false, fmt.Errorf("repo: resolve %q: %w", src, err)
		}
		return abs, false, nil
	}
}

// Get returns a cached WatchedIndex for source, building it once on
// first access. Concurrent first-access calls for the same key share
// a single build via singleflight. The returned *WatchedIndex is
// shared across all callers and across subsequent Get calls until
// evicted; callers MUST NOT call wix.Close() themselves — the cache
// owns the lifecycle.
//
// Back-compat: returns just the WatchedIndex from the cached bundle,
// matching the pre-Stage-8 signature. Tool handlers that also need
// the structural index call GetBundle instead.
func (c *Cache) Get(ctx context.Context, source string) (*search.WatchedIndex, error) {
	b, err := c.GetBundle(ctx, source)
	if err != nil {
		return nil, err
	}
	return b.Index, nil
}

// GetBundle returns the cached RepoBundle (WatchedIndex + structural
// index) for source, building both eagerly on first access. Used by
// Track 2 tool handlers that need the structural index alongside the
// retrieval one.
//
// Cache + lifecycle semantics are identical to Get — singleflight on
// first build, LRU eviction, c.closed observed during in-flight
// builds. Bundle.Structural may be nil if the structural build
// produced no entries (unsupported corpus language); handlers must
// defend against that.
func (c *Cache) GetBundle(ctx context.Context, source string) (*RepoBundle, error) {
	key, _, err := NormalizeKey(source)
	if err != nil {
		return nil, err
	}

	c.mu.Lock()
	if c.closed {
		c.mu.Unlock()
		return nil, fmt.Errorf("repo: cache is closed")
	}
	if e, ok := c.items[key]; ok {
		c.ll.MoveToFront(e)
		b := e.Value.(*cacheEntry).bundle
		c.mu.Unlock()
		return b, nil
	}
	c.mu.Unlock()

	v, err, _ := c.sf.Do(key, func() (any, error) {
		bundle, cleanup, err := c.build(ctx, key)
		if err != nil {
			return nil, err
		}
		c.mu.Lock()
		defer c.mu.Unlock()
		// M8: a Close() that fired while build was in flight has
		// already drained the map. Don't repopulate it — the just-
		// built watcher + cleanup get reaped right here so they
		// don't outlive the cache.
		if c.closed {
			_ = bundle.Index.Close()
			if cleanup != nil {
				cleanup()
			}
			return nil, fmt.Errorf("repo: cache is closed")
		}
		// Re-check in case another sf turn populated it (cheap; sf
		// coalesces same-key calls but being defensive is harmless).
		// If we lost the race, close the just-built watcher AND run
		// its cleanup — the cache already has a usable entry.
		if e, ok := c.items[key]; ok {
			_ = bundle.Index.Close()
			if cleanup != nil {
				cleanup()
			}
			c.ll.MoveToFront(e)
			return e.Value.(*cacheEntry).bundle, nil
		}
		for len(c.items) >= c.max {
			tail := c.ll.Back()
			if tail == nil {
				break
			}
			ev := c.ll.Remove(tail).(*cacheEntry)
			delete(c.items, ev.key)
			_ = ev.bundle.Index.Close()
			if ev.cleanup != nil {
				ev.cleanup()
			}
		}
		ent := &cacheEntry{key: key, bundle: bundle, cleanup: cleanup}
		c.items[key] = c.ll.PushFront(ent)
		return bundle, nil
	})
	if err != nil {
		return nil, err
	}
	return v.(*RepoBundle), nil
}

// Close releases every cached entry. Stops the watcher goroutine for
// each (wix.Close()) and runs the user-supplied cleanup (rm -rf for
// temp clones). Safe to call multiple times.
//
// M8: sets c.closed under c.mu before draining the map. Concurrent
// Get() calls whose singleflight build is in flight observe the
// flag once they re-acquire the lock and reap the just-built
// watcher rather than repopulating the now-cleared map. This
// avoids a use-after-close where a stale entry survives past
// Close() and outlives the cache's intent.
func (c *Cache) Close() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.closed = true
	for e := c.ll.Front(); e != nil; e = e.Next() {
		ent := e.Value.(*cacheEntry)
		_ = ent.bundle.Index.Close()
		if ent.cleanup != nil {
			ent.cleanup()
		}
	}
	c.items = map[string]*list.Element{}
	c.ll.Init()
}
