"""Overlay / custom-test / install-steps discovery + precedence (Phase 1d/1e, DG4/DG5 + HC2/HC3).

The generic is the default floor for each lifecycle op and, per Phase 1e HC3, runs ADDITIVELY
alongside a recipe overlay by default (the orchestrator owns the op; both assertion sets evaluate the
shared post-op state). Discovery here only locates the candidate assertion files + the install-steps
hook; the orchestrator decides additive-vs-skip. Sources, in precedence order
(machine-docs/DECISIONS.md):

    lifecycle op (install/upgrade/backup/restore) — the OVERLAY assertion file, if any:
        repo-local  tests/test_<op>.py      (upstream-authoritative, wins same-name collisions)
      > cc-ci       tests/<recipe>/test_<op>.py
    (the generic tests/_generic/test_<op>.py is the always-present floor, run separately by default)

    custom test_*.py (`custom/` canonical; `functional/` + `playwright/` deprecated aliases) —
        ALL run, additively, from BOTH locations (opt-in).

    install-steps hook — install_steps.sh: repo-local > cc-ci, or none.

Repo-local = the recipe repo's own tests/ dir, snapshotted after fetch (it survives abra
re-checking-out the recipe to a version tag — see the run orchestrator). It is PR-author-controlled
code that runs on the CI host with /run/secrets/* present, so per Phase 1e HC2 it is **default-deny**:
the repo-local source is consulted ONLY when the recipe is on the cc-ci approval allowlist
(`tests/repo-local-approved.txt`). Otherwise precedence is cc-ci > generic only.
"""

from __future__ import annotations

import glob
import os
import sys

LIFECYCLE_OPS = ("install", "upgrade", "backup", "restore")

ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
GENERIC_DIR = os.path.join(ROOT, "tests", "_generic")
APPROVED_FILE = os.path.join(ROOT, "tests", "repo-local-approved.txt")


def cc_ci_dir(recipe: str) -> str:
    return os.path.join(ROOT, "tests", recipe)


def approved_file_path() -> str:
    """Location of the repo-local approval allowlist. Overridable via CCCI_REPO_LOCAL_APPROVED_FILE
    (used by tests + by the Adversary to demonstrate approved-vs-not without editing the checked-in
    file). Default: the git-tracked tests/repo-local-approved.txt."""
    return os.environ.get("CCCI_REPO_LOCAL_APPROVED_FILE", APPROVED_FILE)


def approved_recipes() -> set[str]:
    """Recipes whose repo-local (PR-authored) code is trusted to execute (HC2). One name per line in
    the allowlist; `#` comments + blank lines ignored. Missing file ⇒ empty set ⇒ default-deny."""
    names: set[str] = set()
    try:
        with open(approved_file_path()) as fh:
            for raw in fh:
                line = raw.split("#", 1)[0].strip()
                if line:
                    names.add(line)
    except OSError:
        pass
    return names


def repo_local_approved(recipe: str) -> bool:
    """True iff `recipe` is on the cc-ci repo-local approval allowlist (default-deny, HC2)."""
    return recipe in approved_recipes()


def _gated(recipe: str, repo_local_dir: str | None) -> str | None:
    """The repo-local dir to actually consult: the given dir if the recipe is approved, else None
    (default-deny). Centralizes the HC2 gate so every discovery function honors it identically."""
    return repo_local_dir if (repo_local_dir and repo_local_approved(recipe)) else None


def resolve_overlay_op(recipe: str, op: str, repo_local_dir: str | None) -> tuple[str, str] | None:
    """Return (source, path) for the OVERLAY assertion file for `op` (repo-local > cc-ci), or None if
    the recipe ships no overlay for it. The generic floor is handled separately by the orchestrator
    (HC3 additive). Repo-local is consulted only for allowlist-approved recipes (HC2)."""
    fname = f"test_{op}.py"
    rl = _gated(recipe, repo_local_dir)
    if rl:
        p = os.path.join(rl, fname)
        if os.path.isfile(p):
            return ("repo-local", p)
    p = os.path.join(cc_ci_dir(recipe), fname)
    if os.path.isfile(p):
        return ("cc-ci", p)
    return None


