package memory

import (
	"context"
	"fmt"
	"os"
	"sort"
	"strings"

	"go.uber.org/zap"
)

// Manager is the central orchestrator for the memory system.
// It owns all sub-stores and provides the same API surface that
// ContextBuilder and memoryWorker currently use, plus new capabilities.
type Manager struct {
	baseDir      string
	workspaceDir string // current session's workspace directory
	logger       *zap.Logger
	config       Config

	Profile  *UserProfileStore
	Facts    *FactIndex
	Topics   *TopicTracker
	Projects *ProjectTracker
	Patterns *PatternDetector
	Daily    *DailyNoteStore

	compactor *Compactor
	retriever *RelevanceRetriever
	migration *Migration

	// HyDE Phase 3b: optional vector index. Attached lazily by the
	// caller via AttachVectorIndex so the memory package never imports
	// llm/embedding directly. nil → keyword-only retrieval (no
	// regression).
	vectors *VectorIndex
}

// NewManager creates a new memory manager. The memoryDir should be the
// base memory directory (e.g., ~/.chatcli/memory/).
func NewManager(memoryDir string, config Config, logger *zap.Logger) *Manager {
	if err := os.MkdirAll(memoryDir, 0o750); err != nil {
		logger.Warn("failed to create memory dir", zap.Error(err))
	}

	m := &Manager{
		baseDir: memoryDir,
		logger:  logger,
		config:  config,
	}

	// Initialize all sub-stores
	m.Profile = NewUserProfileStore(memoryDir, logger)
	m.Facts = NewFactIndex(memoryDir, config, logger)
	m.Topics = NewTopicTracker(memoryDir, logger)
	m.Projects = NewProjectTracker(memoryDir, logger)
	m.Patterns = NewPatternDetector(memoryDir, logger)
	m.Daily = NewDailyNoteStore(memoryDir, logger)

	m.compactor = NewCompactor(m.Facts, m.Daily, config, memoryDir, logger)
	m.retriever = NewRelevanceRetriever(m.Facts, m.Profile, m.Topics, m.Projects, m.Patterns, m.Daily, config)
	m.migration = NewMigration(memoryDir, m.Facts, logger)

	// Auto-migrate if needed
	if m.migration.NeedsMigration() {
		if err := m.migration.RunHeuristic(); err != nil {
			logger.Warn("migration failed", zap.Error(err))
		} else {
			m.compactor.RegenerateMemoryMD()
		}
	}

	return m
}

// --- Backward-compatible API (matches old MemoryStore) ---

// GetMemoryContext returns memory context for the system prompt.
// This is the backward-compatible version that dumps relevant context.
func (m *Manager) GetMemoryContext() string {
	return m.retriever.Retrieve(nil)
}

// GetRelevantContext returns memory context tailored to conversation hints.
func (m *Manager) GetRelevantContext(hints []string) string {
	return m.retriever.Retrieve(hints)
}

// GetMemoryIndex returns a COMPACT, STABLE digest of what memory knows: a
// one-line user-profile summary, the names of the top topics and active
// projects, and a fact tally by category. Unlike GetRelevantContext it is
// NOT hint-driven, so its text is stable across turns and bounded in size
// regardless of how large memory grows — the "map" half of the pull model.
// The agent reads this to learn what exists, then pulls detail on demand via
// the @memory recall tool.
//
// budget caps the returned size in characters (<=0 applies a default). The
// digest degrades gracefully: empty sections are omitted, and an entirely
// empty memory yields "".
func (m *Manager) GetMemoryIndex(budget int) string {
	if budget <= 0 {
		budget = 600
	}

	var lines []string
	if m.Profile != nil {
		if s := profileOneLine(m.Profile.Get()); s != "" {
			lines = append(lines, "Profile: "+s)
		}
	}
	if m.Projects != nil {
		var names []string
		for _, p := range m.Projects.GetActive() {
			if n := strings.TrimSpace(p.Name); n != "" {
				names = append(names, n)
			}
		}
		// Sort deterministically: GetActive orders by LastActive, whose ties
		// are not stable across calls. The digest must be byte-identical turn
		// to turn to stay cache-friendly, so fold to alphabetical order.
		sort.Strings(names)
		if len(names) > 0 {
			lines = append(lines, "Projects: "+strings.Join(capStrings(names, 6), ", "))
		}
	}
	if m.Topics != nil {
		topics := m.Topics.GetAll()
		// Rank by mention count, breaking ties alphabetically so the order is
		// deterministic regardless of map-iteration order in the tracker.
		sort.SliceStable(topics, func(i, j int) bool {
			if topics[i].Mentions != topics[j].Mentions {
				return topics[i].Mentions > topics[j].Mentions
			}
			return topics[i].Name < topics[j].Name
		})
		var names []string
		for _, t := range topics {
			if n := strings.TrimSpace(t.Name); n != "" {
				names = append(names, n)
			}
		}
		if len(names) > 0 {
			lines = append(lines, "Topics: "+strings.Join(capStrings(names, 8), ", "))
		}
	}
	if m.Facts != nil {
		if all := m.Facts.GetAll(); len(all) > 0 {
			lines = append(lines, "Facts: "+factTally(all))
		}
	}

	if len(lines) == 0 {
		return ""
	}
	out := "# Memory Index\n" + strings.Join(lines, "\n")
	if r := []rune(out); len(r) > budget {
		out = string(r[:budget])
	}
	return out
}

