"""İtiraz / şekli inceleme yazısı ayıklama mantığı.

PDF'ten metin çıkarır (pypdf) ve Claude Haiku ile patent ofisinin tespit
ettiği eksiklik maddelerini yapılandırılmış biçimde ayıklar. Üç ofis
desteklenir; her birinin sistem prompt'u, kural anahtar tablosu ve başvuru
numarası regex'i ayrıdır:

- TR (TürkPatent): "Şekli İnceleme" yazısı, formality.rules TR kuralları
- EP (EPO): Communication under Rule 57/58 EPC, formality.epo kuralları
- US (USPTO): Notice to File Missing Parts / Office Action (formal),
  formality.uspto kuralları

Çıkarılan her madde, mümkünse `formality` modülünün kural kimlikleriyle
eşleştirilir — "itiraz → düzeltme → değişiklik evrakı" döngüsünün halkasıdır.
"""

from __future__ import annotations

import io
import logging
import re

from anthropic import APIError
from pydantic import BaseModel, Field
from pypdf import PdfReader

from app.services.formality.models import Jurisdiction
from app.services.llm import LLMNotConfiguredError, get_anthropic_client, resolve
from app.services.llm.usage_log import record_usage
from app.services.objection.models import ObjectionItem, ObjectionReport

_log = logging.getLogger(__name__)

# Model seçimi router'da (app/services/llm/router.py).
_MODEL = resolve("objection.extract").model

# Tek LLM çağrısına gönderilecek azami metin (token bütçesi). Şekli
# inceleme yazıları kısadır; 12K karakter fazlasıyla yeter.
_MAX_CHARS = 12_000


class ObjectionExtractionError(Exception):
    """PDF okunamadığında veya metin çıkarılamadığında fırlatılır."""


def extract_objection_text(pdf_bytes: bytes) -> str:
    """PDF baytlarından düz metin çıkarır.

    Metin tabanlı PDF'ler için çalışır. Taranmış (görüntü) PDF'lerde boş
    veya çok az metin döner — bu durumda LLM katmanı da bir şey bulamaz.
    """
    try:
        reader = PdfReader(io.BytesIO(pdf_bytes))
    except Exception as exc:  # noqa: BLE001
        raise ObjectionExtractionError(
            f"PDF okunamadı: {exc}"
        ) from exc

    parts: list[str] = []
    for page in reader.pages:
        try:
            parts.append(page.extract_text() or "")
        except Exception:  # noqa: BLE001  # bozuk sayfa — atla
            continue
    text = "\n".join(parts).strip()
    if not text:
        raise ObjectionExtractionError(
            "PDF'ten metin çıkarılamadı — belge taranmış görüntü olabilir."
        )
    return text


# --------------------------------------------------------------------------
# Ofise-özgü kural anahtar tabloları
# --------------------------------------------------------------------------
# Her ofis için: (formality kural kimliği, itiraz metninde aranan anahtar
# kelimeler). LLM zaten `rule_id_guess` önerir; bu tablo deterministik bir
# yedek/doğrulama katmanıdır.

_RULE_KEYWORDS_BY_JURIS: dict[Jurisdiction, list[tuple[str, list[str]]]] = {
    Jurisdiction.TR: [
        ("claims.no_subheadings", ["alt başlık", "alt baslik"]),
        (
            "abstract.missing_title",
            ["özet başlığı", "ozet basligi", "özet sayfası"],
        ),
        ("claims.numbering_gap", ["numaraland", "birbirini izleyen"]),
        (
            "terms.foreign_phrase",
            ["yabancı dil", "yabanci dil", "türkçe karşıl"],
        ),
        ("claims.multi_sentence", ["tek cümle", "tek cumle"]),
    ],
    Jurisdiction.EP: [
        ("epo.abstract_missing", ["abstract is missing", "no abstract"]),
        ("epo.claims_missing", ["no claims", "claims are missing"]),
        (
            "epo.abstract_too_long",
            ["abstract exceeds", "more than 150 words"],
        ),
        (
            "epo.abstract_merit_language",
            ["alleged merits", "merit", "comparison with the prior art"],
        ),
        (
            "claims.no_subheadings",
            ["sub-heading", "subheading"],
        ),
        ("claims.multi_sentence", ["single sentence", "more than one sentence"]),
    ],
    Jurisdiction.US: [
        ("uspto.abstract_missing", ["abstract is missing", "no abstract"]),
        ("uspto.claims_missing", ["no claims", "claims are missing"]),
        (
            "uspto.abstract_too_long",
            ["exceeds 150 words", "abstract exceeds", "150 words"],
        ),
        (
            "uspto.abstract_prior_art",
            ["compare", "prior art", "merits"],
        ),
        (
            "uspto.no_dependent_claims",
            ["dependent claim", "no dependent"],
        ),
    ],
}

