"""v3.6 Runtime Harness — PR Diff Hygiene Guard (task-2716).

chair_authorization_id=CHAIR-AUTH-TASK-2716-PR-DIFF-HYGIENE-GUARD-260530

목적
----
runtime code PR diff 에 *미선언 artifact* 가 섞이는 것을 차단하는 allowlist-aware
가드. PR #156 / PR #161(evidence md 혼입) 재발 방지. 선언된 expected_files 는
false-positive 0 으로 통과시킨다.

Contract
--------
- classify_pr_diff(changed_files, expected_files) -> dict
    Returns {
        "status": "PASS" | "PR_DIFF_CONTAMINATED",
        "blocked": [<path>, ...],          # 차단된 미선언 artifact
        "reasons": {<path>: <why>, ...},   # 각 차단 파일이 왜 막혔는지
    }
    * status == PASS  → diff 가 선언된 expected_files(code + 선언 report)만 포함.
    * status == PR_DIFF_CONTAMINATED → 미선언 artifact 발견.

- detect_contamination(changed_files, expected_files) -> dict
    watcher 보조 결선용 read-only alias. classify_pr_diff 결과를 그대로 반환하되
    "detection_only": True 를 부가(보고 전용, 차단 아님).

- allowlist 는 *함수 인자*(expected_files)로만 받는다 — 하드코딩 금지.
  task md 의 expected_files 를 뽑아주는 헬퍼 extract_expected_files() 제공.

설계 원칙(회장 verbatim)
------------------------
1. allowlist-aware: memory/reports 전체 차단 금지. expected_files 에 명시된
   파일은 통과. expected_files 에 없는 artifact 패턴만 차단.
2. 결선: 주=finish-task.sh pre-push 차단, 보조=watcher read-only detection.
   CI workflow(.github/workflows/**) 는 본 가드가 절대 건드리지 않는다.
3. 결과 분류: PASS / PR_DIFF_CONTAMINATED.
"""
from __future__ import annotations

import json
import re
import sys
from fnmatch import fnmatch
from typing import Iterable, Optional

CHAIR_AUTHORIZATION_ID = "CHAIR-AUTH-TASK-2716-PR-DIFF-HYGIENE-GUARD-260530"

PASS = "PASS"
PR_DIFF_CONTAMINATED = "PR_DIFF_CONTAMINATED"

# ─── Artifact 차단 패턴 ────────────────────────────────────────────────────────
# 각 엔트리: (reason_label, predicate). predicate(path_norm, basename) -> bool.
# path_norm 은 슬래시 정규화된 상대 경로, basename 은 마지막 path 컴포넌트.
# ★ .github/workflows/** 는 절대 건드리지 않는다 — 여기 패턴에 포함하지 않는다.


def _has_prefix(path_norm: str, prefix: str) -> bool:
    return path_norm == prefix.rstrip("/") or path_norm.startswith(prefix)


_ARTIFACT_RULES = [
    (
        "memory/events/** (런타임 이벤트 마커 — PR diff 에 혼입 금지)",
        lambda p, b: _has_prefix(p, "memory/events/"),
    ),
    (
        "memory/artifacts/** (artifact 산출물 — PR diff 에 혼입 금지)",
        lambda p, b: _has_prefix(p, "memory/artifacts/"),
    ),
    (
        "callback-envelope json (콜백 봉투 — runtime artifact)",
        lambda p, b: fnmatch(b, "*callback-envelope*.json"),
    ),
    (
        "auth marker json (auth-path-marker — runtime artifact)",
        lambda p, b: fnmatch(b, "*auth*marker*.json"),
    ),
    (
        "evidence md (PR #161 회귀 — evidence 문서는 expected_files 선언 필요)",
        lambda p, b: fnmatch(b, "*-evidence.md") or fnmatch(b, "*fix-evidence*.md"),
    ),
    (
        "temporary report (임시 리포트 — 선언 필요)",
        lambda p, b: fnmatch(b, "*temp*report*.md") or fnmatch(b, "*tmp*report*.md"),
    ),
    (
        "cleanup evidence (정리 증거물 — 선언 필요)",
        lambda p, b: fnmatch(b, "*cleanup*evidence*"),
    ),
]


def _normalize(path: str) -> str:
    """슬래시 정규화 + 선행 ./ 제거."""
    p = path.strip().replace("\\", "/")
    while p.startswith("./"):
        p = p[2:]
    return p.lstrip("/") if not p.startswith("/") else p.lstrip("/")


def _is_allowlisted(path_norm: str, allow: Iterable[str]) -> bool:
    """expected_files 에 의해 명시 허용되었는지(정확 일치 또는 glob)."""
    for entry in allow:
        e = _normalize(entry)
        if not e:
            continue
        if path_norm == e:
            return True
        # glob 선언 지원(예: memory/reports/task-2716-*/**)
        if fnmatch(path_norm, e):
            return True
        # 디렉터리 선언(끝이 /) → prefix 허용
        if e.endswith("/") and path_norm.startswith(e):
            return True
    return False


def _artifact_reason(path_norm: str) -> Optional[str]:
    """artifact 차단 패턴에 걸리면 사유 문자열, 아니면 None."""
    basename = path_norm.rsplit("/", 1)[-1]
    for reason, pred in _ARTIFACT_RULES:
        try:
            if pred(path_norm, basename):
                return reason
        except Exception:
            continue
    return None