// profileOneLine renders the most identifying profile attributes as a single
// compact line, omitting whatever is unset.
func profileOneLine(p UserProfile) string {
	var parts []string
	name := strings.TrimSpace(p.Name)
	role := strings.TrimSpace(p.Role)
	switch {
	case name != "" && role != "":
		parts = append(parts, name+", "+role)
	case name != "":
		parts = append(parts, name)
	case role != "":
		parts = append(parts, role)
	}
	if exp := strings.TrimSpace(p.ExpertiseLevel); exp != "" {
		parts = append(parts, "("+exp+")")
	}
	if co := strings.TrimSpace(p.Company); co != "" {
		parts = append(parts, "@ "+co)
	}
	return strings.Join(parts, " ")
}

// factTally summarizes facts as "N stored (cat count, cat count, ...)" with
// categories ordered by descending count.
func factTally(facts []*Fact) string {
	counts := map[string]int{}
	var order []string
	for _, f := range facts {
		c := strings.TrimSpace(f.Category)
		if c == "" {
			c = "general"
		}
		if _, seen := counts[c]; !seen {
			order = append(order, c)
		}
		counts[c]++
	}
	sort.SliceStable(order, func(i, j int) bool {
		if counts[order[i]] != counts[order[j]] {
			return counts[order[i]] > counts[order[j]]
		}
		return order[i] < order[j] // alphabetical tie-break for determinism
	})
	parts := make([]string, 0, len(order))
	for _, c := range order {
		parts = append(parts, fmt.Sprintf("%s %d", c, counts[c]))
	}
	return fmt.Sprintf("%d stored (%s)", len(facts), strings.Join(parts, ", "))
}

// capStrings returns at most n elements of xs.
func capStrings(xs []string, n int) []string {
	if len(xs) <= n {
		return xs
	}
	return xs[:n]
}

