"""
llm_extractor.py - Modul ekstraksi akademik via LLM.

Menangani:
- Pembersihan string JSON respons LLM (Lapis 1 - The Sweeper)
- Sanitasi nilai field Pydantic (Lapis 3 - The Pydantic Armor)
- Struktur model validasi Pydantic (LiteratureMetadata, LiteraturePillars, ExtractionResponse)
- Fungsi ekstraksi utama extract_pillars_with_llm dengan dependency injection
"""

import json
import logging
import threading
from typing import Annotated, Any, List

from pydantic import BaseModel, Field, BeforeValidator
from tenacity import retry, wait_exponential_jitter, stop_after_attempt

logger = logging.getLogger(__name__)


def clean_llm_json_string(raw_text: str) -> str:
    """
    LAPIS 1 (The Sweeper):
    Membersihkan teks respons LLM dari basa-basi dan markdown fences.
    Hanya mengambil teks di antara tanda kurung kurawal pertama dan terakhir.
    """
    if not raw_text:
        return ""

    start_idx = raw_text.find('{')
    end_idx = raw_text.rfind('}')

    if start_idx != -1 and end_idx != -1 and start_idx < end_idx:
        return raw_text[start_idx:end_idx + 1]

    return raw_text


def sanitize_llm_field(value) -> str:
    """
    LAPIS 3 (The Pydantic Armor):
    Mengoersi berbagai macam respons halusinasi LLM (None, [], int, frasa palsu)
    menjadi string murni yang aman bagi Neo4j.
    
    Penjelasan lebih lanjut tentang fungsi sanitize_llm_field:
    Fungsi ini dirancang untuk menangani berbagai jenis respons yang mungkin diberikan oleh LLM, 
    yang sering kali tidak konsisten dan dapat mencakup nilai-nilai seperti None, list kosong, angka, atau bahkan frasa yang menunjukkan bahwa informasi tidak tersedia. 
    Dengan menerapkan fungsi ini sebagai BeforeValidator di dalam model Pydantic, kita memastikan bahwa setiap nilai yang masuk ke dalam field tersebut akan diproses melalui 
    lapisan sanitasi yang sama, sehingga data yang disimpan di Neo4j akan lebih bersih, konsisten, dan dapat diandalkan untuk analisis selanjutnya
    """
    if value is None:
        return ""

    if isinstance(value, list):
        value = " ".join(str(v) for v in value)
    elif not isinstance(value, str):
        value = str(value)

    value = value.strip()

    reject_phrases = [
        "information not available",
        "informasi tidak tersedia",
        "not specified",
        "tidak disebutkan",
        "tidak ada",
        "none"
    ]

    value_lower = value.lower()
    for phrase in reject_phrases:
        if phrase in value_lower and len(value) < 100:
            return ""

    return value


class LiteratureMetadata(BaseModel):
    title: str = Field(description="Judul lengkap artikel ilmiah")
    keywords: List[str] = Field(description="Daftar 3-5 kata kunci utama penelitian")


class LiteraturePillars(BaseModel):
    background: Annotated[str, BeforeValidator(sanitize_llm_field)] = Field(default="", description="Latar Belakang dari jurnal")
    methodology: Annotated[str, BeforeValidator(sanitize_llm_field)] = Field(default="", description="Metodologi yang digunakan di dalam jurnal")
    gap: Annotated[str, BeforeValidator(sanitize_llm_field)] = Field(default="", description="Gap penelitian atau masalah yang belum terselesaikan")
    objective: Annotated[str, BeforeValidator(sanitize_llm_field)] = Field(default="", description="Tujuan dari penelitian") #maksud dari before validator di sini adalah untuk memastikan bahwa setiap field yang diproses melalui model Pydantic ini akan melewati fungsi sanitasi yang sama, menjaga konsistensi data sebelum disimpan ke Neo4j
    futurework: Annotated[str, BeforeValidator(sanitize_llm_field)] = Field(default="", description="Pekerjaan di masa depan yang diusulkan oleh penulis") #sanitasi yang dimaksud di sini adalah untuk membersihkan nilai-nilai yang mungkin tidak sesuai atau tidak berguna untuk disimpan di Neo4j, seperti nilai None, frasa yang menunjukkan informasi tidak tersedia, atau bahkan angka yang tidak relevan. Dengan menggunakan BeforeValidator, kita memastikan bahwa setiap nilai yang masuk ke dalam field ini sudah melalui proses pembersihan yang konsisten, sehingga data yang disimpan di Neo4j lebih bersih dan dapat diandalkan untuk analisis selanjutnya.


