"""Stage conformance(Tier 3) 매니페스트 검증 + `QA-RESULT` 파서.

implementation/final-verification 의 verifier 는 stage 별 conformance 스크립트를
실행해 상위 요구사항 부합을 검증한다. 본 모듈은 그 검증/파싱의 결정론적 코어다.

1. `conformance-manifest.json` 구조 검증 (`validate_conformance_manifest`).
2. 스크립트 stdout 의 `QA-RESULT` 마커 파싱 (`parse_qa_result`).
3. 선언된 script 파일 부재 (`missing_declared_scripts`) — 구현/최종검증만.

스크립트 실행/게이트 강제는 verifier prompt 와 validators/validate-run.py 가 담당한다.
계획 단계는 선언만 내고 이 모듈의 파일 존재 검사를 호출하지 않는다.
"""
from __future__ import annotations

import fnmatch
import re
from dataclasses import dataclass
from pathlib import Path

from .qa_commands import verification_command_defects

# diff 가 건드린 표면과 대조할 capability 태그 화이트리스트.
CAPABILITY_WHITELIST: tuple[str, ...] = ("db", "io", "http", "external")
EXTERNAL_ADVISORY_CAPABILITIES: frozenset[str] = frozenset(
    {"db", "http", "external"}
)


# --- 계획 산출물의 stage conformance 선언 파싱 ---
#
# 정본 형식은 prompts/profiles/implementation-planning.md 의
# `Conformance tests: stage-<N> — <script> (requires=[db|io|http|external,...])`
# 이고, data.json 의 `conformanceTests` 는 그 줄에서 `stage-<N> — ` 접두사를 뗀
# 나머지다. 승인 경계(run.py `_validate_approved_plan`)와 구현 런 끝
# (validators/validate-run.py)이 이 한 규칙을 같이 읽는다 — 규칙을 두 곳에
# 복제하면 승인은 통과하는데 구현 런은 막히는 상태가 다시 만들어진다.
_CONFORMANCE_TESTS_RE = re.compile(
    r"^(?P<script>\S+)\s+\(requires=\[(?P<requires>[^\]]+)\]\)$"
)


def normalize_conformance_script(
    script: str,
    task_root: Path | None = None,
) -> str:
    """script 표기를 task-root 상대형 한 가지로 맞춘다.

    같은 스크립트를 선언(승인 계획)과 매니페스트 entry 가 서로 다른 접두사로
    적는다 — `<task_root>/qa/...`, `./qa/...`, 그리고 계획이 절대경로
    `/…/<task_root>/qa/scripts/stage-2.ts` 를 쓰고 실행자는 `qa/scripts/stage-2.ts`
    를 쓴 실측(2026-09-10 dev-10628-3 stage 2). 대조 전에 양쪽을 이 함수로
    통과시켜 표기 차이만으로 stage 가 막히지 않게 한다.

    `task_root` 를 주면 그 아래를 가리키는 절대경로도 상대형으로 접는다. 주지
    않으면 절대경로는 그대로 둔다 — 계획 단계 파싱처럼 task_root 를 모르는
    호출부가 있다.
    """
    prefix = "<task_root>/"
    value = script[len(prefix):] if script.startswith(prefix) else script
    while value.startswith("./"):
        value = value[2:]
    if task_root is not None and value.startswith(".okstra/"):
        for parent in task_root.resolve().parents:
            if parent.name == ".okstra" and task_root.resolve().is_relative_to(
                parent / "tasks"
            ):
                value = str((parent.parent / value).resolve())
                break
    if task_root is not None and value.startswith("/"):
        try:
            return Path(value).resolve().relative_to(task_root.resolve()).as_posix()
        except (ValueError, OSError):
            return value
    return value