# Ek geçerli kural kimlikleri (LLM önerebilir ama anahtar tabloda yok).
_EXTRA_VALID_BY_JURIS: dict[Jurisdiction, set[str]] = {
    Jurisdiction.TR: {"claims.numbering_start", "abstract.too_long"},
    Jurisdiction.EP: {"claims.numbering_gap", "claims.numbering_start"},
    Jurisdiction.US: {"claims.numbering_gap", "claims.numbering_start"},
}


def _valid_rule_ids(jurisdiction: Jurisdiction) -> set[str]:
    return {
        rid for rid, _ in _RULE_KEYWORDS_BY_JURIS[jurisdiction]
    } | _EXTRA_VALID_BY_JURIS[jurisdiction]


def _match_rule(
    description: str, llm_guess: str, jurisdiction: Jurisdiction
) -> str:
    """Bir eksiklik açıklamasını ofisin kural kimliğine eşler.

    Önce LLM'in önerisini doğrular; geçerli bir kimlikse onu kullanır.
    Aksi halde ofisin anahtar kelime tablosuna düşer.
    """
    if llm_guess and llm_guess in _valid_rule_ids(jurisdiction):
        return llm_guess
    low = description.lower()
    for rule_id, keywords in _RULE_KEYWORDS_BY_JURIS[jurisdiction]:
        if any(kw in low for kw in keywords):
            return rule_id
    return ""


# --------------------------------------------------------------------------
# Ofise-özgü sistem promptları
# --------------------------------------------------------------------------

_SYSTEM_PROMPT_TR = """Sen Türk patent başvurularının şekli inceleme \
uzmanısın. Görevin: TürkPatent'in (Türk Patent ve Marka Kurumu) gönderdiği \
bir şekli inceleme veya itiraz yazısının metnini alıp, Kurumun tespit \
ettiği eksiklik maddelerini yapılandırılmış biçimde çıkarmak.

ÇIKARILACAKLAR:
1. items — Kurumun tespit ettiği HER eksiklik ayrı bir madde. Yazıda \
genellikle tire (-) veya numara ile listelenir. Her madde için:
   - description: eksikliğin metni (Kurumun ifadesiyle, kısaltarak).
   - category: "istemler" | "tarifname" | "ozet" | "resimler" | "genel".
   - rule_id_guess: aşağıdaki listeden uygun olanı, emin değilsen boş bırak:
       claims.no_subheadings        (istemlerde alt başlık kullanılmış)
       abstract.missing_title       (özette buluş başlığı yok)
       claims.numbering_gap         (sayfa/istem numaralandırma hatası)
       claims.numbering_start       (numaralandırma 1'den başlamıyor)
       terms.foreign_phrase         (yabancı ifadenin Türkçe karşılığı yok)
       claims.multi_sentence        (istem tek cümle değil)
       abstract.too_long            (özet 150 kelimeyi aşıyor)
   - suggested_fix: eksikliğin nasıl giderileceğine dair tek cümlelik öneri.
2. deadline — Eksikliklerin giderilmesi için verilen süre (örn. "iki ay"). \
Yoksa boş.
3. application_number — Yazıdaki başvuru numarası (örn. "2026/005880").
4. letter_reference — Yazının sayı ve tarih bilgisi.

KURALLAR:
- Yalnızca yazıda AÇIKÇA belirtilen eksiklikleri çıkar; yorum ekleme.
- Genel bilgilendirme cümlelerini (yıllık ücret, yayım, tebligat vb.) \
madde olarak ALMA — yalnızca giderilmesi gereken şekli eksiklikleri al.
- Eksiklik yoksa items boş liste döner.

Çıktı yalnızca structured JSON olarak döner; ek açıklama yapma.
"""

