"""
Erase All Polygons In Layer By Chosen Layer Coverage

This action runs at layer scope. When invoked on a polygon layer it asks the
user to pick another polygon layer (the "eraser") and erases every polygon
feature in the source layer where it overlaps with the eraser layer's coverage.
Only the area NOT covered by the eraser layer remains in each feature.

This is the geometric difference / erase operation — the opposite of
"Clip Layer Polygons to Layer Coverage".

The eraser union is computed ONCE and reused for all features in the source
layer. A spatial bbox filter limits eraser features loaded into memory.
The action handles CRS transforms, edit-mode, batching of geometry operations
for performance, and records undo payloads for restore.
"""

from .base_action import BaseAction
from qgis.PyQt.QtWidgets import QInputDialog, QApplication
from qgis.PyQt.QtCore import Qt
from qgis.core import (
    QgsProject,
    QgsVectorLayer,
    QgsGeometry,
    QgsWkbTypes,
    QgsCoordinateTransform,
    QgsFeatureRequest,
)

import base64
import math
from ..i18n import tr


class ErasePolygonsLayerByLayerCoverageAction(BaseAction):
    """Action to erase the parts of every polygon in a layer that overlap with another polygon layer."""

    def __init__(self):
        super().__init__()

        self.action_id = 'erase_polygons_layer_by_layer_coverage'
        self.name = 'Erase Layer Polygons by Layer Coverage'
        self.category = 'Editing'
        self.description = (
            'Erase every polygon in the clicked layer where it overlaps with a chosen '
            'polygon layer. Only the area NOT covered by the chosen layer remains. '
            'This is the geometric difference — the opposite of clip. Undoable.'
        )
        self.enabled = True

        # Layer-scoped action
        self.set_action_scope('layer')
        self.set_supported_scopes(['layer'])
        self.set_supported_click_types(['polygon', 'multipolygon'])
        self.set_supported_geometry_types(['polygon', 'multipolygon'])

    def get_settings_schema(self):
        return {
            'confirm_erase': {
                'type': 'bool',
                'default': True,
                'label': 'Confirm',
                'description': 'Confirm before erasing the whole layer',
            },
            'auto_commit_changes': {
                'type': 'bool',
                'default': True,
                'label': 'Auto-commit',
                'description': 'Automatically commit changes after erasing',
            },
            'batch_size': {
                'type': 'int',
                'default': 50,
                'label': 'Batch union size',
                'description': 'Number of geometries to union per batch (performance tuning)',
                'min': 1,
                'max': 1000,
            },
        }

    # ------------------------------------------------------------------
    # Undo support
    # ------------------------------------------------------------------

    def supports_undo(self) -> bool:
        return True

    def get_undo_category(self) -> str:
        return 'payload'

    def get_undo_payload(self, context: dict, execute_result=None) -> dict:
        return execute_result or {}

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    def _compute_union_of_layer(self, layer, geom_filter=None, batch_size=50):
        """
        Compute the union of all geometries in `layer`.

        If `geom_filter` is provided (a QgsGeometry), only features whose
        bounding box intersects `geom_filter` are loaded, greatly reducing
        the data set for large layers.
        """
        geoms = []

        if layer.selectedFeatureCount() > 0:
            feats = layer.selectedFeatures()
        else:
            if geom_filter is not None:
                try:
                    req = QgsFeatureRequest().setFilterRect(geom_filter.boundingBox())
                    req.setSubsetOfAttributes([])
                    feats = layer.getFeatures(req)
                except Exception:
                    feats = layer.getFeatures()
            else:
                feats = layer.getFeatures()

        for f in feats:
            g = f.geometry()
            if not g or g.isEmpty():
                continue
            try:
                if not g.isGeosValid():
                    g = g.makeValid()
                    if not g or g.isEmpty():
                        continue
            except Exception:
                pass
            # Fine-grained spatial check against the filter geometry
            if geom_filter is not None:
                try:
                    if not g.intersects(geom_filter):
                        continue
                except Exception:
                    pass
            geoms.append(g)

        if not geoms:
            return None

        # Batched union to avoid memory spikes for large layers
        try:
            if len(geoms) > batch_size * 4:
                chunks = []
                for i in range(0, len(geoms), batch_size):
                    chunk = geoms[i:i + batch_size]
                    try:
                        u = QgsGeometry.unaryUnion(chunk)
                    except Exception:
                        u = chunk[0]
                        for gg in chunk[1:]:
                            try:
                                u = u.combine(gg)
                            except Exception:
                                pass
                    if u and not u.isEmpty():
                        chunks.append(u)

                while len(chunks) > 1:
                    new_chunks = []
                    for i in range(0, len(chunks), batch_size):
                        chunk = chunks[i:i + batch_size]
                        try:
                            u = QgsGeometry.unaryUnion(chunk)
                        except Exception:
                            u = chunk[0]
                            for gg in chunk[1:]:
                                try:
                                    u = u.combine(gg)
                                except Exception:
                                    pass
                        if u and not u.isEmpty():
                            new_chunks.append(u)
                    chunks = new_chunks

                return chunks[0] if chunks else None
            else:
                return QgsGeometry.unaryUnion(geoms)

        except Exception:
            try:
                u = geoms[0]
                for gg in geoms[1:]:
                    try:
                        u = u.combine(gg)
                    except Exception:
                        pass
                return u
            except Exception:
                return None

    # ------------------------------------------------------------------
    # Main execute
    # ------------------------------------------------------------------

    def execute(self, context):
        try:
            confirm_erase = bool(self.get_setting('confirm_erase', True))
            auto_commit = bool(self.get_setting('auto_commit_changes', True))
            batch_size = int(self.get_setting('batch_size', 50))
            batch_size = max(1, min(1000, batch_size))
        except Exception as e:
            self.show_error('Error', f'Invalid settings: {e}')
            return

        # --- Extract source layer ---
        detected = context.get('detected_features', [])
        if not detected:
            self.show_error('Error', 'No layer detected in context')
            return

        source_layer = detected[0].layer
        if source_layer is None or not isinstance(source_layer, QgsVectorLayer):
            self.show_error('Error', 'Source layer not available')
            return

        try:
            if source_layer.geometryType() != QgsWkbTypes.PolygonGeometry:
                self.show_error('Error', 'This action works only on polygon layers')
                return
        except Exception:
            pass

        # --- Collect candidate eraser layers ---
        project = QgsProject.instance()
        polygon_layers = []
        for lid, lyr in project.mapLayers().items():
            if not isinstance(lyr, QgsVectorLayer):
                continue
            try:
                if lyr.geometryType() != QgsWkbTypes.PolygonGeometry:
                    continue
            except Exception:
                continue
            if lyr.id() == source_layer.id():
                continue
            if not lyr.isValid():
                continue
            polygon_layers.append(lyr)

        if not polygon_layers:
            self.show_error('Error', 'No other polygon layers available in the project')
            return

        # --- Let user pick the eraser layer ---
        choices = [f"{ly.name()} ({ly.id()[:8]})" for ly in polygon_layers]
        chosen, ok = QInputDialog.getItem(
            None, tr('Select Eraser Layer'),
            tr('Erase coverage of layer:'),
            choices, 0, False
        )
        if not ok:
            return

        # Resolve chosen layer
        eraser_layer = None
        for ly in polygon_layers:
            if ly.id().startswith(chosen.split('(')[-1].rstrip(')')):
                eraser_layer = ly
                break
        if eraser_layer is None:
            name_part = chosen.split(' (')[0]
            for ly in polygon_layers:
                if ly.name() == name_part:
                    eraser_layer = ly
                    break
        if eraser_layer is None:
            self.show_error(tr('Error'), tr('Eraser layer not found'))
            return

        # --- Optional confirmation ---
        if confirm_erase:
            if not self.confirm_action(
                tr('Erase Layer'),
                tr("Erase overlapping parts of all polygons in layer '{source}' using coverage of layer '{eraser}'?").format(
                    source=source_layer.name(), eraser=eraser_layer.name()
                )
            ):
                return

        # --- Compute eraser union (once, before iterating source features) ---
        # Use source layer bbox (transformed to eraser CRS) as a spatial filter so
        # we only load eraser features near the source data — not the whole layer.
        source_crs = source_layer.crs()
        eraser_crs = eraser_layer.crs()

        try:
            source_extent = source_layer.extent()
            bbox_geom = None
            try:
                bbox_geom = QgsGeometry.fromRect(source_extent)
                if source_crs.isValid() and eraser_crs.isValid() and source_crs != eraser_crs:
                    t = QgsCoordinateTransform(source_crs, eraser_crs, QgsProject.instance())
                    bbox_geom.transform(t)
            except Exception:
                bbox_geom = None

            try:
                QApplication.setOverrideCursor(Qt.WaitCursor)
            except Exception:
                pass

            eraser_union = self._compute_union_of_layer(
                eraser_layer, geom_filter=bbox_geom, batch_size=batch_size
            )
            # If spatial filter yielded nothing, retry without filter (safety fallback)
            if eraser_union is None or eraser_union.isEmpty():
                eraser_union = self._compute_union_of_layer(
                    eraser_layer, geom_filter=None, batch_size=batch_size
                )
            if eraser_union is None or eraser_union.isEmpty():
                try:
                    QApplication.restoreOverrideCursor()
                except Exception:
                    pass
                self.show_error(tr('Error'), tr('Failed to compute eraser layer coverage geometry'))
                return

        except Exception as e:
            try:
                QApplication.restoreOverrideCursor()
            except Exception:
                pass
            self.show_error(tr('Error'), tr('Failed to compute eraser coverage: {error}').format(error=e))
            return

        # --- Enter edit mode on source layer ---
        was_in_edit, entered = self.handle_edit_mode(source_layer, 'Erase layer polygons')
        if was_in_edit is None:
            try:
                QApplication.restoreOverrideCursor()
            except Exception:
                pass
            return

        features_payload = []
        changed = 0
        fully_erased = 0
        skipped = 0

        try:
            # Iterate source features (respect selection if present)
            if source_layer.selectedFeatureCount() > 0:
                feats = list(source_layer.selectedFeatures())
            else:
                feats = list(source_layer.getFeatures())

            for feat in feats:
                fid = int(feat.id())
                g = feat.geometry()
                if not g or g.isEmpty():
                    skipped += 1
                    continue

                # Transform feature geometry to eraser CRS for the difference operation
                feat_geom_work = QgsGeometry(g)
                try:
                    if source_crs.isValid() and eraser_crs.isValid() and source_crs != eraser_crs:
                        tf_fwd = QgsCoordinateTransform(source_crs, eraser_crs, QgsProject.instance())
                        feat_geom_work.transform(tf_fwd)
                except Exception:
                    pass

                # Quick bbox pre-check: if bboxes don't overlap, skip (no work needed)
                try:
                    if not feat_geom_work.boundingBox().intersects(eraser_union.boundingBox()):
                        skipped += 1
                        continue
                except Exception:
                    pass

                # === Core operation: difference ===
                try:
                    new_geom_in_eraser_crs = feat_geom_work.difference(eraser_union)
                except Exception:
                    skipped += 1
                    continue

                if new_geom_in_eraser_crs is None or new_geom_in_eraser_crs.isEmpty():
                    # Feature is completely covered by the eraser — set to empty geometry
                    new_geom = QgsGeometry()
                    fully_erased += 1
                else:
                    # Transform result back to source CRS
                    try:
                        if source_crs.isValid() and eraser_crs.isValid() and source_crs != eraser_crs:
                            tf_inv = QgsCoordinateTransform(eraser_crs, source_crs, QgsProject.instance())
                            new_geom_in_eraser_crs.transform(tf_inv)
                    except Exception:
                        pass
                    new_geom = new_geom_in_eraser_crs

                # No-op check: difference equals original means no overlap
                try:
                    if new_geom and not new_geom.isEmpty() and new_geom.equals(g):
                        skipped += 1
                        continue
                except Exception:
                    try:
                        if g.area() > 0 and new_geom.area() > 0:
                            if abs(g.area() - new_geom.area()) / g.area() < 1e-9:
                                skipped += 1
                                continue
                    except Exception:
                        pass

                # Backup old geometry for undo
                try:
                    backup = self.create_feature_backup(feat, source_layer)
                except Exception:
                    backup = None

                # Apply geometry change
                try:
                    if not source_layer.changeGeometry(fid, new_geom):
                        raise Exception('changeGeometry failed')
                    changed += 1

                    try:
                        new_wkb = new_geom.asWkb() if new_geom and not new_geom.isEmpty() else b''
                        new_b64 = base64.b64encode(new_wkb).decode('utf-8') if new_wkb else None
                    except Exception:
                        new_b64 = None

                    features_payload.append({
                        'fid': fid,
                        'old_geometry': backup.get('geometry') if backup else None,
                        'new_geometry': {'wkb_base64': new_b64} if new_b64 else None,
                    })
                except Exception:
                    skipped += 1
                    continue

            if changed == 0:
                self.show_info(
                    tr('No Changes'),
                    tr('No features in layer "{source}" overlap with layer "{eraser}". Nothing was changed.').format(
                        source=source_layer.name(), eraser=eraser_layer.name()
                    )
                )
                # Rollback the edit session (nothing to commit)
                self.rollback_changes(source_layer)
                return

            # --- Commit ---
            if auto_commit:
                if not self.commit_changes(source_layer, 'Erase layer polygons'):
                    return

            try:
                source_layer.triggerRepaint()
            except Exception:
                pass

            # --- Record history ---
            try:
                layer_desc = self.create_layer_descriptor(source_layer)
                desc = (
                    f'Erased {changed} feature(s) in layer "{source_layer.name()}" '
                    f'using coverage of "{eraser_layer.name()}"'
                    + (f' ({fully_erased} fully erased)' if fully_erased else '')
                )
                undo_payload = {
                    'undo_type': 'update_geometry',
                    'layers': [layer_desc],
                    'features': features_payload,
                    'description': desc,
                }
                self.record_to_history(
                    description=desc,
                    undo_type='update_geometry',
                    can_undo=True,
                    undo_payload=undo_payload,
                    layers=[layer_desc],
                    features=features_payload,
                    meta={
                        'erased_count': changed,
                        'fully_erased_count': fully_erased,
                        'skipped_count': skipped,
                        'eraser_layer': eraser_layer.id(),
                    },
                )
            except Exception:
                pass

            # --- Summary message ---
            parts = [f'Modified {changed} feature(s) in layer "{source_layer.name()}".']
            if fully_erased:
                parts.append(f'{fully_erased} feature(s) were fully covered and now have empty geometry.')
            if skipped:
                parts.append(f'{skipped} feature(s) had no overlap and were left unchanged.')
            self.show_info(tr('Result'), '\n'.join(parts))

        except Exception as e:
            self.rollback_changes(source_layer)
            self.show_error(tr('Error'), tr('Failed while erasing layer: {error}').format(error=e))
        finally:
            try:
                QApplication.restoreOverrideCursor()
            except Exception:
                pass
            self.exit_edit_mode(source_layer, entered)


# Global instance for automatic discovery
erase_polygons_layer_by_layer_coverage = ErasePolygonsLayerByLayerCoverageAction()
