package main

import (
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"

	"github.com/spf13/cobra"
	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
	"gopkg.in/yaml.v3"

	"github.com/zzet/gortex/internal/config"
	"github.com/zzet/gortex/internal/embedding"
	"github.com/zzet/gortex/internal/eval/recall"
	"github.com/zzet/gortex/internal/graph"
	"github.com/zzet/gortex/internal/indexer"
	gortexmcp "github.com/zzet/gortex/internal/mcp"
	"github.com/zzet/gortex/internal/parser"
	"github.com/zzet/gortex/internal/parser/languages"
	"github.com/zzet/gortex/internal/query"
	"github.com/zzet/gortex/internal/search"
	"github.com/zzet/gortex/internal/tokens"
)

var (
	evalRecallFixture       string
	evalRecallIndex         string
	evalRecallFormat        string
	evalRecallOut           string
	evalRecallEmbeddings    bool
	evalRecallEmbedder      string
	evalRecallEmbeddingsURL string
	evalRecallModel         string
	evalRecallRankers       string
	evalRecallSkipRipgrep   bool
	evalRecallVerbose       bool
	evalRecallJudge         string
)

var evalRecallCmd = &cobra.Command{
	Use:   "recall",
	Short: "Recall@1/5/20 + latency + tokens-returned per ranker against a fixture",
	Long: `Runs each query in the fixture through every retrieval ranker and reports
any-hit recall@1/5/20, mean reciprocal rank, p50/p95 latency, and tokens-
returned. Cases are tiered (exact / concept / multi_hop) so per-tier recall
is broken out separately.

Rankers:
  bm25      — text-only (default text backend)
  semantic  — vector-only (requires --embeddings)
  rrf       — BM25 + vector fused via RRF (requires --embeddings)
  winnow    — graph-aware constraint chain (MCP winnow_symbols scorer)
  ripgrep   — rg --files-with-matches baseline ("retrieval floor")

By default every available ranker runs; narrow with --rankers bm25,rrf.

Without --embeddings, semantic and RRF degrade: semantic reports SKIPPED
and RRF falls back to BM25 inside HybridBackend.Search. Use --embeddings
to enable the built-in static (GloVe) provider, or --embeddings-url to
point at an OpenAI-compatible API (e.g. Ollama).`,
	RunE: runEvalRecall,
}

func init() {
	evalRecallCmd.Flags().StringVar(&evalRecallFixture, "fixture", "bench/fixtures/retrieval.yaml", "fixture YAML path")
	evalRecallCmd.Flags().StringVar(&evalRecallIndex, "index", ".", "repository path to index before running queries")
	evalRecallCmd.Flags().StringVar(&evalRecallFormat, "format", "markdown", "output format: markdown or json")
	evalRecallCmd.Flags().StringVar(&evalRecallOut, "out", "", "output file (default: stdout)")
	evalRecallCmd.Flags().BoolVar(&evalRecallEmbeddings, "embeddings", false, "enable local embedder for semantic/RRF rankers (auto-picks Hugot MiniLM-L6-v2 by default)")
	evalRecallCmd.Flags().StringVar(&evalRecallEmbedder, "embedder", "auto", "embedder to use when --embeddings is set: auto|hugot|static")
	evalRecallCmd.Flags().StringVar(&evalRecallEmbeddingsURL, "embeddings-url", "", "OpenAI-compatible embeddings API URL (overrides --embedder)")
	evalRecallCmd.Flags().StringVar(&evalRecallModel, "embeddings-model", "", "embeddings model name (for --embeddings-url)")
	evalRecallCmd.Flags().StringVar(&evalRecallRankers, "rankers", "", "comma-separated subset of rankers to run (default: all)")
	evalRecallCmd.Flags().BoolVar(&evalRecallSkipRipgrep, "skip-ripgrep", false, "skip the rg baseline row")
	evalRecallCmd.Flags().BoolVar(&evalRecallVerbose, "verbose", false, "print per-case miss diagnostics to stderr")
	evalRecallCmd.Flags().StringVar(&evalRecallJudge, "judge", "", "LLM judge model (e.g. claude-haiku-4-5) — rescues misses where a top-K entry plausibly answers the query. Requires ANTHROPIC_API_KEY.")
	evalCmd.AddCommand(evalRecallCmd)
}

