"""
Fill Polygon Layer by Categories Action for Right-click Utilities and Shortcuts Hub

Styles a polygon layer from a selected attribute field. Value 0 is filled grey,
main values are filled with user-selected solid colours, and compound values
joined with "/" are rendered as equal-area colored bands for each referenced
main value.
"""

from .base_action import BaseAction
from ..i18n import tr

import math

from qgis.PyQt.QtCore import Qt
from qgis.PyQt.QtGui import QColor, QIcon, QPixmap
from qgis.PyQt.QtWidgets import (
    QComboBox,
    QDialog,
    QDialogButtonBox,
    QFormLayout,
    QGroupBox,
    QLabel,
    QScrollArea,
    QToolButton,
    QVBoxLayout,
    QDoubleSpinBox,
    QWidget,
    QColorDialog,
)
from qgis.core import (
    QgsCategorizedSymbolRenderer,
    QgsCoordinateReferenceSystem,
    QgsCoordinateTransform,
    QgsFeature,
    QgsField,
    QgsFields,
    QgsGeometry,
    QgsRendererCategory,
    QgsPointXY,
    QgsSimpleFillSymbolLayer,
    QgsFillSymbol,
    QgsProject,
    QgsRectangle,
    QgsVectorLayer,
    QgsWkbTypes,
    Qgis,
)
from qgis.PyQt.QtCore import QVariant


def _value_to_text(value):
    """Return a stable display/classification text for an attribute value."""
    if value is None:
        return ""
    try:
        if hasattr(value, "isNull") and value.isNull():
            return ""
    except Exception:
        pass
    return str(value).strip()


def _is_zero_value(value_text):
    """True when a category should receive the fixed grey fill."""
    if value_text == "0":
        return True
    try:
        return float(value_text) == 0.0 and "/" not in value_text
    except Exception:
        return False


def _color_button(color, parent=None):
    """Create a compact colour swatch button."""
    btn = QToolButton(parent)
    btn.setFixedSize(34, 24)
    btn._color = QColor(color)

    def update_icon():
        pixmap = QPixmap(28, 18)
        pixmap.fill(btn._color)
        btn.setIcon(QIcon(pixmap))
        btn.setIconSize(pixmap.size())

    def pick_color():
        chosen = QColorDialog.getColor(btn._color, parent, tr("Choose Fill Color"))
        if chosen.isValid():
            btn._color = chosen
            update_icon()

    btn.clicked.connect(pick_color)
    btn.color = lambda: QColor(btn._color)
    update_icon()
    return btn