// GetRelevantContextWithHyDE runs the full HyDE retrieval (Phase 3a +
// Phase 3b) when an augmenter and/or vector index are configured.
// Falls back to plain Retrieve(hints) when neither is provided so the
// no-regression contract holds.
//
// query is the raw user question used to seed the hypothesis. Pass
// "" to skip augmentation entirely.
func (m *Manager) GetRelevantContextWithHyDE(ctx context.Context, query string, hints []string, augmenter *HyDEAugmenter) string {
	out := m.retriever.RetrieveWithHyDE(ctx, query, hints, augmenter, m.vectors)

	// Lazy backfill: fire-and-forget embedding of any facts visible to
	// the current scorer that lack a vector. Bounded so a cold cache
	// doesn't bill more than a few cents — at Voyage's $0.05/M tokens
	// and ~50 tokens/fact average, 500 facts ≈ $0.001.
	if m.vectors != nil && m.vectors.Enabled() {
		all := m.Facts.GetAll()
		// Bound the per-retrieval embed batch so a cold cache can't bill an
		// unbounded amount on a huge memory. Config-driven (was a hard-coded
		// 500); GetAll returns highest-scoring facts first, so the cap keeps
		// the most relevant vectors warm.
		if batchMax := m.config.BackfillBatchMax; batchMax > 0 && len(all) > batchMax {
			all = all[:batchMax]
		}
		ids := make([]string, 0, len(all))
		for _, f := range all {
			ids = append(ids, f.ID)
		}
		missing := m.vectors.MissingFor(ids)
		if len(missing) > 0 {
			items := make(map[string]string, len(missing))
			for _, id := range missing {
				if f, ok := m.Facts.GetByID(id); ok {
					items[id] = f.Content
				}
			}
			// Intentionally detached from the request context: the
			// retrieve call returns in milliseconds; backfill must
			// survive the turn's ctx cancellation so embeddings
			// become available for the next retrieval.
			// context.WithoutCancel inherits values from ctx but is not
			// cancelled when ctx is, so the backfill survives the turn's
			// cancellation as required (see comment above).
			bgCtx := context.WithoutCancel(ctx)
			go func(items map[string]string) { //#nosec G118 -- detached on purpose; see comment above
				if err := m.vectors.BackfillFacts(bgCtx, items); err != nil {
					m.logger.Warn("vector backfill failed", zap.Error(err))
				}
			}(items)
		}
	}

	return out
}

// AttachVectorIndex wires a VectorIndex into the manager. Pass nil to
// detach (returns retrieval to keyword-only mode). Safe to call
// multiple times — only the most recent index is used.
func (m *Manager) AttachVectorIndex(v *VectorIndex) {
	m.vectors = v
}

// VectorIndex returns the attached vector index (may be nil).
func (m *Manager) VectorIndex() *VectorIndex {
	return m.vectors
}

// ReadLongTerm returns the rendered MEMORY.md content.
func (m *Manager) ReadLongTerm() string {
	return m.Facts.GenerateMarkdown(m.config.MaxMemoryMDSize)
}

// WriteLongTerm replaces all long-term memory with new content.
// This parses the content into facts.
func (m *Manager) WriteLongTerm(content string) error {
	// Parse content into facts
	lines := strings.Split(content, "\n")
	currentCategory := "general"

	for _, line := range lines {
		trimmed := strings.TrimSpace(line)
		if trimmed == "" {
			continue
		}
		if strings.HasPrefix(trimmed, "#") {
			currentCategory = detectCategory(trimmed)
			continue
		}
		factContent := strings.TrimPrefix(trimmed, "- ")
		factContent = strings.TrimPrefix(factContent, "* ")
		factContent = strings.TrimSpace(factContent)
		if len(factContent) >= 5 {
			m.Facts.AddFact(factContent, currentCategory, extractTags(factContent))
		}
	}

	m.compactor.RegenerateMemoryMD()
	return nil
}

// AppendLongTerm adds new content to long-term memory.
func (m *Manager) AppendLongTerm(entry string) error {
	_, err := m.appendLongTermCounted(entry)
	return err
}

// appendLongTermCounted is the workhorse behind AppendLongTerm. It parses
// an optional "[category]" prefix on each line (the extraction prompt asks
// for it) and falls back to content classification, so facts land in the
// right bucket instead of all collapsing into "general". It returns the
// number of genuinely new facts added (duplicates don't count).
func (m *Manager) appendLongTermCounted(entry string) (int, error) {
	entry = strings.TrimSpace(entry)
	if entry == "" {
		return 0, nil
	}

	added := 0
	for _, line := range strings.Split(entry, "\n") {
		trimmed := strings.TrimSpace(line)
		if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "---") {
			continue
		}
		factContent := strings.TrimPrefix(trimmed, "- ")
		factContent = strings.TrimPrefix(factContent, "* ")
		factContent = strings.TrimSpace(factContent)

		category, factContent := splitCategoryPrefix(factContent)
		if category == "" {
			category = classifyFactContent(factContent)
		}
		if len(factContent) >= 5 {
			if m.Facts.AddFactWithSource(factContent, category, extractTags(factContent), m.workspaceDir) {
				added++
			}
		}
	}

	if added > 0 {
		m.compactor.RegenerateMemoryMD()
	}
	return added, nil
}