func runEvalRecall(_ *cobra.Command, _ []string) error {
	fixtureBytes, err := os.ReadFile(evalRecallFixture)
	if err != nil {
		return fmt.Errorf("reading fixture: %w", err)
	}
	var fixture recall.Fixture
	if err := yaml.Unmarshal(fixtureBytes, &fixture); err != nil {
		return fmt.Errorf("parsing fixture: %w", err)
	}
	if len(fixture.Cases) == 0 {
		return fmt.Errorf("fixture %s has no cases", evalRecallFixture)
	}
	if fixture.Name == "" {
		fixture.Name = filepath.Base(evalRecallFixture)
	}

	absIndex, err := filepath.Abs(evalRecallIndex)
	if err != nil {
		return fmt.Errorf("resolving index path: %w", err)
	}

	cfg, err := config.Load(cfgFile)
	if err != nil {
		return fmt.Errorf("loading config: %w", err)
	}

	// Build indexer, optionally with an embedder so vector search has data.
	// A real stderr logger surfaces embedding failures —
	// silently swallowing them via zap.NewNop() let the recall harness
	// fall back to BM25 with no visible signal that the hybrid backend
	// failed to build.
	g := graph.New()
	reg := parser.NewRegistry()
	languages.RegisterAll(reg)
	idx := indexer.New(g, reg, cfg.Index, newRecallLogger())

	embedder := chooseEmbedder()
	if embedder != nil {
		idx.SetEmbedder(embedder)
	}

	fmt.Fprintf(os.Stderr, "[gortex eval recall] indexing %s...\n", absIndex)
	res, err := idx.Index(absIndex)
	if err != nil {
		return fmt.Errorf("indexing: %w", err)
	}
	fmt.Fprintf(os.Stderr, "[gortex eval recall] indexed %d files, %d nodes in %dms\n",
		res.FileCount, res.NodeCount, res.DurationMs)

	// Validate fixture: every expected ID must exist in the graph,
	// otherwise the case is unreachable and skews recall downwards
	// regardless of ranker quality. Warnings go to stderr so they
	// surface during curation but don't pollute the report.
	if missing := validateFixture(fixture, g); len(missing) > 0 {
		fmt.Fprintf(os.Stderr, "[gortex eval recall] WARNING: %d expected IDs missing from graph — fixture bug:\n", len(missing))
		for _, m := range missing {
			fmt.Fprintf(os.Stderr, "  %s: %s\n", m.caseID, m.id)
		}
	}

	selected := parseSelectedRankers(evalRecallRankers)

	// Peel the Swappable wrapper so we can see the real backend. When
	// embeddings are on, the indexer builds a HybridBackend internally;
	// its TextBackend() is the pure BM25/Bleve side, and the whole
	// HybridBackend is what RRF queries.
	inner := idx.Search()
	if sw, ok := inner.(*search.Swappable); ok {
		inner = sw.Inner()
	}
	hybrid, _ := inner.(*search.HybridBackend)
	var textBackend search.Backend
	switch {
	case hybrid != nil:
		textBackend = hybrid.TextBackend()
	default:
		textBackend = inner
	}

	fmt.Fprintf(os.Stderr, "[gortex eval recall] inner backend: %T\n", inner)
	fmt.Fprintf(os.Stderr, "[gortex eval recall] text backend: %T count=%d\n", textBackend, textBackend.Count())
	if hybrid != nil {
		vec := hybrid.VectorIndex()
		vecCount := 0
		if vec != nil {
			vecCount = vec.Count()
		}
		fmt.Fprintf(os.Stderr, "[gortex eval recall] vector index: count=%d\n", vecCount)
	}

	// Engine-backed BM25 mirrors the MCP search_symbols call path
	// (BM25 + substring fallback for camelCase-only queries). This is
	// what real callers hit. Critically: the engine is pointed at the
	// PURE text backend even when --embeddings is on — otherwise the
	// "bm25" row would silently run through HybridBackend.Search (RRF
	// fusion with vector results) and the measurement would no longer
	// isolate BM25's contribution.
	engForBM25 := query.NewEngine(g)
	engForBM25.SetSearch(textBackend)
	engineSearch := func(q string, limit int) []string {
		nodes := engForBM25.SearchSymbols(q, limit)
		out := make([]string, len(nodes))
		for i, n := range nodes {
			out[i] = n.ID
		}
		return out
	}

	rankers := []recall.Ranker{}
	if selected["bm25"] {
		rankers = append(rankers, recall.EngineRanker("bm25", engineSearch))
	}
	if selected["semantic"] {
		if hybrid != nil && embedder != nil && hybrid.VectorIndex() != nil && hybrid.VectorIndex().Count() > 0 {
			rankers = append(rankers, recall.SemanticRanker("semantic", hybrid.VectorIndex(), embedder))
		} else {
			rankers = append(rankers, skippedRanker("semantic", "no embedder (pass --embeddings)"))
		}
	}
	if selected["rrf"] {
		if hybrid != nil {
			rankers = append(rankers, recall.RRFRanker("rrf", hybrid))
		} else {
			rankers = append(rankers, skippedRanker("rrf", "no hybrid backend (pass --embeddings)"))
		}
	}

	// Graph-traversal ranker for DI-dependent / call-chain queries. Uses
	// the same engine pointed at the pure text backend as Winnow above —
	// text side doesn't matter for traversal, but keeping the engine
	// identical makes it trivial to add text-plus-graph hybrid rankers
	// later without re-plumbing.
	if selected["graph"] {
		graphEng := query.NewEngine(g)
		graphEng.SetSearch(textBackend)
		rankers = append(rankers, recall.GraphRanker("graph", &graphTraverser{eng: graphEng}))
	}

	// Winnow: needs a Server to run the graph-aware scorer. The Server's
	// own engine is pointed at the PURE text backend so winnow's
	// internal text-match step measures graph+BM25 behaviour, not
	// graph+hybrid — otherwise weak static-embedding semantics would
	// drag winnow's exact-tier recall down artificially, hiding the
	// real graph-axis contribution.
	if selected["winnow"] {
		eng := query.NewEngine(g)
		eng.SetSearch(textBackend)
		srv := gortexmcp.NewServer(eng, g, idx, nil, zap.NewNop(), cfg.Guards.Rules)
		srv.SetArchitecture(cfg.Architecture)
		srv.SetArtifacts(cfg.Artifacts)
		srv.SetNamedQueries(cfg.Queries)
		srv.RunAnalysis()
		provider := func(q string, extras map[string]any, limit int) []string {
			return srv.WinnowForEval(q, extras, limit)
		}
		// Pass through per-case constraints via a query→extras lookup
		// built from the fixture.
		extrasByQuery := make(map[string]map[string]any, len(fixture.Cases))
		for _, c := range fixture.Cases {
			if c.WinnowConstraints != nil {
				extrasByQuery[c.Query] = c.WinnowConstraints
			}
		}
		caseExtras := func(q string) map[string]any { return extrasByQuery[q] }
		rankers = append(rankers, recall.WinnowRanker("winnow", provider, caseExtras))
	}

	// Ripgrep baseline over file paths. Uses a filename-adapted copy of
	// the fixture so any-hit recall compares file paths, not symbol IDs.
	var rgReport *recall.Report
	if selected["ripgrep"] && !evalRecallSkipRipgrep {
		if _, err := exec.LookPath("rg"); err == nil {
			rgRanker := recall.RipgrepRanker("ripgrep", absIndex)
			rgFixture := recall.Fixture{
				Name:  fixture.Name + " (file-level)",
				Cases: recall.AdaptCasesForFileRanker(fixture.Cases),
			}
			r := recall.Run(rgFixture, []recall.Ranker{rgRanker}, tokenCounter())
			rgReport = &r
		} else {
			rankers = append(rankers, skippedRanker("ripgrep", "rg not on PATH"))
		}
	}

	report := recall.Run(fixture, rankers, tokenCounter())
	report.GortexRev = readGitRev(absIndex)
	stampSkipReasons(&report)

	if evalRecallVerbose {
		printMissDiagnostics(&report)
	}

	if evalRecallJudge != "" {
		judge := recall.NewJudge(evalRecallJudge)
		if judge == nil {
			fmt.Fprintln(os.Stderr, "[gortex eval recall] --judge set but ANTHROPIC_API_KEY is empty — skipping")
		} else {
			rescued, errs := recall.ApplyJudge(&report, judge)
			fmt.Fprintf(os.Stderr, "[gortex eval recall] judge: rescued %d misses across all rankers\n", rescued)
			for _, e := range errs {
				fmt.Fprintf(os.Stderr, "[gortex eval recall] judge error: %v\n", e)
			}
		}
	}

	// Merge rg result into the main report so the markdown table has
	// a single coherent row-set. File-level recall is still honest
	// per-query — methodology note flags the difference.
	if rgReport != nil && len(rgReport.Rankers) > 0 {
		report.Rankers = append(report.Rankers, rgReport.Rankers[0])
	}

	var out []byte
	switch evalRecallFormat {
	case "markdown", "md":
		out = []byte(recall.Markdown(report))
	case "json":
		out, err = json.MarshalIndent(report, "", "  ")
		if err != nil {
			return fmt.Errorf("encoding JSON: %w", err)
		}
		out = append(out, '\n')
	default:
		return fmt.Errorf("unknown format: %s (want markdown or json)", evalRecallFormat)
	}

	if evalRecallOut == "" {
		_, _ = os.Stdout.Write(out)
		return nil
	}
	if err := os.WriteFile(evalRecallOut, out, 0o644); err != nil {
		return fmt.Errorf("writing output: %w", err)
	}
	fmt.Fprintf(os.Stderr, "[gortex eval recall] wrote %s\n", evalRecallOut)
	return nil
}

