"""분해 규칙을 실행하고 검증한다 — 경계는 코드가 찍는다.

## 왜 이 모듈이 있나

대본마다 씬 헤딩 형식이 다르다. `org/` 의 시나리오 22개에서 8가지가 나왔고
(줄머리 번호 · `S#번호` · 마크다운 볼드 · 들여쓴 `Scene N:` · `INT/EXT` ·
번호와 `INT/EXT` 공존 · 번호가 헤딩 뒤 · 헤딩이 두 줄), 한 규칙으로 덮이는
것은 많아야 12/22 다. 그래서 규칙을 미리 고정할 수 없다.

그렇다고 LLM 이 경계를 직접 찍게 두면 **형식이 튀는 씬을 의미로 판단하다
놓친다.** 실측: 금월도 2고의 `4. 몽타주:` `82. 몽타주:` `105. 몽타주:` 셋을
두 실행이 똑같이 놓쳤다. 다른 씬은 장소·시간이 붙는데 이 셋만 형식이 달랐다.
흔들림이 아니라 결정론적 결함이라 캐시로도 안 고쳐진다.

**그래서 나눈다 — LLM 은 그 대본의 규칙을 쓰고, 이 모듈이 그 규칙으로 찍는다.**
같은 입력에 같은 출력이 나오고, 형식 판단은 정규식이 하므로 어휘를 읽지 않는다.

## ★검증이 짝이다

저작된 규칙이 틀리면 한 씬이 아니라 **대본 전체가 한꺼번에** 밀린다. LLM 이
경계를 찍을 때는 틀려도 그 씬만 틀렸다. 그래서 `verify` 가 선택이 아니다.
"""
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple

import regex

#: 패턴 길이 상한. 이보다 긴 것은 헤딩 한 줄을 찾는 규칙이 아니다 — 저작이
#: 어긋났다는 신호로 보고 실행 전에 막는다.
MAX_PATTERN_LEN = 500
#: 정규식 실행 상한(초). ★파이썬 기본 `re` 에는 상한이 없다 — 실측으로
#: `(a|a)*$` 하나가 10.5초를 먹었다. `regex` 의 timeout 이 유일한 방어다.
DEFAULT_TIMEOUT_S = 5.0


@dataclass
class SegmentRule:
    """LLM 이 저작하는 것 — 그 대본에서 **헤딩 줄**을 찾는 규칙.

    파이썬 코드가 아니라 정규식으로 받는다. 관찰된 8형식이 전부 정규식 하나로
    잡히고, 정규식은 길이·문법·시간만으로 가둘 수 있다. 이보다 큰 표현력이
    실제로 필요한 대본이 나오면 그때 넓힌다 — 미리 넓히면 검증할 수 없는
    자유도만 는다.
    """

    name: str
    pattern: str
    #: 씬 번호를 담은 캡처 그룹 번호. 번호가 없는 형식이면 None.
    number_group: Optional[int] = None
    #: 그 대본에서 실제로 걸리는 줄 예시 — 사람이 읽고 판단할 근거.
    rationale: str = ""


@dataclass
class Segment:
    heading: str
    start_char: int
    end_char: int
    text: str
    scene_no: Optional[int] = None
    #: 정규식이 실제로 맞은 자리. `start_char` 는 앞 공백을 건너뛴 뒤라 둘이
    #: 다를 수 있다 — C4 는 이 자리에서 재야 한다(아래 `verify` 참고).
    match_start: Optional[int] = None


@dataclass
class Verdict:
    ok: bool
    failures: List[str] = field(default_factory=list)
    match_count: int = 0
    #: 번호가 빠짐없이 이어지는 정도(0~1). 번호가 없는 형식이면 1.0.
    number_continuity: float = 1.0
    #: 매치한 줄에 **헤딩 내용이 있는** 비율(0~1). 쪽 번호와 씬 헤딩을 가르는
    #: 거의 유일한 형식 신호다 — 쪽 번호 줄에는 번호와 마침표뿐이다.
    heading_content: float = 0.0
    detail: Dict[str, Any] = field(default_factory=dict)


