"""Single recipe-meta loader + declarative key registry (recipe-custom restructure P1; spec
docs/recipe-customization.md §8 R1).

THE one place `tests/<recipe>/recipe_meta.py` is `exec()`d. Every consumer (orchestrator, pytest
`meta` fixture, deploy env shaping, deps, warm-canonical enrollment, screenshot) reads the ONE
loaded `RecipeMeta` object instead of re-exec'ing the file and cherry-picking keys — that drift
(six divergent loaders, spec §4 L1–L6) is what made `SCREENSHOT` an unreachable knob (R2) and let
key typos silently disable coverage (R6).

Validation (locked decision, recipe-custom-restructure-full-plan.md):
- unknown ALL-CAPS top-level name → MetaError (hard error, fails fast at load; the all-recipes
  unit test catches it at PR time). Underscore-prefixed names (`_FOO`) are recipe-private and
  exempt; lowercase names (helper functions/imports) are ignored.
- type mismatch → MetaError. Callables are accepted ONLY for hook-typed keys.

The KEYS registry is the single source of truth for the key set: it drives validation, the
RecipeMeta dataclass fields, and the generated reference table in docs/recipe-customization.md §4
(scripts/gen-meta-docs.py; a unit test asserts the committed table matches).
"""

from __future__ import annotations

import copy
import dataclasses
import difflib
import inspect
import json
import os
from collections.abc import Callable

ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
TESTS_DIR = os.path.join(ROOT, "tests")


class MetaError(Exception):
    """A recipe_meta.py failed registry validation (unknown key / type mismatch / callable on a
    data key). Hard error by design: a typo'd key must fail the run at load, not silently reduce
    coverage (spec §8 R6 — the worst failure mode for a CI harness)."""


@dataclasses.dataclass(frozen=True)
class Key:
    """One registered recipe_meta key: name, type tag, default, one-line doc (rendered into the
    generated reference table), optional extra validator, and a deprecation marker (deprecated
    keys still load+validate but are scheduled for deletion)."""

    name: str
    type: str  # "int"|"str"|"tuple[int]"|"bool"|"dict_or_hook"|"hook"|"list[str]"|"dict"
    default: object
    doc: str
    validate: Callable[[object], None] | None = None
    deprecated: bool = False
    # Expected positional-parameter names for a callable value (rcust P3 uniform ctx convention).
    # Enforced at load so a legacy-signature hook (e.g. `def READY_PROBE(domain)`) fails with a
    # CLEAR MetaError naming the migration — never a silent TypeError mid-run.
    hook_params: tuple[str, ...] | None = None


KEYS: tuple[Key, ...] = (
    Key(
        "HEALTH_PATH",
        "str",
        "/",
        "Path probed for serving/health checks (deploy wait + generic `assert_serving`).",
    ),
    Key("HEALTH_OK", "tuple[int]", (200, 301, 302), "Acceptable HTTP status codes for health."),
    Key("DEPLOY_TIMEOUT", "int", 600, "Max seconds to wait for swarm convergence per deploy."),
    Key("HTTP_TIMEOUT", "int", 300, "Max seconds to wait for HTTP health after convergence."),
    Key(
        "BACKUP_CAPABLE",
        "bool",
        None,
        "Override the backup-tier capability auto-detect (compose `backupbot.backup` labels). `False` forces an intentional skip of the backup/restore rung; `True` forces the tier on; unset = auto-detect.",
    ),
    Key(
        "EXPECTED_NA",
        "dict",
        None,
        "Declare a non-run rung an INTENTIONAL skip: `{rung: reason}` — the level climbs past it; an undeclared non-run rung is *unverified* and blocks the level above it (classification table: machine-docs/DECISIONS.md phase lvl5). Never overrides an exercised pass/fail; the `lint` rung has no escape hatch. Declaring `upgrade` also suppresses the upgrade-tier BASE deploy — the single deploy is the PR head itself — for recipes whose published versions exist but are genuinely undeployable (phase bsky).",
    ),
    Key(
        "READY_PROBE",
        "hook",
        None,
        "Callable `(ctx) -> [probe, ...]` returning extra readiness probes, run after install AND after upgrade: HTTP `{host, path, ok}` or TCP `{tcp_host, tcp_port, stable}`.",
        hook_params=("ctx",),
    ),
    Key(
        "BACKUP_VERIFY",
        "hook",
        None,
        "Callable `(ctx) -> bool` post-backup data-capture check; `False` re-runs the backup (truncated-dump race guard), retried up to 3 attempts.",
        hook_params=("ctx",),
    ),
    Key(
        "UPGRADE_EXTRA_ENV",
        "dict_or_hook",
        None,
        "Extra `.env` keys applied after the PR-head checkout, before the chaos redeploy (env that exists only at head). Dict, or callable `(ctx) -> dict`.",
        hook_params=("ctx",),
    ),
    Key(
        "EXTRA_ENV",
        "dict_or_hook",
        {},
        "Extra `.env` keys applied at EVERY deploy (base install AND upgrade old-app). Dict, or callable `(ctx) -> dict` deriving values from the per-run domain (`ctx.domain`).",
        hook_params=("ctx",),
    ),
    Key(
        "DEPS",
        "list[str]",
        [],
        'Dep recipes deployed/provisioned alongside (e.g. `["keycloak"]`); creds land in `$CCCI_DEPS_FILE`.',
    ),
    Key(
        "WARM_CANONICAL",
        "bool",
        False,
        "Enroll the recipe in the warm/canonical app system (docs/warm.md): green cold runs on LATEST advance the canonical snapshot.",
    ),
    Key(
        "SCREENSHOT",
        "hook",
        None,
        "Callable `(page, ctx)` driving Playwright to a safe, credential-free post-login view for the results-card screenshot (default: landing page).",
        hook_params=("page", "ctx"),
    ),
    Key(
        "UPGRADE_SECRET_PREP",
        "hook",
        None,
        "Callable `(ctx)` invoked after UPGRADE_EXTRA_ENV env_set but before `abra secret generate --all` in the upgrade path. Use to pre-insert secrets that `generate --all` would produce with wrong format (e.g. when the .env.sample spec is commented out).",
        hook_params=("ctx",),
    ),
    Key(
        "UPGRADE_BASE_FLOOR",
        "str",
        None,
        "Declared STRUCTURAL breaking boundary for the upgrade tier (phase basefloor): the first "
        "post-break published version tag. Bases strictly below it are excluded from resolution "
        "(canonical / step-back / no-canonical fallback) because an in-place upgrade across the "
        "boundary is not supported upstream (e.g. a db-family change). When no ≥-floor predecessor "
        "exists the tier records a DECLARED skip. NOT a static base pin (§2.G stays removed) — "
        "resolution remains dynamic above the floor.",
    ),
    # (CHAOS_BASE_DEPLOY, OIDC_AT_INSTALL and SKIP_GENERIC were deleted in restructure P2:
    # compose.ccci.yml is first-class + auto-chaos; install-time deps wiring is the only mode;
    # the generic floor is suppressible only via the dev-only CCCI_SKIP_GENERIC* env form.)
)

