# -*- coding: utf-8 -*-
"""Pandvinder: find a BAG pand (building) by its 16-digit identificatienummer.

Two entry points, one lookup engine:
- a toolbar button (same top toolbar row as PDOK Services) that opens a search dialog
- a QGIS locator filter (type "pand <16 digits>" or just the 16 digits in Ctrl+K)

On a hit the map pans to the pand, its polygon geometry is flashed on the
canvas, and its bijbehorende adressen are shown. Data comes from the Kadaster
BAG viewer API (public, no API key) - see docs/research/bag-pand-locator.md.
The toolbar/locator behaviour mirrors the PDOK services plugin.
"""

import json
import os
import re

from osgeo import ogr

from qgis.core import (
    Qgis,
    QgsBlockingNetworkRequest,
    QgsCoordinateReferenceSystem,
    QgsCoordinateTransform,
    QgsGeometry,
    QgsLocatorFilter,
    QgsLocatorResult,
    QgsMessageLog,
    QgsPointXY,
    QgsProject,
    QgsTask,
    QgsWkbTypes,
)
from qgis.PyQt.QtCore import QTimer
from qgis.PyQt.QtGui import QColor, QIcon
from qgis.PyQt.QtNetwork import QNetworkRequest
from qgis.PyQt.QtCore import QUrl
from qgis.PyQt.QtWidgets import (
    QAction,
    QDialog,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QListWidget,
    QPushButton,
    QVBoxLayout,
)
from qgis.core import QgsApplication  # taskManager lives here
PAND_ID_RE = re.compile(r"^\d{16}$")
BAG_API = "https://api.bag.kadaster.nl/lvbag/viewerbevragingen/v2/"
ZOOM_SCALE = 400.0  # fixed map scale for a found pand (user preference)
ICON_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "icon.png")
LOG_TAG = "Pandvinder"


def log(msg):
    QgsMessageLog.logMessage(str(msg), LOG_TAG, Qgis.MessageLevel.Info)


def get_json(url):
    """Blocking GET returning (status, body). Safe from the locator worker thread
    (fetchResults runs off the GUI thread, so a blocking request is correct —
    same pattern as the PDOK plugins).

    Non-2xx responses are returned with their body: the API signals errors as
    JSON and QGIS reports such responses as err!=NoError, so the status is
    checked explicitly."""
    request = QNetworkRequest(QUrl(url))
    # NOTE: no Accept header! This API 406s on 'Accept: application/json'
    # but returns JSON happily with no Accept (default). Only Accept-Crs is needed.
    request.setRawHeader(b"Accept-Crs", b"epsg:28992")  # geometry in RD New
    request.setRawHeader(b"User-Agent", b"qgis-pandvinder")
    net = QgsBlockingNetworkRequest()
    err = net.get(request, True)  # blocking
    if err != QgsBlockingNetworkRequest.ErrorCode.NoError:
        # distinguish a hard transport failure from an HTTP error response
        reply = net.reply()
        if not reply:
            raise RuntimeError("HTTP error {} for {}".format(err, url))
        status_code = None
        try:
            status_code = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
        except Exception:  # noqa: BLE001
            status_code = None
        if status_code is not None and 400 <= int(status_code) < 600:
            body = bytes(reply.content())
            return int(status_code), body.decode("utf-8", "replace") if body else ""
        raise RuntimeError("HTTP error {} for {}".format(err, url))
    reply = net.reply()
    body = bytes(reply.content())
    return 200, body.decode("utf-8", "replace")


def _json_body(body):
    return json.loads(body) if body.strip() else {}


def pand_lookup(pand_id):
    """Return (wkt_geom, addresses, info_dict) for a BAG pand id, or None."""
    status, body = get_json("{}panden/{}".format(BAG_API, pand_id))
    if status != 200:
        return None
    data = _json_body(body)
    pand = data.get("pand") if isinstance(data, dict) else None
    if not pand:
        return None
    geom = pand.get("geometrie")
    if not geom:
        raise RuntimeError("No geometry in pand response for {}".format(pand_id))
    # GeoJSON geometry -> WKT via ogr (QgsGeometry has no fromJson on 3.44)
    ogr_geom = ogr.CreateGeometryFromJson(json.dumps(geom))
    if ogr_geom is None:
        raise RuntimeError("Cannot parse geometry for pand {}".format(pand_id))
    wkt = ogr_geom.ExportToWkt()

    info = {
        "identificatie": pand.get("identificatie"),
        "status": pand.get("status"),
        "bouwjaar": pand.get("oorspronkelijkBouwjaar"),
    }
    return wkt, fetch_addresses(pand_id), info


