"""
Georeferencer Core Engine - Standalone 2-Panel Window for Image Rectification & Georeferencing.
Contains Data Models, Viewports, Map Tools, GDAL Exporter Engine, and UI Components.
"""

import os
import sys
import math
import tempfile
import urllib.request
import urllib.parse
import json
import re


from concurrent.futures import ThreadPoolExecutor
from osgeo import gdal, osr

from qgis.PyQt.QtCore import (
    Qt, pyqtSignal, QPointF, QRectF, QTimer, QThread, QAbstractTableModel, QModelIndex
)
from qgis.PyQt.QtWidgets import (
    QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QComboBox,
    QPushButton, QFileDialog, QAction, QGroupBox, QLabel, QLineEdit,
    QMessageBox, QToolBar, QTableView, QHeaderView, QTextEdit, QDialog,
    QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QGraphicsItemGroup,
    QGraphicsEllipseItem, QGraphicsLineItem, QGraphicsSimpleTextItem,
    QProgressBar, QFrame, QCompleter, QSizePolicy, QAbstractItemView
)
from qgis.PyQt.QtGui import QIcon, QColor, QFont, QPen, QBrush, QPainter, QPixmap, QImage, QStandardItemModel, QStandardItem, QImageReader
from qgis.gui import QgsMapCanvas, QgsMapToolPan, QgsMapToolZoom, QgsMapTool, QgsMapCanvasItem, QgsProjectionSelectionDialog
from qgis.core import (
    QgsProject, QgsRasterLayer, QgsCoordinateReferenceSystem, QgsCoordinateTransform, QgsPointXY, QgsTask, QgsNetworkAccessManager, QgsLogger
)
from qgis.PyQt import sip


# ==============================================================================
# 1. GCP DATA MODEL
# ==============================================================================

class GCPItem:
    """
    Data container for a Ground Control Point (GCP).
    """
    def __init__(self, point_id, src_x, src_y, ref_x, ref_y, enabled=True):
        self.id = point_id
        self.src_x = float(src_x)
        self.src_y = float(src_y)
        self.ref_x = float(ref_x)
        self.ref_y = float(ref_y)
        self.enabled = bool(enabled)
        self.residual_x = 0.0
        self.residual_y = 0.0
        self.residual_total = 0.0


class GCPTableModel(QAbstractTableModel):
    """
    Qt Table Model for GCP panel with automatic signals on change.
    """
    HEADERS = ["ID", "Src X (px)", "Src Y (px)", "Ref X (geo)", "Ref Y (geo)", "Residual"]

    def __init__(self, parent=None):
        super().__init__(parent)
        self._gcps = []

    def rowCount(self, parent=QModelIndex()):
        return len(self._gcps)

    def columnCount(self, parent=QModelIndex()):
        return len(self.HEADERS)

    def headerData(self, section, orientation, role=Qt.DisplayRole):
        if role == Qt.DisplayRole and orientation == Qt.Horizontal:
            return self.HEADERS[section]
        return None

    def data(self, index, role=Qt.DisplayRole):
        if not index.isValid() or not (0 <= index.row() < len(self._gcps)):
            return None

        gcp = self._gcps[index.row()]
        col = index.column()

        if role == Qt.DisplayRole or role == Qt.EditRole:
            if col == 0:
                return str(gcp.id)
            elif col == 1:
                return f"{gcp.src_x:.2f}"
            elif col == 2:
                return f"{gcp.src_y:.2f}"
            elif col == 3:
                return f"{gcp.ref_x:.6f}"
            elif col == 4:
                return f"{gcp.ref_y:.6f}"
            elif col == 5:
                return f"{gcp.residual_total:.4f}"

        elif role == Qt.BackgroundRole:
            if gcp.residual_total > 10.0:
                return QBrush(QColor(255, 200, 200))
            return QBrush(QColor(245, 255, 245))

        elif role == Qt.TextAlignmentRole:
            return Qt.AlignCenter

        return None

    def flags(self, index):
        if not index.isValid():
            return Qt.NoItemFlags

        flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable
        if index.column() in (1, 2, 3, 4):
            flags |= Qt.ItemIsEditable

        return flags

    def setData(self, index, value, role=Qt.EditRole):
        if not index.isValid() or not (0 <= index.row() < len(self._gcps)):
            return False

        gcp = self._gcps[index.row()]
        col = index.column()

        try:
            if col == 1:
                gcp.src_x = float(value)
            elif col == 2:
                gcp.src_y = float(value)
            elif col == 3:
                gcp.ref_x = float(value)
            elif col == 4:
                gcp.ref_y = float(value)
            
            self.dataChanged.emit(index, index, [role])
            return True
        except ValueError:
            return False

    def add_gcp(self, src_x, src_y, ref_x, ref_y):
        self.beginInsertRows(QModelIndex(), len(self._gcps), len(self._gcps))
        new_id = len(self._gcps) + 1
        gcp = GCPItem(new_id, src_x, src_y, ref_x, ref_y)
        self._gcps.append(gcp)
        self.endInsertRows()
        return gcp

    def remove_gcp_rows(self, rows):
        if not rows:
            return False
        sorted_rows = sorted(set(rows), reverse=True)
        valid_rows = [r for r in sorted_rows if 0 <= r < len(self._gcps)]
        if not valid_rows:
            return False

        self.beginResetModel()
        for r in valid_rows:
            del self._gcps[r]
        for i, p in enumerate(self._gcps):
            p.id = i + 1
        self.endResetModel()
        return True

    def remove_gcp(self, row):
        return self.remove_gcp_rows([row])

    def clear(self):
        if not self._gcps:
            return
        self.beginResetModel()
        self._gcps.clear()
        self.endResetModel()

    def get_gcps(self, enabled_only=False):
        if enabled_only:
            return [p for p in self._gcps if p.enabled]
        return list(self._gcps)

    def set_residuals(self, residuals):
        if not self._gcps:
            return
        for i, res in enumerate(residuals):
            if i < len(self._gcps):
                rx, ry = res[0], res[1]
                self._gcps[i].residual_x = rx
                self._gcps[i].residual_y = ry
                self._gcps[i].residual_total = math.sqrt(rx * rx + ry * ry)
        if len(self._gcps) > 0:
            self.dataChanged.emit(self.index(0, 5), self.index(len(self._gcps) - 1, 5), [Qt.DisplayRole, Qt.BackgroundRole])



# ==============================================================================
# 2. BASEMAP PROVIDER
# ==============================================================================

class BasemapProvider:
    """
    High-speed tile layer provider with connection pooling & 500 MB disk tile caching optimization.
    """
    BASEMAPS = {
        "OpenStreetMap": "type=xyz&url=https://tile.openstreetmap.org/{z}/{x}/{y}.png&zmax=19&zmin=0&http-header:User-Agent=Georeferencer/2.0",
        "Google Satellite": "type=xyz&url=https://mt1.google.com/vt/lyrs=s%26x={x}%26y={y}%26z={z}&zmax=20&zmin=0&http-header:User-Agent=Georeferencer/2.0",
        "Google Hybrid": "type=xyz&url=https://mt1.google.com/vt/lyrs=y%26x={x}%26y={y}%26z={z}&zmax=20&zmin=0&http-header:User-Agent=Georeferencer/2.0",
        "Google Roadmap": "type=xyz&url=https://mt1.google.com/vt/lyrs=m%26x={x}%26y={y}%26z={z}&zmax=20&zmin=0&http-header:User-Agent=Georeferencer/2.0",
        "Google Terrain": "type=xyz&url=https://mt1.google.com/vt/lyrs=t%26x={x}%26y={y}%26z={z}&zmax=20&zmin=0&http-header:User-Agent=Georeferencer/2.0",
        "ESRI Satellite": "type=xyz&url=https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}&zmax=19&zmin=0&http-header:User-Agent=Georeferencer/2.0",
        "CartoDB Light": "type=xyz&url=https://cartodb-basemaps-a.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png&zmax=19&zmin=0&http-header:User-Agent=Georeferencer/2.0",
        "CartoDB Dark": "type=xyz&url=https://cartodb-basemaps-a.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png&zmax=19&zmin=0&http-header:User-Agent=Georeferencer/2.0",
        "OpenTopoMap": "type=xyz&url=https://a.tile.opentopomap.org/{z}/{x}/{y}.png&zmax=17&zmin=0&http-header:User-Agent=Georeferencer/2.0"
    }

    _cache_initialized = False

    @classmethod
    def optimize_network(cls):
        try:
            nam = QgsNetworkAccessManager.instance()
            nam.setMaxConcurrentConnections(128)
            nam.setMaxConcurrentConnectionsPerHost(64)

            if not cls._cache_initialized:
                from qgis.PyQt.QtNetwork import QNetworkDiskCache
                cache = QNetworkDiskCache()
                cache_dir = os.path.join(tempfile.gettempdir(), "georeferencer_tile_cache")
                os.makedirs(cache_dir, exist_ok=True)
                cache.setCacheDirectory(cache_dir)
                cache.setMaximumCacheSize(500 * 1024 * 1024)
                nam.setCache(cache)
                cls._cache_initialized = True
        except (AttributeError, OSError, RuntimeError) as err:
            QgsLogger.warning(f"Failed to optimize network or tile cache: {err}")

    @classmethod
    def get_basemap_layer(cls, name="OpenStreetMap"):
        cls.optimize_network()
        uri = cls.BASEMAPS.get(name, cls.BASEMAPS["OpenStreetMap"])
        return QgsRasterLayer(uri, name, "wms")



