"""대본 추출 빈 응답 → 반분 재시도 — 분할 계약 (2026-07-30).

## 왜 이 테스트가 필요한가

80페이지 대본을 40페이지씩 나눠 추출하다 한 덩어리가 빈 응답으로 왔다.
빈 문자열로 흘리면 **대본 40페이지가 조용히 사라지고** 하류 전 단계가
오염되므로 예외로 세우고 범위를 반씩 좁혀 재시도한다.

여기서 고정하는 것은 세 가지 결정론 계약이다.
    1. 빈 응답은 절대 빈 문자열로 흘러가지 않는다(조용한 누락 금지).
    2. 좁히기는 **1페이지 단위까지** 내려간다 — docstring 이 약속한 경계.
       Codex 리뷰 #7 은 깊이 상한 5 가 이 약속과 어긋난다고 지적했고,
       실측 결과 40페이지는 2페이지에서 포기하고 있었다.
    3. 성공한 조각들은 페이지 순서대로 이어붙고 어느 페이지도 빠지지 않는다.

추출 자체(LLM 이 무엇을 얼마나 잘 읽는가)는 유닛의 대상이 아니다 — 호출을
스텁으로 잘라내고 **분할 산술만** 본다.
"""
from __future__ import annotations

import re

import fitz
import pytest

from app.modules.pipeline import text_cleaner

_LABEL = re.compile(r"#p(\d+)-(\d+)$")


def _blank_doc(pages: int) -> "fitz.Document":
    doc = fitz.open()
    for _ in range(pages):
        doc.new_page()
    return doc


def _width(label: str) -> int:
    m = _LABEL.search(label)
    assert m, f"라벨 형식이 바뀌었다: {label}"
    return int(m.group(2)) - int(m.group(1)) + 1


def _range(label: str) -> tuple:
    m = _LABEL.search(label)
    return int(m.group(1)), int(m.group(2))


def test_never_returns_empty_string_on_empty_response(monkeypatch):
    """1페이지까지 좁혀도 비면 **세운다** — 빈 문자열로 흘리지 않는다."""
    monkeypatch.setattr(
        text_cleaner, "_extract_pdf_bytes_llm",
        lambda b, m, label: (_ for _ in ()).throw(
            text_cleaner.EmptyExtraction(label)))
    doc = _blank_doc(4)
    with pytest.raises(text_cleaner.EmptyExtraction):
        text_cleaner._extract_range_resilient(doc, 0, 3, "gemini-lite", "s.pdf")


def test_narrows_all_the_way_to_a_single_page(monkeypatch):
    """전 구간 빈 응답이면 좁히기가 **폭 1** 까지 도달해야 한다.

    한 청크(``_SAFE_PAGE_CHUNK``) 전체가 비는 최악의 경우를 재현한다.
    """
    calls = []

    def _always_empty(pdf_bytes, model_alias, label):
        calls.append(label)
        raise text_cleaner.EmptyExtraction(label)

    monkeypatch.setattr(text_cleaner, "_extract_pdf_bytes_llm", _always_empty)
    n = text_cleaner._SAFE_PAGE_CHUNK
    doc = _blank_doc(n)
    with pytest.raises(text_cleaner.EmptyExtraction):
        text_cleaner._extract_range_resilient(
            doc, 0, n - 1, "gemini-lite", "sample.pdf")
    widths = [_width(c) for c in calls]
    assert min(widths) == 1, (
        f"1페이지까지 좁히지 못하고 폭 {min(widths)} 에서 포기했다 — "
        "docstring 의 '1페이지까지 좁혔는데도 비면 그때 세운다' 계약 위반. "
        f"시도 폭: {sorted(set(widths), reverse=True)}")
    # 첫 시도는 언제나 요청 범위 전체
    assert _width(calls[0]) == n


def test_split_depth_cap_covers_the_chunk_width():
    """깊이 상한은 청크 폭을 1페이지로 만들 수 있어야 한다(계약의 산술)."""
    assert 2 ** text_cleaner._MAX_SPLIT_DEPTH >= text_cleaner._SAFE_PAGE_CHUNK


def test_successful_halves_are_concatenated_in_page_order(monkeypatch):
    """폭이 작아져 성공하면 조각이 **페이지 순서대로** 이어붙는다."""
    ok_width = 8
    seen = []

    def _empty_above_width(pdf_bytes, model_alias, label):
        lo, hi = _range(label)
        if (hi - lo + 1) > ok_width:
            raise text_cleaner.EmptyExtraction(label)
        seen.append((lo, hi))
        return f"[{lo}-{hi}]"

    monkeypatch.setattr(text_cleaner, "_extract_pdf_bytes_llm",
                        _empty_above_width)
    doc = _blank_doc(32)
    parts = text_cleaner._extract_range_resilient(
        doc, 0, 31, "gemini-lite", "sample.pdf")
    assert parts == [f"[{lo}-{hi}]" for lo, hi in seen]
    assert seen == sorted(seen)
    # 페이지 커버리지 — 어느 페이지도 빠지지 않고 겹치지도 않는다.
    covered = []
    for lo, hi in seen:
        covered.extend(range(lo, hi + 1))
    assert covered == list(range(1, 33))


def test_single_page_document_raises_instead_of_splitting(monkeypatch):
    """더 쪼갤 수 없는 1페이지는 즉시 세운다(무한 재귀 없음)."""
    calls = []

    def _always_empty(pdf_bytes, model_alias, label):
        calls.append(label)
        raise text_cleaner.EmptyExtraction(label)

    monkeypatch.setattr(text_cleaner, "_extract_pdf_bytes_llm", _always_empty)
    doc = _blank_doc(1)
    with pytest.raises(text_cleaner.EmptyExtraction):
        text_cleaner._extract_range_resilient(
            doc, 0, 0, "gemini-lite", "sample.pdf")
    assert len(calls) == 1
