# -*- coding: utf-8 -*-
"""Missing node checker — the most complex custom algorithm.

Detects nodes of polygon A that lie on edges of polygon B but are
missing from B's vertex list. Uses point_on_segment() projection.

Scientific basis:
    t = dot(AP, AB) / dot(AB, AB)
    F = A + t * AB
    P on AB iff 0 < t < 1 AND |PF| < tolerance

Author: Loc Vu Trung tong hop vi dam me cung ban!
"""
from qgis.core import (
    QgsGeometry, QgsPointXY, QgsSpatialIndex,
    QgsVectorLayer, QgsVectorFileWriter,
    QgsCoordinateTransformContext)
from .base import BaseChecker
from ..utils import point_on_segment


class MissingNodeChecker(BaseChecker):
    name = "missing_nodes"
    display_name_en = "Missing Nodes"
    display_name_vi = "Thieu nut tren canh chung"

    def _collect_ring_coords(self, geometry):
        """Get all vertex coordinates from all rings."""
        coords = []
        if geometry.isMultipart():
            multi = geometry.asMultiPolygon()
            if multi:
                for p in multi:
                    for ring in p:
                        coords.extend(ring)
        else:
            poly = geometry.asPolygon()
            if poly:
                for ring in poly:
                    coords.extend(ring)
        return coords

    def _get_rings(self, geometry):
        """Get ring list from polygon or multipolygon."""
        if geometry.isMultipart():
            mp = geometry.asMultiPolygon()
            if mp:
                return [ring for p in mp for ring in p]
            return []
        poly = geometry.asPolygon()
        return poly if poly else []

    def _find_missing(self, layer, tolerance, max_seconds=10):
        """Find missing nodes: nodes of A on edges of B.

        Has built-in timeout protection for large datasets.
        """
        import time
        start_time = time.time()

        index = QgsSpatialIndex(layer.getFeatures())
        features = {f.id(): f for f in layer.getFeatures()}
        missing = {}
        total_feats = len(features)

        # Safety: skip if dataset is too large (O(n²) would hang)
        if total_feats > 500:
            return missing

        # Also skip if total vertex count is too high
        total_verts = 0
        for f in features.values():
            g = f.geometry()
            if g and not g.isNull():
                total_verts += sum(
                    1 for _ in g.vertices())
            if total_verts > 50000:
                return missing

        for count, (fid_a, feat_a) in enumerate(features.items()):
            # Timeout check every 10 features
            if count % 10 == 0 and count > 0:
                if (time.time() - start_time) > max_seconds:
                    break

            geom_a = feat_a.geometry()
            if not geom_a or geom_a.isNull():
                continue
            coords_a = self._collect_ring_coords(geom_a)
            if not coords_a:
                continue
            bbox = geom_a.boundingBox()
            bbox.grow(tolerance * 10)

            for fid_b in index.intersects(bbox):
                if fid_b <= fid_a:
                    continue
                feat_b = features[fid_b]
                geom_b = feat_b.geometry()
                if not geom_b or geom_b.isNull():
                    continue
                if not geom_a.boundingBox().intersects(geom_b.boundingBox()):
                    continue

                rings_b = self._get_rings(geom_b)

                for pt_a in coords_a:
                    px, py = pt_a.x(), pt_a.y()
                    for ri, ring in enumerate(rings_b):
                        for si in range(len(ring) - 1):
                            ax = ring[si].x()
                            ay = ring[si].y()
                            bx = ring[si + 1].x()
                            by = ring[si + 1].y()
                            # Skip if P coincides with endpoint
                            if (abs(px - ax) < tolerance
                                    and abs(py - ay) < tolerance):  # noqa: W503
                                continue
                            if (abs(px - bx) < tolerance
                                    and abs(py - by) < tolerance):  # noqa: W503
                                continue
                            on, t = point_on_segment(
                                px, py, ax, ay, bx, by, tolerance)
                            if on:
                                missing.setdefault(fid_b, []).append(
                                    (ri, si, QgsPointXY(px, py), t))

                # Also check B's nodes on A's edges
                coords_b = self._collect_ring_coords(geom_b)
                rings_a = self._get_rings(geom_a)
                for pt_b in coords_b:
                    px, py = pt_b.x(), pt_b.y()
                    for ri, ring in enumerate(rings_a):
                        for si in range(len(ring) - 1):
                            ax = ring[si].x()
                            ay = ring[si].y()
                            bx = ring[si + 1].x()
                            by = ring[si + 1].y()
                            if (abs(px - ax) < tolerance
                                    and abs(py - ay) < tolerance):  # noqa: W503
                                continue
                            if (abs(px - bx) < tolerance
                                    and abs(py - by) < tolerance):  # noqa: W503
                                continue
                            on, t = point_on_segment(
                                px, py, ax, ay, bx, by, tolerance)
                            if on:
                                missing.setdefault(fid_a, []).append(
                                    (ri, si, QgsPointXY(px, py), t))
        return missing

    def check(self, layer, params):
        # Fast guard: skip for large datasets to prevent hanging
        feat_count = layer.featureCount()
        if feat_count > 200:
            return {"error_geometries": [], "error_features": [],
                    "details": [f"Skipped: {feat_count} features (max 200)"]}
        tol = params.get("missing_node_tol", 0.001)
        missing = self._find_missing(layer, tol)
        errors = []
        for fid, items in missing.items():
            for _, _, pt, _ in items:
                errors.append(QgsGeometry.fromPointXY(pt))
        return {"error_geometries": errors, "error_features": [],
                "details": [f"{len(missing)} features need node insertion"]}

    def _rebuild(self, geometry, insertions):
        """Insert missing nodes into geometry rings."""
        poly = geometry.asPolygon()
        if not poly:
            return geometry
        by_ring = {}
        for ri, si, pt, t in insertions:
            by_ring.setdefault(ri, []).append((si, pt, t))
        for ri, items in by_ring.items():
            if ri >= len(poly):
                continue
            ring = list(poly[ri])
            # Sort descending by segment index, then by t (to insert from end)
            items.sort(key=lambda x: (x[0], x[2]), reverse=True)
            for si, pt, t in items:
                pos = si + 1
                if pos <= len(ring):
                    ring.insert(pos, pt)
            poly[ri] = ring
        return QgsGeometry.fromPolygonXY(poly)

    def fix(self, input_path, output_path, params):
        tol = params.get("missing_node_tol", 0.001)
        layer = QgsVectorLayer(input_path, "in", "ogr")
        missing = self._find_missing(layer, tol)
        changes = {}
        features = {f.id(): f for f in layer.getFeatures()}
        for fid, items in missing.items():
            geom = features[fid].geometry()
            rebuilt = self._rebuild(geom, items)
            if rebuilt and not rebuilt.isNull():
                changes[fid] = rebuilt
        if changes:
            layer.dataProvider().changeGeometryValues(changes)
        opts = QgsVectorFileWriter.SaveVectorOptions()
        opts.driverName = "GPKG"
        opts.forceMulti = True
        opts.includeZ = True
        QgsVectorFileWriter.writeAsVectorFormatV3(
            layer, output_path, QgsCoordinateTransformContext(), opts)
        return output_path
