# Fundemental imports
#####################################################################
from django.utils import timezone
from django.conf import settings
from django.apps import apps
from django.core.exceptions import FieldDoesNotExist
import difflib
import os
import re
import json
import requests
import logging

logger = logging.getLogger(__name__)

def extract_metadata_with_llm(model_name, ocr_text):
    url = settings.LLM_API_URL

    system_prompt = """
You are a strict JSON generator.

Analyze Arabic OCR text and extract structured metadata for one document.

Respond with EXACTLY one JSON object.
No explanations, no comments, no markdown, no text outside the JSON.

Current document type: {model_name}

Output format:
{
  "title": "",
  "body": "",

  "document_number": "",
  "og_document_number": "",

  "document_date": "",
  "og_document_date": "",

  "source": "",
  "destination": ""
}

Rules:

1. Document types:
- decree: formal decree, numbered articles, legal references, decree number, subject, footer date.
- incoming: original letter + received stamp (usually 2 dates, 2 numbers), source and destination, letter body.
- outgoing / internal / other: usually one date and one number. Source is a ministry department, destination is another entity.

2. Dates:
- One date → document_date
- Two dates → older (letter header) = og_document_date, newer (stamp) = document_date

3. Numbers:
- document_number:
  - outgoing/internal/other → official document number
  - incoming → received stamp number
- og_document_number:
  - incoming only → original document number
- Number fields MUST always contain digits only.
- DO NOT use Eastern Arabic numerals (like ٠١٢٣) or any other numeral system.
- DO NOT include letters. If OCR included letters with the numbers, REMOVE the letters and keep the numbers.

4. Source & Destination:
- Extract real entity names only. Clean OCR noise but do NOT invent names.
- Incoming source: organization in the header.
- Outgoing source: department under the signer’s name.
- Destination often appears near the start, commonly after the words sir or sirs and a "/".

5. Title:
- Extract explicit subject/title if present (will always be in the beggining if present).
- Otherwise infer a short, accurate phrase from the document text.

6. Body:
- Return the full cleaned document text with OCR noise removed.

Always fill all fields.
If unknown or not applicable, use an empty string "".

Return ONLY the JSON object.
"""

    user_prompt = f"""
OCR TEXT:
\"\"\"
{ocr_text}
\"\"\"
"""

    payload = {
        "model": "/models/" + settings.LLM_MODEL,
        "messages": [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt}
        ],
        "temperature": 0.5,
        "max_tokens": 1024,
        "response_format": {"type": "json_object"},
        "top_p": 0.8,
        "min_p": 0,
        "top_k": 20,
        "presence_penalty": 0.5,
    }

    # ------------------------------------------------
    # Call the LLM API
    # ------------------------------------------------
    try:
        response = requests.post(url, json=payload, timeout=(10, 1200))
        response.raise_for_status()
    except requests.exceptions.RequestException:
        raise

    data = response.json()

    if "choices" not in data or not data["choices"]:
        raise ValueError("LLM API returned no choices.")

    raw_content = data["choices"][0]["message"]["content"]

    # ------------------------------------------------
    # Extract JSON safely
    # ------------------------------------------------
    metadata = extract_json_from_text(raw_content)



    # Ensure required keys exist
    required = ["source", "destination", "document_number", "document_date", "title", "body"]
    for key in required:
        metadata.setdefault(key, "")

    # Sanitize number fields (strict integer enforcement)
    # Even if the AI hallucinates "Ref: 123", we want "123".
    # If "No number", we want "".
    metadata["document_number"] = clean_number_field(metadata["document_number"])
    if "og_document_number" in metadata:
         metadata["og_document_number"] = clean_number_field(metadata["og_document_number"])

    return metadata

def extract_json_from_text(text):
    """
    Safely extracts the first JSON object from LLM output.
    Handles cases like:
    - ```json {...} ```
    - text before/after JSON
    - invalid trailing content
    """
    match = re.search(r"\{.*\}", text, flags=re.DOTALL)
    if not match:
        raise ValueError("No JSON object found in LLM response.")

    json_str = match.group()

    try:
        return json.loads(json_str)
    except json.JSONDecodeError as exc:
        raise ValueError(f"Malformed JSON: {exc}")

def validate_date(date_str):
    """Return a cleaned YYYY-MM-DD or None."""
    if not date_str:
        return None
    try:
        timezone.datetime.strptime(date_str, "%Y-%m-%d")
        return date_str
    except Exception:
        return None

def _find_best_match(input_text, model_name, field_name, cutoff=60):

    if not input_text:
        return None

    text = str(input_text).strip()
    
    app_label = getattr(settings, "OCR_APP_LABEL", "documents")

    try:
        model = apps.get_model(app_label, model_name)
    except LookupError:
        logger.error(f"Model {model_name} not found")
        return None

    try:
        field = model._meta.get_field(field_name)
    except FieldDoesNotExist:
        logger.error(f"Field {field_name} not found in model {model_name}")
        return None

    related_model = field.related_model
    possible_fields = ['source', 'destination']
    
    for field in possible_fields:
        if hasattr(related_model, field):
            # Get all values for this field
            choices = list(related_model.objects.values_list('id', field))
            
            if choices:
                # Extract just the text values for matching
                values = [value for _, value in choices]
                
                # Use difflib to find closest match
                matches = difflib.get_close_matches(text, values, n=1, cutoff=cutoff)
                
                if matches:
                    best_match = matches[0]
                    # Find and return the corresponding ID
                    for id, value in choices:
                        if value == best_match:
                            return id
    
    return None

def find_closest_source(raw_text, model_name):
    return _find_best_match(raw_text, model_name, "source")


def find_closest_destination(raw_text, model_name):
    return _find_best_match(raw_text, model_name, "destination")

def clean_number_field(value):
    """
    Removes all non-digit characters from the string.
    Also handles common OCR substitutions (letters that look like numbers).
    Returns a string of digits, or empty string if no digits found.
    """
    if not value:
        return ""
    
    val_str = str(value)
    
    # Common OCR substitutions map
    substitutions = {
        's': '5', 'S': '5',
        'z': '7', 'Z': '7',
        'b': '8', 'B': '8',
        'l': '1', 'I': '1', 'i': '1',
        'o': '0', 'O': '0',
    }
    
    # Apply substitutions
    for char, digit in substitutions.items():
        val_str = val_str.replace(char, digit)
    
    # Filter only digits
    cleaned = "".join(filter(str.isdigit, val_str))
    
    return cleaned