// RememberFact stores a single fact deterministically — no LLM, no
// throttling. category may be empty (or unrecognized) to auto-classify.
// Returns true when a new fact was added (false on duplicate/empty).
// Backs the /memory remember command and the @memory tool.
func (m *Manager) RememberFact(content, category string) bool {
	content = strings.TrimSpace(content)
	if content == "" {
		return false
	}
	cat := strings.ToLower(strings.TrimSpace(category))
	if !isKnownCategory(cat) {
		cat = classifyFactContent(content)
	}
	// Deterministic, user/agent-stated facts are trusted more than the
	// background extractor's inferences.
	added := m.Facts.AddFactWithMeta(content, cat, extractTags(content), m.workspaceDir, ConfidenceUser, ProvenanceUser)
	m.compactor.RegenerateMemoryMD()
	return added
}

// UpdateProfile applies deterministic profile updates and returns whether
// anything changed. Backs /memory profile set and the @memory tool.
func (m *Manager) UpdateProfile(updates map[string]string) bool {
	return m.Profile.Update(updates)
}

// ForgetFacts removes every fact whose content matches substr
// (case-insensitive) and returns how many were removed.
func (m *Manager) ForgetFacts(substr string) int {
	removed := m.Facts.ForgetMatching(substr)
	if len(removed) > 0 {
		m.compactor.RegenerateMemoryMD()
	}
	return len(removed)
}

// WriteDailyNote delegates to the daily note store.
func (m *Manager) WriteDailyNote(entry string) error {
	return m.Daily.WriteDailyNote(entry)
}

// GetRecentDailyNotes delegates to the daily note store.
func (m *Manager) GetRecentDailyNotes(days int) []DailyNote {
	return m.Daily.GetRecentDailyNotes(days)
}

// TodayNotePath delegates to the daily note store.
func (m *Manager) TodayNotePath() string {
	return m.Daily.TodayNotePath()
}

// EnsureDirectories creates the memory directory structure.
func (m *Manager) EnsureDirectories() error {
	return os.MkdirAll(m.baseDir, 0o750)
}

// --- Enhanced API ---

// ExtractionSummary reports what a single ProcessExtraction call persisted.
// The memory worker turns a non-empty summary into a one-line terminal
// notice, so the user can see the system is actually learning instead of
// memory updating silently (the prior behavior, which made it impossible to
// tell whether anything was captured).
type ExtractionSummary struct {
	DailyWritten     bool
	FactsAdded       int
	ProfileUpdated   bool
	TopicsRecorded   int
	ProjectsUpserted int
}

// IsEmpty reports whether nothing was persisted.
func (s ExtractionSummary) IsEmpty() bool {
	return !s.DailyWritten && s.FactsAdded == 0 && !s.ProfileUpdated &&
		s.TopicsRecorded == 0 && s.ProjectsUpserted == 0
}

// ProcessExtraction processes the output from the memory extraction LLM,
// populating profile, topics, and projects. Kept with its original (void)
// signature for API compatibility; callers that need the summary use
// ProcessExtractionResult.
func (m *Manager) ProcessExtraction(response string) {
	m.ProcessExtractionResult(response)
}

// ProcessExtractionResult is ProcessExtraction with a summary of what changed.
func (m *Manager) ProcessExtractionResult(response string) ExtractionSummary {
	daily, longTerm, profileUpdates, topics, projects := parseEnhancedResponse(response)

	var sum ExtractionSummary

	if daily != "" {
		if err := m.Daily.WriteDailyNote(daily); err != nil {
			m.logger.Warn("failed to write daily note", zap.Error(err))
		} else {
			sum.DailyWritten = true
		}
	}

	if longTerm != "" {
		if n, _ := m.appendLongTermCounted(longTerm); n > 0 {
			sum.FactsAdded = n
		}
	}

	if len(profileUpdates) > 0 {
		if m.Profile.Update(profileUpdates) {
			sum.ProfileUpdated = true
		}
	}

	if len(topics) > 0 {
		m.Topics.RecordWithSummary(topics)
		sum.TopicsRecorded = len(topics)
	}

	if len(projects) > 0 {
		if m.Projects.Upsert(projects) {
			sum.ProjectsUpserted = 1
		}
	}

	return sum
}