# 계획 서사의 정본 줄은 `Conformance tests: stage-<N> — <나머지>` 이고 data.json
# `conformanceTests` 는 그 나머지만 담는다. writer 가 `stage-<N> — ` 라벨까지
# 옮겨 적는 실측 실패가 있어(dev-10341 stage 1) 조립이 게시 전에 이 라벨을
# 뗀다. 파서(`parse_conformance_tests`)는 라벨을 용인하지 않는다 — 용인하면
# 정본 형식이 두 개가 된다.
_STAGE_DECLARATION_LABEL_RE = re.compile(
    r"^stage-(?P<stage>[1-9]\d*)[ \t]+[—–-][ \t]+"
)


def strip_stage_declaration_label(stage: int, value: str) -> str:
    """자기 stage 의 `stage-<N> — ` 라벨 접두를 뗀 나머지를 돌려준다.

    라벨이 없거나 다른 stage 번호를 달고 있으면 원문을 그대로 둔다 — 그런 값은
    뒤따르는 검증이 판정할 몫이다.
    """
    stripped = value.strip()
    match = _STAGE_DECLARATION_LABEL_RE.match(stripped)
    if match is None or int(match.group("stage")) != stage:
        return value
    return stripped[match.end():]


def missing_declared_scripts(entries: object, task_root: Path) -> list[str]:
    """승인 계획이 가리키는 script 파일이 task_root 아래에 없으면 오류.

    계획 단계는 호출하지 않는다. 스크립트와 runCommand 는 매칭 implementation
    stage 가 만들고, 구현/최종검증 게이트만 이 함수로 부재를 닫힌 실패로 본다.
    """
    errors: list[str] = []
    if not isinstance(entries, list):
        return errors
    root = task_root.resolve()
    for entry in entries:
        if not isinstance(entry, dict):
            continue
        script = entry.get("script")
        if not isinstance(script, str) or not script.strip():
            continue
        relative = normalize_conformance_script(script, task_root)
        candidate = (root / relative).resolve()
        stage_number = str(entry.get("stageKey") or "").rsplit("-stage-", 1)[-1]
        try:
            candidate.relative_to(root)
        except ValueError:
            errors.append(
                f"stage {stage_number} declared script escapes task root: {relative}"
            )
            continue
        if not candidate.is_file():
            errors.append(
                f"stage {stage_number} declared script is missing: {relative}"
            )
    return errors


def parse_conformance_tests(value: object) -> tuple[str, frozenset[str]] | None:
    """stage 선언의 `<script> (requires=[cap,...])` 를 파싱. 형식이 아니면 None."""
    if not isinstance(value, str):
        return None
    match = _CONFORMANCE_TESTS_RE.fullmatch(value.strip())
    if match is None:
        return None
    script = normalize_conformance_script(match.group("script"))
    capabilities = [part.strip() for part in match.group("requires").split(",")]
    if (
        not script
        or any(not capability for capability in capabilities)
        or len(set(capabilities)) != len(capabilities)
        or any(capability not in CAPABILITY_WHITELIST for capability in capabilities)
    ):
        return None
    return script, frozenset(capabilities)


def malformed_conformance_stages(data: object) -> list[int]:
    """`conformanceTests` 를 선언했으나 파싱되지 않는 stage 번호들.

    선언이 아예 없는 stage 는 대상이 아니다 — 둘 중 하나를 요구하는 XOR 은
    validators/validate-implementation-plan-stages.py 의 check S11 이 계획
    단계에서 이미 판정한다. `stage` 가 양의 정수가 아닌 경우도 여기 몫이 아니다:
    스키마(`stage: integer, minimum 1`)가 계획 런의 validate-run 에서 먼저 막는다.
    `conformanceTests` 가 문자열이 아닌 경우도 같은 이유로 뺀다 — 스키마가
    `type: string, minLength: 1` 로 못박아 그 값은 계획 런의 validate-run 을
    통과하지 못한다. 여기서 그것까지 malformed 로 세면 스키마가 이미 낸 판정을
    이 게이트가 다른 문구로 되풀이한다.
    """
    planning = data.get("implementationPlanning") if isinstance(data, dict) else None
    stages = planning.get("stages") if isinstance(planning, dict) else None
    bad: list[int] = []
    for stage in stages if isinstance(stages, list) else []:
        if not isinstance(stage, dict):
            continue
        declared = stage.get("conformanceTests")
        if not isinstance(declared, str) or not declared.strip():
            continue
        number = stage.get("stage")
        if not isinstance(number, int) or isinstance(number, bool) or number < 1:
            continue
        if parse_conformance_tests(declared) is None:
            bad.append(number)
    return bad


