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

import json
import logging
from enum import Enum
from pathlib import Path

from PyQt5.QtWebChannel import QWebChannel
from PyQt5.QtWebEngineWidgets import (
    QWebEngineDownloadItem,
    QWebEnginePage,
    QWebEngineProfile,
    QWebEngineView,
)
from qgis.core import (
    Qgis,
    QgsApplication,
    QgsProject,
)
from qgis.gui import QgisInterface
from qgis.PyQt import QtWidgets, uic
from qgis.PyQt.QtCore import (
    QFileInfo,
    QObject,
    QSize,
    Qt,
    QThreadPool,
    QTimer,
    QUrl,
    QUrlQuery,
    pyqtSignal,
    pyqtSlot,
)
from qgis.PyQt.QtGui import QCloseEvent, QDesktopServices, QIcon
from qgis.PyQt.QtWidgets import (
    QApplication,
    QFileDialog,
    QGroupBox,
    QLabel,
    QMessageBox,
    QPushButton,
    QSizePolicy,
    QTextBrowser,
    QWidget,
)

from xmas_plugin import settings_manager
from xmas_plugin.plan_manager import PlanManager
from xmas_plugin.plan_tools import PlanTools
from xmas_plugin.processing.alg_split_plan import SPLIT_BUS
from xmas_plugin.server_manager import ServerManager  # worker infra kept available
from xmas_plugin.services.http_worker import PostWorker
from xmas_plugin.settings_manager import (
    get_appschema,
    save_setting,
    show_settings_dialog,
)
from xmas_plugin.topo_check.topo_check import TopologyChecker
from xmas_plugin.util.health_checker import HealthChecker
from xmas_plugin.util.helpers import get_main_window, get_plugin_root
from xmas_plugin.util.metadata import PLUGIN_DIR_NAME, PLUGIN_NAME
from xmas_plugin.util.webengine import (
    detach_channel,
    on_view_destroyed,
)
from xmas_plugin.widgets.page import XMASPluginPage

FORM_CLASS, _ = uic.loadUiType(str(Path(__file__).parent / "ui/dock.ui"))
logger = logging.getLogger(PLUGIN_DIR_NAME)


class CustomWebEnginePage(QWebEnginePage):
    """Custom page to pipe JS console messages into Python logging."""

    def certificateError(self, certificateError):
        """Log, but ignore SSL certificate errors."""
        logger.warning("Certificate error: %s", certificateError)
        return True

    def javaScriptConsoleMessage(self, level, message, lineNumber, sourceID):
        print(f"JS[{level}] {sourceID}:{lineNumber}: {message}")
        logger.warning(f"JS[{level}] {sourceID}:{lineNumber}: {message}")


