"""Unit tests for the single recipe-meta loader + key registry (rcust P1; spec §8 R1/R6).

Covers: every in-repo recipe_meta.py loads clean through the registry (THE typo gate), validation
hard-errors (unknown key, wrong type, callable on a data key), the zero-config baseline defaults
(spec §2), the underscore exemption for recipe-private constants, and the registry↔generated-doc
sync (P1.5; drift fails CI). Run: cc-ci-run -m pytest tests/unit/test_meta.py -q
"""

from __future__ import annotations

import os
import subprocess
import sys

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import meta as meta_mod  # noqa: E402
from harness.meta import KEYS, MetaError, RecipeMeta  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))


def _recipes_with_meta() -> list[str]:
    tests_dir = os.path.join(ROOT, "tests")
    return sorted(
        n
        for n in os.listdir(tests_dir)
        if os.path.isfile(os.path.join(tests_dir, n, "recipe_meta.py"))
    )


# ---- the typo gate: every in-repo recipe meta must validate against the registry --------------


@pytest.mark.parametrize("recipe", _recipes_with_meta())
def test_every_recipe_meta_loads_clean(recipe):
    """All tests/*/recipe_meta.py in the repo load + validate through the registry. A typo'd or
    unregistered ALL-CAPS key in any recipe meta fails HERE, at PR time — not silently at run
    time (the R6 failure mode this restructure kills)."""
    meta = meta_mod.load(recipe)
    assert isinstance(meta, RecipeMeta)
    # sanity: the 4 base keys always materialize with usable types
    assert isinstance(meta.HEALTH_PATH, str)
    assert isinstance(meta.HEALTH_OK, tuple) and meta.HEALTH_OK
    assert isinstance(meta.DEPLOY_TIMEOUT, int) and isinstance(meta.HTTP_TIMEOUT, int)


# ---- zero-config baseline (spec §2) ------------------------------------------------------------


def test_missing_meta_yields_spec_baseline(tmp_path):
    meta = meta_mod.load("no-such-recipe", tests_dir=str(tmp_path))
    assert meta.HEALTH_PATH == "/"
    assert meta.HEALTH_OK == (200, 301, 302)
    assert meta.DEPLOY_TIMEOUT == 600
    assert meta.HTTP_TIMEOUT == 300
    assert meta.BACKUP_CAPABLE is None  # None = auto-detect (tri-state, not False)
    assert meta.EXPECTED_NA is None
    assert meta.READY_PROBE is None
    assert meta.BACKUP_VERIFY is None
    assert meta.UPGRADE_EXTRA_ENV is None
    assert meta.EXTRA_ENV == {}
    assert meta.DEPS == []
    assert meta.WARM_CANONICAL is False
    assert meta.SCREENSHOT is None
    assert meta.UPGRADE_SECRET_PREP is None
    assert meta_mod.non_default(meta) == {}


def test_registry_field_set_matches_dataclass():
    """The RecipeMeta field set is generated from KEYS — no drift possible, pinned anyway."""
    import dataclasses

    assert [f.name for f in dataclasses.fields(RecipeMeta)] == [k.name for k in KEYS]
    # 14 final keys (UPGRADE_BASE_VERSION removed in phase canon §2.G; the 3 P2-deleted legacy keys
    # are gone too — any recipe_meta still setting a removed key hard-fails the typo gate)
    assert len(KEYS) == 14
    assert not [k for k in KEYS if k.deprecated]
    for gone in ("CHAOS_BASE_DEPLOY", "OIDC_AT_INSTALL", "SKIP_GENERIC"):
        assert gone not in {k.name for k in KEYS}


# ---- validation hard errors (locked decision: fail fast at load) -------------------------------


def _write_meta(tmp_path, body: str, recipe: str = "r") -> str:
    d = tmp_path / recipe
    d.mkdir(exist_ok=True)
    (d / "recipe_meta.py").write_text(body)
    return recipe


def test_unknown_key_raises_with_suggestion(tmp_path):
    r = _write_meta(tmp_path, "READINESS_PROBE = None\n")  # the R6 typo example
    with pytest.raises(MetaError) as ei:
        meta_mod.load(r, tests_dir=str(tmp_path))
    msg = str(ei.value)
    assert "READINESS_PROBE" in msg and "READY_PROBE" in msg  # names the typo + nearest key