def exempt_stage_surface_conflicts(
    data: object, patterns: object = None,
) -> list[dict[str, object]]:
    """`Conformance exemption:` 을 선언했는데 계획된 경로가 capability 표면을 건드리는 stage.

    면제 규칙(prompts/profiles/implementation-planning.md "Per-stage conformance
    declaration")은 "db/io/http/external 표면을 건드리지 않는 stage" 에만 허용하고,
    구현 diff 가 그 표면을 건드리면 validate-run 의 diff-surface 대조가 막는다고
    적는다. 그 대조는 구현이 끝난 뒤에만 돌았고, 승인된 계획은 불변이라 그때는
    고칠 수 없었다(실측 2026-09-09, dev-10784 Stage 2: plannedPaths 에 ORM
    repository 를 넣고 면제 선언 → 구현 완료 후 `contract-violated`). 같은 모순은
    계획의 `stepwiseExecution[].plannedPaths` 로 승인 전에 판별된다 — 이 함수가
    그 판별이고, 구현 게이트와 같은 `detect_surfaces` 를 쓴다.

    반환 행: `{"stage": <int>, "surfaces": [..], "paths": [..]}`. `plannedPaths`
    가 없는 step 은 `files` 문자열(쉼표 구분)을 대신 읽는다.
    """
    planning = data.get("implementationPlanning") if isinstance(data, dict) else None
    stages = planning.get("stages") if isinstance(planning, dict) else None
    conflicts: list[dict[str, object]] = []
    for stage in stages if isinstance(stages, list) else []:
        if not isinstance(stage, dict):
            continue
        exemption = stage.get("conformanceExemption")
        if not isinstance(exemption, str) or not exemption.strip():
            continue
        number = stage.get("stage")
        if not isinstance(number, int) or isinstance(number, bool) or number < 1:
            continue
        paths = _stage_planned_paths(stage)
        touching = sorted(
            path for path in paths if detect_surfaces([path], patterns)
        )
        surfaces = detect_surfaces(touching, patterns)
        if surfaces:
            conflicts.append(
                {"stage": number, "surfaces": sorted(surfaces), "paths": touching}
            )
    return conflicts


def _stage_planned_paths(stage: dict) -> list[str]:
    """stage 의 step 들이 계획한 경로 — 경로 모양인 문자열만.

    plannedPaths 에는 `(none — read-only repository command)` 같은 산문
    자리표시자도 들어온다(실측 2026-09-09 dev-10627). 공백이 든 문자열을 표면
    패턴에 대면 `*repository*` 가 그 산문에 걸려 거짓 양성이 된다. 실제 경로는
    공백이 없고 `/` 나 `.` 을 품는다."""
    paths: list[str] = []
    for step in stage.get("stepwiseExecution") or []:
        if not isinstance(step, dict):
            continue
        planned = step.get("plannedPaths")
        candidates: list[str] = []
        if isinstance(planned, list):
            candidates = [p for p in planned if isinstance(p, str)]
        elif isinstance(step.get("files"), str):
            candidates = step["files"].split(",")
        paths.extend(c.strip() for c in candidates if _looks_like_path(c.strip()))
    return paths


