package mcp

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/google/uuid"

	"github.com/justinstimatze/slimemold/internal/analysis"
	"github.com/justinstimatze/slimemold/internal/extract"
	"github.com/justinstimatze/slimemold/internal/ingest"
	"github.com/justinstimatze/slimemold/internal/store"
	"github.com/justinstimatze/slimemold/types"
)

// documentPromptVersion bumps whenever systemPrompt + documentModeSupplement
// changes in a way that invalidates prior cached extractions. The cache key
// includes this so we don't serve stale results after a prompt edit.
//
// v5: added Moore et al. 2026 inventory flags (grand_significance,
// unique_connection, dismisses_counterevidence, ability_overstatement,
// sentience_claim, relational_drift). Old cached extractions don't carry
// these and would deserialize as false-for-everything; bumping forces
// re-extraction so the flags actually get populated.
//
// v6: added explicit "basis must be one of {…}, never a speaker value"
// HARD CONSTRAINT to the system prompt after the v5 README audit caught
// Sonnet emitting basis="document" once in sixteen chunks. The defensive
// coerceBasis layer in claimFromExtracted still catches any future drift
// at runtime; the prompt reinforcement reduces how often that layer has
// to fire. Cache invalidation here so cached v5 extractions don't mask
// the rate change — we want the next ingest to actually exercise the
// new prompt and confirm the rate dropped.
//
// v7: added consequential_action inventory flag drawn from Yang et al.
// (2026) "AI-Induced Delusional Spirals" (CHI EA '26). Yang's first
// monitoring criterion (§4.3) — "consequential real-world actions
// disproportionate to demonstrated expertise" — was the one signal not
// captured by the existing six Moore flags. Old cached extractions
// don't carry the field; bumping forces re-extraction so it's populated.
//
// v8: tightened "definition" basis classifier after the variance harness
// surfaced 22-62% noise on definition counts across README and Sokal
// fixtures (Marinetti has no definitional content so wasn't a signal).
// The decision-tree rule for definition got example phrasings ("X is
// defined as Y", "by 'Z' we mean..."); a new "Additional precision"
// paragraph distinguishes definition (declares what a term *means*)
// from convention (declares what we *do*) and from vibes/research
// (general factual claims about a named thing). Bumping invalidates
// cached extractions so the next ingest re-runs against the new prompt
// and the noise floor drop can be measured directly.
//
// v9: tried swapping convention-before-definition and dropping the
// "we use 'X' to refer to Y" example. 5-run variance against the same
// README fixture made things worse: definition mean 30.0→37.0,
// stddev 7.72→10.39 (still 28%, no better than v7). Convention firings
// stayed at 1.0 ± 0.0 across all 5 runs, suggesting the README has
// almost no stipulative-policy claims at all and the swap just left
// definition more permissive without redirecting anything. Reverted.
//
// v10: revert to v8's prompt content. Bumping the version (rather than
// just reverting in place) so any v9-cached extractions written during
// the failed validation aren't served on subsequent ingests.
//
// v11: added SCOPE EXCLUSIONS block to the system prompt — narrow
// suppression of badge / repository-metadata facts (CI status, license
// identity, language-by-badge), boilerplate identity statements about
// the document itself, and bare section pointers. Motivated by the
// v0.11.0 quality-harness baseline (0.49 substantive / 0.51 filler on
// the README): the filler samples were dominated by exactly these
// metadata claims, which constrain no downstream reasoning yet count
// equally in the topology analysis.
//
// Measured outcome on README.md:
//   - quality harness: substantive rate 0.49 → 0.54 (+5pp, at the
//     documented signal threshold). Substantive count absolute 128 →
//     140 (+12 kept); filler 135 → 118 (−17, the targeted reduction);
//     unclear 20 → 5. Controls: pos 0.97 → 0.94, neg 0.14 → 0.15
//     (both safely within the gate).
//   - variance harness (3 runs): claims 272.0 ± 4.32 vs the v7 floor
//     of 275.0 ± 9.78 (within 1σ — no recall loss). Edges 461.0 ±
//     8.52 vs 483.8 ± 18.78 (~1.2σ, below the 2σ signal threshold).
//     Vibes basis 177.7 ± 9.39 vs 192.4 ± 10.69 (consistent with
//     suppressing badge/license claims that previously emitted as
//     vibes).
//
// Buddy's analogous softening experiment (~/Documents/buddy) tanked
// recall (lexicon 100 → 73%, opus 13–27% at long context) and was
// reverted. The lesson stood, but didn't apply here — the rule is
// exclusion-by-category, not tone shift, and the recall metrics held.
// If a future evaluation finds claims dropping >10pp from this
// version's variance run, revert following the v9 → v10 precedent.
//
// v12: added execution-status reports to SCOPE EXCLUSIONS. Precision
// adjudication of all 41 historical hook fires on the slimemold
// project (2026-06-01 → 06-09) found ~68% anchored on claims like
// "All tests pass" / "commit was pushed" — extracted from the
// assistant's own end-of-turn execution summaries, true at utterance,
// stale within hours. The exclusion carries an explicit mechanism
// exception ("release was auto-created when the tag pushed" IS a
// claim — it asserts external-system behavior) that the detector-level
// regex filter (analysis.IsEphemeralStatus) cannot express. The two
// layers are complementary: prompt-level stops new extraction; the
// detector filter covers the ~1800 status claims already in graphs.
// Variance-harness results recorded in the commit message.
const documentPromptVersion = 12