def fetch_addresses(pand_id):
    """Addresses (verblijfsobjecten) that belong to a pand, labelled like
    'Straat 13a, 1761AX'. May be empty; uses pageSize 10 (API minimum)."""
    url = (
        "{}verblijfsobjecten?pandIdentificatie={}&huidig=true"
        "&pageSize=10&expand=heeftAlsHoofdAdres&page=1"
    ).format(BAG_API, pand_id)
    status, body = get_json(url)
    if status != 200:
        return []
    data = _json_body(body)
    embedded = data.get("_embedded", {}) if isinstance(data, dict) else {}
    vbos = embedded.get("verblijfsobjecten", [])
    out = []
    for item in vbos:
        hfa = item.get("_embedded", {}).get("heeftAlsHoofdAdres", {}) or {}
        na = hfa.get("nummeraanduiding", {}) or {}
        huisnummer = na.get("huisnummer")
        if huisnummer is None:
            continue
        huisletter = na.get("huisletter", "") or ""
        postcode = na.get("postcode", "") or ""
        straat = ""
        openbare_ruimte_id = hfa.get("ligtAan") or na.get("ligtAan")
        if openbare_ruimte_id:
            try:
                or_status, or_body = get_json(
                    "{}openbareruimten/{}".format(BAG_API, openbare_ruimte_id)
                )
                if or_status == 200:
                    or_obj = _json_body(or_body).get("openbareRuimte", {}) or {}
                    straat = or_obj.get("naam", "") or ""
            except Exception as e:  # noqa: BLE001 - label is best-effort
                log("street lookup failed for {}: {}".format(openbare_ruimte_id, e))
        label = "{}".format(straat).strip()
        if huisnummer is not None:
            label += " {}{}".format(huisnummer, huisletter)
        if postcode:
            label += ", " + postcode
        if label.strip():
            out.append(label.strip())
    return out


class _LookupTask(QgsTask):
    """Run pand_lookup (blocking HTTP) off the GUI thread, like the locator filter.

    Keeps the search dialog responsive during a request. run() happens on a
    QGIS task-manager thread; taskCompleted fires back on the GUI thread.
    Only the python builtins / plain data move between the two.

    NOTE: do NOT override finished() — the C++ task manager does not route
    that virtual back through Python; taskCompleted is the reliable hook.
    """

    def __init__(self, pand_id, on_done):
        super().__init__("Pandvinder lookup")
        self.pand_id = pand_id
        self.on_done = on_done
        self.result = None
        self.error = None
        # taskCompleted has no args and fires on the GUI thread after run()
        self.taskCompleted.connect(self._complete)

    def _complete(self):
        if self.error is not None:
            self.on_done(None, self.error)
        else:
            self.on_done(self.result, None)

    def run(self):
        try:
            self.result = pand_lookup(self.pand_id)
        except Exception as err:  # noqa: BLE001 - surface to _complete()/user
            self.error = err
        return True


