"""Gate 1 — Semantic Regex Ban (umbrella spec §3.1).

production diff (backend/app/**/*.py) 의 changed lines 에서 open-world
visual / story semantic classifier 목적의 `_NOUNS / _TOKENS / _KEYWORDS /
_PHRASES / _PATTERNS / _STEMS / _TAILS / _MARKERS` 식별자 **신규 추가** 시
review blocker.

Allowlist (closed-world 허용) — substring 매칭 금지. identifier 를
'_' token 으로 split 후 exact token set 매칭 또는 ID-prefix regex 매칭.

allowed body tokens (multi-token 식별자는 모든 token 이 set 안에 있어야 통과):
  PATH / HASH / VERSION / STATUS / SAFETY / SCHEMA / POLICY /
  ID / IDS / SHORT / ENTITY
allowed ID-prefix (literal scenario ID — umbrella spec §3.1):
  ^[CPLO]\\d{2}(?:O\\d{2})? 가 body 시작 (예: _C01_NOUNS, _P02_TOKENS).

CI 단계:
- 1차 (본 file): pytest changed-lines-only static gate.
- 2차: review checklist.
- 나중: pre-commit hook.

Silent-disarm 방지:
- pathspec = ':(glob)...' magic prefix → GIT_LITERAL_PATHSPECS=1 환경에서도 작동.
- git diff 실패 (shallow clone / bad rev / pathspec error) → pytest.fail (skip 아님).
- 변경 없음 (정상 empty stdout) 만 skip.
"""
import re
import subprocess
from pathlib import Path

import pytest


_REPO_ROOT = Path(__file__).resolve().parents[3]
_FORBIDDEN_PATTERN = re.compile(
    r"\b_[A-Z][A-Z0-9_]*"
    r"(?:NOUNS|TOKENS|KEYWORDS|PHRASES|PATTERNS|STEMS|TAILS|MARKERS)\b"
)
_BAN_SUFFIXES = (
    "NOUNS", "TOKENS", "KEYWORDS", "PHRASES",
    "PATTERNS", "STEMS", "TAILS", "MARKERS",
)

# Closed-world body tokens. identifier body 의 모든 token 이 이 set 안이면 allow.
_ALLOWLIST_TOKENS = frozenset({
    "PATH",     # file path / URL path (closed system)
    "HASH",     # hash digest field
    "VERSION",  # version field
    "STATUS",   # status enum
    "SAFETY",   # safety policy literal (Area I SPECIAL)
    "SCHEMA",   # schema field name token
    "POLICY",   # policy literal
    "ID",       # ID-related token
    "IDS",      # IDs-related token
    "SHORT",    # SHORT_ID compound (closed)
    "ENTITY",   # ENTITY_ID compound (closed)
})
# ID-prefix scenario-ID literal (umbrella spec §3.1 ID regex allowlist)
_ID_PREFIX_BODY_RE = re.compile(r"^[CPLO]\d{2}(?:O\d{2})?(?:_|$)")


def _identifier_body(ident: str) -> str:
    """leading '_' + trailing (NOUNS/TOKENS/KEYWORDS/PHRASES) 제거 한 body."""
    body = ident.lstrip("_")
    for suf in _BAN_SUFFIXES:
        if body.endswith(suf):
            body = body[: -len(suf)].rstrip("_")
            break
    return body


def _is_allowlist_identifier(ident: str) -> bool:
    """ident 가 closed-world allowlist (token set 또는 ID-prefix) 에 해당하는가."""
    body = _identifier_body(ident)
    if not body:
        # bare _NOUNS / _TOKENS / ... — 정의상 open-world.
        return False
    # ID-prefix literal (C##/P##/L##/O##/C##O##)
    if _ID_PREFIX_BODY_RE.match(body):
        return True
    # body 의 모든 '_' token 이 allowlist set 안.
    tokens = [t for t in body.split("_") if t]
    return bool(tokens) and all(t in _ALLOWLIST_TOKENS for t in tokens)