class ExtractionResponse(BaseModel):
    metadata: LiteratureMetadata
    pillars: LiteraturePillars


@retry(wait=wait_exponential_jitter(initial=2, max=60), stop=stop_after_attempt(10), reraise=True)
def extract_pillars_with_llm(
    text: str,
    client: Any,
    semaphore: threading.BoundedSemaphore,
) -> tuple[dict, dict]:
    """
    Ekstrak metadata dan 5 pilar akademik dari teks menggunakan LLM.

    Parameter:
        text       : Teks konteks jurnal yang sudah di-retrieve dan di-deduplikasi.
        client     : Gateway-backed OpenAI-compatible client facade.
        semaphore  : BoundedSemaphore untuk rate limiting API calls.

    Return:
        tuple[dict, dict]: (extraction_result, usage_stats)
            - extraction_result: dict dengan key 'metadata' dan 'pillars'
            - usage_stats: dict dengan key 'prompt_tokens' dan 'completion_tokens'
    """
    prompt = f"""You are a Senior Academic Reviewer and Data Extraction Specialist. Your job is to analyze academic papers and extract specific information to build a research knowledge graph.

## TASK
Extract structured information from the following FILTERED academic paper sections.
You must organize your JSON output into exactly TWO nested categories: `metadata` and `pillars`.

---

## 1. METADATA INSTRUCTIONS
Extract the core identity of the paper.
CRITICAL RULE: You MUST extract the title and keywords in the ORIGINAL LANGUAGE of the document. DO NOT translate them.

- title: Identify and extract the ORIGINAL research title.
  - CRITICAL: Ignore publication info such as Journal Name, Volume, Issue, Year, ISSN, DOI, or URLs.
  - IDENTIFIER: The title is usually the most prominent and longest phrase describing the research topic.
  - CONSTRAINTS: Output ONLY the research title text, free from any journal template elements.
- keywords: 
  PRIORITAS 1 (HARFIAH): Scan the text for explicit keyword sections (e.g., "Keywords:", "Kata Kunci:", "Index Terms:"). If found, extract those exact words/phrases as an array of strings.
  PRIORITAS 2 (INFERENSI): If and ONLY IF an explicit keyword section is missing, infer 3-5 core keywords that best represent the domain and methodology of the research.
  STRICT RULE: Keywords MUST NOT be the same as the title and MUST NOT contain the full title sentence. Extract atomic concepts that best represent the research domain and methodology.

---

## 2. PILLARS INSTRUCTIONS
Extract the academic pillars based on the following specific criteria.
CRITICAL RULE: Unlike metadata, you MUST write ALL pillar outputs STRICTLY IN FORMAL INDONESIAN with a sharp, flowing academic tone. Avoid robotic or machine-translated styles.

- background: Berdasarkan teks, ekstrak LATAR BELAKANG PENELITIAN. Fokus pada konteks masalah, motivasi riset, dan penelitian terdahulu yang relevan. Gunakan frasa pembuka seperti: 'Dilatarbelakangi oleh...', 'Menjadi krusial karena...', atau 'Urgensi kajian ini berfokus pada...'
- methodology: Berdasarkan teks, ekstrak METODOLOGI PENELITIAN. Fokus pada teknik/pendekatan yang digunakan, arsitektur sistem, dataset, dan prosedur eksperimen. Gunakan frasa pembuka seperti: 'Mengadopsi pendekatan...', 'Dieksekusi secara komprehensif menggunakan...', atau 'Rancang bangun eksperimental...'
- gap: Berdasarkan teks, ekstrak GAP DAN KETERBATASAN PENELITIAN. Fokus pada kelemahan metode, batasan ruang lingkup, dan area yang belum tercover. Gunakan frasa pembuka seperti: 'Meninggalkan celah pada...', 'Belum dieksplorasi secara mendalam...', atau 'Keterbatasan utama terletak pada...'
- objective: Berdasarkan teks, ekstrak TUJUAN PENELITIAN. Fokus pada apa yang ingin dicapai, kontribusi utama, dan hipotesis. Gunakan frasa pembuka seperti: 'Bertujuan secara spesifik untuk memitigasi...', atau 'Berfokus pada evaluasi komprehensif...'
- futurework: Berdasarkan teks, ekstrak ARAHAN PENELITIAN MASA DEPAN (FUTURE WORK). Fokus pada rekomendasi penulis untuk studi selanjutnya, pertanyaan terbuka, dan usulan ekstensi. Gunakan frasa pembuka seperti: 'Penelitian lanjutan direkomendasikan untuk...', atau 'Mengusulkan trayektori riset terkait...'

---

## GLOBAL CONSTRAINTS & FORMAT
- Extract ONLY from the provided text — do NOT hallucinate external facts.
- If a pillar's information is completely missing or unclear, return an empty string "".
- Each pillar text MUST strictly be between 50 and 80 words.
- Your response MUST perfectly match the requested JSON schema.

Compiled Text Excerpts:
{text[:25000]}
"""

    empty_response = {
        "metadata": {"title": "Unknown", "keywords": []},
        "pillars": {"background": "", "methodology": "", "gap": "", "objective": "", "futurework": ""}
    }

    with semaphore:
        response = client.chat.completions.create(
            response_format={"type": "json_object"},
            messages=[
                {
                    "role": "system",
                    "content": (
                        "You are a Senior Academic Data Integrity Engine. "
                        "You have zero tolerance for bibliography noise in the metadata fields. "
                        "Your priority is to distinguish the Research Title from the Journal Template info "
                        "(Vol, No, ISSN, DOI). Follow the user rubric exactly. Return ONLY one valid JSON object "
                        "containing 'metadata' and 'pillars'."
                    ),
                },
                {"role": "user", "content": prompt}
            ],
            temperature=0.1,
            timeout=60.0
        )

    if response is None:
        logger.warning("LLM API returned None response, returning empty pillars.")
        return empty_response, {"prompt_tokens": 0, "completion_tokens": 0}

    if not hasattr(response, 'choices') or not response.choices:
        logger.warning("LLM response missing choices array, returning empty pillars.")
        return empty_response, {"prompt_tokens": 0, "completion_tokens": 0}

    try:
        raw_json = response.choices[0].message.content
    except (IndexError, AttributeError, TypeError) as e:
        logger.error("Error extracting message content: %s: %s", type(e).__name__, e, exc_info=True)
        return empty_response, {"prompt_tokens": 0, "completion_tokens": 0}

    if raw_json is None:
        logger.warning("LLM message content is None, returning empty pillars.")
        return empty_response, {"prompt_tokens": 0, "completion_tokens": 0}

    try:
        cleaned_json_str = clean_llm_json_string(raw_json)
        parsed = json.loads(cleaned_json_str)
        logger.debug("Parsed LLM JSON: %s", json.dumps(parsed, indent=2))
        validated_response = ExtractionResponse.model_validate(parsed)
        usage_stats = {
            "prompt_tokens": getattr(getattr(response, "usage", None), "prompt_tokens", 0),
            "completion_tokens": getattr(getattr(response, "usage", None), "completion_tokens", 0),
        }
        return validated_response.model_dump(), usage_stats
    except Exception as e:
        logger.error(
            "Gagal parse respons LLM menjadi JSON: %s. Raw response (200 chars): %s",
            e,
            raw_json[:200] if raw_json else "None",
            exc_info=True,
        )
        return empty_response, {"prompt_tokens": 0, "completion_tokens": 0}
