"""
LLM OCR Dialog for extracting bearing-distance data from TCT images.
Uses LLM Vision APIs (Gemini, OpenAI, etc.) for OCR and structured parsing.
"""

from qgis.PyQt import uic
from qgis.PyQt.QtWidgets import (
    QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
    QFileDialog, QMessageBox, QTextEdit, QLineEdit, QApplication,
    QProgressDialog, QComboBox, QWidget
)
from qgis.PyQt.QtGui import QPixmap, QImage, QDesktopServices
from qgis.PyQt.QtCore import Qt, QBuffer, QIODevice, QSettings, QUrl
import os
import re
import json
import base64
import urllib.request
import urllib.error
import time

try:
    from .tie_point_selector_dialog import _TIEPOINT_DF  # Import tie point data
except ImportError:
    _TIEPOINT_DF = None

# Load the UI file
FORM_CLASS, _ = uic.loadUiType(os.path.join(
    os.path.dirname(os.path.dirname(__file__)), 'forms', 'TCT_OCR_Dialog.ui'))

# Modern Stylesheet (Consistent with Tie Point Selector)
MODERN_STYLESHEET = """
/* Main Dialog */
QDialog {
    background-color: #f1fcfb;
    color: #06292d;
    font-family: 'Segoe UI', Arial, sans-serif;
    font-size: 9pt;
}make 

/* Labels */
QLabel {
    color: #06292d;
    font-weight: bold;
}

/* Input Fields */
QLineEdit {
    background-color: #ffffff;
    border: 2px solid #16474b;
    border-radius: 6px;
    padding: 6px;
    color: #06292d;
    selection-background-color: #14575b;
    selection-color: #f1fcfb;
}

QLineEdit:focus {
    border: 2px solid #14575b;
    background-color: #f8ffff;
}

QLineEdit:hover {
    border: 2px solid #1a6b70;
}

/* Text Edit (LLM Response Box) */
QTextEdit {
    background-color: #ffffff;
    border: 2px solid #16474b;
    border-radius: 6px;
    padding: 6px;
    color: #06292d;
    font-family: 'Consolas', 'Monaco', monospace;
    font-size: 9pt;
}

/* ComboBox */
QComboBox {
    background-color: #ffffff;
    border: 2px solid #16474b;
    border-radius: 6px;
    padding: 4px 10px;
    color: #06292d;
    selection-background-color: #14575b;
    selection-color: #f1fcfb;
}

QComboBox:hover {
    border: 2px solid #1a6b70;
}

QComboBox:on { /* shift the text when the popup opens */
    padding-top: 3px;
    padding-left: 4px;
}

QComboBox::drop-down {
    subcontrol-origin: padding;
    subcontrol-position: top right;
    width: 24px;
    border-left-width: 1px;
    border-left-color: #16474b;
    border-left-style: solid;
    border-top-right-radius: 6px;
    border-bottom-right-radius: 6px;
    background-color: #e0f5f3; /* Light teal accent for the button */
}

QComboBox::drop-down:hover {
    background-color: #d0f0ee;
}

QComboBox::down-arrow {
    image: none; /* No image, we can try a simple shape if supported or default */
    width: 0; 
    height: 0; 
    border-left: 5px solid transparent;
    border-right: 5px solid transparent;
    border-top: 5px solid #14575b;
    margin: 0px 6px 0px 0px;
}

/* Popup List */
QComboBox QAbstractItemView {
    border: 2px solid #16474b;
    border-radius: 4px;
    background-color: #ffffff;
    color: #06292d;
    selection-background-color: #1a6b70;
    selection-color: #ffffff;
    outline: none;
}

/* Buttons */
QPushButton {
    background-color: #14575b;
    border: none;
    border-radius: 6px;
    padding: 8px 16px;
    color: #f1fcfb;
    font-weight: bold;
    font-size: 9pt;
}

QPushButton:hover {
    background-color: #1a6b70;
}

QPushButton:pressed {
    background-color: #0f4548;
}

QPushButton:disabled {
    background-color: #a8d4d6;
    color: #5a8a8d;
}
"""


