package mcp

import (
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"slices"
	"sort"
	"strings"
	"sync"
	"time"

	"github.com/zzet/gortex/internal/graph"
	"github.com/zzet/gortex/internal/persistence"
)

// memoryManager owns the cross-session development-memory store.
// It mirrors notesManager structurally (same persistence + filter
// shape) but its entries have no SessionID — every memory is
// workspace-scoped and durable across sessions, so the store
// compounds the longer a team uses Gortex.
//
// Memories live alongside the graph as a separate, persistent
// side-store written into the same per-repo cache directory as
// notes / feedback / combo / frecency. Empty dir yields an
// in-memory-only manager (test fixtures, single-shot CLI calls).
type memoryManager struct {
	mu    sync.Mutex
	store persistence.MemoryStore
	dir   string
}

// newMemoryManager constructs a manager, lazily loading any
// existing memories from disk. Empty cacheDir/repoPath yields a
// no-disk manager.
func newMemoryManager(cacheDir, repoPath string) *memoryManager {
	if cacheDir == "" || repoPath == "" {
		return &memoryManager{}
	}
	dir := persistence.MemoriesDir(cacheDir, repoPath)
	mm := &memoryManager{dir: dir}

	loaded, err := persistence.LoadMemories(dir)
	if err == nil && loaded != nil {
		mm.store = *loaded
	}
	return mm
}

// MemoryQueryFilter constrains a Query call. Zero-value fields
// disable the corresponding filter; tag matching is exact
// (case-insensitive).
type MemoryQueryFilter struct {
	SymbolID          string
	FilePath          string
	Tag               string
	Kind              string
	Source            string
	AuthorAgent       string
	TextSearch        string // case-insensitive substring against Body / Title
	Since             time.Time
	WorkspaceID       string
	ProjectID         string
	Pinned            *bool
	MinImportance     int  // 0 = no filter; 1..5 = lower bound
	IncludeSuperseded bool // false (default) hides entries with SupersededBy != ""
	Limit             int
}

// MemoryPatch is the update payload — every pointer field is
// optional ("leave alone" when nil), every slice field replaces
// the prior value when non-nil. AddLinks is additive.
type MemoryPatch struct {
	Body         *string
	Title        *string
	Kind         *string
	Source       *string
	Confidence   *float32
	Importance   *int
	Pinned       *bool
	SupersededBy *string
	Tags         []string
	SymbolIDs    []string
	FilePaths    []string
	AddLinks     []string
}

// Save persists a new entry, returning the generated ID. Defaults
// for missing fields: Confidence=1.0, Importance=3, Kind="reference",
// Source="manual".
func (mm *memoryManager) Save(entry persistence.MemoryEntry) (string, error) {
	mm.mu.Lock()
	defer mm.mu.Unlock()

	if entry.ID == "" {
		entry.ID = newMemoryID()
	}
	now := time.Now().UTC()
	if entry.Timestamp.IsZero() {
		entry.Timestamp = now
	}
	entry.UpdatedAt = now

	entry.AutoLinks = dedupeStrings(entry.AutoLinks)
	entry.SymbolIDs = dedupeStrings(entry.SymbolIDs)
	entry.FilePaths = dedupeStrings(entry.FilePaths)
	entry.Tags = dedupeStrings(normaliseTags(entry.Tags))

	if entry.Confidence == 0 {
		entry.Confidence = 1.0
	}
	if entry.Importance == 0 {
		entry.Importance = 3
	}
	if entry.Kind == "" {
		entry.Kind = "reference"
	}
	if entry.Source == "" {
		entry.Source = "manual"
	}

	mm.store.Entries = append(mm.store.Entries, entry)
	if err := mm.flushLocked(); err != nil {
		return entry.ID, err
	}
	return entry.ID, nil
}

// Update mutates an existing memory by ID. Pass nil for a field
// you don't want to change.
func (mm *memoryManager) Update(id string, patch MemoryPatch) (persistence.MemoryEntry, error) {
	mm.mu.Lock()
	defer mm.mu.Unlock()

	idx := mm.findLocked(id)
	if idx < 0 {
		return persistence.MemoryEntry{}, fmt.Errorf("memory %q not found", id)
	}
	e := mm.store.Entries[idx]
	if patch.Body != nil {
		e.Body = *patch.Body
	}
	if patch.Title != nil {
		e.Title = *patch.Title
	}
	if patch.Kind != nil {
		e.Kind = *patch.Kind
	}
	if patch.Source != nil {
		e.Source = *patch.Source
	}
	if patch.Confidence != nil {
		e.Confidence = *patch.Confidence
	}
	if patch.Importance != nil {
		e.Importance = *patch.Importance
	}
	if patch.Pinned != nil {
		e.Pinned = *patch.Pinned
	}
	if patch.SupersededBy != nil {
		e.SupersededBy = *patch.SupersededBy
	}
	if patch.Tags != nil {
		e.Tags = dedupeStrings(normaliseTags(patch.Tags))
	}
	if patch.SymbolIDs != nil {
		e.SymbolIDs = dedupeStrings(patch.SymbolIDs)
	}
	if patch.FilePaths != nil {
		e.FilePaths = dedupeStrings(patch.FilePaths)
	}
	if len(patch.AddLinks) > 0 {
		e.AutoLinks = dedupeStrings(append(append([]string{}, e.AutoLinks...), patch.AddLinks...))
	}
	e.UpdatedAt = time.Now().UTC()
	mm.store.Entries[idx] = e
	if err := mm.flushLocked(); err != nil {
		return e, err
	}
	return e, nil
}

