"""Unit tests for overlay/custom/hook discovery + precedence (Phase 1d DG4 + Phase 1e HC2/HC3).

Deterministic, no deployment. Proves:
  - the overlay resolution rule (repo-local > cc-ci) + the generic floor (HC3: additive, separate);
  - the HC2 repo-local approval gate (default-deny; repo-local consulted only for approved recipes).
Run with: `cc-ci-run -m pytest tests/unit`. These live under tests/unit/ (NOT a recipe name, NOT
tests/_generic/) so the run orchestrator never picks them up as overlays/custom tests."""

import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import discovery  # noqa: E402


def _approve(tmp_path, *recipes):
    """Point the allowlist at a temp file approving the given recipes (HC2), and return a cleanup."""
    f = tmp_path / "approved.txt"
    f.write_text("".join(f"{r}\n" for r in recipes))
    os.environ["CCCI_REPO_LOCAL_APPROVED_FILE"] = str(f)


def teardown_function():
    os.environ.pop("CCCI_REPO_LOCAL_APPROVED_FILE", None)


# ---- HC3: generic is the floor; overlay resolution is separate + additive --------------------


def test_no_overlay_means_generic_floor():
    # hedgedoc ships no tests/hedgedoc/ overlay and no repo-local -> no overlay; generic floor exists
    assert discovery.resolve_overlay_op("hedgedoc", "install", None) is None
    src, path = discovery.generic_op("install")
    assert src == "generic"
    assert path == os.path.join(discovery.GENERIC_DIR, "test_install.py")
    # back-compat resolver still yields generic when there is no overlay
    assert discovery.resolve_op("hedgedoc", "install", None) == ("generic", path)


def test_cc_ci_overlay_found_for_each_op():
    # custom-html ships cc-ci overlays for all four ops -> resolve_overlay_op returns the cc-ci file
    for op in discovery.LIFECYCLE_OPS:
        res = discovery.resolve_overlay_op("custom-html", op, None)
        assert res == (
            "cc-ci",
            os.path.join(discovery.cc_ci_dir("custom-html"), f"test_{op}.py"),
        ), op


# ---- HC2: repo-local approval gate (default-deny) --------------------------------------------


def test_repo_local_ignored_when_not_approved(tmp_path):
    # default-deny: a repo-local overlay is NOT consulted for an unapproved recipe -> cc-ci wins
    _approve(tmp_path)  # empty allowlist
    (tmp_path / "test_install.py").write_text("# repo-local overlay\n")
    res = discovery.resolve_overlay_op("custom-html", "install", str(tmp_path))
    assert res == ("cc-ci", os.path.join(discovery.cc_ci_dir("custom-html"), "test_install.py"))
    assert discovery.repo_local_approved("custom-html") is False


def test_repo_local_wins_when_approved(tmp_path):
    # approved: repo-local is upstream-authoritative and beats cc-ci for the same op
    rl = tmp_path / "repo"
    rl.mkdir()
    (rl / "test_install.py").write_text("# repo-local overlay\n")
    _approve(tmp_path, "custom-html")
    assert discovery.repo_local_approved("custom-html") is True
    res = discovery.resolve_overlay_op("custom-html", "install", str(rl))
    assert res == ("repo-local", str(rl / "test_install.py"))


