"""``testctl catalog`` — generate ``fed-resources/generated/test_catalog.json``.

Enumerates the project's *real* tests with ``pytest --collect-only -q`` (the
runner's own truth, never a hand-maintained list) and canonicalizes the node-ids
into the fixed **category → group → test** tree (**D-CATALOG-CANON**):

* node-ids are sorted and de-duplicated;
* **category** = the test file's top-level directory (``tests/unit/x.py`` → ``tests``);
* **group**    = the test file itself (``tests/unit/x.py``);
* **test**     = the full node-id (``tests/unit/x.py::Klass::test_y``).

Fully deterministic, so the freshness gate's ``regenerate → byte-identical`` holds
— which is exactly why no wall-clock ``generated_at`` and no volatile
``source_commit`` are written (both would defeat the byte-identity). Each test is
emitted with ``runner:"pytest"``, ``independent:false``, ``last_status:"unknown"``
and empty ``config``/``config_schema``/``tags`` (markers aren't read here — that
needs a second collection pass; left as the cheap ``[]`` default per the brief).
The result validates clean against the mgf-fedres ``test_catalog`` schema.
"""

from __future__ import annotations

import json
import subprocess
from datetime import UTC, datetime
from pathlib import Path, PurePosixPath
from typing import Any

from mgf.fedres.canonical import dumpb

from ._gitref import head_commit
from ._pyenv import pytest_cmd
from ._zig import ZIG_RUNNER_ID
from ._zig import test_steps as zig_test_steps

RUNNER_ID = "pytest"
ENUMERATE = "pytest --collect-only -q"
_ROOT_CATEGORY = "(root)"

#: Marker files that identify a Python/pytest project. A repo with any of these is
#: enumerated with pytest; a repo with none of them (e.g. a Zig repo carrying only
#: ``build.zig``) is *not* — forcing pytest there yields a misleading "install
#: pytest" error for a project that never had Python tests.
_PYTEST_MARKERS = ("pyproject.toml", "setup.py", "setup.cfg", "pytest.ini", "tox.ini")


class CollectionError(RuntimeError):
    """pytest collection failed — a nonzero exit with nothing collected.

    Carries the pytest output so ``testctl catalog`` can show *why* (a missing
    pytest in the project's venv, a ``minversion`` mismatch, an import error in
    a test module) instead of reporting an empty catalog as if the project
    simply had no tests. That silent-zero is exactly what hid a stale
    interpreter until someone noticed the catalog was empty.
    """

    def __init__(self, project_root: str | Path, returncode: int, output: str) -> None:
        self.project_root = str(project_root)
        self.returncode = returncode
        self.output = output
        tail = "\n".join(output.strip().splitlines()[-8:])
        super().__init__(
            f"pytest collection failed in {self.project_root} (exit {returncode}) — "
            f"no tests collected. Ensure the project's .venv has its test deps "
            f"(pytest) installed.\n{tail}"
        )


def detect_project_kind(project_root: str | Path) -> str:
    """Which test runner this project uses: ``"pytest"`` | ``"zig"`` | ``"unknown"``.

    A Python/pytest project is identified by a packaging/pytest marker
    (:data:`_PYTEST_MARKERS`); a Zig project by ``build.zig``. pytest wins a
    hybrid (it carries the importable Python package). ``"unknown"`` means no
    recognized test project at the root — neither marker is present.

    Only the pytest path is enumerated today; ``"zig"``/``"unknown"`` let
    ``testctl catalog`` report an honest, type-appropriate status instead of
    forcing pytest on a repo that has none (the ProofWarden spec §4.1 scopes a
    zig runner — ``zig build`` — but it is not yet implemented here).
    """
    root = Path(project_root)
    if any((root / marker).exists() for marker in _PYTEST_MARKERS):
        return "pytest"
    if (root / "build.zig").exists():
        return "zig"
    return "unknown"


def parse_collect_output(text: str) -> list[str]:
    """Pull node-ids out of ``pytest --collect-only -q`` stdout (sorted, unique).

    A node-id line is ``<file>.py::<rest>``; warnings/summary lines never carry a
    ``::`` after a ``.py`` head, so this filter is robust to the surrounding noise.
    """
    node_ids: set[str] = set()
    for raw in text.splitlines():
        line = raw.strip()
        head, sep, _rest = line.partition("::")
        if sep and head.endswith(".py"):
            node_ids.add(line)
    return sorted(node_ids)


def collect_node_ids(project_root: str | Path) -> list[str]:
    """Run pytest collection in ``project_root`` and parse the node-ids.

    Collection runs under the *project's own* interpreter (``<root>/.venv``, via
    :func:`mgf.test_web._pyenv.pytest_cmd`), never testctl's — a project's tests
    carry that project's test-deps and pyproject pins (``minversion`` &c.), so
    only its own venv can collect them, and this matches the interpreter the
    warden runs with so the catalog lists exactly what a run will execute.
    """
    proc = subprocess.run(  # noqa: S603 — cmd from _pyenv.pytest_cmd (project venv), not user input
        [*pytest_cmd(project_root), "--collect-only", "-q", "-p", "no:cacheprovider"],
        cwd=str(project_root),
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        check=False,
    )
    node_ids = parse_collect_output(proc.stdout)
    # pytest exit codes: 0 = collected, 5 = no tests found (legitimately empty).
    # Anything else with nothing collected — 2/3/4 (usage/config/internal, e.g. a
    # minversion mismatch) or 1 (the interpreter had no pytest at all) — is a real
    # failure. Surface it rather than silently writing a hollow "0 tests" catalog.
    if not node_ids and proc.returncode not in (0, 5):
        raise CollectionError(project_root, proc.returncode, proc.stdout)
    return node_ids