// Delete removes a memory by ID. Idempotent.
func (mm *memoryManager) Delete(id string) error {
	mm.mu.Lock()
	defer mm.mu.Unlock()

	idx := mm.findLocked(id)
	if idx < 0 {
		return nil
	}
	mm.store.Entries = append(mm.store.Entries[:idx], mm.store.Entries[idx+1:]...)
	return mm.flushLocked()
}

// Get returns a single memory by ID, or (zero, false) when not found.
func (mm *memoryManager) Get(id string) (persistence.MemoryEntry, bool) {
	mm.mu.Lock()
	defer mm.mu.Unlock()
	idx := mm.findLocked(id)
	if idx < 0 {
		return persistence.MemoryEntry{}, false
	}
	return mm.store.Entries[idx], true
}

// MarkAccessed increments AccessCount and stamps LastAccessed on
// the given IDs. Best-effort: a flush failure is non-fatal because
// access stats are advisory.
func (mm *memoryManager) MarkAccessed(ids []string) {
	if len(ids) == 0 {
		return
	}
	mm.mu.Lock()
	defer mm.mu.Unlock()
	now := time.Now().UTC()
	touched := false
	for _, id := range ids {
		idx := mm.findLocked(id)
		if idx < 0 {
			continue
		}
		mm.store.Entries[idx].AccessCount++
		mm.store.Entries[idx].LastAccessed = now
		touched = true
	}
	if touched {
		_ = mm.flushLocked()
	}
}

// Query returns memories matching every set filter. Results sort
// pinned-first, then by importance DESC, then by UpdatedAt DESC.
// Limit caps the slice after filtering & sorting.
func (mm *memoryManager) Query(f MemoryQueryFilter) []persistence.MemoryEntry {
	mm.mu.Lock()
	defer mm.mu.Unlock()

	textNeedle := strings.ToLower(f.TextSearch)
	tagNeedle := strings.ToLower(f.Tag)

	var out []persistence.MemoryEntry
	for _, e := range mm.store.Entries {
		if !f.IncludeSuperseded && e.SupersededBy != "" {
			continue
		}
		if f.WorkspaceID != "" && e.WorkspaceID != f.WorkspaceID {
			continue
		}
		if f.ProjectID != "" && e.ProjectID != f.ProjectID {
			continue
		}
		if f.Kind != "" && !strings.EqualFold(e.Kind, f.Kind) {
			continue
		}
		if f.Source != "" && !strings.EqualFold(e.Source, f.Source) {
			continue
		}
		if f.AuthorAgent != "" && !strings.EqualFold(e.AuthorAgent, f.AuthorAgent) {
			continue
		}
		if f.SymbolID != "" && !memoryReferencesSymbol(e, f.SymbolID) {
			continue
		}
		if f.FilePath != "" && !memoryReferencesFile(e, f.FilePath) {
			continue
		}
		if tagNeedle != "" && !hasTag(e.Tags, tagNeedle) {
			continue
		}
		if textNeedle != "" &&
			!strings.Contains(strings.ToLower(e.Body), textNeedle) &&
			!strings.Contains(strings.ToLower(e.Title), textNeedle) {
			continue
		}
		if !f.Since.IsZero() && e.UpdatedAt.Before(f.Since) {
			continue
		}
		if f.Pinned != nil && e.Pinned != *f.Pinned {
			continue
		}
		if f.MinImportance > 0 && e.Importance < f.MinImportance {
			continue
		}
		out = append(out, e)
	}

	sort.Slice(out, func(i, j int) bool {
		if out[i].Pinned != out[j].Pinned {
			return out[i].Pinned
		}
		if out[i].Importance != out[j].Importance {
			return out[i].Importance > out[j].Importance
		}
		return out[i].UpdatedAt.After(out[j].UpdatedAt)
	})
	if f.Limit > 0 && len(out) > f.Limit {
		out = out[:f.Limit]
	}
	return out
}

