# -*- coding: utf-8 -*-
"""3D OSM Model - QGIS plugin entry point."""
from __future__ import annotations

import contextlib
import os
import webbrowser

from qgis.core import (
    Qgis,
    QgsApplication,
    QgsDistanceArea,
    QgsGeometry,
    QgsMessageLog,
    QgsProject,
    QgsRasterLayer,
    QgsTask,
    QgsVectorLayer,
    QgsWkbTypes,
)
from qgis.PyQt.QtCore import QEventLoop, Qt
from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import QAction, QApplication, QMessageBox

from .builder import build_and_export, prepare_study_area
from .server import Osm3dServer


# PyQt5 exposes this flag directly on QEventLoop, while PyQt6 nests it under
# ProcessEventsFlag.  QGIS 3 and QGIS 4 therefore need different enum paths.
_EXCLUDE_USER_INPUT_EVENTS = getattr(
    QEventLoop, "ProcessEventsFlag", QEventLoop
).ExcludeUserInputEvents


class _OverpassFetchTask(QgsTask):
    """Network-only task; QGIS geometry and layer work stays on the UI thread."""

    def __init__(self, bbox, use_cache, callback):
        super().__init__("3D OSM Model: download OpenStreetMap", QgsTask.CanCancel)
        self.bbox = tuple(float(value) for value in bbox)
        self.use_cache = bool(use_cache)
        self.callback = callback
        self.payload = None
        self.error_text = ""

    def run(self):
        from .osm_download import fetch_overpass

        if self.isCanceled():
            self.error_text = "OSM download cancelled."
            return False
        try:
            self.payload = fetch_overpass(
                *self.bbox,
                use_cache=self.use_cache,
                cancel_check=self.isCanceled,
            )
            return not self.isCanceled()
        except Exception as exc:
            self.error_text = str(exc)
            return False

    def finished(self, result):
        callback, self.callback = self.callback, None
        if callback is not None:
            callback(self, bool(result))