_SYSTEM_PROMPT_EP = """You are an expert in European Patent Office (EPO) \
formal examination. Your task: read a formal communication issued by the \
EPO (e.g. under Rule 57/58 EPC, noting missing parts or formal defects) \
and extract each defect in a structured form.

EXTRACT:
1. items — each defect raised by the EPO is a separate item. They are \
typically listed by bullet, dash or numbering. For each:
   - description: the defect as stated by the EPO, concisely.
   - category: "claims" | "description" | "abstract" | "drawings" | "general".
   - rule_id_guess: one of the following if appropriate, else leave empty:
       epo.abstract_missing            (no abstract is filed)
       epo.claims_missing              (no claims are filed)
       epo.abstract_too_long           (abstract exceeds 150 words)
       epo.abstract_merit_language     (abstract makes merit/comparison claims)
       claims.no_subheadings           (claims use forbidden sub-headings)
       claims.multi_sentence           (a claim is not a single sentence)
       claims.numbering_gap            (claims/pages not consecutively numbered)
       claims.numbering_start          (claim numbering does not start at 1)
   - suggested_fix: one-sentence remedy suggestion.
2. deadline — period given to remedy the deficiencies (e.g. "two months"). \
Empty if not stated.
3. application_number — the EP application number (e.g. "EP 21 123 456.7" \
or "21123456.7").
4. letter_reference — the communication reference and/or date.

RULES:
- Extract only deficiencies the EPO EXPLICITLY raises; do not add interpretation.
- Skip purely informational paragraphs (renewal fees, publication, service of \
documents, etc.) — only items that the applicant must remedy.
- If there are no deficiencies, items is an empty list.
- Output language: match the communication's language (English/French/German).

Output is structured JSON only; no additional commentary.
"""

_SYSTEM_PROMPT_US = """You are an expert in USPTO formal examination. Your \
task: read a USPTO formal Office Action (e.g. Notice to File Missing Parts, \
Notice of Incomplete Application, or formality-related rejection) and \
extract each deficiency in a structured form.

EXTRACT:
1. items — each deficiency raised by the USPTO is a separate item. They \
are typically listed by bullet or numbering. For each:
   - description: the deficiency as stated, concisely.
   - category: "claims" | "specification" | "abstract" | "drawings" | "general".
   - rule_id_guess: one of the following if appropriate, else leave empty:
       uspto.abstract_missing          (no Abstract of the Disclosure)
       uspto.claims_missing            (no claims; 37 CFR 1.75)
       uspto.abstract_too_long         (abstract exceeds 150 words; 37 CFR 1.72(b))
       uspto.abstract_prior_art        (abstract compares with prior art; MPEP 608.01(b))
       uspto.no_dependent_claims       (no dependent claims; 37 CFR 1.75(c))
       claims.numbering_gap            (claims/pages not consecutively numbered)
       claims.numbering_start          (claim numbering does not start at 1)
       claims.multi_sentence           (a claim is not a single sentence)
   - suggested_fix: one-sentence remedy suggestion.
2. deadline — period given to reply (e.g. "two months from the mailing date"). \
Empty if not stated.
3. application_number — the U.S. application number (e.g. "17/123,456" or \
"17/123456").
4. letter_reference — the Office Action mailing date and/or paper number.

RULES:
- Extract only deficiencies the USPTO EXPLICITLY raises; do not add interpretation.
- Skip purely informational paragraphs (filing receipt, fee schedule, \
publication, etc.) — only items the applicant must remedy.
- If there are no deficiencies, items is an empty list.
- Output language: match the Office Action (English).

Output is structured JSON only; no additional commentary.
"""

_SYSTEM_PROMPTS: dict[Jurisdiction, str] = {
    Jurisdiction.TR: _SYSTEM_PROMPT_TR,
    Jurisdiction.EP: _SYSTEM_PROMPT_EP,
    Jurisdiction.US: _SYSTEM_PROMPT_US,
}


# --------------------------------------------------------------------------
# Başvuru numarası regex'leri (LLM yoksa veya hata aldıysa yedek)
# --------------------------------------------------------------------------

_APP_NUMBER_PATTERNS: dict[Jurisdiction, list[str]] = {
    # TR: YYYY/NNNNNN
    Jurisdiction.TR: [r"\b(20\d{2}/\d{4,6})\b"],
    # EP: "EP 21 123 456.7" veya "21123456.7" veya "21123456"
    Jurisdiction.EP: [
        r"\b(EP\s?\d{2}\s?\d{3}\s?\d{3}(?:\.\d)?)\b",
        r"\b(\d{8}\.\d)\b",
    ],
    # US: "17/123,456" veya "17/123456" — / sonrası 6 hane
    Jurisdiction.US: [r"\b(\d{2}/\d{3},?\d{3})\b"],
}


