# SPDX-FileCopyrightText: 2025 XLeitstelle Planen und Bauen <xleitstelle@gv.hamburg.de>
# SPDX-FileContributor: Tobias Kraft <tobias.kraft@gv.hamburg.de>
# SPDX-FileContributor: Michael Holzapfel <michael.holzapfel@geocledian.com>
#
# SPDX-License-Identifier: EUPL-1.2

import json
import logging
from typing import Any, Literal

from qgis.core import (
    Qgis,
    QgsBlockingNetworkRequest,
    QgsDataProvider,
    QgsDataSourceUri,
    QgsDefaultValue,
    QgsEditorWidgetSetup,
    QgsFeature,
    QgsFeatureRequest,
    QgsFieldConstraints,
    QgsFields,
    QgsGeometry,
    QgsNetworkReplyContent,
    QgsProviderMetadata,
    QgsProviderRegistry,
    QgsVectorDataProvider,
    QgsVectorLayer,
)
from qgis.PyQt.QtCore import QByteArray, Qt, QUrl, QUrlQuery
from qgis.PyQt.QtNetwork import QNetworkRequest

from xmas_plugin.settings_manager import get_normalized_url
from xmas_plugin.util.metadata import PLUGIN_DIR_NAME, PLUGIN_NAME
from xmas_plugin.util.webengine import _is_valid_qt

logger = logging.getLogger(PLUGIN_DIR_NAME)

PROVIDER_NAME = PLUGIN_DIR_NAME
PROVIDER_DESCRIPTION = f"{PLUGIN_NAME} Vector Data Provider"