# ==============================================================================
# 3. TRANSFORMATION & RMSE ENGINE
# ==============================================================================

class TransformationEngine:
    """
    Affine, Polynomial, Helmert, and Thin Plate Spline spatial transformation solver.
    """
    @classmethod
    def compute_transformation(cls, src_pts, ref_pts, algo="Helmert"):
        if len(src_pts) < 2:
            raise ValueError("At least 2 points required.")

        mean_src_x = sum(p[0] for p in src_pts) / len(src_pts)
        mean_src_y = sum(p[1] for p in src_pts) / len(src_pts)
        mean_ref_x = sum(p[0] for p in ref_pts) / len(ref_pts)
        mean_ref_y = sum(p[1] for p in ref_pts) / len(ref_pts)

        if algo in ("Linear", "Helmert", "Polynomial 1") or len(src_pts) < 3:
            s_x = sum((p[0] - mean_src_x)**2 + (p[1] - mean_src_y)**2 for p in src_pts)
            if s_x == 0:
                s_x = 1.0
            a = sum((src_pts[i][0] - mean_src_x)*(ref_pts[i][0] - mean_ref_x) + (src_pts[i][1] - mean_src_y)*(ref_pts[i][1] - mean_ref_y) for i in range(len(src_pts))) / s_x
            b = sum((src_pts[i][0] - mean_src_x)*(ref_pts[i][1] - mean_ref_y) - (src_pts[i][1] - mean_src_y)*(ref_pts[i][0] - mean_ref_x) for i in range(len(src_pts))) / s_x
            c = mean_ref_x - a * mean_src_x + b * mean_src_y
            d = mean_ref_y - b * mean_src_x - a * mean_src_y
            return {"type": "helmert", "params": (a, b, c, d)}

        a = (ref_pts[1][0] - ref_pts[0][0]) / max(0.001, src_pts[1][0] - src_pts[0][0])
        d = (ref_pts[1][1] - ref_pts[0][1]) / max(0.001, src_pts[1][1] - src_pts[0][1])
        c = mean_ref_x - a * mean_src_x
        f = mean_ref_y - d * mean_src_y
        return {"type": "affine", "params": (a, 0, c, 0, d, f)}

    @classmethod
    def transform(cls, pt, model):
        x, y = pt
        p = model["params"]
        if model["type"] == "helmert":
            a, b, c, d = p
            return (a * x - b * y + c, b * x + a * y + d)
        else:
            a, b, c, d, e, f = p
            return (a * x + b * y + c, d * x + e * y + f)


class RMSECalculator:
    """
    Computes GCP point residual errors and global Root Mean Square Error (RMSE).
    """
    @classmethod
    def calculate_residuals(cls, gcps, model):
        residuals = []
        sq_sum = 0.0

        for gcp in gcps:
            est_x, est_y = TransformationEngine.transform((gcp.src_x, gcp.src_y), model)
            rx = est_x - gcp.ref_x
            ry = est_y - gcp.ref_y
            r_tot = math.sqrt(rx * rx + ry * ry)
            residuals.append((rx, ry, r_tot))
            sq_sum += r_tot * r_tot

        global_rmse = math.sqrt(sq_sum / max(1, len(gcps)))
        return residuals, global_rmse


# ==============================================================================
# 4. GDAL EXPORTER & BACKGROUND WORKER
# ==============================================================================

class MultiFormatExporter:
    """
    Warp & Rectification Exporter using GDAL with LZW Compression, Size Preservation & Distortion Protection.
    """
    @classmethod
    def export_geotiff(cls, src_filepath, dst_filepath, gcps, crs_wkt="EPSG:3857", algo_name="Helmert", progress_callback=None):
        enabled_gcps = [p for p in gcps if p.enabled]
        if len(enabled_gcps) < 2:
            raise ValueError("Minimum 2 enabled GCP points required for export.")

        # Check for scale ratio divergence to prevent bowtie distortions in Google Earth
        effective_algo = algo_name
        if len(enabled_gcps) >= 3 and ("Linear" in algo_name or "Polynomial 1" in algo_name):
            scales = []
            for i in range(len(enabled_gcps) - 1):
                p1 = enabled_gcps[i]
                p2 = enabled_gcps[i+1]
                ds = math.hypot(p2.src_x - p1.src_x, p2.src_y - p1.src_y)
                dr = math.hypot(p2.ref_x - p1.ref_x, p2.ref_y - p1.ref_y)
                if ds > 10.0 and dr > 0.1:
                    scales.append(dr / ds)
            if scales:
                max_s = max(scales)
                min_s = min(scales)
                if min_s > 0 and (max_s / min_s) > 4.0:
                    effective_algo = "Thin Plate Spline"

        gdal_gcps = []
        for p in enabled_gcps:
            gdal_gcps.append(gdal.GCP(p.ref_x, p.ref_y, 0.0, p.src_x, p.src_y, f"GCP_{p.id}", str(p.id)))

        src_ds = gdal.Open(src_filepath, gdal.GA_ReadOnly)
        if src_ds is None:
            raise ValueError(f"Unable to open source file: {src_filepath}")

        src_w = src_ds.RasterXSize
        src_h = src_ds.RasterYSize

        srs = osr.SpatialReference()
        srs.SetFromUserInput(crs_wkt if crs_wkt else "EPSG:3857")

        vrt_driver = gdal.GetDriverByName('VRT')
        vrt_ds = vrt_driver.CreateCopy('', src_ds)
        vrt_ds.SetGCPs(gdal_gcps, srs.ExportToWkt())

        def cb(complete, message, cb_data):
            if progress_callback:
                progress_callback(float(complete * 100.0))
            return 1

        warp_kwargs = {
            "format": "GTiff",
            "dstSRS": srs.ExportToWkt(),
            "resampleAlg": gdal.GRA_Cubic,
            "width": src_w,
            "height": src_h,
            "dstAlpha": True,
            "dstNodata": 0,
            "creationOptions": ["COMPRESS=LZW", "TILED=YES", "PREDICTOR=2"],
            "callback": cb
        }


        if "Thin Plate Spline" in effective_algo or "TPS" in effective_algo:
            warp_kwargs["tps"] = True
        elif "2" in effective_algo:
            warp_kwargs["polynomialOrder"] = 2
        elif "3" in effective_algo:
            warp_kwargs["polynomialOrder"] = 3
        else:
            warp_kwargs["polynomialOrder"] = 1

        gdal.SetConfigOption("CHECK_DISK_FREE_SPACE", "FALSE")
        res = gdal.Warp(dst_filepath, vrt_ds, **warp_kwargs)
        res = None
        vrt_ds = None
        src_ds = None

        if not os.path.exists(dst_filepath) or os.path.getsize(dst_filepath) == 0:
            raise RuntimeError(f"Failed to generate output GeoTIFF at {dst_filepath}")

        return True


