import sys; sys.path.append("..")
import os
import re
import ast
import json
import datetime as _dt
import traceback
from typing import Any, Dict, List, Tuple
from pypdf import PdfReader
from pypdf.errors import PdfReadError
from manager.OpenAiManager import OpenAiManager
from manager.MysqlManager import MysqlManager
from models._system import Base
from models.base_base import BaseBase
from models.deal_deal import DealDeal
from models.option_opti import OptionOpti

# This variable holds the complete multi-line prompt that includes all extraction rules
promptTemplate: str = (
    """
Du bist eine hochpräzise API zur Extraktion spezifischer Mobilfunktarif-Merkmale aus Dokumentenpaaren. Deine Eingabe besteht immer aus dem extrahierten Text von zwei PDF-Dateien: einem **Produktdetailblatt/Flyer** und einem **Produktinformationsblatt (PIB)**, die gemeinsam *einen* spezifischen Tarif beschreiben.

Deine Aufgabe ist es, beide Dokumente sorgfältig und vergleichend zu analysieren, um die unten definierten **relevanten Tarifbestandteile** mit höchstmöglicher Genauigkeit zu extrahieren. Das Ergebnis **muss ausschließlich** ein einzelnes JSON-Objekt sein, das exakt die vorgegebenen Schlüsselnamen und das flache Key-Value-Format verwendet. Ignoriere irrelevante Informationen wie Anbieteradressen, AGB-Verweise oder allgemeine Marketingtexte.

**Extraktionsanweisungen und Felddefinitionen:**

1.  **`tariff_name` (String):** Extrahiere den vollständigen, exakten Namen des Tarifs.
2.  **`marketing_start_date` (String oder null):** Extrahiere das Vermarktungsdatum (aus PIB). Formatiere als `JJJJ-MM-TT` oder `null`.
3.  **`network_operator` (String oder null):** Identifiziere den Netzbetreiber (z.B. "Telekom", "Vodafone", "O2").
4.  **`network_technology` (String oder null):** Identifiziere die primär beworbene/höchste Netztechnologie (z.B. "5G", "LTE").
5.  **`is_data_only_tariff` (Boolean):** Erfasse, ob es ein reiner Datentarif ist. Kriterien: Telefonie nicht als Flat inkludiert oder explizit als "nicht möglich". Prüfe Inklusivleistungen und Preise sorgfältig.
6.  **`inclusive_internet_flat` (Boolean oder null):** Prüfe auf explizite Nennung einer Internet-Flat.
7.  **`inclusive_telephony_flat` (Boolean oder null):** Prüfe auf explizite Nennung einer Telefonie-Flat oder Preis pro Minute von 0,00 €.
8.  **`telephony_price_per_minute_eur_brutto` (Number oder null):** Wenn `inclusive_telephony_flat` false und Telefonie möglich: extrahiere Brutto-Preis pro Minute. Sonst 0.0 (Flat) oder null.
9.  **`telephony_price_per_minute_eur_netto` (Number oder null):** Bei positivem Brutto-Wert: Netto = Brutto / 1.19, gerundet auf 4 Nachkommastellen. Sonst 0.0 oder null.
10. **`inclusive_sms_flat` (Boolean oder null):** Prüfe auf SMS-Flat oder Preis pro SMS 0,00 €.
11. **`sms_price_per_unit_eur_brutto` (Number oder null):** Wenn keine SMS-Flat: extrahiere Brutto-Preis pro SMS.
12. **`sms_price_per_unit_eur_netto` (Number oder null):** Bei positivem Brutto-Wert: Netto = Brutto / 1.19, gerundet auf 4 Nachkommastellen.
13. **`inclusive_volte_wlan_call` (Boolean oder null):** Prüfe auf VoLTE/WLAN-Call. Setze false bei reinen Datentarifen.
14. **`data_volume_gb` (Number oder null):** Extrahiere das Datenvolumen in GB.
15. **`data_download_max_mbps` (Number oder null):** Maximaler Download in Mbit/s.
16. **`data_upload_max_mbps` (Number oder null):** Maximaler Upload in Mbit/s.
17. **`data_download_throttled_kbps` (Number oder null):** Gedrosselter Download in kbit/s.
18. **`data_upload_throttled_kbps` (Number oder null):** Gedrosselter Upload in kbit/s.
19. **`data_billing_increment_kb` (Number oder null):** Datentaktung in KB.
20. **`telephony_billing_increment_seconds` (String oder null):** Telefontaktung, z.B. "60/60".
21. **`pricing_connection_fee_eur_brutto` (Number oder null):** Einmaliger Anschlusspreis (brutto).
22. **`pricing_connection_fee_eur_netto` (Number oder null):** Netto-Anschlusspreis = Brutto / 1.19, gerundet auf 4 Nachkommastellen.
23. **`pricing_monthly_initial_eur_brutto` (Number oder null):** Monatlicher Bruttopreis in der Anfangsphase.
24. **`pricing_monthly_initial_eur_netto` (Number oder null):** Netto-Betrag derselben Phase; falls nicht angegeben selbst berechnen.
25. **`pricing_monthly_after_period_eur_brutto` (Number oder null):** Bruttopreis nach Aktionsphase.
26. **`pricing_monthly_after_period_eur_netto` (Number oder null):** Netto-Preis nach Aktionsphase = Brutto / 1.19.
27. **`contract_min_duration_months` (Number oder null):** Mindest-Vertragslaufzeit in Monaten.
28. **`contract_cancellation_notice_period_months` (Number oder null):** Kündigungsfrist in Monaten.

Werte, die nicht zuverlässig extrahiert werden können, auf `null` setzen.
Numerische Werte als Number belassen, Netto stets auf 4 Nachkommastellen runden.

**WICHTIG:** Antworte ausschließlich mit dem JSON-Objekt. Schreibe weder Markdown noch zusätzliche Erklärungen.
#########
"""
)