class ApiKeyHelpDialog(QDialog):
    """Dialog showing instructions to get an API key based on provider."""
    
    # Provider configuration for help dialog
    PROVIDERS = {
        "Gemini": {
            "title": "Get Your Free Gemini API Key",
            "url": "https://aistudio.google.com/app/apikey",
            "instructions": (
                "<ol style='margin-left: 15px;'>"
                "<li style='margin-bottom: 8px;'>Click the <b>'Open Google AI Studio'</b> button below.</li>"
                "<li style='margin-bottom: 8px;'>Sign in with your Google account.</li>"
                "<li style='margin-bottom: 8px;'>Click <b>'Get API key'</b> on the top left of the page.</li>"
                "<li style='margin-bottom: 8px;'>Click <b>'Create API key'</b>.</li>"
                "<li style='margin-bottom: 8px;'>Copy the generated key string.</li>"
                "</ol>"
            )
        },
        "OpenAI": {
            "title": "Get Your OpenAI API Key",
            "url": "https://platform.openai.com/api-keys",
            "instructions": (
                "<ol style='margin-left: 15px;'>"
                "<li style='margin-bottom: 8px;'>Click the <b>'Open OpenAI Platform'</b> button below.</li>"
                "<li style='margin-bottom: 8px;'>Sign in or create an account.</li>"
                "<li style='margin-bottom: 8px;'>Click <b>'Create new secret key'</b>.</li>"
                "<li style='margin-bottom: 8px;'>Name your key and click 'Create secret key'.</li>"
                "<li style='margin-bottom: 8px;'>Copy the key immediately (you won't see it again).</li>"
                "</ol>"
            )
        },
        "DeepSeek": {
            "title": "Get Your DeepSeek API Key",
            "url": "https://platform.deepseek.com/api_keys",
            "instructions": (
                "<ol style='margin-left: 15px;'>"
                "<li style='margin-bottom: 8px;'>Click the <b>'Open DeepSeek Platform'</b> button below.</li>"
                "<li style='margin-bottom: 8px;'>Sign in or create an account.</li>"
                "<li style='margin-bottom: 8px;'>Navigate to the <b>API Keys</b> section.</li>"
                "<li style='margin-bottom: 8px;'>Create a new API key.</li>"
                "</ol>"
            )
        },
        "OpenRouter": {
            "title": "Get Your OpenRouter API Key",
            "url": "https://openrouter.ai/settings/keys",
            "instructions": (
                "<ol style='margin-left: 15px;'>"
                "<li style='margin-bottom: 8px;'>Click the <b>'Open OpenRouter Settings'</b> button below.</li>"
                "<li style='margin-bottom: 8px;'>Sign in or create an account.</li>"
                "<li style='margin-bottom: 8px;'>Click <b>'Create Key'</b>.</li>"
                "<li style='margin-bottom: 8px;'>Name your key and set a credit limit (optional).</li>"
                "</ol>"
            )
        },
        "Qwen": {
            "title": "Get Your Qwen (Alibaba) API Key",
            "url": "https://dashscope.console.aliyun.com/apiKey",
            "instructions": (
                "<ol style='margin-left: 15px;'>"
                "<li style='margin-bottom: 8px;'>Click the <b>'Open DashScope Console'</b> button below.</li>"
                "<li style='margin-bottom: 8px;'>Login to Alibaba Cloud (requires account).</li>"
                "<li style='margin-bottom: 8px;'>Navigate to <b>API-KEY Management</b>.</li>"
                "<li style='margin-bottom: 8px;'>Create and copy your API key.</li>"
                "</ol>"
            )
        }
    }

    def __init__(self, parent=None, provider="Gemini"):
        super().__init__(parent)
        self.provider = provider if provider in self.PROVIDERS else "Gemini"
        config = self.PROVIDERS[self.provider]
        
        self.target_url = config['url']
        
        self.setWindowTitle(f"How to Get a {self.provider} API Key")
        self.setMinimumWidth(450)
        self.layout = QVBoxLayout(self)
        self.layout.setSpacing(15)

        # Instructions
        html_content = (
            f"<h3>{config['title']}</h3>"
            f"<p>To use the OCR feature with {self.provider}, you need a valid API key.</p>"
            f"{config['instructions']}"
            "<li style='margin-bottom: 8px; margin-left: 15px;'>Return to this plugin and paste it into the <b>'API Key'</b> field.</li>"
            "<br>"
        )

        label = QLabel(html_content)
        label.setWordWrap(True)
        label.setTextFormat(Qt.RichText)
        label.setOpenExternalLinks(True) 
        self.layout.addWidget(label)

        # Action Buttons
        btn_box = QHBoxLayout()
        
        # Styled 'Open' button
        self.open_url_btn = QPushButton(f"Open {self.provider} Console")
        self.open_url_btn.setCursor(Qt.PointingHandCursor)
        self.open_url_btn.setStyleSheet("font-weight: bold; padding: 6px;")
        self.open_url_btn.clicked.connect(self.open_url)
        
        self.close_btn = QPushButton("Close")
        self.close_btn.clicked.connect(self.accept)

        btn_box.addWidget(self.open_url_btn)
        btn_box.addStretch()
        btn_box.addWidget(self.close_btn)
        
        self.layout.addLayout(btn_box)

    def open_url(self):
        """Open the API key URL in the default browser."""
        url = QUrl(self.target_url)
        QDesktopServices.openUrl(url)