def _diff_changed_lines() -> list[tuple[str, int, str]]:
    """git diff main...HEAD 의 added lines. git error 시 pytest.fail."""
    cmd = [
        "git", "diff", "main...HEAD", "--unified=0", "--no-color",
        "--", ":(glob)backend/app/**/*.py",
    ]
    try:
        result = subprocess.run(
            cmd,
            cwd=_REPO_ROOT,
            capture_output=True,
            text=True,
            check=True,
        )
    except subprocess.CalledProcessError as e:
        pytest.fail(
            "Gate 1: git diff 실패 (rc={rc}). 환경 문제 (shallow clone / "
            "missing 'main' ref / pathspec error). silent skip 금지.\n"
            "cmd: {cmd}\nstderr:\n{stderr}".format(
                rc=e.returncode,
                cmd=" ".join(cmd),
                stderr=(e.stderr or "(empty)").strip(),
            )
        )

    out_lines = result.stdout.splitlines()
    parsed: list[tuple[str, int, str]] = []
    current_file = None
    current_lineno = 0
    for line in out_lines:
        if line.startswith("+++ b/"):
            current_file = line[len("+++ b/"):]
        elif line.startswith("@@"):
            m = re.match(r"@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
            if m:
                current_lineno = int(m.group(1))
        elif line.startswith("+") and not line.startswith("+++"):
            if current_file:
                parsed.append((current_file, current_lineno, line[1:]))
            current_lineno += 1
        elif not line.startswith("-"):
            current_lineno += 1
    return parsed


def test_semantic_regex_ban_gate():
    """changed lines 에서 forbidden 식별자 신규 추가가 있으면 fail."""
    changed = _diff_changed_lines()
    if not changed:
        pytest.skip("no changes against main detected (running outside PR context)")

    violations: list[tuple[str, int, str, str]] = []
    for file_path, lineno, line in changed:
        stripped = line.lstrip()
        if stripped.startswith("#"):
            continue
        for m in _FORBIDDEN_PATTERN.finditer(line):
            ident = m.group(0)
            if _is_allowlist_identifier(ident):
                continue
            violations.append((file_path, lineno, ident, line.rstrip()))

    if violations:
        msgs = []
        for f, ln, ident, src in violations:
            msgs.append(f"  {f}:{ln}: {ident!r} in `{src.strip()[:80]}`")
        pytest.fail(
            "Gate 1 (Semantic Regex Ban) — open-world semantic classifier "
            "noun/token list 신규 추가 감지. allowlist 가 아니면 LLM SOT 로 "
            "이동. umbrella spec §3.1.\n" + "\n".join(msgs)
        )


# -------------------- gate self-tests (M5 lock-in) --------------------
#
# Gate 가 silently 깨지지 않도록 (silent disarm) parser / regex / allowlist 의
# 핵심 동작을 fixture 없이 단위 검증. 본 self-test 는 가벼움 — git 호출 X.


def test_allowlist_denies_open_world_pathway_nouns():
    """_PATHWAY_NOUNS 는 open-world (PATH substring 통과 X)."""
    assert not _is_allowlist_identifier("_PATHWAY_NOUNS")


def test_allowlist_allows_path_tokens():
    """_PATH_TOKENS 는 closed-world (token=PATH 단독, allowlist)."""
    assert _is_allowlist_identifier("_PATH_TOKENS")


def test_allowlist_denies_file_path_tokens():
    """_FILE_PATH_TOKENS — FILE 이 allowlist 외 → 의도된 deny.

    allowlist 확장은 review 거쳐 명시적으로만 추가 (umbrella spec §3.1).
    """
    assert not _is_allowlist_identifier("_FILE_PATH_TOKENS")


def test_allowlist_allows_id_prefix_literal():
    """_C##_/_P##_/_L##_/_O##_/_C##O##_ ID prefix 는 literal scenario ID — allow."""
    for ident in ("_C01_NOUNS", "_P02_TOKENS", "_L05_KEYWORDS",
                  "_O03_PHRASES", "_C07O02_NOUNS"):
        assert _is_allowlist_identifier(ident), ident


def test_allowlist_allows_compound_id_tokens():
    """_SHORT_ID_TOKENS / _ENTITY_ID_TOKENS — 모든 token 이 allowlist 안."""
    assert _is_allowlist_identifier("_SHORT_ID_TOKENS")
    assert _is_allowlist_identifier("_ENTITY_ID_NOUNS")


def test_forbidden_pattern_catches_pattern_stem_tail_marker_suffixes():
    """_FORBIDDEN_PATTERN 이 PATTERNS/STEMS/TAILS/MARKERS suffix 도 catch
    (umbrella spec §2 inventory line 52/59 에 명시된 migration 대상).
    """
    samples = [
        "_FACE_CLOSE_UP_PATTERNS",
        "_KOREAN_GAZE_STEMS",
        "_DIRECTIONAL_TAILS",
        "_CLOSE_UP_MARKERS",
    ]
    for ident in samples:
        assert _FORBIDDEN_PATTERN.search(ident), f"pattern missed {ident!r}"


def test_allowlist_denies_pattern_stem_tail_marker_identifiers():
    """4 신규 suffix 의 typical open-world identifier 모두 deny."""
    for ident in (
        "_FACE_CLOSE_UP_PATTERNS",
        "_KOREAN_GAZE_STEMS",
        "_DIRECTIONAL_TAILS",
        "_CLOSE_UP_MARKERS",
    ):
        assert not _is_allowlist_identifier(ident), ident


def test_diff_uses_glob_magic_prefix(monkeypatch):
    """pathspec 이 ':(glob)' magic prefix — GIT_LITERAL_PATHSPECS 무력화."""
    captured = {}

    class _FakeResult:
        returncode = 0
        stdout = ""
        stderr = ""

    def _fake_run(*args, **kwargs):
        captured["argv"] = list(args[0]) if args else list(kwargs.get("args") or [])
        return _FakeResult()

    monkeypatch.setattr(subprocess, "run", _fake_run)
    _diff_changed_lines()
    pathspec = captured["argv"][-1]
    assert pathspec.startswith(":(glob)"), pathspec
    assert pathspec == ":(glob)backend/app/**/*.py"


def test_diff_fails_on_git_error(monkeypatch):
    """git diff CalledProcessError → pytest.fail (skip 아님). stderr exposed."""
    def _fake_run(*args, **kwargs):
        raise subprocess.CalledProcessError(
            returncode=128,
            cmd=args[0] if args else kwargs.get("args"),
            output=b"",
            stderr="fatal: bad revision 'main'",
        )

    monkeypatch.setattr(subprocess, "run", _fake_run)
    with pytest.raises(pytest.fail.Exception, match=r"git diff 실패"):
        _diff_changed_lines()
