# -*- coding: utf-8 -*-
"""
Dialog to manage network style sets and apply them to loaded networks.
"""

from __future__ import annotations

import os
import re
import shutil
import xml.etree.ElementTree as ET
from typing import Optional

from qgis.PyQt import uic, QtWidgets
from qgis.PyQt.QtCore import Qt, QSize
from qgis.PyQt.QtGui import QPainter, QPixmap, QImage
from qgis.PyQt.QtWidgets import QDialogButtonBox, QMessageBox, QInputDialog, QLineEdit
from qgis.PyQt.QtXml import QDomDocument

from qgis.core import (
    QgsApplication,
    QgsExpressionContext,
    QgsExpressionContextUtils,
    QgsFeature,
    QgsGeometry,
    QgsLayerTreeGroup,
    QgsLayerTreeLayer,
    QgsMapSettings,
    QgsPathResolver,
    QgsPointXY,
    QgsProject,
    QgsRectangle,
    QgsReadWriteContext,
    QgsRenderContext,
    QgsSymbol,
    QgsSymbolLayerUtils,
    QgsVectorLayer,
)

from .network_store_core import (
    list_style_sets,
    style_set_dir,
    styles_root,
    list_loaded_network_ids,
    apply_style_set_to_network,
)

FORM_CLASS_STYLES, _ = uic.loadUiType(
    os.path.join(os.path.dirname(__file__), "network_store_dialog_styles.ui")
)

_PREVIEW_COLUMNS = 5
_PREVIEW_ROWS = 5
_PREVIEW_CELL_SIZE = 40
_PREVIEW_ICON_SIZE = 32
_PREVIEW_DEBUG_ENV = "NETWORK_STORE_STYLE_PREVIEW_DEBUG"