// newRecallLogger returns a stderr-only zap logger gated at warn level
// so the recall harness stays quiet on the success path but surfaces
// embedding failures and other indexer warnings — silently swallowing
// them via zap.NewNop() lets a missed embedding chunk demote the
// backend to BM25 with no visible signal.
func newRecallLogger() *zap.Logger {
	cfg := zap.NewProductionConfig()
	cfg.Level = zap.NewAtomicLevelAt(zapcore.WarnLevel)
	cfg.OutputPaths = []string{"stderr"}
	cfg.ErrorOutputPaths = []string{"stderr"}
	logger, err := cfg.Build()
	if err != nil {
		return zap.NewNop()
	}
	return logger
}

// chooseEmbedder honours --embeddings-url > --embedder > --embeddings > off.
// Default with --embeddings is the best local provider (Hugot MiniLM-L6-v2
// auto-downloads to ~/.cache/gortex/models/ on first use). Users can force
// static GloVe with --embedder static.
func chooseEmbedder() embedding.Provider {
	if evalRecallEmbeddingsURL != "" {
		fmt.Fprintf(os.Stderr, "[gortex eval recall] embedder: API (%s)\n", evalRecallEmbeddingsURL)
		return embedding.NewAPIProvider(evalRecallEmbeddingsURL, evalRecallModel)
	}
	if !evalRecallEmbeddings {
		return nil
	}
	switch evalRecallEmbedder {
	case "static":
		p, err := embedding.NewStaticProvider()
		if err != nil {
			fmt.Fprintf(os.Stderr, "[gortex eval recall] static embedder failed: %v\n", err)
			return nil
		}
		fmt.Fprintln(os.Stderr, "[gortex eval recall] embedder: static (GloVe)")
		return p
	case "hugot":
		p, err := embedding.NewHugotProvider()
		if err != nil {
			fmt.Fprintf(os.Stderr, "[gortex eval recall] hugot failed: %v (semantic/RRF will be skipped)\n", err)
			return nil
		}
		fmt.Fprintf(os.Stderr, "[gortex eval recall] embedder: hugot (dim=%d)\n", p.Dimensions())
		return p
	case "auto", "":
		p, err := embedding.NewLocalProvider()
		if err != nil {
			fmt.Fprintf(os.Stderr, "[gortex eval recall] local embedder failed: %v (semantic/RRF will be skipped)\n", err)
			return nil
		}
		fmt.Fprintf(os.Stderr, "[gortex eval recall] embedder: local (%T, dim=%d)\n", p, p.Dimensions())
		return p
	default:
		fmt.Fprintf(os.Stderr, "[gortex eval recall] unknown --embedder %q\n", evalRecallEmbedder)
		return nil
	}
}