def _extract_app_number(text: str, jurisdiction: Jurisdiction) -> str:
    """Metinden ofisin başvuru numarası biçimini regex ile çıkar."""
    for pattern in _APP_NUMBER_PATTERNS[jurisdiction]:
        m = re.search(pattern, text)
        if m:
            return m.group(1)
    return ""


def _user_message(text: str, jurisdiction: Jurisdiction) -> str:
    """LLM'e gönderilen kullanıcı mesajını ofise göre hazırla."""
    excerpt = text.strip()[:_MAX_CHARS]
    if jurisdiction == Jurisdiction.TR:
        return (
            "Aşağıdaki TürkPatent şekli inceleme/itiraz yazısını incele ve "
            "tespit edilen eksiklik maddelerini çıkar:\n\n" + excerpt
        )
    if jurisdiction == Jurisdiction.EP:
        return (
            "Read the following EPO formal communication and extract each "
            "deficiency raised:\n\n" + excerpt
        )
    return (
        "Read the following USPTO formal Office Action and extract each "
        "deficiency raised:\n\n" + excerpt
    )


class _RawObjectionItem(BaseModel):
    """Haiku'nun ayıkladığı ham madde."""

    description: str = Field(..., description="Eksiklik metni")
    category: str = Field(default="genel")
    rule_id_guess: str = Field(
        default="", description="Tahmini şekli tarayıcı kural kimliği"
    )
    suggested_fix: str = Field(default="")


class _RawObjectionReport(BaseModel):
    """Haiku'nun structured output şeması."""

    items: list[_RawObjectionItem] = Field(default_factory=list, max_length=30)
    deadline: str = Field(default="")
    application_number: str = Field(default="")
    letter_reference: str = Field(default="")


async def parse_objection(
    pdf_text: str,
    jurisdiction: Jurisdiction = Jurisdiction.TR,
) -> ObjectionReport:
    """İtiraz yazısı metninden eksiklik maddelerini ayıklar.

    Ofise (`jurisdiction`) göre sistem prompt'u, kural anahtar tablosu ve
    başvuru numarası regex'i seçilir. LLM yapılandırılmamışsa (API key yok)
    `extracted=False` ile boş rapor döner — çağıran taraf kullanıcıyı
    bilgilendirir.
    """
    report = ObjectionReport()
    fallback_app_no = _extract_app_number(pdf_text, jurisdiction)

    try:
        client = get_anthropic_client()
    except LLMNotConfiguredError:
        report.application_number = fallback_app_no
        return report

    system_prompt = _SYSTEM_PROMPTS[jurisdiction]
    user_message = _user_message(pdf_text, jurisdiction)

    try:
        resp = await client.messages.parse(
            # Şekli inceleme yazılarında 4-8 madde + açıklamalar olabilir;
            # 2048 token kesilmeye yol açıyordu. 4096 güvenli marj.
            model=_MODEL,
            max_tokens=4096,
            system=[
                {
                    "type": "text",
                    "text": system_prompt,
                    "cache_control": {"type": "ephemeral"},
                }
            ],
            messages=[{"role": "user", "content": user_message}],
            output_format=_RawObjectionReport,
        )
        record_usage(
            model=_MODEL,
            usage=resp.usage,
            endpoint=f"objection.parse.{jurisdiction.value.lower()}",
        )
        raw: _RawObjectionReport = resp.parsed_output  # type: ignore[assignment]
    except APIError as exc:
        # Anthropic API hatası (rate limit, auth vb.) — ayıklama yapılamadı.
        _log.warning("Objection parse — Anthropic API hatası: %s", exc)
        report.application_number = fallback_app_no
        return report
    except Exception as exc:  # noqa: BLE001  # structured-output doğrulama hatası
        # Yanıt kesilmesi / şema doğrulama hatası — sessizce yutma, logla.
        _log.warning("Objection parse — yanıt ayrıştırılamadı: %s", exc)
        report.application_number = fallback_app_no
        return report

    report.extracted = True
    report.deadline = raw.deadline
    report.letter_reference = raw.letter_reference
    report.application_number = raw.application_number or fallback_app_no
    for item in raw.items:
        report.items.append(
            ObjectionItem(
                description=item.description,
                category=item.category or "genel",
                matched_rule_id=_match_rule(
                    item.description, item.rule_id_guess, jurisdiction
                ),
                suggested_fix=item.suggested_fix,
            )
        )
    report.item_count = len(report.items)
    return report
