"""Tests for the drift checker."""

from __future__ import annotations

import json
import shutil
from pathlib import Path

import pytest

from mgf.fed._drift import check
from mgf.fed._roster import members
from mgf.fed._template import load_policy

# The gate-floor commands every Python member's pipeline must RUN (PAPER-121) —
# `mgf.fed._pipeline.DEFAULT_REQUIRED_COMMANDS`, in a step-shaped pipeline.
# (The gate floor itself is tested in tests/unit/test_gate_floor.py, which needs no
# sibling mgf-standard and therefore runs in a single-repo CI clone too.)
_GATE_COMMANDS = (
    "    commands:\n"
    "      - ruff check src tests\n"
    "      - ruff format --check src tests\n"
    "      - python scripts/check_commit_trailers.py\n"
)

# The hardened clone block the gate floor now asserts on every pipeline (CIR-03).
_CLONE = (
    "clone:\n  - name: clone\n    image: woodpeckerci/plugin-git:2.9.2\n"
    "    settings:\n      partial: false\n      use-ssh: true\n"
)

_REAL_TEMPLATES = Path(__file__).resolve().parents[3] / "mgf-standard" / "templates"

# Federation-INTEGRATION tests: read the REAL mgf-standard/templates, present only
# when the sibling repo is checked out alongside (federation-root layout). A
# single-repo CI clone has no sibling mgf-standard, so skip there — drift is still
# validated by `mgf-fed drift-check` (supervisor) and federation-root/local runs.
pytestmark = pytest.mark.skipif(
    not _REAL_TEMPLATES.exists(),
    reason="needs sibling mgf-standard/templates (federation-root); validated by drift-check",
)

_POLICY = """\
schema = 1
present = ["scripts/verify_codeberg.py", "SCOPE.md"]
py_typed = true
woodpecker_required_steps = ["lint", "publish"]
[exact]
"scripts/verify_codeberg.py" = "verify_codeberg.py"
[pyproject]
mypy_strict = true
ruff_spec = "ruff>=0.4,<0.16"
standards_extra = true
"""


def _root(tmp_path: Path) -> Path:
    reg = {
        "release_waves": [["mgf-clean", "mgf-drift"]],
        "libraries": [
            {
                "repo": "mgf-clean",
                "lang": "python",
                "version": "1.0.0",
                "extras": ["dev", "standards"],
            },
            {"repo": "mgf-drift", "lang": "python", "version": "0.1.0", "extras": ["dev"]},
        ],
    }
    std = tmp_path / "mgf-standard"
    (std / "templates").mkdir(parents=True)
    (std / "MGF_LIBRARIES.json").write_text(json.dumps(reg), encoding="utf-8")
    (std / "templates" / "policy.toml").write_text(_POLICY, encoding="utf-8")
    (std / "templates" / "verify_codeberg.py").write_text("# canonical\n", encoding="utf-8")

    # mgf-clean: satisfies the policy
    clean = tmp_path / "mgf-clean"
    (clean / "scripts").mkdir(parents=True)
    (clean / "src" / "mgf" / "clean").mkdir(parents=True)
    (clean / "src" / "mgf" / "clean" / "py.typed").touch()
    (clean / "scripts" / "verify_codeberg.py").write_text("# canonical\n", encoding="utf-8")
    (clean / "SCOPE.md").write_text("scope\n")
    (clean / ".woodpecker.yml").write_text(
        f"{_CLONE}steps:\n  - name: lint\n{_GATE_COMMANDS}  - name: publish\n"
    )
    (clean / "pyproject.toml").write_text(
        '[project]\nname="mgf-clean"\nversion="1.0.0"\n'
        '[project.optional-dependencies]\ndev=["ruff>=0.4,<0.16"]\nstandards=["mgf-common[standards]"]\n'
        "[tool.mypy]\nstrict=true\n"
    )
    # mgf-drift: violates several rules
    drift = tmp_path / "mgf-drift"
    (drift / "scripts").mkdir(parents=True)
    (drift / "scripts" / "verify_codeberg.py").write_text("# DIFFERENT\n", encoding="utf-8")
    (drift / ".woodpecker.yml").write_text("steps:\n  - name: lint\n")  # missing 'publish'
    (drift / "pyproject.toml").write_text(
        '[project]\nname="mgf-drift"\nversion="0.1.0"\n'
        '[project.optional-dependencies]\ndev=["ruff>=0.4"]\n'  # no upper bound, no standards
        "[tool.mypy]\nstrict=false\n"
    )
    return tmp_path


