#!/usr/bin/env python3
"""Non-CI microbenchmark: single-pass scoring vs legacy per-term rescoring.

Verifies the single-pass refactor eliminates the T+1 graph-scoring passes a
T-term query used to run, without changing the result. Reports median/min
latency for:

  * legacy  — one combined `_score_nodes(G, terms)` call PLUS one
              `_score_nodes(G, [token])` call per distinct query token
              (the old `_pick_seeds(terms=...)` per-term guarantee loop).
  * optimized — one `_score_query(G, terms, collect_per_term_seeds=True)`
                 call, with the per-token best computed inline in the same
                 traversal. `_pick_seeds` then consumes `best_seed_by_term`
                 directly — no rescoring.

Equality is asserted (ranked, best_seed_by_term, seeds) before timing. The
optimized path's traversal count is invariant in the number of query terms.

Run it manually; do NOT wire this into CI (wall-clock assertions are flaky):

    uv run python tests/bench_query_scoring.py \\
        --nodes 100000 --term-counts 3,10 --repeats 5

    uv run python tests/bench_query_scoring.py \\
        --graph graphify-out/graph.json \\
        --query "what calls extract" --query "symbol resolution" \\
        --repeats 10
"""
from __future__ import annotations

import argparse
import json
import random
import statistics
import sys
import time
from pathlib import Path

import networkx as nx

from graphify.serve import (
    _get_trigram_index,
    _pick_seeds,
    _query_terms,
    _score_nodes,
    _score_query,
    _search_tokens,
)


SYLLABLES = [
    "foo", "bar", "baz", "get", "set", "run", "user", "name", "path",
    "build", "report", "extract", "router", "config", "service",
    "handler", "token", "auth", "rate", "limit", "widget", "model",
]

QUERIES_BY_TERM_COUNT: dict[int, list[str]] = {
    1: ["foo"],
    2: ["foo", "bar"],
    3: ["router", "service", "handler"],
    5: ["get", "user", "run", "name", "path"],
    10: ["extract", "build", "report", "router", "config",
         "service", "token", "rate", "limit", "widget"],
}


def _build_random_graph(n: int, *, seed: int) -> nx.DiGraph:
    """Reproducible broad-match DiGraph: short constructed labels + edge noise.

    Labels draw from a small syllable pool so tokens collide across nodes,
    forcing the trigram prefilter to be selective and exercising score ties
    on common tokens. Edge noise provides degree variance so the legacy
    tie-break (`max(tied, key=degree)`) is actually exercised against the
    new `(-singleton, -degree, label_len, nid)` key tuple."""
    rng = random.Random(seed)
    G: nx.DiGraph = nx.DiGraph()
    for i in range(n):
        label = "_".join(rng.sample(SYLLABLES, rng.randint(1, 3)))
        G.add_node(f"n{i}", label=label, source_file=f"src/{label[:8]}.py")
    for _ in range(n * 2):
        a, b = rng.randrange(n), rng.randrange(n)
        if a != b:
            G.add_edge(f"n{a}", f"n{b}", relation="calls", confidence="EXTRACTED")
    return G


def _load_real_graph(path: str) -> nx.Graph:
    """Light wrapper that just builds a NetworkX graph from a real
    `graphify-out/graph.json`, skipping the size cap and work-memory overlay
    that `serve._load_graph` enforces (the bench is read-only)."""
    data = json.loads(Path(path).read_text(encoding="utf-8"))
    if "links" not in data and "edges" in data:
        data = dict(data, links=data["edges"])
    data = {**data, "directed": True}
    try:
        return nx.readwrite.json_graph.node_link_graph(data, edges="links")
    except TypeError:
        return nx.readwrite.json_graph.node_link_graph(data)