// DocumentPromptVersion exposes the version constant so outside packages
// (e.g. the eval CLI) can label snapshots by prompt identity.
func DocumentPromptVersion() int { return documentPromptVersion }

// CoreIngestDocument chunks an authored document and extracts claims from each
// chunk. One session per ingest, named deterministically from the path so
// re-ingesting the same file reuses the session identifier.
func CoreIngestDocument(ctx context.Context, db *store.DB, extractor *extract.Extractor, project, path string, maxChars int) (*types.AuditResult, error) {
	content, displayPath, err := readDocument(path)
	if err != nil {
		return nil, err
	}

	chunks := ingest.Chunk(content, maxChars)
	if len(chunks) == 0 {
		return nil, fmt.Errorf("no extractable content in %s", displayPath)
	}

	sessionID := documentSessionID(displayPath)

	// Flip extractor into document mode for this ingest. Restore on exit so a
	// long-lived extractor (e.g. the MCP server's) doesn't stay in doc mode.
	prevDocMode := extractor.DocumentMode
	extractor.DocumentMode = true
	defer func() { extractor.DocumentMode = prevDocMode }()

	totalNewClaims := 0
	totalNewEdges := 0

	for i, chunk := range chunks {
		newClaims, newEdges, err := ingestOneChunk(ctx, db, extractor, project, sessionID, displayPath, chunk)
		if err != nil {
			return nil, fmt.Errorf("chunk %d/%d: %w", i+1, len(chunks), err)
		}
		totalNewClaims += newClaims
		totalNewEdges += newEdges
		fmt.Fprintf(os.Stderr, "  chunk %d/%d: %s — %d claims, %d edges\n",
			i+1, len(chunks), chunkLabel(chunk), newClaims, newEdges)
	}

	claims, _ := db.GetClaimsByProject(project)
	edges, _ := db.GetEdgesByProject(project)
	topo, vulns := analysis.Analyze(claims, edges, project)

	totalClaims, _ := db.CountClaims(project)
	totalEdges, _ := db.CountEdges(project)

	summary := analysis.FormatAuditSummary(topo, vulns)

	_ = db.CreateAudit(&types.Audit{
		Project:       project,
		SessionID:     sessionID,
		Findings:      summary,
		ClaimCount:    totalClaims,
		EdgeCount:     totalEdges,
		CriticalCount: vulns.CriticalCount,
	})

	return &types.AuditResult{
		NewClaims:       totalNewClaims,
		NewEdges:        totalNewEdges,
		TotalClaims:     totalClaims,
		TotalEdges:      totalEdges,
		Vulnerabilities: *vulns,
		Summary:         summary,
	}, nil
}

