"""텍스트 정리 모듈 — PDF에서 Gemini Flash Lite로 직접 텍스트 추출.

PDF를 Gemini에 직접 전달하여 시나리오 텍스트를 깨끗하게 추출.
PyMuPDF 대비 장점: $, & 등 변환 오류 없음, 단어 중간 줄바꿈 자동 처리,
인물명/대사 구분 유지, 페이지 번호 제거.

대용량 PDF 처리: gemini-flash 는 PDF 가 일정 페이지(실측 ~50p)를 넘으면
문서 일부(tail)만 반환하는 현상이 있다. 따라서 페이지 수가 임계값을 넘으면
페이지 범위로 분할해 각 범위를 온전히 추출한 뒤 이어붙인다. 입력을 자르는
것이 아니라 전 페이지를 빠짐없이 처리해 합치는 것이므로 "원문 전체 전달"
원칙을 지킨다(분석 단계는 합쳐진 전문을 한 번에 받는다).
"""

import base64
import logging
import math
from pathlib import Path
from typing import Dict, List, Optional

import fitz  # PyMuPDF

from app.modules.llm.llm_client import _resolve_model

logger = logging.getLogger(__name__)

# gemini-flash 가 안정적으로 온전히 추출하는 페이지 상한(실측: 50p=94씬 OK, 97p=tail만).
# 마진을 둬 40p 로 분할.
_SAFE_PAGE_CHUNK = 40
# 빈 응답 시 반으로 쪼개 재시도할 최대 깊이.
# ★상수로 박지 않고 청크 폭에서 유도한다 — `_extract_range_resilient` 의 계약이
# "1페이지까지 좁혔는데도 비면 그때 세운다" 이기 때문이다. 고정값 5 로는 40p
# 청크가 폭 2 에서 포기해 마지막 반분을 건너뛰었다(2026-07-30 유닛 실측,
# Codex 리뷰 #7). ceil(log2(40))=6 이면 40→20→10→5→3→2→1 로 폭 1 에 닿는다.
# 재귀 자체는 범위가 매번 줄어 반드시 끝나므로 이 상한은 비용 안전장치다.
_MAX_SPLIT_DEPTH = max(1, math.ceil(math.log2(max(2, _SAFE_PAGE_CHUNK))))

_EXTRACT_PROMPT = """이 PDF는 영화/드라마 시나리오 원본입니다. 전체 내용을 순수 텍스트로 추출해주세요.

[절대 규칙]
- 내용을 수정, 요약, 재구성하지 마세요. 원본 그대로 추출합니다.
- 시나리오의 모든 씬, 지문, 대사, 인물명을 빠짐없이 포함하세요.

[제거 대상]
- 페이지 번호 (1, 2, 3... 또는 - 1 -, - 2 - 형태)
- 헤더/푸터 반복 텍스트
- PDF 변환 오류 문자: $ (공백 대체), & (구분자 대체) 등 원본에 없는 특수문자
- 제작사명, 대표님 이름 등 메타 정보 (첫 페이지 표지 제외한 반복 요소)

[줄바꿈 처리 — 매우 중요]
- 단어 중간에 줄바꿈이 들어간 경우 반드시 합쳐주세요:
  예: "네메\\n시스" → "네메시스", "지\\n지합니다" → "지지합니다"
- 인물명 뒤 줄바꿈은 유지하세요 (인물명과 대사를 분리)
- 문장이 끝나는 곳의 줄바꿈은 유지하세요.
- 지문(설명) 중간의 불필요한 줄바꿈은 합쳐주세요.

[포맷 유지]
- 씬 번호 유지, 인물명 별도 줄, 괄호 지시문/전환 지시어 유지"""


class EmptyExtraction(RuntimeError):
    """LLM 이 그 덩어리에서 텍스트를 하나도 돌려주지 않았다.

    2026-07-28 실측(금월도 2고, 80p): 40p 덩어리에서 `message.content` 가
    None 으로 왔고 `len(None)` 이 TypeError 로 터졌다. 빈 문자열로 흘리면
    그 페이지 범위가 조용히 사라져 하류 전체가 오염되므로 세운다.
    """


def _page_count_of(pdf_bytes: bytes) -> int:
    try:
        d = fitz.open(stream=pdf_bytes, filetype="pdf")
        try:
            return d.page_count
        finally:
            d.close()
    except Exception:  # noqa: BLE001
        return -1


