package dream

import (
	"context"
	"encoding/json"
	"fmt"
	"path/filepath"
	"sort"
	"strings"
)

// This file implements the real, LLM-backed Consolidator (SPEC §5.1 step 5,
// §5.1.1). It turns the deterministic Plan into a prompt for the main-session
// model, asks it to confirm semantic merges and conservative prunes, and parses
// the strict-JSON response back into a ConsolidateResult the Runner applies.
//
// The deterministic half (exact dedup, dead-path cleanup, MEMORY.md rewrite,
// Reconcile, counters) stays in the Runner; this file is purely the semantic
// LLM step. It never touches disk — it only produces decisions the Runner
// path-guards and applies.

// completeFn performs a single LLM completion: given the system and user
// prompts it returns the model's text response, or an error for a hard
// transport/provider failure. The provider-backed implementation lives in
// apply.go (newModelCompleter); tests inject a canned function so no live model
// is ever called.
type completeFn func(ctx context.Context, systemPrompt, userPrompt string) (string, error)

// defaultBodyBudget caps how many bytes of each entry body are shown to the
// model, bounding prompt size on large memory libraries (SPEC §8.2 token
// budget). Bodies longer than this are truncated with a marker so the model
// still sees the leading, usually most salient, content.
const defaultBodyBudget = 6000

// llmConsolidator is the production Consolidator: it drives the main-session
// model through complete and parses the response. bodyBudget (0 → default)
// bounds per-entry body size in the prompt.
type llmConsolidator struct {
	complete   completeFn
	bodyBudget int
}

// Consolidate builds the prompt from the plan, runs one model completion, and
// parses the response into merge/prune decisions. A hard model/transport error
// is returned (the Runner maps it to a failed run, SPEC §5.5). A well-formed
// call whose text cannot be parsed is NOT an error: it yields an empty result
// with an explanatory note, so an unparseable response conservatively KEEPs
// everything (PRD FR-14) and the deterministic pass still applies.
func (c *llmConsolidator) Consolidate(ctx context.Context, in ConsolidateInput) (ConsolidateResult, error) {
	if c.complete == nil {
		return ConsolidateResult{}, fmt.Errorf("dream: llmConsolidator has no completion function")
	}

	var res ConsolidateResult
	eligible := eligibleFiles(in.Plan)
	if len(eligible) > 0 {
		budget := c.bodyBudget
		if budget <= 0 {
			budget = defaultBodyBudget
		}
		prompt := buildConsolidatePrompt(in, eligible, budget)

		raw, err := c.complete(ctx, dreamSystemPrompt, prompt)
		if err != nil {
			return ConsolidateResult{}, fmt.Errorf("dream: model completion: %w", err)
		}

		allowed := make(map[string]struct{}, len(eligible))
		for _, f := range eligible {
			allowed[filepath.Clean(f.Path)] = struct{}{}
		}
		res = parseConsolidateResponse(raw, allowed)
	}

	// Distillation pass (SPEC §5.3, PRD US-005/FR-13): a SEPARATE model call over
	// the recent-session transcripts the Runner collected. It runs even when the
	// library is empty (nothing to merge/prune) so a first-time distill can seed
	// memory from sessions. A hard model failure aborts the run; a well-formed
	// call yielding nothing simply adds no entries (Runner records the no-op).
	if strings.TrimSpace(in.Transcripts) != "" {
		if err := c.distill(ctx, in, &res); err != nil {
			return ConsolidateResult{}, err
		}
	}
	return res, nil
}