func ingestOneChunk(ctx context.Context, db *store.DB, extractor *extract.Extractor, project, sessionID, displayPath string, chunk ingest.DocumentChunk) (int, int, error) {
	// Load ALL claims (including archived) so the dedup index can match
	// archived rows and unarchive them on resurrection — same fix as
	// CoreParseTranscript. Without this, re-ingesting a document after a
	// sweep silently re-inserts duplicate rows, defeating the working-set
	// containment the sweep was designed to provide.
	allClaims, _ := db.GetClaimsByProjectAll(project)
	existingClaims := make([]types.Claim, 0, len(allClaims))
	for _, c := range allClaims {
		if !c.Archived {
			existingClaims = append(existingClaims, c)
		}
	}
	existingEdges, _ := db.GetEdgesByProject(project)
	relevant := selectRelevantClaims(existingClaims, existingEdges, chunk.Text)

	existingRefs := make([]extract.ExistingClaimRef, 0, len(relevant))
	for _, c := range relevant {
		existingRefs = append(existingRefs, extract.ExistingClaimRef{
			ID:    c.ID,
			Text:  c.Text,
			Basis: string(c.Basis),
		})
	}

	chunkText := chunk.Text
	if len(chunk.HeadingPath) > 0 {
		chunkText = fmt.Sprintf("[Section: %s]\n\n%s", strings.Join(chunk.HeadingPath, " > "), chunk.Text)
	}

	result, cached, err := extractWithCache(ctx, db, extractor, chunk.ContentHash, chunkText, existingRefs)
	if err != nil {
		return 0, 0, fmt.Errorf("extraction: %w", err)
	}
	if cached {
		fmt.Fprintf(os.Stderr, "    (cache hit)\n")
	}
	if result == nil || len(result.Claims) == 0 {
		return 0, 0, nil
	}

	validateResearchBasis(result.Claims, chunk.Text)

	source := formatDocumentSource(displayPath, chunk.HeadingPath)
	for i := range result.Claims {
		if result.Claims[i].Speaker != string(types.SpeakerDocument) {
			result.Claims[i].Speaker = string(types.SpeakerDocument)
		}
		// Authored prose never has basis=llm_output — the author is not an AI.
		// The prompt asks the extractor to avoid llm_output in document mode, but
		// it leaks through occasionally on confidently-asserted technical content
		// (~10% of Sokal claims). Normalize here so the invariant doesn't depend
		// on prompt adherence.
		if result.Claims[i].Basis == "llm_output" {
			result.Claims[i].Basis = string(types.BasisVibes)
		}
		if result.Claims[i].Source == "" {
			result.Claims[i].Source = source
		}
	}

	result.Claims = deduplicateBatch(result.Claims)
	// Index built from ALL claims (incl. archived) so paraphrased
	// resurrections of swept claims match the original and unarchive in
	// place rather than creating duplicate rows.
	existingIndex := buildNgramIndex(allClaims)

	txDB, err := db.Begin()
	if err != nil {
		return 0, 0, fmt.Errorf("begin transaction: %w", err)
	}
	defer func() { _ = txDB.Rollback() }()

	indexToID := make(map[int]string)
	newClaims := 0
	for _, ec := range result.Claims {
		if match := existingIndex.findSimilar(ec.Text, types.Speaker(ec.Speaker)); match != nil {
			// Unlike CoreParseTranscript's dedup branch, ingest ALWAYS
			// calls RecordSessionClaim — document ingest sessions are
			// deterministic per file and the membership table is what
			// makes a re-ingest produce the same session-scoped findings
			// as the first ingest.
			//
			// Archived matches additionally need unarchiving so the
			// resurrected claim re-enters the working set; error logged
			// but not propagated (same rationale as core.go).
			if match.Archived {
				if n, err := txDB.UnarchiveClaims(project, []string{match.ID}); err != nil {
					fmt.Fprintf(os.Stderr, "slimemold: failed to unarchive %s during ingest: %v\n", match.ID, err)
				} else if n == 0 {
					fmt.Fprintf(os.Stderr, "slimemold: unarchive of %s affected 0 rows (race?)\n", match.ID)
				}
			}
			indexToID[ec.Index] = match.ID
			_ = txDB.RecordSessionClaim(sessionID, match.ID)
			continue
		}
		claim := claimFromExtracted(ec, sessionID, project)
		if err := txDB.CreateClaim(claim); err != nil {
			return 0, 0, fmt.Errorf("creating claim: %w", err)
		}
		_ = txDB.RecordSessionClaim(sessionID, claim.ID)
		indexToID[ec.Index] = claim.ID
		newClaims++
	}

	newEdges := 0
	for _, ec := range result.Claims {
		newEdges += resolveEdgesForClaim(txDB, ec, indexToID)
	}

	pruneHighDegreeEdges(txDB, project)

	if err := txDB.Commit(); err != nil {
		return 0, 0, fmt.Errorf("commit: %w", err)
	}
	return newClaims, newEdges, nil
}