def _category_of(file_path: str) -> str:
    parts = PurePosixPath(file_path).parts
    return parts[0] if len(parts) > 1 else _ROOT_CATEGORY


def _test_entry(node_id: str, file_path: str) -> dict[str, Any]:
    prefix = file_path + "::"
    label = node_id[len(prefix) :] if node_id.startswith(prefix) else node_id
    return {
        "id": node_id,
        "label": label,
        "runner": RUNNER_ID,
        "independent": False,
        "tags": [],
        "last_status": "unknown",
        "config": {},
        "config_schema": [],
    }


def build_catalog(node_ids: list[str], project: str) -> dict[str, Any]:
    """Canonicalize node-ids into the fixed tree (deterministic / gate-clean)."""
    tree: dict[str, dict[str, list[dict[str, Any]]]] = {}
    for node_id in sorted(set(node_ids)):
        file_path = node_id.split("::", 1)[0]
        tree.setdefault(_category_of(file_path), {}).setdefault(file_path, []).append(
            _test_entry(node_id, file_path)
        )
    categories: list[dict[str, Any]] = []
    for cat in sorted(tree):
        group_map: dict[str, list[dict[str, Any]]] = tree[cat]
        groups: list[dict[str, Any]] = []
        for file_path in sorted(group_map):
            tests = sorted(group_map[file_path], key=lambda test: str(test["id"]))
            groups.append({"id": file_path, "label": PurePosixPath(file_path).name, "tests": tests})
        categories.append({"id": cat, "label": cat, "groups": groups})
    return {
        "schema_version": "0.1",
        "kind": "test_catalog",
        "project": project,
        "runners": [{"id": RUNNER_ID, "cmd": "pytest", "enumerate": ENUMERATE}],
        "categories": categories,
    }


def generate_catalog(project_root: str | Path, project: str) -> dict[str, Any]:
    """Collect + canonicalize: the full catalog dict for ``project_root``."""
    return build_catalog(collect_node_ids(project_root), project)


def catalog_path(project_root: str | Path) -> Path:
    return Path(project_root) / "fed-resources" / "generated" / "test_catalog.json"


def provenance_path(project_root: str | Path) -> Path:
    return Path(project_root) / "fed-resources" / "generated" / "test_catalog.provenance.json"


def write_provenance(project_root: str | Path) -> None:
    """Write the catalog's provenance sidecar — the commit + time it was generated.

    Deliberately **not** part of the deterministic catalog (which stays byte-identical
    for the freshness gate): this is volatile, un-gated metadata the console reads to
    flag a possibly-stale catalog — its ``source_commit`` vs the repo's current HEAD.
    """
    prov = {
        "schema_version": "0.1",
        "source_commit": head_commit(project_root) or "",
        "generated_at": datetime.now(UTC).isoformat(timespec="seconds"),
    }
    path = provenance_path(project_root)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(prov, indent=2) + "\n", encoding="utf-8")


def count_tests(catalog: dict[str, Any]) -> int:
    return sum(len(group["tests"]) for cat in catalog["categories"] for group in cat["groups"])


def write_catalog(project_root: str | Path, project: str) -> tuple[Path, dict[str, Any]]:
    """Generate the catalog + its provenance sidecar; return ``(path, catalog)``."""
    catalog = generate_catalog(project_root, project)
    path = catalog_path(project_root)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_bytes(dumpb(catalog))
    write_provenance(project_root)
    return path, catalog


# --------------------------------------------------------------------------- #
# Zig runner (D-CATALOG-CANON, zig variant). Zig has no per-test enumeration —
# the unit is a build *step* (``zig build --list-steps``); the test entries are
# the conventional ``test`` step (+ any ``test``-prefixed). Same fixed
# category→group→test tree + determinism as pytest, so the freshness gate holds.
# --------------------------------------------------------------------------- #


def _zig_test_entry(step: str) -> dict[str, Any]:
    return {
        "id": step,
        "label": step,
        "runner": ZIG_RUNNER_ID,
        "independent": False,
        "tags": [],
        "last_status": "unknown",
        "config": {},
        "config_schema": [],
    }


def build_zig_catalog(steps: list[tuple[str, str]], project: str) -> dict[str, Any]:
    """Canonicalize zig test steps into the fixed tree (deterministic / gate-clean)."""
    names = sorted({name for name, _desc in steps})
    tests = [_zig_test_entry(name) for name in names]
    groups = [{"id": "build.zig", "label": "build.zig", "tests": tests}] if tests else []
    categories = [{"id": "zig", "label": "zig", "groups": groups}] if groups else []
    return {
        "schema_version": "0.1",
        "kind": "test_catalog",
        "project": project,
        "runners": [
            {"id": ZIG_RUNNER_ID, "cmd": "zig build", "enumerate": "zig build --list-steps"}
        ],
        "categories": categories,
    }


def generate_zig_catalog(project_root: str | Path, project: str) -> dict[str, Any]:
    """Enumerate zig test steps + canonicalize: the full catalog dict for a Zig repo."""
    return build_zig_catalog(zig_test_steps(project_root), project)


def write_zig_catalog(project_root: str | Path, project: str) -> tuple[Path, dict[str, Any]]:
    """Generate the zig catalog + its provenance sidecar; return ``(path, catalog)``."""
    catalog = generate_zig_catalog(project_root, project)
    path = catalog_path(project_root)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_bytes(dumpb(catalog))
    write_provenance(project_root)
    return path, catalog