// parseSelectedRankers turns a comma-separated flag into a set. Default
// (empty) = all known rankers on.
func parseSelectedRankers(csv string) map[string]bool {
	all := []string{"bm25", "semantic", "rrf", "winnow", "graph", "ripgrep"}
	if csv == "" {
		out := make(map[string]bool, len(all))
		for _, r := range all {
			out[r] = true
		}
		return out
	}
	out := make(map[string]bool)
	for _, r := range strings.Split(csv, ",") {
		out[strings.TrimSpace(r)] = true
	}
	return out
}

// skippedRanker registers a placeholder so the report shows the row
// with a skip reason instead of silently dropping it.
func skippedRanker(name, reason string) recall.Ranker {
	r := recall.Ranker{
		Name: name,
		Search: func(_ string, _ int) []string {
			return nil
		},
	}
	// The Run loop itself doesn't know about Skipped — we set it
	// post-hoc on the Report.Rankers entries that had Search returning
	// nothing, but only when the reason is non-empty. Easier path: the
	// caller marks the RankerResult. Since recall.Run can't see inside,
	// we record skip reason on a side channel applied after Run.
	evalSkipReasons[name] = reason
	return r
}

// evalSkipReasons carries the skip reasons keyed by ranker name so we
// can stamp them onto the report after Run. Shared across the single
// goroutine call site; not reached concurrently.
var evalSkipReasons = map[string]string{}

