"""분해 규칙을 코드가 실행하고 기계가 검증한다.

LLM 은 그 대본에 맞는 **규칙**만 쓰고, 경계는 여기 코드가 찍는다. 그래야 같은
입력에 같은 출력이 나오고, LLM 이 형식이 튀는 씬을 의미로 판단하다 놓치는 일이
없다(실측: 금월도 2고의 `N. 몽타주:` 셋을 두 실행이 똑같이 놓쳤다).

★저작된 규칙이 틀리면 한 씬이 아니라 **대본 전체가 한꺼번에** 밀린다. 그래서
검증 계약이 선택이 아니라 짝이다.

아래 fixture 의 문구는 실제 대본 8형식에서 **구조만** 가져온 것이다. 코드는
어떤 어휘도 읽지 않는다 — 형식 판단은 전부 LLM 이 쓴 정규식이 한다.
"""
from __future__ import annotations

import pytest

from app.modules.pipeline.segment_rule import (
    SegmentRule, apply_rule, choose_rule, verify,
)

# 실제 대본에서 관찰된 8형식의 구조만 옮긴 표본 (어휘는 무의미한 자리채움)
NUMBERED = "머리말\n1. 실외. 갈대숲 - 낮\n가나다\n2. 실내. 창고 - 낮\n라마바\n3. 몽타주:\n사아자\n"
HASH_S = "표지\nS#1. 숲속 / 저녁\n가나다\nS#2. 골목 / 밤\n라마바\n"
BOLD = "제목\n**1. 바다 속 – D**\n가나다\n**2. 수거선 – D**\n라마바\n"
INTEXT = "TITLE\nINT. ROOM - DAY\naaa\nEXT. STREET - NIGHT\nbbb\n"

RULE_NUM = SegmentRule(name="줄머리 번호", pattern=r"^[ \t]*(\d+)[ \t]*\.",
                       number_group=1)
RULE_HASH = SegmentRule(name="S#번호", pattern=r"^[ \t]*S\s*#\s*(\d+)[ \t]*\.",
                        number_group=1)
RULE_BOLD = SegmentRule(name="볼드 번호", pattern=r"^[ \t]*\*\*[ \t]*(\d+)[ \t]*\.",
                        number_group=1)
RULE_INTEXT = SegmentRule(name="INT/EXT", pattern=r"^[ \t]*(?:INT|EXT)\.",
                          number_group=None)