class Osm3dModelPlugin:
    # Live OpenStreetMap tiles, added with one click so the study area can be
    # framed on an empty project. Braces are percent-encoded for the WMS/XYZ URI.
    OSM_BASEMAP_NAME = "OpenStreetMap"
    OSM_BASEMAP_URI = (
        "type=xyz&url=https://tile.openstreetmap.org/%7Bz%7D/%7Bx%7D/%7By%7D.png"
        "&zmax=19&zmin=0&crs=EPSG3857"
    )

    def __init__(self, iface):
        self.iface = iface
        self.plugin_dir = os.path.dirname(__file__)
        self.web_root = os.path.join(self.plugin_dir, "web")
        self.action = None
        self.dialog = None
        self.server = Osm3dServer(self.web_root)
        self._running = False
        self._unloaded = False
        self._canvas_probe_connected = False
        self._task = None
        self._pending_run = None

    # -- QGIS lifecycle -----------------------------------------------------
    def initGui(self):
        if self._unloaded or self.action is not None:
            return
        icon = QIcon(os.path.join(self.plugin_dir, "icons", "icon.png"))
        self.action = QAction(icon, "3D OSM Model", self.iface.mainWindow())
        self.action.setStatusTip("Download OSM for an area and open it as a 3D city")
        self.action.triggered.connect(self.show_dialog)
        self.iface.addToolBarIcon(self.action)
        self.iface.addPluginToMenu("&3D OSM Model", self.action)

    def unload(self):
        if self._unloaded:
            return
        self._unloaded = True
        self._running = False

        task, self._task = self._task, None
        self._pending_run = None
        if task is not None:
            task.cancel()

        action, self.action = self.action, None
        dialog, self.dialog = self.dialog, None
        server, self.server = self.server, None
        iface, self.iface = self.iface, None

        if action is not None:
            try:
                action.triggered.disconnect(self.show_dialog)
            except (RuntimeError, TypeError):
                pass
            if iface is not None:
                iface.removePluginMenu("&3D OSM Model", action)
                iface.removeToolBarIcon(action)
            action.deleteLater()

        if dialog is not None:
            for signal, slot in (
                (dialog.runRequested, self.run_export),
                (dialog.reopenRequested, self.reopen_viewer),
                (dialog.openFolderRequested, self._open_data_folder),
                (dialog.clearCacheRequested, self._clear_osm_cache),
                (dialog.basemapRequested, self.add_osm_basemap),
            ):
                try:
                    signal.disconnect(slot)
                except (RuntimeError, TypeError):
                    pass
            self._disconnect_canvas_probe()
            dialog.area_probe = None
            dialog.prepare_for_unload()
            dialog.close()
            dialog.deleteLater()

        if server is not None:
            server.stop()

    def show_dialog(self):
        if self._unloaded or self._running or self.iface is None:
            return
        if self.dialog is None:
            from .dialog import Osm3dModelDialog

            self.dialog = Osm3dModelDialog(self.iface.mainWindow())
            self.dialog.runRequested.connect(self.run_export)
            self.dialog.reopenRequested.connect(self.reopen_viewer)
            self.dialog.openFolderRequested.connect(self._open_data_folder)
            self.dialog.clearCacheRequested.connect(self._clear_osm_cache)
            self.dialog.basemapRequested.connect(self.add_osm_basemap)
            self.dialog.area_probe = self._area_summary
            self._connect_canvas_probe()
        self.dialog.show()
        self.dialog.raise_()
        self.dialog.activateWindow()

    # -- Geometry resolution ------------------------------------------------
    def _resolve_area(self, source: str):
        """Return (QgsGeometry, QgsCoordinateReferenceSystem) for the chosen source."""
        if source == "selection":
            layer = self.iface.activeLayer()
            if not isinstance(layer, QgsVectorLayer) or layer.geometryType() != QgsWkbTypes.PolygonGeometry:
                raise ValueError("Select a polygon layer as the active layer, then select feature(s).")
            feats = [f.geometry() for f in layer.selectedFeatures() if f.hasGeometry() and not f.geometry().isEmpty()]
            if not feats:
                raise ValueError("No selected polygon features in the active layer.")
            combined = QgsGeometry.collectGeometry(feats) if len(feats) > 1 else QgsGeometry(feats[0])
            if combined.isEmpty():
                raise ValueError("Selected features have no usable geometry.")
            return combined, layer.crs()

        # Default: current map canvas extent.
        canvas = self.iface.mapCanvas()
        rect = canvas.extent()
        if rect.isEmpty():
            raise ValueError("The map view is empty. Zoom to your area first.")
        return QgsGeometry.fromRect(rect), canvas.mapSettings().destinationCrs()

    # -- Main action --------------------------------------------------------
    def run_export(self, params: dict):
        if self._unloaded or self._running:
            return
        self._running = True
        cursor_set = False

        try:
            geom, crs = self._resolve_area(params.get("source", "canvas"))
            if self._unloaded:
                return
            if self.dialog is not None:
                self.dialog.set_status("Computing study area...", busy=True)
            QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
            cursor_set = True
            QApplication.processEvents(_EXCLUDE_USER_INPUT_EVENTS)

            prepared = prepare_study_area(
                geom, crs,
                max_ha=float(params.get("max_ha", 300.0)),
                shape=params.get("shape", "circle"),
            )
            if self._unloaded:
                return
            self._pending_run = (dict(params), prepared)
            task = _OverpassFetchTask(
                prepared["wgs_bbox"], params.get("use_cache", True),
                self._on_osm_fetched,
            )
            self._task = task
            if self.dialog is not None:
                self.dialog.set_status(
                    "Downloading OpenStreetMap data in the background...", busy=True,
                )
            QgsApplication.taskManager().addTask(task)
        except Exception as exc:
            task, self._task = self._task, None
            self._pending_run = None
            if task is not None:
                task.cancel()
            if not self._unloaded:
                self._fail(str(exc))
            self._running = False
        finally:
            if cursor_set:
                QApplication.restoreOverrideCursor()

    def _on_osm_fetched(self, task: _OverpassFetchTask, success: bool):
        """Continue on the QGIS UI thread after the background HTTP request."""
        if task is not self._task:
            return
        self._task = None
        pending, self._pending_run = self._pending_run, None
        if self._unloaded:
            self._running = False
            return
        if not success or task.payload is None:
            self._running = False
            self._fail(task.error_text or "The OpenStreetMap download was cancelled.")
            return
        if pending is None:
            self._running = False
            self._fail("The prepared export state was lost.")
            return
        params, prepared = pending
        self._finish_export(params, prepared, task.payload)

    def _feedback(self, msg: str):
        if self._unloaded:
            return
        if self.dialog is not None:
            self.dialog.set_status(msg, busy=True)
        QApplication.processEvents(_EXCLUDE_USER_INPUT_EVENTS)

    def _finish_export(self, params: dict, prepared: dict, osm_payload: dict):
        """Parse, clip and publish the downloaded model on the QGIS UI thread."""
        cursor_set = False
        try:
            QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
            cursor_set = True
            summary = build_and_export(
                None, None, self.web_root,
                dem_layer=params.get("dem_layer"),
                basemap_layer=params.get("basemap_layer"),
                max_ha=float(params.get("max_ha", 300.0)),
                add_to_project=True,
                shape=params.get("shape", "circle"),
                theme=params.get("theme", "Plugin Tones"),
                use_cache=params.get("use_cache", True),
                feedback=self._feedback,
                prepared=prepared,
                osm_payload=osm_payload,
            )
            if self._unloaded or self.server is None:
                return
            url = self.server.start()
            if params.get("auto_open", True):
                webbrowser.open(url)
            if self._unloaded:
                return

            counts = summary.get("counts", {})
            furniture = sum(
                counts.get(key, 0)
                for key in ("busstops", "benches", "lights", "trashbins")
            )
            waterways = counts.get("waterlines", 0) + counts.get("waterareas", 0)
            if summary.get("radius_m"):
                dim = f"r={summary['radius_m']} m"
            elif summary.get("width_m"):
                dim = f"{summary['width_m']}×{summary['depth_m']} m"
            else:
                dim = summary.get("shape_label", "study area")
            msg = (
                f"Done. {counts.get('buildings', 0)} buildings, "
                f"{counts.get('roads', 0)} roads, "
                f"{counts.get('bikelanes', 0)} bike lanes, "
                f"{counts.get('greens', 0)} greens, "
                f"{counts.get('trees', 0)} trees, {waterways} waterways, "
                f"{furniture} furniture | "
                f"{dim} ({summary.get('area_ha')} ha). Viewer: {url}"
            )
            if self.dialog is not None:
                self.dialog.set_status(msg, busy=False)
                self.dialog.show_summary(summary, url)
            if self.iface is not None:
                self.iface.messageBar().pushSuccess("3D OSM Model", msg)
        except Exception as exc:
            if not self._unloaded:
                self._fail(str(exc))
        finally:
            if cursor_set:
                QApplication.restoreOverrideCursor()
            self._running = False

    # -- OSM basemap --------------------------------------------------------
    def add_osm_basemap(self):
        """Add (or reveal) the live OpenStreetMap tile layer in the project.

        It goes to the bottom of the layer tree so it sits under everything else,
        and a second click reuses the existing layer instead of stacking copies.
        When no basemap underlay has been chosen yet, it is also offered to the
        3D export as the ground texture.
        """
        if self._unloaded or self._running:
            return
        project = QgsProject.instance()
        root = project.layerTreeRoot()

        for layer in project.mapLayers().values():
            if (
                isinstance(layer, QgsRasterLayer)
                and layer.name() == self.OSM_BASEMAP_NAME
                and "tile.openstreetmap.org" in layer.source()
            ):
                node = root.findLayer(layer.id())
                if node is not None:
                    node.setItemVisibilityChecked(True)
                self._basemap_added(layer, "OSM basemap is already on the map.")
                return

        layer = QgsRasterLayer(self.OSM_BASEMAP_URI, self.OSM_BASEMAP_NAME, "wms")
        if not layer.isValid():
            self._fail("Could not load the OSM basemap (no network, or tiles unavailable).")
            return

        layer.setAttribution("© OpenStreetMap contributors")
        layer.setAttributionUrl("https://www.openstreetmap.org/copyright")
        project.addMapLayer(layer, False)
        root.addLayer(layer)  # appended == bottom of the tree, under other layers
        self._basemap_added(
            layer, "Added the OpenStreetMap basemap. Tiles © OpenStreetMap contributors.",
        )

    def _basemap_added(self, layer, message: str):
        if self.dialog is not None:
            # Only fill an empty choice, never override a basemap the user picked.
            if self.dialog.basemap_combo.currentLayer() is None:
                self.dialog.select_basemap_layer(layer)
                message += " It will also underlay the 3D model."
            self.dialog.set_status(message)
        if self.iface is not None:
            self.iface.messageBar().pushInfo("3D OSM Model", message)

    def reopen_viewer(self):
        if self._unloaded or self._running or self.server is None:
            return
        try:
            url = self.server.url or self.server.start()
            webbrowser.open(url)
            if self.dialog:
                self.dialog.set_status(f"Viewer opened: {url}")
        except Exception as exc:
            self._fail(str(exc))

    def _connect_canvas_probe(self) -> None:
        """Keep the dialog's hectare readout in step with the map canvas.

        The readout only refreshed when the dialog opened or when a radio button
        changed, so zooming or panning left a stale figure on screen next to a
        "Current map view (canvas extent)" option that no longer described the
        view. Both canvas signals are needed: extentsChanged covers zoom and pan,
        destinationCrsChanged covers a project CRS switch, which changes the
        measured area without moving the extent.
        """
        if self._canvas_probe_connected or self.iface is None:
            return
        canvas = self.iface.mapCanvas()
        if canvas is None:
            return
        for signal_name in ("extentsChanged", "destinationCrsChanged"):
            signal = getattr(canvas, signal_name, None)
            if signal is None:
                continue
            with contextlib.suppress(RuntimeError, TypeError):
                signal.connect(self._on_canvas_changed)
        self._canvas_probe_connected = True

    def _disconnect_canvas_probe(self) -> None:
        if not self._canvas_probe_connected or self.iface is None:
            self._canvas_probe_connected = False
            return
        canvas = self.iface.mapCanvas()
        if canvas is not None:
            for signal_name in ("extentsChanged", "destinationCrsChanged"):
                signal = getattr(canvas, signal_name, None)
                if signal is None:
                    continue
                with contextlib.suppress(RuntimeError, TypeError):
                    signal.disconnect(self._on_canvas_changed)
        self._canvas_probe_connected = False

    def _on_canvas_changed(self) -> None:
        """Refresh the area readout, never letting a canvas signal raise into Qt."""
        if self._unloaded or self._running or self.dialog is None:
            return
        try:
            if not self.dialog.isVisible():
                return
            self.dialog.refresh_area_summary()
        except RuntimeError:
            # The dialog's C++ object is gone; drop the connection.
            self._disconnect_canvas_probe()
        except Exception as exc:  # a readout must never break the canvas
            QgsMessageLog.logMessage(
                f"Area readout refresh failed: {exc}", "3D OSM Model", Qgis.Warning
            )

    def _area_summary(self, source: str) -> str:
        """Short, human-readable estimate of the chosen study area for the dialog."""
        if self._unloaded or self.iface is None:
            return "Plugin is unloading."
        try:
            geom, crs = self._resolve_area(source)
        except Exception as exc:
            return str(exc)
        da = QgsDistanceArea()
        da.setSourceCrs(crs, QgsProject.instance().transformContext())
        da.setEllipsoid("WGS84")
        try:
            area_ha = da.measureArea(geom) / 10000.0
        except Exception:
            area_ha = 0.0
        if source == "selection":
            layer = self.iface.activeLayer()
            count = len(layer.selectedFeatures()) if isinstance(layer, QgsVectorLayer) else 0
            return f"{count} selected feature(s) · ≈ {area_ha:,.0f} ha in extent"
        return f"Canvas extent · ≈ {area_ha:,.0f} ha"

    def _open_data_folder(self):
        if self._unloaded or self._running:
            return
        folder = os.path.join(self.web_root, "data", "yerlesim")
        if not os.path.isdir(folder):
            folder = os.path.join(self.web_root, "data")
        from qgis.PyQt.QtCore import QUrl
        from qgis.PyQt.QtGui import QDesktopServices
        if not QDesktopServices.openUrl(QUrl.fromLocalFile(folder)):
            webbrowser.open("file://" + folder.replace("\\", "/"))

    def _clear_osm_cache(self):
        if self._unloaded or self._running:
            return
        from .osm_download import clear_cache

        removed, freed = clear_cache()
        mb = freed / (1024 * 1024)
        msg = (f"Cleared {removed} cached OSM file(s), freed {mb:.1f} MB."
               if removed else "OSM cache was already empty.")
        if self.dialog:
            self.dialog.set_status(msg)
        if self.iface is not None:
            self.iface.messageBar().pushInfo("3D OSM Model", msg)

    def _fail(self, text: str):
        if self._unloaded:
            return
        if self.dialog is not None:
            self.dialog.set_status(text, error=True, busy=False)
        parent = self.iface.mainWindow() if self.iface is not None else None
        QMessageBox(QMessageBox.Icon.Critical, "3D OSM Model", text,
                    QMessageBox.StandardButton.Ok, parent).exec()