def test_unknown_key_without_near_match_lists_registry(tmp_path):
    r = _write_meta(tmp_path, "TOTALLY_BOGUS_KNOB = 1\n")
    with pytest.raises(MetaError) as ei:
        meta_mod.load(r, tests_dir=str(tmp_path))
    assert "HEALTH_PATH" in str(ei.value)  # registered keys listed for the reader


def test_wrong_type_raises(tmp_path):
    r = _write_meta(tmp_path, 'DEPLOY_TIMEOUT = "900"\n')
    with pytest.raises(MetaError, match="DEPLOY_TIMEOUT"):
        meta_mod.load(r, tests_dir=str(tmp_path))


def test_bool_not_accepted_as_int(tmp_path):
    r = _write_meta(tmp_path, "DEPLOY_TIMEOUT = True\n")
    with pytest.raises(MetaError, match="DEPLOY_TIMEOUT"):
        meta_mod.load(r, tests_dir=str(tmp_path))


def test_callable_on_data_key_rejected(tmp_path):
    r = _write_meta(tmp_path, "def HEALTH_PATH():\n    return '/'\n")
    with pytest.raises(MetaError, match="hook-typed"):
        meta_mod.load(r, tests_dir=str(tmp_path))


def test_non_callable_on_hook_key_rejected(tmp_path):
    r = _write_meta(tmp_path, "READY_PROBE = ['not', 'a', 'callable']\n")
    with pytest.raises(MetaError, match="READY_PROBE"):
        meta_mod.load(r, tests_dir=str(tmp_path))


def test_underscore_names_are_private_and_exempt(tmp_path):
    r = _write_meta(
        tmp_path,
        "_WELCOME_TEXT_MARKER = 'marker-xyz'\n_MAX_USERS = 42\n"
        "EXTRA_ENV = {'WELCOME_TEXT': _WELCOME_TEXT_MARKER, 'USERS': str(_MAX_USERS)}\n",
    )
    meta = meta_mod.load(r, tests_dir=str(tmp_path))
    assert meta.EXTRA_ENV == {"WELCOME_TEXT": "marker-xyz", "USERS": "42"}


def test_lowercase_helpers_ignored(tmp_path):
    r = _write_meta(
        tmp_path,
        "def _helper(d):\n    return {'K': d}\n\ndef EXTRA_ENV(ctx):\n    return _helper(ctx.domain)\n",
    )
    meta = meta_mod.load(r, tests_dir=str(tmp_path))
    ctx = meta_mod.hook_ctx("x.example", meta)
    assert meta_mod.extra_env(meta, ctx) == {"K": "x.example"}


# ---- normalization + helpers --------------------------------------------------------------------


def test_health_ok_list_normalized_to_tuple(tmp_path):
    r = _write_meta(tmp_path, "HEALTH_OK = [200, 302]\n")
    assert meta_mod.load(r, tests_dir=str(tmp_path)).HEALTH_OK == (200, 302)


def test_extra_env_dict_and_callable_forms(tmp_path):
    r = _write_meta(tmp_path, "EXTRA_ENV = {'A': 1}\n")
    meta = meta_mod.load(r, tests_dir=str(tmp_path))
    assert meta_mod.extra_env(meta, meta_mod.hook_ctx("d", meta)) == {"A": "1"}  # stringified
    r2 = _write_meta(
        tmp_path, "UPGRADE_EXTRA_ENV = lambda ctx: {'COMPOSE_FILE': ctx.domain}\n", recipe="r2"
    )
    meta2 = meta_mod.load(r2, tests_dir=str(tmp_path))
    ctx2 = meta_mod.hook_ctx("dom.x", meta2, op="upgrade")
    assert meta_mod.upgrade_extra_env(meta2, ctx2) == {"COMPOSE_FILE": "dom.x"}
    assert meta_mod.extra_env(meta2, ctx2) == {}  # unset EXTRA_ENV resolves to {}


# ---- P3: uniform ctx hook convention -------------------------------------------------------------


def test_hook_ctx_fields(tmp_path):
    meta = meta_mod.load("no-such", tests_dir=str(tmp_path))
    ctx = meta_mod.hook_ctx("app.ci.example", meta, op="backup")
    assert ctx.domain == "app.ci.example"
    assert ctx.base_url == "https://app.ci.example"
    assert ctx.meta is meta
    assert ctx.op == "backup"
    assert meta_mod.hook_ctx("d", meta).op is None