class ExportTaskWorker(QgsTask):
    """
    QgsTask background thread worker for GDAL raster warping.
    """
    def __init__(self, src_path, dst_path, gcps, crs_wkt, algo_name, callback):
        super().__init__("Smart Georeferencer Export Task", QgsTask.CanCancel)
        self.src_path = src_path
        self.dst_path = dst_path
        self.gcps = gcps
        self.crs_wkt = crs_wkt
        self.algo_name = algo_name
        self.callback = callback

    def run(self):
        try:
            def on_progress(pct):
                self.setProgress(int(pct))

            MultiFormatExporter.export_geotiff(
                self.src_path, self.dst_path, self.gcps, self.crs_wkt, self.algo_name,
                progress_callback=on_progress
            )
            return True
        except Exception as e:
            print(f"Export worker error: {e}")
            return False

    def finished(self, result):
        if self.callback:
            self.callback(result, self.dst_path)


# ==============================================================================
# 5. RASTER CANVAS VIEWPORT (USER IMAGE)
# ==============================================================================

class GCPMarkerGraphicsItem(QGraphicsItemGroup):
    """
    Crosshair X dot GCP marker with green point ID label.
    """
    def __init__(self, x, y, point_id, color=QColor(231, 76, 60)):
        super().__init__()
        self.setPos(x, y)
        self.setFlag(QGraphicsItemGroup.ItemIgnoresTransformations, True)

        s = 6
        pen = QPen(color, 2)

        l1 = QGraphicsLineItem(-s, -s, s, s, self)
        l1.setPen(pen)
        l2 = QGraphicsLineItem(-s, s, s, -s, self)
        l2.setPen(pen)

        dot = QGraphicsEllipseItem(-2, -2, 4, 4, self)
        dot.setBrush(QBrush(color))
        dot.setPen(QPen(Qt.NoPen))

        txt = QGraphicsSimpleTextItem(str(point_id), self)
        txt.setFont(QFont("Arial", 10, QFont.Bold))
        txt.setBrush(QBrush(QColor(0, 150, 40)))
        txt.setPos(8, -12)


class RasterLoaderThread(QThread):
    """
    Background worker thread to load and downsample high-res rasters without freezing UI, using up to 4 threads.
    """
    loaded = pyqtSignal(bool, object, int, int, str)

    def __init__(self, filepath, parent=None):
        super().__init__(parent)
        self.filepath = filepath

    def run(self):
        try:
            num_threads = min(4, max(1, os.cpu_count() or 4))
            gdal.SetConfigOption("GDAL_NUM_THREADS", str(num_threads))

            reader = QImageReader(self.filepath)
            reader.setAutoTransform(True)
            qimg = reader.read()

            if not qimg.isNull():
                w, h = qimg.width(), qimg.height()
                max_dim = max(w, h)
                if max_dim > 4096:
                    scale = 4096.0 / max_dim
                    qimg = qimg.scaled(int(w * scale), int(h * scale), Qt.KeepAspectRatio, Qt.SmoothTransformation)
                self.loaded.emit(True, qimg, w, h, self.filepath)
                return

            ds = gdal.Open(self.filepath, gdal.GA_ReadOnly)
            if ds is not None:
                w, h = ds.RasterXSize, ds.RasterYSize
                max_dim = max(w, h)
                disp_w = int(w * 4096.0 / max_dim) if max_dim > 4096 else w
                disp_h = int(h * 4096.0 / max_dim) if max_dim > 4096 else h

                band_count = ds.RasterCount
                qimg = None

                if num_threads > 1 and disp_h >= 64:
                    chunk_height = disp_h // num_threads
                    chunks = []
                    for i in range(num_threads):
                        y_off = i * chunk_height
                        h_chunk = disp_h - y_off if i == num_threads - 1 else chunk_height
                        src_y_off = int(y_off * (h / float(disp_h)))
                        src_h_chunk = int(h_chunk * (h / float(disp_h)))
                        chunks.append((y_off, h_chunk, src_y_off, src_h_chunk))

                    def read_chunk(chunk_info):
                        c_y_off, c_disp_h, c_src_y, c_src_h = chunk_info
                        local_ds = gdal.Open(self.filepath, gdal.GA_ReadOnly)
                        if not local_ds:
                            return None
                        if band_count >= 3:
                            raw = local_ds.ReadRaster(0, c_src_y, w, c_src_h, buf_xsize=disp_w, buf_ysize=c_disp_h, band_list=[1, 2, 3])
                        else:
                            raw = local_ds.ReadRaster(0, c_src_y, w, c_src_h, buf_xsize=disp_w, buf_ysize=c_disp_h, band_list=[1])
                        local_ds = None
                        return (c_y_off, c_disp_h, raw)

                    with ThreadPoolExecutor(max_workers=num_threads) as executor:
                        results = list(executor.map(read_chunk, chunks))

                    if all(r is not None and r[2] is not None for r in results):
                        results.sort(key=lambda r: r[0])
                        combined_bytes = b"".join(r[2] for r in results)
                        if band_count >= 3:
                            qimg = QImage(combined_bytes, disp_w, disp_h, disp_w * 3, QImage.Format_RGB888).copy()
                        else:
                            qimg = QImage(combined_bytes, disp_w, disp_h, disp_w, QImage.Format_Grayscale8).copy()

                if qimg is None or qimg.isNull():
                    if band_count >= 3:
                        raw_bytes = ds.ReadRaster(0, 0, w, h, buf_xsize=disp_w, buf_ysize=disp_h, band_list=[1, 2, 3])
                        if raw_bytes:
                            qimg = QImage(raw_bytes, disp_w, disp_h, disp_w * 3, QImage.Format_RGB888).copy()
                    elif band_count >= 1:
                        raw_bytes = ds.ReadRaster(0, 0, w, h, buf_xsize=disp_w, buf_ysize=disp_h, band_list=[1])
                        if raw_bytes:
                            qimg = QImage(raw_bytes, disp_w, disp_h, disp_w, QImage.Format_Grayscale8).copy()
                ds = None

                if qimg and not qimg.isNull():
                    self.loaded.emit(True, qimg, w, h, self.filepath)
                    return

            self.loaded.emit(False, None, 0, 0, self.filepath)
        except Exception as e:
            print(f"Error in RasterLoaderThread: {e}")
            self.loaded.emit(False, None, 0, 0, self.filepath)


class CircularLoadingOverlay(QWidget):
    """
    Sleek animated circular loading spinner overlay displayed during background image loading.
    """
    def __init__(self, parent=None):
        super().__init__(parent)
        self.angle = 0
        self.timer = QTimer(self)
        self.timer.setInterval(25)
        self.timer.timeout.connect(self._rotate)
        self.setAttribute(Qt.WA_TransparentForMouseEvents, False)
        self.hide()

    def _rotate(self):
        self.angle = (self.angle + 8) % 360
        self.update()

    def start(self):
        self.angle = 0
        if self.parentWidget():
            self.setGeometry(self.parentWidget().rect())
        self.show()
        self.raise_()
        self.timer.start()

    def stop(self):
        self.timer.stop()
        self.hide()

    def paintEvent(self, event):
        if not self.isVisible():
            return
        painter = QPainter(self)
        painter.setRenderHint(QPainter.Antialiasing, True)

        painter.fillRect(self.rect(), QColor(25, 25, 30, 180))

        cx = self.width() / 2.0
        cy = self.height() / 2.0 - 15
        radius = 35.0

        pen_track = QPen(QColor(255, 255, 255, 40), 6)
        painter.setPen(pen_track)
        painter.setBrush(Qt.NoBrush)
        painter.drawEllipse(QPointF(cx, cy), radius, radius)

        pen_arc = QPen(QColor(52, 152, 219), 6, Qt.SolidLine, Qt.RoundCap)
        painter.setPen(pen_arc)
        rect = QRectF(cx - radius, cy - radius, radius * 2, radius * 2)
        painter.drawArc(rect, -int(self.angle * 16), int(110 * 16))

        painter.setFont(QFont("Arial", 11, QFont.Bold))
        painter.setPen(QPen(QColor(240, 240, 240)))
        painter.drawText(QRectF(0, cy + radius + 15, self.width(), 30), Qt.AlignCenter, "Loading Image...")