def _compiled(rule: SegmentRule):
    """안전 검사 후 컴파일. 실패는 전부 ValueError 로 모은다."""
    if len(rule.pattern) > MAX_PATTERN_LEN:
        raise ValueError(
            f"패턴이 너무 길다({len(rule.pattern)}자 > {MAX_PATTERN_LEN})")
    try:
        return regex.compile(rule.pattern, regex.MULTILINE)
    except regex.error as exc:
        raise ValueError(f"정규식 문법 오류: {exc}") from exc


def apply_rule(text: str, rule: SegmentRule,
               timeout_s: float = DEFAULT_TIMEOUT_S) -> List[Segment]:
    """규칙으로 경계를 찍는다. 매치가 없으면 빈 목록.

    첫 매치 앞(표지·제목 따위)은 세그먼트로 만들지 않는다. 버리는 것이 아니라
    **어느 세그먼트에도 속하지 않는 머리말**이고, 그 사실은 첫 세그먼트의
    `start_char` 가 0 이 아닌 것으로 드러난다.
    """
    pat = _compiled(rule)
    try:
        matches = list(pat.finditer(text, timeout=timeout_s))
    except TimeoutError as exc:
        raise ValueError(
            f"정규식이 시간 안에 안 끝난다({timeout_s}s): {rule.pattern!r}") from exc

    # ★걸린 매치를 하나도 버리지 않는다. 한때 "번호가 되돌아가는 매치를
    #  버린다"로 씬 안의 항목 번호를 정상화하려 했는데 **더 위험했다**(실측):
    #  앞의 항목이 먼저 채택돼 기준 번호가 올라가고, 뒤에 오는 **진짜 씬이
    #  작다는 이유로 버려졌다.** 개수도 번호 연속성도 완벽해 모든 계약을
    #  통과하고 경계만 조용히 바뀌었다. 지금은 되돌아감을 C5 실패로 세워
    #  더 구체적인 규칙을 다시 저작하게 한다.
    starts = [_heading_start(m) for m in matches]
    segments: List[Segment] = []
    for i, m in enumerate(matches):
        start = starts[i]
        end = starts[i + 1] if i + 1 < len(matches) else len(text)
        body = text[start:end]
        segments.append(Segment(
            heading=body.split("\n", 1)[0].strip(),
            start_char=start,
            end_char=end,
            text=body,
            scene_no=_scene_no(m, rule),
            match_start=m.start(),
        ))
    return segments


def _heading_start(match) -> int:
    r"""매치가 앞에서 먹은 **공백만** 건너뛴 자리 — 진짜 헤딩이 시작하는 곳.

    ★`\s` 는 개행도 먹는다(실측). 저작된 패턴이 `^\s*(\d+)\.…` 이면 `^` 는
    줄머리에 걸리지만 뒤따르는 `\s*` 가 **빈 줄들을 거슬러 올라가** 매치 시작이
    헤딩보다 앞이 된다. 그러면 헤딩이 빈 문자열이 되고, 그 줄에 글자가 없어
    C7 이 0 이 되며, 같은 헤딩인데 시작 위치가 달라 다른 후보와 "다른 자리"로
    세어진다. 한 대본에서 이것 하나로 126줄이 거짓 누락으로 잡혔다.

    앞쪽 공백만 건너뛴다 — 매치 안쪽의 개행은 그대로 둔다. 헤딩이 두 줄인
    대본이 있어서, 뒤까지 손대면 그런 규칙을 망가뜨린다.
    """
    g = match.group(0)
    return match.start() + (len(g) - len(g.lstrip()))