class PandfinderPlugin:
    """Plugin: toolbar button + locator filter, sharing one lookup engine."""

    def __init__(self, iface):
        # Keep a reference to iface: QGIS segfaults if it is garbage collected.
        self.iface = iface
        self.dlg = None
        self.filter = None
        self._task = None
        self._last = None  # (wkt, addresses, pand_id) of the most recent hit

    # --- plugin lifecycle ------------------------------------------------
    def initGui(self):
        self.filter = PandLocatorFilter(self)
        self.iface.registerLocatorFilter(self.filter)

        # own toolbar, stacked in the same top toolbar row as PDOK Services
        self.toolbar = self.iface.addToolBar("Pandvinder")
        self.toolbar.setObjectName("PandvinderToolBar")

        self.action = QAction(QIcon(ICON_PATH), "Pandvinder", self.iface.mainWindow())
        self.action.setToolTip("Pandvinder: zoek BAG-pand op identificatienummer")
        self.action.triggered.connect(self.open_dialog)
        self.toolbar.addAction(self.action)
        # also reachable from Extensies/Plugins, like the other plugins
        self.iface.addPluginToMenu("&Pandvinder", self.action)

    def unload(self):
        # Best-effort, logged cleanup: a failure in one step must not skip
        # the rest (QGIS shows an error dialog if unload raises).
        try:
            if self.filter is not None:
                self.iface.deregisterLocatorFilter(self.filter)
        except Exception as e:  # noqa: BLE001
            log("unload filter: {}".format(e))
        self.filter = None
        try:
            if self.toolbar is not None:
                self.toolbar.deleteLater()  # QgisInterface has no removeToolBar
        except Exception as e:  # noqa: BLE001
            log("unload toolbar: {}".format(e))
        self.toolbar = None
        try:
            if self.dlg is not None:
                self.dlg.deleteLater()
        except Exception as e:  # noqa: BLE001
            log("unload dialog: {}".format(e))
        self.dlg = None
        try:
            self.iface.removePluginMenu("&Pandvinder", self.action)
        except Exception as e:  # noqa: BLE001
            log("unload menu: {}".format(e))

    # --- search dialog -----------------------------------------------------
    def open_dialog(self):
        if self.dlg is None:
            self.dlg = self._build_dialog()
        self.dlg.show()
        self.dlg.raise_()
        self.dlg.activateWindow()
        self.search_edit.setFocus()

    def _build_dialog(self):
        dlg = QDialog(self.iface.mainWindow())
        dlg.setWindowTitle("Pandvinder")
        dlg.setObjectName("PandvinderDialog")
        dlg.setWindowIcon(QIcon(ICON_PATH))
        dlg.resize(420, 340)

        layout = QVBoxLayout(dlg)
        layout.setContentsMargins(6, 6, 6, 6)

        lbl = QLabel("BAG pand identificatienummer (16 cijfers):")
        layout.addWidget(lbl)

        search_row = QHBoxLayout()
        self.search_edit = QLineEdit()
        self.search_edit.setPlaceholderText("bijv. 1911100000598978")
        self.search_edit.setClearButtonEnabled(True)
        go_btn = QPushButton("Zoek")
        search_row.addWidget(self.search_edit, 1)
        search_row.addWidget(go_btn)
        layout.addLayout(search_row)

        self.result_list = QListWidget()
        self.result_list.setSelectionMode(QListWidget.SelectionMode.SingleSelection)
        layout.addWidget(self.result_list, 1)

        self.status_label = QLabel("")
        self.status_label.setWordWrap(True)
        layout.addWidget(self.status_label)

        # Enter or Zoek searches; clicking a result row jumps the map
        go_btn.clicked.connect(self.search_pressed)
        self.search_edit.returnPressed.connect(self.search_pressed)
        self.result_list.itemClicked.connect(self.on_item_clicked)
        # debounce live search as user types
        self._search_timer = QTimer()
        self._search_timer.setSingleShot(True)
        self._search_timer.setInterval(300)
        self._search_timer.timeout.connect(self.run_search)
        self.search_edit.textEdited.connect(lambda: self._search_timer.start())
        # keep a running search id so a stale worker result is never applied
        self._search_seq = 0
        # status auto-clear timer
        self._status_timer = QTimer()
        self._status_timer.setSingleShot(True)
        self._status_timer.timeout.connect(self._clear_status)

        return dlg

    # --- dialog behaviour ---------------------------------------------------
    def search_pressed(self):
        self._search_timer.stop()
        self.run_search()

    def run_search(self):
        pand_id = self.search_edit.text().strip()
        if not PAND_ID_RE.match(pand_id):
            self.result_list.clear()
            self._set_status("Voer een geldig 16-cijferig pand identificatienummer in.")
            return
        # guard against out-of-order worker results (new search = stale is dropped)
        self._search_seq += 1
        seq = self._search_seq
        # keep a reference: the task manager drops the C++ task if the Python
        # wrapper is garbage-collected before it finishes running
        self._task = _LookupTask(
            pand_id, lambda found, err: self._lookup_done(seq, found, err)
        )
        QgsApplication.taskManager().addTask(self._task)
        self._set_status("Zoeken...")

    def _lookup_done(self, seq, found, error):
        """GUI thread; only apply the result if no newer search was started."""
        if seq != self._search_seq or self.dlg is None:
            return  # stale result or dialog closed
        if error is not None:
            log("lookup error: {}".format(error))
            self._set_status("Fout bij opzoeken: {}".format(error))
            self.iface.messageBar().pushWarning("Pandvinder", str(error))
            return
        if not found:
            self.result_list.clear()
            self._set_status("Pand {} niet gevonden.".format(self.search_edit.text().strip()))
            return
        wkt, addresses, info = found
        pand_id = self.search_edit.text().strip()
        self._last = (wkt, addresses, pand_id)  # reuse on row click, no re-fetch
        self.result_list.clear()
        self.result_list.addItem(
            "Pand {} ({})".format(pand_id, (info.get("status") or "onbekend").lower())
        )
        for addr in addresses:
            self.result_list.addItem(addr)
        self.result_list.setCurrentRow(0)
        self._set_status("")
        # auto-jump on the (single) search result
        self.jump_to(wkt, addresses, pand_id)

    def on_item_clicked(self, item):
        """A clicked row (pand or address) re-jumps the map from the last lookup."""
        if self._last is not None:
            self.jump_to(*self._last)

    def _set_status(self, text):
        self.status_label.setText(text)
        # clear transient statuses after a few seconds; keep errors longer
        ms = 4000 if text and not text.startswith("Fout") else 9000
        self._status_timer.stop()
        self._status_timer.setInterval(ms)
        self._status_timer.start()

    def _clear_status(self):
        self.status_label.setText("")

    # --- shared jump logic (also used by the locator filter) ---------------
    def jump_to(self, wkt, addresses, pand_id):
        """Pan/zoom the map to the pand polygon and flash its geometry."""
        try:
            geom = QgsGeometry.fromWkt(wkt)
            if geom.isEmpty():
                log("empty geometry for pand {}".format(pand_id))
                return
            crs = self.iface.mapCanvas().mapSettings().destinationCrs()
            transform = QgsCoordinateTransform(
                QgsCoordinateReferenceSystem.fromEpsgId(28992),
                crs,
                QgsProject.instance(),
            )
            geom.transform(transform)

            # center on the pand and zoom to a fixed scale
            center = QgsPointXY(geom.boundingBox().center())
            self.iface.mapCanvas().setCenter(center)
            self.iface.mapCanvas().zoomScale(ZOOM_SCALE)

            # flash the geometry outline (polygon -> line so it is not a solid block)
            flash_geom = geom
            if geom.type() == QgsWkbTypes.GeometryType.PolygonGeometry:
                flash_geom = geom.convertToType(
                    QgsWkbTypes.GeometryType.LineGeometry, destMultipart=True
                )
            self.iface.mapCanvas().flashGeometries(
                [flash_geom],
                startColor=QColor("#ff0000"),
                endColor=QColor("#FFFF00"),
                flashes=5,
            )
            self.iface.mapCanvas().refresh()

            if addresses:
                self.iface.messageBar().pushInfo(
                    "Pand {}".format(pand_id), "Adressen: " + "; ".join(addresses)
                )
        except Exception as e:  # noqa: BLE001
            log("jump error: {}".format(e))
            self.iface.messageBar().pushWarning("Pandvinder", str(e))


