#!/usr/bin/env python3
"""bench.py — ECHO performance test suite (timing harness).

Measures the operations the 1.1.0 (pooling + concurrent read_many + shared cache)
and 1.2.0 (fuzzy resolve / alias learning) updates targeted, and gates them on
RELATIVE ratios (portable across machines/WAN) rather than absolute ms.

It reuses the live `echo` client module so it times the REAL pooled/concurrent
code path. Reads are non-destructive; write metrics live in a disposable
namespace (_agent/_bench/<run-id>/) cleaned up at the end unless --keep.

This is a MANUAL / pre-release tool, not a CI gate — it depends on the live
configured endpoint (echo.BASE / $ECHO_BASE) and WAN latency.

  python3 bench.py <metric> [options]
  python3 bench.py all                      # the read-only safe set
  python3 bench.py read-full -n 10
  python3 bench.py subject-pull
  python3 bench.py bulk-put -k 50
  python3 bench.py worker-sweep
  python3 bench.py resolve                  # 1.2.0 fuzzy correctness + timing
  python3 bench.py --list

Metrics: read-full, subject-pull, bulk-get, bulk-put, bulk-append,
         pool-warmup, worker-sweep, resolve, index, lint, lock, queue, cache, soak

Options:
  -n / --iterations N   timed iterations (default 10 read / 5 write)
  -k / --count K        notes/appends for bulk metrics (default 50)
  --workers W           override ECHO_WORKERS for this run
  --warmup M            warm-up iterations to discard (default 2)
  --soak-seconds S      duration for the soak metric (default 60)
  --keep                do NOT delete the _bench namespace after the run
  --no-gate             skip relative-ratio gating (report numbers only)
  --json-out PATH       write the results JSON here (default results/<date>-<commit>.json)
  --quiet               suppress the per-metric human table (JSON only)

Exit: 0 all gates pass (or --no-gate) · 1 a gate failed · 2 usage/setup error.
"""

from __future__ import annotations

import argparse
import contextlib
import datetime as dt
import io
import json
import os
import statistics
import subprocess
import sys
import time
import uuid
from pathlib import Path

HERE = Path(__file__).resolve().parent
SCRIPTS = HERE.parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS))

import echo            # noqa: E402  the validated client — real pooled/concurrent path
import echo_index      # noqa: E402  resolve / fuzzy_candidates (1.2.0)
import echo_recall     # noqa: E402  recall (search + 1-hop expansion)

BASELINES = HERE / "baselines.json"
RESULTS_DIR = HERE / "results"


# ---------------------------------------------------------------------------
# stats + timing
# ---------------------------------------------------------------------------
def stats(samples: list[float]) -> dict:
    """min/median/p90/p95/max/mean in milliseconds from a list of seconds."""
    if not samples:
        return {}
    ms = sorted(s * 1000.0 for s in samples)

    def pct(p: float) -> float:
        if len(ms) == 1:
            return ms[0]
        k = (len(ms) - 1) * p
        lo, hi = int(k), min(int(k) + 1, len(ms) - 1)
        return ms[lo] + (ms[hi] - ms[lo]) * (k - lo)

    return {
        "n": len(ms),
        "min_ms": round(ms[0], 2),
        "median_ms": round(statistics.median(ms), 2),
        "p90_ms": round(pct(0.90), 2),
        "p95_ms": round(pct(0.95), 2),
        "max_ms": round(ms[-1], 2),
        "mean_ms": round(statistics.fmean(ms), 2),
    }


def timed(fn, n: int, warmup: int) -> dict:
    """Run fn() warmup+n times. Returns warm-up samples separately from the timed
    set so cold-start cost (the thing keep-alive kills) is visible, not averaged in."""
    warm: list[float] = []
    samples: list[float] = []
    for i in range(warmup + n):
        t0 = time.perf_counter()
        fn()
        dt_s = time.perf_counter() - t0
        (warm if i < warmup else samples).append(dt_s)
    out = stats(samples)
    if warm:
        out["warmup"] = stats(warm)
    return out