# This variable holds the list of keys that must be present in the GPT response
expectedKeys: List[str] = [
    "tariff_name",
    "marketing_start_date",
    "network_operator",
    "network_technology",
    "is_data_only_tariff",
    "inclusive_internet_flat",
    "inclusive_telephony_flat",
    "telephony_price_per_minute_eur_brutto",
    "telephony_price_per_minute_eur_netto",
    "inclusive_sms_flat",
    "sms_price_per_unit_eur_brutto",
    "sms_price_per_unit_eur_netto",
    "inclusive_volte_wlan_call",
    "data_volume_gb",
    "data_download_max_mbps",
    "data_upload_max_mbps",
    "data_download_throttled_kbps",
    "data_upload_throttled_kbps",
    "data_billing_increment_kb",
    "telephony_billing_increment_seconds",
    "pricing_connection_fee_eur_brutto",
    "pricing_connection_fee_eur_netto",
    "pricing_monthly_initial_eur_brutto",
    "pricing_monthly_initial_eur_netto",
    "pricing_monthly_after_period_eur_brutto",
    "pricing_monthly_after_period_eur_netto",
    "contract_min_duration_months",
    "contract_cancellation_notice_period_months",
]


# This function extracts the complete text from a PDF file and returns it as a string
def extractTextFromPdf(pdfPath: str) -> str | None:

    # This condition checks if the file does not exist, returning None if missing
    if not os.path.exists(pdfPath):
        print(f"INFO: Datei nicht gefunden: {os.path.basename(pdfPath)}")
        return None

    # This variable holds all page texts extracted from the PDF
    pageTexts: List[str] = []

    # This block attempts to open and read the PDF file
    try:
        with open(pdfPath, "rb") as fileHandle:

            # This line initializes the PDF reader to parse the file
            reader = PdfReader(fileHandle)

            # This loop iterates through the pages of the PDF to extract text
            for page in reader.pages:
                txt = page.extract_text()

                # This condition checks if text was actually extracted from the page
                if txt:
                    pageTexts.append(txt)

        # This condition checks if no text was extracted from the PDF
        if not pageTexts:
            print(f"INFO: Kein Text in {os.path.basename(pdfPath)}")
            return None

        # This line returns the joined text from all pages
        return "\n".join(pageTexts).strip()

    # This block handles a specific PDF reading error from pypdf
    except PdfReadError as exc:
        print(f"WARNUNG: pypdf-Lesefehler bei '{os.path.basename(pdfPath)}': {exc}")
        return None

    # This block handles any other unexpected errors
    except Exception:
        print(f"FEHLER: Unerwarteter Fehler bei '{os.path.basename(pdfPath)}':")
        traceback.print_exc(limit=1)
        return None


# This function removes incoming code fences to provide pure JSON
def stripCodeFence(raw: str) -> str:

    # This condition checks if the string starts with triple backticks to remove them
    if raw.strip().startswith("```"):
        return re.sub(r"```[\w]*", "", raw).strip()

    # This line returns the raw string if no code fences were found
    return raw


# This function removes unnecessary commas before closing brackets in JSON strings
def removeTrailingCommas(js: str) -> str:

    # This line substitutes commas that appear right before closing braces or brackets
    return re.sub(r",(\s*[}\]])", r"\1", js)


# This function tries to parse a string as JSON using multiple repair approaches
def loadJsonSafe(raw: str) -> Dict[str, Any] | None:

    # This variable holds the cleaned string without carriage returns
    cleaned = stripCodeFence(raw).replace("\r", "")

    # This loop attempts different variants of the cleaned string for JSON decoding
    for variant in (cleaned, removeTrailingCommas(cleaned)):
        try:
            return json.loads(variant)
        except json.JSONDecodeError:
            pass

    # This block tries a relaxed approach using Python literal evaluation after replacements
    try:
        relaxed = cleaned.replace("null", "None").replace("true", "True").replace("false", "False")
        return ast.literal_eval(relaxed)
    except Exception:
        return None