def _legacy_score_and_pick(G, terms):
    """Recreates the pre-refactor flow: combined scoring plus one
    `_score_nodes([token])` call per distinct query token, with the legacy
    tie-break (`max(tied, key=degree)` over a (-score, label_len, nid)-sorted
    list) to derive `best_seed_by_term`. Returns the same triple as the
    optimized path so they can be compared for equality."""
    ranked = _score_nodes(G, terms)
    norm_terms = sorted({tok for t in terms for tok in _search_tokens(t)})
    best_seed_by_term: dict[str, str] = {}
    for term in norm_terms:
        term_scored = _score_nodes(G, [term])
        if not term_scored:
            continue
        best_score = term_scored[0][0]
        tied = [nid for s, nid in term_scored if s == best_score]
        best_nid = max(tied, key=lambda n: G.degree(n)) if len(tied) > 1 else term_scored[0][1]
        best_seed_by_term[term] = best_nid
    seeds = _pick_seeds(ranked, G=G, best_seed_by_term=best_seed_by_term)
    return ranked, best_seed_by_term, seeds


def _optimized_score_and_pick(G, terms):
    qs = _score_query(G, terms, collect_per_term_seeds=True)
    seeds = _pick_seeds(qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term)
    return qs.ranked, qs.best_seed_by_term, seeds


def _warm_caches(G, terms):
    """Pre-populate the trigram index and IDF cache so the first timed
    iteration doesn't pay the amortized build cost on either path. Both
    legacy and optimized share these caches via the graph object, so warming
    once is fair to both."""
    _get_trigram_index(G)
    # Touch the idf cache for the combined terms and every per-token singleton,
    # matching exactly the calls the legacy path will make.
    _score_nodes(G, terms)
    for term in {tok for t in terms for tok in _search_tokens(t)}:
        _score_nodes(G, [term])


def _bench(fn, *, repeats: int) -> list[float]:
    # One uncounted warm-up — `_warm_caches` already populated caches, but
    # this also amortizes any per-call code-path setup unique to `fn`.
    fn()
    times: list[float] = []
    for _ in range(repeats):
        t0 = time.perf_counter()
        fn()
        times.append(time.perf_counter() - t0)
    return times


def _verify_equality(G, terms) -> tuple[int, int]:
    leg_rank, leg_best, leg_seeds = _legacy_score_and_pick(G, terms)
    opt_rank, opt_best, opt_seeds = _optimized_score_and_pick(G, terms)
    assert leg_rank == opt_rank, (
        f"ranked diverged for terms={terms}: legacy[:5]={leg_rank[:5]} opt[:5]={opt_rank[:5]}"
    )
    assert leg_best == opt_best, (
        f"best_seed_by_term diverged for terms={terms}: legacy={leg_best} opt={opt_best}"
    )
    assert leg_seeds == opt_seeds, (
        f"seeds diverged for terms={terms}: legacy={leg_seeds} opt={opt_seeds}"
    )
    return len(leg_rank), len(leg_seeds)


def _row(label: str, n_nodes: int, n_terms: int, times: list[float],
         traversal_count: int, n_ranked: int, n_seeds: int) -> str:
    med = statistics.median(times) * 1000
    mn = min(times) * 1000
    return (f"{label:<10} | n={n_nodes:<7} | terms={n_terms:<3} | "
            f"median={med:7.2f}ms | min={mn:7.2f}ms | "
            f"passes={traversal_count:<3} | ranked={n_ranked:<6} seeds={n_seeds}")


def _legacy_traversal_count(terms) -> int:
    # 1 combined pass + one per-token singleton pass.
    return 1 + len({tok for t in terms for tok in _search_tokens(t)})