// claimFromExtracted constructs a fresh Claim from an ExtractedClaim,
// preserving every persisted field — including the Moore et al. 2026
// inventory flags. Used by both transcript ingestion (CoreParseTranscript
// in core.go) and document ingestion (ingestOneChunk above) so the field
// mapping lives in exactly one place.
//
// Before this helper existed, both call sites had a structurally identical
// inline literal copying ec.* into Claim.* — every time a new field was
// added to ExtractedClaim/Claim, both sites had to be updated, and a missed
// update caused fields to silently drop on one of the two ingestion paths.
// Centralizing eliminates that class of bug. Field preservation across the
// helper + store round-trip is exercised by
// TestClaimFromExtracted_PreservesAllFields and
// TestClaimFromExtracted_RoundTripsThroughStore in server_test.go.
func claimFromExtracted(ec types.ExtractedClaim, sessionID, project string) *types.Claim {
	return &types.Claim{
		ID:                       uuid.New().String(),
		Text:                     ec.Text,
		Basis:                    coerceBasis(ec.Basis, ec.Speaker),
		Confidence:               ec.Confidence,
		Source:                   ec.Source,
		SessionID:                sessionID,
		Project:                  project,
		Speaker:                  types.Speaker(ec.Speaker),
		CreatedAt:                time.Now(),
		TerminatesInquiry:        ec.TerminatesInquiry,
		GrandSignificance:        ec.GrandSignificance,
		UniqueConnection:         ec.UniqueConnection,
		DismissesCounterevidence: ec.DismissesCounterevidence,
		AbilityOverstatement:     ec.AbilityOverstatement,
		SentienceClaim:           ec.SentienceClaim,
		RelationalDrift:          ec.RelationalDrift,
		ConsequentialAction:      ec.ConsequentialAction,
	}
}

// validBasisValues mirrors the schema.sql CHECK constraint and the JSON-schema
// enum in extract.go. Kept as a closed map so coerceBasis can lookup in O(1)
// without dragging in a sort/contains helper.
var validBasisValues = map[string]bool{
	"research":   true,
	"empirical":  true,
	"analogy":    true,
	"vibes":      true,
	"llm_output": true,
	"deduction":  true,
	"assumption": true,
	"definition": true,
	"convention": true,
}

// coerceBasis defends against the model emitting a basis value outside the
// closed enum the DB CHECK constraint enforces. Sonnet occasionally drops out
// of the enum under prompt pressure — e.g. emitting basis="document" on
// document-mode chunks (confusing it with the speaker enum) once the system
// prompt grew past a threshold around v5. Rather than abort the whole ingest,
// we lowercase + map and fall back to a speaker-appropriate default. We log
// the offending value to stderr so prompt regressions remain visible.
func coerceBasis(raw, speaker string) types.Basis {
	v := strings.ToLower(strings.TrimSpace(raw))
	if validBasisValues[v] {
		return types.Basis(v)
	}
	var fallback string
	switch speaker {
	case "assistant":
		fallback = "llm_output"
	default:
		fallback = "vibes"
	}
	fmt.Fprintf(os.Stderr, "    coerced out-of-enum basis %q (speaker=%q) → %q\n", raw, speaker, fallback)
	return types.Basis(fallback)
}

// resolveEdgesForClaim inserts all edges (intra-batch by index, cross-batch by
// existing claim ID) for a single extracted claim and returns the count of
// edges actually created.
func resolveEdgesForClaim(txDB *store.DB, ec types.ExtractedClaim, indexToID map[int]string) int {
	fromID, ok := indexToID[ec.Index]
	if !ok {
		return 0
	}
	n := 0
	n += resolveIntraBatchEdges(txDB, fromID, ec.DependsOnIndices, indexToID, types.RelDependsOn)
	n += resolveIntraBatchEdges(txDB, fromID, ec.SupportsIndices, indexToID, types.RelSupports)
	n += resolveIntraBatchEdges(txDB, fromID, ec.ContradictsIndices, indexToID, types.RelContradicts)
	n += resolveIntraBatchEdges(txDB, fromID, ec.QuestionsIndices, indexToID, types.RelQuestions)
	n += resolveCrossBatchEdges(txDB, fromID, ec.DependsOnExisting, types.RelDependsOn)
	n += resolveCrossBatchEdges(txDB, fromID, ec.SupportsExisting, types.RelSupports)
	n += resolveCrossBatchEdges(txDB, fromID, ec.ContradictsExisting, types.RelContradicts)
	n += resolveCrossBatchEdges(txDB, fromID, ec.QuestionsExisting, types.RelQuestions)
	return n
}

func resolveIntraBatchEdges(txDB *store.DB, fromID string, targets []int, indexToID map[int]string, rel types.Relation) int {
	n := 0
	for _, targetIdx := range targets {
		toID, ok := indexToID[targetIdx]
		if !ok || toID == fromID {
			continue
		}
		if createEdgeIfNew(txDB, fromID, toID, rel) {
			n++
		}
	}
	return n
}

func resolveCrossBatchEdges(txDB *store.DB, fromID string, existingIDs []string, rel types.Relation) int {
	n := 0
	for _, existingID := range existingIDs {
		if existingID == fromID || !claimExists(txDB, existingID) {
			continue
		}
		if createEdgeIfNew(txDB, fromID, existingID, rel) {
			n++
		}
	}
	return n
}