def _looks_like_path(value: str) -> bool:
    if not value or any(ch.isspace() for ch in value):
        return False
    if value.startswith(".okstra/"):
        # task 산출물(qa 스크립트·fixture·decision 기록)은 코드 표면이 아니다 —
        # 구현 diff 는 워크트리에서 나오므로 그 경로는 구현 게이트에도 닿지 않는다.
        # 실측(84개 계획 sweep): `.okstra/**` 를 세면 `*migration*` 이 decision
        # 파일명에 걸려 거짓 양성이 14건 늘었다.
        return False
    return "/" in value or "." in value


def is_advisory_conformance_entry(entry: object) -> bool:
    """Return whether one entry depends on user-owned external QA."""
    if not isinstance(entry, dict):
        return False
    requires = entry.get("requires")
    if not isinstance(requires, list):
        return False
    return bool(
        EXTERNAL_ADVISORY_CAPABILITIES.intersection(
            capability for capability in requires if isinstance(capability, str)
        )
    )


def _check_nonempty_str(value: object, path: str, errors: list[str]) -> bool:
    if not isinstance(value, str) or not value.strip():
        errors.append(f"{path} must be a non-empty string")
        return False
    return True


def _check_capabilities(value: object, path: str, errors: list[str]) -> None:
    if not isinstance(value, list):
        errors.append(f"{path} must be an array")
        return
    for cap in value:
        if cap not in CAPABILITY_WHITELIST:
            errors.append(
                f"{path}: unknown capability {cap!r} "
                f"(allowed: {', '.join(CAPABILITY_WHITELIST)})"
            )


def _check_exemption(value: object, path: str, errors: list[str]) -> None:
    if value is None:
        return
    if not isinstance(value, dict):
        errors.append(f"{path} must be an object or null")
        return
    _check_nonempty_str(value.get("reason"), f"{path}.reason", errors)
    _check_nonempty_str(value.get("declaredAt"), f"{path}.declaredAt", errors)


def _check_waiver(value: object, path: str, errors: list[str]) -> None:
    if value is None:
        return
    if not isinstance(value, dict):
        errors.append(f"{path} must be an object or null")
        return
    _check_nonempty_str(value.get("acknowledgedBy"), f"{path}.acknowledgedBy", errors)
    _check_nonempty_str(value.get("reason"), f"{path}.reason", errors)
    _check_nonempty_str(value.get("at"), f"{path}.at", errors)
    _check_capabilities(value.get("scope", []), f"{path}.scope", errors)


def _check_entry(entry: object, idx: int, errors: list[str]) -> None:
    path = f"entries[{idx}]"
    if not isinstance(entry, dict):
        errors.append(f"{path} must be an object")
        return
    _check_nonempty_str(entry.get("stageKey"), f"{path}.stageKey", errors)
    _check_nonempty_str(entry.get("script"), f"{path}.script", errors)
    script = entry.get("script")
    # 실행 스크립트는 qa/scripts/ 하위 격리가 계약(implementation 이 파일을 씀);
    # qa/ 루트는 manifest·result-*.json 데이터 사이드카 전용이다.
    if isinstance(script, str) and script.strip() and "qa/scripts/" not in script:
        errors.append(f"{path}.script must live under the task qa scripts dir (qa/scripts/), got {script!r}")
    _check_nonempty_str(entry.get("runCommand"), f"{path}.runCommand", errors)
    run_command = entry.get("runCommand")
    if isinstance(run_command, str):
        errors.extend(f"{path}.runCommand {error}" for error in verification_command_defects(run_command))
    _check_nonempty_str(entry.get("passContract"), f"{path}.passContract", errors)
    req_ids = entry.get("requirementIds")
    if (
        not isinstance(req_ids, list)
        or not req_ids
        or not all(isinstance(r, str) and r.strip() for r in req_ids)
    ):
        errors.append(f"{path}.requirementIds must be a non-empty array of strings")
    _check_capabilities(entry.get("requires", []), f"{path}.requires", errors)
    _check_exemption(entry.get("exemption"), f"{path}.exemption", errors)
    _check_waiver(entry.get("waiver"), f"{path}.waiver", errors)