# ruff: noqa: D102
class XMASPluginVectorDataProvider(QgsVectorDataProvider):
    """Vector Data provider for plugin features.

    Read operations are delegated to the underlying postgres provider,
    while write operations are handled by the webapp backend.
    """

    @classmethod
    def providerKey(cls) -> str:
        """Returns the provider key."""
        return PLUGIN_DIR_NAME

    @classmethod
    def description(cls) -> str:
        """Returns the provider description."""
        return PROVIDER_DESCRIPTION

    @classmethod
    def createProvider(cls, uri, *args, **kwargs):
        """Creates a new provider object."""
        return XMASPluginVectorDataProvider(uri, *args, **kwargs)

    @classmethod
    def register_provider(cls) -> bool:
        """Register the provider with the provider registry."""
        registry = QgsProviderRegistry.instance()
        success = True
        if cls.providerKey() not in registry.providerList():
            metadata = QgsProviderMetadata(
                cls.providerKey(),
                cls.description(),
                cls.createProvider,
            )
            success = registry.registerProvider(metadata)
        if not success:
            raise RuntimeError(
                f"Registering vector data provider '{cls.providerKey()}' failed"
            )
        else:
            logger.info(f"Registered vector provider '{cls.providerKey()}'")

    def __init__(
        self,
        uri="",
        providerOptions=QgsDataProvider.ProviderOptions(),
        flags=QgsDataProvider.ReadFlags(),
    ):
        """Initialize the provider.

        This also initializes a postgres provider to delegate methods this provider does not re-implement itself.
        """
        super().__init__(uri)
        # keep reference to a layer so that the postgres provider C++ object does not get destroyed
        self._layer = QgsVectorLayer(uri, None, "postgres")
        self._db = self._layer.dataProvider()
        self._srid = self.crs().postgisSrid()
        ds_uri = QgsDataSourceUri(uri)
        self._plan_id = ds_uri.param("plan_id")
        self._appschema = ds_uri.param("appschema")
        self._version = ds_uri.param("version")
        self._fields: QgsFields = self._set_fields()
        self._db.notify.connect(self.notify, type=Qt.UniqueConnection)
        # safely disconnect notify signal on layer and db object destruction
        self._db.destroyed.connect(
            lambda db: (
                db.notify.disconnect(self.notify)
                if _is_valid_qt(db) and hasattr(db, "notify")
                else logger.debug("db c++ object already deleted")
            )
        )

    # Implementation of helper methods
    def _set_fields(self) -> QgsFields:
        fields = QgsFields()
        for field in self._db.fields():
            match name := field.name():
                case "id":
                    constraints = QgsFieldConstraints()
                    constraints.setConstraint(
                        QgsFieldConstraints.Constraint.ConstraintUnique,
                        QgsFieldConstraints.ConstraintOrigin.ConstraintOriginProvider,
                    )
                    constraints.setConstraintStrength(
                        QgsFieldConstraints.Constraint.ConstraintUnique,
                        QgsFieldConstraints.ConstraintStrength.ConstraintStrengthHard,
                    )
                    field.setConstraints(constraints)
                    field.setDefaultValueDefinition(
                        QgsDefaultValue("uuid('WithoutBraces')")
                    )
                    # generate new UUID on splitting, duplication and merging
                    field.setDuplicatePolicy(Qgis.FieldDuplicatePolicy.DefaultValue)
                    field.setSplitPolicy(Qgis.FieldDomainSplitPolicy.DefaultValue)
                    if Qgis.versionInt() >= 34400:
                        field.setMergePolicy(Qgis.FieldDomainMergePolicy.DefaultValue)
                    field.setEditorWidgetSetup(QgsEditorWidgetSetup("Hidden", {}))
                case "properties":
                    field.setEditorWidgetSetup(
                        QgsEditorWidgetSetup(PLUGIN_DIR_NAME, {})
                    )
                    constraints = QgsFieldConstraints()
                    constraints.setConstraint(
                        QgsFieldConstraints.Constraint.ConstraintNotNull,
                        QgsFieldConstraints.ConstraintOrigin.ConstraintOriginProvider,
                    )
                    constraints.setConstraintStrength(
                        QgsFieldConstraints.Constraint.ConstraintNotNull,
                        QgsFieldConstraints.ConstraintStrength.ConstraintStrengthHard,
                    )
                    field.setConstraints(constraints)
                case "appschema" | "version":
                    field.setDefaultValueDefinition(
                        QgsDefaultValue(repr(getattr(self, f"_{name}"))),
                    )
                    field.setEditorWidgetSetup(QgsEditorWidgetSetup("Hidden", {}))
                case _:
                    field.setEditorWidgetSetup(QgsEditorWidgetSetup("Hidden", {}))
            fields.append(field)
        return fields

    def _prepare_insert_item(self, feature: QgsFeature) -> tuple[str, dict]:
        properties = dict(**feature["properties"])
        properties["id"] = feature["id"]
        item = {
            "appschema": feature["appschema"],
            "version": feature["version"],
            "featuretype": feature["featuretype"],
            "properties": properties,
            "geometry": {
                "srid": self._srid,
                "wkt": feature.geometry()
                .forcePolygonCounterClockwise()
                .asWkt()
                .upper(),
            }
            if feature.hasGeometry()
            else None,
        }
        return item

    def _prepare_payload(
        self, attr_map: dict[int, dict] = {}, geometry_map: dict[int, QgsGeometry] = {}
    ):
        payload = {}
        if not attr_map and not geometry_map:
            raise RuntimeError("both attribute and geometry map missing")
        fids = list(attr_map.keys() | geometry_map.keys())
        request = (
            QgsFeatureRequest()
            .setFlags(QgsFeatureRequest.NoGeometry)
            .setSubsetOfAttributes(["id"], self.fields())
            .setFilterFids(fids)
        )
        for feature in self.getFeatures(request):
            fid = feature.id()
            properties = attr_map.get(fid, {}).get(
                feature.fieldNameIndex("properties"), {}
            )
            uid = str(feature["id"])
            if properties:
                payload.setdefault(uid, {}).setdefault("properties", properties)
            if geometry := geometry_map.get(fid):
                payload.setdefault(uid, {}).setdefault(
                    "geometry",
                    {
                        "wkt": geometry.forcePolygonCounterClockwise().asWkt().upper(),
                        "srid": self._srid,
                    },
                )
        if not payload:
            raise RuntimeError("empty payload")
        return payload

    def _make_network_request(
        self, method: Literal["get", "post"], request: QNetworkRequest, data: Any = None
    ) -> tuple[QgsNetworkReplyContent, int]:
        """Performs a network request and returns its reply and status code."""
        requester = QgsBlockingNetworkRequest()

        logger.debug(
            f"Sending {method.upper()} request with payload to {request.url().toString()}"
        )
        result = (
            requester.post(request, data)
            if method == "post"
            else requester.get(request, forceRefresh=True)
        )
        reply = requester.reply()

        if result != QgsBlockingNetworkRequest.ErrorCode.NoError:
            error_string = (
                reply.errorString() if reply else "Unbekannter Netzwerkfehler"
            )
            logger.error(f"POST request transport failed: {error_string}")
            raise RuntimeError(error_string)

        if not reply:
            logger.error("POST request returned NoError but no reply object")
            raise RuntimeError("Unerwarteter Fehler: Keine Server-Antwort erhalten")

        status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)

        return reply, status_code

    def _transfer_payload(
        self,
        payload: dict[str, dict] | list[dict],
        endpoint: Literal["/update-features", "/insert-features"],
    ):
        """Transfers a feature payload to webapp."""
        base_url = get_normalized_url()
        if not base_url:
            raise RuntimeError("Keine App-Url gesetzt")

        expected_status = 201 if endpoint == "/insert-features" else 204

        url = QUrl(base_url)
        url.setPath(endpoint)

        query = QUrlQuery()
        if endpoint == "/insert-features":
            query.addQueryItem("planId", str(self._plan_id))
        url.setQuery(query)

        request = QNetworkRequest(url)
        request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json")

        data = QByteArray(json.dumps(payload).encode("utf-8"))

        reply, status_code = self._make_network_request("post", request, data)

        if status_code == expected_status:
            logger.debug(f"POST Request was successful (HTTP {status_code})")
            self._db.reloadData()  # needed because we add/change features outside qgis
            return

        error_string = reply.errorString()
        if status_code == 422:
            try:
                content = reply.content()
                body_bytes = content.data()
                body = json.loads(body_bytes.decode("utf-8"))
                detail = body.get("detail", body)
                message = f"Validierungsfehler: {detail}"
            except Exception:
                message = error_string
        else:
            message = error_string
        logger.error(
            f"POST Request failed with status code {status_code}: {error_string}"
        )
        raise RuntimeError(message)

    def get_feature_properties(
        self,
        feature_id: str,
    ) -> dict:
        """Gets a feature's current properties from webapp by its ID."""
        base_url = get_normalized_url()
        if not base_url:
            raise RuntimeError("Keine App-Url gesetzt")

        url = QUrl(base_url)
        url.setPath(f"/feature/{feature_id}/properties")

        request = QNetworkRequest(url)

        reply, status_code = self._make_network_request("get", request)

        if status_code == 200:
            logger.debug(f"GET Request was successful (HTTP {status_code})")
            return json.loads(bytes(reply.content().data()))
        else:
            error_string = reply.errorString()
            logger.error(
                f"GET Request failed with status code {status_code}: {error_string}"
            )
            raise RuntimeError(error_string)

    # Implementation of methods from QgsVectorDataProvider

    def featureSource(self):
        return self._db.featureSource()

    def dataSourceUri(self, expandAuthConfig=True):
        db_uri = self._db.dataSourceUri(expandAuthConfig)
        ds = QgsDataSourceUri(db_uri)
        if self._plan_id:
            ds.setParam("plan_id", self._plan_id)
        if self._appschema:
            ds.setParam("appschema", self._appschema)
        if self._version:
            ds.setParam("version", self._version)
        return ds.uri(expandAuthConfig)

    def storageType(self):
        return f"Coretable ({self._db.storageType()})"

    def getFeatures(self, request=QgsFeatureRequest()):
        return self._db.getFeatures(request)

    def uniqueValues(self, fieldIndex: int, limit: int = -1):
        return self._db.uniqueValues(fieldIndex, limit)

    def wkbType(self):
        return self._db.wkbType()

    def featureCount(self):
        return self._db.featureCount()

    def fields(self):
        return self._fields

    def hasFeatures(self):
        return self._db.hasFeatures()

    def hasSpatialIndex(self):
        return self._db.hasSpatialIndex()

    def pkAttributeIndexes(self):
        return self._db.pkAttributeIndexes()

    def addFeatures(
        self, flist: list[QgsFeature], flags=None
    ) -> tuple[bool, list[QgsFeature]]:
        logger.debug("addFeatures called: flist_len=%d", len(flist))
        payload = []
        ids = []
        try:
            for feature in flist:
                ids.append(repr(feature["id"]))
                item = self._prepare_insert_item(feature)
                payload.append(item)
            logger.debug(f"addFeatures payload: {payload}")
            self._transfer_payload(payload, "/insert-features")
        except Exception as e:
            self.pushError(str(e))
            return False, []
        else:
            # request added features by saved ids
            request = QgsFeatureRequest().setFilterExpression(
                f"id IN ({','.join(ids)})"
            )
            return True, [feature for feature in self.getFeatures(request)]

    def deleteFeatures(self, ids) -> bool:
        # delete features directly in the DB for now
        return self._db.deleteFeatures(ids)

    def changeAttributeValues(self, attr_map: dict[int, dict]) -> bool:
        try:
            payload = self._prepare_payload(attr_map=attr_map)
            logger.debug(f"changeAttributeValues payload: {payload}")
            self._transfer_payload(payload, "/update-features")
            return True
        except Exception as e:
            self.pushError(str(e))
            return False

    def changeGeometryValues(self, geometry_map: dict[int, QgsGeometry]) -> bool:
        try:
            payload = self._prepare_payload(geometry_map=geometry_map)
            logger.debug(f"changeGeometryValues payload: {payload}")
            self._transfer_payload(payload, "/update-features")
            return True
        except Exception as e:
            self.pushError(str(e))
            return False

    def changeFeatures(
        self, attr_map: dict[int, dict], geometry_map: dict[int, QgsGeometry]
    ) -> bool:
        try:
            payload = self._prepare_payload(
                attr_map=attr_map, geometry_map=geometry_map
            )
            logger.debug(f"changeFeatures payload: {payload}")
            self._transfer_payload(payload, "/update-features")
            return True
        except Exception as e:
            self.pushError(str(e))
            return False

    def allFeatureIds(self):
        return self._db.allFeatureIds()

    def subsetString(self):
        return self._db.subsetString()

    def setSubsetString(self, subset: str | None, updateFeatureCount: bool):
        return self._db.setSubsetString(subset, updateFeatureCount)

    def supportsSubsetString(self):
        return self._db.supportsSubsetString()

    def createSpatialIndex(self):
        return self._db.createSpatialIndex()

    def sourceExtent(self):
        return self._db.sourceExtent()

    def sourceExtent3D(self):
        return self._db.sourceExtent3D()

    def capabilities(self):
        """Customizes reported provider capabilies.

        E.g. attribute modifications are excluded.
        """
        return (
            Qgis.VectorProviderCapability.AddFeatures
            | Qgis.VectorProviderCapability.DeleteFeatures
            | Qgis.VectorProviderCapability.ChangeAttributeValues
            | Qgis.VectorProviderCapability.ChangeGeometries
            | Qgis.VectorProviderCapability.ChangeFeatures
            | Qgis.VectorProviderCapability.CircularGeometries
            # | Qgis.VectorProviderCapability.TransactionSupport # currently not implemented
            | Qgis.VectorProviderCapability.SelectAtId
            | Qgis.VectorProviderCapability.SimplifyGeometries
            | Qgis.VectorProviderCapability.SimplifyGeometriesWithTopologicalValidation
        )

    def maximumValue(self, fieldIndex: int) -> Any:
        return self._db.maximumValue(fieldIndex)

    def minumumValue(self, fieldIndex: int) -> Any:
        return self._db.minimumValue(fieldIndex)

    # Implementation of methods from QgsDataProvider

    def name(self):
        return self.providerKey()

    def extent(self):
        return self._db.extent()

    def updateExtents(self):
        return self._db.updateExtents()

    def isValid(self):
        return self._db.isValid()

    def crs(self):
        return self._db.crs()

    def setListening(self, isListening: bool):
        self._db.setListening(isListening)