class PandLocatorFilter(QgsLocatorFilter):

    def __init__(self, plugin):
        self.plugin = plugin
        super(QgsLocatorFilter, self).__init__()

    def name(self):
        return "PandvinderLocatorFilter"

    def clone(self):
        return PandLocatorFilter(self.plugin)

    def displayName(self):
        return self.tr("BAG pand by id")

    def prefix(self):
        # user can type "pand 1911..." to scope the search
        return "pand"

    def fetchResults(self, search, context, feedback):
        if not PAND_ID_RE.match(search.strip()):
            return
        try:
            found = pand_lookup(search.strip())
        except Exception as err:  # noqa: BLE001 - report & continue (mirrors PDOK)
            log("lookup error: {}".format(err))
            return
        if found is None:
            return
        wkt, addresses, info = found
        result = QgsLocatorResult()
        result.filter = self
        result.displayString = "Pand {}".format(search)
        # single hit per id; carry everything needed for triggerResult
        result.userData = {"pand_id": search.strip(), "wkt": wkt, "addresses": addresses}
        result.score = 100.0
        self.resultFetched.emit(result)

    def triggerResult(self, result):
        try:
            data = result.userData
            self.plugin.jump_to(data["wkt"], data.get("addresses") or [], data["pand_id"])
        except Exception as err:  # noqa: BLE001 - mirror PDOK behaviour
            log("trigger error: {}".format(err))