class Test규칙_실행:

    def test_경계를_찍어_세그먼트를_만든다(self):
        segs = apply_rule(NUMBERED, RULE_NUM)

        assert [s.scene_no for s in segs] == [1, 2, 3]
        assert segs[0].text.startswith("1. 실외.")
        assert segs[2].text.startswith("3. 몽타주:")

    def test_형식이_튀는_씬도_같은_규칙에_걸린다(self):
        """`3. 몽타주:` — LLM 이 의미로 판단하다 놓친 자리다.

        정규식은 장소·시간이 붙었는지 보지 않으므로 형식만 맞으면 잡는다.
        """
        segs = apply_rule(NUMBERED, RULE_NUM)

        assert any(s.scene_no == 3 for s in segs)

    def test_첫_경계_앞은_머리말로_따로_둔다(self):
        """표지·제목은 씬이 아니다. 버리지도 않는다 — 어디로 갔는지 남긴다."""
        segs = apply_rule(NUMBERED, RULE_NUM)

        assert segs[0].start_char == NUMBERED.index("1. 실외.")
        assert all(s.start_char > 0 for s in segs)

    def test_세그먼트가_틈_없이_이어진다(self):
        segs = apply_rule(NUMBERED, RULE_NUM)

        for a, b in zip(segs, segs[1:]):
            assert a.end_char == b.start_char
        assert segs[-1].end_char == len(NUMBERED)

    def test_번호_그룹이_없으면_scene_no_는_비운다(self):
        segs = apply_rule(INTEXT, RULE_INTEXT)

        assert len(segs) == 2
        assert all(s.scene_no is None for s in segs)

    def test_안_걸리는_규칙은_빈_결과(self):
        assert apply_rule(NUMBERED, RULE_HASH) == []

    def test_패턴_앞의_공백이_경계를_앞으로_끌지_않는다(self):
        r"""`\s` 는 개행도 먹는다 — 실측으로 경계가 앞 빈 줄을 가리켰다.

        저작된 패턴이 `^\s*(\d+)\.…` 였다. `^` 는 줄머리에 걸리지만 뒤따르는
        `\s*` 가 **빈 줄들을 거슬러 올라가** 매치 시작이 헤딩보다 앞이 된다.
        그러면 ①헤딩이 빈 문자열이 되고 ②그 줄에 글자가 없어 C7 이 0 이 되며
        ③같은 헤딩인데 시작 위치가 달라 다른 후보와 "다른 자리"로 세어진다.
        실측에서 이것 하나로 126줄이 거짓 누락으로 잡혔다.
        """
        text = "머리말\n\n1. 가 - 낮\n내용\n\n2. 나 - 밤\n내용\n"
        rule = SegmentRule(name="공백 허용", pattern=r"^\s*(\d+)\.\s+.+$",
                           number_group=1)

        segs = apply_rule(text, rule)

        assert [s.heading for s in segs] == ["1. 가 - 낮", "2. 나 - 밤"]
        assert verify(text, segs, rule).heading_content == 1.0

    def test_여러_줄을_매치하는_패턴은_건드리지_않는다(self):
        """헤딩이 두 줄인 대본이 있다 — 앞 공백만 건너뛰고 그 뒤는 그대로 둔다."""
        text = "머리말\n#1.\n산 - 밤\n내용\n#2.\n숲 - 낮\n내용\n"
        rule = SegmentRule(name="두 줄 헤딩", pattern=r"^#(\d+)\.\n.+$",
                           number_group=1)

        segs = apply_rule(text, rule)

        assert [s.scene_no for s in segs] == [1, 2]
        assert segs[0].text.startswith("#1.\n산 - 밤")


class Test씬_안의_항목_번호:
    """씬 안에 줄머리 번호가 또 나오는 경우 — 실제 대본에 있다.

    금월도 2고 정리본의 `4. 몽타주:` 아래가 이렇다::

        4. 몽타주:
        넓은 와이드 화면 가득 …
        1. 매케한 미세먼지로 뒤덮힌 아침 출근길 …
        …
        9. 그 중… 파란색 안전조끼를 입고 앉아 …
        5. 실내. 달리는 퇴근 버스 - 밤          ← 진짜 씬 5

    항목도 씬 헤딩도 줄머리 `N.` 이라 **형식으로는 못 가른다**.

    ★한때 "번호가 되돌아가는 매치를 버린다"로 정상화하려 했는데 **더 위험했다**
    (Codex 지적, 실측으로 확인). 앞의 항목 5~9 가 먼저 채택돼 기준이 9 로 올라가고,
    뒤에 오는 **진짜 씬 5~9 가 작다는 이유로 버려진다.** 개수는 116 으로 맞아
    떨어지고 번호도 1~116 연속이라 모든 계약을 통과한다 — 경계만 조용히 바뀐다.

    그래서 정상화하지 않는다. **되돌아가면 실패로 세우고 더 구체적인 규칙을 다시
    저작하게 한다.** 조용한 치환이 원리적으로 일어날 수 없게 만드는 쪽이 안전하다.
    """

    #: 실제 정리본의 치환 구조를 그대로 옮긴 표본. 항목이 앞 씬 번호를 넘어간다.
    MONTAGE = ("머리말\n"
               "1. 가\n내용\n"
               "2. 나\n내용\n"
               "3. 목록:\n"
               "1. 하나\n2. 둘\n3. 셋\n4. 넷\n5. 다섯\n"
               "4. 실내. 진짜 넷째 씬\n내용\n"
               "5. 실내. 진짜 다섯째 씬\n내용\n")

    def test_되돌아가는_번호가_있으면_실패로_세운다(self):
        segs = apply_rule(self.MONTAGE, RULE_NUM)
        v = verify(self.MONTAGE, segs, RULE_NUM)

        assert not v.ok
        assert "C5" in v.failures

    def test_아무_매치도_조용히_버리지_않는다(self):
        """본문이 사라지지 않는다 — 걸린 것은 전부 세그먼트가 된다."""
        segs = apply_rule(self.MONTAGE, RULE_NUM)

        assert [s.scene_no for s in segs] == [1, 2, 3, 1, 2, 3, 4, 5, 4, 5]

    def test_진짜_씬이_항목으로_치환되지_않는다(self):
        """★이 시험이 지키는 것: 개수가 맞아도 경계가 바뀌면 안 된다.

        정상화 필터가 있던 판본에서는 항목 `5. 다섯`이 채택되고 진짜 씬
        `5. 실내. 진짜 다섯째 씬`이 버려졌다. 개수만 보면 못 알아챈다.
        """
        segs = apply_rule(self.MONTAGE, RULE_NUM)
        fifths = [s.heading for s in segs if s.scene_no == 5]

        assert "5. 실내. 진짜 다섯째 씬" in fifths

    def test_구체적인_규칙이면_통과한다(self):
        """재저작이 향할 자리 — 항목과 헤딩을 가르는 표기를 규칙에 담는다."""
        specific = SegmentRule(name="번호 + 장소 표기",
                               pattern=r"^[ \t]*(\d+)[ \t]*\.[ \t]*(?:실내|실외)",
                               number_group=1)

        segs = apply_rule(self.MONTAGE, specific)
        v = verify(self.MONTAGE, segs, specific)

        assert [s.scene_no for s in segs] == [4, 5]
        assert v.ok, v.failures

    def test_결번은_통과한다(self):
        """결번이 있는 대본이 있다 — 되돌아감만 실패고 결번은 점수다."""
        gap = "머리말\n1. 가\n내용\n5. 나\n내용\n9. 다\n내용\n"

        v = verify(gap, apply_rule(gap, RULE_NUM), RULE_NUM)

        assert v.ok
        assert v.number_continuity < 1.0