def _extract_range_resilient(
    doc: "fitz.Document", p0: int, p1: int, model_alias: str,
    stem: str, depth: int = 0,
) -> List[str]:
    """페이지 범위를 추출하되, 빈 응답이면 **반으로 쪼개 재시도**한다.

    빈 응답의 흔한 원인은 그 덩어리가 출력 상한에 걸리는 것이라, 범위를
    줄이면 대개 풀린다. 1페이지까지 좁혔는데도 비면 그때는 세운다
    (조용한 누락 금지).
    """
    label = f"{stem}#p{p0 + 1}-{p1 + 1}"
    sub = fitz.open()
    try:
        sub.insert_pdf(doc, from_page=p0, to_page=p1)
        payload = sub.tobytes()
    finally:
        sub.close()
    try:
        return [_extract_pdf_bytes_llm(payload, model_alias, label)]
    except EmptyExtraction as exc:
        if p0 >= p1 or depth >= _MAX_SPLIT_DEPTH:
            raise
        mid = (p0 + p1) // 2
        logger.warning(
            "text_cleanup: %s 빈 응답 — %d..%d / %d..%d 로 분할 재시도 (%s)",
            label, p0 + 1, mid + 1, mid + 2, p1 + 1, exc)
        return (
            _extract_range_resilient(doc, p0, mid, model_alias, stem,
                                     depth + 1)
            + _extract_range_resilient(doc, mid + 1, p1, model_alias, stem,
                                       depth + 1)
        )


def _extract_pdf_bytes_llm(pdf_bytes: bytes, model_alias: str, label: str) -> str:
    """PDF 바이트 한 덩어리를 Gemini 로 텍스트 추출."""
    pdf_b64 = base64.standard_b64encode(pdf_bytes).decode()

    # LiteLLM Router를 통해 호출 (API 키 풀 + retry 활용)
    from app.modules.llm.llm_client import router_completion

    response = router_completion(
        model=model_alias,
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "image_url",
                    "image_url": {"url": f"data:application/pdf;base64,{pdf_b64}"},
                },
                {"type": "text", "text": _EXTRACT_PROMPT},
            ],
        }],
        max_tokens=65536,
        temperature=0,
    )

    choice = response.choices[0]
    text = getattr(choice.message, "content", None)
    finish = getattr(choice, "finish_reason", None)
    if not (text or "").strip():
        # 빈 응답을 빈 문자열로 흘리면 **대본 수십 페이지가 조용히 사라진다**
        # (하류 전 단계 오염). 호출자가 분할 재시도할 수 있도록 세운다.
        raise EmptyExtraction(
            f"{label}: 추출 결과 없음 (finish_reason={finish!r}, "
            f"pages≈{_page_count_of(pdf_bytes)})")
    logger.info("text_cleanup: extracted %d chars from %s "
                "(finish_reason=%s)", len(text), label, finish)
    return text


def extract_text_from_pdf_llm(
    pdf_path: str,
    project_config: Optional[Dict] = None,
    opik_metadata: Optional[Dict] = None,
) -> str:
    """PDF에서 Gemini Flash Lite로 직접 텍스트 추출.

    페이지 수가 _SAFE_PAGE_CHUNK 이하면 단일 호출(기존 동작 그대로).
    초과하면 페이지 범위로 분할해 전 페이지를 추출한 뒤 이어붙인다.
    """
    path = Path(pdf_path)
    if not path.exists():
        raise FileNotFoundError(f"PDF 파일을 찾을 수 없습니다: {pdf_path}")

    model_alias = _resolve_model("text_cleanup", project_config)

    doc = fitz.open(str(path))
    try:
        n_pages = doc.page_count

        logger.info("text_cleanup: PDF %s (%d bytes, %d pages) → model=%s",
                    path.name, path.stat().st_size, n_pages, model_alias)

        # 소형 PDF: 단일 호출 (기존 검증된 경로 보존)
        # 2026-07-28: 단일 호출도 빈 응답이면 분할 재시도를 태운다 —
        # 예외를 그대로 올리면 40p 이하 대본이 통째로 죽는다.
        if n_pages <= _SAFE_PAGE_CHUNK:
            try:
                return _extract_pdf_bytes_llm(
                    path.read_bytes(), model_alias, path.name)
            except EmptyExtraction as exc:
                if n_pages <= 1:
                    raise
                logger.warning("text_cleanup: %s 빈 응답 — 분할 재시도 (%s)",
                               path.name, exc)
                return "\n".join(_extract_range_resilient(
                    doc, 0, n_pages - 1, model_alias, path.name))

        # 대형 PDF: 페이지 범위 분할 → 전 페이지 추출 후 concat (잘림 없음)
        logger.info("text_cleanup: large PDF (%d pages > %d) → 페이지 분할 추출",
                    n_pages, _SAFE_PAGE_CHUNK)
        parts: List[str] = []
        p0 = 0
        while p0 < n_pages:
            p1 = min(p0 + _SAFE_PAGE_CHUNK - 1, n_pages - 1)
            parts.extend(_extract_range_resilient(
                doc, p0, p1, model_alias, path.name))
            p0 = p1 + 1

        full = "\n".join(parts)
        logger.info("text_cleanup: chunked extraction 완료 — %d chars (%d chunks) from %s",
                    len(full), len(parts), path.name)
        return full
    finally:
        doc.close()