def validate_conformance_manifest(manifest: object) -> list[str]:
    """conformance-manifest 전체 검증. 위반 메시지 리스트 반환(비면 안전).

    매니페스트 부재(None)는 합법 — 스크립트 없는 task 가 있을 수 있고, 게이트
    강제(diff surface 대조)는 validators/validate-run.py 가 판정한다.
    """
    if manifest is None:
        return []
    if not isinstance(manifest, dict):
        return [f"conformance manifest must be an object, got {type(manifest).__name__}"]
    entries = manifest.get("entries")
    if not isinstance(entries, list):
        return ["conformance manifest .entries must be an array"]
    errors: list[str] = []
    seen: set[str] = set()
    for idx, entry in enumerate(entries):
        _check_entry(entry, idx, errors)
        key = entry.get("stageKey") if isinstance(entry, dict) else None
        if isinstance(key, str) and key:
            if key in seen:
                errors.append(f"entries[{idx}].stageKey duplicate: {key!r}")
            seen.add(key)
    return errors


_QA_RESULT_RE = re.compile(r"^QA-RESULT:\s*(PASS|FAIL)\s*$", re.MULTILINE)
_REQ_LINE_RE = re.compile(r"^REQ\s+(\S+):\s*(PASS|FAIL):\s*(.*)$", re.MULTILINE)


@dataclass
class QaResult:
    overall: str  # "PASS" | "FAIL" | "MISSING"
    requirements: dict[str, dict[str, str]]  # id -> {"status": "PASS"|"FAIL", "reason": str}


def conformance_result_file(qa_dir: Path, stage_key: str) -> Path:
    """한 stage 의 conformance 실행 결과 사이드카 경로.

    이름 규칙이 검증기 안에만 리터럴로 있어, 같은 파일을 읽어야 하는 두 번째
    소비자가 생기면 규칙이 두 곳에 적히게 된다. 매니페스트 경로와 같은 자리에서
    소유한다.
    """
    return Path(qa_dir) / f"result-{stage_key}.json"


def parse_qa_result(stdout: str) -> QaResult:
    """스크립트 stdout 에서 `QA-RESULT` 마커 + `REQ` 줄 파싱.

    마커가 없으면 overall='MISSING' — 스크립트가 계약을 안 지킨 것이므로 게이트는
    FAIL 로 취급한다. 마커가 여럿이면 마지막 것을 채택한다.
    """
    text = stdout or ""
    markers = _QA_RESULT_RE.findall(text)
    overall = markers[-1] if markers else "MISSING"
    requirements: dict = {}
    for rid, status, reason in _REQ_LINE_RE.findall(text):
        requirements[rid] = {"status": status, "reason": reason.strip()}
    return QaResult(overall=overall, requirements=requirements)


@dataclass
class ConformanceVerdict:
    stage_key: str
    status: str        # "PASS" | "ADVISORY" | "BLOCKING" | "WAIVED" | "EXEMPT"
    ok: bool           # True when the gate permits progress.
    conditional: bool  # True only for a user-confirmed blocking-entry waiver.
    message: str


def _waiver_covers_entry(waiver: dict, entry: dict) -> bool:
    """waiver 가 이 entry 의 conformance 를 통째로 면해 주는지 판정.

    spec §7.2 의 capability allowlist 의미: `scope` 가 빈 배열이면 stage 전체
    waiver(blanket). 비어 있지 않으면 그 capability 들만 waive 하므로, entry 가
    요구하는 모든 capability(`requires`)가 scope 안에 들어올 때만 entry 가 완전히
    가려진다. scope 밖 capability 를 entry 가 건드리면 waiver 가 덮지 못하므로
    결과로 정상 게이트해야 한다(예: `db` 만 waive 한 run 에서 `http` 는 계속 게이트)."""
    requires = entry.get("requires")
    required = {c for c in requires if isinstance(c, str)} if isinstance(requires, list) else set()
    if not required:
        return False
    scope = waiver.get("scope")
    if not isinstance(scope, list) or not scope:
        return True
    return required.issubset({c for c in scope if isinstance(c, str)})