def test_clean_repo_has_no_drift(tmp_path: Path) -> None:
    root = _root(tmp_path)
    m = next(x for x in members(root) if x.repo == "mgf-clean")
    assert check(m, load_policy(root), root).ok


def test_drift_repo_flags_every_violation(tmp_path: Path) -> None:
    root = _root(tmp_path)
    m = next(x for x in members(root) if x.repo == "mgf-drift")
    items = check(m, load_policy(root), root).items
    kinds = {(i.kind, i.detail) for i in items}
    details = " ".join(i.detail for i in items)
    assert any(k == "missing" and "SCOPE.md" in d for k, d in kinds)
    assert "py.typed" in details
    assert any(k == "exact" for k, _ in kinds)  # verify_codeberg.py differs
    assert any(k == "ci-step" for k, _ in kinds)  # missing publish
    # PAPER-121: the pipeline runs no gate COMMANDS at all, only a step name.
    assert any(k == "ci-command" and "ruff format --check" in d for k, d in kinds)
    assert "!= required" in details and "[standards]" in details and "strict is not true" in details


# --------------------------------------------------------------------------------
# [exempt].woodpecker — bespoke pipelines skip generation-currency (WF-13),
# falling back to the step-name list instead.
# --------------------------------------------------------------------------------

# Generation-on policy with one bespoke repo excused from generation-currency.
_POLICY_GEN_EXEMPT = """\
schema = 1
present = []
woodpecker_generated = true
woodpecker_required_steps = ["lint", "publish"]
[exact]
[pyproject]
[exempt]
woodpecker = ["mgf-bespoke"]
"""


def _gen_exempt_root(tmp_path: Path, *, pipeline: str) -> Path:
    """Root whose policy generates pipelines but excuses ``mgf-bespoke``."""
    reg = {
        "release_waves": [["mgf-bespoke"]],
        "libraries": [
            {"repo": "mgf-bespoke", "lang": "python", "version": "1.0.0", "extras": ["dev"]}
        ],
    }
    std = tmp_path / "mgf-standard"
    std.mkdir(parents=True)
    (std / "MGF_LIBRARIES.json").write_text(json.dumps(reg), encoding="utf-8")
    shutil.copytree(_REAL_TEMPLATES, std / "templates")
    (std / "templates" / "policy.toml").write_text(_POLICY_GEN_EXEMPT, encoding="utf-8")
    repo = tmp_path / "mgf-bespoke"
    (repo / "src" / "mgf" / "bespoke").mkdir(parents=True)
    (repo / "src" / "mgf" / "bespoke" / "__init__.py").write_text("__version__ = '1.0.0'\n")
    (repo / "pyproject.toml").write_text(
        '[project]\nname = "mgf-bespoke"\nversion = "1.0.0"\n'
        "[project.optional-dependencies]\ndev = []\n"
    )
    (repo / ".woodpecker.yml").write_text(pipeline, encoding="utf-8")
    return tmp_path


def test_exempt_repo_not_flagged_ci_generated(tmp_path: Path) -> None:
    """A bespoke (off-render) pipeline with all required steps + gate commands is clean."""
    # Deliberately NOT a fresh render (a generation-currency check would flag it),
    # but it carries every required step name + gate command + a bespoke extra step.
    pipeline = (
        f"{_CLONE}steps:\n"
        f"  - name: lint\n{_GATE_COMMANDS}"
        "  - name: zig-build-and-test\n"  # bespoke extra
        "  - name: publish\n"
    )
    root = _gen_exempt_root(tmp_path, pipeline=pipeline)
    m = next(x for x in members(root) if x.repo == "mgf-bespoke")
    items = check(m, load_policy(root), root).items
    kinds = {i.kind for i in items}
    assert "ci-generated" not in kinds  # generation-currency skipped for the exempt repo
    assert "ci-step" not in kinds  # name-list fallback satisfied
    assert not items  # fully clean


def test_exempt_repo_uses_name_list_fallback(tmp_path: Path) -> None:
    """The exempt repo is still gated by the step-name list: a missing step flags ci-step."""
    pipeline = f"steps:\n  - name: lint\n{_GATE_COMMANDS}"  # missing 'publish'
    root = _gen_exempt_root(tmp_path, pipeline=pipeline)
    m = next(x for x in members(root) if x.repo == "mgf-bespoke")
    items = check(m, load_policy(root), root).items
    kinds = {i.kind for i in items}
    assert "ci-generated" not in kinds  # never the generation-currency check
    assert "ci-step" in kinds  # the name-list fallback caught the missing step


# --- shape-scoping: a Zig member is not flagged for Python-packaging artifacts ---