// tokenCounter returns a real tiktoken-based counter (internal/tokens
// already handles its own fallback if the encoder fails to init).
func tokenCounter() recall.TokenCounter {
	return func(s string) int { return tokens.Count(s) }
}

// stampSkipReasons copies skip reasons recorded on the eval-local side
// channel onto the matching report rows.
func stampSkipReasons(report *recall.Report) {
	for i, r := range report.Rankers {
		if reason, ok := evalSkipReasons[r.Name]; ok {
			report.Rankers[i].Skipped = reason
		}
	}
}

// printMissDiagnostics dumps per-ranker miss lists so fixture curators
// can see which cases are genuinely hard vs plainly mis-labelled.
func printMissDiagnostics(report *recall.Report) {
	for _, r := range report.Rankers {
		if r.Skipped != "" || len(r.Misses) == 0 {
			continue
		}
		fmt.Fprintf(os.Stderr, "\n[gortex eval recall] %s misses (%d):\n", r.Name, len(r.Misses))
		for _, m := range r.Misses {
			top3 := m.Top
			if len(top3) > 3 {
				top3 = top3[:3]
			}
			rank := "MISS"
			if m.Rank > 0 {
				rank = fmt.Sprintf("rank=%d", m.Rank)
			}
			fmt.Fprintf(os.Stderr, "  %-38s  %-6s  %q  want=%v  got=%v\n", m.CaseID, rank, m.Query, m.Expected, top3)
		}
	}
}

// fixtureMiss records an expected ID that doesn't exist in the graph.
type fixtureMiss struct{ caseID, id string }

// validateFixture reports expected IDs that aren't present in the graph
// so fixture curation errors surface before they depress recall numbers.
func validateFixture(f recall.Fixture, g *graph.Graph) []fixtureMiss {
	var out []fixtureMiss
	for _, c := range f.Cases {
		for _, id := range c.Expected {
			if g.GetNode(id) == nil {
				out = append(out, fixtureMiss{caseID: c.ID, id: id})
			}
		}
	}
	return out
}

// readGitRev returns the short commit SHA of the repo at root, empty
// string if git is missing or the path isn't a repo.
func readGitRev(root string) string {
	cmd := exec.Command("git", "-C", root, "rev-parse", "--short", "HEAD")
	out, err := cmd.Output()
	if err != nil {
		return ""
	}
	return strings.TrimSpace(string(out))
}

// graphTraverser adapts the query engine to recall.GraphProvider. Each
// "tool" prefix (callers, call_chain, usages, dependents) maps to a
// specific engine method with a sane default depth for the fixture's
// query shape.
type graphTraverser struct {
	eng *query.Engine
}

func (g *graphTraverser) Traverse(tool, id string, limit int) []string {
	if g == nil || g.eng == nil {
		return nil
	}
	opts := query.QueryOptions{Depth: 3, Limit: limit * 2}
	var sg *query.SubGraph
	switch tool {
	case "callers":
		sg = g.eng.GetCallers(id, opts)
	case "call_chain":
		sg = g.eng.GetCallChain(id, opts)
	case "usages":
		sg = g.eng.FindUsages(id)
	case "dependents":
		sg = g.eng.GetDependents(id, opts)
	default:
		return nil
	}
	if sg == nil {
		return nil
	}
	out := make([]string, 0, len(sg.Nodes))
	for _, n := range sg.Nodes {
		if n.ID == id {
			continue // the subject itself is not a caller/callee/user
		}
		out = append(out, n.ID)
		if len(out) >= limit {
			break
		}
	}
	return out
}
