"""Build an evaluation dataset: cross-product of queries × search engines × judge LLMs.

For each (query, engine, model) cell, runs `quick_summary` end-to-end and
saves (a) the formatted report and (b) a verbose log capturing the
filter's KEPT/REMOVED decisions. Writes a summary CSV so you can rank
runs by source count, timing, and visually inspect outliers.

Resumable: cells whose report file already exists are skipped unless
--force is passed. Use this to recover from interrupted runs or to
extend an existing dataset with new rows.

Usage (defaults produce a ~12-cell grid ≈ 60-90min total on qwen3.5:9b):
    LDR_BOOTSTRAP_ALLOW_UNENCRYPTED=true LDR_TESTING_WITH_MOCKS=false \\
    pdm run python tests/performance/_shared/build_eval_dataset.py \\
        --output-dir ./ldr_eval_output

Override any axis from the CLI:
    ... --queries "q1|q2" --engines "arxiv,openalex" --models "qwen3.5:9b,gemma3:12b"

Be conservative with --parallel — each parallel slot loads an LLM on the
Ollama endpoint; running 2+ different model tags concurrently may OOM
the server. Same-model parallel is usually fine (Ollama queues).
"""

from __future__ import annotations

import argparse
import csv
import os
import re
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path

DEFAULT_QUERIES = [
    "LLM interpretability latest research",
    "mechanistic interpretability of transformer language models",
    "sparse autoencoders for neural network feature discovery",
    "safety alignment and refusal in large language models",
]
DEFAULT_ENGINES = ["arxiv", "openalex"]
DEFAULT_MODELS = ["qwen3.5:9b", "gemma3:12b", "ministral-3:14b"]


def slugify(text: str, max_len: int = 50) -> str:
    s = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
    return s[:max_len]


@dataclass
class Cell:
    query: str
    engine: str
    model: str

    def key(self) -> str:
        return f"{self.engine}__{slugify(self.model)}__{slugify(self.query)}"


def already_done(cell: Cell, out_dir: Path) -> bool:
    return (out_dir / "reports" / f"{cell.key()}.md").exists()


def run_cell(cell: Cell, out_dir: Path, ollama_url: str) -> dict:
    """Invoke run_full_search.py as a subprocess for isolation between cells."""
    report_path = out_dir / "reports" / f"{cell.key()}.md"
    log_path = out_dir / "logs" / f"{cell.key()}.log"

    script = Path(__file__).parent / "run_full_search.py"
    cmd = [
        sys.executable,
        str(script),
        "--query",
        cell.query,
        "--engine",
        cell.engine,
        "--model",
        cell.model,
        "--ollama-url",
        ollama_url,
        "--verbose",
        "--output",
        str(report_path),
    ]

    env = os.environ.copy()
    env.setdefault("LDR_BOOTSTRAP_ALLOW_UNENCRYPTED", "true")
    env.setdefault("LDR_TESTING_WITH_MOCKS", "false")

    t0 = time.monotonic()
    try:
        with log_path.open("w") as logf:
            proc = subprocess.run(
                cmd,
                env=env,
                stdout=logf,
                stderr=subprocess.STDOUT,
                timeout=60 * 30,  # 30-min hard cap per cell
                check=False,
            )
        elapsed = time.monotonic() - t0
        exit_code = proc.returncode
    except subprocess.TimeoutExpired:
        elapsed = time.monotonic() - t0
        exit_code = -1
        # A partially-written report would otherwise cause ``already_done``
        # to skip this cell on the next run even though it never finished.
        report_path.unlink(missing_ok=True)
    except Exception as exc:
        elapsed = time.monotonic() - t0
        exit_code = -2
        log_path.write_text(f"Exception: {exc!r}\n")
        report_path.unlink(missing_ok=True)

    result = {
        "query": cell.query,
        "engine": cell.engine,
        "model": cell.model,
        "elapsed_s": round(elapsed, 1),
        "exit_code": exit_code,
        "report_path": str(report_path) if report_path.exists() else "",
        "sources": 0,
        "log_path": str(log_path),
    }

    if report_path.exists():
        text = report_path.read_text()
        m = re.search(r"^\s*-\s*\*\*Sources:\*\*\s*(\d+)", text, re.M)
        if m:
            result["sources"] = int(m.group(1))

    return result


def parse_list(
    s: str, sep_primary: str = "|", sep_fallback: str = ","
) -> list[str]:
    """Parse a delimiter-separated list — '|' preferred for queries with commas."""
    sep = sep_primary if sep_primary in s else sep_fallback
    return [x.strip() for x in s.split(sep) if x.strip()]