_SHAPE_POLICY = """\
schema = 1
present = ["PUBLIC_API.json", "tools/generate_public_api_json.py", "SCOPE.md"]
py_typed = true
woodpecker_required_steps = ["lint"]
[pyproject]
mypy_strict = true
"""


def _shape_root(tmp_path: Path) -> Path:
    """A python member and a zig member, both BARE (missing every present file, no
    pyproject, no py.typed) — so the only thing separating their drift is lang."""
    reg = {
        "release_waves": [["mgf-py", "mgf-zig"]],
        "libraries": [
            {"repo": "mgf-py", "lang": "python", "version": "1.0.0"},
            {"repo": "mgf-zig", "lang": "zig", "version": "1.0.0"},
        ],
    }
    std = tmp_path / "mgf-standard"
    (std / "templates").mkdir(parents=True)
    (std / "MGF_LIBRARIES.json").write_text(json.dumps(reg), encoding="utf-8")
    (std / "templates" / "policy.toml").write_text(_SHAPE_POLICY, encoding="utf-8")
    for repo in ("mgf-py", "mgf-zig"):
        (tmp_path / repo).mkdir()  # bare: no SCOPE.md, no PUBLIC_API.json, no pyproject
    return tmp_path


def test_zig_member_is_not_flagged_for_python_packaging_artifacts(tmp_path: Path) -> None:
    """`--shape zig` / `--shape all` reach check() for a Zig member; it must NOT be
    flagged for PUBLIC_API.json, its generator, `src/**/py.typed`, or a pyproject —
    a Zig repo carries none of those, and the default `--shape python` already
    excuses out-of-shape members the whole scaffolding policy upstream."""
    root = _shape_root(tmp_path)
    m = next(x for x in members(root) if x.repo == "mgf-zig")
    blob = " ".join(f"{i.kind} {i.detail}" for i in check(m, load_policy(root), root).items)
    assert "PUBLIC_API.json" not in blob
    assert "generate_public_api_json.py" not in blob
    assert "py.typed" not in blob
    assert "pyproject" not in blob
    # ...but the language-agnostic scaffolding still applies — SCOPE.md is universal.
    assert "SCOPE.md" in blob


def test_python_member_IS_still_flagged_for_the_same_artifacts(tmp_path: Path) -> None:
    """Regression guard: the shape-gate must not disable the checks for Python members.
    The bare python member is missing exactly the artifacts the zig member is excused."""
    root = _shape_root(tmp_path)
    m = next(x for x in members(root) if x.repo == "mgf-py")
    blob = " ".join(f"{i.kind} {i.detail}" for i in check(m, load_policy(root), root).items)
    assert "PUBLIC_API.json" in blob
    assert "generate_public_api_json.py" in blob
    assert "py.typed" in blob
    assert "pyproject" in blob
    assert "SCOPE.md" in blob


# --- required-steps are shape-scoped: a web/assets member's bespoke pipeline is
# --- held to the universal governance subset, not the Python step-list ---

_EXEMPT_STEPS_POLICY = """\
schema = 1
present = ["SCOPE.md"]
woodpecker_required_steps = [
    "lint", "typecheck", "test-3.12", "build", "sbom", "commit-trailers", "secret-scan",
]
[exempt]
woodpecker = ["mgf-web", "mgf-py"]
"""


def _steps_root(tmp_path: Path, *, web_pipeline: str, py_pipeline: str) -> Path:
    reg = {
        "release_waves": [["mgf-web", "mgf-py"]],
        "libraries": [
            {"repo": "mgf-web", "lang": "web", "version": "0.1.0"},
            {"repo": "mgf-py", "lang": "python", "version": "1.0.0"},
        ],
    }
    std = tmp_path / "mgf-standard"
    (std / "templates").mkdir(parents=True)
    (std / "MGF_LIBRARIES.json").write_text(json.dumps(reg), encoding="utf-8")
    (std / "templates" / "policy.toml").write_text(_EXEMPT_STEPS_POLICY, encoding="utf-8")
    for repo, pipe in (("mgf-web", web_pipeline), ("mgf-py", py_pipeline)):
        d = tmp_path / repo
        d.mkdir()
        (d / "SCOPE.md").write_text("scope\n")
        (d / ".woodpecker.yml").write_text(pipe)
    return tmp_path