_REGISTRY: dict[str, Key] = {k.name: k for k in KEYS}

# The one validated, attribute-access view of a recipe's customization. Generated from KEYS so the
# field set can never drift from the registry (frozen: consumers share one immutable object).
RecipeMeta = dataclasses.make_dataclass(
    "RecipeMeta",
    [(k.name, object, dataclasses.field(default=None)) for k in KEYS],
    frozen=True,
)
RecipeMeta.__doc__ = (
    "Validated per-recipe customization (one field per registered key; attribute access). "
    "Built ONLY by meta.load()."
)


def meta_path(recipe: str, tests_dir: str | None = None) -> str:
    """Canonical path of a recipe's meta file (pure)."""
    return os.path.join(tests_dir or TESTS_DIR, recipe, "recipe_meta.py")


def check_hook_signature(fn, expected: tuple[str, ...], where: str) -> None:
    """Enforce the uniform ctx hook convention (rcust P3): a hook callable's positional parameters
    must be exactly `expected` (e.g. ("ctx",) or ("page", "ctx")). A legacy-signature hook (the
    pre-restructure `(domain)` / `(domain, meta)` / `(page, domain, meta)` forms) raises a CLEAR
    MetaError naming the migration — never a silent TypeError mid-run."""
    try:
        params = [
            p.name
            for p in inspect.signature(fn).parameters.values()
            if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
        ]
    except (TypeError, ValueError):  # builtins/odd callables — let the call site surface it
        return
    if tuple(params) != expected:
        raise MetaError(
            f"{where}: hook signature is ({', '.join(params)}) — the recipe-customization "
            f"restructure (P3) changed ALL recipe hook signatures to ({', '.join(expected)}); "
            f"read fields off the HookCtx (ctx.domain, ctx.base_url, ctx.meta, ctx.deps, ctx.op). "
            f"See docs/recipe-customization.md §5."
        )


def _coerce(key: Key, value: object, path: str) -> object:
    """Validate `value` against `key`'s declared type; normalize containers (tuple[int]/list[str]).
    Raises MetaError on mismatch — including a callable supplied for a data-typed key."""
    t = key.type
    if callable(value) and t not in ("hook", "dict_or_hook"):
        raise MetaError(
            f"{path}: {key.name} is a data key (type {t}) — callables are accepted only for "
            f"hook-typed keys"
        )
    if t == "int":
        if isinstance(value, int) and not isinstance(value, bool):
            return value
    elif t == "str":
        if isinstance(value, str):
            return value
    elif t == "bool":
        if isinstance(value, bool):
            return value
    elif t == "tuple[int]":
        if isinstance(value, tuple | list) and all(
            isinstance(x, int) and not isinstance(x, bool) for x in value
        ):
            return tuple(value)
    elif t == "list[str]":
        if isinstance(value, tuple | list) and all(isinstance(x, str) for x in value):
            return list(value)
    elif t == "dict":
        if isinstance(value, dict):
            return value
    elif (
        t == "hook"
        and callable(value)
        or t == "dict_or_hook"
        and (isinstance(value, dict) or callable(value))
    ):
        return value
    raise MetaError(f"{path}: {key.name} must be {t}, got {type(value).__name__} ({value!r})")