// RecordInteraction records a usage event.
func (m *Manager) RecordInteraction(event InteractionEvent) {
	m.Patterns.RecordInteraction(event)

	if event.Command != "" {
		m.Profile.RecordCommand(event.Command)
	}
}

// RunCompaction runs memory compaction (LLM-assisted or score-based).
func (m *Manager) RunCompaction(ctx context.Context, sendPrompt func(ctx context.Context, prompt string) (string, error)) error {
	if sendPrompt != nil {
		return m.compactor.RunWithLLM(ctx, sendPrompt)
	}
	return m.compactor.RunScoreBased()
}

// NeedsCompaction checks if compaction should run.
func (m *Manager) NeedsCompaction() bool {
	return m.compactor.NeedsCompaction()
}

// CleanupDailyNotes removes old daily notes.
func (m *Manager) CleanupDailyNotes() (int, error) {
	return m.compactor.CleanupDailyNotes()
}

// GetConfig returns the current config.
func (m *Manager) GetConfig() Config {
	return m.config
}

// Stats returns a summary of memory system state.
func (m *Manager) Stats() map[string]interface{} {
	return map[string]interface{}{
		"facts_count":    m.Facts.Count(),
		"topics_count":   len(m.Topics.GetAll()),
		"projects_count": len(m.Projects.GetAll()),
		"profile":        m.Profile.Get(),
		"usage_stats":    m.Patterns.GetStats(),
	}
}

// --- Response Parsing ---

// parseEnhancedResponse parses the enhanced extraction prompt response.
func parseEnhancedResponse(response string) (daily, longTerm string, profile map[string]string, topics map[string]string, projects map[string]string) {
	profile = make(map[string]string)
	projects = make(map[string]string)
	topics = make(map[string]string)

	upper := strings.ToUpper(response)

	// Find all section positions
	type section struct {
		name string
		idx  int
	}
	sections := []section{
		{"DAILY", findSection(upper, "DAILY")},
		{"LONGTERM", findSection(upper, "LONGTERM")},
		{"PROFILE_UPDATE", findSection(upper, "PROFILE_UPDATE")},
		{"PROFILE", findSection(upper, "PROFILE")},
		{"TOPICS", findSection(upper, "TOPICS")},
		{"PROJECTS", findSection(upper, "PROJECTS")},
	}

	// Filter found sections and sort by position
	var found []section
	for _, s := range sections {
		if s.idx >= 0 {
			found = append(found, s)
		}
	}
	for i := 0; i < len(found); i++ {
		for j := i + 1; j < len(found); j++ {
			if found[j].idx < found[i].idx {
				found[i], found[j] = found[j], found[i]
			}
		}
	}

	// Extract content between sections
	extractContent := func(startIdx int, nextIdx int) string {
		// Find end of header line
		nlIdx := strings.Index(response[startIdx:], "\n")
		if nlIdx < 0 {
			return ""
		}
		contentStart := startIdx + nlIdx + 1
		contentEnd := len(response)
		if nextIdx > 0 {
			contentEnd = nextIdx
		}
		if contentStart >= contentEnd {
			return ""
		}
		return strings.TrimSpace(response[contentStart:contentEnd])
	}

	for i, sec := range found {
		nextIdx := -1
		if i+1 < len(found) {
			nextIdx = found[i+1].idx
		}
		content := extractContent(sec.idx, nextIdx)

		if isNothingNew(content) {
			continue
		}

		switch sec.name {
		case "DAILY":
			daily = content
		case "LONGTERM":
			longTerm = content
		case "PROFILE_UPDATE", "PROFILE":
			profile = parseKeyValues(content)
		case "TOPICS":
			topics = parseTopics(content)
		case "PROJECTS":
			projects = parseKeyValues(content)
		}
	}

	// If no sections found, treat as daily
	if len(found) == 0 && !isNothingNew(response) {
		daily = response
	}

	return
}