class FillPolygonLayerCategoriesDialog(QDialog):
    """Dialog for selecting the category field and main-value colours."""

    STRIPE_UNIT_OPTIONS = [
        ("pixels", tr("Pixels")),
        ("millimeters", tr("Millimeters")),
        ("centimeters", tr("Centimeters")),
        ("inches", tr("Inches")),
        ("points", tr("Points")),
        ("map_units", tr("Map Units")),
        ("meters_in_map_units", tr("Meters in Map Units")),
    ]

    DEFAULT_COLORS = [
        "#1f77b4",
        "#ff7f0e",
        "#2ca02c",
        "#d62728",
        "#9467bd",
        "#8c564b",
        "#e377c2",
        "#17becf",
        "#bcbd22",
        "#7f7f7f",
    ]

    def __init__(self, layer, stripe_width=1.5, stripe_unit="pixels", parent=None):
        super().__init__(parent)
        self.layer = layer
        self.field_combo = None
        self.color_buttons = {}
        self.summary_widget = None
        self.stripe_width_spin = None
        self.stripe_unit_combo = None
        self.main_values = []
        self.sub_values = []
        self.zero_values = []
        self.value_lookup = {}

        self.setWindowTitle(tr("Fill Polygon Layer by Categories"))
        self.setMinimumWidth(520)
        self.setMinimumHeight(460)
        self._initial_stripe_width = float(stripe_width)
        self._initial_stripe_unit = str(stripe_unit or "pixels")
        self._init_ui()
        self._refresh_categories()

    def _init_ui(self):
        layout = QVBoxLayout(self)

        field_box = QGroupBox(tr("Category Field"))
        field_layout = QFormLayout(field_box)
        self.field_combo = QComboBox()
        field_names = []
        preferred_field = None
        for field in self.layer.fields():
            name = field.name()
            if name.lower() in ("geometry",):
                continue
            if name.lower() == "dominant_name":
                preferred_field = name
            else:
                field_names.append(name)

        if preferred_field is not None:
            self.field_combo.addItem(preferred_field)
        for name in field_names:
            self.field_combo.addItem(name)

        if preferred_field is not None:
            preferred_index = self.field_combo.findText(preferred_field)
            if preferred_index >= 0:
                self.field_combo.setCurrentIndex(preferred_index)
        self.field_combo.currentIndexChanged.connect(self._refresh_categories)
        field_layout.addRow(tr("Attribute column:"), self.field_combo)
        layout.addWidget(field_box)

        stripe_box = QGroupBox(tr("Stripe Settings"))
        stripe_layout = QFormLayout(stripe_box)
        self.stripe_width_spin = QDoubleSpinBox()
        self.stripe_width_spin.setRange(0.1, 1000.0)
        self.stripe_width_spin.setDecimals(2)
        self.stripe_width_spin.setSingleStep(0.5)
        self.stripe_width_spin.setValue(self._initial_stripe_width)
        self.stripe_unit_combo = QComboBox()
        for key, label in self.STRIPE_UNIT_OPTIONS:
            self.stripe_unit_combo.addItem(label, key)
        initial_index = self.stripe_unit_combo.findData(self._initial_stripe_unit)
        if initial_index >= 0:
            self.stripe_unit_combo.setCurrentIndex(initial_index)
        stripe_layout.addRow(tr("Stripe width:"), self.stripe_width_spin)
        stripe_layout.addRow(tr("Stripe unit:"), self.stripe_unit_combo)
        layout.addWidget(stripe_box)

        self.summary_widget = QWidget()
        self.summary_layout = QVBoxLayout(self.summary_widget)
        self.summary_layout.setContentsMargins(0, 0, 0, 0)

        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        scroll.setWidget(self.summary_widget)
        layout.addWidget(scroll, 1)

        buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        try:
            buttons.button(QDialogButtonBox.Ok).setText(tr("Apply Fill"))
        except Exception:
            pass
        buttons.accepted.connect(self.accept)
        buttons.rejected.connect(self.reject)
        layout.addWidget(buttons)

    def _clear_summary(self):
        while self.summary_layout.count():
            item = self.summary_layout.takeAt(0)
            widget = item.widget()
            if widget:
                widget.deleteLater()

    def _refresh_categories(self):
        self._clear_summary()
        self.color_buttons = {}
        self.main_values = []
        self.sub_values = []
        self.zero_values = []
        self.value_lookup = {}

        field_name = self.selected_field()
        if not field_name:
            self.summary_layout.addWidget(QLabel(tr("No attribute fields found.")))
            return

        self.value_lookup = self._collect_values(field_name)
        values = sorted(self.value_lookup.keys(), key=lambda v: v.casefold())
        referenced_parts = set()
        explicit_main = set()

        for value in values:
            if _is_zero_value(value):
                self.zero_values.append(value)
            elif "/" in value:
                parts = self._split_subvalue(value)
                self.sub_values.append((value, parts))
                referenced_parts.update(parts)
            else:
                explicit_main.add(value)

        self.main_values = sorted(explicit_main.union(referenced_parts), key=lambda v: v.casefold())
        self.zero_values = sorted(set(self.zero_values), key=lambda v: v.casefold())
        self.sub_values = sorted(self.sub_values, key=lambda item: item[0].casefold())

        if self.zero_values:
            zero_label = QLabel(
                tr("Value 0 will be filled with fixed grey: {values}").format(
                    values=", ".join(self.zero_values)
                )
            )
            zero_label.setWordWrap(True)
            self.summary_layout.addWidget(zero_label)

        main_box = QGroupBox(tr("Main Values"))
        main_layout = QFormLayout(main_box)
        if self.main_values:
            for index, value in enumerate(self.main_values):
                button = _color_button(self.DEFAULT_COLORS[index % len(self.DEFAULT_COLORS)], self)
                self.color_buttons[value] = button
                main_layout.addRow(QLabel(value), button)
        else:
            main_layout.addRow(QLabel(tr("No main values found.")))
        self.summary_layout.addWidget(main_box)

        sub_box = QGroupBox(tr("Equal-Area Subvalues"))
        sub_layout = QVBoxLayout(sub_box)
        if self.sub_values:
            for value, parts in self.sub_values:
                label = QLabel(f"{value}  ->  {' + '.join(parts)}")
                label.setWordWrap(True)
                sub_layout.addWidget(label)
        else:
            sub_layout.addWidget(QLabel(tr("No values containing '/' found.")))
        self.summary_layout.addWidget(sub_box)
        self.summary_layout.addStretch()

    def _collect_values(self, field_name):
        field_index = self.layer.fields().indexOf(field_name)
        if field_index < 0:
            return {}

        values = {}
        for feature in self.layer.getFeatures():
            raw_value = feature.attribute(field_index)
            text = _value_to_text(raw_value)
            if text:
                values.setdefault(text, raw_value)
        return values

    @staticmethod
    def _split_subvalue(value):
        return [part.strip() for part in value.split("/") if part.strip()]

    def selected_field(self):
        return self.field_combo.currentText() if self.field_combo and self.field_combo.count() else ""

    def selected_colors(self):
        return {
            value: button.color()
            for value, button in self.color_buttons.items()
        }

    def selected_stripe_width(self):
        return float(self.stripe_width_spin.value()) if self.stripe_width_spin else 1.0

    def selected_stripe_unit(self):
        if not self.stripe_unit_combo:
            return "pixels"
        return str(self.stripe_unit_combo.currentData() or "pixels")

    def categories(self):
        return {
            "zero_values": list(self.zero_values),
            "main_values": list(self.main_values),
            "sub_values": list(self.sub_values),
            "value_lookup": dict(self.value_lookup),
        }