class RasterCanvas(QGraphicsView):
    """
    User Image canvas supporting high-res raster loading with Pyramidal Downsampling, 60 FPS panning, scroll wheel zoom, and GCP placement.
    """
    pixel_selected = pyqtSignal(float, float)

    def __init__(self, parent=None):
        super().__init__(parent)
        self.scene = QGraphicsScene(self)
        self.setScene(self.scene)

        self.pixmap_item = None
        self.gcp_markers = {}
        self.panning = False
        self.pan_start = None
        self.gcp_mode = True
        self.loader_thread = None
        self.pending_callback = None

        self.spinner = CircularLoadingOverlay(self)

        self.setRenderHint(QPainter.Antialiasing, True)
        self.setRenderHint(QPainter.SmoothPixmapTransform, True)
        self.setOptimizationFlag(QGraphicsView.DontSavePainterState, True)
        self.setOptimizationFlag(QGraphicsView.DontAdjustForAntialiasing, True)
        self.setViewportUpdateMode(QGraphicsView.SmartViewportUpdate)
        self.setDragMode(QGraphicsView.NoDrag)
        self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)

    def resizeEvent(self, event):
        super().resizeEvent(event)
        if hasattr(self, 'spinner') and self.spinner:
            self.spinner.setGeometry(self.rect())

    def load_raster_async(self, filepath, callback=None):
        if self.loader_thread and self.loader_thread.isRunning():
            self.loader_thread.terminate()
            self.loader_thread.wait()

        self.pending_callback = callback
        self.spinner.start()
        self.loader_thread = RasterLoaderThread(filepath, self)
        self.loader_thread.loaded.connect(self._on_raster_loaded)
        self.loader_thread.start()

    def _on_raster_loaded(self, success, qimg, w, h, filepath):
        self.spinner.stop()
        self.clear_gcp_markers()
        self.scene.clear()

        if success and qimg and not qimg.isNull():
            pix_display = QPixmap.fromImage(qimg)
            self.pixmap_item = QGraphicsPixmapItem(pix_display)
            self.pixmap_item.setScale(w / float(pix_display.width()))
            self.scene.addItem(self.pixmap_item)
            self.setSceneRect(0, 0, w, h)
            self.fitInView(self.sceneRect(), Qt.KeepAspectRatio)

            if self.pending_callback:
                self.pending_callback(True, w, h)
        else:
            self.pixmap_item = None
            if self.pending_callback:
                self.pending_callback(False, 0, 0)

    def load_raster(self, filepath):
        self.spinner.stop()
        self.clear_gcp_markers()
        self.scene.clear()

        reader = QImageReader(filepath)
        reader.setAutoTransform(True)
        qimg = reader.read()

        if not qimg.isNull():
            w, h = qimg.width(), qimg.height()
            max_dim = max(w, h)

            if max_dim > 4096:
                scale = 4096.0 / max_dim
                pix_display = QPixmap.fromImage(qimg).scaled(int(w * scale), int(h * scale), Qt.KeepAspectRatio, Qt.SmoothTransformation)
                self.pixmap_item = QGraphicsPixmapItem(pix_display)
                self.pixmap_item.setScale(w / float(pix_display.width()))
            else:
                self.pixmap_item = QGraphicsPixmapItem(QPixmap.fromImage(qimg))
        else:
            ds = gdal.Open(filepath, gdal.GA_ReadOnly)
            if not ds:
                return False
            w, h = ds.RasterXSize, ds.RasterYSize
            max_dim = max(w, h)
            disp_w = int(w * 4096.0 / max_dim) if max_dim > 4096 else w
            disp_h = int(h * 4096.0 / max_dim) if max_dim > 4096 else h
            band_count = ds.RasterCount
            qimg = None
            if band_count >= 3:
                raw_bytes = ds.ReadRaster(0, 0, w, h, buf_xsize=disp_w, buf_ysize=disp_h, band_list=[1, 2, 3])
                if raw_bytes:
                    qimg = QImage(raw_bytes, disp_w, disp_h, disp_w * 3, QImage.Format_RGB888).copy()
            elif band_count >= 1:
                raw_bytes = ds.ReadRaster(0, 0, w, h, buf_xsize=disp_w, buf_ysize=disp_h, band_list=[1])
                if raw_bytes:
                    qimg = QImage(raw_bytes, disp_w, disp_h, disp_w, QImage.Format_Grayscale8).copy()
            ds = None

            if qimg and not qimg.isNull():
                pix_display = QPixmap.fromImage(qimg)
                self.pixmap_item = QGraphicsPixmapItem(pix_display)
                self.pixmap_item.setScale(w / float(pix_display.width()))
            else:
                return False

        self.scene.addItem(self.pixmap_item)
        self.setSceneRect(0, 0, w, h)
        self.fitInView(self.sceneRect(), Qt.KeepAspectRatio)
        return True

    def wheelEvent(self, e):
        factor = 1.15 if e.angleDelta().y() > 0 else 1 / 1.15
        self.scale(factor, factor)

    def mousePressEvent(self, e):
        if e.button() == Qt.RightButton:
            self.panning = True
            self.pan_start = e.pos()
            self.setCursor(Qt.ClosedHandCursor)
        elif e.button() == Qt.LeftButton and self.gcp_mode and self.pixmap_item:
            scene_pt = self.mapToScene(e.pos())
            if self.sceneRect().contains(scene_pt):
                self.pixel_selected.emit(scene_pt.x(), scene_pt.y())
        super().mousePressEvent(e)

    def mouseMoveEvent(self, e):
        if self.panning and self.pan_start is not None:
            delta = e.pos() - self.pan_start
            self.pan_start = e.pos()
            self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - delta.x())
            self.verticalScrollBar().setValue(self.verticalScrollBar().value() - delta.y())
        super().mouseMoveEvent(e)

    def mouseReleaseEvent(self, e):
        if e.button() == Qt.RightButton and self.panning:
            self.panning = False
            self.setCursor(Qt.CrossCursor if self.gcp_mode else Qt.ArrowCursor)
        super().mouseReleaseEvent(e)

    def add_gcp_marker(self, px_x, px_y, point_id):
        if point_id in self.gcp_markers:
            self.remove_gcp_marker(point_id)
        item = GCPMarkerGraphicsItem(px_x, px_y, point_id)
        self.scene.addItem(item)
        self.gcp_markers[point_id] = item

    def remove_gcp_marker(self, point_id):
        if point_id in self.gcp_markers:
            item = self.gcp_markers.pop(point_id)
            try:
                if item and not sip.isdeleted(item):
                    if item.scene() == self.scene:
                        self.scene.removeItem(item)
                    sip.delete(item)
            except (AttributeError, RuntimeError) as err:
                QgsLogger.warning(f"Failed to delete raster GCP marker item: {err}")

    def clear_gcp_markers(self):
        for item in list(self.gcp_markers.values()):
            try:
                if item and not sip.isdeleted(item):
                    if item.scene() == self.scene:
                        self.scene.removeItem(item)
                    sip.delete(item)
            except (AttributeError, RuntimeError) as err:
                QgsLogger.warning(f"Failed to delete raster GCP marker item: {err}")
        self.gcp_markers.clear()

    def toggle_gcp_placement(self, enabled):
        self.gcp_mode = enabled
        self.setCursor(Qt.CrossCursor if enabled else Qt.ArrowCursor)


# ==============================================================================
# 6. REFERENCE MAP CANVAS VIEWPORT (BASE MAP)
# ==============================================================================

