"""PDF 유효성 검증 모듈 — GPT 비전으로 생성된 PDF의 품질을 검증.

prompt_loader 통합 (problems.md #14): hard-coded ``v1`` 디렉토리 직접 read 제거
→ ``prompt_loader.load_prompt`` 경유 (DB 우선 + numeric version 정렬 + #6 #13).
"""

import base64
import json
import socket
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Dict

from app.core.config import settings
from app.modules.llm.image_tracer import traced_call

_MODULE = "pdf_validation"

VALIDATION_SCHEMA = {
    "type": "object",
    "additionalProperties": False,
    "properties": {
        "text_readable": {"type": "boolean"},
        "images_present": {"type": "boolean"},
        "layout_correct": {"type": "boolean"},
        "caption_visible": {"type": "boolean"},
        "overall_quality": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
        },
        "issues": {
            "type": "array",
            "items": {"type": "string"},
        },
    },
    "required": [
        "text_readable",
        "images_present",
        "layout_correct",
        "caption_visible",
        "overall_quality",
        "issues",
    ],
}


def _load_prompt(filename: str) -> str:
    """``prompts/_base/pdf_validation`` 에서 stem 로드 (DB 우선).

    Raises FileNotFoundError 또는 RuntimeError (#6 strict mode) — caller 처리.
    """
    from app.modules.prompt_loader import load_prompt

    stem = filename[:-3] if filename.endswith(".md") else filename
    return load_prompt(_MODULE, stem)


def _pdf_first_page_to_png(pdf_path: Path) -> bytes:
    """Convert the first page of a PDF to a PNG image.

    Uses fpdf2 to render a summary page since pypdf cannot rasterize.
    Falls back to reading the raw PDF bytes for vision API.
    """
    # Try using subprocess with sips (macOS) or pdftoppm
    import subprocess
    import tempfile

    with tempfile.TemporaryDirectory() as tmpdir:
        out_path = Path(tmpdir) / "page"
        # Try pdftoppm first (poppler)
        try:
            subprocess.run(
                [
                    "pdftoppm",
                    "-png",
                    "-f", "1",
                    "-l", "1",
                    "-r", "150",
                    str(pdf_path),
                    str(out_path),
                ],
                check=True,
                capture_output=True,
                timeout=30,
            )
            # pdftoppm outputs as page-1.png or page-01.png
            for candidate in out_path.parent.iterdir():
                if candidate.suffix == ".png":
                    return candidate.read_bytes()
        except (subprocess.CalledProcessError, FileNotFoundError):
            # 의도적: pdftoppm 미설치 또는 실패 시 sips fallback으로 넘김.
            pass

        # Fallback: use sips on macOS to convert PDF to PNG
        try:
            png_out = Path(tmpdir) / "page.png"
            subprocess.run(
                [
                    "sips",
                    "-s", "format", "png",
                    "--resampleHeight", "2000",
                    str(pdf_path),
                    "--out", str(png_out),
                ],
                check=True,
                capture_output=True,
                timeout=30,
            )
            if png_out.exists():
                return png_out.read_bytes()
        except (subprocess.CalledProcessError, FileNotFoundError):
            # 의도적: sips도 실패 시 아래 RuntimeError로 명시적 에러 전파.
            pass

    raise RuntimeError(
        "Could not convert PDF to image. Install poppler (pdftoppm) "
        "or run on macOS with sips available."
    )


@traced_call(operation="pdf_validation", provider="openai",
             model_of=lambda *a, **k: settings.openai_model,
             prompt_of=lambda image_bytes, system_prompt, *a, **k: system_prompt,
             output_text="[pdf judged]")