def test_web_member_owes_only_the_universal_governance_steps(tmp_path: Path) -> None:
    """A bespoke web/assets pipeline (deploy / asset-lint) must NOT be flagged for the
    Python step-list (typecheck/test-3.x/build/sbom) — only commit-trailers + secret-scan."""
    root = _steps_root(
        tmp_path,
        # a deploy pipeline: has the two universal gates, none of the python ones
        web_pipeline="steps:\n  - name: commit-trailers\n  - name: secret-scan\n  - name: deploy\n",
        py_pipeline="steps:\n  - name: lint\n",
    )
    m = next(x for x in members(root) if x.repo == "mgf-web")
    items = check(m, load_policy(root), root).items
    steps = " ".join(i.detail for i in items if i.kind == "ci-step")
    assert steps == "", f"a web member was flagged for python steps: {steps}"


def test_web_member_IS_flagged_when_it_lacks_a_universal_step(tmp_path: Path) -> None:
    """The universal governance gates are still enforced — a web pipeline missing
    commit-trailers is flagged (that is exactly why mgf-website gains the step)."""
    root = _steps_root(
        tmp_path,
        web_pipeline="steps:\n  - name: secret-scan\n  - name: deploy\n",  # no commit-trailers
        py_pipeline="steps:\n  - name: lint\n",
    )
    m = next(x for x in members(root) if x.repo == "mgf-web")
    items = check(m, load_policy(root), root).items
    assert any(i.kind == "ci-step" and "commit-trailers" in i.detail for i in items)


def test_python_member_still_owes_the_full_step_list(tmp_path: Path) -> None:
    """Regression guard: a python exempt member is still held to the FULL list."""
    root = _steps_root(
        tmp_path,
        web_pipeline="steps:\n  - name: commit-trailers\n  - name: secret-scan\n",
        py_pipeline="steps:\n  - name: lint\n",  # missing typecheck/test/build/sbom/...
    )
    m = next(x for x in members(root) if x.repo == "mgf-py")
    items = check(m, load_policy(root), root).items
    missing = " ".join(i.detail for i in items if i.kind == "ci-step")
    assert "typecheck" in missing and "build" in missing


# --------------------------------------------------------------------------------
# ci-unknown-key — an unrecognised [tool.mgf-fed] carve-out.
#
# The class the byte-render check structurally CANNOT catch: a key the renderer
# does not read shapes nothing, so the render equals itself and `ci-generated`
# stays silent while the author believes a knob is on. mgf-uitest-desktop's
# `qt = true` lived that way until CI went red (2026-07-29).
# --------------------------------------------------------------------------------


def test_unknown_tool_mgf_fed_key_is_reported_as_drift(tmp_path: Path) -> None:
    root = _root(tmp_path)
    pyproject = root / "mgf-clean" / "pyproject.toml"
    pyproject.write_text(
        pyproject.read_text(encoding="utf-8") + "\n[tool.mgf-fed]\nqt = true\n", encoding="utf-8"
    )
    m = next(x for x in members(root) if x.repo == "mgf-clean")
    items = check(m, load_policy(root), root).items
    hits = [i for i in items if i.kind == "ci-unknown-key"]
    assert len(hits) == 1
    assert "`qt`" in hits[0].detail
    assert "did you mean `apt_packages`?" in hits[0].detail


def test_a_repo_with_only_known_carve_outs_is_not_flagged(tmp_path: Path) -> None:
    root = _root(tmp_path)
    pyproject = root / "mgf-clean" / "pyproject.toml"
    pyproject.write_text(
        pyproject.read_text(encoding="utf-8")
        + '\n[tool.mgf-fed]\nbrowser = true\ncoverage_floor = 85\napt_packages = ["libgl1"]\n',
        encoding="utf-8",
    )
    m = next(x for x in members(root) if x.repo == "mgf-clean")
    assert check(m, load_policy(root), root).ok


def test_the_baseline_clean_repo_stays_clean(tmp_path: Path) -> None:
    # Guards the false-positive direction: a repo with NO [tool.mgf-fed] table at
    # all must not acquire a finding (this check runs on every python member).
    root = _root(tmp_path)
    m = next(x for x in members(root) if x.repo == "mgf-clean")
    assert check(m, load_policy(root), root).ok


def test_unknown_key_is_flagged_even_when_the_pipeline_is_missing(tmp_path: Path) -> None:
    # The defect is in the DECLARATION, not the pipeline — a repo with no
    # .woodpecker.yml still owns its bad carve-out.
    root = _root(tmp_path)
    (root / "mgf-clean" / ".woodpecker.yml").unlink()
    pyproject = root / "mgf-clean" / "pyproject.toml"
    pyproject.write_text(
        pyproject.read_text(encoding="utf-8") + "\n[tool.mgf-fed]\nqt = true\n", encoding="utf-8"
    )
    m = next(x for x in members(root) if x.repo == "mgf-clean")
    assert any(i.kind == "ci-unknown-key" for i in check(m, load_policy(root), root).items)