# This function validates the GPT response to ensure all required keys exist
def validateResponse(raw: str) -> Tuple[bool, Dict[str, Any] | None]:

    # This line loads the raw string into a Python dictionary
    data = loadJsonSafe(raw)

    # This condition checks if the result is invalid or not a dictionary
    if data is None or not isinstance(data, dict):
        print("VALIDATION: Antwort ist kein gültiges JSON-Objekt.")
        return False, None

    # This line finds any missing keys from the expected list
    missing = [k for k in expectedKeys if k not in data]

    # This condition checks if any keys are missing from the response
    if missing:
        print(f"VALIDATION: Fehlende Schlüssel: {', '.join(missing)}")
        return False, None

    # This line returns True if all checks passed
    return True, data


# This block runs if the script is executed directly
if __name__ == "__main__":

    # This variable holds the directory where PDF files are stored
    cacheDir = "../cache"

    # This line outputs information about the PDF file search location
    print(f"INFO: Suche nach PDF-Dateien in '{cacheDir}' …")

    # This condition checks if the cache directory exists
    if not os.path.isdir(cacheDir):
        print("FEHLER: Cache-Verzeichnis nicht gefunden.")
        sys.exit(1)

    # This list comprehension gathers all files with .pdf extension
    pdfFiles = [f for f in os.listdir(cacheDir) if f.lower().endswith(".pdf")]

    # This variable holds a set of tariff IDs derived from the PDF filenames
    tariffIds: set[str] = set()

    # This loop iterates over all found PDF files to identify possible tariff pairs
    for f in pdfFiles:
        stem = f[:-4].lower()

        # This condition checks if the filename ends with _flyer
        if stem.endswith("_flyer"):
            tariffIds.add(stem[:-6])

        # This condition checks if the filename ends with _pib
        elif stem.endswith("_pib"):
            tariffIds.add(stem[:-4])

    # This condition checks if no matching PDF pairs were found
    if not tariffIds:
        print("INFO: Keine passenden PDF-Paare gefunden.")
        sys.exit(0)

    # This line creates an instance of the OpenAiManager
    gptManager = OpenAiManager()

    # This line creates a database session using MysqlManager
    dbSession  = MysqlManager().getSession()

    # This loop processes each identified tariff ID
    for tariffId in sorted(tariffIds):
        print(f"\n--- Verarbeitung ID: {tariffId} ---")

        # This query retrieves all BaseBase records matching the tariff ID
        baseRecords = dbSession.query(BaseBase).filter_by(providercode_base=tariffId).all()

        # This condition checks if no BaseBase records were found
        if not baseRecords:
            print("WARNUNG: Kein BaseBase-Datensatz gefunden – übersprungen.")
            continue

        # This condition checks if details_base is already filled for all rows
        if all(br.details_base for br in baseRecords):
            print("INFO: details_base bereits für alle Zeilen gefüllt – übersprungen.")
            continue

        # This line constructs the paths for the flyer and PIB PDF files
        flyerPath = os.path.join(cacheDir, f"{tariffId}_flyer.pdf")
        pibPath   = os.path.join(cacheDir, f"{tariffId}_pib.pdf")

        # This line extracts text from the flyer PDF
        flyerText = extractTextFromPdf(flyerPath)

        # This line extracts text from the PIB PDF
        pibText   = extractTextFromPdf(pibPath)

        # This condition checks if any text is missing
        if not flyerText or not pibText:
            print("INFO: Fehlende Texte – übersprungen.")
            continue

        # This variable holds the full prompt to be sent to GPT by combining the templates and extracted text
        fullPrompt = promptTemplate + "# Flyer-Text:\n" + flyerText + "\n" + "# PIB-Text:\n" + pibText

        # This variable will store the validated data after GPT response
        validatedData: Dict[str, Any] | None = None

        # This loop tries up to three attempts to get a valid response from GPT
        for attempt in range(1, 4):
            print(f"INFO: GPT-Abfrage Versuch {attempt}/3 …")

            # This block attempts to send the prompt to GPT
            try:
                raw = gptManager.chat(fullPrompt, model="gpt-4.1")

            # This block catches any exceptions during the GPT request
            except Exception as exc:
                print(f"FEHLER: GPT-Abfrage fehlgeschlagen: {exc}")
                raw = ""

            # This line validates the GPT response
            ok, parsed = validateResponse(raw)

            # This condition checks if the response was valid
            if ok:
                validatedData = parsed
                break

            # This line indicates the response was invalid, prompting the next attempt
            print("WARNUNG: Antwort ungültig – nächster Versuch …")

        # This condition checks if no valid response was obtained after three attempts
        if not validatedData:
            print("FEHLER: Drei ungültige Antworten – übersprungen.")
            continue

        # This loop updates the details_base for each relevant BaseBase record
        for br in baseRecords:

            # This condition checks if details_base is None to set the validated data
            if br.details_base is None:
                br.details_base = validatedData
                br.updated_base = _dt.datetime.now()

        # This line commits the changes to the database
        dbSession.commit()
        print(f"INFO: JSON in {len([b for b in baseRecords if b.details_base])} Zeile(n) gespeichert.")

    # This line closes the database session after processing
    dbSession.close()
    print("INFO: Verarbeitung abgeschlossen.")