func findSection(upper string, keyword string) int {
	patterns := []string{
		"## " + keyword,
		"##" + keyword,
		"# " + keyword,
		"**" + keyword + "**",
	}
	best := -1
	for _, p := range patterns {
		idx := strings.Index(upper, p)
		if idx >= 0 && (best < 0 || idx < best) {
			best = idx
		}
	}
	// Also try LONG-TERM and LONG_TERM variants for LONGTERM
	if keyword == "LONGTERM" {
		for _, alt := range []string{"LONG-TERM", "LONG_TERM"} {
			for _, p := range []string{"## " + alt, "##" + alt, "# " + alt} {
				idx := strings.Index(upper, p)
				if idx >= 0 && (best < 0 || idx < best) {
					best = idx
				}
			}
		}
	}
	return best
}

func isNothingNew(s string) bool {
	normalized := strings.ToUpper(strings.TrimSpace(s))
	normalized = strings.TrimRight(normalized, ".!,;:")
	normalized = strings.TrimSpace(normalized)
	switch normalized {
	case "NOTHING_NEW", "NOTHING NEW", "NOTHING-NEW", "N/A", "NONE", "NA", "":
		return true
	}
	return false
}

func parseKeyValues(content string) map[string]string {
	result := make(map[string]string)
	lines := strings.Split(content, "\n")
	for _, line := range lines {
		line = strings.TrimSpace(line)
		line = strings.TrimPrefix(line, "- ")
		line = strings.TrimPrefix(line, "* ")

		// Support KEY=VALUE and KEY: VALUE formats
		var key, value string
		if idx := strings.Index(line, "="); idx > 0 {
			key = strings.TrimSpace(line[:idx])
			value = strings.TrimSpace(line[idx+1:])
		} else if idx := strings.Index(line, ":"); idx > 0 {
			key = strings.TrimSpace(line[:idx])
			value = strings.TrimSpace(line[idx+1:])
		}

		if key != "" && value != "" {
			result[key] = value
		}
	}
	return result
}

// parseTopics parses the TOPICS section into a name→summary map. It accepts the
// new "name: summary" line format and the legacy bare/comma-separated names
// (summary empty), so old prompts and partial model output still parse.
func parseTopics(content string) map[string]string {
	out := make(map[string]string)
	for _, line := range strings.Split(content, "\n") {
		line = strings.TrimSpace(line)
		line = strings.TrimLeft(line, "-•*# ")
		line = strings.TrimSpace(line)
		if line == "" || isNothingNew(line) {
			continue
		}
		if i := strings.Index(line, ":"); i >= 0 {
			name := strings.TrimSpace(line[:i])
			summary := strings.TrimSpace(line[i+1:])
			if name != "" {
				out[name] = summary
			}
			continue
		}
		// No colon → a bare (possibly comma-separated) list of names.
		for _, part := range strings.Split(line, ",") {
			if n := strings.TrimSpace(part); n != "" {
				if _, exists := out[n]; !exists {
					out[n] = ""
				}
			}
		}
	}
	return out
}

// knownCategories is the fixed set of fact buckets shared by the migration
// heuristic, the extraction parser, and the manual commands.
var knownCategories = map[string]struct{}{
	"architecture": {}, "pattern": {}, "preference": {},
	"gotcha": {}, "project": {}, "personal": {}, "general": {},
}

// isKnownCategory reports whether cat is one of the recognized buckets.
func isKnownCategory(cat string) bool {
	_, ok := knownCategories[cat]
	return ok
}

// splitCategoryPrefix extracts a leading "[category]" tag from a fact line,
// e.g. "[personal] Earned AWS SAA" -> ("personal", "Earned AWS SAA"). The
// extraction prompt asks the model to tag LONGTERM lines this way; when the
// tag is absent or unrecognized the line is returned unchanged with an
// empty category so the caller can classify by content.
func splitCategoryPrefix(line string) (category, rest string) {
	if !strings.HasPrefix(line, "[") {
		return "", line
	}
	end := strings.Index(line, "]")
	if end <= 1 {
		return "", line
	}
	cat := strings.ToLower(strings.TrimSpace(line[1:end]))
	if !isKnownCategory(cat) {
		return "", line
	}
	return cat, strings.TrimSpace(line[end+1:])
}