def load(recipe: str, tests_dir: str | None = None):
    """Load + validate a recipe's customization -> RecipeMeta. THE only exec() of recipe_meta.py.

    Missing file -> all registry defaults (the zero-config baseline, spec §2). Unknown
    non-underscore ALL-CAPS top-level name or type mismatch -> MetaError (hard error).
    `tests_dir` overrides the recipe-meta root (unit tests / fixtures)."""
    path = meta_path(recipe, tests_dir)
    values = {k.name: copy.copy(k.default) for k in KEYS}
    if os.path.exists(path):
        ns: dict = {}
        with open(path) as fh:
            exec(compile(fh.read(), path, "exec"), ns)  # noqa: S102 (trusted, in-repo)
        for name in sorted(ns):
            if name.startswith("_") or not name.isupper():
                continue  # _FOO = recipe-private (exempt); lowercase = helpers/imports (ignored)
            key = _REGISTRY.get(name)
            if key is None:
                near = difflib.get_close_matches(name, _REGISTRY, n=1)
                hint = f" — did you mean {near[0]!r}?" if near else ""
                raise MetaError(
                    f"{path}: unknown recipe_meta key {name!r}{hint}. Registered keys: "
                    f"{', '.join(sorted(_REGISTRY))}. Recipe-private constants must be "
                    f"underscore-prefixed (e.g. _{name})."
                )
            values[name] = _coerce(key, ns[name], path)
            if key.hook_params and callable(values[name]):
                check_hook_signature(values[name], key.hook_params, f"{path}: {name}")
            if key.validate:
                key.validate(values[name])
    return RecipeMeta(**values)


def as_dict(meta) -> dict:
    """RecipeMeta -> {key: value} (every registered key, defaults included)."""
    return dataclasses.asdict(meta)


def non_default(meta) -> dict:
    """The keys a recipe explicitly customized: {key: value} where value differs from the registry
    default. Hooks compare by identity-vs-None (a set hook is always non-default). Feeds the run's
    customization manifest (P5)."""
    out = {}
    for k in KEYS:
        v = getattr(meta, k.name)
        if v != k.default:
            out[k.name] = v
    return out


@dataclasses.dataclass(frozen=True)
class HookCtx:
    """The single argument every recipe hook receives (rcust P3 uniform ctx convention):
    `EXTRA_ENV(ctx)`, `UPGRADE_EXTRA_ENV(ctx)`, `READY_PROBE(ctx)`, `BACKUP_VERIFY(ctx)`,
    `SCREENSHOT(page, ctx)`, ops.py `pre_<op>(ctx)`."""

    domain: str  # the app's per-run domain
    base_url: str  # https://<domain>
    meta: object  # the recipe's full RecipeMeta
    deps: dict | None  # provisioned dep creds ({dep_recipe: entry}) or None if absent/empty
    op: str | None  # current lifecycle op (install|upgrade|backup|restore) or None


def _run_deps() -> dict | None:
    """The current run's provisioned dep creds from $CCCI_DEPS_FILE (either shape), or None.
    Read directly (not via harness.deps) to keep meta.py import-cycle-free."""
    path = os.environ.get("CCCI_DEPS_FILE")
    if not path or not os.path.exists(path):
        return None
    try:
        with open(path) as f:
            data = json.load(f)
    except (OSError, ValueError):
        return None
    if isinstance(data, dict):
        return data or None
    if isinstance(data, list):
        out = {e["recipe"]: e for e in data if isinstance(e, dict) and e.get("recipe")}
        return out or None
    return None


def hook_ctx(domain: str, meta, *, op: str | None = None) -> HookCtx:
    """Build the HookCtx for a hook call site. Dep creds are picked up from the run's
    $CCCI_DEPS_FILE when present (None otherwise)."""
    return HookCtx(domain=domain, base_url=f"https://{domain}", meta=meta, deps=_run_deps(), op=op)


def _env_map(value, ctx: HookCtx) -> dict[str, str]:
    if callable(value):
        value = value(ctx)
    return {str(k): str(v) for k, v in (value or {}).items()}


def extra_env(meta, ctx: HookCtx) -> dict[str, str]:
    """Resolve EXTRA_ENV (dict or callable(ctx)->dict) to the concrete per-run env map."""
    return _env_map(meta.EXTRA_ENV, ctx)


def upgrade_extra_env(meta, ctx: HookCtx) -> dict[str, str]:
    """Resolve UPGRADE_EXTRA_ENV (dict or callable(ctx)->dict) to the concrete env map."""
    return _env_map(meta.UPGRADE_EXTRA_ENV, ctx)


def upgrade_secret_prep(meta, ctx: HookCtx) -> None:
    """Run UPGRADE_SECRET_PREP(ctx) if defined. Called before `abra secret generate --all` in the
    upgrade path so recipes can pre-insert secrets with the correct format/length."""
    if callable(meta.UPGRADE_SECRET_PREP):
        meta.UPGRADE_SECRET_PREP(ctx)