// distill runs the JSONL distillation model call and folds the resulting new
// entries into res: it prompts the distiller with the transcripts plus a summary
// of the existing library (so the model avoids re-proposing known facts), parses
// the response into path-guarded NewEntry writes deduped against the existing
// memory, and bumps res.Distilled by the number added. A hard model/transport
// error is returned so the Runner marks the run failed (SPEC §5.5); an
// unparseable or empty response adds nothing and is not an error (conservative
// KEEP, PRD FR-14).
func (c *llmConsolidator) distill(ctx context.Context, in ConsolidateInput, res *ConsolidateResult) error {
	prompt := buildDistillPrompt(in)
	raw, err := c.complete(ctx, dreamDistillSystemPrompt, prompt)
	if err != nil {
		return fmt.Errorf("dream: distill completion: %w", err)
	}
	entries, notes := parseDistillResponse(raw, in.Plan.Files, in.MemoryRoot, in.ProjectDir)
	res.NewEntries = append(res.NewEntries, entries...)
	res.Distilled += len(entries)
	res.Notes = append(res.Notes, notes...)
	return nil
}

// buildDistillPrompt renders the distiller's user prompt: the target scope, a
// compact list of the titles/paths of existing memories (so the model does not
// re-propose known facts), and the recent-session transcripts. Existing bodies
// are summarized (path + leading text) rather than dumped in full to keep the
// prompt bounded; the Go side still enforces near-duplicate rejection.
func buildDistillPrompt(in ConsolidateInput) string {
	var b strings.Builder
	b.WriteString("# Memory distillation request\n\n")
	if in.ProjectDir != "" {
		b.WriteString("Current project: ")
		b.WriteString(in.ProjectDir)
		b.WriteByte('\n')
	} else {
		b.WriteString("Scope: global only\n")
	}

	existing := eligibleFiles(in.Plan)
	if len(existing) > 0 {
		b.WriteString(fmt.Sprintf("\n## Existing memories (%d) — do NOT re-propose these\n\n", len(existing)))
		for _, f := range existing {
			b.WriteString("- ")
			b.WriteString(clip(strings.TrimSpace(firstNonEmptyLine(f.Body)), 120))
			b.WriteByte('\n')
		}
	}

	b.WriteString("\n## Recent session transcripts\n\n")
	b.WriteString(in.Transcripts)
	b.WriteString("\n\nReturn the JSON object described in your instructions. Extract only genuinely new, durable facts. When in doubt, return no entries.\n")
	return b.String()
}

// firstNonEmptyLine returns the first non-blank line of s (trimmed), or "" when
// s is entirely blank. Used to label an existing memory in the distill prompt.
func firstNonEmptyLine(s string) string {
	for _, line := range strings.Split(s, "\n") {
		if t := strings.TrimSpace(line); t != "" {
			return t
		}
	}
	return ""
}

// eligibleFiles is the subset of plan files the model may act on: it excludes
// MEMORY.md index files (they are indexes, not entries — #521 NEW_WORK: we
// special-case MEMORY.md out of merge/prune so the index is never folded into an
// entry) and the redundant members of an exact-dedupe group (g.Paths[1:], which
// the deterministic pass removes anyway — offering them would let the model
// merge into a path about to be deleted). The representative g.Paths[0] stays.
func eligibleFiles(plan Plan) []MemoryFile {
	drop := make(map[string]struct{})
	for _, g := range plan.DedupeGroups {
		for _, p := range g.Paths[1:] {
			drop[filepath.Clean(p)] = struct{}{}
		}
	}
	var out []MemoryFile
	for _, f := range plan.Files {
		if isMemoryIndex(f.Path) {
			continue
		}
		if _, dup := drop[filepath.Clean(f.Path)]; dup {
			continue
		}
		out = append(out, f)
	}
	return out
}

// isMemoryIndex reports whether path is a scope MEMORY.md index file, which the
// consolidation step must never merge, rewrite, or prune.
func isMemoryIndex(path string) bool {
	return strings.EqualFold(filepath.Base(path), "MEMORY.md")
}