class NetworkStoreStyleManagerDialog(QtWidgets.QDialog, FORM_CLASS_STYLES):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setupUi(self)

        self.btnRefresh.clicked.connect(self._refresh_all)
        self.btnCopy.clicked.connect(self._copy_style_set)
        self.btnRename.clicked.connect(self._rename_style_set)
        self.btnDelete.clicked.connect(self._delete_style_set)
        self.btnApply.clicked.connect(self._apply_style_to_network)
        self.listStyleSets.currentTextChanged.connect(self._sync_apply_style_from_list)
        self.listStyleSets.currentTextChanged.connect(self._update_style_preview)

        preview_size = QSize(
            _PREVIEW_COLUMNS * _PREVIEW_CELL_SIZE,
            _PREVIEW_ROWS * _PREVIEW_CELL_SIZE,
        )
        self.labelStylePreview.setFixedSize(preview_size)
        self.labelStylePreview.setScaledContents(False)

        close_btn = self.buttonBox.button(QDialogButtonBox.Close)
        if close_btn is not None:
            close_btn.setText(self.tr("Close"))

        self._refresh_all()

    def _refresh_all(self) -> None:
        self._ensure_styles_root()
        self._refresh_style_sets()
        self._refresh_networks()

    def _ensure_styles_root(self) -> None:
        root = styles_root()
        os.makedirs(root, exist_ok=True)
        default_dir = style_set_dir("default")
        if not os.path.isdir(default_dir):
            os.makedirs(default_dir, exist_ok=True)

    def _refresh_style_sets(self) -> None:
        self.listStyleSets.blockSignals(True)
        self.listStyleSets.clear()
        for name in list_style_sets():
            self.listStyleSets.addItem(name)
        self.listStyleSets.blockSignals(False)
        self._select_default_style()
        self._refresh_apply_styles()
        self._update_style_preview()

    def _refresh_apply_styles(self) -> None:
        self.comboApplyStyle.blockSignals(True)
        self.comboApplyStyle.clear()
        for name in list_style_sets():
            self.comboApplyStyle.addItem(name)
        self.comboApplyStyle.blockSignals(False)
        self._select_default_style_combo()

    def _refresh_networks(self) -> None:
        self.comboNetwork.blockSignals(True)
        self.comboNetwork.clear()
        for nid in list_loaded_network_ids():
            self.comboNetwork.addItem(nid)
        self.comboNetwork.blockSignals(False)

    def _update_style_preview(self) -> None:
        style_set = self._selected_style_set() or "default"
        qml_paths = self._list_preview_style_qmls(style_set)
        if not qml_paths:
            self.labelStylePreview.setPixmap(QPixmap())
            self.labelStylePreview.setText(self.tr("No point styles found."))
            return
        pixmap = self._render_style_preview(qml_paths)
        self._dump_preview_pixmap(pixmap, style_set)
        self.labelStylePreview.setText("")
        self.labelStylePreview.setPixmap(pixmap)

    def _list_preview_style_qmls(self, style_set: str) -> list[str]:
        base_dir = style_set_dir(style_set)
        if not os.path.isdir(base_dir):
            base_dir = style_set_dir("default")
        if not os.path.isdir(base_dir):
            return []
        qmls: list[tuple[str, str]] = []
        for name in os.listdir(base_dir):
            if not name.lower().endswith(".qml"):
                continue
            if name.startswith("_"):
                continue
            path = os.path.join(base_dir, name)
            geom = self._qml_geometry_type(path)
            if geom in ("point", "line"):
                qmls.append((geom, path))
        qmls.sort(
            key=lambda item: (
                0 if item[0] == "point" else 1,
                os.path.basename(item[1]).lower(),
            )
        )
        return [path for _, path in qmls]

    def _qml_geometry_type(self, qml_path: str) -> str | None:
        try:
            root = ET.parse(qml_path).getroot()
        except Exception:
            return None
        geom = root.findtext("layerGeometryType")
        if geom is not None:
            geom = geom.strip()
            if geom == "0":
                return "point"
            if geom == "1":
                return "line"
            if geom == "2":
                return "polygon"
            return None
        for symbol in root.findall(".//symbol"):
            sym_type = symbol.attrib.get("type")
            if sym_type == "marker":
                return "point"
            if sym_type == "line":
                return "line"
            if sym_type == "fill":
                return "polygon"
        return None

    def _render_style_preview(self, qml_paths: list[str]) -> QPixmap:
        cols = _PREVIEW_COLUMNS
        rows = _PREVIEW_ROWS
        cell = _PREVIEW_CELL_SIZE
        icon = _PREVIEW_ICON_SIZE
        grid_w = cols * cell
        grid_h = rows * cell
        pixmap = QPixmap(grid_w, grid_h)
        pixmap.fill(Qt.transparent)

        painter = QPainter(pixmap)
        painter.setRenderHint(QPainter.Antialiasing, True)

        layer = QgsVectorLayer("Point?crs=EPSG:4326", "preview", "memory")
        max_items = cols * rows
        for idx, qml_path in enumerate(qml_paths[:max_items]):
            icon_pixmap = self._render_qml_symbol_preview(qml_path, QSize(icon, icon))
            if self._pixmap_is_blank(icon_pixmap):
                if not self._load_named_style(layer, qml_path):
                    continue
                icon_pixmap = self._render_layer_preview(layer, QSize(icon, icon))
                if self._pixmap_is_blank(icon_pixmap):
                    icon_pixmap = self._render_layer_tree_preview(
                        layer, QSize(icon, icon)
                    )
                if self._pixmap_is_blank(icon_pixmap):
                    renderer = layer.renderer()
                    if renderer is None:
                        continue
                    symbol = renderer.symbol()
                    if symbol is None:
                        continue
                    symbol = symbol.clone()
                    icon_pixmap = QgsSymbolLayerUtils.symbolPreviewPixmap(
                        symbol, QSize(icon, icon)
                    )
            row = idx // cols
            col = idx % cols
            x = col * cell + (cell - icon) // 2
            y = row * cell + (cell - icon) // 2
            painter.drawPixmap(x, y, icon_pixmap)

        painter.end()
        return pixmap

    @staticmethod
    def _normalize_load_result(result) -> tuple[bool, str]:
        if isinstance(result, tuple):
            if len(result) >= 2:
                first, second = result[0], result[1]
                if isinstance(first, bool) and isinstance(second, str):
                    return first, second
                if isinstance(first, str) and isinstance(second, bool):
                    return second, first
                if isinstance(first, bool):
                    return first, str(second)
                if isinstance(second, bool):
                    return second, str(first)
                return bool(first), str(second)
            if len(result) == 1:
                return bool(result[0]), ""
            return False, ""
        return bool(result), ""

    def _load_named_style(self, layer: QgsVectorLayer, qml_path: str) -> bool:
        ok, _msg = self._normalize_load_result(layer.loadNamedStyle(qml_path))
        return ok

    def _render_qml_symbol_preview(self, qml_path: str, size: QSize) -> QPixmap:
        symbol = self._symbol_from_qml(qml_path)
        if symbol is None:
            return QPixmap()
        self._resolve_svg_paths(symbol, qml_path)
        return QgsSymbolLayerUtils.symbolPreviewPixmap(symbol, size)

    def _symbol_from_qml(self, qml_path: str) -> QgsSymbol | None:
        doc = QDomDocument()
        try:
            with open(qml_path, "r", encoding="utf-8") as handle:
                content = handle.read()
        except OSError:
            return None
        if not doc.setContent(content):
            return None
        symbols = doc.elementsByTagName("symbol")
        if symbols.isEmpty():
            return None
        symbol_elem = symbols.at(0).toElement()
        context = QgsReadWriteContext()
        try:
            context.setPathResolver(QgsPathResolver(os.path.dirname(qml_path)))
        except Exception:
            pass
        try:
            symbol = QgsSymbolLayerUtils.loadSymbol(symbol_elem, context)
        except Exception:
            return None
        return symbol

    def _resolve_svg_paths(self, symbol: QgsSymbol, qml_path: str) -> None:
        plugin_root = os.path.dirname(__file__)
        svg_dirs = []
        try:
            svg_dirs = QgsApplication.svgPaths()
        except Exception:
            svg_dirs = []
        svg_dirs = [plugin_root] + svg_dirs

        for idx in range(symbol.symbolLayerCount()):
            layer = symbol.symbolLayer(idx)
            if not hasattr(layer, "path") or not hasattr(layer, "setPath"):
                continue
            svg_path = layer.path()
            if not svg_path or os.path.isabs(svg_path):
                continue
            for base in svg_dirs:
                candidate = os.path.join(base, svg_path)
                if os.path.exists(candidate):
                    layer.setPath(candidate)
                    break

    def _pixmap_is_blank(self, pixmap: QPixmap) -> bool:
        if pixmap.isNull():
            return True
        image = pixmap.toImage().convertToFormat(QImage.Format_ARGB32)
        width = image.width()
        height = image.height()
        for y in range(height):
            for x in range(width):
                if image.pixelColor(x, y).alpha() > 0:
                    return False
        return True

    def _render_layer_tree_preview(self, layer: QgsVectorLayer, size: QSize) -> QPixmap:
        try:
            from qgis.gui import QgsLayerTreeModel
        except Exception:
            return QPixmap()

        root = QgsLayerTreeGroup()
        root.addChildNode(QgsLayerTreeLayer(layer))

        model = QgsLayerTreeModel(root)
        model.setFlag(QgsLayerTreeModel.ShowLegend, True)
        try:
            model.refreshLayerLegend(root.children()[0])
        except Exception:
            pass

        layer_index = model.index(0, 0)
        for row in range(model.rowCount(layer_index)):
            idx = model.index(row, 0, layer_index)
            icon = model.data(idx, Qt.DecorationRole)
            if hasattr(icon, "pixmap"):
                pixmap = icon.pixmap(size)
                if not self._pixmap_is_blank(pixmap):
                    return pixmap

        return QPixmap()

    def _render_layer_preview(self, layer: QgsVectorLayer, size: QSize) -> QPixmap:
        renderer = layer.renderer()
        if renderer is None:
            return QPixmap()

        image = QImage(size, QImage.Format_ARGB32_Premultiplied)
        image.fill(Qt.transparent)
        painter = QPainter(image)

        map_settings = QgsMapSettings()
        map_settings.setOutputSize(size)
        map_settings.setExtent(QgsRectangle(-1, -1, 1, 1))

        render_context = QgsRenderContext.fromMapSettings(map_settings)
        render_context.setPainter(painter)
        render_context.setFlag(QgsRenderContext.Antialiasing, True)

        expr_context = QgsExpressionContext()
        expr_context.appendScopes(
            QgsExpressionContextUtils.globalProjectLayerScopes(
                QgsProject.instance(), layer
            )
        )

        feature = QgsFeature(layer.fields())
        feature.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(0, 0)))
        expr_context.setFeature(feature)
        render_context.setExpressionContext(expr_context)

        renderer.startRender(render_context, layer.fields())
        renderer.renderFeature(feature, render_context)
        renderer.stopRender(render_context)

        painter.end()
        return QPixmap.fromImage(image)

    def _dump_preview_pixmap(self, pixmap: QPixmap, style_set: str) -> None:
        debug_path = os.environ.get(_PREVIEW_DEBUG_ENV, "").strip()
        if not debug_path:
            debug_path = "/tmp/ns_style_preview.png"
        path = debug_path
        if os.path.isdir(path):
            safe_name = self._sanitize_style_set_name(style_set) or "style"
            path = os.path.join(path, f"style_preview_{safe_name}.png")
        try:
            pixmap.save(path, "PNG")
        except Exception:
            pass

    def _select_default_style(self) -> None:
        items = self.listStyleSets.findItems("default", Qt.MatchExactly)
        if items:
            self.listStyleSets.setCurrentItem(items[0])
        elif self.listStyleSets.count() > 0:
            self.listStyleSets.setCurrentRow(0)

    def _select_default_style_combo(self) -> None:
        idx = self.comboApplyStyle.findText("default")
        if idx >= 0:
            self.comboApplyStyle.setCurrentIndex(idx)
        elif self.comboApplyStyle.count() > 0:
            self.comboApplyStyle.setCurrentIndex(0)

    def _selected_style_set(self) -> Optional[str]:
        item = self.listStyleSets.currentItem()
        return item.text().strip() if item else None

    def _sanitize_style_set_name(self, raw: str) -> str:
        name = re.sub(r"\s+", "_", (raw or "").strip())
        name = re.sub(r"[^A-Za-z0-9_-]+", "_", name)
        name = re.sub(r"_+", "_", name).strip("_")
        return name

    def _prompt_style_name(self, title: str, default: str) -> Optional[str]:
        name, ok = QInputDialog.getText(
            self, title, self.tr("Style set name:"), QLineEdit.Normal, default
        )
        if not ok:
            return None
        name = self._sanitize_style_set_name(name)
        if not name:
            QMessageBox.warning(
                self, self.tr("Invalid name"), self.tr("Style set name is required.")
            )
            return None
        return name

    def _copy_style_set(self) -> None:
        src_name = self._selected_style_set()
        if not src_name:
            return
        new_name = self._prompt_style_name(
            self.tr("Copy style set"), f"{src_name}_copy"
        )
        if not new_name:
            return
        if new_name == "default":
            QMessageBox.warning(
                self,
                self.tr("Invalid name"),
                self.tr("The 'default' style set is reserved."),
            )
            return
        src_dir = style_set_dir(src_name)
        dest_dir = style_set_dir(new_name)
        if os.path.exists(dest_dir):
            QMessageBox.warning(
                self,
                self.tr("Name exists"),
                self.tr(f"A style set named '{new_name}' already exists."),
            )
            return
        try:
            shutil.copytree(src_dir, dest_dir)
        except Exception as e:  # noqa: BLE001
            QMessageBox.critical(
                self,
                self.tr("Copy failed"),
                self.tr(f"Failed to copy style set: {e}"),
            )
            return
        self._refresh_style_sets()

    def _rename_style_set(self) -> None:
        src_name = self._selected_style_set()
        if not src_name:
            return
        if src_name == "default":
            QMessageBox.warning(
                self,
                self.tr("Not allowed"),
                self.tr("The 'default' style set cannot be renamed."),
            )
            return
        new_name = self._prompt_style_name(self.tr("Rename style set"), src_name)
        if not new_name or new_name == src_name:
            return
        if new_name == "default":
            QMessageBox.warning(
                self,
                self.tr("Invalid name"),
                self.tr("The 'default' style set is reserved."),
            )
            return
        dest_dir = style_set_dir(new_name)
        if os.path.exists(dest_dir):
            QMessageBox.warning(
                self,
                self.tr("Name exists"),
                self.tr(f"A style set named '{new_name}' already exists."),
            )
            return
        try:
            shutil.move(style_set_dir(src_name), dest_dir)
        except Exception as e:  # noqa: BLE001
            QMessageBox.critical(
                self,
                self.tr("Rename failed"),
                self.tr(f"Failed to rename style set: {e}"),
            )
            return
        self._refresh_style_sets()

    def _delete_style_set(self) -> None:
        name = self._selected_style_set()
        if not name:
            return
        if name == "default":
            QMessageBox.warning(
                self,
                self.tr("Not allowed"),
                self.tr("The 'default' style set cannot be deleted."),
            )
            return
        res = QMessageBox.question(
            self,
            self.tr("Delete style set"),
            self.tr(f"Delete style set '{name}'? This cannot be undone."),
            QMessageBox.Yes | QMessageBox.No,
        )
        if res != QMessageBox.Yes:
            return
        try:
            shutil.rmtree(style_set_dir(name))
        except Exception as e:  # noqa: BLE001
            QMessageBox.critical(
                self,
                self.tr("Delete failed"),
                self.tr(f"Failed to delete style set: {e}"),
            )
            return
        self._refresh_style_sets()

    def _sync_apply_style_from_list(self, name: str) -> None:
        idx = self.comboApplyStyle.findText(name)
        if idx >= 0:
            self.comboApplyStyle.setCurrentIndex(idx)

    def _apply_style_to_network(self) -> None:
        network_id = (self.comboNetwork.currentText() or "").strip()
        style_set = (self.comboApplyStyle.currentText() or "").strip()
        if not network_id:
            QMessageBox.warning(
                self, self.tr("Missing network"), self.tr("Select a network first.")
            )
            return
        if not style_set:
            QMessageBox.warning(
                self, self.tr("Missing style"), self.tr("Select a style set first.")
            )
            return
        styled = apply_style_set_to_network(network_id, style_set)
        QMessageBox.information(
            self,
            self.tr("Style applied"),
            self.tr(
                f"Applied style set '{style_set}' to {styled} layer(s) in '{network_id}'."
            ),
        )