def generic_op(op: str) -> tuple[str, str]:
    """The always-present generic assertion file for `op` (the floor, HC3)."""
    return ("generic", os.path.join(GENERIC_DIR, f"test_{op}.py"))


def resolve_op(recipe: str, op: str, repo_local_dir: str | None) -> tuple[str, str]:
    """Back-compat single-file resolver (override semantics): overlay if present, else generic.
    Phase-1e orchestration uses resolve_overlay_op + generic_op (additive); this remains for unit
    tests and any caller wanting the legacy "one file wins" view. HC2 gate still applies."""
    return resolve_overlay_op(recipe, op, repo_local_dir) or generic_op(op)


def custom_tests(recipe: str, repo_local_dir: str | None) -> list[tuple[str, str]]:
    """All custom-tier test_*.py from cc-ci's tests/<recipe>/ and (if approved) the recipe's
    repo-local tests/. PLACEMENT RULE (rcust P4): custom tests live under canonical
      - custom/       tests/<recipe>/custom/test_*.py      (canonical home)
      - functional/   tests/<recipe>/functional/test_*.py  (deprecated alias)
      - playwright/   tests/<recipe>/playwright/test_*.py  (deprecated alias)
    A top-level test_*.py is a LIFECYCLE OVERLAY (test_<op>.py) and nothing else — top-level
    non-lifecycle files are NOT discovered (zero users at the time of the change; the lifecycle-
    name exclusion below stays as a safety net so a misfiled test_<op>.py can never double-run).
    Repo-local is consulted only for allowlist-approved recipes (HC2)."""
    lifecycle_names = {f"test_{op}.py" for op in LIFECYCLE_OPS}
    subdirs = ("custom", "functional", "playwright")
    found: list[tuple[str, str]] = []
    for source, d in (("cc-ci", cc_ci_dir(recipe)), ("repo-local", _gated(recipe, repo_local_dir))):
        if not d or not os.path.isdir(d):
            continue
        for sub in subdirs:
            for p in sorted(glob.glob(os.path.join(d, sub, "test_*.py"))):
                if os.path.basename(p) not in lifecycle_names:
                    if sub != "custom":
                        print(
                            f"WARNING [cfold]: test found in deprecated folder '{sub}/' — move to custom/: {p}",
                            file=sys.stderr,
                            flush=True,
                        )
                    found.append((source, p))
    return found


def install_steps(recipe: str, repo_local_dir: str | None) -> tuple[str, str] | None:
    """The custom install-steps hook (install_steps.sh) for a recipe, or None. repo-local > cc-ci.
    Repo-local is consulted only for allowlist-approved recipes (HC2)."""
    rl = _gated(recipe, repo_local_dir)
    if rl:
        p = os.path.join(rl, "install_steps.sh")
        if os.path.isfile(p):
            return ("repo-local", p)
    p = os.path.join(cc_ci_dir(recipe), "install_steps.sh")
    if os.path.isfile(p):
        return ("cc-ci", p)
    return None


def pre_op_hook(recipe: str, op: str, repo_local_dir: str | None) -> tuple[str, str] | None:
    """The pre-op seed hook for `op`: the path to a recipe `ops.py` module that defines a
    `pre_<op>(ctx)` callable, or None. cc-ci's tests/<recipe>/ops.py wins; the repo-local
    ops.py is consulted only for allowlist-approved recipes (HC2). The orchestrator imports the
    module and calls pre_<op> BEFORE performing the op (HC3 op/assertion split — overlays seed
    pre-op state here, then assert post-op in test_<op>.py)."""
    fn = f"pre_{op}"
    for source, d in (("cc-ci", cc_ci_dir(recipe)), ("repo-local", _gated(recipe, repo_local_dir))):
        if not d:
            continue
        p = os.path.join(d, "ops.py")
        if os.path.isfile(p) and _module_defines(p, fn):
            return (source, p)
    return None


def _module_defines(path: str, name: str) -> bool:
    """Cheap source scan for a top-level `def <name>(` — avoids importing the module just to check."""
    try:
        with open(path) as fh:
            src = fh.read()
    except OSError:
        return False
    return f"def {name}(" in src
