"""Generate `EVAL_CATALOG.md` from eval test files and `categories.json`.

Usage:
    python scripts/generate_eval_catalog.py           # writes EVAL_CATALOG.md
    python scripts/generate_eval_catalog.py --check   # exits 1 if file is stale
"""

from __future__ import annotations

import argparse
import ast
import difflib
import json
from pathlib import Path

_EVALS_DIR = Path(__file__).resolve().parents[1]
"""Root of the evals package (libs/evals/)."""

_TESTS_DIR = _EVALS_DIR / "tests" / "evals"
"""Directory containing eval test modules."""

_CATEGORIES_JSON = _EVALS_DIR / "deepagents_evals" / "categories.json"
"""Category definitions: ordering and human-readable labels."""

_OUTPUT = _EVALS_DIR / "EVAL_CATALOG.md"
"""Generated output file."""

_GITHUB_BASE = "https://github.com/langchain-ai/deepagents/blob/main/libs/evals"
"""Base URL for linking to source lines on GitHub (always targets `main`)."""

_HEADER = """\
<!-- AUTO-GENERATED by scripts/generate_eval_catalog.py — do not edit manually. -->
# Eval catalog

Quick reference for every eval, grouped by category.
Source of truth: [`tests/evals/`](tests/evals/).
"""
"""Markdown preamble written at the top of the generated file."""


def _is_eval_category_call(node: object) -> str | None:
    """Return the category name if *node* is an `eval_category("name")` call.

    Only the terminal attribute name is checked; the `pytest.mark` prefix is
    not verified.
    """
    if not (
        isinstance(node, ast.Call)
        and isinstance(node.func, ast.Attribute)
        and node.func.attr == "eval_category"
        and node.args
        and isinstance(node.args[0], ast.Constant)
    ):
        return None
    return str(node.args[0].value)


def _collect_evals() -> dict[str, list[tuple[str, str, int]]]:
    """Discover all eval functions grouped by category.

    Returns:
        Mapping of category -> list of (function_name, path_relative_to_evals_dir, line_number).
    """
    catalog: dict[str, list[tuple[str, str, int]]] = {}

    for path in sorted(_TESTS_DIR.rglob("test_*.py")):
        tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
        rel = str(path.relative_to(_EVALS_DIR))

        # Collect module-level categories from pytestmark.
        module_cats: list[str] = []
        functions: list[tuple[str, int, list[str]]] = []

        for node in ast.iter_child_nodes(tree):
            if isinstance(node, ast.Assign):
                targets = [t.id for t in node.targets if isinstance(t, ast.Name)]
                if "pytestmark" in targets:
                    for elt in ast.walk(node.value):
                        cat = _is_eval_category_call(elt)
                        if cat:
                            module_cats.append(cat)

            if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
                if not node.name.startswith("test_"):
                    continue
                # Per-function categories from decorators.
                fn_cats: list[str] = []
                for decorator in node.decorator_list:
                    cat = _is_eval_category_call(decorator)
                    if cat:
                        fn_cats.append(cat)
                functions.append((node.name, node.lineno, fn_cats))

        for fn_name, lineno, fn_cats in functions:
            cats = fn_cats or module_cats
            for cat in cats:
                catalog.setdefault(cat, []).append((fn_name, rel, lineno))

    return catalog


def generate() -> str:
    """Return the full markdown content for `EVAL_CATALOG.md`.

    Category ordering and display labels are read from `categories.json`.
    """
    with _CATEGORIES_JSON.open(encoding="utf-8") as f:
        meta = json.load(f)

    for key in ("categories", "labels"):
        if key not in meta:
            msg = f"{_CATEGORIES_JSON} is missing required key {key!r}."
            raise ValueError(msg)

    categories: list[str] = meta["categories"]
    labels: dict[str, str] = meta["labels"]
    catalog = _collect_evals()

    unknown = set(catalog.keys()) - set(categories)
    if unknown:
        msg = (
            f"Evals found with categories not in categories.json: {sorted(unknown)}. "
            f"Add them to {_CATEGORIES_JSON} or fix the decorator typo."
        )
        raise ValueError(msg)

    lines: list[str] = [_HEADER]

    lines.append("Categories (for `--eval-category` filtering):\n")
    lines.append(f"```txt\n{','.join(categories)}\n```\n")

    total = sum(len(catalog.get(cat, [])) for cat in categories)
    lines.append(f"**{total} evals** across **{len(categories)} categories**\n")

    for cat in categories:
        evals = catalog.get(cat, [])
        label = labels.get(cat, cat)
        count = len(evals)
        noun = "eval" if count == 1 else "evals"
        lines.append(f"## {label} (`{cat}`) ({count} {noun})\n")

        for fn_name, rel_path, lineno in evals:
            gh_link = f"{_GITHUB_BASE}/{rel_path}#L{lineno}"
            ide_path = f"{rel_path}:{lineno}"
            lines.append(f"- [`{fn_name}`]({gh_link}) — `{ide_path}`")

        lines.append("")

    return "\n".join(lines)


def main() -> None:
    """Entry point."""
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--check",
        action="store_true",
        help="Check that EVAL_CATALOG.md is up-to-date (exit 1 if stale).",
    )
    args = parser.parse_args()

    expected = generate()

    if args.check:
        if not _OUTPUT.exists():
            print(f"MISSING: {_OUTPUT}")
            print("Run `make eval-catalog` from libs/evals/ to regenerate.")
            raise SystemExit(1)
        actual = _OUTPUT.read_text(encoding="utf-8")
        if actual != expected:
            diff = difflib.unified_diff(
                actual.splitlines(),
                expected.splitlines(),
                fromfile="EVAL_CATALOG.md (on disk)",
                tofile="EVAL_CATALOG.md (expected)",
                lineterm="",
            )
            print(f"STALE: {_OUTPUT}\n")
            print("\n".join(diff))
            print(
                "\nRun `make eval-catalog` from libs/evals/ to regenerate."
            )
            raise SystemExit(1)
        print(f"OK: {_OUTPUT} is up-to-date")
    else:
        _OUTPUT.write_text(expected, encoding="utf-8")
        print(f"Wrote {_OUTPUT}")


if __name__ == "__main__":
    main()
