"""``testctl catalog`` — deterministic generation + schema-clean + honest parsing."""

from __future__ import annotations

import json
import subprocess
from pathlib import Path

import pytest
from mgf.fedres import validate_payload
from mgf.fedres.canonical import dumpb

from mgf.test_web.catalog import (
    CollectionError,
    build_catalog,
    build_zig_catalog,
    collect_node_ids,
    count_tests,
    detect_project_kind,
    parse_collect_output,
    write_catalog,
)
from mgf.test_web.testctl import main

_PYPROJECT = """\
[project]
name = "catproj"
version = "0.0.0"
requires-python = ">=3.11"

[tool.pytest.ini_options]
minversion = "8.0"
"""
_TEST_A = """\
class TestGroup:
    def test_b(self) -> None:
        assert True


def test_a() -> None:
    assert True
"""
_TEST_TOP = "def test_root() -> None:\n    assert True\n"


def _make_project(root: Path) -> None:
    (root / "pyproject.toml").write_text(_PYPROJECT, encoding="utf-8")
    (root / "conftest.py").write_text("# rootdir anchor\n", encoding="utf-8")
    (root / "tests" / "unit").mkdir(parents=True)
    (root / "tests" / "unit" / "test_alpha.py").write_text(_TEST_A, encoding="utf-8")
    (root / "test_top.py").write_text(_TEST_TOP, encoding="utf-8")


def test_parse_collect_output_keeps_only_node_ids() -> None:
    text = (
        "test_top.py::test_root\n"
        "tests/unit/test_alpha.py::TestGroup::test_b\n"
        "tests/unit/test_alpha.py::test_a\n"
        "\n"
        "tests/unit/test_alpha.py:4\n"  # a warning line — single colon, no '::'
        "3 tests collected in 0.01s\n"
    )
    assert parse_collect_output(text) == [
        "test_top.py::test_root",
        "tests/unit/test_alpha.py::TestGroup::test_b",
        "tests/unit/test_alpha.py::test_a",
    ]