class GCPMapCanvasMarker(QgsMapCanvasItem):
    """
    Custom QgsMapCanvasItem rendering a green crosshair X dot with point ID label.
    """
    def __init__(self, canvas, point_x, point_y, number, color=QColor(46, 204, 113)):
        super().__init__(canvas)
        self.canvas = canvas
        self.point = QgsPointXY(point_x, point_y)
        self.number = str(number)
        self.color = color
        self.setZValue(100)

    def paint(self, painter, option=None, widget=None):
        if not painter or not painter.isActive():
            return
        pt = self.toCanvasCoordinates(self.point)
        cx, cy = pt.x(), pt.y()

        painter.setRenderHint(QPainter.Antialiasing, True)
        pen = QPen(self.color, 3)
        painter.setPen(pen)

        s = 7
        painter.drawLine(int(cx - s), int(cy - s), int(cx + s), int(cy + s))
        painter.drawLine(int(cx - s), int(cy + s), int(cx + s), int(cy - s))

        painter.setBrush(QBrush(self.color))
        painter.drawEllipse(QPointF(cx, cy), 3, 3)

        painter.setFont(QFont("Arial", 11, QFont.Bold))
        painter.setPen(QPen(QColor(0, 150, 40)))
        painter.drawText(int(cx + 9), int(cy + 4), self.number)

    def boundingRect(self):
        pt = self.toCanvasCoordinates(self.point)
        return QRectF(pt.x() - 15, pt.y() - 15, 60, 40)


class CustomGCPMapTool(QgsMapTool):
    """
    Native QGIS Map Tool supporting Left-Click GCP selection and Right-Click Drag Panning.
    """
    def __init__(self, ref_canvas):
        super().__init__(ref_canvas.canvas)
        self.ref_canvas = ref_canvas
        self.panning = False
        self.start_pos = None

    def canvasPressEvent(self, e):
        if e.button() == Qt.RightButton:
            self.panning = True
            self.start_pos = e.pos()
            self.canvas().setCursor(Qt.ClosedHandCursor)
        elif e.button() == Qt.LeftButton:
            map_pt = e.mapPoint()
            self.ref_canvas.point_selected.emit(map_pt.x(), map_pt.y())

    def canvasMoveEvent(self, e):
        if self.panning and self.start_pos is not None:
            curr_pos = e.pos()
            dx = curr_pos.x() - self.start_pos.x()
            dy = curr_pos.y() - self.start_pos.y()
            self.start_pos = curr_pos

            mupp = self.canvas().mapUnitsPerPixel()
            center = self.canvas().center()
            new_x = center.x() - (dx * mupp)
            new_y = center.y() + (dy * mupp)
            self.canvas().setCenter(QgsPointXY(new_x, new_y))
            self.canvas().refresh()

    def canvasReleaseEvent(self, e):
        if e.button() == Qt.RightButton and self.panning:
            self.panning = False
            self.canvas().setCursor(Qt.CrossCursor if self.ref_canvas.marker_tool_active else Qt.ArrowCursor)


class ReferenceMapCanvas(QWidget):
    """
    Base Map Reference Window displaying georeferenced base map canvas (QgsMapCanvas).
    """
    point_selected = pyqtSignal(float, float)

    def __init__(self, iface=None, parent=None):
        super().__init__(parent)
        self.iface = iface
        self.active_basemap_layer = None
        self.gcp_markers = {}
        self.marker_tool_active = True
        self.init_ui()

    def init_ui(self):
        layout = QVBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(2)

        self.toolbar = QToolBar("Reference Map Toolbar")
        layout.addWidget(self.toolbar)

        self.basemap_combo = QComboBox()
        self.basemap_combo.addItems(list(BasemapProvider.BASEMAPS.keys()))
        self.basemap_combo.currentTextChanged.connect(self.change_basemap)
        self.toolbar.addWidget(QLabel(" Base Map: "))
        self.toolbar.addWidget(self.basemap_combo)
        self.toolbar.addSeparator()

        self.pan_action = QAction("Pan", self)
        self.pan_action.triggered.connect(self.set_pan_tool)
        self.toolbar.addAction(self.pan_action)

        self.zoom_in_action = QAction("Zoom In", self)
        self.zoom_in_action.triggered.connect(lambda: self.set_zoom_tool(True))
        self.toolbar.addAction(self.zoom_in_action)

        self.zoom_out_action = QAction("Zoom Out", self)
        self.zoom_out_action.triggered.connect(lambda: self.set_zoom_tool(False))
        self.toolbar.addAction(self.zoom_out_action)

        self.marker_action = QAction("📍 Add GCP", self)
        self.marker_action.setCheckable(True)
        self.marker_action.setChecked(True)
        self.marker_action.triggered.connect(self.set_marker_tool)
        self.toolbar.addAction(self.marker_action)

        self.home_action = QAction("Full Extent", self)
        self.home_action.triggered.connect(self.zoom_full)
        self.toolbar.addAction(self.home_action)

        self.canvas = QgsMapCanvas(self)
        self.canvas.setCanvasColor(QColor(245, 245, 245))
        self.canvas.enableAntiAliasing(True)
        layout.addWidget(self.canvas)

        self.pan_tool = QgsMapToolPan(self.canvas)
        self.zoom_in_tool = QgsMapToolZoom(self.canvas, False)
        self.zoom_out_tool = QgsMapToolZoom(self.canvas, True)
        self.gcp_map_tool = CustomGCPMapTool(self)

        self.canvas.setMapTool(self.gcp_map_tool)
        self.change_basemap("OpenStreetMap")

    def change_basemap(self, basemap_name):
        curr_extent = None
        if self.active_basemap_layer and not self.canvas.extent().isEmpty():
            curr_extent = self.canvas.extent()

        if self.active_basemap_layer:
            QgsProject.instance().removeMapLayer(self.active_basemap_layer.id())

        layer = BasemapProvider.get_basemap_layer(basemap_name)
        if layer.isValid():
            QgsProject.instance().addMapLayer(layer, False)
            self.active_basemap_layer = layer
            self.canvas.setLayers([layer])
            self.canvas.setDestinationCrs(QgsCoordinateReferenceSystem("EPSG:3857"))
            if curr_extent and not curr_extent.isEmpty():
                self.canvas.setExtent(curr_extent)
                self.canvas.refresh()
            else:
                self.canvas.zoomToFullExtent()

    def set_pan_tool(self):
        self.marker_action.setChecked(False)
        self.marker_tool_active = False
        self.canvas.setMapTool(self.pan_tool)

    def set_zoom_tool(self, in_mode=True):
        self.marker_action.setChecked(False)
        self.marker_tool_active = False
        self.canvas.setMapTool(self.zoom_in_tool if in_mode else self.zoom_out_tool)

    def set_marker_tool(self, checked):
        self.marker_tool_active = checked
        if not checked:
            self.set_pan_tool()
        else:
            self.canvas.setMapTool(self.gcp_map_tool)
            self.canvas.setCursor(Qt.CrossCursor)

    def zoom_full(self):
        self.canvas.zoomToFullExtent()

    def center_on_map_point(self, ref_x, ref_y):
        self.canvas.setCenter(QgsPointXY(ref_x, ref_y))
        self.canvas.refresh()

    def center_on_location(self, lat, lon, zoom_level=14):
        try:
            src_crs = QgsCoordinateReferenceSystem("EPSG:4326")
            dst_crs = self.canvas.mapSettings().destinationCrs()
            ct = QgsCoordinateTransform(src_crs, dst_crs, QgsProject.instance())
            pt_dst = ct.transform(QgsPointXY(lon, lat))
            self.canvas.setCenter(pt_dst)
            self.canvas.zoomScale(50000)
            self.canvas.refresh()
        except Exception as e:
            print(f"Error centering on location ({lat}, {lon}): {e}")

    def add_gcp_marker(self, ref_x, ref_y, point_id):
        if point_id in self.gcp_markers:
            self.remove_gcp_marker(point_id)
        item = GCPMapCanvasMarker(self.canvas, ref_x, ref_y, point_id)
        self.gcp_markers[point_id] = item
        self.canvas.refresh()

    def remove_gcp_marker(self, point_id):
        if point_id in self.gcp_markers:
            item = self.gcp_markers.pop(point_id)
            try:
                if item and not sip.isdeleted(item):
                    sip.delete(item)
            except (AttributeError, RuntimeError) as err:
                QgsLogger.warning(f"Failed to delete GCP marker item: {err}")
            self.canvas.refresh()

    def clear_gcp_markers(self):
        for item in list(self.gcp_markers.values()):
            try:
                if item and not sip.isdeleted(item):
                    sip.delete(item)
            except (AttributeError, RuntimeError) as err:
                QgsLogger.warning(f"Failed to delete GCP marker item: {err}")
        self.gcp_markers.clear()
        self.canvas.refresh()


