# Copyright (c) 2026 Carnegie Mellon University
# SPDX-License-Identifier: BSD-3-Clause-Clear
"""Docs-catalog contract (RFC #379 §9).

The marketplace catalog under ``docs/modules/`` is GENERATED by
``tools/gen_docs_catalog.py`` from the ``airstack-modules-index`` registry and
committed; the docs deploy workflows regenerate it against the live registry
at build time. This contract pins the pieces that must stay true:

* the generator is deterministic (two runs => byte-identical output);
* ``--check`` (the CI drift mode) passes against the committed pages when run
  from the registry snapshot fixture (``tests/meta/fixtures/modules_index/``,
  a copy of the registry entries the committed pages were generated from);
* the catalog table lists every registered module;
* the new-developer walkthrough page exists and is reachable from the nav,
  along with the Modules nav section;
* the three docs deploy workflows parse as YAML and carry the module-docs
  fetch step with per-clone failure isolation (an unreachable module repo
  must never fail a docs deploy).
"""
import re
import subprocess
import sys
from pathlib import Path

import pytest
import yaml

from harness.discovery import TESTS_DIR

pytestmark = pytest.mark.unit

REPO = TESTS_DIR.parent
GENERATOR = REPO / "tools" / "gen_docs_catalog.py"
FIXTURE_INDEX = TESTS_DIR / "meta" / "fixtures" / "modules_index"
CATALOG_DIR = REPO / "docs" / "modules"
WALKTHROUGH = REPO / "docs" / "getting_started" / "modular_airstack.md"
MKDOCS_YML = REPO / "mkdocs.yml"
DEPLOY_WORKFLOWS = [
    REPO / ".github" / "workflows" / name
    for name in (
        "deploy_docs_from_develop.yaml",
        "deploy_docs_from_main.yaml",
        "deploy_docs_from_release.yaml",
    )
]

MODULE_NAMES = sorted(p.stem for p in (FIXTURE_INDEX / "modules").glob("*.yaml"))


def _run_generator(*args: str) -> subprocess.CompletedProcess:
    return subprocess.run(
        [sys.executable, str(GENERATOR), *args],
        capture_output=True,
        text=True,
        cwd=REPO,
    )


def _generate_into(out_dir: Path, tmp_path: Path) -> "dict[str, str]":
    empty_modules = tmp_path / "no-fetched-modules"
    result = _run_generator(
        "--index", str(FIXTURE_INDEX),
        "--out", str(out_dir),
        "--modules-dir", str(empty_modules),
    )
    assert result.returncode == 0, (
        f"generator failed:\nstdout: {result.stdout}\nstderr: {result.stderr}"
    )
    return {p.name: p.read_text() for p in sorted(out_dir.glob("*.md"))}


# ------------------------------------------------------------ generator


def test_fixture_snapshot_is_populated():
    assert MODULE_NAMES, f"no registry snapshot under {FIXTURE_INDEX}/modules"
    assert (FIXTURE_INDEX / "stacks").is_dir()


def test_generator_is_deterministic(tmp_path):
    first = _generate_into(tmp_path / "run1", tmp_path)
    second = _generate_into(tmp_path / "run2", tmp_path)
    assert first == second, "two generator runs produced different output"
    assert set(first) == {"index.md", *(f"{n}.md" for n in MODULE_NAMES)}


def test_check_mode_passes_against_committed_pages(tmp_path):
    """CI drift style: committed docs/modules/ must match regeneration."""
    empty_modules = tmp_path / "no-fetched-modules"
    result = _run_generator(
        "--index", str(FIXTURE_INDEX),
        "--out", str(CATALOG_DIR),
        "--modules-dir", str(empty_modules),
        "--check",
    )
    assert result.returncode == 0, (
        "committed docs/modules/ pages drift from regeneration — rerun\n"
        "  python3 tools/gen_docs_catalog.py --index <registry-checkout> "
        "--modules-dir <empty-dir>\n"
        f"stderr:\n{result.stderr}"
    )


def test_check_mode_detects_drift(tmp_path):
    out = tmp_path / "pages"
    _generate_into(out, tmp_path)
    (out / "index.md").write_text("tampered\n")
    empty_modules = tmp_path / "no-fetched-modules"
    result = _run_generator(
        "--index", str(FIXTURE_INDEX),
        "--out", str(out),
        "--modules-dir", str(empty_modules),
        "--check",
    )
    assert result.returncode != 0, "--check did not flag a tampered page"
    assert "DRIFT" in result.stderr


def test_catalog_lists_every_registered_module():
    index_md = (CATALOG_DIR / "index.md").read_text()
    for name in MODULE_NAMES:
        assert f"[{name}]({name}.md)" in index_md, (
            f"catalog table is missing module {name}"
        )
        assert (CATALOG_DIR / f"{name}.md").is_file(), (
            f"missing per-module page docs/modules/{name}.md"
        )


def test_module_pages_carry_the_contracted_sections():
    for name in MODULE_NAMES:
        entry = yaml.safe_load(
            (FIXTURE_INDEX / "modules" / f"{name}.yaml").read_text()
        )
        page = (CATALOG_DIR / f"{name}.md").read_text()
        repo = entry["repo"].rstrip("/")
        ref = entry["registered_ref"]
        assert f"airstack module add {repo} --version {ref}" in page, (
            f"{name}.md: install snippet missing or unpinned"
        )
        assert "DECLARED" in page and "VERIFIED" in page, (
            f"{name}.md: declared-vs-verified compat note missing"
        )
        assert f"{repo}/blob/{ref}/README.md" in page, (
            f"{name}.md: README link at the registered ref missing"
        )