class Test검증_계약:

    def test_맞는_규칙은_전부_통과한다(self):
        for text, rule in ((NUMBERED, RULE_NUM), (HASH_S, RULE_HASH),
                           (BOLD, RULE_BOLD), (INTEXT, RULE_INTEXT)):
            v = verify(text, apply_rule(text, rule), rule)
            assert v.ok, f"{rule.name}: {v.failures}"

    def test_C1_매치가_둘_미만이면_분해가_아니다(self):
        one = "머리말\n1. 실외. 갈대숲 - 낮\n가나다\n"

        v = verify(one, apply_rule(one, RULE_NUM), RULE_NUM)

        assert not v.ok
        assert "C1" in v.failures

    def test_C4_매치가_없는_자리에서_시작하면_실패(self):
        """세그먼트를 손으로 어긋나게 만들어 계약이 잡는지 본다."""
        segs = apply_rule(NUMBERED, RULE_NUM)
        segs[1].match_start += 3          # 규칙이 안 맞는 자리로

        v = verify(NUMBERED, segs, RULE_NUM)

        assert not v.ok
        assert "C4" in v.failures

    def test_C6_줄_한가운데를_경계로_찍으면_실패(self):
        """★C4 만으로는 못 잡는다 — 줄머리에 안 묶인 규칙이 본문 한가운데를
        경계로 찍고도 다른 계약을 전부 통과한다. 그러면 경계 앞 글자들이 앞
        씬 끝에 조용히 붙어, 개수만 보면 정상으로 보인다.
        """
        text = "prefix 1. INT. HOUSE - DAY\naaa\nprefix 2. INT. ROOM - NIGHT\nbbb\n"
        loose = SegmentRule(name="줄머리에 안 묶임",
                            pattern=r"(\d+)\. (?:INT|EXT)\..*$", number_group=1)

        v = verify(text, apply_rule(text, loose), loose)

        assert not v.ok
        assert "C6" in v.failures

    def test_C6_들여쓴_헤딩은_통과한다(self):
        """앞이 공백뿐이면 줄 시작으로 본다 — 들여쓴 대본을 버리지 않는다."""
        text = "    Scene 1: House\naaa\n    Scene 2: Room\nbbb\n"
        rule = SegmentRule(name="들여쓴 Scene",
                           pattern=r"^\s*Scene\s+(\d+):.*$", number_group=1)

        v = verify(text, apply_rule(text, rule), rule)

        assert v.ok, v.failures

    def test_C3_전체를_덮지_않으면_실패(self):
        segs = apply_rule(NUMBERED, RULE_NUM)
        segs[-1].end_char -= 5           # 끝을 잘라 틈을 만든다

        v = verify(NUMBERED, segs, RULE_NUM)

        assert not v.ok
        assert "C3" in v.failures

    def test_C5_번호가_이어지면_점수가_만점(self):
        v = verify(NUMBERED, apply_rule(NUMBERED, RULE_NUM), RULE_NUM)

        assert v.number_continuity == 1.0

    def test_C5_번호가_빠지면_점수만_내려간다(self):
        """결번은 정당할 수 있다 — 점수로만 다룬다."""
        gap = "머리말\n1. 가\n내용\n2. 나\n내용\n5. 다\n내용\n"

        v = verify(gap, apply_rule(gap, RULE_NUM), RULE_NUM)

        assert v.ok                       # 필수 계약은 통과한다
        assert v.number_continuity < 1.0  # 다만 점수가 낮다

    def test_C5_번호가_되돌아가면_실패한다(self):
        """씬이 아닌 것을 잡고 있다는 강한 신호다.

        ★점수로 두면 안 된다. 실측(금월도 2고): 몽타주 안 항목 28개가 섞여
        116개 씬이 144개가 됐는데 **여섯 계약을 전부 통과했다** — 고유 번호가
        1~116 그대로라 연속성 점수도 1.0 이었다. 순위가 매치 수 순이라 틀린
        규칙이 옳은 규칙을 이겼다. 되돌아감을 통과/실패로 올려야 걸린다.
        """
        text = "머리말\n1. 가\n내용\n2. 나\n내용\n1. 하나\n내용\n3. 다\n내용\n"

        v = verify(text, apply_rule(text, RULE_NUM), RULE_NUM)

        assert not v.ok
        assert "C5" in v.failures

    def test_C5_번호가_일부_헤딩에만_있어도_통과한다(self):
        """번호가 **일부 씬에만** 붙는 대본이 실제로 있다(실측: Woman 13화).

        ★전에는 못 읽는 매치가 하나라도 있으면 C5 실패였다. 그 바람에 같은
        경계를 찍는 두 규칙이 **번호 그룹을 선언했느냐만으로** 갈렸다 —
        `number_group` 을 성실히 적으면 탈락하고 `None` 으로 두면 통과.
        더 많은 것을 알려준 쪽이 벌을 받았다.

        실제로 gemini-flash 저작에서 42개짜리 후보 둘이 다 이 이유로 떨어지고
        41개가 확정돼 **씬 하나를 조용히 놓쳤다**(번호 1~41 연속, 나머지 계약
        전부 통과). 탈락한 후보는 재저작 신호에도 안 들어가 알아챌 길이 없었다.
        """
        text = "1. 가\n내용\n안. 번호 없는 헤딩\n내용\n2. 나\n내용\n"
        rule = SegmentRule(name="번호 선택", pattern=r"^(?:(\d+)\. |안\. ).*$",
                           number_group=1)

        v = verify(text, apply_rule(text, rule), rule)

        assert v.ok, v.failures
        assert v.detail.get("C5_unreadable") == 1     # 세기는 하되 떨어뜨리지 않는다

    def test_C5_번호를_하나도_못_읽으면_실패한다(self):
        """그 그룹이 번호를 안 가리킨다는 뜻이라 규칙이 이 대본과 안 맞는다."""
        text = "A 씬\n내용\nB 씬\n내용\n"
        rule = SegmentRule(name="글자 그룹", pattern=r"^([A-Z]) 씬$", number_group=1)

        v = verify(text, apply_rule(text, rule), rule)

        assert not v.ok
        assert "C5" in v.failures