// HasData reports whether the store holds at least one memory.
func (mm *memoryManager) HasData() bool {
	mm.mu.Lock()
	defer mm.mu.Unlock()
	return len(mm.store.Entries) > 0
}

// Count returns the total number of stored memories.
func (mm *memoryManager) Count() int {
	mm.mu.Lock()
	defer mm.mu.Unlock()
	return len(mm.store.Entries)
}

func (mm *memoryManager) findLocked(id string) int {
	for i := range mm.store.Entries {
		if mm.store.Entries[i].ID == id {
			return i
		}
	}
	return -1
}

func (mm *memoryManager) flushLocked() error {
	if mm.dir == "" {
		return nil
	}
	return persistence.SaveMemories(mm.dir, &mm.store)
}

// ---------------------------------------------------------------------------
// surface_memories — proactive retrieval given a working set
// ---------------------------------------------------------------------------

// SurfaceOptions tunes Surface. Defaults: Limit=10, ExcerptCap=320,
// MinScore=0, MarkAccessed=true, IncludeSuperseded=false.
type SurfaceOptions struct {
	Task              string
	SymbolIDs         []string
	FilePaths         []string
	WorkspaceID       string
	ProjectID         string
	Limit             int
	ExcerptCap        int
	MinScore          float32
	IncludeSuperseded bool
	MarkAccessed      bool
}

// surfaceResult is the structured digest returned by Surface. The
// shape is stable across JSON / TOON / GCX wire formats.
type surfaceResult struct {
	Task      string       `json:"task,omitempty"`
	Total     int          `json:"total"`
	Memories  []surfaceHit `json:"memories,omitempty"`
	Anchors   []string     `json:"anchors,omitempty"`
	Truncated bool         `json:"truncated,omitempty"`
}

type surfaceHit struct {
	ID           string    `json:"id"`
	Title        string    `json:"title,omitempty"`
	Body         string    `json:"body"`
	Kind         string    `json:"kind,omitempty"`
	Source       string    `json:"source,omitempty"`
	Tags         []string  `json:"tags,omitempty"`
	SymbolIDs    []string  `json:"symbol_ids,omitempty"`
	FilePaths    []string  `json:"file_paths,omitempty"`
	Importance   int       `json:"importance,omitempty"`
	Confidence   float32   `json:"confidence,omitempty"`
	Pinned       bool      `json:"pinned,omitempty"`
	Score        float32   `json:"score"`
	UpdatedAt    time.Time `json:"updated_at"`
	MatchReasons []string  `json:"match_reasons,omitempty"`
}