def test_custom_tests_repo_local_gated(tmp_path, monkeypatch):
    # custom test_*.py from repo-local only count for approved recipes (HC2); placement rule
    # (cfold): custom/ is canonical, while functional/ and playwright/ remain deprecated aliases.
    # Use a synthetic recipe name + monkeypatched cc_ci_dir so this is independent of what
    # tests/<real-recipe>/ ships (F2-1).
    fake_recipe = "ccci-hc2-fixture"
    monkeypatch.setattr(discovery, "cc_ci_dir", lambda r: str(tmp_path / "cc-ci" / r))
    (tmp_path / "cc-ci" / fake_recipe).mkdir(parents=True)
    rl = tmp_path / "repo"
    (rl / "custom").mkdir(parents=True)
    (rl / "custom" / "test_sso.py").write_text("# repo-local custom\n")
    (rl / "custom" / "test_install.py").write_text("# lifecycle name -> excluded from custom\n")

    _approve(tmp_path)  # not approved -> repo-local custom ignored
    assert discovery.custom_tests(fake_recipe, str(rl)) == []

    _approve(tmp_path, fake_recipe)  # approved -> repo-local custom honored
    customs = discovery.custom_tests(fake_recipe, str(rl))
    names = {(src, os.path.basename(p)) for src, p in customs}
    assert ("repo-local", "test_sso.py") in names
    assert all(os.path.basename(p) != "test_install.py" for _, p in customs)


def test_custom_tests_prefers_custom_and_warns_on_deprecated_aliases(tmp_path, monkeypatch, capsys):
    fake_recipe = "ccci-cfold-fixture"
    fake_dir = tmp_path / "tests" / fake_recipe
    (fake_dir / "custom").mkdir(parents=True)
    (fake_dir / "functional").mkdir()
    (fake_dir / "playwright").mkdir()
    (fake_dir / "custom" / "test_a.py").write_text("# canonical\n")
    (fake_dir / "functional" / "test_b.py").write_text("# deprecated alias\n")
    (fake_dir / "playwright" / "test_c.py").write_text("# deprecated alias\n")
    monkeypatch.setattr(discovery, "cc_ci_dir", lambda r: str(tmp_path / "tests" / r))

    customs = discovery.custom_tests(fake_recipe, None)

    assert [os.path.basename(path) for _, path in customs] == [
        "test_a.py",
        "test_b.py",
        "test_c.py",
    ]
    err = capsys.readouterr().err
    assert "deprecated folder 'functional/'" in err
    assert "deprecated folder 'playwright/'" in err


def test_install_steps_repo_local_gated(tmp_path):
    rl = tmp_path / "repo"
    rl.mkdir()
    (rl / "install_steps.sh").write_text("#!/usr/bin/env bash\n")

    _approve(tmp_path)  # not approved -> repo-local hook ignored (custom-html has no cc-ci hook)
    assert discovery.install_steps("custom-html", str(rl)) is None

    _approve(tmp_path, "custom-html")  # approved -> repo-local hook honored
    hook = discovery.install_steps("custom-html", str(rl))
    assert hook == ("repo-local", str(rl / "install_steps.sh"))
    assert discovery.install_steps("hedgedoc", None) is None


def test_pre_op_hook_repo_local_gated(tmp_path):
    # hedgedoc has no cc-ci ops.py, so this isolates the repo-local gate (custom-html now ships a
    # real cc-ci tests/custom-html/ops.py, which would mask the gate).
    rl = tmp_path / "repo"
    rl.mkdir()
    (rl / "ops.py").write_text("def pre_upgrade(domain, meta):\n    pass\n")

    _approve(tmp_path)  # not approved -> repo-local ops.py ignored (no cc-ci ops.py either)
    assert discovery.pre_op_hook("hedgedoc", "upgrade", str(rl)) is None

    _approve(tmp_path, "hedgedoc")  # approved -> repo-local pre-op hook honored
    hook = discovery.pre_op_hook("hedgedoc", "upgrade", str(rl))
    assert hook == ("repo-local", str(rl / "ops.py"))
    # an ops.py that does NOT define pre_<op> is not a hook for that op
    assert discovery.pre_op_hook("hedgedoc", "backup", str(rl)) is None


def test_default_allowlist_is_empty():
    # the checked-in tests/repo-local-approved.txt approves nothing (default-deny invariant)
    os.environ.pop("CCCI_REPO_LOCAL_APPROVED_FILE", None)
    assert discovery.approved_recipes() == set()