class XMASPluginDockWidget(QtWidgets.QDockWidget, FORM_CLASS):
    """Main dock widget."""

    closingPlugin = pyqtSignal()
    runSplitRequested = pyqtSignal()

    def __init__(
        self,
        iface: QgisInterface,
        server_manager: ServerManager,
        plan_manager: PlanManager,
        parent: QWidget | None = None,
    ) -> None:
        """Constructor."""
        super(XMASPluginDockWidget, self).__init__(parent)
        logger.debug("DockWidget __init__ entered")

        # Initialize UI BEFORE setting the iface variable to avoid bug where iface becomes a bool
        self.setupUi(self)
        self._accept_split_done = True
        self.iface = iface
        self.plugin_dir = get_plugin_root()
        self.visibilityChanged.connect(self._on_visibility_changed)

        # Traffic light indicator
        self.traffic_light: QLabel = self.findChild(QLabel, "trafficLightLabel")
        logger.debug(f"traffic_light found? {self.traffic_light is not None}")

        self._last_reported_state = None
        self._server_owned = False
        self._server_start_count = 0

        # Track workers to avoid GC while threads still emit
        self._workers = set()

        # set up tabs
        self.tabs = {
            "Planmanager": self.planManager,
            "Topologie Prüfung": self.topoCheck,
            "Plan aufteilen": self.splitPlan,
            "Planwerkzeuge": self.planTools,
        }

        # hide close button for Planmanager tab
        idx = self.tabWidget.indexOf(self.tabs.get("Planmanager"))
        tab_bar = self.tabWidget.tabBar()
        tab_bar.setTabButton(idx, tab_bar.RightSide, None)

        # hide all tabs on init except Planmanager, index 0
        self.close_all_tab_pages(self.tabWidget)

        # # Wire up signals
        self.openSplitPlan.clicked.connect(
            lambda: self.open_tab_page(name="Plan aufteilen")
        )
        self.openPlanTools.clicked.connect(
            lambda: self.open_tab_page(name="Planwerkzeuge")
        )
        self.openTopocheck.clicked.connect(
            lambda: self.open_tab_page(name="Topologie Prüfung")
        )
        self.openHelp.clicked.connect(self.show_manual)

        # ---- Split + Post / Cancel buttons ----
        try:
            # Post (always visible, disabled until split_done)
            self.btnSaveSplitPlan.setEnabled(False)
            # Cancel (always visible, disabled until split_done)
            self.btnCancelSplitPlan.setEnabled(False)

            self.btnSplitPlan.clicked.connect(self.runSplitRequested.emit)
            self.btnSaveSplitPlan.clicked.connect(self.on_click_post)
            self.btnCancelSplitPlan.clicked.connect(self._on_click_cancel_split)
            self.btnSplitPlan.clicked.connect(self._arm_accept_split_done)

            # Listen to the algo success
            SPLIT_BUS.split_done.connect(self._on_split_done, type=Qt.QueuedConnection)

            # Cancel split process if opening a new project
            self._wire_project_auto_cancel()

        except Exception as e:
            logger.error(f"Couldn't load split/post tools in dock: {e}")

        # initialize poll timer (before associations handler)
        self._poll_timer = QTimer(self)
        self._poll_timer.setTimerType(Qt.VeryCoarseTimer)
        self._poll_timer.setSingleShot(True)
        self._poll_timer.timeout.connect(self.check_server_status)

        self._poll_ms_when_off = 20_000
        self._poll_ms_when_running = 20_000
        self._poll_ms_when_starting = 10_000

        try:
            # Instantiate managers (once)
            self.server_manager = server_manager
            logger.info(f"server_manager in dock.py: {server_manager}")
            self.plan_manager = plan_manager

            # Thread pool remains available
            self.threadpool = QThreadPool.globalInstance()

            # Server state machine
            self.server_state = ServerState.OFF

            # WebEngine tab
            self.initialize_plan_manager()

            # Association handler tab
            self.associations_handler = PlanTools(self)

            # Settings tab
            self.openSettings.clicked.connect(lambda: show_settings_dialog(self))

            self.initWebServer.clicked.connect(self.on_start_webserver_clicked)

            # Help tab
            self.help_browser = QTextBrowser()
            self.help_browser.setOpenExternalLinks(True)
            self.help_browser.setSizePolicy(
                QSizePolicy.Expanding, QSizePolicy.Expanding
            )
            if getattr(self, "helpTab", None) and self.helpTab.layout():
                self.helpTab.layout().addWidget(self.help_browser, 0, 0)

            # load topo check widget (needs to be after plan_manager)
            self.topocheck_widget = TopologyChecker(self)
            _placeholder = self.findChild(QWidget, "topoCheckWidget")
            _layout = _placeholder.parentWidget().layout()
            _layout.replaceWidget(_placeholder, self.topocheck_widget)
            _placeholder.deleteLater()
            del _placeholder

        except Exception as e:
            logger.error(
                f"Failed to initialize {PLUGIN_NAME}DockWidget: {e}", exc_info=True
            )
            QMessageBox.critical(self, "Error", f"Failed to load dock: {e}")
            return

        # ---- Health checker (feature branch, Qt-native, chain polling)
        self.last_known_status = None
        self._poll_paused = False

        self.health = HealthChecker(self, user_agent=PLUGIN_NAME)
        # Qt signals; queued to GUI thread automatically
        self.health.statusChanged.connect(
            self.update_server_status, type=Qt.QueuedConnection
        )
        self.health.error.connect(self._on_health_error, type=Qt.QueuedConnection)

        # Initial UI + initial health probe
        self._apply_traffic_light(self.server_state)
        self._toggle_server_btn(self.server_state)
        self._schedule_poll(self._poll_ms_when_off)
        self.check_server_status()

    def close_tab_page(self, index):
        """Close the tab page identified by index"""
        # prevent closing of Plan Manager tab
        self.tabWidget.tabCloseRequested.emit(index)
        if not self.tabWidget.tabText(index) == "Planmanager":
            self.tabWidget.removeTab(index)

    def close_tab_by_button(self):
        """Close tab associated with the close button that was clicked"""
        button = self.sender()
        tab_bar = self.tabWidget.tabBar()

        # Find which tab this button belongs to
        for i in range(tab_bar.count()):
            if tab_bar.tabButton(i, tab_bar.RightSide) == button:
                self.close_tab_page(i)
                break

    def close_all_tab_pages(self, tab_widget):
        """Close all tab pages except Planmanager"""
        for i in reversed(range(tab_widget.count())):
            if tab_widget.tabText(i) != "Planmanager":
                tab_widget.removeTab(i)

    def get_tab_page_by_name(self, tab_widget, name):
        """Helper for getting a tab page by name"""
        for i in range(tab_widget.count()):
            if tab_widget.tabText(i) == name:
                return tab_widget.widget(i)
        return None

    def show_manual(self):
        """Open the online help."""
        QDesktopServices.openUrl(
            QUrl.fromLocalFile(
                str(Path(self.plugin_dir) / "resources/manual/index.html")
            )
        )

    def open_tab_page(self, name):
        """Open tab associated with the tab page identified by name"""

        # prevent duplicate opening
        tabPage = self.get_tab_page_by_name(self.tabWidget, name)
        idx = None

        # set custom close button & icons
        close_button = QPushButton()
        tab_bar = self.tabWidget.tabBar()

        close_icon = QIcon(":/images/themes/default/mIconClose.svg")
        close_button.setIcon(close_icon)
        close_button.setIconSize(QSize(16, 16))
        close_button.setFlat(True)
        close_button.setFixedSize(18, 18)

        if not tabPage:
            idx = self.tabWidget.addTab(self.tabs.get(name), name)
        else:
            idx = self.tabWidget.indexOf(tabPage)

        # Connect the button to remove the associated tab
        close_button.clicked.connect(self.close_tab_by_button)

        tab_bar.setTabButton(idx, tab_bar.RightSide, close_button)
        self.tabWidget.setCurrentIndex(idx)

    # ----------------------------------------------------------------------
    # Post button handling
    # ----------------------------------------------------------------------
    @pyqtSlot(object)
    def _on_split_done(self, payload: dict):
        """Receive split success, stash payload, show Post button (GUI-thread safe)."""
        if not getattr(self, "_accept_split_done", True):
            logger.info("Ignoring split_done due to recent cancel/project change")
            return

        # guard if dock is already closing/hidden or button missing
        if not hasattr(self, "btnSaveSplitPlan") or self.btnSaveSplitPlan is None:
            logger.warning("btnSaveSplitPlan missing; ignoring split_done")
            return

        # Cache payload for POST only
        self._last_split_payload = payload

        # Cache group names for later UI cleanup (independent of payload clearing)
        inner_name = (payload.get("inner") or {}).get("group_name")
        outer_name = (payload.get("outer") or {}).get("group_name")
        self._split_group_names = [n for n in (inner_name, outer_name) if n]

        logger.info(
            "[Split] Prepared payload; split groups=%s",
            getattr(self, "_split_group_names", []),
        )

        # Enable both action buttons
        QTimer.singleShot(
            0,
            lambda: (
                self.btnSaveSplitPlan.setEnabled(True),
                self.btnCancelSplitPlan.setEnabled(True),
            ),
        )
        self.iface.messageBar().pushInfo(
            "Split bereit", "Daten vorbereitet. Jetzt an Webapp senden."
        )

    def on_click_post(self):
        logger.info("Post-button clicked.")
        payload = getattr(self, "_last_split_payload", None)
        if not payload:
            self.iface.messageBar().pushWarning(
                "Kein Split", "Kein Split-Ergebnis zum Senden vorhanden."
            )
            logger.warning("No split payload available on post click")
            return

        base_url = self.base_url
        if not base_url:
            self.iface.messageBar().pushWarning(
                "Einstellung fehlt", "Bitte Basis-URL in den Einstellungen setzen."
            )
            logger.error("Missing base_url for split POST")
            return

        post_endpoint = base_url.rstrip("/") + "/split-tool"
        logger.info("Posting split to endpoint=%s", post_endpoint)

        self._post_worker = PostWorker(post_endpoint, payload, timeout=120)
        w = self._post_worker
        self._workers.add(w)

        w.finished_ok.connect(self._on_post_ok, Qt.QueuedConnection)
        w.finished_ok.connect(w.deleteLater, Qt.QueuedConnection)

        w.failed.connect(self._on_post_failed, Qt.QueuedConnection)
        w.failed.connect(w.deleteLater, Qt.QueuedConnection)

        w.destroyed.connect(lambda: self._workers.discard(w), Qt.QueuedConnection)
        w.start()

    def _on_post_ok(self, resp: dict):
        """
        Success flow:
        1) clear payload
        2) remove temp split groups from the layer tree
        3) add newly created plans
        4) deactivate buttons
        """
        inner = (resp.get("inner") or {}).get("plan_id", "—")
        outer = (resp.get("outer") or {}).get("plan_id", "—")
        logger.info("POST success: inner_plan_id=%s outer_plan_id=%s", inner, outer)

        self.iface.messageBar().pushSuccess(
            PLUGIN_NAME, "Neue Pläne erfolgreich angelegt ✅"
        )
        self.iface.messageBar().pushInfo(PLUGIN_NAME, f"Innen={inner} | Außen={outer}")

        # Clear payload
        self._clear_split_payload_only()

        # Remove temp split groups before adding the new plans
        try:
            self._remove_split_groups_from_layertree()
        except Exception:
            logger.exception("Failed to remove split groups before adding new plans")

        # Load new plans
        for key in ("inner", "outer"):
            pd = resp.get(key) or {}
            if pd:
                self.plan_manager.load_plan(
                    plan_name=pd["plan_name"],
                    plan_id=pd["plan_id"],
                    plan_type=pd["plan_type"],
                    appschema=pd["appschema"],
                    version=pd["version"],
                    bereiche=pd.get("bereiche"),
                )

        # Deactivate
        self._deactivate_split_actions()

    def _on_post_failed(self, msg: str):
        logger.error("POST failed: %s", msg)
        text = msg
        # Try to extract FastAPI's JSON error detail
        if ":" in msg:
            _, _, body = msg.partition(":")
            body = body.strip()
            try:
                data = json.loads(body)
                if isinstance(data, dict) and "detail" in data:
                    detail = data["detail"]
                    code = detail.get("code", "error")
                    message = detail.get("message", "Fehler")
                    viols = detail.get("violations") or []
                    if viols:
                        bullets = "\n".join(
                            f"• {v.get('featuretype', '?')} (id={v.get('old_id', '?')}): {v.get('hint', '')}"
                            for v in viols[:10]
                        )
                        more = (
                            ""
                            if len(viols) <= 10
                            else f"\n(+{len(viols) - 10} weitere …)"
                        )
                        text = f"{message}\n\n{bullets}{more}"
                    else:
                        text = f"{code}: {message}"
            except json.JSONDecodeError:
                pass

        self.iface.messageBar().pushCritical("Speichern fehlgeschlagen", text)

    def _on_click_cancel_split(self):
        """
        Cancel flow:
        1) clear payload
        2) remove temp split groups
        3) deactivate buttons
        """
        try:
            self._accept_split_done = False
            self._clear_split_payload_only()
            self._remove_split_groups_from_layertree()
            self._deactivate_split_actions()
        except Exception:
            logger.exception("Failed to cancel split cleanly")

    def _clear_split_payload_only(self):
        """Clear only payload-related state (no layertree ops)."""
        self._last_split_payload = None
        self._payload_project_path = None
        logger.debug("Cleared split payload state")

    def _remove_split_groups_from_layertree(self):
        """Remove temp split groups using cached names; then forget the names."""
        names = getattr(self, "_split_group_names", [])
        try:
            self._remove_groups_from_layertree(names)
        finally:
            self._split_group_names = []
            logger.debug("Cleared cached split group names")

    def _deactivate_split_actions(self):
        """Disable Save/Cancel buttons."""
        if hasattr(self, "btnSaveSplitPlan"):
            self.btnSaveSplitPlan.setEnabled(False)
        if hasattr(self, "btnCancelSplitPlan"):
            self.btnCancelSplitPlan.setEnabled(False)
        logger.debug("Disabled split action buttons")

    def _remove_groups_from_layertree(
        self, group_names: list[str] | tuple[str, ...] | None
    ):
        if not group_names:
            return

        root = QgsProject.instance().layerTreeRoot()
        if not root:
            return

        def _delete_group_by_name(name: str) -> bool:
            grp = root.findGroup(name)
            if grp and grp.parent():
                grp.parent().removeChildNode(grp)
                return True
            return False

        removed = [n for n in group_names if _delete_group_by_name(n)]
        if removed:
            logger.info("Removed split groups from layer tree: %s", removed)

    def _wire_project_auto_cancel(self):
        """Connect project lifecycle signals to auto-cancel once."""
        if getattr(self, "_project_signals_wired", False):
            return
        try:
            proj = QgsProject.instance()

            # Can't use QueuedConnection here
            proj.readProject.connect(self._auto_cancel_split)
            proj.cleared.connect(self._auto_cancel_split)

            try:
                proj.fileNameChanged.connect(self._auto_cancel_split)
            except Exception:
                pass

            self._project_signals_wired = True
            logger.info(
                "Project auto-cancel signals wired (readProject, cleared, fileNameChanged)"
            )
        except Exception:
            logger.exception("Failed to wire project auto-cancel signals")

    @pyqtSlot()
    def _auto_cancel_split(self, *args):
        """
        Auto-cancel flow:
        1) clear payload
        2) remove temp split groups
        3) deactivate buttons
        """
        try:
            self._accept_split_done = False
            self._clear_split_payload_only()
            self._remove_split_groups_from_layertree()
            self._deactivate_split_actions()
            QTimer.singleShot(0, self._deactivate_split_actions)
            logger.info("Auto-cancelled split due to project change")
        except Exception:
            logger.exception("Failed during auto-cancel split cleanup")

    def _cancel_split_state(self, *, user_requested: bool, reason: str = ""):
        try:
            self._accept_split_done = False
            self._clear_split_payload_only()
            self._remove_split_groups_from_layertree()
            QTimer.singleShot(0, self._deactivate_split_actions)
            logger.info(
                "Split state cancelled (%s): %s",
                "user" if user_requested else "auto",
                reason,
            )
        except Exception:
            logger.exception("Failed to cancel split state (%s)", reason)

    def _arm_accept_split_done(self):
        """Allow the next split_done to be processed (user just started a new split)."""
        self._accept_split_done = True

    def _on_project_read(self, _doc):
        # treat as project change -> auto-cancel split UI
        self._auto_cancel_split()

    # ----------------------------------------------------------------------
    # Download handling
    # ----------------------------------------------------------------------
    def ensure_download_hook(self) -> None:
        """Hook the global download handler exactly once."""
        if getattr(self, "_dl_hooked", False):
            return
        QWebEngineProfile.defaultProfile().downloadRequested.connect(
            self._handle_download_request
        )
        self._dl_hooked = True

    def _handle_download_request(self, download: QWebEngineDownloadItem) -> None:
        try:
            old_path = download.path()
        except RuntimeError:
            return  # item already destroyed

        suffix = QFileInfo(old_path).suffix()
        suggested = old_path or "download"
        parent = getattr(self.iface, "mainWindow", lambda: None)() or self

        try:
            filt = f"*.{suffix}" if suffix else "*"
            path, _ = QFileDialog.getSaveFileName(
                parent, "Datei speichern", suggested, filt
            )
        except Exception:
            logger.exception("Couldn't open save dialog for %r", old_path)
            return

        if path:
            download.setPath(path)
            download.accept()

    # ----------------------------------------------------------------------
    # WebEngine / Plan Manager
    # ----------------------------------------------------------------------
    def initialize_plan_manager(self) -> None:
        """Initialize the plan manager QWebEngineView with QWebChannel and download handlers."""
        view = getattr(self, "plansView", None)
        logger.debug(f"plansView type: {type(view).__name__}")
        if view is None:
            logger.error("plansView not found in UI; cannot initialize plan manager")
            return
        if not isinstance(view, QWebEngineView):
            inner = self.findChild(QWebEngineView)
            if inner:
                view = inner
            else:
                logger.error(
                    "plansView is not a QWebEngineView. Check imports and .ui."
                )
                return

        # Unplug any stale bridge (idempotent & safe; main branch)
        detach_channel(view)

        # one-time global hook for downloads
        self.ensure_download_hook()

        # Page parented to the view; attach page BEFORE accessing settings
        self._we_page = XMASPluginPage(view)
        page = self._we_page
        page.profile().downloadRequested.connect(self._handle_download_request)
        self.plan_manager.planCreated.connect(
            lambda _: page.runJavaScript(
                "try {emitEvent('planCreated');} catch (e) {}"
            ),  # TODO: Add planmanger reload trigger
        )
        view.setPage(page)

        # WebChannel
        class CallHandler(QObject):
            def __init__(self, parent=None):
                super().__init__(parent)

            def __del__(self):
                logger.debug("CallHandler __del__ called, object deleted")

            # For the slots: Use stringed QVariant and drop type hints in signature to make cross version stable
            @pyqtSlot("QVariant")
            def create_plan(self, plan_data: dict):
                dock = getattr(self, "dock", None)
                if dock:
                    dock.plan_manager.create_plan(**plan_data)

            @pyqtSlot("QVariant")
            def load_plan(self, plans_data: list[dict]):
                dock = getattr(self, "dock", None)
                if dock:
                    for plan_data in plans_data:
                        dock.plan_manager.load_plan(**plan_data)

            @pyqtSlot("QVariant")
            def deleted_plans(self, plans_data: list[dict]):
                dock = getattr(self, "dock", None)
                if dock:
                    dock.plan_manager.on_plans_deleted(plans_data)

        def make_handler(_parent_channel):
            handler = CallHandler(_parent_channel)
            handler.dock = self
            return handler

        # Initial settings and signals at first start
        if not getattr(view, "_pm_detached", False):
            view.channel = QWebChannel(page)
            view.handler = make_handler(view.channel)
            view.channel.registerObject("handler", view.handler)
            page.setWebChannel(view.channel)

            try:
                view.destroyed.connect(on_view_destroyed)
            except Exception:
                pass

            # Pause health polling during heavy loads
            try:
                view.loadStarted.connect(
                    lambda: self._pause_polls(True), type=Qt.UniqueConnection
                )
            except TypeError:
                pass
            try:
                view.loadFinished.connect(
                    lambda _ok: self._pause_polls(False), type=Qt.UniqueConnection
                )
            except TypeError:
                pass
            # Build URL with appschema/version
            server_url = self.base_url
            if not server_url:
                return
            view.plans_url = QUrl(f"{server_url}/plans")
            logger.info(f"[PlanManager] plans_url = {view.plans_url.toString()}")

            appschema, version = get_appschema()
            if appschema and version:
                query = QUrlQuery()
                query.addQueryItem("appschema", appschema)
                query.addQueryItem("version", version)
                view.plans_url.setQuery(query)

            # Only load if server already running
            if self.server_state == ServerState.RUNNING:
                view.load(view.plans_url)

    def _maybe_load_plans(self) -> None:
        """Load the Plan Manager page if running and URL is known."""
        view = getattr(self, "plansView", None)
        if view is None:
            logger.error("plansView not found in UI; cannot load plans")
            return
        if not hasattr(view, "plans_url") or view.plans_url.isEmpty():
            appschema, version = get_appschema()
            server_url = self.base_url
            if not server_url:
                return
            view.plans_url = QUrl(f"{server_url}/plans")
            if appschema and version:
                q = QUrlQuery()
                q.addQueryItem("appschema", appschema)
                q.addQueryItem("version", version)
                view.plans_url.setQuery(q)

        if self.server_state == ServerState.RUNNING and view.page():
            url_str = view.plans_url.toString()
            logger.info(f"Loading Plan Manager at {url_str}")
            view.load(view.plans_url)
        setattr(view, "_pm_detached", False)

    # ----------------------------------------------------------------------
    # Health / Polling
    # ----------------------------------------------------------------------
    def on_start_webserver_clicked(self) -> None:
        """Launch the NiceGUI App (Web Server)."""
        logger.info("Launch Webserver button clicked.")

        if self.server_state != ServerState.OFF:
            return

        self._server_owned = True  # Optimistic
        self._server_start_count = self._server_start_count + 1

        if self.server_state == ServerState.OFF:
            # instant yellow for feedback
            self._set_server_state(ServerState.BUSY)
            QApplication.processEvents()
            try:
                self.server_manager.start_server_preflight_ui()
            except Exception as e:
                logger.exception("Could not start webserver.")
                QtWidgets.QMessageBox.critical(
                    None,
                    PLUGIN_NAME,
                    f"Fehler beim Starten der Webapp: {str(e)}",
                )
                self._server_owned = False
                self._set_server_state(ServerState.OFF)
                return
        self.server_manager.start_server_in_background()
        # One immediate check; subsequent ones are chained via single-shot timer
        self._poll_timer.stop()
        self.check_server_status()

    def check_server_status(self) -> None:
        """Trigger a health probe (Qt NAM) and re-arm the chain."""
        base_url = self.base_url
        if not base_url:
            self._schedule_poll(self._next_interval())
            return
        url = f"{base_url}/health_check"
        self.health.check(url, timeout_ms=8_000)

    def _on_health_error(self, msg: str) -> None:
        if getattr(self, "_last_health_error_msg", None) == msg:
            self._schedule_poll(self._next_interval())
            return
        self._last_health_error_msg = msg

        self.iface.messageBar().pushCritical(PLUGIN_NAME, msg)
        QgsApplication.messageLog().logMessage(msg, PLUGIN_NAME, Qgis.Critical)
        self._schedule_poll(self._next_interval())

    @pyqtSlot(bool)
    def update_server_status(self, is_running: bool) -> None:
        """Update the traffic light indicator based on the server status."""
        # Avoid repeated processing of the same reported state
        if (
            hasattr(self, "_last_reported_state")
            and self._last_reported_state == is_running
        ):
            self._schedule_poll(self._next_interval())
            return
        self._last_reported_state = is_running

        prev_state = self.server_state

        # Log only on transitions
        logger.info(
            "[PlanManager] server state change: %s → %s",
            prev_state.name,
            "RUNNING" if is_running else "OFF",
        )

        if is_running and prev_state != ServerState.RUNNING:
            # server just transitioned to RUNNING
            self.server_state = ServerState.RUNNING
            url = getattr(self.plansView, "plans_url", QUrl()).toString()
            logger.info(
                f"Server transitioned to RUNNING — (re)loading Plan Manager at {url}"
            )
            # Defer actual load a tick to let UI settle
            QTimer.singleShot(100, self._maybe_load_plans)

        elif is_running and prev_state == ServerState.RUNNING:
            # Still running; slow polls handled by interval
            logger.debug("Server still RUNNING; skipping reload")

        elif prev_state == ServerState.BUSY and not is_running:
            # Still booting; remain yellow
            logger.debug("Server still STARTING")

        elif prev_state == ServerState.RUNNING and not is_running:
            # Went OFF
            self.server_state = ServerState.OFF
            self._server_owned = False
            logger.info("Server transitioned to OFF (stopped)")
            self.iface.messageBar().pushCritical(
                PLUGIN_NAME, "Webserver nicht mehr erreichbar"
            )
            if view := getattr(self, "plansView", None):
                view.load(QUrl("about:blank"))

        else:
            # OFF -> OFF
            self.server_state = ServerState.OFF
            logger.debug("Server is OFF")

        # Update indicator
        self._apply_traffic_light(self.server_state)
        self._toggle_server_btn(self.server_state)

        # Re-arm the single-shot poll chain
        self._schedule_poll(self._next_interval())

    def _schedule_poll(self, delay_ms: int) -> None:
        """Schedule the next single-shot poll unless paused."""
        if getattr(self, "_poll_paused", False):
            return
        self._poll_timer.start(delay_ms)

    def _next_interval(self) -> int:
        """Return next poll interval based on current state."""
        if self.server_state == ServerState.RUNNING:
            return self._poll_ms_when_running
        if self.server_state == ServerState.BUSY:
            return self._poll_ms_when_starting
        return self._poll_ms_when_off

    def _on_visibility_changed(self, visible: bool) -> None:
        """Pause/resume polling based on dock visibility."""
        logger.debug(f"Dock visibilityChanged: {visible}")
        self._poll_paused = not visible
        if visible:
            self._schedule_poll(500)
            self._ensure_plan_manager_ready()
        else:
            self._poll_timer.stop()
            self.health.stop()

    def _pause_polls(self, yes: bool) -> None:
        """Helper to pause polls during heavier operations."""
        self._poll_paused = yes
        if yes:
            self._poll_timer.stop()
            self.health.stop()
        else:
            self._schedule_poll(500)

    def _set_server_state(self, state: "ServerState") -> None:
        if self.server_state != state:
            logger.info("Server state -> %s", state.name)
            self.server_state = state
            self._apply_traffic_light(state)
            self._toggle_server_btn(state)

    def _apply_traffic_light(self, state: "ServerState") -> None:
        if not self.traffic_light:
            return
        if state == ServerState.RUNNING:
            color = "#00A36C"
        elif state == ServerState.BUSY:
            color = "#FFD700"
        else:
            color = "#f00"
        self.traffic_light.setStyleSheet(
            f"background-color: {color}; border-radius: 10px; width: 20px; height: 20px;"
        )

    def _retarget_initWebServer(self, slot) -> None:
        # avoid multiple connections
        try:
            self.initWebServer.clicked.disconnect()
        except TypeError:
            pass
        self.initWebServer.clicked.connect(slot)

    def _toggle_server_btn(self, state: "ServerState") -> None:
        # Keep consistent size
        self.initWebServer.setIconSize(QSize(20, 20))

        if state == ServerState.BUSY:
            self.initWebServer.setEnabled(False)
            self.initWebServer.setIcon(
                QIcon(":/images/themes/default/mActionStop.svg")
                if self._server_owned
                else QIcon(":/images/themes/default/mActionPlay.svg")
            )
            self.initWebServer.setToolTip("Webserver startet …")
            # While starting, ignore clicks
            self._retarget_initWebServer(lambda: None)
            return

        if state == ServerState.RUNNING:
            self.initWebServer.setEnabled(self._server_owned)
            if self._server_owned:
                self.initWebServer.setIcon(
                    QIcon(":/images/themes/default/mActionStop.svg")
                    if self._server_owned
                    else QIcon(":/images/themes/default/mActionPlay.svg")
                )
                self.initWebServer.setToolTip(
                    "Webserver stoppen"
                    if self._server_owned
                    else "Webserver läuft extern."
                )
                self._retarget_initWebServer(self.on_stop_webserver_clicked)
                self._retarget_initWebServer(
                    self.on_stop_webserver_clicked
                    if self._server_owned
                    else lambda: None
                )
            return

        # OFF
        self._server_owned = False
        if self._server_start_count == 0:
            self.initWebServer.setEnabled(True)
            self.initWebServer.setToolTip("Webserver starten")
        else:
            self.initWebServer.setToolTip(
                "Um den Webserver ein zweites Mal zu starten, bitte zuerst QGIS neustarten."
            )

        self.initWebServer.setIcon(QIcon(":/images/themes/default/mActionPlay.svg"))
        self._retarget_initWebServer(self.on_start_webserver_clicked)

    def on_stop_webserver_clicked(self) -> None:
        logger.debug("Stop webserver button clicked.")
        self.initWebServer.setEnabled(False)
        self._apply_traffic_light(ServerState.BUSY)
        self._server_owned = False
        self.server_manager.request_stop()

        def _check_done():
            if self.server_manager.is_alive():
                QTimer.singleShot(100, _check_done)
                return
            # now actually off
            self._set_server_state(ServerState.OFF)
            self._schedule_poll(500)

        QTimer.singleShot(0, _check_done)

    # ----------------------------------------------------------------------
    # Misc
    # ----------------------------------------------------------------------

    def set_fixed_height(self, groupbox: QGroupBox, height: int) -> None:
        """Helper to fix the vertical height for a group box."""
        groupbox.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
        groupbox.setMaximumHeight(height)

    def closeEvent(self, event: QCloseEvent) -> None:
        """Handle dock close event."""
        logger.debug(
            "DockWidget closeEvent - stopping workers & cleaning up webengine views"
        )

        # Stop background work
        try:
            if hasattr(self, "_poll_timer") and self._poll_timer:
                self._poll_timer.stop()
        except Exception:
            pass
        try:
            if hasattr(self, "health") and self.health:
                self.health.stop()
        except Exception:
            pass

        try:
            SPLIT_BUS.split_done.disconnect(
                self._on_split_done, type=Qt.QueuedConnection
            )
        except Exception:
            pass

        # Persist visibility intent
        try:
            if not getattr(self, "_app_quitting", False):
                save_setting("dock_visible", "false")
        except Exception:
            pass

        # Notify plugin
        try:
            self.closingPlugin.emit()
        except Exception:
            pass

        try:
            self._cancel_split_state(user_requested=False, reason="Dock geschlossen")
        except Exception:
            pass

        event.accept()

    @property
    def base_url(self) -> str | None:
        """Resolve and cache the normalized base URL from settings.
        Returns None if not configured.
        """
        url = settings_manager.get_normalized_url()
        if not url:
            parent = get_main_window()
            if parent is not None:
                QMessageBox.warning(
                    # TODO: check in Daily (timer! every n seconds)
                    # parent.mainWindow(),
                    parent,
                    "Fehlende URL",
                    "Kein URL zum Webserver in den Plugin-Einstellungen gefunden. Bitte URL eingeben.",
                )
        return url

    def showEvent(self, ev):
        super().showEvent(ev)
        logger.debug("Dock showEvent fired")
        self._ensure_plan_manager_ready()

    def _ensure_plan_manager_ready(self):
        try:
            view = self.plansView
            needs = (
                bool(getattr(view, "_pm_detached", False))
                or not getattr(view, "channel", None)
                or not view.url().isValid()
            )
            if getattr(view, "_pm_detached", False):
                logger.debug("Reinitializing Plan Manager (needs=%s)", needs)
                self.initialize_plan_manager()
                # If server already running, initialize_plan_manager() will load
                if self.server_state == ServerState.RUNNING:
                    view.load(view.plans_url)
        except Exception:
            logger.exception("Failed to ensure Plan Manager readiness")


class ServerState(Enum):
    """The possible states of the webapp server."""

    OFF = "off"
    BUSY = "busy"
    RUNNING = "running"