class Test안전:

    def test_C6_너무_긴_패턴은_거부한다(self):
        rule = SegmentRule(name="긴 것", pattern="a" * 5000)

        with pytest.raises(ValueError, match="패턴이 너무 길다"):
            apply_rule(NUMBERED, rule)

    def test_C6_문법이_틀린_패턴은_거부한다(self):
        rule = SegmentRule(name="깨진 것", pattern=r"^[ \t]*(\d+")

        with pytest.raises(ValueError, match="정규식 문법"):
            apply_rule(NUMBERED, rule)

    def test_C6_폭주하는_패턴은_시간_안에_끊는다(self):
        """되돌이가 폭발하는 패턴에 붙들려 있으면 안 된다.

        표본은 실측으로 골랐다 — 파이썬 기본 `re` 는 이 패턴에 10.5초를 쓴다.
        (`^(a+)+$` 같은 교과서 표본은 `regex` 가 알아서 최적화해 안 터진다.)
        """
        rule = SegmentRule(name="폭주", pattern=r"(a|a)*$")
        text = "a" * 26 + "b"

        with pytest.raises(ValueError, match="시간"):
            apply_rule(text, rule, timeout_s=0.3)


class Test페이지_번호_반례:
    """쪽 번호가 씬 헤딩을 이기면 안 된다 — 실측 반례다.

    srd part 1 은 각 쪽에 번호만 있는 줄(`2.` `3.` …)이 두 번씩 들어 있다.
    줄머리 번호로 잡으면 122개가 걸리고, 되돌아감을 걸러도 **61개가 남아
    오름차순·연속성이 완벽하다.** 진짜 씬 헤딩인 `INT/EXT` 는 37개뿐이라,
    매치 수로 순위를 매기면 쪽 번호가 이긴다.

    가르는 신호는 하나다 — **그 줄에 헤딩 내용이 있는가.**
    """

    #: srd 의 구조만 옮긴 표본. 쪽 번호 줄은 번호와 마침표뿐이다.
    PAGED = ("표지\n"
             "2.\nINT. ROOM - DAY\n가나다\n"
             "3.\n라마바\n"
             "4.\nEXT. STREET - NIGHT\n사아자\n"
             "5.\n차카타\n")

    def test_번호만_있는_줄은_헤딩_내용이_없다(self):
        v = verify(self.PAGED, apply_rule(self.PAGED, RULE_NUM), RULE_NUM)

        assert v.heading_content == 0.0

    def test_헤딩_줄은_내용이_있다(self):
        v = verify(NUMBERED, apply_rule(NUMBERED, RULE_NUM), RULE_NUM)

        assert v.heading_content == 1.0

    def test_패턴이_줄_전체를_삼켜도_내용을_잰다(self):
        """저작된 패턴은 줄 끝까지 소비하는 모양일 수 있다.

        실측: 첫 저작 결과가 ``^\\s*(\\d{1,3})\\.\\s+\\S.*$`` 였다. "매치 뒤에
        남는 글자"로 재면 0이 나오는데, 그 줄에는 분명히 헤딩 내용이 있다.
        패턴 모양에 좌우되면 안 되므로 **줄에 글자가 있는가**로 잰다.
        """
        greedy = SegmentRule(name="줄 전체 소비",
                             pattern=r"^[ \t]*(\d+)[ \t]*\..*$", number_group=1)

        v = verify(NUMBERED, apply_rule(NUMBERED, greedy), greedy)

        assert v.heading_content == 1.0

    def test_기호와_숫자만_있는_줄은_글자가_없다(self):
        """언어를 가리지 않는다 — 어떤 글자든 있으면 내용으로 본다."""
        symbols = "머리말\n1.\n가나다\n2.\n라마바\n3.\n사아자\n"

        v = verify(symbols, apply_rule(symbols, RULE_NUM), RULE_NUM)

        assert v.heading_content == 0.0

    def test_적게_잡아도_내용이_있는_쪽을_고른다(self):
        """쪽 번호 4개 vs 진짜 헤딩 2개 — 매치 수로는 쪽 번호가 이긴다.

        쪽 번호는 오름차순이라 C5 도 통과한다. C7 만이 가른다.
        """
        rule, verdict = choose_rule(self.PAGED, [RULE_NUM, RULE_INTEXT])

        assert rule is RULE_INTEXT
        assert verdict.match_count == 2


