"""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

# 셸에서 프로세스의 cwd 를 바꾸는 명령. verifier 가 워크트리 cwd 에서 실행하는
# 계약이 이것들로 무력화된다.
_CWD_CHANGING_COMMANDS: frozenset[str] = frozenset({"cd", "pushd", "popd", "chdir"})

# 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) -> str:
    """`<task_root>/` 접두사를 뗀다. 선언과 매니페스트 entry 가 같은 스크립트를
    서로 다른 접두사로 적을 수 있어, 대조 전에 양쪽을 이 형태로 맞춘다."""
    prefix = "<task_root>/"
    return script[len(prefix):] if script.startswith(prefix) else script


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)
        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 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")
    # 이 명령은 워크트리 cwd 에서 verbatim 실행되고(_implementation-verifier.md
    # "Otherwise run runCommand in the worktree cwd"), **그 cwd 가 곧 검사 대상**이다.
    # 스크립트·tsconfig 는 `.okstra/` 아래 사는데 워크트리에는 `.okstra/` 가 없으므로
    # (implementation-worker-preamble.md "the worktree may not contain them")
    # 그것들을 절대경로로 가리키는 것은 정상이고 사실상 필수다. 금지되는 것은 cwd
    # 를 옮기는 일뿐이다 — 선행 `cd <메인 체크아웃>` 은 stage diff 가 없는 트리에서
    # 검사를 돌려 미변경 코드를 통과시킨다.
    if isinstance(run_command, str):
        for segment in re.split(r"&&|\|\||;|\|", run_command):
            words = segment.split()
            if words and words[0] in _CWD_CHANGING_COMMANDS:
                errors.append(
                    f"{path}.runCommand must run in the worktree cwd — that cwd is "
                    f"the tree under test; a leading `{words[0]}` repoints it, so "
                    "the script checks whichever checkout it lands in instead of "
                    "this stage's diff"
                )
                break
    _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 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