def silent(fn):
    """Run fn() with stdout swallowed (recall/resolve print a lot)."""
    def wrapped():
        with contextlib.redirect_stdout(io.StringIO()):
            return fn()
    return wrapped


# ---------------------------------------------------------------------------
# vault enumeration (mirrors sweep.py / vault_lint.py)
# ---------------------------------------------------------------------------
SKIP_BASENAMES = {"README.md"}


def _list_dir(path: str):
    p = "" if path in ("", "/") else (path if path.endswith("/") else path + "/")
    body = echo.get_text(p)
    if body is None:
        return [], []
    try:
        j = json.loads(body)
    except json.JSONDecodeError:
        return [], []
    entries = list(j.get("files", [])) + list(j.get("folders", []))
    files = [e for e in entries if not e.endswith("/")]
    folders = [e[:-1] for e in entries if e.endswith("/")]
    return files, folders


def walk_vault(prefix: str = ""):
    files, folders = _list_dir(prefix)
    for f in files:
        yield prefix + f
    for d in folders:
        yield from walk_vault(f"{prefix}{d}/")


def all_notes() -> list[str]:
    return [p for p in walk_vault()
            if p.endswith(".md") and p.rsplit("/", 1)[-1] not in SKIP_BASENAMES]


# ---------------------------------------------------------------------------
# write primitives (replicate echo.py semantics so we time the real cost)
# ---------------------------------------------------------------------------
def put_verified(path: str, body: str) -> None:
    """PUT then read-back GET — the same verify echo.py cmd_put does."""
    status, b = echo.request("PUT", echo.vault_url(path),
                             data=body.encode(), headers={"Content-Type": "text/markdown"})
    echo.check(status, b, f"put {path}")
    status, _ = echo.request("GET", echo.vault_url(path))
    if status != 200:
        raise echo.EchoError(f"put {path}: did not verify (GET {status})")


def append_line(path: str, line: str) -> bool:
    """Whole-line idempotent append (mirrors echo.py cmd_append). Returns True if a
    write happened, False if it was an idempotent skip."""
    status, body = echo.request("GET", echo.vault_url(path))
    if status == 200:
        existing = [ln.rstrip("\r") for ln in body.decode(errors="replace").splitlines()]
        if line.rstrip("\r") in existing:
            return False
    status, body = echo.request("POST", echo.vault_url(path),
                                data=f"{line}\n".encode(),
                                headers={"Content-Type": "text/markdown"})
    echo.check(status, body, f"append {path}")
    return True


def delete_path(path: str) -> None:
    echo.request("DELETE", echo.vault_url(path))


# ---------------------------------------------------------------------------
# metrics
# ---------------------------------------------------------------------------
def m_read_full(ctx) -> dict:
    """§3.1 full-vault read: serial GET vs concurrent read_many -> speedup ratio."""
    paths = all_notes()
    if not paths:
        raise echo.EchoError("read-full: no notes enumerated (vault unreachable?)")

    def serial():
        for p in paths:
            echo.get_text(p)

    def concurrent():
        echo.read_many(paths)

    # one warm pass so neither side pays the cold-handshake tax (pool-warmup owns that)
    echo.get_text(paths[0])
    # serial is a slow reference baseline (N serial round-trips); one warm pass is
    # enough to establish the ratio, so we don't multiply the full-vault serial cost.
    serial_t = timed(serial, n=1, warmup=0)
    concurrent_t = timed(concurrent, n=ctx.n, warmup=ctx.warmup)
    ratio = (serial_t["median_ms"] / concurrent_t["median_ms"]) if concurrent_t.get("median_ms") else None
    return {
        "note_count": len(paths),
        "workers": echo.MAX_WORKERS,
        "serial": serial_t,
        "concurrent": concurrent_t,
        "speedup_ratio": round(ratio, 2) if ratio else None,
        "notes_per_sec_concurrent": round(len(paths) / (concurrent_t["median_ms"] / 1000.0), 1)
        if concurrent_t.get("median_ms") else None,
    }