// classifyFactContent assigns a category to an untagged fact by scanning its
// text. It mirrors detectCategory's vocabulary but works on the whole fact
// rather than a markdown header, so live extraction stops collapsing
// everything into "general". Order matters: more specific personal/
// preference signals are checked before generic ones.
func classifyFactContent(content string) string {
	lower := strings.ToLower(content)
	switch {
	case containsAny(lower, "i prefer", "prefers", "prefiro", "preference", "always use", "never use", "communication style", "estilo de"):
		return "preference"
	case containsAny(lower, "my name is", "meu nome", "i am a", "i'm a", "sou ", "certification", "certified", "certificaç", "certificado", "skill", "expertise", "i work as", "trabalho como"):
		return "personal"
	case containsAny(lower, "architecture", "arquitetura", "design decision", "system design", "module structure"):
		return "architecture"
	case containsAny(lower, "convention", "idiom", "best practice", "pattern", "padrão", "padrao"):
		return "pattern"
	case containsAny(lower, "gotcha", "pitfall", "workaround", "caveat", "bug ", "fails when", "broken", " error"):
		return "gotcha"
	case containsAny(lower, "project", "projeto", "repository", "repo ", "codebase", "/users/"):
		return "project"
	default:
		return "general"
	}
}

// containsAny reports whether s contains any of subs.
func containsAny(s string, subs ...string) bool {
	for _, sub := range subs {
		if strings.Contains(s, sub) {
			return true
		}
	}
	return false
}

// EnhancedExtractionPrompt is the original memory-extraction prompt.
//
// Deprecated: kept for API compatibility; the worker uses
// EnhancedExtractionPromptV2, which adds typed/free-form profile fields and
// per-line category tagging for long-term facts.
const EnhancedExtractionPrompt = `You are a memory annotation system. Analyze this conversation segment and extract structured annotations.

OUTPUT FORMAT — use EXACTLY these section headers:

## DAILY
Write a brief log of what was done in this segment. Use bullet points. Include:
- Files read, modified or created (with paths)
- Commands executed and their outcomes
- Errors encountered and how they were resolved
- Tasks completed or in progress

## LONGTERM
Write ONLY genuinely new facts that should be remembered permanently:
- Architectural decisions made
- Patterns or conventions discovered/established
- User preferences expressed
- Important file paths or project structure insights
- Technical constraints or gotchas learned

## PROFILE_UPDATE
If the user revealed new information about themselves, output KEY=VALUE pairs:
name=...
role=...
expertise_level=beginner|intermediate|expert
preferred_language=...
communication_style=...
(Only include fields that have new information. Skip this section if nothing new.)

## TOPICS
List technical topics discussed in this segment (comma-separated):
Go, Bubble Tea, memory systems, ...
(Skip if no clear technical topics.)

## PROJECTS
If a specific project was worked on, output KEY=VALUE pairs:
project_name=...
project_path=...
project_status=active|paused|completed
project_description=...
project_technologies=Go, React, ...
(Skip if no project context.)

RULES:
- If nothing new was learned for a section, write "NOTHING_NEW" in that section
- If the conversation is trivial (greetings, simple questions), respond with just: NOTHING_NEW
- Keep each bullet to ONE line
- Use exact file paths, never paraphrase
- Do NOT repeat facts already in EXISTING LONG-TERM MEMORY
- Write in the same language the user is using in the conversation
- Be concise — this is metadata, not prose`