# ==============================================================================
# 7. UI DIALOGS & COMPONENTS
# ==============================================================================

class GCPTableDialog(QDialog):
    """
    Popup Floating Dialog Window containing Ground Control Points table.
    """
    def __init__(self, gcp_model, parent=None):
        super().__init__(parent)
        self.setWindowTitle("Ground Control Points (GCP Table)")
        self.resize(750, 400)
        self.gcp_model = gcp_model

        layout = QVBoxLayout(self)
        self.table_view = QTableView(self)
        self.table_view.setModel(self.gcp_model)
        self.table_view.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.table_view.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
        layout.addWidget(self.table_view)

        btn_layout = QHBoxLayout()
        del_btn = QPushButton("Delete Selected")
        del_btn.clicked.connect(self.delete_selected)
        btn_layout.addWidget(del_btn)

        clear_btn = QPushButton("Clear All")
        clear_btn.clicked.connect(self.gcp_model.clear)
        btn_layout.addWidget(clear_btn)

        close_btn = QPushButton("Close")
        close_btn.clicked.connect(self.accept)
        btn_layout.addWidget(close_btn)
        layout.addLayout(btn_layout)

    def delete_selected(self):
        selection = self.table_view.selectionModel()
        rows = set()
        if selection:
            for idx in selection.selectedIndexes():
                if idx.isValid():
                    rows.add(idx.row())
        if not rows:
            idx = self.table_view.currentIndex()
            if idx.isValid() and 0 <= idx.row() < self.gcp_model.rowCount():
                rows.add(idx.row())

        if rows:
            self.gcp_model.remove_gcp_rows(rows)



class LogConsole(QTextEdit):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setReadOnly(True)
        self.setStyleSheet("background-color: #1e1e1e; color: #dcdcdc; font-family: monospace;")

    def log_info(self, msg):
        self.append(f"🟢 {msg}")

    def log_error(self, msg):
        self.append(f"🔴 {msg}")


class LogConsoleDialog(QDialog):
    def __init__(self, log_console, parent=None):
        super().__init__(parent)
        self.setWindowTitle("Operation Log & Output")
        self.resize(600, 350)
        layout = QVBoxLayout(self)
        layout.addWidget(log_console)