// Surface returns memories ranked by relevance to a set of anchor
// symbols / files plus an optional task description.
//
// Scoring (deterministic, no LLM):
//
//	+3.0  per anchor symbol overlap with SymbolIDs
//	+1.5  per anchor symbol overlap with AutoLinks
//	+1.5  per anchor file overlap with FilePaths
//	+1.0  per task-keyword hit in body or title
//	+0.5  × importance
//	+0.4  if pinned
//	+0.3  if updated within the last 30 days
//	+0.2  if AccessCount > 0 (already proven useful)
//	*= confidence (when 0 < confidence < 1)
//
// Containing files of anchor symbols are added to the file set
// automatically — a memory anchored to the file usually applies
// when you're editing any symbol inside it.
//
// When at least one anchor / keyword is provided, a memory must
// match at least one reason (or be pinned) to be returned. When
// no anchors are provided, every memory is returned, ranked by
// importance + recency.
func (mm *memoryManager) Surface(opts SurfaceOptions, resolveNode func(string) *graph.Node) surfaceResult {
	if opts.Limit <= 0 {
		opts.Limit = 10
	}

	res := surfaceResult{Task: opts.Task}

	symbolSet := make(map[string]struct{}, len(opts.SymbolIDs))
	for _, s := range opts.SymbolIDs {
		if s != "" {
			symbolSet[s] = struct{}{}
		}
	}
	fileSet := make(map[string]struct{}, len(opts.FilePaths))
	for _, f := range opts.FilePaths {
		if f != "" {
			fileSet[f] = struct{}{}
		}
	}

	// Promote anchor symbols → their containing files (free signal).
	if resolveNode != nil {
		for sym := range symbolSet {
			if node := resolveNode(sym); node != nil && node.FilePath != "" {
				fileSet[node.FilePath] = struct{}{}
			}
		}
	}

	for s := range symbolSet {
		res.Anchors = append(res.Anchors, s)
	}
	sort.Strings(res.Anchors)

	keywords := tokeniseIdentifiers(opts.Task, 3)
	keywordSet := make(map[string]struct{}, len(keywords))
	for _, k := range keywords {
		keywordSet[strings.ToLower(k)] = struct{}{}
	}

	hasAnchor := len(symbolSet)+len(fileSet)+len(keywordSet) > 0

	mm.mu.Lock()
	candidates := make([]persistence.MemoryEntry, 0, len(mm.store.Entries))
	for _, e := range mm.store.Entries {
		if e.SupersededBy != "" && !opts.IncludeSuperseded {
			continue
		}
		if opts.WorkspaceID != "" && e.WorkspaceID != opts.WorkspaceID {
			continue
		}
		if opts.ProjectID != "" && e.ProjectID != opts.ProjectID {
			continue
		}
		candidates = append(candidates, e)
	}
	mm.mu.Unlock()

	now := time.Now().UTC()
	scored := make([]surfaceHit, 0, len(candidates))
	for _, e := range candidates {
		var score float32
		var reasons []string

		for _, s := range e.SymbolIDs {
			if _, ok := symbolSet[s]; ok {
				score += 3.0
				reasons = append(reasons, "symbol:"+s)
			}
		}
		for _, s := range e.AutoLinks {
			if _, ok := symbolSet[s]; ok {
				score += 1.5
				reasons = append(reasons, "link:"+s)
			}
		}
		for _, f := range e.FilePaths {
			if _, ok := fileSet[f]; ok {
				score += 1.5
				reasons = append(reasons, "file:"+f)
			}
		}
		if len(keywordSet) > 0 {
			bodyLower := strings.ToLower(e.Body)
			titleLower := strings.ToLower(e.Title)
			for k := range keywordSet {
				if strings.Contains(bodyLower, k) || strings.Contains(titleLower, k) {
					score += 1.0
					reasons = append(reasons, "kw:"+k)
				}
			}
		}
		if e.Importance > 0 {
			score += float32(e.Importance) * 0.5
		}
		if e.Pinned {
			score += 0.4
			reasons = append(reasons, "pinned")
		}
		if !e.UpdatedAt.IsZero() && now.Sub(e.UpdatedAt) < 30*24*time.Hour {
			score += 0.3
		}
		if e.AccessCount > 0 {
			score += 0.2
		}
		if e.Confidence > 0 && e.Confidence < 1 {
			score *= e.Confidence
		}

		if score < opts.MinScore {
			continue
		}
		// With anchors/keywords: require at least one match reason
		// (pinned counts). Without anchors: return everything.
		if hasAnchor && len(reasons) == 0 {
			continue
		}

		body := e.Body
		if opts.ExcerptCap > 0 && len(body) > opts.ExcerptCap {
			body = body[:opts.ExcerptCap] + "…"
		}

		scored = append(scored, surfaceHit{
			ID:           e.ID,
			Title:        e.Title,
			Body:         body,
			Kind:         e.Kind,
			Source:       e.Source,
			Tags:         append([]string{}, e.Tags...),
			SymbolIDs:    append([]string{}, e.SymbolIDs...),
			FilePaths:    append([]string{}, e.FilePaths...),
			Importance:   e.Importance,
			Confidence:   e.Confidence,
			Pinned:       e.Pinned,
			Score:        score,
			UpdatedAt:    e.UpdatedAt,
			MatchReasons: reasons,
		})
	}

	sort.Slice(scored, func(i, j int) bool {
		if scored[i].Score != scored[j].Score {
			return scored[i].Score > scored[j].Score
		}
		if scored[i].Pinned != scored[j].Pinned {
			return scored[i].Pinned
		}
		return scored[i].UpdatedAt.After(scored[j].UpdatedAt)
	})

	res.Total = len(scored)
	if len(scored) > opts.Limit {
		scored = scored[:opts.Limit]
		res.Truncated = true
	}
	res.Memories = scored

	if opts.MarkAccessed && len(res.Memories) > 0 {
		ids := make([]string, 0, len(res.Memories))
		for _, h := range res.Memories {
			ids = append(ids, h.ID)
		}
		mm.MarkAccessed(ids)
	}

	return res
}

// memoryReferencesSymbol reports whether the memory is attached to
// the given symbol either directly (SymbolIDs) or via auto-link.
func memoryReferencesSymbol(e persistence.MemoryEntry, sym string) bool {
	if slices.Contains(e.SymbolIDs, sym) {
		return true
	}
	return slices.Contains(e.AutoLinks, sym)
}

// memoryReferencesFile reports whether the memory is attached to
// the given file via FilePaths.
func memoryReferencesFile(e persistence.MemoryEntry, path string) bool {
	return slices.Contains(e.FilePaths, path)
}

// newMemoryID returns an 8-byte hex token. Crypto-strength because
// the IDs travel through MCP responses and can be referenced by
// other tool calls — predictability would let one session guess
// another's memory IDs on a shared cache.
func newMemoryID() string {
	var b [8]byte
	if _, err := rand.Read(b[:]); err != nil {
		return fmt.Sprintf("mem%016x", time.Now().UnixNano())
	}
	return "mem" + hex.EncodeToString(b[:])
}