class LLMOCRDialog(QDialog, FORM_CLASS):
    """Dialog for extracting bearing-distance data from TCT images using various LLM APIs."""

    # Provider Configuration
    PROVIDER_CONFIG = {
        "Gemini": {
            "api_url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent",
            "model": "gemini-3-flash-preview",  # Free tier with vision support
            "style": "gemini"
        },
        "OpenAI": {
            "api_url": "https://api.openai.com/v1/chat/completions",
            "model": "gpt-4o-mini", # Cost effective
            "style": "openai"
        },
        "DeepSeek": {
            "api_url": "https://api.deepseek.com/chat/completions", 
            "model": "deepseek-chat", # Only supported endpoint
            "style": "openai"
        },
        "OpenRouter": {
            "api_url": "https://openrouter.ai/api/v1/chat/completions",
            "model": "google/gemini-2.0-flash-lite-preview-02-05:free", # Free default
            "style": "openai"
        },
        "Qwen": {
            "api_url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
            "model": "qwen-vl-plus", # VL model required for vision
            "style": "openai"
        }
    }

    # Maximum image dimension to reduce token usage (smaller = fewer tokens)
    # Increased to 2048 to ensure text legibility for full pages
    MAX_IMAGE_DIMENSION = 2048

    def __init__(self, parent=None):
        """Constructor."""
        super(LLMOCRDialog, self).__init__(parent)
        self.setupUi(self)
        
        # Apply modern styling
        self.setStyleSheet(MODERN_STYLESHEET)

        # Store the parent dialog reference
        self.parent_dialog = parent

        # Initialize image data
        self.current_image_base64 = None
        self.current_image_mime = None
        self.current_qimage = None

        # Load settings
        self.settings = QSettings('TitlePlotterPH', 'GeminiOCR')
        
        # Load saved provider (default to Gemini)
        self.current_provider = self.settings.value('provider', 'Gemini')
        if self.current_provider not in self.PROVIDER_CONFIG:
            self.current_provider = 'Gemini'

        # Connect signals
        self.uploadButton.clicked.connect(self.upload_image)
        self.pasteButton.clicked.connect(self.paste_from_clipboard)
        self.doneButton.clicked.connect(self.accept_and_apply)
        self.cancelButton.clicked.connect(self.reject)

        # Add API key input field
        self.setup_api_key_input()


        # --- Middle Section (Image + Text) ---
        mid_layout = QHBoxLayout()

        # Create a container for the image preview and Parse button
        image_container = QWidget()
        image_layout = QVBoxLayout(image_container)
        image_layout.setContentsMargins(0, 0, 0, 0)
        image_layout.setSpacing(5)

        # Move imagePreview from vertical layout to this new container
        self.verticalLayout.removeWidget(self.imagePreview)
        image_layout.addWidget(self.imagePreview, 1)

        # Add Parse button at the bottom right of image container
        parse_btn_layout = QHBoxLayout()
        parse_btn_layout.addStretch()
        self.parseButton = QPushButton("Parse")
        self.parseButton.setToolTip("Parse the image using AI to extract bearing data")
        self.parseButton.setFixedWidth(80)
        self.parseButton.clicked.connect(self.process_image)
        parse_btn_layout.addWidget(self.parseButton)
        image_layout.addLayout(parse_btn_layout)

        mid_layout.addWidget(image_container, 2)  # Wider column

        # Create a container for the LLM response text
        response_container = QWidget()
        response_layout = QVBoxLayout(response_container)
        response_layout.setContentsMargins(0, 0, 0, 0)
        response_layout.setSpacing(5)

        # Add QTextEdit for LLM response
        self.rawOcrTextEdit = QTextEdit(self)
        self.rawOcrTextEdit.setReadOnly(False)  # Allow editing for manual corrections
        self.rawOcrTextEdit.setPlaceholderText("Extracted text will appear here. You may edit it manually.")
        response_layout.addWidget(self.rawOcrTextEdit, 1)

        mid_layout.addWidget(response_container, 1)  # Narrower column

        # Insert the middle layout after the API key section (index 1)
        # Note: Index 0 is API key (inserted), Index 1 was Image (removed), Index 2 is buttons
        # So inserting at 1 puts it between API key and buttons
        self.verticalLayout.insertLayout(1, mid_layout)

        # Update window title
        self.update_window_title()

    def update_window_title(self):
        self.setWindowTitle(f"TCT Image OCR ({self.current_provider})")

    def setup_api_key_input(self):
        """Add provider selection and API key input field."""
        # Main layout for top section
        top_layout = QVBoxLayout()
        top_layout.setSpacing(10)

        # --- Provider Selection ---
        provider_layout = QHBoxLayout()
        provider_label = QLabel("AI Provider:")
        provider_label.setFixedWidth(80)
        
        self.providerCombo = QComboBox()
        self.providerCombo.addItems(self.PROVIDER_CONFIG.keys())
        self.providerCombo.setCurrentText(self.current_provider)
        self.providerCombo.currentIndexChanged.connect(self.on_provider_changed)
        
        provider_layout.addWidget(provider_label)
        provider_layout.addWidget(self.providerCombo)
        top_layout.addLayout(provider_layout)

        # --- API Key Input ---
        api_key_layout = QHBoxLayout()

        api_key_label = QLabel("API Key:")
        api_key_label.setFixedWidth(80)
        
        self.apiKeyInput = QLineEdit()
        self.apiKeyInput.setEchoMode(QLineEdit.Password)
        self.apiKeyInput.setPlaceholderText(f"Enter your {self.current_provider} API key")

        # Load saved API key for current provider
        saved_key = self.settings.value(f'api_key_{self.current_provider}', '')
        if saved_key:
            self.apiKeyInput.setText(saved_key)

        # Add show/hide toggle button
        self.toggleKeyBtn = QPushButton("Show")
        self.toggleKeyBtn.setFixedWidth(70)
        self.toggleKeyBtn.clicked.connect(self.toggle_api_key_visibility)

        # Add 'Get Key' help button
        self.helpBtn = QPushButton("Get API Key")
        self.helpBtn.setToolTip("Click for instructions on how to get a free API key")
        self.helpBtn.clicked.connect(self.show_api_help)
        
        # Style the help button to look like a link or action
        self.helpBtn.setStyleSheet("color: #007bff; font-weight: bold; text-decoration: underline; background: transparent; border: none;")
        self.helpBtn.setCursor(Qt.PointingHandCursor)


        api_key_layout.addWidget(api_key_label)
        api_key_layout.addWidget(self.apiKeyInput)
        api_key_layout.addWidget(self.toggleKeyBtn)
        api_key_layout.addWidget(self.helpBtn)

        top_layout.addLayout(api_key_layout)

        # Insert at the top of the dialog
        self.verticalLayout.insertLayout(0, top_layout)

    def on_provider_changed(self):
        """Handle provider change."""
        new_provider = self.providerCombo.currentText()
        
        # Save key for previous provider
        old_key = self.apiKeyInput.text().strip()
        if old_key:
           self.settings.setValue(f'api_key_{self.current_provider}', old_key)
        
        # Update current provider
        self.current_provider = new_provider
        self.settings.setValue('provider', self.current_provider)
        
        # Update UI text
        self.apiKeyInput.setPlaceholderText(f"Enter your {self.current_provider} API key")
        self.update_window_title()
        
        # Load key for new provider
        saved_key = self.settings.value(f'api_key_{self.current_provider}', '')
        self.apiKeyInput.setText(saved_key)

    def toggle_api_key_visibility(self):
        """Toggle API key visibility."""
        if self.apiKeyInput.echoMode() == QLineEdit.Password:
            self.apiKeyInput.setEchoMode(QLineEdit.Normal)
            self.toggleKeyBtn.setText("Hide")
        else:
            self.apiKeyInput.setEchoMode(QLineEdit.Password)
            self.toggleKeyBtn.setText("Show")

    def show_api_help(self):
        """Show the dialog with instructions on getting an API key."""
        dialog = ApiKeyHelpDialog(self, provider=self.current_provider)
        dialog.exec_()

    def upload_image(self):
        """Handle image upload from file."""
        file_name, _ = QFileDialog.getOpenFileName(
            self,
            "Select TCT Image",
            "",
            "Image Files (*.png *.jpg *.jpeg *.bmp *.tif *.tiff *.webp)"
        )

        if file_name:
            self.load_image_from_file(file_name)

    def paste_from_clipboard(self):
        """Handle image paste from clipboard."""
        clipboard = QApplication.clipboard()
        image = clipboard.image()

        if not image.isNull():
            # Resize if too large to reduce token usage
            image = self.resize_image_if_needed(image)
            self.current_qimage = image
            # Convert QImage to base64 for LLM API
            self.current_image_base64, self.current_image_mime = self.qimage_to_base64(image)
            self.display_image(image)
        else:
            QMessageBox.warning(self, "Error", "No image found in clipboard.")

    def resize_image_if_needed(self, qimage):
        """Resize image if it exceeds maximum dimensions to reduce API token usage."""
        width = qimage.width()
        height = qimage.height()
        max_dim = self.MAX_IMAGE_DIMENSION

        if width > max_dim or height > max_dim:
            # Scale down while maintaining aspect ratio
            if width > height:
                new_width = max_dim
                new_height = int(height * max_dim / width)
            else:
                new_height = max_dim
                new_width = int(width * max_dim / height)

            print(f"Resizing image from {width}x{height} to {new_width}x{new_height}")
            return qimage.scaled(new_width, new_height, Qt.KeepAspectRatio, Qt.SmoothTransformation)

        return qimage

    def load_image_from_file(self, file_path):
        """Load, resize if needed, and display image from file."""
        try:
            # Load image using QImage
            qimage = QImage(file_path)
            if qimage.isNull():
                raise Exception("Failed to load image file")

            # Resize if too large to reduce token usage
            qimage = self.resize_image_if_needed(qimage)
            self.current_qimage = qimage

            # Convert to base64 for LLM API
            self.current_image_base64, self.current_image_mime = self.qimage_to_base64(qimage)

            # Display image
            self.display_image(qimage)

        except Exception as e:
            QMessageBox.critical(self, "Error", f"Failed to load image: {str(e)}")

    def qimage_to_base64(self, qimage):
        """Convert QImage to base64 string using JPEG for smaller file size."""
        buffer = QBuffer()
        buffer.open(QIODevice.WriteOnly)
        # Use JPEG with 70% quality - good enough for OCR, much smaller size
        qimage.save(buffer, "JPEG", 70)
        image_data = bytes(buffer.data())
        base64_str = base64.b64encode(image_data).decode('utf-8')
        print(f"Image encoded: {len(base64_str) // 1024} KB base64")
        return base64_str, 'image/jpeg'

    def display_image(self, image):
        """Display image in the preview area."""
        pixmap = QPixmap.fromImage(image)
        scaled_pixmap = pixmap.scaled(
            self.imagePreview.size(),
            Qt.KeepAspectRatio,
            Qt.SmoothTransformation
        )
        self.imagePreview.setPixmap(scaled_pixmap)

    def extract_json_from_response(self, response):
        """Extract JSON from LLM response, handling various formats.

        Handles:
        - Plain JSON
        - JSON wrapped in ```json ... ``` code blocks
        - JSON wrapped in ``` ... ``` code blocks
        - JSON with leading/trailing text
        """
        text = response.strip()

        # Method 1: Try to find JSON in markdown code blocks
        # Pattern: ```json\n{...}\n``` or ```\n{...}\n```
        json_block_pattern = re.compile(r'```(?:json)?\s*\n?([\s\S]*?)\n?```', re.IGNORECASE)
        match = json_block_pattern.search(text)
        if match:
            extracted = match.group(1).strip()
            # Verify it looks like JSON (starts with { and ends with })
            if extracted.startswith('{') and extracted.endswith('}'):
                return extracted

        # Method 2: Simple markdown removal
        clean = text
        if clean.startswith("```json"):
            clean = clean[7:]
        elif clean.startswith("```"):
            clean = clean[3:]
        if clean.endswith("```"):
            clean = clean[:-3]
        clean = clean.strip()
        if clean.startswith('{') and clean.endswith('}'):
            return clean

        # Method 3: Find JSON object using brace matching
        # Find the first { and match to its closing }
        start_idx = text.find('{')
        if start_idx != -1:
            brace_count = 0
            end_idx = start_idx
            for i, char in enumerate(text[start_idx:], start_idx):
                if char == '{':
                    brace_count += 1
                elif char == '}':
                    brace_count -= 1
                    if brace_count == 0:
                        end_idx = i
                        break
            if brace_count == 0:
                extracted = text[start_idx:end_idx + 1]
                return extracted

        # Method 4: Return stripped text as-is (let json.loads fail with good error)
        return text

    def process_image(self):
        """Process the image using LLM Vision API for OCR and structured parsing.

        Sends the image directly to the LLM which performs both OCR and
        structured data extraction in a single call.
        """
        if not self.current_image_base64:
            QMessageBox.warning(self, "Error", "No image to process.")
            return

        # Get API key
        api_key = self.apiKeyInput.text().strip()
        if not api_key:
            QMessageBox.warning(self, "Error", f"Please enter your {self.current_provider} API key.")
            return

        # Save API key
        self.settings.setValue(f'api_key_{self.current_provider}', api_key)

        # Clear previous text
        self.rawOcrTextEdit.clear()

        # Show progress
        progress = QProgressDialog(f"Processing image with {self.current_provider}...", "Cancel", 0, 0, self)
        progress.setWindowModality(Qt.WindowModal)
        progress.setMinimumDuration(0)
        progress.show()
        QApplication.processEvents()

        print(f"DEBUG: Sending image to {self.current_provider} for OCR and parsing...")
        print(f"DEBUG: API Key starts with: '{api_key[:4]}...'")

        try:
            # Create prompt for vision-based OCR and parsing
            # This prompt is designed to handle various Philippine land title formats
            prompt = """You are an expert OCR system for Philippine land title technical descriptions.

TASK: Extract ALL bearing-distance lines from this image. Philippine land titles have VARYING formats.

COMMON FORMATS TO RECOGNIZE:
1. Line labels: "1-2", "2-3", "Corner 1", "Line 1", "TP-1", "thence", or just sequential lines
2. Bearings: "N 78 32 W", "N. 78° 32' W.", "N78-32-W", "North 78 deg 32 min West"
3. Distances: "936.15 M", "936.15m", "936.15 meters", "936.15"

EXTRACTION RULES:
- Extract EVERY bearing-distance pair in the order they appear
- Bearing format: Direction (N/S) + Degrees + Minutes + Quadrant (E/W)
- Distance is always in meters (ignore unit labels)
- Some titles show seconds (e.g., N 45 30 15 E) - ignore seconds, use only degrees and minutes
- If a line says "thence" it means continue to next bearing

OUTPUT FORMAT - Return STRICT JSON with this exact schema:
{
    "raw_text": "Line-by-line extraction showing what was read from the image, one line per bearing in format: LINE_LABEL: BEARING, DISTANCE",
    "tie_point": {
        "name": "BLLM No. 1, MBM, BLCM, or similar monument name (or null)",
        "province": "province name if visible (or null)",
        "municipality": "municipality/city name if visible (or null)"
    },
    "technical_description": [
        {
            "line": "original line label (e.g., 1-2, TP-1)",
            "direction": "N or S",
            "degrees": 78,
            "minutes": 32,
            "quadrant": "E or W",
            "distance": 936.15
        }
    ]
}

CRITICAL:
- Output ONLY valid JSON, no markdown code blocks
- Extract ALL bearing lines, not just the first few. If the list is long, continue until the end. DO NOT TRUNCATE.
- Preserve the exact order of lines as they appear in the document
- The "raw_text" field should be human-readable extracted text showing each line"""

            # Call Vision API (sends image)
            llm_response = self.call_llm_api(api_key, prompt)
            progress.close()

            # Process the response
            if llm_response:
                print(f"DEBUG: Raw LLM response:\n{llm_response[:1000]}...")

                # Extract JSON from response using multiple methods
                clean_text = self.extract_json_from_response(llm_response)
                print(f"DEBUG: Cleaned text:\n{clean_text[:500]}...")

                try:
                    data = json.loads(clean_text)

                    # Display the human-readable extracted text in the text box
                    raw_text = data.get('raw_text', '')
                    if raw_text:
                        self.rawOcrTextEdit.setPlainText(raw_text)
                    else:
                        # Fallback: format the technical description as readable text
                        lines = []
                        for i, b in enumerate(data.get('technical_description', [])):
                            line_label = b.get('line', f'{i+1}')
                            direction = b.get('direction', '?')
                            degrees = b.get('degrees', 0)
                            minutes = b.get('minutes', 0)
                            quadrant = b.get('quadrant', '?')
                            distance = b.get('distance', 0)
                            lines.append(f"{line_label}: {direction} {degrees} {minutes} {quadrant}, {distance} M")
                        self.rawOcrTextEdit.setPlainText('\n'.join(lines) if lines else clean_text)

                    # 1. Handle Tie Point
                    tie_point_info = data.get('tie_point', {})
                    messages = []

                    if tie_point_info:
                        found_tp = self.lookup_tie_point(tie_point_info)
                        if found_tp:
                            self.parent_dialog.tie_point = found_tp
                            self.parent_dialog.tiePointNorthingInput.setText(str(found_tp['northing']))
                            self.parent_dialog.tiePointEastingInput.setText(str(found_tp['easting']))
                            messages.append(f"Tie Point found: {found_tp['name']}")
                        elif tie_point_info.get('name'):
                            messages.append(f"Tie Point identified ({tie_point_info.get('name')}) but not found in database.")

                    # 2. Handle Technical Description
                    bearings = data.get('technical_description', [])
                    if bearings:
                        messages.append(f"Extracted {len(bearings)} bearing lines.")
                        messages.append("Click 'Done' to apply to Technical Description.")

                        if messages:
                            QMessageBox.information(self, "Parse Complete", "\n".join(messages))
                    else:
                        QMessageBox.warning(self, "Warning", "No technical description found in the extracted text.")

                except json.JSONDecodeError as e:
                    print(f"Failed to parse JSON response from LLM: {e}")
                    print(f"Attempted to parse: {clean_text[:200]}...")

                    # Fallback to legacy parsing on the raw LLM response
                    bearings = self.parse_bearings_legacy(llm_response)
                    if bearings:
                        # Format the extracted bearings for display
                        lines = []
                        for i, b in enumerate(bearings):
                            lines.append(f"{i+1}: {b['direction']} {b['degrees']} {b['minutes']} {b['quadrant']}, {b['distance']} M")
                        self.rawOcrTextEdit.setPlainText('\n'.join(lines))

                        QMessageBox.information(
                            self, "Parse Complete",
                            f"LLM response format was unexpected.\n"
                            f"Used fallback parser and found {len(bearings)} bearing lines.\n"
                            f"Click 'Done' to apply to Technical Description."
                        )
                    else:
                        # Display detailed error info for debugging
                        error_info = (
                            f"JSON Parse Error: {str(e)}\n\n"
                            f"--- Raw LLM Response ---\n{llm_response}\n\n"
                            f"--- Cleaned Text ---\n{clean_text}"
                        )
                        self.rawOcrTextEdit.setPlainText(error_info)
                        QMessageBox.warning(
                            self, "Parsing Failed",
                            f"Could not parse the LLM response.\n"
                            f"Error: {str(e)}\n\n"
                            "The raw response is displayed for debugging."
                        )

            else:
                QMessageBox.warning(self, "Error", f"Failed to get response from {self.current_provider}.")

        except Exception as e:
            progress.close()
            error_msg = str(e)
            if "401" in error_msg:
                error_msg += "\n\nPlease check your API Key."
            QMessageBox.critical(self, "Error", f"Failed to process image: {error_msg}")

    def lookup_tie_point(self, info):
        """Look up tie point in the loaded dataframe."""
        if _TIEPOINT_DF is None or _TIEPOINT_DF.empty:
            return None

        # Handle None values from JSON null - use 'or' to convert None to empty string
        name = (info.get('name') or '').replace(" ", "").lower()
        province = (info.get('province') or '').lower()
        municipality = (info.get('municipality') or '').lower()
        
        if not name:
            return None
            
        # Create a copy for searching
        df = _TIEPOINT_DF.copy()
        df["__name"] = df["Tie Point Name"].astype(str).str.replace(" ", "").str.lower()
        
        # Filter by name first (most specific)
        matches = df[df["__name"].str.contains(name, na=False)]
        
        if matches.empty:
            return None
            
        # Try to narrow down by location if multiple matches
        if len(matches) > 1 and (province or municipality):
            if province:
                matches_prov = matches[matches["Province"].str.lower().str.contains(province, na=False)]
                if not matches_prov.empty:
                    matches = matches_prov
            
            if municipality and len(matches) > 1:
                matches_muni = matches[matches["Municipality"].str.lower().str.contains(municipality, na=False)]
                if not matches_muni.empty:
                    matches = matches_muni
        
        if not matches.empty:
            # Take the first best match
            row = matches.iloc[0]
            return {
                'name': row["Tie Point Name"],
                'description': row["Description"],
                'province': row["Province"],
                'municipality': row["Municipality"],
                'northing': float(row["Northing"]),
                'easting': float(row["Easting"])
            }
        return None

    def call_llm_api(self, api_key, prompt):
        """Dispatch call to appropriate API handler (with image)."""
        config = self.PROVIDER_CONFIG[self.current_provider]

        if config["style"] == "gemini":
            return self.call_gemini_native_api(api_key, prompt, config["api_url"])
        else:
            return self.call_openai_style_api(api_key, prompt, config)

    def call_gemini_native_api(self, api_key, prompt, api_url, retry_count=0):
        """Call Gemini Native API."""
        try:
            url = f"{api_url}?key={api_key}"

            payload = {
                "contents": [{
                    "parts": [
                        {"text": prompt},
                        {
                            "inline_data": {
                                "mime_type": self.current_image_mime,
                                "data": self.current_image_base64
                            }
                        }
                    ]
                }],
                "generationConfig": {
                    "maxOutputTokens": 8192,
                    "temperature": 0
                }
            }
            
            headers = {"Content-Type": "application/json"}
            data = json.dumps(payload).encode('utf-8')
            request = urllib.request.Request(url, data=data, headers=headers, method='POST')

            print(f"Sending request to Gemini...")
            with urllib.request.urlopen(request, timeout=120) as response:
                result = json.loads(response.read().decode('utf-8'))
                
                if 'candidates' in result and len(result['candidates']) > 0:
                    candidate = result['candidates'][0]
                    if 'content' in candidate and 'parts' in candidate['content']:
                        parts = candidate['content']['parts']
                        if len(parts) > 0 and 'text' in parts[0]:
                            return parts[0]['text']
                return None

        except urllib.error.HTTPError as e:
            # Re-use existing error handling logic
            return self.handle_http_error(e, api_key, prompt, "gemini", retry_count)
        except Exception as e:
            raise e

    def call_openai_style_api(self, api_key, prompt, config, retry_count=0):
        """Call OpenAI-compatible API (OpenAI, DeepSeek, OpenRouter, Qwen)."""
        try:
            url = config["api_url"]
            model = config["model"]
            
            # Construct standard OpenAI vision payload
            # Note: DeepSeek v3 officially doesn't support images yet, but we'll try standard format 
            # or rely on provider configuration if they add it. 
            # Some providers like Qwen-VL accept the image_url with data URI scheme.
            
            messages = [
                {
                    "role": "user",
                    "content": [
                        {"type": "text", "text": prompt},
                        {
                            "type": "image_url",
                            "image_url": {
                                "url": f"data:{self.current_image_mime};base64,{self.current_image_base64}"
                            }
                        }
                    ]
                }
            ]

            payload = {
                "model": model,
                "messages": messages,
                "temperature": 0,
                "max_tokens": 2048
            }
            
            # Headers
            headers = {
                "Content-Type": "application/json",
                "Authorization": f"Bearer {api_key}"
            }
            
            # Specific header tweaks
            if self.current_provider == "OpenRouter":
                headers["HTTP-Referer"] = "https://github.com/isaacenage/TitlePlotterPH"
                headers["X-Title"] = "Title Plotter PH"
            
            data = json.dumps(payload).encode('utf-8')
            request = urllib.request.Request(url, data=data, headers=headers, method='POST')

            print(f"Sending request to {self.current_provider}...")
            # Longer timeout for vision analysis
            with urllib.request.urlopen(request, timeout=120) as response:
                result = json.loads(response.read().decode('utf-8'))
                
                if 'choices' in result and len(result['choices']) > 0:
                    return result['choices'][0]['message']['content']
                return None

        except urllib.error.HTTPError as e:
            return self.handle_http_error(e, api_key, prompt, "openai", retry_count)
        except Exception as e:
            raise e

    def handle_http_error(self, e, api_key, prompt, style, retry_count):
        """Centralized HTTP error handling with retry logic."""
        error_body = e.read().decode('utf-8') if e.fp else ''
        code = e.code
        print(f"HTTP Error {code}: {error_body}")

        if code == 429 and retry_count < 2:
            wait_time = self.extract_retry_time(error_body)
            if wait_time and wait_time <= 120:
                reply = QMessageBox.question(self, "Rate Limited",
                    f"API rate limit reached. Wait {int(wait_time)} seconds and retry?",
                    QMessageBox.Yes | QMessageBox.No)
                if reply == QMessageBox.Yes:
                    QApplication.processEvents()
                    time.sleep(wait_time + 1)
                    if style == "gemini":
                        return self.call_gemini_native_api(api_key, prompt, self.PROVIDER_CONFIG[self.current_provider]['api_url'], retry_count + 1)
                    else:  # "openai" style
                        return self.call_openai_style_api(api_key, prompt, self.PROVIDER_CONFIG[self.current_provider], retry_count + 1)

        self.show_api_error(code, error_body)
        return None

    def extract_retry_time(self, error_body):
        """Extract retry wait time from error message."""
        try:
            match = re.search(r'retry in (\d+(?:\.\d+)?)', error_body, re.IGNORECASE)
            if match:
                return float(match.group(1))
        except:
            pass
        return 60

    def show_api_error(self, code, error_body):
        """Show appropriate error message based on HTTP error code."""
        try:
            error_json = json.loads(error_body)
            error_message = error_json.get('error', {}).get('message', error_body)
        except:
            error_message = error_body

        if code == 400:
            QMessageBox.critical(self, "API Error",
                f"Bad request.\n\nDetails: {error_message}")
        elif code == 401 or code == 403:
            QMessageBox.critical(self, "API Error",
                f"Invalid or unauthorized API key.\n\nDetails: {error_message}")
        elif code == 429:
            QMessageBox.critical(self, "API Error",
                f"Rate limit or quota exceeded.\n\nDetails: {error_message}\n\n"
                "Please wait a moment or check your API quota at:\n"
                "https://console.cloud.google.com/apis/api/generativelanguage.googleapis.com/quotas")
        elif code == 404:
            QMessageBox.critical(self, "API Error",
                f"Model not found. Try a different model.\n\nDetails: {error_message}")
        else:
            QMessageBox.critical(self, "API Error",
                f"HTTP Error {code}:\n\n{error_message}")

    def parse_bearings_legacy(self, raw_text):
        """Legacy text parser as fallback - handles various formats including partial JSON."""
        bearings = []

        # Log the raw text for debugging
        print("Legacy parser input:", raw_text[:500])

        # Method 1: Try to extract from JSON-like structure (for when JSON parse fails but data is there)
        # Look for patterns like "direction": "N", "degrees": 45, etc.
        json_bearing_pattern = re.compile(
            r'"direction"\s*:\s*"([NS])"\s*,\s*'
            r'"degrees"\s*:\s*(\d+)\s*,\s*'
            r'"minutes"\s*:\s*(\d+)\s*,\s*'
            r'"quadrant"\s*:\s*"([EW])"\s*,\s*'
            r'"distance"\s*:\s*([\d.]+)',
            re.IGNORECASE
        )
        json_matches = json_bearing_pattern.findall(raw_text)
        if json_matches:
            print(f"Found {len(json_matches)} bearings from JSON-like structure")
            for match in json_matches:
                try:
                    bearings.append({
                        'direction': match[0].upper(),
                        'degrees': int(match[1]),
                        'minutes': int(match[2]),
                        'quadrant': match[3].upper(),
                        'distance': float(match[4])
                    })
                except (ValueError, IndexError):
                    continue
            if bearings:
                return bearings

        # Method 2: Standard bearing pattern
        # Pattern to match bearing lines like: N 45 30 E 25.50 or N45 30 E 25.50 or N 45° 30' E 25.50m
        bearing_pattern = re.compile(
            r"""
            ([NS])[\s.-]*                        # N or S (with optional separators)
            (\d{1,2})[\s°.-]*                    # Degrees
            (\d{1,2})[\s'''-]*                   # Minutes
            ([EW])[\s,.-]*                       # E or W
            (\d+(?:[.,]\d+)?)\s*[mM]?            # Distance
            """,
            re.IGNORECASE | re.VERBOSE
        )

        # Find all matches
        matches = bearing_pattern.findall(raw_text)

        print(f"Found {len(matches)} bearing-distance matches from text pattern.")

        for match in matches:
            try:
                direction = match[0].upper()
                degrees = int(match[1])
                minutes = int(match[2])
                quadrant = match[3].upper()
                distance = float(match[4].replace(',', '.'))

                # Validate values
                if direction in ['N', 'S'] and quadrant in ['E', 'W']:
                    if 0 <= degrees <= 90 and 0 <= minutes <= 59 and distance > 0:
                        bearing = {
                            'direction': direction,
                            'degrees': degrees,
                            'minutes': minutes,
                            'quadrant': quadrant,
                            'distance': distance
                        }
                        bearings.append(bearing)
                        print(f"Valid bearing: {direction} {degrees}° {minutes}' {quadrant} {distance}m")
                    else:
                        print(f"Invalid values: deg={degrees}, min={minutes}, dist={distance}")
                else:
                    print(f"Invalid direction/quadrant: {direction}/{quadrant}")

            except (ValueError, IndexError) as e:
                print(f"Error processing match {match}: {str(e)}")
                continue

        return bearings

    def add_bearings_to_parent(self, bearings):
        """Add extracted bearings to the parent dialog's bearing rows.

        If existing rows have data, append new bearings after them instead of overwriting.
        """
        if not self.parent_dialog:
            return

        if not bearings:
            return

        # Helper function to check if a row has data
        def row_has_data(row):
            return (row.directionInput.text().strip() or
                    row.degreesInput.text().strip() or
                    row.minutesInput.text().strip() or
                    row.quadrantInput.text().strip() or
                    row.distanceInput.text().strip())

        # Find the first empty row index
        first_empty_idx = None
        for idx, row in enumerate(self.parent_dialog.bearing_rows):
            if not row_has_data(row):
                first_empty_idx = idx
                break

        # If all existing rows have data, we'll append after the last row
        if first_empty_idx is None:
            first_empty_idx = len(self.parent_dialog.bearing_rows)

        # Fill existing empty rows first
        bearing_idx = 0
        for row_idx in range(first_empty_idx, len(self.parent_dialog.bearing_rows)):
            if bearing_idx >= len(bearings):
                break
            row = self.parent_dialog.bearing_rows[row_idx]
            bearing = bearings[bearing_idx]
            row.directionInput.setText(bearing['direction'])
            row.degreesInput.setText(str(bearing['degrees']))
            row.minutesInput.setText(str(bearing['minutes']))
            row.quadrantInput.setText(bearing['quadrant'])
            row.distanceInput.setText(str(bearing['distance']))
            bearing_idx += 1

        # Add new rows for remaining bearings
        while bearing_idx < len(bearings):
            self.parent_dialog.add_bearing_row()
            row = self.parent_dialog.bearing_rows[-1]
            bearing = bearings[bearing_idx]
            row.directionInput.setText(bearing['direction'])
            row.degreesInput.setText(str(bearing['degrees']))
            row.minutesInput.setText(str(bearing['minutes']))
            row.quadrantInput.setText(bearing['quadrant'])
            row.distanceInput.setText(str(bearing['distance']))
            bearing_idx += 1

        # Update preview
        self.parent_dialog.generate_wkt()

    def apply_text_to_parent(self):
        """Parse the text from the response box and apply bearings to the parent dialog.

        Returns True if bearings were successfully applied, False otherwise.
        """
        if not self.parent_dialog:
            QMessageBox.warning(self, "Error", "No parent dialog available.")
            return False

        text = self.rawOcrTextEdit.toPlainText().strip()
        if not text:
            # No text to apply - this is OK if user just wants to close
            return False

        bearings = []

        # First, try to parse as JSON
        try:
            # Try to extract JSON from the text
            clean_text = self.extract_json_from_response(text)
            data = json.loads(clean_text)

            # Handle tie point if present
            tie_point_info = data.get('tie_point', {})
            if tie_point_info:
                found_tp = self.lookup_tie_point(tie_point_info)
                if found_tp:
                    self.parent_dialog.tie_point = found_tp
                    self.parent_dialog.tiePointNorthingInput.setText(str(found_tp['northing']))
                    self.parent_dialog.tiePointEastingInput.setText(str(found_tp['easting']))

            # Extract bearings from JSON
            bearings = data.get('technical_description', [])
        except (json.JSONDecodeError, KeyError):
            # If JSON parsing fails, try to parse as plain text
            bearings = self.parse_text_bearings(text)

        if not bearings:
            # Try legacy parsing as last resort
            bearings = self.parse_bearings_legacy(text)

        if bearings:
            self.add_bearings_to_parent(bearings)
            return True
        return False

    def accept_and_apply(self):
        """Apply any extracted bearings and close the dialog."""
        # Try to apply text if available (silently - no error if empty)
        self.apply_text_to_parent()
        # Close the dialog
        self.accept()

    def parse_text_bearings(self, text):
        """Parse bearing data from plain text format.

        Expected formats:
        - "1-2: N 45 30 E, 125.50"
        - "TP-1: S 78 32 W, 936.15 M"
        - "N 45 30 E 125.50"
        """
        bearings = []
        lines = text.strip().split('\n')

        for line in lines:
            line = line.strip()
            if not line:
                continue

            # Pattern to match bearing lines with or without line labels
            # Handles: "1-2: N 45 30 E, 125.50" or "N 45 30 E 125.50"
            pattern = re.compile(
                r"""
                (?:[\w\-]+\s*:\s*)?          # Optional line label (e.g., "1-2:", "TP-1:")
                ([NS])\s*                     # Direction N or S
                (\d{1,2})\s*[°]?\s*           # Degrees
                (\d{1,2})\s*[''']?\s*         # Minutes
                ([EW])\s*                     # Quadrant E or W
                [,\s]*                        # Separator (comma or space)
                ([\d.]+)\s*[mM]?              # Distance
                """,
                re.IGNORECASE | re.VERBOSE
            )

            match = pattern.search(line)
            if match:
                try:
                    bearing = {
                        'direction': match.group(1).upper(),
                        'degrees': int(match.group(2)),
                        'minutes': int(match.group(3)),
                        'quadrant': match.group(4).upper(),
                        'distance': float(match.group(5).replace(',', '.'))
                    }

                    # Validate values
                    if (bearing['direction'] in ['N', 'S'] and
                        bearing['quadrant'] in ['E', 'W'] and
                        0 <= bearing['degrees'] <= 90 and
                        0 <= bearing['minutes'] <= 59 and
                        bearing['distance'] > 0):
                        bearings.append(bearing)
                except (ValueError, IndexError):
                    continue

        return bearings

    def resizeEvent(self, event):
        """Handles resize event for the dialog."""
        super().resizeEvent(event)
        if self.rawOcrTextEdit.isVisible():
            self.rawOcrTextEdit.updateGeometry()