// EnhancedExtractionPromptV2 is the active extraction prompt. It expands
// PROFILE_UPDATE with typed and free-form user fields and asks for per-line
// category tags on LONGTERM facts so they are filed correctly.
const EnhancedExtractionPromptV2 = `You are a memory annotation system. Analyze this conversation segment and extract structured annotations.

OUTPUT FORMAT — use EXACTLY these section headers:

## DAILY
Write a brief log of what was done in this segment. Use bullet points. Include:
- Files read, modified or created (with paths)
- Commands executed and their outcomes
- Errors encountered and how they were resolved
- Tasks completed or in progress

## LONGTERM
Write ONLY genuinely new facts that should be remembered permanently.
Tag EACH line with a category in brackets so it is filed correctly:
- [architecture] decisions about system/module design
- [pattern] conventions, idioms, best practices established
- [preference] how the user likes things done
- [gotcha] bugs, pitfalls, workarounds learned
- [project] project paths, structure, technologies
- [personal] durable facts about the user (see also PROFILE_UPDATE)
- [general] anything else worth keeping
Example: - [gotcha] embed.FS requires '/' separators, never filepath.Join

## PROFILE_UPDATE
If the user revealed new durable information about THEMSELVES, output KEY=VALUE pairs.
Known fields (use these exact keys when they apply):
name=...
role=...
expertise_level=beginner|intermediate|expert
preferred_language=...
communication_style=...
company=...
location=...
certifications=...      (comma-separated; e.g. AWS SAA, CKA)
skills=...              (comma-separated)
goals=...               (comma-separated)
You MAY also emit any other relevant key=value about the user even if it is
not listed above (e.g. github=..., years_experience=..., favorite_editor=...).
Unknown keys are preserved as profile preferences — never drop a stable fact
about the user just because it has no predefined field.
(Only include fields that have new information. Skip this section if nothing new.)

## TOPICS
List technical topics discussed in this segment (comma-separated):
Go, Bubble Tea, memory systems, ...
(Skip if no clear technical topics.)

## PROJECTS
If a specific project was worked on, output KEY=VALUE pairs:
project_name=...
project_path=...
project_status=active|paused|completed
project_description=...
project_technologies=Go, React, ...
(Skip if no project context.)

RULES:
- If nothing new was learned for a section, write "NOTHING_NEW" in that section
- If the conversation is trivial (greetings, simple questions), respond with just: NOTHING_NEW
- Keep each bullet to ONE line
- Use exact file paths, never paraphrase
- Do NOT repeat facts already in EXISTING LONG-TERM MEMORY
- Write in the same language the user is using in the conversation
- Be concise — this is metadata, not prose`

// FormatExistingContext builds the context section for the extraction prompt,
// including existing memory to avoid duplication.
func (m *Manager) FormatExistingContext() string {
	var parts []string

	longTerm := m.ReadLongTerm()
	if longTerm != "" {
		parts = append(parts, "EXISTING LONG-TERM MEMORY (do NOT duplicate these facts):\n\n"+longTerm)
	}

	profile := m.Profile.FormatForPrompt()
	if profile != "" {
		parts = append(parts, "EXISTING USER PROFILE:\n\n"+profile)
	}

	topics := m.Topics.FormatForPrompt(15)
	if topics != "" {
		parts = append(parts, "KNOWN TOPICS:\n\n"+topics)
	}

	projects := m.Projects.FormatForPrompt()
	if projects != "" {
		parts = append(parts, "KNOWN PROJECTS:\n\n"+projects)
	}

	if len(parts) == 0 {
		return ""
	}
	return strings.Join(parts, "\n\n---\n\n")
}

// MemoryDir returns the base memory directory path.
func (m *Manager) MemoryDir() string {
	return m.baseDir
}

// SetWorkspaceDir sets the current session's workspace directory.
// Facts created during this session will be annotated with this path.
// The retriever also uses this to disambiguate facts from other projects.
func (m *Manager) SetWorkspaceDir(dir string) {
	m.workspaceDir = dir
	m.retriever.SetWorkspaceDir(dir)
}

// WorkspaceDir returns the current session's workspace directory.
func (m *Manager) WorkspaceDir() string {
	return m.workspaceDir
}

// FormatStats returns a formatted string of memory system statistics.
func (m *Manager) FormatStats() string {
	stats := m.Stats()
	var sb strings.Builder

	sb.WriteString(fmt.Sprintf("Facts: %d\n", stats["facts_count"]))
	sb.WriteString(fmt.Sprintf("Topics: %d\n", stats["topics_count"]))
	sb.WriteString(fmt.Sprintf("Projects: %d\n", stats["projects_count"]))

	usageStats, _ := stats["usage_stats"].(UsageStats)
	if usageStats.SessionCount > 0 {
		sb.WriteString(fmt.Sprintf("Sessions: %d\n", usageStats.SessionCount))
		sb.WriteString(fmt.Sprintf("Total messages: %d\n", usageStats.TotalMessages))
	}

	return sb.String()
}