def decide_conformance_gate(entry: dict, result: object) -> ConformanceVerdict:
    """Determine the gate verdict from one stage entry and its result.

    Precedence is exemption, advisory waiver, blocking waiver, then result
    evaluation. A PASS result passes. For unexecuted, missing, or failed results,
    external-capability entries are ADVISORY with user-owned follow-up; all other
    entries are BLOCKING. Manifest validation already guarantees valid exemption
    and waiver shapes.
    """
    key = entry.get("stageKey", "<unknown>")
    exemption = entry.get("exemption")
    if exemption:
        return ConformanceVerdict(
            key, "EXEMPT", True, False,
            f"conformance exempted: {exemption.get('reason', '')}",
        )
    advisory = is_advisory_conformance_entry(entry)
    waiver = entry.get("waiver")
    if advisory and waiver and _waiver_covers_entry(waiver, entry):
        return ConformanceVerdict(
            key, "ADVISORY", True, False,
            f"external conformance not run by user waiver: "
            f"{waiver.get('reason', '')}",
        )
    if not advisory and waiver and _waiver_covers_entry(waiver, entry):
        return ConformanceVerdict(
            key, "WAIVED", True, True,
            f"conformance waived by {waiver.get('acknowledgedBy', '?')}: "
            f"{waiver.get('reason', '')}",
        )
    overall = getattr(result, "overall", None)  # None when result is None → "never ran"
    if overall == "PASS":
        return ConformanceVerdict(key, "PASS", True, False, "conformance PASS")
    if advisory:
        if overall is None:
            reason = "external conformance did not run"
        elif overall == "MISSING":
            reason = "external conformance emitted no QA-RESULT marker"
        else:
            reason = f"external conformance {overall}"
        return ConformanceVerdict(
            key, "ADVISORY", True, False,
            f"{reason} (user-owned follow-up; non-blocking)",
        )
    if overall is None:
        return ConformanceVerdict(
            key, "BLOCKING", False, False,
            "conformance script never ran (no result recorded)",
        )
    if overall == "MISSING":
        return ConformanceVerdict(
            key, "BLOCKING", False, False,
            "conformance script ran but emitted no QA-RESULT marker",
        )
    return ConformanceVerdict(key, "BLOCKING", False, False, f"conformance {overall}")


def qa_result_from_dict(data: object) -> QaResult:
    """결과 사이드카(JSON dict)를 `QaResult` 로 복원. Phase 3 의 verifier 가 쓴
    `result-stage-<N>.json` 을 validate-run 이 로드할 때 쓴다. 형태가 깨졌으면
    overall='MISSING'(=BLOCKING 취급)으로 안전하게 강등한다."""
    if not isinstance(data, dict):
        return QaResult(overall="MISSING", requirements={})
    overall = data.get("overall")
    if overall not in ("PASS", "FAIL", "MISSING"):
        overall = "MISSING"
    reqs = data.get("requirements")
    return QaResult(overall=overall, requirements=reqs if isinstance(reqs, dict) else {})


def evaluate_conformance(manifest: object, results_by_stage: object) -> list[ConformanceVerdict]:
    """매니페스트 전 entry 에 대해 게이트 판정 목록을 반환.

    `results_by_stage`: stageKey -> `QaResult`. 키가 없으면 미실행(None)으로 본다.
    매니페스트 구조 검증은 호출 전에 `validate_conformance_manifest` 로 끝낸다는 전제.
    """
    entries = manifest.get("entries") if isinstance(manifest, dict) else None
    if not isinstance(entries, list):
        return []
    results = results_by_stage if isinstance(results_by_stage, dict) else {}
    verdicts: list[ConformanceVerdict] = []
    for entry in entries:
        if not isinstance(entry, dict):
            continue
        result = results.get(entry.get("stageKey"))
        verdicts.append(decide_conformance_gate(entry, result))
    return verdicts