def _starts_line(text: str, pos: int) -> bool:
    """`pos` 가 줄의 시작인가 — 앞이 개행이거나, 줄머리부터 여기까지 공백뿐인가.

    들여쓴 헤딩을 살리려고 공백을 허용한다. 글자가 하나라도 앞에 있으면
    그 자리는 줄 한가운데다.
    """
    line_start = text.rfind("\n", 0, pos) + 1      # 없으면 0
    return text[line_start:pos].strip() == ""


def _scene_no(match, rule: SegmentRule) -> Optional[int]:
    """번호를 못 뽑으면 None — 저작된 규칙이 없는 그룹을 가리킬 수 있다.

    ★여기서 세우지 않는다. 번호는 C5 점수에만 쓰이고, 번호가 없는 형식도
    정상이다(`INT/EXT` 계열). 못 뽑았다는 사실은 C5 가 1.0 으로 남는 것으로
    드러나고, 그 규칙이 옳은지는 C1~C4 가 따로 판정한다.
    """
    if rule.number_group is None:
        return None
    try:
        return int(match.group(rule.number_group))
    except (IndexError, TypeError, ValueError):
        return None


def verify(text: str, segments: List[Segment], rule: SegmentRule) -> Verdict:
    """기계로만 잰다. 어휘도 의미도 읽지 않는다.

    C1~C4 는 통과/실패(하나라도 깨지면 그 규칙은 쓰지 않는다).
    C5 는 점수다 — 후보끼리 **상대 비교**할 때 쓴다. 절대 문턱을 세우면 엄하면
    전부 탈락하고 느슨하면 전부 통과한다.
    """
    failures: List[str] = []
    detail: Dict[str, Any] = {}

    # C1 — 둘 미만이면 분해가 아니다
    if len(segments) < 2:
        failures.append("C1")

    # C2 — 경계가 앞으로만 간다
    if any(b.start_char <= a.start_char for a, b in zip(segments, segments[1:])):
        failures.append("C2")

    # C3 — 첫 경계부터 끝까지 틈이 없다
    if segments:
        gaps = [(a.end_char, b.start_char)
                for a, b in zip(segments, segments[1:]) if a.end_char != b.start_char]
        if gaps or segments[-1].end_char != len(text):
            failures.append("C3")
            detail["C3_gaps"] = gaps[:5]
            detail["C3_tail"] = (segments[-1].end_char, len(text))

    # C4 — 각 세그먼트가 규칙이 실제로 맞은 자리에서 시작한다
    #
    # ★`start_char` 로 재면 안 된다(재현함). `start_char` 는 매치가 앞에서 먹은
    #  공백을 건너뛴 뒤라, 들여쓴 헤딩(`^\s*Scene\s+(\d+):`)에서는 `^` 가 그
    #  자리에 다시 안 맞아 **옳은 규칙이 C4 실패로 버려진다.** 매치가 있었던
    #  자리에서 재고, 줄머리인지는 아래 C6 이 따로 본다.
    pat = _compiled(rule)
    off = [s.start_char for s in segments
           if not pat.match(text, s.match_start if s.match_start is not None
                            else s.start_char)]
    if off:
        failures.append("C4")
        detail["C4_offsets"] = off[:5]

    # C6 — 헤딩이 줄 시작이다
    #
    # ★C4 만으로는 못 잡는다(재현함). `(\d+)\. (?:INT|EXT)\..*$` 처럼 줄머리에
    #  안 묶인 규칙은 **본문 줄 한가운데**를 경계로 찍고도 C1~C5 를 전부
    #  통과한다. 그러면 경계 앞 글자들이 앞 씬 끝에 조용히 붙는다 — 오늘 찾은
    #  결함 4건과 같은 종류로, 개수만 보면 정상으로 보인다.
    #
    #  어휘를 읽지 않는다. "그 앞이 줄머리이거나, 줄머리부터 여기까지 공백뿐"
    #  인지만 본다 — 들여쓴 헤딩은 통과하고 줄 중간은 걸린다.
    midline = [s.start_char for s in segments
               if not _starts_line(text, s.start_char)]
    if midline:
        failures.append("C6")
        detail["C6_midline"] = midline[:5]

    # C5 — 번호. 되돌아감은 통과/실패, 촘촘함은 점수.
    #
    # ★되돌아감을 점수로 두면 안 된다(실측): 금월도 2고에서 몽타주 안 항목
    #  28개가 섞여 116개 씬이 144개가 됐는데 **다른 계약을 전부 통과했다** —
    #  고유 번호가 1~116 그대로라 촘촘함 점수마저 1.0 이었다. 순위가 매치 수
    #  순이라 틀린 규칙이 옳은 규칙을 이겼다. 씬 번호가 뒤로 가는 대본은
    #  못 봤고, 되돌아간다는 것은 씬 아닌 것을 잡고 있다는 뜻이다.
    nos = [s.scene_no for s in segments if s.scene_no is not None]
    continuity = 1.0
    if rule.number_group is not None and segments:
        # 번호 그룹을 걸어 놓고 **하나도** 못 뽑으면 그 그룹은 번호를 안
        # 가리킨다 — 그 규칙은 이 대본과 안 맞는다.
        #
        # ★일부만 못 읽는 것은 실패가 아니다(실측). 번호가 **일부 씬에만 붙는**
        #  대본이 실제로 있다(Woman: 41개엔 번호가 있고 13화 시작 한 곳엔 없다).
        #  전에는 그런 규칙을 C5 로 떨어뜨렸는데, 그 바람에 같은 42개 경계를
        #  찍는 두 규칙이 **번호 그룹을 선언했느냐만으로** 갈렸다 —
        #  `number_group=1` 로 성실히 선언하면 탈락하고 `None` 으로 두면 통과.
        #  더 많은 것을 알려준 쪽이 벌을 받는 셈이다.
        #
        #  실제로 gemini-flash 저작에서 그 일이 났다: 42개짜리 후보 둘이 다
        #  C5 로 떨어지고 41개가 확정돼 **씬 하나를 조용히 놓쳤다**(번호 1~41
        #  연속, 다른 계약 전부 통과). 탈락한 후보는 재저작 신호에도 안 들어가
        #  알아챌 방법이 없었다.
        #
        # ★그 매치를 버리지는 않는다 — 근거 없이 버리면 본문이 조용히 사라진다.
        unread = sum(1 for s in segments if s.scene_no is None)
        if unread:
            detail["C5_unreadable"] = unread
        if unread == len(segments):
            failures.append("C5")
    if len(nos) >= 2:
        back = [(a, b) for a, b in zip(nos, nos[1:]) if b <= a]
        if back and "C5" not in failures:
            failures.append("C5")
        if back:
            detail["C5_backward"] = back[:5]
            detail["C5_backward_count"] = len(back)
        span = max(nos) - min(nos) + 1
        continuity = len(set(nos)) / span if span > 0 else 0.0
        detail["C5_span"] = span
        detail["C5_seen"] = len(set(nos))

    # C7 — 매치한 줄에 헤딩 내용이 있는가 (점수)
    #
    # ★쪽 번호와 씬 헤딩을 가르는 거의 유일한 형식 신호다. 실측(srd part 1):
    #  줄머리 번호로 잡으면 쪽 번호 122개가 걸리고, 되돌아감을 걸러도 61개가
    #  남아 **오름차순도 연속성도 완벽하다**. 진짜 헤딩인 `INT/EXT` 는 37개뿐
    #  이라 매치 수로 순위를 매기면 쪽 번호가 이긴다. 쪽 번호 줄에는 번호와
    #  마침표밖에 없다는 것만이 남는 차이다.
    with_content = sum(1 for s in segments if _has_heading_content(text, s))
    heading_content = with_content / len(segments) if segments else 0.0
    detail["C7_with_content"] = with_content

    return Verdict(
        ok=not failures,
        failures=failures,
        match_count=len(segments),
        number_continuity=continuity,
        heading_content=heading_content,
        detail=detail,
    )