class Test후보_선택:

    def test_공존하면_헤딩_내용이_있는_쪽을_고른다(self):
        """`번호.` 와 `INT/EXT` 가 한 대본에 함께 있는 경우 — 실제 대본에 있다.

        절대 문턱으로 자르지 않고 **둘 중 어느 쪽**만 묻는다.
        """
        both = ("표지\n"
                "1. INT. ROOM - DAY\n가나다\n"
                "2. EXT. STREET - NIGHT\n라마바\n"
                "3. INT. CAR - DAY\n사아자\n")

        rule, verdict = choose_rule(both, [RULE_INTEXT, RULE_NUM])

        assert rule is RULE_NUM           # 줄머리부터 헤딩 전체를 담는다
        assert verdict.ok

    def test_번호를_못_읽는_매치가_섞여도_버리지_않는다(self):
        """근거 없이 버리면 본문이 조용히 사라진다 — 남겨 두고 세기만 한다.

        ★전에는 이 경우를 C5 실패로 떨어뜨렸다. 실측이 그것을 뒤집었다: 번호가
        일부 씬에만 붙는 대본이 있어서(Woman 13화), 같은 경계를 찍는 두 규칙이
        **번호 그룹을 선언했느냐만으로** 갈렸다. gemini-flash 저작에서 그 탓에
        42개짜리 후보가 다 떨어지고 41개가 확정돼 씬 하나를 조용히 놓쳤다.
        지금은 **하나도 못 읽을 때만** 실패다(그 그룹이 번호를 안 가리킨다는 뜻).
        """
        text = "머리말\n1. 가\n내용\nA. 나\n내용\n2. 다\n내용\n"
        rule = SegmentRule(name="번호 또는 글자",
                           pattern=r"^[ \t]*(?:(\d+)|[A-Z])[ \t]*\.",
                           number_group=1)

        segs = apply_rule(text, rule)
        v = verify(text, segs, rule)

        assert len(segs) == 3             # 버리지 않는다
        assert v.detail.get("C5_unreadable") == 1
        assert v.ok, v.failures

    def test_점수가_같으면_더_많이_잡는_쪽을_고른다(self):
        """실측 반례 — 이것 때문에 진짜 씬 하나를 놓쳤다.

        한 대본에 번호 붙은 헤딩 41개와 번호 없는 헤딩 1개가 섞여 있었다.
        저작된 후보 둘 중 하나는 번호를 **필수**로 요구해 41개를, 다른 하나는
        번호를 선택으로 두고 42개를 잡았다. 그런데 둘 다 C7 1.0 이고, 뒤쪽은
        `number_group` 이 없어 연속성이 **측정값이 아니라 기본값 1.0** 이었다.
        완전 동점이 되자 먼저 온 41개짜리가 뽑혔다.

        번호 연속성은 통과/실패로 이미 걸렀으니, 남은 동점은 **얼마나 덮는가**로
        가른다. 쪽 번호가 이걸 타고 이기는 일은 없다 — C7 이 먼저라 번호만 있는
        줄은 거기서 진다(`Test페이지_번호_반례` 가 그것을 고정한다).
        """
        narrow = SegmentRule(name="번호 필수",
                             pattern=r"^(\d+)[ \t]+안\.[ \t]+.+$", number_group=1)
        wide = SegmentRule(name="번호 선택",
                           pattern=r"^(?:\d+[ \t]+)?안\.[ \t]+.+$")
        text = ("머리말\n1 안. 가 - 낮\n내용\n2 안. 나 - 낮\n내용\n"
                "안. 다 - 밤\n내용\n")

        rule, verdict = choose_rule(text, [narrow, wide])

        assert rule is wide
        assert verdict.match_count == 3

    def test_계약을_못_넘는_후보는_안_고른다(self):
        rule, verdict = choose_rule(NUMBERED, [RULE_HASH])

        assert rule is None
        assert verdict is None

    def test_후보가_비면_None(self):
        assert choose_rule(NUMBERED, []) == (None, None)