# 경로 → capability surface 기본 매핑. 프로젝트별 override 는 qaEnv.surfacePatterns
# (Phase 4e). 'external' 은 경로로 감지하기 어려워 기본 패턴 없음 — 명시 선언 의존.
_DEFAULT_SURFACE_PATTERNS: dict[str, tuple[str, ...]] = {
    "db": ("*.sql", "*migration*", "*repository*", "*.entity.*", "*entities*", "*schema.prisma*"),
    "http": ("*controller*", "*.routes.*", "*router*", "*endpoint*", "*.api.*"),
    "io": ("*filesystem*", "*storage*", "*.fs.*"),
}


def detect_surfaces(file_paths: object, patterns: object = None) -> set[str]:
    """변경된 파일 경로들에서 capability surface 집합을 감지(소문자 fnmatch).
    `patterns` 미지정 시 기본 매핑 사용."""
    table = patterns if isinstance(patterns, dict) else _DEFAULT_SURFACE_PATTERNS
    found: set[str] = set()
    for raw in file_paths or []:
        if not isinstance(raw, str):
            continue
        path = raw.strip().lower()
        for surface, globs in table.items():
            if any(fnmatch.fnmatch(path, g) for g in globs):
                found.add(surface)
    return found


def parse_qa_waiver_arg(arg: object) -> tuple[str, str] | None:
    """`--qa-waiver` 값 `<stageKey>:<reason>` 를 (stageKey, reason) 로 분해.
    형식이 아니거나 비면 None."""
    if not isinstance(arg, str) or ":" not in arg:
        return None
    key, reason = arg.split(":", 1)
    key, reason = key.strip(), reason.strip()
    if not key or not reason:
        return None
    return key, reason


def apply_qa_waiver(manifest: object, stage_key: str, reason: str, *, at: str,
                    acknowledged_by: str = "user") -> bool:
    """매니페스트에서 stage_key entry 의 `waiver` 를 채운다(in place). 찾으면 True.
    사용자 확인형 우회(spec §7.2) — reason 은 사용자 지시 원문."""
    entries = manifest.get("entries") if isinstance(manifest, dict) else None
    if not isinstance(entries, list):
        return False
    for entry in entries:
        if isinstance(entry, dict) and entry.get("stageKey") == stage_key:
            entry["waiver"] = {"acknowledgedBy": acknowledged_by, "reason": reason,
                               "scope": [], "at": at}
            return True
    return False


def clear_qa_waiver(manifest: object, stage_key: str) -> bool:
    """stage_key entry 의 `waiver` 를 제거한다(in place). 제거했으면 True.

    한 stage 의 새 run 이 시작될 때, 그 stage entry 에 남아 있던 이전 run 의
    waiver(예: all-gate run 이 미래 stage 를 미리 waive 한 것)는 stale 다 —
    그대로 두면 verifier 가 conformance 를 skip 해 마스킹된다. 이 run 이 실제로
    검증하도록 제거한다. 사용자가 이번 run 에 같은 stage 를 명시 waive 한
    경우(--qa-waiver)는 호출 측에서 걸러 보존한다."""
    entries = manifest.get("entries") if isinstance(manifest, dict) else None
    if not isinstance(entries, list):
        return False
    for entry in entries:
        if isinstance(entry, dict) and entry.get("stageKey") == stage_key:
            return entry.pop("waiver", None) is not None
    return False


def manifest_required_surfaces(manifest: object) -> set[str]:
    """매니페스트 전 entry 의 `requires` 합집합 — 선언된 surface 집합."""
    entries = manifest.get("entries") if isinstance(manifest, dict) else None
    if not isinstance(entries, list):
        return set()
    out: set[str] = set()
    for entry in entries:
        if isinstance(entry, dict) and isinstance(entry.get("requires"), list):
            out.update(c for c in entry["requires"] if isinstance(c, str))
    return out