def main() -> int:
    p = argparse.ArgumentParser(
        description="Build a cross-product eval dataset (query × engine × model).",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    p.add_argument(
        "--queries",
        default=None,
        help="Queries separated by '|'. Default: a 4-query interpretability set.",
    )
    p.add_argument(
        "--engines",
        default=",".join(DEFAULT_ENGINES),
        help="Comma-separated engine names.",
    )
    p.add_argument(
        "--models",
        default=",".join(DEFAULT_MODELS),
        help="Comma-separated Ollama model tags.",
    )
    p.add_argument(
        "--output-dir",
        default="./ldr_eval_output",
        help="Where reports/, logs/, summary.csv land.",
    )
    p.add_argument(
        "--ollama-url",
        default=os.environ.get(
            "LDR_TEST_OLLAMA_BASE_URL", "http://localhost:11434"
        ),
    )
    p.add_argument(
        "--parallel",
        type=int,
        default=1,
        help="Cells to run concurrently. 1 = sequential. 2+ may OOM Ollama if different models are loaded simultaneously.",
    )
    p.add_argument(
        "--force",
        action="store_true",
        help="Re-run cells even if their report file already exists.",
    )
    args = p.parse_args()

    queries = (
        parse_list(args.queries) if args.queries else list(DEFAULT_QUERIES)
    )
    engines = parse_list(args.engines)
    models = parse_list(args.models)

    out_dir = Path(args.output_dir).resolve()
    (out_dir / "reports").mkdir(parents=True, exist_ok=True)
    (out_dir / "logs").mkdir(parents=True, exist_ok=True)

    cells = [
        Cell(query=q, engine=e, model=m)
        for q in queries
        for e in engines
        for m in models
    ]

    total = len(cells)
    todo = [c for c in cells if args.force or not already_done(c, out_dir)]
    skipped = total - len(todo)

    print(
        f"Grid: {len(queries)} queries × {len(engines)} engines × {len(models)} models = {total} cells",
        file=sys.stderr,
    )
    print(f"  {skipped} already done (use --force to re-run)", file=sys.stderr)
    print(f"  {len(todo)} to run, parallel={args.parallel}", file=sys.stderr)
    print(f"  Output: {out_dir}", file=sys.stderr)

    summary_path = out_dir / "summary.csv"
    summary_rows: list[dict] = []
    if summary_path.exists():
        with summary_path.open() as f:
            summary_rows = list(csv.DictReader(f))

    def run_and_log(cell: Cell) -> dict:
        print(
            f"  >>> {cell.engine} | {cell.model} | {cell.query[:60]}",
            file=sys.stderr,
        )
        r = run_cell(cell, out_dir, args.ollama_url)
        status = "ok" if r["exit_code"] == 0 else f"exit={r['exit_code']}"
        print(
            f"      {status}  {r['elapsed_s']}s  sources={r['sources']}  {cell.key()}",
            file=sys.stderr,
        )
        return r

    t0 = time.monotonic()
    if args.parallel <= 1:
        for cell in todo:
            summary_rows = _upsert(summary_rows, run_and_log(cell))
            _write_summary(summary_path, summary_rows)
    else:
        with ThreadPoolExecutor(max_workers=args.parallel) as pool:
            futures = {pool.submit(run_and_log, c): c for c in todo}
            for fut in as_completed(futures):
                summary_rows = _upsert(summary_rows, fut.result())
                _write_summary(summary_path, summary_rows)

    elapsed = time.monotonic() - t0
    print(
        f"\nDataset build complete in {elapsed / 60:.1f}min.", file=sys.stderr
    )
    print(f"  Summary: {summary_path}", file=sys.stderr)
    _print_table(summary_rows)
    return 0


def _upsert(rows: list[dict], new: dict) -> list[dict]:
    """Replace a row with the same (query, engine, model) or append."""
    out = [
        r
        for r in rows
        if not (
            r.get("query") == new["query"]
            and r.get("engine") == new["engine"]
            and r.get("model") == new["model"]
        )
    ]
    out.append({k: str(v) for k, v in new.items()})
    return out


def _write_summary(path: Path, rows: list[dict]) -> None:
    if not rows:
        return
    fieldnames = [
        "query",
        "engine",
        "model",
        "sources",
        "elapsed_s",
        "exit_code",
        "report_path",
        "log_path",
    ]
    with path.open("w", newline="") as f:
        w = csv.DictWriter(f, fieldnames=fieldnames)
        w.writeheader()
        for r in rows:
            w.writerow({k: r.get(k, "") for k in fieldnames})


def _print_table(rows: list[dict]) -> None:
    if not rows:
        return
    print("\n=== Dataset summary ===")
    print(
        f"{'engine':10s} {'model':20s} {'sources':>8s} {'elapsed_s':>10s}  query"
    )
    for r in rows:
        print(
            f"{r.get('engine', ''):10s} {r.get('model', ''):20s} "
            f"{str(r.get('sources', '')):>8s} {str(r.get('elapsed_s', '')):>10s}  "
            f"{r.get('query', '')[:60]}"
        )


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