def _run_scenario(G, terms, *, repeats: int) -> tuple[float, float]:
    _warm_caches(G, terms)
    n_ranked, n_seeds = _verify_equality(G, terms)

    legacy_times = _bench(lambda: _legacy_score_and_pick(G, terms), repeats=repeats)
    opt_times = _bench(lambda: _optimized_score_and_pick(G, terms), repeats=repeats)

    n_nodes = G.number_of_nodes()
    n_terms = len(set(tok for t in terms for tok in _search_tokens(t)))
    print(_row("legacy", n_nodes, n_terms, legacy_times,
               _legacy_traversal_count(terms), n_ranked, n_seeds))
    print(_row("optimized", n_nodes, n_terms, opt_times,
               1, n_ranked, n_seeds))

    med_legacy = statistics.median(legacy_times)
    med_opt = statistics.median(opt_times)
    speedup = med_legacy / med_opt if med_opt > 0 else float("inf")
    print(f"speedup   | median: {speedup:.2f}x | "
          f"min: {min(legacy_times) / min(opt_times):.2f}x")
    return med_legacy, med_opt


def _resolve_scenarios(args) -> list[list[str]]:
    if args.graph:
        # Real-graph mode: each --query is a natural-language sentence, tokenized
        # using the same helper the production path uses.
        sentences = args.query or ["what calls extract"]
        scenarios = [_query_terms(s) for s in sentences]
        # Dedupe identical token sets (multiple --query args may tokenize the same).
        seen: list[list[str]] = []
        for q in scenarios:
            if q not in seen:
                seen.append(q)
        return seen
    term_counts = [int(s.strip()) for s in args.term_counts.split(",") if s.strip()]
    scenarios: list[list[str]] = []
    for tc in term_counts:
        if tc in QUERIES_BY_TERM_COUNT:
            scenarios.append(QUERIES_BY_TERM_COUNT[tc])
        elif tc > 0:
            scenarios.append([SYLLABLES[i % len(SYLLABLES)] for i in range(tc)])
    return scenarios


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(
        description="Microbenchmark single-pass query scoring vs legacy per-term rescoring.",
    )
    p.add_argument("--nodes", type=int, default=100_000,
                   help="node count for the synthetic benchmark graph (default: 100000)")
    p.add_argument("--seed", type=int, default=20260714, help="RNG seed for the synthetic graph")
    p.add_argument("--term-counts", default="3,10",
                   help="comma-separated list of term counts to benchmark (synthetic mode)")
    p.add_argument("--repeats", type=int, default=5,
                   help="timed iterations per scenario (after one warm-up)")
    p.add_argument("--graph", default=None,
                   help="optional path to a real graphify-out/graph.json; overrides --nodes")
    p.add_argument("--query", action="append", default=None,
                   help="natural-language query sentence (real-graph mode; repeat for multiple)")
    args = p.parse_args(argv)

    if args.graph:
        print(f"loading real graph: {args.graph} ...", file=sys.stderr)
        t0 = time.perf_counter()
        G = _load_real_graph(args.graph)
        print(f"  loaded in {time.perf_counter() - t0:.2f}s", file=sys.stderr)
    else:
        print(f"building synthetic graph: n={args.nodes} seed={args.seed} ...",
              file=sys.stderr)
        t0 = time.perf_counter()
        G = _build_random_graph(args.nodes, seed=args.seed)
        print(f"  built in {time.perf_counter() - t0:.2f}s", file=sys.stderr)

    print()
    print(f"graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
    scenarios = _resolve_scenarios(args)
    print(f"scenarios: {len(scenarios)} | repeats per scenario: {args.repeats}")
    print("-" * 110)

    summaries = []
    for terms in scenarios:
        print()
        med_legacy, med_opt = _run_scenario(G, terms, repeats=args.repeats)
        summaries.append((terms, med_legacy, med_opt))

    print()
    print("-" * 110)
    print("summary:")
    for terms, med_legacy, med_opt in summaries:
        speedup = med_legacy / med_opt if med_opt > 0 else float("inf")
        print(f"  terms={len(set(tok for t in terms for tok in _search_tokens(t))):>3} | "
              f"median legacy={med_legacy*1000:>8.2f}ms | "
              f"median optimized={med_opt*1000:>8.2f}ms | "
              f"speedup={speedup:>5.2f}x")
    return 0


if __name__ == "__main__":
    sys.exit(main())