def _call_vision_api(
    image_bytes: bytes,
    system_prompt: str,
    _api_key: str | None = None,
) -> Dict[str, Any]:
    """Call OpenAI vision API with an image for PDF validation.

    ★기록: urllib 직접 호출이라 litellm 콜백 밖이다 (2026-08-07).
    """
    if _api_key is None:
        # 슬롯은 브로커가 정하고, 키 수준 실패면 다음 슬롯으로 다시 부른다.
        from app.core.openai_keys import call_with_key_failover

        return call_with_key_failover(
            lambda _k: _call_vision_api(image_bytes, system_prompt, _api_key=_k),
            where="pdf_validator.vision")
    api_key = _api_key
    if not api_key:
        raise RuntimeError("OpenAI API key is not configured.")

    image_b64 = base64.b64encode(image_bytes).decode("ascii")

    body = {
        "model": settings.openai_model,
        "instructions": system_prompt,
        "input": [
            {
                "role": "user",
                "content": [
                    {
                        "type": "input_image",
                        "image_url": f"data:image/png;base64,{image_b64}",
                    },
                    {
                        "type": "input_text",
                        "text": "Validate this webbook PDF page.",
                    },
                ],
            },
        ],
        "temperature": 0.1,
        "store": False,
        "text": {
            "format": {
                "type": "json_schema",
                "name": "pdf_validation_result",
                "strict": True,
                "schema": VALIDATION_SCHEMA,
            }
        },
    }

    request = urllib.request.Request(
        "https://api.openai.com/v1/responses",
        data=json.dumps(body).encode("utf-8"),
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        },
        method="POST",
    )

    timeout = settings.llm_timeout_validation
    max_retries = settings.llm_max_retries
    last_error: Exception | None = None
    for attempt in range(1, max_retries + 2):  # 첫 시도 + max_retries 재시도
        try:
            with urllib.request.urlopen(request, timeout=timeout) as resp:
                payload = json.loads(resp.read().decode("utf-8"))
            break
        except urllib.error.HTTPError as exc:
            error_text = exc.read().decode("utf-8", errors="replace")
            last_error = RuntimeError(f"OpenAI API error {exc.code}: {error_text}")
            # urllib 은 provider 를 안 실어 브로커가 OpenAI 호출인지 모른다 —
            # 올라가는 예외에 표시를 붙여야 기존 판정 계약이 적용된다.
            from app.core.openai_keys import mark_openai_failure
            mark_openai_failure(
                last_error, status=exc.code, body=error_text)
            # ★키 수준 실패는 같은 키로 재시도해도 풀리지 않는다 — quota 소진이
            # 429 로 올 때 죽은 키를 max_retries 번 더 태우고 backoff 까지
            # 걸었다(실측 13.8초). 전환은 브로커가 판단하게 넘긴다.
            from app.core.openai_keys import is_key_level_failure
            if (exc.code in {429, 500, 502, 503, 504}
                    and attempt <= max_retries
                    and not is_key_level_failure(last_error)):
                time.sleep(2 * attempt)
                continue
            raise last_error from exc
        except (urllib.error.URLError, socket.timeout) as exc:
            last_error = exc
            if attempt <= max_retries:
                time.sleep(2 * attempt)
                continue
            raise RuntimeError(f"OpenAI API failed after {max_retries} retries: {exc}") from exc
    else:
        raise RuntimeError(f"OpenAI API failed: {last_error}")

    # Extract text from response
    output_text = payload.get("output_text")
    if not isinstance(output_text, str) or not output_text.strip():
        output = payload.get("output")
        if isinstance(output, list):
            for item in output:
                if not isinstance(item, dict):
                    continue
                content = item.get("content")
                if not isinstance(content, list):
                    continue
                for part in content:
                    if isinstance(part, dict) and part.get("type") == "output_text":
                        text = part.get("text")
                        if isinstance(text, str) and text.strip():
                            output_text = text
                            break

    if not output_text:
        raise RuntimeError("OpenAI vision response did not include output text.")

    return json.loads(output_text)


class PDFValidator:
    """PDF 품질 검증기 — GPT 비전 기반."""

    def validate(self, pdf_path: Path) -> Dict[str, Any]:
        """Validate a generated PDF using GPT vision.

        Converts the first page to an image, sends it to GPT for
        layout and quality analysis.

        Args:
            pdf_path: Path to the PDF file to validate.

        Returns:
            Validation result dict with text_readable, images_present,
            layout_correct, caption_visible, overall_quality, issues.
        """
        if not pdf_path.exists():
            raise FileNotFoundError(f"PDF not found: {pdf_path}")

        system_prompt = _load_prompt("validate.md")
        image_bytes = _pdf_first_page_to_png(pdf_path)
        result = _call_vision_api(image_bytes, system_prompt)

        # Add metadata
        result["pdf_path"] = str(pdf_path)
        result["pdf_filename"] = pdf_path.name

        return result

    def validate_batch(
        self, pdf_paths: list[Path]
    ) -> list[Dict[str, Any]]:
        """Validate multiple PDFs.

        Args:
            pdf_paths: List of PDF file paths.

        Returns:
            List of validation results.
        """
        results = []
        for pdf_path in pdf_paths:
            try:
                result = self.validate(pdf_path)
                results.append(result)
            except Exception as exc:
                results.append(
                    {
                        "pdf_path": str(pdf_path),
                        "pdf_filename": pdf_path.name,
                        "error": str(exc),
                        "text_readable": False,
                        "images_present": False,
                        "layout_correct": False,
                        "caption_visible": False,
                        "overall_quality": 0,
                        "issues": [f"Validation failed: {exc}"],
                    }
                )
        return results

def _active_openai_key() -> str:
    """활성 키 슬롯 — 1차 필드를 직접 보면 보조 키가 안 쓰인다."""
    from app.core.openai_keys import active_key

    return active_key()