// maxDocumentBytes caps how much we're willing to load into memory at once.
// 10 MiB is generously above any realistic essay, paper, or book chapter
// (typical plain-text novels are ~500 KB; full academic papers ~1 MB).
// Streaming ingestion is Phase 2; this guard is here so a mis-typed path or
// pathological input can't OOM the process.
const maxDocumentBytes = 10 * 1024 * 1024

func readDocument(path string) (string, string, error) {
	if path == "-" {
		data, err := io.ReadAll(io.LimitReader(os.Stdin, maxDocumentBytes+1))
		if err != nil {
			return "", "", fmt.Errorf("reading stdin: %w", err)
		}
		if int64(len(data)) > maxDocumentBytes {
			return "", "", fmt.Errorf("stdin input exceeds %d-byte limit — split the document or ingest sections separately", maxDocumentBytes)
		}
		return string(data), "<stdin>", nil
	}
	abs, err := filepath.Abs(path)
	if err != nil {
		return "", "", fmt.Errorf("invalid path: %w", err)
	}
	info, err := os.Stat(abs)
	if err != nil {
		return "", "", fmt.Errorf("reading %s: %w", path, err)
	}
	if info.IsDir() {
		return "", "", fmt.Errorf("%s is a directory", path)
	}
	if info.Size() > maxDocumentBytes {
		return "", "", fmt.Errorf("%s is %d bytes, exceeds %d-byte limit — split the document or ingest sections separately",
			path, info.Size(), maxDocumentBytes)
	}
	data, err := os.ReadFile(abs)
	if err != nil {
		return "", "", fmt.Errorf("reading %s: %w", path, err)
	}
	return string(data), abs, nil
}

func documentSessionID(displayPath string) string {
	sum := sha256.Sum256([]byte(displayPath))
	return "doc:" + hex.EncodeToString(sum[:])[:12]
}

// DocumentSessionID is the exported form used by callers that need to
// correlate ingested documents to their session_id (e.g. the eval command
// building per-document summaries from a shared project graph).
//
// The input path is resolved to absolute via filepath.Abs before hashing so
// callers get the same session_id that CoreIngestDocument uses internally,
// regardless of whether they pass a relative or absolute path. Silent
// fallback to the input on Abs error — the error case is essentially
// impossible on valid input, and returning ("", error) would make the
// signature awkward for callers who just want a lookup key.
func DocumentSessionID(path string) string {
	abs, err := filepath.Abs(path)
	if err != nil {
		abs = path
	}
	return documentSessionID(abs)
}

func formatDocumentSource(displayPath string, headingPath []string) string {
	base := filepath.Base(displayPath)
	if len(headingPath) == 0 {
		return base
	}
	return base + "#" + strings.Join(headingPath, "/")
}

// extractWithCache looks up a prior extraction for this content/model/prompt
// tuple and returns it on hit. On miss, runs the extractor and stores the
// result for next time. Cache key does not include existingRefs — the cached
// intra-batch claims and edges are always valid; cross-batch edge hints from
// the cache are IDs that may no longer exist, and get filtered out downstream
// by the claimExists check.
func extractWithCache(ctx context.Context, db *store.DB, extractor *extract.Extractor, contentHash, chunkText string, existingRefs []extract.ExistingClaimRef) (*types.ExtractionResult, bool, error) {
	if cached, ok := db.GetExtractionCache(contentHash, extractor.Model(), documentPromptVersion); ok {
		var result types.ExtractionResult
		if err := json.Unmarshal([]byte(cached), &result); err == nil {
			return &result, true, nil
		}
		// Unmarshal failed — the cached row is corrupt (truncated write, schema
		// mismatch, etc.). Delete it so we don't keep re-parsing the same bad
		// blob on every run, then fall through to re-extract.
		_ = db.DeleteExtractionCache(contentHash, extractor.Model(), documentPromptVersion)
	}

	result, err := extractor.Extract(ctx, chunkText, existingRefs)
	if err != nil {
		return nil, false, err
	}
	if result != nil {
		if raw, err := json.Marshal(result); err == nil {
			_ = db.SetExtractionCache(contentHash, extractor.Model(), documentPromptVersion, string(raw))
		}
	}
	return result, false, nil
}

func chunkLabel(chunk ingest.DocumentChunk) string {
	if len(chunk.HeadingPath) == 0 {
		return "(no heading)"
	}
	return strings.Join(chunk.HeadingPath, " > ")
}