// buildConsolidatePrompt renders the user prompt: the scope, the eligible
// entries (path + scope/type + body, truncated to budget), and the deterministic
// hints (near-dup candidate pairs, dead local-path references). It only lists
// paths that are eligible, so the model is naturally steered away from MEMORY.md
// and duplicate paths.
func buildConsolidatePrompt(in ConsolidateInput, eligible []MemoryFile, budget int) string {
	var b strings.Builder
	b.WriteString("# Memory consolidation request\n\n")
	b.WriteString("Memory root: ")
	b.WriteString(in.MemoryRoot)
	b.WriteByte('\n')
	if in.ProjectDir != "" {
		b.WriteString("Active project scope: ")
		b.WriteString(in.ProjectDir)
		b.WriteByte('\n')
	} else {
		b.WriteString("Scope: global only\n")
	}
	b.WriteString(fmt.Sprintf("\n## Entries (%d)\n\n", len(eligible)))
	for i, f := range eligible {
		typ := f.Type
		if typ == "" {
			typ = "(root)"
		}
		b.WriteString(fmt.Sprintf("### [%d] %s\n", i+1, f.Path))
		b.WriteString(fmt.Sprintf("scope=%s type=%s bytes=%d\n\n", f.Scope, typ, f.Size))
		b.WriteString("```\n")
		b.WriteString(truncateBody(f.Body, budget))
		b.WriteString("\n```\n\n")
	}

	if pairs := eligiblePairs(in.Plan, eligible); len(pairs) > 0 {
		b.WriteString("## Near-duplicate candidate pairs (merge only if truly overlapping)\n\n")
		for _, p := range pairs {
			b.WriteString(fmt.Sprintf("- %s  <->  %s  (similarity %.2f)\n", p.A, p.B, p.Similarity))
		}
		b.WriteByte('\n')
	}

	if refs := eligibleInvalidRefs(in.Plan, eligible); len(refs) > 0 {
		b.WriteString("## Entries referencing local files that no longer exist\n")
		b.WriteString("(the dead reference text is cleaned automatically; only PRUNE an entry if losing that reference leaves it meaningless)\n\n")
		for _, r := range refs {
			b.WriteString(fmt.Sprintf("- %s references missing %s\n", r.File, r.Ref))
		}
		b.WriteByte('\n')
	}

	b.WriteString("Return the JSON object described in your instructions. When in doubt, KEEP.\n")
	return b.String()
}

// truncateBody trims body to at most budget bytes on a rune boundary, appending
// a marker when truncation occurred so the model knows content was elided.
func truncateBody(body string, budget int) string {
	if budget <= 0 || len(body) <= budget {
		return body
	}
	cut := budget
	for cut > 0 && !isRuneStart(body[cut]) {
		cut--
	}
	return body[:cut] + "\n…[truncated]"
}

// isRuneStart reports whether b is not a UTF-8 continuation byte, so a truncation
// cut there does not split a multi-byte rune.
func isRuneStart(b byte) bool { return b&0xC0 != 0x80 }

// eligiblePairs filters the plan's near-dup pairs to those whose BOTH members
// are eligible (both are still offered to the model), so we never point the
// model at a MEMORY.md or a to-be-deduped duplicate.
func eligiblePairs(plan Plan, eligible []MemoryFile) []NearDupPair {
	ok := make(map[string]struct{}, len(eligible))
	for _, f := range eligible {
		ok[filepath.Clean(f.Path)] = struct{}{}
	}
	var out []NearDupPair
	for _, p := range plan.NearDupPairs {
		_, a := ok[filepath.Clean(p.A)]
		_, b := ok[filepath.Clean(p.B)]
		if a && b {
			out = append(out, p)
		}
	}
	return out
}

// eligibleInvalidRefs filters dead-path references to those on eligible files.
func eligibleInvalidRefs(plan Plan, eligible []MemoryFile) []InvalidPathRef {
	ok := make(map[string]struct{}, len(eligible))
	for _, f := range eligible {
		ok[filepath.Clean(f.Path)] = struct{}{}
	}
	var out []InvalidPathRef
	for _, r := range plan.InvalidPathRefs {
		if _, found := ok[filepath.Clean(r.File)]; found {
			out = append(out, r)
		}
	}
	return out
}

// modelDecision mirrors the strict JSON output schema the dream prompt requires.
type modelDecision struct {
	Merges []struct {
		Keep   string   `json:"keep"`
		Body   string   `json:"body"`
		Remove []string `json:"remove"`
	} `json:"merges"`
	Prunes []struct {
		Path   string `json:"path"`
		Reason string `json:"reason"`
	} `json:"prunes"`
	Notes []string `json:"notes"`
}