def m_subject_pull(ctx) -> dict:
    """§3.2 recall a subject: time resolve (floor), search, and full recall, split out."""
    import fixtures
    index = echo_index.load()
    out = {"subjects": []}
    for label, query in fixtures.SUBJECTS:
        # floor: O(1) index resolve
        resolve_t = timed(lambda: echo_index.resolve(index, query), n=ctx.n, warmup=ctx.warmup)
        # search-only layer
        def search():
            with contextlib.redirect_stdout(io.StringIO()):
                echo.request("POST", f"{echo.BASE}/search/simple/?query={query}")
        search_t = timed(search, n=ctx.n, warmup=ctx.warmup)
        # full recall (search + 1-hop expansion reads)
        recall_t = timed(silent(lambda: echo_recall.recall(query)), n=ctx.n, warmup=ctx.warmup)
        out["subjects"].append({
            "label": label, "query": query,
            "resolve_floor": resolve_t,
            "search_only": search_t,
            "recall_total": recall_t,
        })
    return out


def m_bulk_get(ctx) -> dict:
    """§3.3 bulk GET at a controlled K: serial vs read_many over fixture notes."""
    import fixtures
    paths = _seed_notes(ctx, fixtures)

    def serial():
        for p in paths:
            echo.get_text(p)

    def concurrent():
        echo.read_many(paths)

    serial_t = timed(serial, n=ctx.n, warmup=1)
    concurrent_t = timed(concurrent, n=ctx.n, warmup=ctx.warmup)
    ratio = (serial_t["median_ms"] / concurrent_t["median_ms"]) if concurrent_t.get("median_ms") else None
    return {"k": len(paths), "workers": echo.MAX_WORKERS,
            "serial": serial_t, "concurrent": concurrent_t,
            "speedup_ratio": round(ratio, 2) if ratio else None}


def m_bulk_put(ctx) -> dict:
    """§3.3 bulk PUT (read-back verified) -> puts/sec + per-put percentiles."""
    import fixtures
    k = ctx.k
    samples: list[float] = []
    for i in range(k):
        body = fixtures.note_body(ctx.run_id, i)
        path = fixtures.note_path(ctx.run_id, i)
        t0 = time.perf_counter()
        put_verified(path, body)
        samples.append(time.perf_counter() - t0)
    s = stats(samples)
    total_s = sum(samples)
    return {"k": k, "per_put": s, "total_ms": round(total_s * 1000, 1),
            "puts_per_sec": round(k / total_s, 1) if total_s else None}


def m_bulk_append(ctx) -> dict:
    """§3.3 bulk APPEND + idempotent-skip path. Confirms idempotency isn't O(file)."""
    import fixtures
    target = fixtures.append_target(ctx.run_id)
    put_verified(target, fixtures.append_target_seed(ctx.run_id))
    k = ctx.k
    lines = [f"- 2026-06-23: bench append line {i:04d}" for i in range(k)]

    write_samples: list[float] = []
    for ln in lines:
        t0 = time.perf_counter()
        wrote = append_line(target, ln)
        write_samples.append(time.perf_counter() - t0)
        if not wrote:
            raise echo.EchoError("bulk-append: expected a write but got an idempotent skip")

    # second pass: every line already present -> must skip, and skip cost is the
    # idempotency GET on a now-larger file. Confirms no superlinear blowup.
    skip_samples: list[float] = []
    skips = 0
    for ln in lines:
        t0 = time.perf_counter()
        wrote = append_line(target, ln)
        skip_samples.append(time.perf_counter() - t0)
        if not wrote:
            skips += 1

    return {
        "k": k,
        "append_write": stats(write_samples),
        "append_skip": stats(skip_samples),
        "skips_confirmed": skips,
        "all_skipped_second_pass": skips == k,
    }


def m_pool_warmup(ctx) -> dict:
    """§4.1 cold vs warm single GET — the clearest proof keep-alive is alive."""
    paths = all_notes()
    probe = paths[0] if paths else "_agent/echo-vault.md"
    echo._drop_connection()  # force a cold handshake
    t0 = time.perf_counter()
    echo.get_text(probe)
    cold_ms = (time.perf_counter() - t0) * 1000.0
    warm = timed(lambda: echo.get_text(probe), n=max(ctx.n, 10), warmup=1)
    ratio = round(cold_ms / warm["median_ms"], 2) if warm.get("median_ms") else None
    return {"probe": probe, "cold_ms": round(cold_ms, 2), "warm": warm,
            "cold_over_warm_ratio": ratio}