class ProgressBarWidget(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        layout = QHBoxLayout(self)
        layout.setContentsMargins(4, 2, 4, 2)

        self.stage_label = QLabel("Stage: Ready")
        layout.addWidget(self.stage_label)

        self.rmse_label = QLabel("RMSE: 0.00 px")
        layout.addWidget(self.rmse_label)

        self.progress_bar = QProgressBar(self)
        self.progress_bar.setRange(0, 100)
        self.progress_bar.setValue(0)
        self.progress_bar.setMaximumWidth(220)
        layout.addWidget(self.progress_bar)

    def set_stage(self, stage, algo=""):
        self.stage_label.setText(f"Stage: {stage} ({algo})" if algo else f"Stage: {stage}")

    def set_progress(self, val):
        v = int(val)
        self.progress_bar.setValue(v)
        self.progress_bar.setFormat(f"{v}%")
        self.progress_bar.repaint()

    def set_rmse_crs(self, rmse, crs):
        self.rmse_label.setText(f"RMSE: {rmse:.2f} px")


class CoordinateParser:
    """
    Robust free-format coordinate parser extracting Lat/Lon, DMS, and Easting/Northing.
    """
    @classmethod
    def parse(cls, text):
        if not text:
            return None
        t = text.strip()

        # 1. N/S and E/W direction labels (e.g. "29.6803° N latitude and 76.9896° E")
        lat_m = re.search(r'([-+]?\d+\.?\d*)\s*°?\s*([NS])', t, re.IGNORECASE)
        lon_m = re.search(r'([-+]?\d+\.?\d*)\s*°?\s*([EW])', t, re.IGNORECASE)
        if lat_m and lon_m:
            lat = float(lat_m.group(1)) * (-1 if lat_m.group(2).upper() == 'S' else 1)
            lon = float(lon_m.group(1)) * (-1 if lon_m.group(2).upper() == 'W' else 1)
            return (lat, lon, 'latlon')

        # 2. DMS Degrees Minutes Seconds (e.g. 29°40'49"N 76°59'22"E)
        dms_m = re.findall(r'(\d+)\s*°\s*(\d+)\s*[\'′]\s*(\d+\.?\d*)\s*[\"″]?\s*([NSEW])', t, re.IGNORECASE)
        if len(dms_m) == 2:
            coords = {}
            for deg, m, s, dir_c in dms_m:
                val = float(deg) + float(m)/60.0 + float(s)/3600.0
                d = dir_c.upper()
                if d in ('S', 'W'):
                    val = -val
                coords[d] = val
            lat = coords.get('N', coords.get('S', 0.0))
            lon = coords.get('E', coords.get('W', 0.0))
            return (lat, lon, 'latlon')

        # 3. Numeric extraction
        nums = [float(n) for n in re.findall(r'[-+]?\d+\.?\d*', t)]
        if len(nums) >= 2:
            n1, n2 = nums[0], nums[1]
            if -90 <= n1 <= 90 and -180 <= n2 <= 180:
                return (n1, n2, 'latlon')
            elif -180 <= n1 <= 180 and -90 <= n2 <= 90:
                return (n2, n1, 'latlon')
            elif abs(n1) > 1000 and abs(n2) > 1000:
                return (n1, n2, 'meters')

        return None


class NominatimThread(QThread):
    results_ready = pyqtSignal(list)

    def __init__(self, query):
        super().__init__()
        self.query = query

    def run(self):
        try:
            url = f"https://nominatim.openstreetmap.org/search?format=json&q={urllib.parse.quote(self.query)}&addressdetails=1"
            parsed_url = urllib.parse.urlparse(url)
            if parsed_url.scheme not in ('http', 'https'):
                raise ValueError(f"Unsupported URL scheme: {parsed_url.scheme}")
            req = urllib.request.Request(url, headers={'User-Agent': 'Georeferencer/2.0'})
            with urllib.request.urlopen(req, timeout=4) as resp:  # nosec B310
                data = json.loads(resp.read().decode())
                self.results_ready.emit(data if isinstance(data, list) else [])
        except Exception as err:
            QgsLogger.warning(f"Nominatim search request error: {err}")
            self.results_ready.emit([])


class LocationSearchBar(QLineEdit):
    """
    Search bar dedicated for querying place names via OpenStreetMap Nominatim with auto-suggest completer.
    """
    location_selected = pyqtSignal(float, float, str)

    def __init__(self, parent=None):
        super().__init__(parent)
        self.setPlaceholderText("🔍 Search place name (e.g. Karnal, London, Paris)...")
        
        self.completer_model = QStandardItemModel(self)
        self.completer = QCompleter(self.completer_model, self)
        self.completer.setCaseSensitivity(Qt.CaseInsensitive)
        self.completer.setFilterMode(Qt.MatchContains)
        self.setCompleter(self.completer)

        self.completer.activated[QModelIndex].connect(self.on_item_selected)

        self.debounce_timer = QTimer(self)
        self.debounce_timer.setSingleShot(True)
        self.debounce_timer.setInterval(350)
        self.debounce_timer.timeout.connect(self.fetch_suggestions)

        self.textChanged.connect(self.on_text_changed)
        self.returnPressed.connect(self.fetch_suggestions)
        self.thread = None

    def on_text_changed(self, text):
        if len(text.strip()) >= 3:
            self.debounce_timer.start()

    def fetch_suggestions(self):
        txt = self.text().strip()
        if len(txt) < 3:
            return

        if self.thread and self.thread.isRunning():
            self.thread.terminate()

        self.thread = NominatimThread(txt)
        self.thread.results_ready.connect(self.update_suggestions)
        self.thread.start()

    def update_suggestions(self, results):
        self.completer_model.clear()
        for item in results[:8]:
            display = item.get('display_name', '')
            try:
                lat = float(item.get('lat', 0.0))
                lon = float(item.get('lon', 0.0))
                std_item = QStandardItem(display)
                std_item.setData((lat, lon, display), Qt.UserRole)
                self.completer_model.appendRow(std_item)
            except (ValueError, TypeError):
                continue

        if self.completer_model.rowCount() > 0:
            self.completer.complete()

    def on_item_selected(self, index):
        data = index.data(Qt.UserRole)
        if data and len(data) == 3:
            lat, lon, display = data
            self.location_selected.emit(lat, lon, display)


class CoordinateSearchBar(QLineEdit):
    """
    Search bar dedicated for free-format coordinate jumping (Lat/Lon, DMS, Easting/Northing).
    """
    location_selected = pyqtSignal(float, float, str)
    map_point_selected = pyqtSignal(float, float)

    def __init__(self, parent=None):
        super().__init__(parent)
        self.setPlaceholderText("📍 Jump to coordinates (e.g. 29.6803° N, 76.9896° E or 8570543, 3463213)...")

        self.completer_model = QStandardItemModel(self)
        self.completer = QCompleter(self.completer_model, self)
        self.completer.setCaseSensitivity(Qt.CaseInsensitive)
        self.setCompleter(self.completer)

        self.completer.activated[QModelIndex].connect(self.on_item_selected)
        self.textChanged.connect(self.on_text_changed)
        self.returnPressed.connect(self.do_jump)

    def on_text_changed(self, text):
        txt = text.strip()
        parsed = CoordinateParser.parse(txt)
        self.completer_model.clear()
        if parsed:
            if parsed[2] == 'latlon':
                label = f"📍 Coordinates: Lat {parsed[0]:.5f}°, Lon {parsed[1]:.5f}°"
                item = QStandardItem(label)
                item.setData(parsed, Qt.UserRole)
                self.completer_model.appendRow(item)
            elif parsed[2] == 'meters':
                label = f"📍 Map Meters: Easting {parsed[0]:.2f}, Northing {parsed[1]:.2f}"
                item = QStandardItem(label)
                item.setData(parsed, Qt.UserRole)
                self.completer_model.appendRow(item)
            self.completer.complete()

    def do_jump(self):
        txt = self.text().strip()
        parsed = CoordinateParser.parse(txt)
        if parsed:
            if parsed[2] == 'latlon':
                self.location_selected.emit(parsed[0], parsed[1], f"Coordinate Jump ({parsed[0]:.5f}, {parsed[1]:.5f})")
            elif parsed[2] == 'meters':
                self.map_point_selected.emit(parsed[0], parsed[1])

    def on_item_selected(self, index):
        data = index.data(Qt.UserRole)
        if data and len(data) == 3:
            if data[2] == 'latlon':
                self.location_selected.emit(data[0], data[1], str(index.data(Qt.DisplayRole)))
            elif data[2] == 'meters':
                self.map_point_selected.emit(data[0], data[1])




# ==============================================================================
# 8. MAIN STANDALONE APPLICATION WINDOW
# ==============================================================================

class SmartGeoreferencerWindow(QMainWindow):
    """
    Main 2-Viewport Georeferencer Application.
    """

    def __init__(self, iface, parent=None):
        super().__init__(parent)
        self.iface = iface
        self.setWindowTitle("Smart Georeferencer - Image Rectifier")
        self.resize(1350, 880)

        self.gcp_model = GCPTableModel(self)
        self.raster_filepath = None
        self.target_crs = QgsCoordinateReferenceSystem("EPSG:3857")
        self.pending_px_x = None
        self.pending_px_y = None
        self.export_task = None
        self.is_exporting = False

        self.init_ui()

    def init_ui(self):
        self.gcp_dialog = GCPTableDialog(self.gcp_model, self)
        self.gcp_model.rowsRemoved.connect(self.on_gcp_data_changed)
        self.gcp_model.modelReset.connect(self.on_gcp_data_changed)
        self.gcp_model.dataChanged.connect(self.on_gcp_data_changed)

        self.log_console = LogConsole(self)
        self.log_dialog = LogConsoleDialog(self.log_console, self)

        # Menubar & Toolbar
        menubar = self.menuBar()
        file_menu = menubar.addMenu("&File")
        open_act = QAction("📂 Open Ungeoreferenced Raster...", self)
        open_act.triggered.connect(self.open_raster_file)
        file_menu.addAction(open_act)

        start_georef_act = QAction("▶ Start Georeferencing...", self)
        start_georef_act.triggered.connect(self.start_georeferencing)
        file_menu.addAction(start_georef_act)

        main_toolbar = QToolBar("Main Georeferencer Toolbar")
        self.addToolBar(main_toolbar)
        main_toolbar.addAction(open_act)
        main_toolbar.addAction(start_georef_act)
        main_toolbar.addSeparator()

        gcp_dialog_act = QAction("🎯 GCP Table", self)
        gcp_dialog_act.triggered.connect(self.gcp_dialog.show)
        main_toolbar.addAction(gcp_dialog_act)

        log_dialog_act = QAction("📋 Operation Log", self)
        log_dialog_act.triggered.connect(self.log_dialog.show)
        main_toolbar.addAction(log_dialog_act)

        central_widget = QWidget(self)
        main_layout = QVBoxLayout(central_widget)
        main_layout.setContentsMargins(6, 6, 6, 6)

        search_layout = QHBoxLayout()
        search_layout.setSpacing(6)

        self.name_search_bar = LocationSearchBar(self)
        search_layout.addWidget(self.name_search_bar, stretch=1)

        self.coord_search_bar = CoordinateSearchBar(self)
        search_layout.addWidget(self.coord_search_bar, stretch=1)

        main_layout.addLayout(search_layout)

        h_splitter = QSplitter(Qt.Horizontal)


        p1_box = QGroupBox("Input Image (Referencing)")
        p1_layout = QVBoxLayout(p1_box)
        self.raster_canvas = RasterCanvas(self)
        p1_layout.addWidget(self.raster_canvas)
        h_splitter.addWidget(p1_box)

        p2_box = QGroupBox("Base Map Reference")
        p2_layout = QVBoxLayout(p2_box)
        self.ref_canvas = ReferenceMapCanvas(self.iface, self)
        p2_layout.addWidget(self.ref_canvas)
        h_splitter.addWidget(p2_box)

        h_splitter.setSizes([600, 600])
        main_layout.addWidget(h_splitter, stretch=10)

        gcp_entry_box = QGroupBox("Ground Control Point (GCP) Entry Controls")
        gcp_entry_layout = QHBoxLayout(gcp_entry_box)
        gcp_entry_layout.setContentsMargins(6, 4, 6, 4)

        gcp_entry_layout.addWidget(QLabel("Pixel X:"))
        self.px_x_edit = QLineEdit()
        self.px_x_edit.setMinimumWidth(85)
        gcp_entry_layout.addWidget(self.px_x_edit)

        gcp_entry_layout.addWidget(QLabel("Pixel Y:"))
        self.px_y_edit = QLineEdit()
        self.px_y_edit.setMinimumWidth(85)
        gcp_entry_layout.addWidget(self.px_y_edit)

        gcp_entry_layout.addWidget(QLabel("X/Easting/Lon:"))
        self.ref_x_edit = QLineEdit()
        self.ref_x_edit.setMinimumWidth(180)
        self.ref_x_edit.setToolTip("Ground Easting / Longitude Coordinate")
        gcp_entry_layout.addWidget(self.ref_x_edit, stretch=1)

        gcp_entry_layout.addWidget(QLabel("Y/Northing/Lat:"))
        self.ref_y_edit = QLineEdit()
        self.ref_y_edit.setMinimumWidth(180)
        self.ref_y_edit.setToolTip("Ground Northing / Latitude Coordinate")
        gcp_entry_layout.addWidget(self.ref_y_edit, stretch=1)

        gcp_entry_layout.addSpacing(10)
        gcp_entry_layout.addWidget(QLabel("Algorithm:"))
        self.algo_combo = QComboBox()
        self.algo_combo.addItems(["Linear", "Helmert", "Polynomial 1", "Polynomial 2", "Polynomial 3", "Thin Plate Spline"])
        self.algo_combo.setCurrentText("Helmert")
        self.algo_combo.currentTextChanged.connect(self.recalculate_rmse)
        gcp_entry_layout.addWidget(self.algo_combo)

        gcp_entry_layout.addSpacing(10)
        self.open_gcp_btn = QPushButton("🎯 GCP Table")
        self.open_gcp_btn.clicked.connect(self.gcp_dialog.show)
        gcp_entry_layout.addWidget(self.open_gcp_btn)

        self.open_log_btn = QPushButton("📋 Log")
        self.open_log_btn.clicked.connect(self.log_dialog.show)
        gcp_entry_layout.addWidget(self.open_log_btn)

        main_layout.addWidget(gcp_entry_box)

        self.progress_widget = ProgressBarWidget(self)
        main_layout.addWidget(self.progress_widget)

        self.setCentralWidget(central_widget)

        self.name_search_bar.location_selected.connect(self.on_location_found)
        self.coord_search_bar.location_selected.connect(self.on_location_found)
        self.coord_search_bar.map_point_selected.connect(self.on_map_point_found)
        self.raster_canvas.pixel_selected.connect(self.on_pixel_selected)
        self.ref_canvas.point_selected.connect(self.on_ref_point_selected)


    def on_gcp_data_changed(self, top_left=None, bottom_right=None, roles=None):
        if top_left and top_left.column() == 5 and bottom_right and bottom_right.column() == 5:
            return
        self.sync_gcp_markers()
        self.recalculate_rmse()

    def sync_gcp_markers(self):
        self.raster_canvas.clear_gcp_markers()
        self.ref_canvas.clear_gcp_markers()
        for gcp in self.gcp_model.get_gcps():
            if gcp.enabled:
                self.raster_canvas.add_gcp_marker(gcp.src_x, gcp.src_y, gcp.id)
                self.ref_canvas.add_gcp_marker(gcp.ref_x, gcp.ref_y, gcp.id)

    def open_raster_file(self):
        path, _ = QFileDialog.getOpenFileName(self, "Open Ungeoreferenced Raster", "", "Raster Files (*.tif *.tiff *.geotiff *.png *.jpg *.jpeg);;All Files (*)")
        if path:
            self.raster_filepath = path
            self.log_console.log_info(f"⏳ Asynchronously loading raster: {path}")
            self.progress_widget.set_stage("Loading Image...", "Background Thread")
            self.raster_canvas.load_raster_async(path, self.on_raster_loaded)

    def on_raster_loaded(self, success, w, h):
        if success:
            self.log_console.log_info(f"✅ Raster loaded: {w} x {h} px")
            self.progress_widget.set_stage("Raster Loaded", "Idle")
        else:
            self.log_console.log_error("Failed to load raster image.")
            self.progress_widget.set_stage("Error Loading Raster", "")


    def on_pixel_selected(self, px_x, px_y):
        next_id = len(self.gcp_model.get_gcps()) + 1
        self.pending_px_x = px_x
        self.pending_px_y = px_y
        self.px_x_edit.setText(f"{px_x:.2f}")
        self.px_y_edit.setText(f"{px_y:.2f}")
        self.raster_canvas.add_gcp_marker(px_x, px_y, next_id)
        self.log_console.log_info(f"📍 Selected User Image Point #{next_id}: ({px_x:.1f}, {px_y:.1f}). Now click matching ground point on Base Map.")

    def on_ref_point_selected(self, ref_x, ref_y):
        self.ref_x_edit.setText(f"{ref_x:.6f}")
        self.ref_y_edit.setText(f"{ref_y:.6f}")

        if self.pending_px_x is not None and self.pending_px_y is not None:
            px_x, px_y = self.pending_px_x, self.pending_px_y
            gcp = self.gcp_model.add_gcp(px_x, px_y, ref_x, ref_y)
            self.ref_canvas.add_gcp_marker(ref_x, ref_y, gcp.id)
            self.raster_canvas.add_gcp_marker(px_x, px_y, gcp.id)
            self.log_console.log_info(f"✅ Paired GCP #{gcp.id}: Pixel({px_x:.1f}, {px_y:.1f}) <-> Map({ref_x:.5f}, {ref_y:.5f})")
            self.pending_px_x = None
            self.pending_px_y = None
            self.recalculate_rmse()

    def on_location_found(self, lat, lon, display_name):
        self.log_console.log_info(f"Center map on search: {display_name} ({lat:.4f}, {lon:.4f})")
        self.ref_canvas.center_on_location(lat, lon)

    def on_map_point_found(self, ref_x, ref_y):
        self.log_console.log_info(f"Center map on coordinate: Easting {ref_x:.2f}, Northing {ref_y:.2f}")
        self.ref_canvas.center_on_map_point(ref_x, ref_y)


    def recalculate_rmse(self):
        gcps = self.gcp_model.get_gcps(enabled_only=True)
        algo_name = self.algo_combo.currentText()
        if len(gcps) >= 2:
            try:
                src_pts = [(p.src_x, p.src_y) for p in gcps]
                ref_pts = [(p.ref_x, p.ref_y) for p in gcps]
                model = TransformationEngine.compute_transformation(src_pts, ref_pts, algo_name)
                residuals, global_rmse = RMSECalculator.calculate_residuals(self.gcp_model.get_gcps(), model)
                self.gcp_model.set_residuals(residuals)
                self.progress_widget.set_rmse_crs(global_rmse, self.target_crs.authid())
            except Exception as e:
                print(f"RMSE calculation error: {e}")
        else:
            for gcp in self.gcp_model.get_gcps():
                gcp.residual_x = 0.0
                gcp.residual_y = 0.0
                gcp.residual_total = 0.0
            self.progress_widget.set_rmse_crs(0.0, self.target_crs.authid())

    def start_georeferencing(self):
        self.export_geotiff()

    def export_geotiff(self):
        if self.is_exporting:
            return

        if not self.raster_filepath:
            QMessageBox.warning(self, "No File Loaded", "Please load a raster map first.")
            return

        enabled_gcps = self.gcp_model.get_gcps(enabled_only=True)
        if len(enabled_gcps) < 2:
            QMessageBox.warning(self, "Insufficient GCPs", "At least 2 Ground Control Points are required to georeference.")
            return

        self.is_exporting = True
        self.progress_widget.set_progress(0)

        base_dir = os.path.dirname(self.raster_filepath)
        filename = os.path.splitext(os.path.basename(self.raster_filepath))[0]
        dst_path = os.path.join(base_dir, f"{filename}_georeferenced.tif")

        try:
            with open(dst_path, 'a'):
                pass
            if os.path.exists(dst_path):
                os.remove(dst_path)
        except Exception:
            dst_path = os.path.join(tempfile.gettempdir(), f"{filename}_georeferenced.tif")

        algo_name = self.algo_combo.currentText()
        crs_str = self.target_crs.authid() if self.target_crs.authid() else "EPSG:3857"
        self.log_console.log_info(f"Georeferencing & Warping raster ({algo_name}): {dst_path}")
        self.progress_widget.set_stage("Warping Raster", f"GDAL Warp ({algo_name})")

        def on_export_complete(success, path):
            self.is_exporting = False
            self.export_task = None
            if success:
                self.progress_widget.set_progress(100)
                self.log_console.log_info(f"🎉 Georeferencing Successful: {path}")
                self.progress_widget.set_stage("Complete", "Idle")

                layer_name = os.path.splitext(os.path.basename(path))[0]
                raster_layer = QgsRasterLayer(path, layer_name)
                if raster_layer.isValid():
                    QgsProject.instance().addMapLayer(raster_layer)
                    if hasattr(self.iface, "mapCanvas") and self.iface.mapCanvas():
                        self.iface.mapCanvas().setExtent(raster_layer.extent())
                        self.iface.mapCanvas().refresh()
                    self.log_console.log_info(f"✅ Loaded '{layer_name}' into QGIS Map Canvas layers.")

                QMessageBox.information(self, "Georeferencing Complete", f"Successfully georeferenced raster!\n\nOutput GeoTIFF: {path}\nLoaded into QGIS Map Layers.")
            else:
                self.progress_widget.set_progress(0)
                self.log_console.log_error("Georeferencing failed.")

        self.export_task = ExportTaskWorker(
            self.raster_filepath, dst_path, enabled_gcps,
            crs_str, algo_name, on_export_complete
        )
        self.export_task.progressChanged.connect(self.progress_widget.set_progress)
        from qgis.core import QgsApplication
        QgsApplication.taskManager().addTask(self.export_task)