// parseConsolidateResponse turns the model's text into a ConsolidateResult,
// validating every path against allowed (the eligible input paths). It is
// conservative by construction: any decision that references an unknown path, a
// MEMORY.md index, an empty merged body, or an empty prune reason is dropped
// rather than obeyed, and an unparseable response yields an empty result with a
// note (PRD FR-14 — default to KEEP on uncertainty). It never returns an error;
// callers treat a hard model failure separately.
func parseConsolidateResponse(raw string, allowed map[string]struct{}) ConsolidateResult {
	body := extractJSONObject(raw)
	if body == "" {
		return ConsolidateResult{Notes: []string{"dream: model response contained no JSON object; kept all entries"}}
	}
	var dec modelDecision
	if err := json.Unmarshal([]byte(body), &dec); err != nil {
		return ConsolidateResult{Notes: []string{"dream: model response was not valid JSON; kept all entries"}}
	}

	var res ConsolidateResult
	res.MergedBodies = make(map[string]string)
	delSet := make(map[string]struct{})

	valid := func(p string) (string, bool) {
		clean := filepath.Clean(strings.TrimSpace(p))
		if clean == "" || clean == "." {
			return "", false
		}
		if isMemoryIndex(clean) {
			return "", false
		}
		if _, ok := allowed[clean]; !ok {
			return "", false
		}
		return clean, true
	}

	for _, m := range dec.Merges {
		keep, ok := valid(m.Keep)
		if !ok || strings.TrimSpace(m.Body) == "" {
			// Unknown/invalid target or an empty rewrite: skip, keep everything.
			continue
		}
		removed := 0
		for _, r := range m.Remove {
			rp, ok := valid(r)
			if !ok || rp == keep {
				continue
			}
			if _, dup := delSet[rp]; dup {
				continue
			}
			delSet[rp] = struct{}{}
			removed++
		}
		if removed == 0 {
			// A merge that removes nothing is a no-op rewrite; ignore it to avoid
			// gratuitously touching a file the model merely echoed back.
			continue
		}
		res.MergedBodies[keep] = m.Body
		res.Merged += removed
	}

	for _, p := range dec.Prunes {
		pp, ok := valid(p.Path)
		if !ok || strings.TrimSpace(p.Reason) == "" {
			// No path or no stated reason → conservative KEEP.
			continue
		}
		if _, dup := delSet[pp]; dup {
			continue
		}
		// Never prune an entry we are simultaneously keeping as a merge target.
		if _, kept := res.MergedBodies[pp]; kept {
			continue
		}
		delSet[pp] = struct{}{}
		res.Pruned++
		res.Notes = append(res.Notes, fmt.Sprintf("pruned %s: %s", pp, strings.TrimSpace(p.Reason)))
	}

	if len(res.MergedBodies) == 0 {
		res.MergedBodies = nil
	}
	for p := range delSet {
		res.Deletions = append(res.Deletions, p)
	}
	sort.Strings(res.Deletions)

	for _, n := range dec.Notes {
		if s := strings.TrimSpace(n); s != "" {
			res.Notes = append(res.Notes, s)
		}
	}
	return res
}

// extractJSONObject returns the outermost {...} span of s, tolerating models
// that wrap the object in prose or Markdown code fences. It returns "" when no
// balanced object is found.
func extractJSONObject(s string) string {
	start := strings.IndexByte(s, '{')
	if start < 0 {
		return ""
	}
	depth := 0
	inStr := false
	esc := false
	for i := start; i < len(s); i++ {
		ch := s[i]
		if inStr {
			switch {
			case esc:
				esc = false
			case ch == '\\':
				esc = true
			case ch == '"':
				inStr = false
			}
			continue
		}
		switch ch {
		case '"':
			inStr = true
		case '{':
			depth++
		case '}':
			depth--
			if depth == 0 {
				return s[start : i+1]
			}
		}
	}
	return ""
}