def test_hook_ctx_deps_from_run_file(tmp_path, monkeypatch):
    import json

    meta = meta_mod.load("no-such", tests_dir=str(tmp_path))
    monkeypatch.delenv("CCCI_DEPS_FILE", raising=False)
    assert meta_mod.hook_ctx("d", meta).deps is None
    f = tmp_path / "deps.json"
    f.write_text(json.dumps({"keycloak": {"recipe": "keycloak", "domain": "kc.x"}}))
    monkeypatch.setenv("CCCI_DEPS_FILE", str(f))
    deps = meta_mod.hook_ctx("d", meta).deps
    assert deps["keycloak"]["domain"] == "kc.x"
    f.write_text("{}")  # empty dict -> None (deps declared but not provisioned)
    assert meta_mod.hook_ctx("d", meta).deps is None


def test_legacy_hook_signature_raises_clear_meta_error(tmp_path):
    """A pre-restructure hook signature must fail AT LOAD with a migration message — never a
    silent TypeError mid-run (P3.4)."""
    r = _write_meta(tmp_path, "def READY_PROBE(domain):\n    return []\n")
    with pytest.raises(MetaError, match="ctx"):
        meta_mod.load(r, tests_dir=str(tmp_path))
    r2 = _write_meta(tmp_path, "EXTRA_ENV = lambda domain: {}\n", recipe="r2")
    with pytest.raises(MetaError, match="restructure"):
        meta_mod.load(r2, tests_dir=str(tmp_path))
    r3 = _write_meta(
        tmp_path, "def SCREENSHOT(page, domain, meta):\n    return None\n", recipe="r3"
    )
    with pytest.raises(MetaError, match="page, ctx"):
        meta_mod.load(r3, tests_dir=str(tmp_path))


def test_ctx_hook_signatures_accepted(tmp_path):
    r = _write_meta(
        tmp_path,
        "def READY_PROBE(ctx):\n    return []\n"
        "def BACKUP_VERIFY(ctx):\n    return True\n"
        "def SCREENSHOT(page, ctx):\n    return None\n"
        "def EXTRA_ENV(ctx):\n    return {}\n",
    )
    meta = meta_mod.load(r, tests_dir=str(tmp_path))
    assert callable(meta.READY_PROBE) and callable(meta.SCREENSHOT)


def test_check_hook_signature_for_pre_op_hooks():
    """The orchestrator validates ops.py pre_<op> hooks with the same checker (legacy
    (domain, meta) form names the migration)."""

    def legacy(domain, meta):
        pass

    def new(ctx):
        pass

    with pytest.raises(MetaError, match="ctx"):
        meta_mod.check_hook_signature(legacy, ("ctx",), "tests/x/ops.py::pre_upgrade")
    meta_mod.check_hook_signature(new, ("ctx",), "tests/x/ops.py::pre_upgrade")  # no raise


def test_non_default_reports_only_customized_keys(tmp_path):
    r = _write_meta(tmp_path, "DEPLOY_TIMEOUT = 1500\nDEPS = ['keycloak']\n")
    nd = meta_mod.non_default(meta_mod.load(r, tests_dir=str(tmp_path)))
    assert nd == {"DEPLOY_TIMEOUT": 1500, "DEPS": ["keycloak"]}


def test_meta_is_frozen():
    import dataclasses

    meta = meta_mod.load("custom-html")
    with pytest.raises(dataclasses.FrozenInstanceError):
        meta.DEPLOY_TIMEOUT = 1


# ---- doc generation sync (P1.5: the committed §4 table == the registry rendering) ---------------


def test_generated_doc_table_in_sync():
    """docs/recipe-customization.md's key reference table is GENERATED from the registry
    (scripts/gen-meta-docs.py). If this fails: re-run `python3 scripts/gen-meta-docs.py` and
    commit the result — the table must never drift from the registry (R5)."""
    gen = os.path.join(ROOT, "scripts", "gen-meta-docs.py")
    doc = os.path.join(ROOT, "docs", "recipe-customization.md")
    rendered = subprocess.run(
        [sys.executable, gen, "--print"], capture_output=True, text=True, check=True
    ).stdout
    with open(doc) as f:
        committed = f.read()
    assert rendered.strip() in committed, (
        "docs/recipe-customization.md key table is out of sync with the harness.meta registry — "
        "run `python3 scripts/gen-meta-docs.py` and commit"
    )