def m_worker_sweep(ctx) -> dict:
    """§4.2 concurrency scaling: full-vault concurrent read at workers 1,2,4,8,16."""
    paths = all_notes()
    if not paths:
        raise echo.EchoError("worker-sweep: no notes enumerated")
    original = echo.MAX_WORKERS
    rows = []
    try:
        # w=1 is a full serial pass (slow); the curve only needs the shape, so use a
        # modest sample count and a single pre-warm rather than per-setting warmups.
        reps = max(1, ctx.n // 2)
        for w in (1, 2, 4, 8, 16):
            echo.MAX_WORKERS = w
            echo.read_many(paths[:5])  # warm the pool at this worker count
            t = timed(lambda: echo.read_many(paths), n=reps, warmup=0)
            rows.append({"workers": w, "median_ms": t["median_ms"], "p95_ms": t["p95_ms"]})
    finally:
        echo.MAX_WORKERS = original
    best = min(rows, key=lambda r: r["median_ms"])
    return {"note_count": len(paths), "sweep": rows, "knee_workers": best["workers"],
            "default_workers": original}


def m_resolve(ctx) -> dict:
    """§4.3 fuzzy resolve — timing AND correctness against the 1.2.0 guard table."""
    import fixtures
    index = echo_index.load()
    cases = []
    passes = info = 0
    for case in fixtures.RESOLVE_CASES:
        mention = case["mention"]
        exact_t = timed(lambda: echo_index.resolve(index, mention), n=ctx.n, warmup=ctx.warmup)
        fuzzy_t = timed(lambda: echo_index.fuzzy_candidates(index, mention), n=ctx.n, warmup=ctx.warmup)
        _, entity = echo_index.resolve(index, mention)
        exact_slug = (entity or {}).get("slug") or _slug_from_entity(entity)
        cands = echo_index.fuzzy_candidates(index, mention) or []
        cand_slugs = [_cand_slug(c) for c in cands]

        expect, want = case["expect"], case.get("expect_slug")
        if expect == "exact":
            ok = exact_slug == want
        elif expect == "candidates":
            ok = (exact_slug != want) and (want in cand_slugs)
        else:  # miss
            ok = entity is None
        verdict = "PASS" if ok else "INFO"  # tune table to live vault before promoting to FAIL
        passes += ok
        info += (not ok)
        cases.append({"mention": mention, "expect": expect, "expect_slug": want,
                      "resolved_slug": exact_slug, "candidate_slugs": cand_slugs[:5],
                      "verdict": verdict,
                      "resolve_timing": exact_t, "fuzzy_timing": fuzzy_t})
    return {"cases": cases, "passes": passes, "needs_tuning": info,
            "note": "verdicts are PASS/INFO only; promote to FAIL once the table is "
                    "confirmed against the live vault"}


def _serial_expand(seeds, nmap, base, max_hops):
    """The pre-fix serial graph BFS, kept here as the reference baseline so the suite
    both (a) measures the concurrency speedup and (b) asserts the concurrent
    expand_graph still returns byte-identical ranking. If echo_recall.expand_graph ever
    regresses to serial, the ratio gate fails; if its results drift, the parity check fails."""
    from collections import deque
    score_of = dict(base)
    seen = set(seeds)
    results = {}
    dq = deque((s, 0) for s in seeds)
    while dq:
        path, hop = dq.popleft()
        if hop >= max_hops:
            continue
        text = echo_recall.links.get_text(path)
        if text is None:
            continue
        body = echo_recall.strip_frontmatter(text)
        targets = set(echo_recall.links.all_wikilinks(body)) | set(echo_recall.links.source_notes(text))
        parent = score_of.get(path, 1.0)
        for t in targets:
            tp = echo_recall._resolve_target(t, nmap)
            if not tp or tp in seeds:
                continue
            decayed = parent * (echo_recall.GRAPH_DECAY ** (hop + 1))
            if decayed > results.get(tp, (0.0, ""))[0]:
                results[tp] = (decayed, path)
            if tp not in seen:
                seen.add(tp)
                dq.append((tp, hop + 1))
    return sorted(results.items(), key=lambda kv: -kv[1][0])


def m_expand_graph(ctx) -> dict:
    """recall() graph layer: serial vs concurrent neighbourhood expansion. This is the
    metric that pins the real recall() bottleneck (the graph BFS, not BM25) and guards
    the read_many concurrency fix. Also asserts the concurrent path's ranking + scores
    match the serial reference exactly."""
    import fixtures
    ix = echo_recall.load_index()
    index = echo_index.load()
    nmap = echo_index.name_map(index)
    max_hops = echo_recall.MAX_HOPS
    # hub subjects stress expansion most; fall back to all subjects if none labelled hub
    subjects = [s for s in fixtures.SUBJECTS if "hub" in s[0]] or fixtures.SUBJECTS
    rows = []
    for label, query in subjects:
        hits = ix.score(query, limit=8)
        base = {p: s for p, s in hits}
        seeds = [p for p, _ in hits]
        if not seeds:
            continue
        echo_recall.expand_graph(seeds, nmap, base, max_hops)  # warm
        serial_t = timed(lambda: _serial_expand(seeds, nmap, base, max_hops), n=1, warmup=0)
        conc_t = timed(lambda: echo_recall.expand_graph(seeds, nmap, base, max_hops),
                       n=ctx.n, warmup=1)
        old = _serial_expand(seeds, nmap, base, max_hops)
        new = echo_recall.expand_graph(seeds, nmap, base, max_hops)
        ranking_ok = [p for p, _ in old] == [p for p, _ in new]
        scores_ok = ranking_ok and all(
            abs(old[i][1][0] - new[i][1][0]) < 1e-9 for i in range(len(old)))
        ratio = (serial_t["median_ms"] / conc_t["median_ms"]) if conc_t.get("median_ms") else None
        rows.append({"label": label, "query": query, "neighbours": len(new),
                     "serial_ms": serial_t["median_ms"], "concurrent_ms": conc_t["median_ms"],
                     "speedup": round(ratio, 2) if ratio else None,
                     "ranking_identical": ranking_ok, "scores_identical": scores_ok})
    speedups = [r["speedup"] for r in rows if r["speedup"]]
    return {"subjects": rows,
            "min_speedup": round(min(speedups), 2) if speedups else None,
            "all_ranking_identical": all(r["ranking_identical"] for r in rows),
            "all_scores_identical": all(r["scores_identical"] for r in rows)}


def _slug_from_entity(entity):
    if not entity:
        return None
    path = entity.get("path", "")
    return path.rsplit("/", 1)[-1].removesuffix(".md") if path else None


def _cand_slug(c):
    if isinstance(c, dict):
        return c.get("slug") or _slug_from_entity(c)
    if isinstance(c, (list, tuple)) and c:
        return c[0]
    return str(c)


def m_index(ctx) -> dict:
    """§4.4 entity-index rebuild + link symmetrize timing (sweep.py, dry-run = read-only)."""
    return _time_script("sweep.py", [])


def m_lint(ctx) -> dict:
    """§4.5 full-vault lint timing (vault_lint.py) — regression-gate vs 1.1.0 baseline."""
    return _time_script("vault_lint.py", [], allow_exit={0, 1})


def _time_script(name: str, extra: list[str], allow_exit=frozenset({0})) -> dict:
    script = SCRIPTS / name
    env = dict(os.environ, ECHO_KEY_LEGACY_OK="1")
    t0 = time.perf_counter()
    proc = subprocess.run([sys.executable, str(script), *extra],
                          capture_output=True, text=True, env=env, timeout=120)
    elapsed = (time.perf_counter() - t0) * 1000.0
    return {"script": name, "elapsed_ms": round(elapsed, 1),
            "exit_code": proc.returncode,
            "ok": proc.returncode in allow_exit,
            "stderr_tail": proc.stderr.strip().splitlines()[-3:] if proc.stderr else []}


def m_lock(ctx) -> dict:
    """§4.6 advisory-lock timing: acquire-free, contended (held-fresh), release."""
    owner = f"bench-{ctx.run_id}"
    other = f"bench-other-{ctx.run_id}"

    def acquire(o):
        return echo.cmd_lock(o, quiet=True)

    def release(o):
        return echo.cmd_unlock(o, quiet=True)

    release(owner); release(other)  # clean slate
    t0 = time.perf_counter(); rc_free = acquire(owner); free_ms = (time.perf_counter() - t0) * 1000
    t0 = time.perf_counter(); rc_held = acquire(other); held_ms = (time.perf_counter() - t0) * 1000
    t0 = time.perf_counter(); release(owner); rel_ms = (time.perf_counter() - t0) * 1000
    return {"acquire_free_ms": round(free_ms, 2), "acquire_free_rc": rc_free,
            "contended_ms": round(held_ms, 2), "contended_rc_expected_75": rc_held,
            "release_ms": round(rel_ms, 2),
            "ok": rc_free == 0 and rc_held == 75}


def m_queue(ctx) -> dict:
    """§4.7 offline write-ahead queue: enqueue K writes against an unreachable base,
    then time the flush replay on 'reconnect'. Uses a bogus base so nothing real is
    written; restores the base afterward. No production writes occur."""
    import importlib
    import echo_queue
    k = min(ctx.k, 20)
    saved_base = echo.BASE
    enqueue_samples: list[float] = []
    try:
        for i in range(k):
            url = f"{echo.BASE}/vault/{fixtures_qpath(ctx.run_id, i)}"
            t0 = time.perf_counter()
            echo_queue.enqueue("PUT", url, b"x", {"Content-Type": "text/markdown"},
                               idem_key=f"bench-{ctx.run_id}-{i}")
            enqueue_samples.append(time.perf_counter() - t0)
        pending_before = len(echo_queue.pending())
        # flush is left UNMEASURED-as-success here: with a real endpoint it would
        # try to write. We only assert enqueue worked and report depth; a true
        # flush-replay timing needs a sandbox vault (see README "queue caveat").
        return {"k": k, "enqueue": stats(enqueue_samples),
                "pending_after_enqueue": pending_before,
                "note": "flush replay intentionally not run against the live vault; "
                        "enqueue path timed only. See README queue caveat."}
    finally:
        echo.BASE = saved_base
        importlib.reload(echo_queue)


def fixtures_qpath(run_id: str, i: int) -> str:
    return f"_agent/_bench/{run_id}/queued-{i:04d}.md"


def m_cache(ctx) -> dict:
    """§4.8 read_many dedup check: a path list with duplicates must collapse to the
    unique set (the cache/dedup that stops a sweep re-fetching a link target)."""
    paths = all_notes()[:20] or ["_agent/echo-vault.md"]
    dupd = paths + paths + paths  # 3x
    t0 = time.perf_counter()
    result = echo.read_many(dupd)
    elapsed = (time.perf_counter() - t0) * 1000
    return {"requested": len(dupd), "unique": len(set(dupd)),
            "returned": len(result), "deduped": len(result) == len(set(dupd)),
            "elapsed_ms": round(elapsed, 2)}


def m_soak(ctx) -> dict:
    """§4.10 stability: loop full-vault reads for N seconds; watch for errors/drift."""
    paths = all_notes()
    if not paths:
        raise echo.EchoError("soak: no notes enumerated")
    deadline = time.time() + ctx.soak_seconds
    durations: list[float] = []
    errors = 0
    while time.time() < deadline:
        t0 = time.perf_counter()
        try:
            res = echo.read_many(paths)
            if any(v is None for v in res.values()):
                errors += 1
        except Exception:
            errors += 1
        durations.append(time.perf_counter() - t0)
    s = stats(durations)
    # drift = late iterations slower than early ones (leak / pool exhaustion signal)
    half = len(durations) // 2 or 1
    early = statistics.fmean(durations[:half]) * 1000
    late = statistics.fmean(durations[half:]) * 1000
    return {"seconds": ctx.soak_seconds, "iterations": len(durations), "errors": errors,
            "per_pass": s, "early_mean_ms": round(early, 2), "late_mean_ms": round(late, 2),
            "drift_ratio_late_over_early": round(late / early, 2) if early else None}


METRICS = {
    "read-full": m_read_full,
    "subject-pull": m_subject_pull,
    "bulk-get": m_bulk_get,
    "bulk-put": m_bulk_put,
    "bulk-append": m_bulk_append,
    "pool-warmup": m_pool_warmup,
    "worker-sweep": m_worker_sweep,
    "resolve": m_resolve,
    "expand-graph": m_expand_graph,
    "index": m_index,
    "lint": m_lint,
    "lock": m_lock,
    "queue": m_queue,
    "cache": m_cache,
    "soak": m_soak,
}

# the read-only safe set run by `all` (no writes to the vault)
READONLY = ["pool-warmup", "read-full", "worker-sweep", "subject-pull",
            "resolve", "expand-graph", "index", "lint", "cache"]
WRITES = {"bulk-get", "bulk-put", "bulk-append", "lock"}  # touch the _bench namespace


# ---------------------------------------------------------------------------
# fixture seeding / cleanup
# ---------------------------------------------------------------------------
def _seed_notes(ctx, fixtures) -> list[str]:
    """Ensure K bench notes exist; return their paths. Cached on ctx so bulk-get
    and bulk-put can share the seed within one run."""
    if getattr(ctx, "_seeded", None):
        return ctx._seeded
    paths = []
    for i in range(ctx.k):
        p = fixtures.note_path(ctx.run_id, i)
        put_verified(p, fixtures.note_body(ctx.run_id, i))
        paths.append(p)
    ctx._seeded = paths
    return paths


def cleanup_namespace(run_id: str) -> int:
    """Delete every file under _agent/_bench/<run-id>/. Returns count deleted."""
    prefix = f"_agent/_bench/{run_id}/"
    victims = [p for p in walk_vault(prefix)]
    for p in victims:
        delete_path(p)
    # remove the (now-empty) run dir marker if the API created one — best effort
    return len(victims)


# ---------------------------------------------------------------------------
# gating
# ---------------------------------------------------------------------------
def load_baselines() -> dict:
    if BASELINES.exists():
        return json.loads(BASELINES.read_text())
    return {}


def gate(metric: str, result: dict, baselines: dict) -> list[dict]:
    """Apply relative-ratio gates from baselines.json. Returns a list of checks."""
    rules = baselines.get(metric, {})
    checks = []
    for rule in rules.get("gates", []):
        path, op, threshold = rule["field"], rule["op"], rule["min"]
        val = _dig(result, path)
        if val is None:
            checks.append({"rule": rule, "value": None, "pass": None, "reason": "field absent"})
            continue
        ok = (val >= threshold) if op == ">=" else (val <= threshold)
        checks.append({"rule": rule, "value": val, "pass": ok})
    return checks


def _dig(obj, dotted: str):
    cur = obj
    for part in dotted.split("."):
        if isinstance(cur, list):
            try:
                cur = cur[int(part)]
                continue
            except (ValueError, IndexError):
                return None
        if not isinstance(cur, dict) or part not in cur:
            return None
        cur = cur[part]
    return cur


# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
class Ctx:
    pass


def env_block(args) -> dict:
    commit = "unknown"
    try:
        commit = subprocess.run(["git", "rev-parse", "--short", "HEAD"],
                                cwd=str(SCRIPTS), capture_output=True, text=True,
                                timeout=10).stdout.strip() or "unknown"
    except Exception:
        pass
    return {
        "date": dt.datetime.now().isoformat(timespec="seconds"),
        "endpoint": echo.BASE,
        "commit": commit,
        "echo_workers": echo.MAX_WORKERS,
        "echo_timeout": echo.TIMEOUT,
        "python": sys.version.split()[0],
        "iterations": args.iterations,
        "count_k": args.count,
    }


def human_table(name: str, result: dict, checks: list[dict]) -> str:
    lines = [f"\n=== {name} ==="]
    lines.append(json.dumps(result, indent=2))
    for c in checks:
        mark = "PASS" if c["pass"] else ("—" if c["pass"] is None else "FAIL")
        lines.append(f"  [{mark}] {c['rule']['field']} {c['rule']['op']} {c['rule']['min']}  (got {c['value']})")
    return "\n".join(lines)


def main(argv=None) -> int:
    ap = argparse.ArgumentParser(description="ECHO performance test suite (manual/pre-release)")
    ap.add_argument("metric", nargs="?", help="metric name, or 'all' for the read-only set")
    ap.add_argument("--list", action="store_true", help="list metrics and exit")
    ap.add_argument("-n", "--iterations", type=int, default=None)
    ap.add_argument("-k", "--count", type=int, default=50)
    ap.add_argument("--workers", type=int, default=None)
    ap.add_argument("--warmup", type=int, default=2)
    ap.add_argument("--soak-seconds", type=int, default=60)
    ap.add_argument("--keep", action="store_true", help="do not delete the _bench namespace")
    ap.add_argument("--no-gate", action="store_true")
    ap.add_argument("--json-out", default=None)
    ap.add_argument("--quiet", action="store_true")
    args = ap.parse_args(argv)

    if args.list or not args.metric:
        print("Metrics:", ", ".join(METRICS))
        print("Groups : all (read-only safe set:", ", ".join(READONLY) + ")")
        print("Writes to _bench namespace:", ", ".join(sorted(WRITES)))
        return 0 if args.list else 2

    if args.workers is not None:
        echo.MAX_WORKERS = args.workers

    selected = READONLY if args.metric == "all" else [args.metric]
    for m in selected:
        if m not in METRICS:
            print(f"unknown metric: {m}", file=sys.stderr)
            return 2

    ctx = Ctx()
    ctx.run_id = dt.datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:6]
    ctx.k = args.count
    ctx.warmup = args.warmup
    ctx.soak_seconds = args.soak_seconds
    ctx._seeded = None

    baselines = load_baselines()
    need_writes = any(m in WRITES for m in selected)
    lock_owner = f"bench-{ctx.run_id}"
    if need_writes:
        if echo.cmd_lock(lock_owner, quiet=True) == 75:
            print("bench: vault lock is held by another session — aborting write metrics.",
                  file=sys.stderr)
            return 2

    report = {"env": env_block(args), "run_id": ctx.run_id, "metrics": {}}
    any_fail = False
    try:
        for m in selected:
            ctx.n = args.iterations if args.iterations is not None else (5 if m in WRITES else 10)
            try:
                result = METRICS[m](ctx)
            except Exception as exc:  # noqa: BLE001
                result = {"error": str(exc)}
                any_fail = True
            checks = [] if args.no_gate else gate(m, result, baselines)
            if any(c["pass"] is False for c in checks):
                any_fail = True
            report["metrics"][m] = {"result": result, "gates": checks}
            if not args.quiet:
                print(human_table(m, result, checks))
    finally:
        if need_writes:
            echo.cmd_unlock(lock_owner, quiet=True)
        if need_writes and not args.keep:
            n = cleanup_namespace(ctx.run_id)
            report["cleanup"] = {"deleted": n}
            if not args.quiet:
                print(f"\nbench: cleaned up {n} files under _agent/_bench/{ctx.run_id}/")
        elif need_writes and args.keep:
            report["cleanup"] = {"deleted": 0, "kept": f"_agent/_bench/{ctx.run_id}/"}

    RESULTS_DIR.mkdir(parents=True, exist_ok=True)
    out_path = Path(args.json_out) if args.json_out else \
        RESULTS_DIR / f"{report['env']['date'][:10]}-{report['env']['commit']}.json"
    out_path.write_text(json.dumps(report, indent=2))
    print(f"\nbench: results -> {out_path}")
    return 1 if any_fail else 0


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