#: 어떤 문자 체계든 "글자"를 뜻한다 — 어휘를 읽지 않고 글자의 있고 없음만 본다.
_LETTER = regex.compile(r"\p{L}")


def _has_heading_content(text: str, seg: Segment) -> bool:
    """헤딩 줄에 글자가 있는가.

    ★"매치가 소비한 뒤에 남는 것"으로 재면 안 된다(실측). 저작된 패턴이
    ``^\\s*(\\d{1,3})\\.\\s+\\S.*$`` 처럼 줄 끝까지 소비하는 모양이면 남는 것이
    없어 헤딩 내용이 있는데도 0 이 나온다. 재는 값이 **패턴 모양에 좌우되면**
    같은 대본에서 같은 경계를 찍는 두 규칙이 다른 점수를 받는다.

    쪽 번호 줄에는 숫자·마침표·공백만 있고 글자가 없다. 씬 헤딩 줄에는 장소든
    시간이든 글자가 있다. 그 차이만 본다.
    """
    line_end = text.find("\n", seg.start_char)
    if line_end < 0:
        line_end = len(text)
    return bool(_LETTER.search(text[seg.start_char:line_end]))


def choose_rule(
    text: str,
    candidates: List[SegmentRule],
    timeout_s: float = DEFAULT_TIMEOUT_S,
) -> Tuple[Optional[SegmentRule], Optional[Verdict]]:
    """계약을 통과한 후보 중 **가장 잘 덮는 것**을 고른다.

    절대 문턱으로 자르지 않고 후보끼리만 비교한다. 실제 대본에는 `번호.` 와
    `INT/EXT` 가 함께 있는 것이 있어(srd part 1~6: 116개와 34개) 어느 쪽이 씬
    경계인지 미리 정할 수 없다 — 이 비교가 그것을 푼다.

    순위는 ①헤딩 내용 비율(C7) ②**매치 수** 순이다.

    ★매치 수를 1순위로 두면 안 된다 — 쪽 번호가 씬 헤딩을 이긴다. 실측
    (srd part 1): 쪽 번호 61개가 진짜 헤딩 37개를 이겼고 그 61개는 오름차순도
    연속성도 완벽했다. C7 이 앞에 있으면 그 일이 안 생긴다(쪽 번호 줄에는
    글자가 없다).

    ★그렇다고 매치 수를 **아예 안 쓰면** 정답이 떨어진다(실측). 한 대본에
    번호 붙은 헤딩 41개와 번호 없는 헤딩 1개가 섞여 있었는데, 번호를 필수로
    요구한 41개짜리와 번호를 선택으로 둔 42개짜리가 **완전 동점**이 됐다 —
    뒤쪽은 `number_group` 이 없어 연속성이 측정값이 아니라 기본값 1.0 이었다.
    먼저 온 41개짜리가 뽑혀 진짜 씬 하나를 놓쳤다.

    번호 연속성(C5)은 순위에 쓰지 않는다. 되돌아감·못 읽음은 이미 통과/실패로
    걸렀고, 남은 촘촘함 점수는 `number_group` 유무에 따라 **잰 값과 안 잰
    값이 섞여** 비교가 성립하지 않는다. 동점이면 먼저 온 후보를 쓴다(결정론).
    """
    best: Tuple[SegmentRule, Verdict] | None = None
    for rule in candidates:
        try:
            segs = apply_rule(text, rule, timeout_s=timeout_s)
        except ValueError:
            continue                       # 안전 검사에 걸린 규칙은 후보가 아니다
        v = verify(text, segs, rule)
        if not v.ok:
            continue
        if best is None or ((v.heading_content, v.match_count)
                            > (best[1].heading_content, best[1].match_count)):
            best = (rule, v)
    return best if best else (None, None)