def test_collect_node_ids_uses_the_project_venv_interpreter(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """The catalog must enumerate with the project's OWN interpreter — the bug was
    ``sys.executable`` (testctl's pytest-less tool env) → a silent, empty catalog."""
    venv_py = tmp_path / ".venv" / "bin" / "python"
    venv_py.parent.mkdir(parents=True)
    venv_py.write_text("#!/bin/sh\n", encoding="utf-8")

    captured: dict[str, list[str]] = {}

    def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
        captured["cmd"] = cmd
        return subprocess.CompletedProcess(cmd, 0, stdout="tests/test_x.py::test_y\n")

    monkeypatch.setattr(subprocess, "run", fake_run)
    assert collect_node_ids(tmp_path) == ["tests/test_x.py::test_y"]
    assert captured["cmd"][0] == str(venv_py)  # not sys.executable
    assert "--collect-only" in captured["cmd"]


def test_collect_node_ids_raises_on_a_real_collection_failure(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """A genuine collection failure (bad exit, nothing collected) now surfaces — it
    no longer masquerades as a project that simply has zero tests."""

    def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
        # exit 4 = pytest usage/config error, e.g. a minversion mismatch
        return subprocess.CompletedProcess(
            cmd, 4, stdout="ERROR: 'minversion' requires pytest-8.0, actual 7.4.4\n"
        )

    monkeypatch.setattr(subprocess, "run", fake_run)
    with pytest.raises(CollectionError) as excinfo:
        collect_node_ids(tmp_path)
    assert "pytest-8.0" in str(excinfo.value)
    assert excinfo.value.returncode == 4


def test_collect_node_ids_allows_a_legitimately_empty_project(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """pytest exit 5 = 'no tests collected' is legitimate emptiness, not a failure."""

    def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
        return subprocess.CompletedProcess(cmd, 5, stdout="no tests ran in 0.01s\n")

    monkeypatch.setattr(subprocess, "run", fake_run)
    assert collect_node_ids(tmp_path) == []


def test_detect_project_kind_pytest_from_a_marker(tmp_path: Path) -> None:
    (tmp_path / "pyproject.toml").write_text("[project]\nname='p'\n", encoding="utf-8")
    assert detect_project_kind(tmp_path) == "pytest"


def test_detect_project_kind_zig_from_build_zig(tmp_path: Path) -> None:
    (tmp_path / "build.zig").write_text("// zig build\n", encoding="utf-8")
    assert detect_project_kind(tmp_path) == "zig"


def test_detect_project_kind_pytest_wins_a_python_zig_hybrid(tmp_path: Path) -> None:
    (tmp_path / "pyproject.toml").write_text("[project]\nname='p'\n", encoding="utf-8")
    (tmp_path / "build.zig").write_text("// zig build\n", encoding="utf-8")
    assert detect_project_kind(tmp_path) == "pytest"


def test_detect_project_kind_unknown_without_markers(tmp_path: Path) -> None:
    assert detect_project_kind(tmp_path) == "unknown"


def test_build_zig_catalog_shape_and_schema() -> None:
    cat = build_zig_catalog(
        [("test", "run tests"), ("test-unit", "unit"), ("test", "dup")], "zigproj"
    )
    assert cat["runners"][0]["id"] == "zig"
    assert validate_payload(cat) == []
    tests = [t for c in cat["categories"] for g in c["groups"] for t in g["tests"]]
    assert [t["id"] for t in tests] == ["test", "test-unit"]  # sorted, deduped
    assert all(t["runner"] == "zig" for t in tests)


def test_build_zig_catalog_empty_when_no_test_steps() -> None:
    cat = build_zig_catalog([], "zigproj")
    assert cat["categories"] == []
    assert validate_payload(cat) == []


def test_build_catalog_canonicalizes_into_the_fixed_tree() -> None:
    ids = [
        "tests/unit/test_alpha.py::test_a",
        "test_top.py::test_root",
        "tests/test_beta.py::test_c",
    ]
    catalog = build_catalog(ids, "proj")
    tree = [(c["id"], [g["id"] for g in c["groups"]]) for c in catalog["categories"]]
    # category = top-level dir (root-level files land in "(root)"); group = the file.
    assert tree == [
        ("(root)", ["test_top.py"]),
        ("tests", ["tests/test_beta.py", "tests/unit/test_alpha.py"]),
    ]
    first_test = catalog["categories"][1]["groups"][1]["tests"][0]
    assert first_test["id"] == "tests/unit/test_alpha.py::test_a"
    assert first_test["label"] == "test_a"
    assert first_test["runner"] == "pytest"
    assert first_test["independent"] is False
    assert first_test["last_status"] == "unknown"
    assert first_test["config"] == {} and first_test["config_schema"] == []


def test_build_catalog_is_deterministic_regardless_of_input_order() -> None:
    ids = ["b.py::t2", "a.py::t1", "a.py::t0"]
    assert dumpb(build_catalog(ids, "p")) == dumpb(build_catalog(list(reversed(ids)), "p"))


def test_catalog_validates_against_the_fedres_schema() -> None:
    catalog = build_catalog(["tests/test_x.py::test_y"], "proj")
    assert validate_payload(catalog) == []


@pytest.mark.integration
def test_write_catalog_end_to_end_from_real_collection(tmp_path: Path) -> None:
    _make_project(tmp_path)
    path, catalog = write_catalog(tmp_path, "catproj")
    assert path == tmp_path / "fed-resources" / "generated" / "test_catalog.json"
    assert validate_payload(catalog) == []
    assert count_tests(catalog) == 3  # test_root, test_a, TestGroup::test_b
    # regenerate -> byte-identical (the freshness gate is meaningful)
    _, again = write_catalog(tmp_path, "catproj")
    assert dumpb(catalog) == dumpb(again)


@pytest.mark.integration
def test_testctl_catalog_subcommand_writes_the_file(
    tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
    _make_project(tmp_path)
    rc = main(["-C", str(tmp_path), "catalog"])
    assert rc == 0
    assert "wrote 3 tests" in capsys.readouterr().out
    assert (tmp_path / "fed-resources" / "generated" / "test_catalog.json").is_file()


def test_testctl_catalog_reports_a_collection_failure(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
    """``testctl catalog`` exits nonzero and prints *why*, rather than writing a
    hollow zero-test catalog, when collection fails."""

    (tmp_path / "pyproject.toml").write_text("[project]\nname='p'\n", encoding="utf-8")

    def boom(project_root: Path, project: str) -> tuple[Path, dict[str, object]]:
        raise CollectionError(project_root, 4, "boom: 'minversion' requires pytest-8.0")

    monkeypatch.setattr("mgf.test_web.testctl.write_catalog", boom)
    rc = main(["-C", str(tmp_path), "catalog"])
    assert rc == 1
    out = capsys.readouterr().out
    assert "ERROR" in out and "pytest-8.0" in out


def test_testctl_catalog_enumerates_a_zig_project(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
    """A Zig repo (build.zig) is enumerated via the zig runner — a step-level catalog.
    The toolchain is mocked (CI has no zig); real enumeration is exercised on the fleet."""
    (tmp_path / "build.zig").write_text("// zig\n", encoding="utf-8")
    monkeypatch.setattr("mgf.test_web.catalog.zig_test_steps", lambda root: [("test", "run tests")])
    rc = main(["-C", str(tmp_path), "catalog"])
    assert rc == 0
    assert "wrote 1 tests" in capsys.readouterr().out
    cat = json.loads((tmp_path / "fed-resources" / "generated" / "test_catalog.json").read_text())
    assert cat["runners"][0]["id"] == "zig"
    assert cat["categories"][0]["groups"][0]["tests"][0]["id"] == "test"


def test_testctl_catalog_reports_a_zig_toolchain_failure(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
    """No zig toolchain surfaces as an ERROR, not a hollow catalog."""
    from mgf.test_web._zig import ZigUnavailable

    (tmp_path / "build.zig").write_text("// zig\n", encoding="utf-8")

    def boom(root: object) -> list[tuple[str, str]]:
        raise ZigUnavailable("no Zig toolchain found")

    monkeypatch.setattr("mgf.test_web.catalog.zig_test_steps", boom)
    rc = main(["-C", str(tmp_path), "catalog"])
    assert rc == 1
    assert "ERROR" in capsys.readouterr().out


def test_testctl_catalog_skips_an_unrecognized_project(
    tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
    """A repo with neither a pytest marker nor build.zig is skipped cleanly."""
    rc = main(["-C", str(tmp_path), "catalog"])
    assert rc == 0
    out = capsys.readouterr().out
    assert "SKIP" in out and "no recognized test project" in out
    assert not (tmp_path / "fed-resources" / "generated" / "test_catalog.json").exists()
