#!/usr/bin/env python3
"""Select deterministic PR tests from the exact diff and source risk map."""

from __future__ import annotations

import argparse
import fnmatch
import json
import subprocess
from pathlib import Path
from typing import Any


DEFAULT_POLICY = Path("ci/pr-test-policy.json")
DEFAULT_RISK_MAP = Path("ci/test-risk-map.json")


def _load_object(path: Path, label: str) -> dict[str, Any]:
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        raise ValueError(f"cannot read {label}: {exc}") from exc
    if not isinstance(value, dict):
        raise ValueError(f"{label} root must be an object")
    return value


def _risk_map_at_ref(base_ref: str, risk_map: Path) -> dict[str, Any] | None:
    """Read the base risk map when it exists so deleted sources remain owned."""

    try:
        content = subprocess.run(
            ["git", "show", f"{base_ref}:{risk_map.as_posix()}"],
            check=True,
            capture_output=True,
            text=True,
        ).stdout
    except subprocess.CalledProcessError:
        return None
    try:
        value = json.loads(content)
    except json.JSONDecodeError as exc:
        raise ValueError(f"base test risk map is invalid JSON: {exc}") from exc
    if not isinstance(value, dict):
        raise ValueError("base test risk map root must be an object")
    return value


def changed_paths(base_ref: str) -> list[str]:
    """Return exact paths changed from base_ref to the checked-out head."""

    try:
        subprocess.run(
            ["git", "rev-parse", "--verify", f"{base_ref}^{{commit}}"],
            check=True,
            capture_output=True,
            text=True,
        )
        raw = subprocess.run(
            ["git", "diff", "--name-only", "-z", base_ref, "HEAD"],
            check=True,
            capture_output=True,
        ).stdout
    except subprocess.CalledProcessError as exc:
        raise ValueError(f"cannot calculate changed paths from {base_ref!r}") from exc
    return sorted(path.decode("utf-8") for path in raw.split(b"\0") if path)


def _surface_tests(risk_map: dict[str, Any]) -> dict[str, set[str]]:
    result: dict[str, set[str]] = {}
    surfaces = risk_map.get("contract_surfaces")
    if not isinstance(surfaces, list):
        raise ValueError("test risk map requires contract_surfaces")
    for surface in surfaces:
        if not isinstance(surface, dict):
            raise ValueError("test risk map contract surface must be an object")
        paths = surface.get("paths")
        tests = surface.get("required_tests")
        if not isinstance(paths, list) or not isinstance(tests, list):
            raise ValueError("test risk map contract surface requires paths and required_tests")
        selected = {test for test in tests if isinstance(test, str)}
        for path in paths:
            if not isinstance(path, str):
                raise ValueError("test risk map source paths must be strings")
            result.setdefault(path, set()).update(selected)
    return result


def _production_source(path: str) -> bool:
    return path == "app.py" or (path.startswith("obliteratus/") and path.endswith(".py"))


def select_tests(
    paths: list[str],
    *,
    policy: dict[str, Any],
    risk_maps: list[dict[str, Any]],
    project_root: Path,
) -> list[str]:
    """Return stable existing CPU tests required by the changed paths."""

    selected = set(policy.get("always_tests", []))
    infrastructure_tests = set(policy.get("infrastructure_tests", []))
    infrastructure_patterns = policy.get("infrastructure_paths", [])
    excluded_prefixes = tuple(policy.get("excluded_test_prefixes", []))
    mappings = [_surface_tests(risk_map) for risk_map in risk_maps]

    for path in paths:
        if path.startswith("tests/") and path.endswith(".py"):
            selected.add(path)
        if any(fnmatch.fnmatchcase(path, pattern) for pattern in infrastructure_patterns):
            selected.update(infrastructure_tests)
        if _production_source(path):
            mapped = set().union(*(mapping.get(path, set()) for mapping in mappings))
            if not mapped:
                raise ValueError(
                    f"changed production source is unmapped in head and base risk maps: {path}",
                )
            selected.update(mapped)

    usable = [
        test
        for test in selected
        if isinstance(test, str)
        and test.startswith("tests/")
        and not test.startswith(excluded_prefixes)
        and (project_root / test).is_file()
    ]
    if not usable:
        raise ValueError("PR test policy selected no runnable CPU tests")
    return sorted(usable)


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--base-ref", required=True, help="exact base commit or ref")
    parser.add_argument("--policy", type=Path, default=DEFAULT_POLICY)
    parser.add_argument("--risk-map", type=Path, default=DEFAULT_RISK_MAP)
    return parser


def main() -> int:
    args = _parser().parse_args()
    try:
        policy = _load_object(args.policy, "PR test policy")
        head_risk_map = _load_object(args.risk_map, "test risk map")
        base_risk_map = _risk_map_at_ref(args.base_ref, args.risk_map)
        tests = select_tests(
            changed_paths(args.base_ref),
            policy=policy,
            risk_maps=[head_risk_map, *([base_risk_map] if base_risk_map else [])],
            project_root=Path.cwd(),
        )
    except ValueError as exc:
        print(f"PR test selection failed: {exc}")
        return 1
    for test in tests:
        print(test)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