class FillPolygonLayerByCategoriesAction(BaseAction):
    """Apply solid and striped fills to a polygon layer based on attribute values."""

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

        self.action_id = "fill_polygon_layer_by_categories"
        self.name = "Fill Polygon Layer by Categories"
        self.category = "Styling"
        self.description = (
            "Fill polygon interiors from a selected attribute field. Value 0 is grey, "
            "single main values get chosen solid colours, and values joined with '/' "
            "are rendered as equal-area bands using their main-value colours."
        )
        self.enabled = True

        self.set_action_scope("layer")
        self.set_supported_scopes(["layer"])
        self.set_supported_click_types(["polygon", "multipolygon"])
        self.set_supported_geometry_types(["polygon", "multipolygon"])

        try:
            from ..history_manager import get_history_manager
            get_history_manager().register_action_handler(self.action_id, self)
        except Exception:
            pass

    def supports_undo(self) -> bool:
        return True

    def get_undo_category(self) -> str:
        return "complex"

    def get_settings_schema(self):
        return {
            "outline_color": {
                "type": "str",
                "default": "#404040",
                "label": "Outline Color",
                "description": "Stroke colour used for polygon borders.",
            },
            "outline_width": {
                "type": "float",
                "default": 0.3,
                "label": "Outline Width",
                "description": "Stroke width used for polygon borders.",
                "min": 0.0,
                "max": 10.0,
                "step": 0.1,
            },
            "stripe_width": {
                "type": "float",
                "default": 1.5,
                "label": "Stripe Width",
                "description": "Width of each coloured stripe line in the selected stripe unit.",
                "min": 0.1,
                "max": 10.0,
                "step": 0.1,
            },
            "stripe_unit": {
                "type": "choice",
                "default": "pixels",
                "label": "Stripe Unit",
                "description": "Measurement unit for stripe width and spacing.",
                "options": [
                    "pixels",
                    "millimeters",
                    "centimeters",
                    "inches",
                    "points",
                    "map_units",
                    "meters_in_map_units",
                ],
            },
            "stripe_angle": {
                "type": "float",
                "default": 45.0,
                "label": "Stripe Angle",
                "description": "Stripe angle in degrees.",
                "min": 0.0,
                "max": 180.0,
                "step": 1.0,
            },
            "show_success_message": {
                "type": "bool",
                "default": True,
                "label": "Show Success Message",
                "description": "Display a summary after applying the renderer.",
            },
        }

    def execute(self, context):
        try:
            outline_color = QColor(str(self.get_setting("outline_color", "#404040")))
            outline_width = float(self.get_setting("outline_width", 0.3))
            stripe_width = float(self.get_setting("stripe_width", 1.5))
            stripe_unit_key = str(self.get_setting("stripe_unit", "pixels"))
            stripe_angle = float(self.get_setting("stripe_angle", 45.0))
            show_success = str(self.get_setting("show_success_message", True)).lower() not in ("false", "0", "no")
        except (TypeError, ValueError) as e:
            self.show_error(tr("Error"), tr("Invalid setting values: {error}").format(error=str(e)))
            return

        layer = context.get("layer")
        if not isinstance(layer, QgsVectorLayer):
            detected_features = context.get("detected_features", [])
            layer = detected_features[0].layer if detected_features else None

        if not isinstance(layer, QgsVectorLayer) or not layer.isValid():
            self.show_error(tr("Error"), tr("No valid polygon layer found."))
            return

        if QgsWkbTypes.geometryType(layer.wkbType()) != QgsWkbTypes.PolygonGeometry:
            self.show_error(tr("Error"), tr("This action works only with polygon layers."))
            return

        if layer.fields().count() == 0:
            self.show_error(tr("Error"), tr("Layer has no attribute fields."))
            return

        old_style = self._save_style(layer)
        old_custom = self._custom_properties(layer)

        dialog = FillPolygonLayerCategoriesDialog(layer, stripe_width=stripe_width, stripe_unit=stripe_unit_key)
        if dialog.exec() != QDialog.Accepted:
            return

        field_name = dialog.selected_field()
        if not field_name:
            self.show_error(tr("Error"), tr("No attribute field selected."))
            return

        categories = dialog.categories()
        colors = dialog.selected_colors()
        stripe_width = dialog.selected_stripe_width()
        stripe_unit_key = dialog.selected_stripe_unit() or stripe_unit_key
        stripe_unit, stripe_scale = self._render_unit_from_key(stripe_unit_key)
        stripe_width = stripe_width * stripe_scale

        try:
            self.set_setting("stripe_width", dialog.selected_stripe_width())
            self.set_setting("stripe_unit", stripe_unit_key)
        except Exception:
            pass

        if not categories["zero_values"] and not categories["main_values"] and not categories["sub_values"]:
            self.show_warning(tr("No Values"), tr("The selected field has no usable values."))
            return

        try:
            source_renderer = self._build_source_renderer(
                field_name=field_name,
                categories=categories,
                colors=colors,
                outline_color=outline_color,
                outline_width=outline_width,
                outline_unit=stripe_unit,
            )
            layer.setRenderer(source_renderer)
            layer.triggerRepaint()
        except Exception as e:
            self.show_error(tr("Error"), tr("Failed to apply polygon fills: {error}").format(error=str(e)))
            return

        overlay_layer = None
        overlay_layer_def = None
        overlay_layer_desc = None
        try:
            if categories["sub_values"]:
                overlay_layer, overlay_layer_def = self._build_equal_area_overlay_layer(
                    source_layer=layer,
                    field_name=field_name,
                    categories=categories,
                    colors=colors,
                    outline_color=outline_color,
                    outline_width=outline_width,
                    band_width=stripe_width,
                    band_unit=stripe_unit,
                    band_angle=stripe_angle,
                )
                if overlay_layer and overlay_layer.isValid():
                    QgsProject.instance().addMapLayer(overlay_layer)
                    overlay_layer_desc = self.create_layer_descriptor(overlay_layer)
        except Exception as e:
            self.show_warning(
                tr("Warning"),
                tr("Main layer styling was applied, but the equal-area overlay could not be created: {error}").format(error=str(e))
            )

        new_style = self._save_style(layer)
        new_custom = self._custom_properties(layer)
        layer_desc = self.create_layer_descriptor(layer)

        undo_payload = {
            "source": {
                "layer_id": layer.id(),
                "layer_name": layer.name(),
                "old_style": old_style,
                "old_custom": old_custom,
                "new_style": new_style,
                "new_custom": new_custom,
            },
            "overlay": {
                "layer_id": overlay_layer.id() if overlay_layer else None,
                "layer_name": overlay_layer.name() if overlay_layer else None,
                "layer_def": overlay_layer_def,
            },
            "settings": {
                "field_name": field_name,
                "stripe_width": stripe_width,
                "stripe_unit": stripe_unit_key,
                "stripe_angle": stripe_angle,
            },
        }

        record_layers = [layer_desc]
        if overlay_layer_desc:
            record_layers.append(overlay_layer_desc)

        self.record_to_history(
            description=f"Filled polygon layer '{layer.name()}' by category field '{field_name}'",
            undo_type="none",
            can_undo=True,
            undo_payload=undo_payload,
            layers=record_layers,
            meta={
                "field_name": field_name,
                "zero_count": len(categories["zero_values"]),
                "main_count": len(categories["main_values"]),
                "subvalue_count": len(categories["sub_values"]),
                "stripe_width": stripe_width,
                "stripe_unit": stripe_unit_key,
                "overlay_created": bool(overlay_layer_desc),
            },
        )

        if show_success:
            self.show_info(
                tr("Polygon Fills Applied"),
                tr("Styled layer '{layer}' using field '{field}'.\nMain values: {main}\nEqual-area overlays: {subvalues}").format(
                    layer=layer.name(),
                    field=field_name,
                    main=len(categories["main_values"]),
                    subvalues=len(categories["sub_values"]),
                ),
            )

    def _build_source_renderer(
        self,
        field_name,
        categories,
        colors,
        outline_color,
        outline_width,
        outline_unit,
    ):
        renderer_categories = []

        value_lookup = categories.get("value_lookup", {})

        for value in categories["zero_values"]:
            symbol = self._solid_symbol(QColor("#B0B0B0"), outline_color, outline_width, outline_unit)
            renderer_categories.append(QgsRendererCategory(value_lookup.get(value, value), symbol, value))

        for value in categories["main_values"]:
            symbol = self._solid_symbol(colors.get(value, QColor("#1f77b4")), outline_color, outline_width, outline_unit)
            renderer_categories.append(QgsRendererCategory(value_lookup.get(value, value), symbol, value))

        for value, parts in categories["sub_values"]:
            symbol = self._solid_symbol(QColor(255, 255, 255, 0), QColor(255, 255, 255, 0), 0.0, outline_unit)
            renderer_categories.append(QgsRendererCategory(value_lookup.get(value, value), symbol, value))

        return QgsCategorizedSymbolRenderer(field_name, renderer_categories)

    def _solid_symbol(self, fill_color, outline_color, outline_width, outline_unit=None):
        symbol_layer = QgsSimpleFillSymbolLayer()
        try:
            symbol_layer.setFillColor(QColor(fill_color))
        except Exception:
            pass
        try:
            symbol_layer.setStrokeColor(QColor(outline_color))
        except Exception:
            pass
        try:
            symbol_layer.setStrokeWidth(float(outline_width))
        except Exception:
            pass
        try:
            if outline_unit is not None and hasattr(symbol_layer, "setStrokeWidthUnit"):
                symbol_layer.setStrokeWidthUnit(outline_unit)
        except Exception:
            pass
        try:
            symbol_layer.setStrokeStyle(Qt.SolidLine)
        except Exception:
            pass

        symbol = QgsFillSymbol()
        try:
            symbol.changeSymbolLayer(0, symbol_layer)
        except Exception:
            pass
        return symbol

    def _build_equal_area_overlay_layer(
        self,
        source_layer,
        field_name,
        categories,
        colors,
        outline_color,
        outline_width,
        band_width,
        band_unit,
        band_angle,
    ):
        crs_auth = source_layer.crs().authid() if source_layer.crs().isValid() else ""
        uri = f"Polygon?crs={crs_auth}" if crs_auth else "Polygon"
        overlay_name = f"{source_layer.name()} - Equal Area Bands"
        overlay_layer = QgsVectorLayer(uri, overlay_name, "memory")
        if not overlay_layer.isValid():
            return None, None

        fields = QgsFields()
        fields.append(QgsField("source_fid", QVariant.Int))
        fields.append(QgsField("source_value", QVariant.String))
        fields.append(QgsField("band_index", QVariant.Int))
        fields.append(QgsField("band_color", QVariant.String))
        fields.append(QgsField("band_parts", QVariant.String))
        overlay_layer.dataProvider().addAttributes(fields.toList())
        overlay_layer.updateFields()

        field_index = source_layer.fields().indexOf(field_name)
        if field_index < 0:
            return None, None

        band_features = []
        for feature in source_layer.getFeatures():
            value_text = _value_to_text(feature.attribute(field_index))
            if not value_text or "/" not in value_text:
                continue

            parts = self._split_subvalue(value_text)
            if len(parts) < 2:
                continue

            part_colors = [colors.get(part, QColor("#1f77b4")) for part in parts]
            band_geometries = self._split_geometry_into_equal_area_bands(
                feature.geometry(),
                len(parts),
                source_layer.crs(),
                band_angle,
            )
            if not band_geometries:
                continue

            for idx, band_geom in enumerate(band_geometries):
                if not band_geom or band_geom.isEmpty():
                    continue
                band_feature = QgsFeature(overlay_layer.fields())
                band_feature.setGeometry(band_geom)
                band_feature.setAttribute("source_fid", int(feature.id()))
                band_feature.setAttribute("source_value", value_text)
                band_feature.setAttribute("band_index", idx + 1)
                band_feature.setAttribute("band_color", QColor(part_colors[idx % len(part_colors)]).name(QColor.HexRgb))
                band_feature.setAttribute("band_parts", "/".join(parts))
                band_features.append(band_feature)

        if not band_features:
            return None, None

        ok, _ = overlay_layer.dataProvider().addFeatures(band_features)
        if not ok:
            return None, None

        overlay_layer.updateExtents()
        self._apply_band_renderer(overlay_layer, outline_color, outline_width, band_width, band_unit)

        overlay_def = self._build_layer_definition(
            overlay_layer,
            style_settings={
                "outline_color": QColor(outline_color).name(QColor.HexRgb),
                "outline_width": float(outline_width),
                "band_width": float(band_width),
                "band_unit": self._render_unit_name(band_unit),
            },
        )
        return overlay_layer, overlay_def

    def _apply_band_renderer(self, layer, outline_color, outline_width, band_width, band_unit):
        categories = []
        seen = set()
        for feat in layer.getFeatures():
            color = _value_to_text(feat["band_color"])
            if not color or color in seen:
                continue
            seen.add(color)
            symbol = self._solid_symbol(QColor(color), outline_color, outline_width, band_unit)
            categories.append(QgsRendererCategory(color, symbol, color))

        renderer = QgsCategorizedSymbolRenderer("band_color", categories)
        layer.setRenderer(renderer)

    @staticmethod
    def _split_subvalue(value):
        return [part.strip() for part in str(value).split("/") if part.strip()]

    def _build_layer_definition(self, layer, style_settings=None):
        fields = []
        for field in layer.fields():
            fields.append({
                "name": field.name(),
                "qmeta_type": field.type(),
                "length": field.length(),
                "precision": field.precision(),
            })

        features = []
        for feat in layer.getFeatures():
            features.append(self.create_feature_backup(feat, layer, include_geometry=True, include_attributes=True))

        renderer_props = {}
        try:
            if hasattr(layer, "saveStyleToString"):
                renderer_props["style"] = layer.saveStyleToString()
        except Exception:
            pass

        return {
            "layer_name": layer.name(),
            "crs": layer.crs().authid() if layer.crs().isValid() else "",
            "geometry_type": QgsWkbTypes.displayString(layer.wkbType()),
            "fields": fields,
            "features": features,
            "renderer": renderer_props,
            "style_settings": style_settings or {},
        }

    def apply_undo(self, payload):
        try:
            source = payload.get("source", {}) or {}
            overlay = payload.get("overlay", {}) or {}

            source_layer_id = source.get("layer_id")
            source_layer = QgsProject.instance().mapLayer(source_layer_id) if source_layer_id else None
            if source_layer:
                old_style = source.get("old_style")
                old_custom = source.get("old_custom", {}) or {}
                if old_style:
                    self._apply_style_string(source_layer, old_style)
                self._apply_custom_properties(source_layer, old_custom)
                try:
                    source_layer.triggerRepaint()
                except Exception:
                    pass

            overlay_layer_id = overlay.get("layer_id")
            if overlay_layer_id:
                overlay_layer = QgsProject.instance().mapLayer(overlay_layer_id)
                if overlay_layer:
                    QgsProject.instance().removeMapLayer(overlay_layer_id)

            return True, "Equal-area polygon fills undone"
        except Exception as e:
            return False, f"Undo failed: {e}"

    def apply_redo(self, payload):
        try:
            source = payload.get("source", {}) or {}
            overlay = payload.get("overlay", {}) or {}

            source_layer_id = source.get("layer_id")
            source_layer = QgsProject.instance().mapLayer(source_layer_id) if source_layer_id else None
            if source_layer:
                new_style = source.get("new_style")
                new_custom = source.get("new_custom", {}) or {}
                if new_style:
                    self._apply_style_string(source_layer, new_style)
                self._apply_custom_properties(source_layer, new_custom)
                try:
                    source_layer.triggerRepaint()
                except Exception:
                    pass

            overlay_def = overlay.get("layer_def")
            if overlay_def:
                restored_overlay = self._restore_layer_from_definition(overlay_def)
                if restored_overlay and restored_overlay.isValid():
                    QgsProject.instance().addMapLayer(restored_overlay)
                    overlay["layer_id"] = restored_overlay.id()

            return True, "Equal-area polygon fills restored"
        except Exception as e:
            return False, f"Redo failed: {e}"

    def _restore_layer_from_definition(self, layer_def):
        try:
            layer_name = layer_def.get("layer_name", "Restored Layer")
            crs = layer_def.get("crs", "")
            geom_type = layer_def.get("geometry_type", "Polygon")
            uri = f"{geom_type}?crs={crs}" if crs else geom_type
            layer = QgsVectorLayer(uri, layer_name, "memory")
            if not layer or not layer.isValid():
                return None

            field_defs = []
            for f in layer_def.get("fields", []):
                try:
                    qmeta_type = int(f.get("qmeta_type")) if f.get("qmeta_type") is not None else QVariant.String
                    fld = QgsField(f.get("name"), qmeta_type)
                    try:
                        fld.setLength(int(f.get("length", 0) or 0))
                    except Exception:
                        pass
                    try:
                        fld.setPrecision(int(f.get("precision", 0) or 0))
                    except Exception:
                        pass
                    field_defs.append(fld)
                except Exception:
                    try:
                        field_defs.append(QgsField(f.get("name"), QVariant.String))
                    except Exception:
                        pass

            if field_defs:
                layer.dataProvider().addAttributes(field_defs)
                layer.updateFields()

            feats = []
            for fb in layer_def.get("features", []) or []:
                try:
                    feat = QgsFeature(layer.fields())
                    geom_info = fb.get("geometry") or {}
                    wkb_b64 = geom_info.get("wkb_base64")
                    if wkb_b64:
                        import base64
                        from qgis.PyQt.QtCore import QByteArray
                        wkb_bytes = base64.b64decode(wkb_b64)
                        try:
                            geom = QgsGeometry.fromWkb(QByteArray(wkb_bytes))
                        except Exception:
                            geom = QgsGeometry()
                            geom.fromWkb(wkb_bytes)
                        feat.setGeometry(geom)

                    attrs = fb.get("attributes", {}) or {}
                    for field in layer.fields():
                        if field.name() in attrs:
                            feat.setAttribute(field.name(), attrs.get(field.name()))
                    feats.append(feat)
                except Exception:
                    continue

            if feats:
                layer.dataProvider().addFeatures(feats)
                layer.updateExtents()

            style_string = (layer_def.get("renderer") or {}).get("style")
            if style_string:
                self._apply_style_string(layer, style_string)

            return layer
        except Exception:
            return None

    def _apply_style_string(self, layer, style_str):
        if not layer or not style_str:
            return False
        try:
            if hasattr(layer, "loadStyleFromString"):
                try:
                    layer.loadStyleFromString(style_str)
                    return True
                except Exception:
                    pass
            if hasattr(layer, "loadNamedStyle"):
                try:
                    layer.loadNamedStyle(style_str)
                    return True
                except Exception:
                    pass
        except Exception:
            pass
        return False

    def _apply_custom_properties(self, layer, props):
        if not layer or not isinstance(props, dict):
            return
        try:
            for key in list(layer.customPropertyKeys()):
                try:
                    layer.removeCustomProperty(key)
                except Exception:
                    pass
            for key, value in props.items():
                try:
                    layer.setCustomProperty(key, value)
                except Exception:
                    pass
        except Exception:
            pass

    def _split_geometry_into_equal_area_bands(self, geometry, num_bands, layer_crs, band_angle):
        if not geometry or geometry.isEmpty() or num_bands <= 0:
            return []

        working_geom, calc_crs = self._prepare_geometry_for_band_split(geometry, layer_crs)
        if working_geom is None or working_geom.isEmpty():
            return []

        center_point = self._geometry_center_point(working_geom)
        if center_point is None:
            return []

        rotated = QgsGeometry(working_geom)
        try:
            rotated.rotate(-band_angle, center_point)
        except Exception:
            pass

        bands_rotated = self._divide_vertical_equal_area(rotated, num_bands)
        results = []
        for band in bands_rotated:
            if not band or band.isEmpty():
                continue
            band_geom = QgsGeometry(band)
            try:
                band_geom.rotate(band_angle, center_point)
            except Exception:
                pass
            if calc_crs and layer_crs and calc_crs.authid() != layer_crs.authid():
                band_geom = self._transform_geometry(band_geom, calc_crs, layer_crs)
                if band_geom is None:
                    continue
            results.append(band_geom)

        return results

    def _prepare_geometry_for_band_split(self, geometry, layer_crs):
        geom = QgsGeometry(geometry)
        try:
            if not geom.isGeosValid():
                fixed = geom.makeValid()
                if fixed and not fixed.isEmpty():
                    geom = fixed
        except Exception:
            pass

        if not layer_crs or not layer_crs.isValid() or not layer_crs.isGeographic():
            return geom, layer_crs

        centroid = None
        try:
            centroid = geom.centroid().asPoint()
        except Exception:
            pass

        try:
            if centroid is not None:
                utm_zone = int((centroid.x() + 180) / 6) + 1
                hemisphere = "north" if centroid.y() >= 0 else "south"
                utm_epsg = f"EPSG:{32600 + utm_zone}" if hemisphere == "north" else f"EPSG:{32700 + utm_zone}"
                projected_crs = QgsCoordinateReferenceSystem(utm_epsg)
            else:
                projected_crs = QgsCoordinateReferenceSystem("EPSG:3857")
        except Exception:
            projected_crs = QgsCoordinateReferenceSystem("EPSG:3857")

        if not projected_crs.isValid():
            return geom, layer_crs

        transformed = self._transform_geometry(geom, layer_crs, projected_crs)
        if transformed is None:
            return geom, layer_crs
        return transformed, projected_crs

    @staticmethod
    def _geometry_center_point(geometry):
        try:
            centroid = geometry.centroid()
            if centroid and not centroid.isEmpty():
                pt = centroid.asPoint()
                return QgsPointXY(pt.x(), pt.y())
        except Exception:
            pass
        try:
            bbox = geometry.boundingBox()
            return QgsPointXY(bbox.center().x(), bbox.center().y())
        except Exception:
            return None

    @staticmethod
    def _transform_geometry(geometry, source_crs, target_crs):
        if not geometry or geometry.isEmpty():
            return None
        if not source_crs or not target_crs or not source_crs.isValid() or not target_crs.isValid() or source_crs.authid() == target_crs.authid():
            return QgsGeometry(geometry)
        try:
            geom = QgsGeometry(geometry)
            transform = QgsCoordinateTransform(source_crs, target_crs, QgsProject.instance())
            geom.transform(transform)
            return geom
        except Exception:
            return None

    @staticmethod
    def _divide_vertical_equal_area(geometry, num_divisions):
        if not geometry or geometry.isEmpty() or num_divisions <= 0:
            return []

        bbox = geometry.boundingBox()
        total_area = geometry.area()
        if total_area is None or total_area <= 0:
            return []

        target_area = total_area / num_divisions
        area_tolerance = max(target_area * 0.000001, 1e-12)
        coord_tolerance = max((bbox.xMaximum() - bbox.xMinimum()) * 1e-10, 1e-12)
        slice_positions = [bbox.xMinimum()]
        x_max = bbox.xMaximum()

        for _ in range(1, num_divisions):
            x_low = slice_positions[-1]
            x_high = x_max
            for _iteration in range(100):
                if x_high - x_low < coord_tolerance:
                    break
                x_mid = (x_low + x_high) / 2.0
                slice_rect = QgsGeometry.fromRect(
                    QgsRectangle(slice_positions[-1], bbox.yMinimum(), x_mid, bbox.yMaximum())
                )
                slice_geom = geometry.intersection(slice_rect)
                slice_area = slice_geom.area() if slice_geom and not slice_geom.isEmpty() else 0
                if abs(slice_area - target_area) < area_tolerance:
                    break
                if slice_area < target_area:
                    x_low = x_mid
                else:
                    x_high = x_mid
            slice_positions.append((x_low + x_high) / 2.0)

        slice_positions.append(x_max)

        bands = []
        for idx in range(num_divisions):
            x_start = slice_positions[idx]
            x_end = slice_positions[idx + 1]
            slice_rect = QgsGeometry.fromRect(
                QgsRectangle(x_start, bbox.yMinimum(), x_end, bbox.yMaximum())
            )
            band_geom = geometry.intersection(slice_rect)
            if band_geom and not band_geom.isEmpty():
                bands.append(band_geom)
        return bands

    @staticmethod
    def _render_unit_name(unit):
        mapping = {
            Qgis.RenderUnit.Pixels: "pixels",
            Qgis.RenderUnit.Millimeters: "millimeters",
            Qgis.RenderUnit.Inches: "inches",
            Qgis.RenderUnit.Points: "points",
            Qgis.RenderUnit.MapUnits: "map_units",
            Qgis.RenderUnit.MetersInMapUnits: "meters_in_map_units",
        }
        return mapping.get(unit, "pixels")

    @staticmethod
    def _render_unit_from_key(unit_key):
        mapping = {
            "pixels": (Qgis.RenderUnit.Pixels, 1.0),
            "millimeters": (Qgis.RenderUnit.Millimeters, 1.0),
            "centimeters": (Qgis.RenderUnit.Millimeters, 10.0),
            "inches": (Qgis.RenderUnit.Inches, 1.0),
            "points": (Qgis.RenderUnit.Points, 1.0),
            "map_units": (Qgis.RenderUnit.MapUnits, 1.0),
            "meters_in_map_units": (Qgis.RenderUnit.MetersInMapUnits, 1.0),
        }
        return mapping.get(unit_key, (Qgis.RenderUnit.Pixels, 1.0))

    @staticmethod
    def _save_style(layer):
        try:
            if hasattr(layer, "saveStyleToString"):
                return layer.saveStyleToString()
        except Exception:
            pass
        return None

    @staticmethod
    def _custom_properties(layer):
        props = {}
        try:
            for key in layer.customPropertyKeys():
                try:
                    value = layer.customProperty(key)
                    if isinstance(value, (str, int, float, bool)) or value is None:
                        props[key] = value
                    else:
                        props[key] = str(value)
                except Exception:
                    pass
        except Exception:
            pass
        return props


# REQUIRED: Create global instance for automatic discovery
fill_polygon_layer_by_categories_action = FillPolygonLayerByCategoriesAction()