def classify_pr_diff(
    changed_files: Iterable[str],
    expected_files: Optional[Iterable[str]] = None,
) -> dict:
    """PR diff 의 changed_files 를 expected_files allowlist 기준으로 분류.

    Args:
        changed_files: PR diff 에 포함된 변경 파일 경로 리스트.
        expected_files: 해당 task 가 선언한 allowlist(없으면 빈 리스트).

    Returns:
        {"status": PASS|PR_DIFF_CONTAMINATED, "blocked": [...], "reasons": {...}}
    """
    allow = list(expected_files or [])
    blocked: list[str] = []
    reasons: dict[str, str] = {}

    for raw in changed_files:
        if raw is None:
            continue
        path_norm = _normalize(str(raw))
        if not path_norm:
            continue
        # 1) expected_files 에 명시 선언되었으면 무조건 통과(artifact 패턴이라도 예외).
        if _is_allowlisted(path_norm, allow):
            continue
        # 2) 미선언인데 artifact 패턴에 걸리면 차단.
        reason = _artifact_reason(path_norm)
        if reason is not None:
            blocked.append(path_norm)
            reasons[path_norm] = reason
        # 3) 그 외(코드 등)는 통과 — false-positive 0.

    status = PR_DIFF_CONTAMINATED if blocked else PASS
    return {"status": status, "blocked": blocked, "reasons": reasons}


def detect_contamination(
    changed_files: Iterable[str],
    expected_files: Optional[Iterable[str]] = None,
) -> dict:
    """watcher 보조 결선용 read-only alias — 보고 전용(차단 아님)."""
    result = classify_pr_diff(changed_files, expected_files)
    result["detection_only"] = True
    return result


# ─── expected_files 추출 헬퍼(하드코딩 금지 — task md 에서 동적 수집) ─────────────
_QUOTED = re.compile(r"""['"]([^'"]+)['"]""")


def extract_expected_files(task_md_text: str) -> list[str]:
    """task md 의 expected_files 섹션(YAML 블록 또는 §Affected Files)에서 경로 수집.

    allowlist 소스는 전적으로 task 의 선언분이며, 본 함수는 그것을 파싱만 한다.
    """
    paths: list[str] = []
    # 1) `expected_files:` YAML 스타일 블록 — 다음 헤딩 전까지.
    block = re.search(r"expected_files:\s*\n(.*?)(?=\n#{1,3} |\Z)", task_md_text, re.DOTALL)
    if block:
        for line in block.group(1).splitlines():
            m = re.match(r"\s*-\s+(.+?)\s*$", line)
            if m:
                token = m.group(1).strip().strip("`").strip("'\"")
                # path 처럼 보이는 토큰만(슬래시 또는 확장자 포함)
                if "/" in token or "." in token:
                    paths.append(token)
            qm = _QUOTED.findall(line)
            paths.extend(qm)
    # 중복 제거(순서 유지)
    seen: set[str] = set()
    out: list[str] = []
    for p in paths:
        pn = _normalize(p)
        if pn and pn not in seen:
            seen.add(pn)
            out.append(pn)
    return out


# ─── CLI(finish-task.sh pre-push 결선용) ───────────────────────────────────────
def _read_lines(arg: Optional[str]) -> list[str]:
    """쉼표/개행/공백 구분 문자열 → 리스트. '-' 또는 '@file' 지원."""
    if not arg:
        return []
    if arg == "-":
        data = sys.stdin.read()
    elif arg.startswith("@"):
        with open(arg[1:], encoding="utf-8") as f:
            data = f.read()
    else:
        data = arg
    parts = re.split(r"[,\n]+", data)
    return [p.strip() for p in parts if p.strip()]


def _main(argv: list[str]) -> int:
    import argparse

    ap = argparse.ArgumentParser(
        prog="pr_diff_hygiene_guard",
        description="allowlist-aware PR diff hygiene guard (task-2716).",
    )
    ap.add_argument(
        "--changed",
        help="변경 파일 목록(쉼표/개행 구분, '-'=stdin, '@path'=파일).",
    )
    ap.add_argument(
        "--expected",
        help="expected_files allowlist(쉼표/개행 구분, '-'=stdin, '@path'=파일).",
    )
    ap.add_argument(
        "--task-md",
        help="task md 경로 — expected_files 를 자동 추출(allowlist 보강).",
    )
    ap.add_argument(
        "--detection-only",
        action="store_true",
        help="watcher 보조 모드: 보고만 하고 exit 0(차단 안 함).",
    )
    ap.add_argument("--json", action="store_true", help="결과를 JSON 으로 출력.")
    args = ap.parse_args(argv)

    changed = _read_lines(args.changed)
    expected = _read_lines(args.expected)
    if args.task_md:
        try:
            with open(args.task_md, encoding="utf-8") as f:
                expected += extract_expected_files(f.read())
        except OSError as e:
            print(f"[hygiene-guard] task-md 읽기 실패: {e}", file=sys.stderr)

    result = classify_pr_diff(changed, expected)

    if args.json:
        print(json.dumps(result, ensure_ascii=False))
    else:
        if result["status"] == PASS:
            print(f"[hygiene-guard] PASS — {len(changed)} file(s), contamination 0")
        else:
            print(
                f"[hygiene-guard] PR_DIFF_CONTAMINATED — "
                f"미선언 artifact {len(result['blocked'])}건 차단:",
                file=sys.stderr,
            )
            for path in result["blocked"]:
                print(f"  - {path}  ← {result['reasons'][path]}", file=sys.stderr)

    if args.detection_only:
        # 보조 결선: read-only → 항상 0.
        return 0
    return 0 if result["status"] == PASS else 1


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(_main(sys.argv[1:]))