# ----------------------------------------------------------- docs + nav


def _load_mkdocs() -> dict:
    """Parse mkdocs.yml, tolerating the !!python/name superfences tag."""

    class Loader(yaml.SafeLoader):
        pass

    Loader.add_multi_constructor(
        "tag:yaml.org,2002:python/name:", lambda loader, suffix, node: suffix
    )
    return yaml.load(MKDOCS_YML.read_text(), Loader=Loader)


def _flatten_nav(nav) -> "list[str]":
    flat = []
    if isinstance(nav, str):
        flat.append(nav)
    elif isinstance(nav, list):
        for item in nav:
            flat.extend(_flatten_nav(item))
    elif isinstance(nav, dict):
        for value in nav.values():
            flat.extend(_flatten_nav(value))
    return flat


def test_walkthrough_page_exists_and_is_in_nav():
    assert WALKTHROUGH.is_file(), "docs/getting_started/modular_airstack.md missing"
    nav_paths = _flatten_nav(_load_mkdocs()["nav"])
    assert "docs/getting_started/modular_airstack.md" in nav_paths
    index_md = (REPO / "docs" / "getting_started" / "index.md").read_text()
    assert "modular_airstack.md" in index_md, (
        "getting_started/index.md must link the walkthrough"
    )


def test_modules_nav_section():
    config = _load_mkdocs()
    nav_paths = _flatten_nav(config["nav"])
    assert "docs/modules/index.md" in nav_paths, "catalog missing from nav"
    for name in MODULE_NAMES:
        assert f"docs/modules/{name}.md" in nav_paths, f"{name} page not in nav"
    for stack_dir in sorted((REPO / "stacks").iterdir()):
        if stack_dir.is_dir() and not stack_dir.name.startswith("."):
            assert f"stacks/{stack_dir.name}/README.md" in nav_paths, (
                f"reference stack {stack_dir.name} README not in nav"
            )


def test_every_nav_entry_points_at_an_existing_file():
    """Every mkdocs nav target must exist (docs_dir is the repo root).

    Guards the 404 class: a nav entry naming a moved/renamed page ships a
    dead link on the published site without failing the build (we cannot
    run ``mkdocs --strict`` while pre-existing warnings stand).

    Submodule paths are skipped: unit CI does not checkout submodules, and
    those READMEs are owned by the submodule repo.
    """
    gitmodules = REPO / ".gitmodules"
    submodule_roots = tuple(
        re.findall(r"^\s*path\s*=\s*(\S+)", gitmodules.read_text(), re.M)
    ) if gitmodules.is_file() else ()
    missing = [
        path
        for path in _flatten_nav(_load_mkdocs()["nav"])
        if not path.startswith(("http://", "https://"))
        and not any(path == root or path.startswith(root + "/") for root in submodule_roots)
        and not (REPO / path).is_file()
    ]
    assert not missing, f"mkdocs.yml nav entries with no file on disk: {missing}"


def test_fetched_module_checkouts_are_not_site_pages():
    exclude = _load_mkdocs().get("exclude_docs", "")
    assert "modules/**" in exclude, (
        "mkdocs exclude_docs must exclude the fetched modules/ checkouts"
    )


# ------------------------------------------------------ deploy workflows


@pytest.mark.parametrize(
    "workflow", DEPLOY_WORKFLOWS, ids=lambda p: p.name
)
def test_deploy_workflow_fetches_module_docs(workflow):
    data = yaml.safe_load(workflow.read_text())
    assert isinstance(data, dict), f"{workflow.name} did not parse to a mapping"

    steps = data["jobs"]["deploy"]["steps"]
    fetch = [
        s for s in steps
        if "registry index" in str(s.get("name", "")).lower()
    ]
    assert fetch, f"{workflow.name}: no registry/module-docs fetch step"
    script = fetch[0]["run"]
    assert "airstack-modules-index" in script
    assert "gen_docs_catalog.py" in script, (
        f"{workflow.name}: fetch step must regenerate docs/modules/"
    )
    # Failure isolation (RFC #379 §9): the registry clone, every per-module
    # clone, and the regeneration itself are all wrapped so an unreachable
    # repo degrades to committed pages / stub notes instead of a red deploy.
    assert script.count("|| echo") + script.count('echo "skipped') >= 2
    assert "skipped" in script
    # The fetch step must run before mike deploys the site.
    step_names = [str(s.get("name", "")) for s in steps]
    fetch_idx = step_names.index(str(fetch[0]["name"]))
    build_idx = next(
        i for i, s in enumerate(steps) if "mike deploy" in str(s.get("run", ""))
    )
    assert fetch_idx < build_idx, f"{workflow.name}: fetch step must precede mike deploy"


def test_develop_workflow_freshness_triggers():
    develop = yaml.safe_load(DEPLOY_WORKFLOWS[0].read_text())
    triggers = develop.get("on") or develop.get(True)
    assert "workflow_dispatch" in triggers
    assert "schedule" in triggers, "weekly freshness rebuild missing"
    for path in ("stacks/**", "tools/gen_docs_catalog.py"):
        assert path in triggers["push"]["paths"], (
            f"develop docs deploy must trigger on {path}"
        )


def test_main_workflow_paths_extended():
    main = yaml.safe_load(DEPLOY_WORKFLOWS[1].read_text())
    triggers = main.get("on") or main.get(True)
    for path in ("stacks/**", "tools/gen_docs_catalog.py"):
        assert path in triggers["push"]["paths"]
