"""Customization manifest (rcust P5; spec §8 R4 mitigation).

One block at run start answering "what does this recipe customize?" across ALL the surfaces
(recipe_meta keys, hook files, file-presence, run-time env overrides) — printed to the run log and
embedded verbatim in results.json under "customization". PURE PRESENTATION: building or printing
the manifest must never influence any verdict (R7-class invariant).
"""

from __future__ import annotations

import os
import re

from . import discovery, lifecycle
from . import meta as meta_mod

_PRE_OP_RE = re.compile(r"^def (pre_[a-z]+)\(", re.MULTILINE)

# Meta values are repo-public by construction (recipe_meta.py is committed; real secrets are
# class-B generated, never meta), but the manifest lands on the dashboard — mask values whose
# key NAME is secret-shaped so a field literally called SECRET_KEY_BASE never shows a value
# (defense in depth + keeps dashboard secret-scans quiet). `KEY` matches only as a word segment
# (API_KEY yes, KEYCLOAK_URL no).
_SENSITIVE_NAME_RE = re.compile(r"SECRET|PASSWORD|TOKEN|CREDENTIAL|(^|_)KEY(_|$)", re.IGNORECASE)


def _jsonable(v, name=""):
    """Manifest values must be JSON-serializable + deterministic: hooks render as '<hook>',
    tuples become lists, secret-named entries (by key name, incl. nested dict keys) as
    '<redacted>'."""
    if callable(v):
        return "<hook>"
    if name and _SENSITIVE_NAME_RE.search(name):
        return "<redacted>"
    if isinstance(v, tuple):
        return list(v)
    if isinstance(v, dict):
        return {k: _jsonable(x, name=str(k)) for k, x in v.items()}
    return v


def _pre_ops(path: str) -> list[str]:
    """The pre_<op> hook names an ops.py defines (cheap source scan, same approach as
    discovery._module_defines — no import)."""
    try:
        with open(path) as fh:
            return sorted(set(_PRE_OP_RE.findall(fh.read())))
    except OSError:
        return []


def _custom_counts(recipe: str, repo_local: str | None) -> dict[str, dict[str, int]]:
    out: dict[str, dict[str, int]] = {}
    for source, _path in discovery.custom_tests(recipe, repo_local):
        sub = "custom"
        out.setdefault(source, {}).setdefault(sub, 0)
        out[source][sub] += 1
    return out


def build(recipe: str, meta, repo_local: str | None) -> dict:
    """Collect the run's resolved customization into one deterministic, JSON-serializable dict.

    Keys: meta_non_default (explicitly-customized recipe_meta keys), hooks (ops.py pre-ops +
    install_steps.sh + compose.ccci.yml with their source), overlays (lifecycle overlay files by
    op + source), custom_tests (counts per source/subdir), env_overrides (active
    CCCI_SKIP_GENERIC* — the dev-only escape hatch, flagged when riding a CI run)."""
    hooks: dict = {}
    pre_ops: dict[str, list[str]] = {}
    for source, d in (
        ("cc-ci", discovery.cc_ci_dir(recipe)),
        ("repo-local", discovery._gated(recipe, repo_local)),  # noqa: SLF001 — same HC2 gate
    ):
        if not d:
            continue
        p = os.path.join(d, "ops.py")
        if os.path.isfile(p):
            ops = _pre_ops(p)
            if ops:
                pre_ops[source] = ops
    if pre_ops:
        hooks["ops.py"] = pre_ops
    ist = discovery.install_steps(recipe, repo_local)
    if ist:
        hooks["install_steps.sh"] = ist[0]
    if lifecycle.has_ccci_overlay(recipe):
        hooks["compose.ccci.yml"] = "cc-ci"

    overlays = {}
    for op in discovery.LIFECYCLE_OPS:
        ov = discovery.resolve_overlay_op(recipe, op, repo_local)
        if ov:
            overlays[op] = ov[0]

    env_overrides = sorted(
        k
        for k in os.environ
        if k.startswith("CCCI_SKIP_GENERIC")
        and str(os.environ.get(k) or "").strip().lower() in ("1", "true", "yes", "on")
    )

    return {
        "meta_non_default": {
            k: _jsonable(v, name=k) for k, v in sorted(meta_mod.non_default(meta).items())
        },
        "hooks": hooks,
        "overlays": overlays,
        "custom_tests": _custom_counts(recipe, repo_local),
        "env_overrides": env_overrides,
    }


def render(recipe: str, manifest: dict) -> str:
    """The human block printed at run start (same content as the results.json key)."""
    lines = [f"===== customization manifest: {recipe} ====="]
    nd = manifest["meta_non_default"]
    lines.append(
        "meta (non-default): "
        + (" ".join(f"{k}={v!r}" for k, v in nd.items()) if nd else "(none — zero-config floor)")
    )
    hk = manifest["hooks"]
    parts = []
    for source, ops in hk.get("ops.py", {}).items():
        parts.append(f"ops.py[{','.join(ops)}]({source})")
    if "install_steps.sh" in hk:
        parts.append(f"install_steps.sh({hk['install_steps.sh']})")
    if "compose.ccci.yml" in hk:
        parts.append(f"compose.ccci.yml({hk['compose.ccci.yml']})")
    lines.append("hooks: " + (" ".join(parts) if parts else "(none)"))
    ov = manifest["overlays"]
    lines.append(
        "overlays: "
        + (" ".join(f"test_{op}.py({src})" for op, src in ov.items()) if ov else "(none)")
    )
    ct = manifest["custom_tests"]
    lines.append(
        "custom tests: "
        + (
            " ".join(
                " ".join(f"{sub}/={n}" for sub, n in sorted(counts.items())) + f" ({source})"
                for source, counts in sorted(ct.items())
            )
            if ct
            else "(none)"
        )
    )
    eo = manifest["env_overrides"]
    if eo:
        suffix = "   !! dev-only override active in CI" if os.environ.get("DRONE") else ""
        lines.append("env overrides: " + " ".join(f"{k}=1" for k in eo) + suffix)
    else:
        lines.append("env overrides: (none)")
    return "\n".join(lines)
