import os
from qgis.PyQt.QtCore import Qt, QSize, QTimer
from qgis.PyQt.QtWidgets import (
    QDialog, QListWidget, QListWidgetItem, QStackedWidget,
    QHBoxLayout, QVBoxLayout, QWidget, QDockWidget,
    QFrame, QAbstractItemView, QApplication, QStyle,
    QLabel, QTextBrowser, QPushButton, QScrollArea, QSizePolicy, QMessageBox, QCheckBox,
    QStyledItemDelegate
)
from qgis.PyQt.QtGui import QIcon, QColor, QPixmap, QPainter, QPen
from qgis.core import QgsApplication, Qgis, QgsMessageLog

from ..engine.engine_widget import EngineDockWidget
from ..extractor.extractor_widget import ExtractorDockWidget
from ..tiler.tiler_widget import TilerDockWidget  # adjust if needed
from .geocoding_widget import GeocodingPlaceWidget
from .results_widget import ResultsWidget
from ..bootstrap import (
    get_last_bootstrap_error,
    repair_runtime_dependencies,
    interactive_install_dependencies,
    get_uninstall_on_plugin_uninstall,
    set_uninstall_on_plugin_uninstall,
)
from ..qt_compat import (
    QtCompat, QgisCompat, QMessageBoxCompat, QFrameCompat, QAbstractItemViewCompat,
    QPainterCompat, QDockWidgetCompat, QStyleCompat, QSizePolicyCompat,
)


PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
NAV_BUSY_ROLE = QtCompat.UserRole + 77


class _NavBusyDelegate(QStyledItemDelegate):
    def __init__(self, parent=None):
        super().__init__(parent)
        icon_path = os.path.join(PLUGIN_ROOT, "static", "images", "icons", "processing.svg")
        if os.path.exists(icon_path):
            self._busy_icon = QIcon(icon_path)
        else:
            style = parent.style() if parent else QApplication.style()
            self._busy_icon = style.standardIcon(QStyleCompat.SP_BrowserReload)

    def paint(self, painter, option, index):
        super().paint(painter, option, index)
        if not bool(index.data(NAV_BUSY_ROLE)):
            return

        size = 12
        margin_right = 12
        x = option.rect.right() - size - margin_right
        y = option.rect.y() + (option.rect.height() - size) // 2
        self._busy_icon.paint(painter, x, y, size, size)

def load_icon(rel_path: str, fallback=None) -> QIcon:
    if fallback is None:
        fallback = QStyleCompat.SP_FileDialogListView

    if rel_path.startswith(":/"):
        ic = QIcon(rel_path)
        if not ic.isNull():
            return ic

    abs_path = os.path.normpath(os.path.join(PLUGIN_ROOT, rel_path))
    if os.path.exists(abs_path):
        ic = QIcon(abs_path)
        if not ic.isNull():
            return ic

    # fallback
    QgsMessageLog.logMessage(f"[VirtuGhan] Icon not found, using fallback: {abs_path}", "VirtuGhan", QgisCompat.Warning)
    return QApplication.style().standardIcon(fallback)


def make_tab_icon(kind: str, size: int = 18, color: QColor | None = None) -> QIcon:
    c = color or QColor(255, 255, 255)
    px = QPixmap(size, size)
    px.fill(QtCompat.transparent)

    p = QPainter(px)
    p.setRenderHint(QPainterCompat.Antialiasing, False)

    pen = QPen(c)
    pen.setWidth(1)
    pen.setCapStyle(QtCompat.RoundCap)
    pen.setJoinStyle(QtCompat.RoundJoin)
    p.setPen(pen)

    k = (kind or "").lower().strip()
    if k == "engine":
        # Gear/settings icon
        p.drawEllipse(5, 5, 8, 8)
        p.drawEllipse(7, 7, 4, 4)
        p.drawLine(9, 2, 9, 4)
        p.drawLine(9, 14, 9, 16)
        p.drawLine(2, 9, 4, 9)
        p.drawLine(14, 9, 16, 9)
        p.drawLine(5, 5, 6, 6)
        p.drawLine(12, 12, 13, 13)
        p.drawLine(12, 6, 13, 5)
        p.drawLine(5, 13, 6, 12)
    elif k == "extractor":
        p.drawLine(9, 4, 9, 10)
        p.drawLine(6, 8, 9, 11)
        p.drawLine(12, 8, 9, 11)
        p.drawLine(4, 13, 14, 13)
        p.drawLine(4, 13, 4, 14)
        p.drawLine(14, 13, 14, 14)
    elif k == "places":
        p.drawEllipse(5, 3, 8, 8)
        p.drawEllipse(8, 6, 2, 2)
        p.drawLine(9, 11, 9, 15)
        p.drawLine(9, 15, 7, 12)
        p.drawLine(9, 15, 11, 12)
    else:
        p.drawRoundedRect(3, 3, 5, 5, 1.0, 1.0)
        p.drawRoundedRect(10, 3, 5, 5, 1.0, 1.0)
        p.drawRoundedRect(3, 10, 5, 5, 1.0, 1.0)
        p.drawRoundedRect(10, 10, 5, 5, 1.0, 1.0)

    p.end()
    return QIcon(px)


class VirtughanHubDialog(QDialog):
    def __init__(self, iface, start_page: str = "engine", parent=None):
        super().__init__(parent)
        self.iface = iface
        self.setWindowTitle("VirtuGhan - Satellite Data Tools")
        self._centered_once = False

        self._help_expanded_width = 300
        self._help_minimized_width = 160
        self._base_width = 680
        self._help_padding = 12
        self._height = 540
        self._compact_min_width = 560
        self._compact_min_height = 440
        self.resize(self._base_width, self._height)

        self._help_by_key = {
            "engine": """
<h3>Compute</h3>
<p>Compute fetches matching scenes inside your AOI and date range, applies a preset or manual formula, and combines results into one analysis layer. You can combine values over time using methods like mean, max, min, or median. If you enable timeseries, it also generates time-series outputs from the matching scenes in your selected date range. This is useful when you want both a summary output and a time-based view, such as NDVI over time.</p>
<p><b>Purpose:</b> Create layers after computing on the satellite images eg. create and download NDVI layer in a map.</p>
<p><b>Use Compute when:</b> you want derived outputs, not only raw band/image downloads.</p>
<p><b>Datasets:</b> Sentinel-2, Landsat 8/9, and Sentinel-1 (Experimental).</p>
<h4>Main fields</h4>
<ul>
    <li><b>Dataset *</b>: choose Sentinel-2, Landsat 8/9, or Sentinel-1 (Experimental). Cloud cover is not used for Sentinel-1.</li>
    <li><b>Start date *</b>, <b>End date *</b>: search period.</li>
    <li><b>Max cloud (%) *</b>: Search Images with cloud cover less than this value.</li>
    <li><b>Simple (Presets)</b>: choose a ready-made index or SAR formula for the selected dataset.</li>
    <li><b>Advanced (Manual)</b>: add one or more band dropdowns with <b>+</b>, then write a formula using those band names. Bands selected but not used in the formula are ignored before processing.</li>
    <li><b>Area of Interest *</b>: choose map extent, draw a rectangle/polygon, or select a polygon layer from the Layers panel. For layers with multiple polygon features, choose one combined AOI or process selected polygons separately as a batch.</li>
</ul>
<h4>Options and output</h4>
<ul>
    <li><b>Aggregation</b>: aggregate multiple scenes with mean/median/min/max/etc.</li>
    <li><b>Generate timeseries</b>: export intermediate frames and GIF animation. The GIF file can be found in the project folder after the computation is complete.</li>
    <li><b>Apply smart filter</b>: optional and off by default. If enabled, it selects representative scenes over time and can reduce the number of images used, especially for date ranges longer than 1 year.</li>
    <li><b>Workers</b>: parallel processing count. Increase for faster runs on capable hardware. Start with the default shown in UI (2 on low-core devices, otherwise 4), then increase gradually. If your machine slows down or becomes unstable, reduce Workers.</li>
    <li><b>Output folder</b>: destination directory (blank uses temporary location).</li>
    <li><b>Show matching scene footprints on map</b>: add matched scene footprints after run.</li>
    <li><b>Preview Matching Scenes</b>: review matching scenes before running. If no images match the selected AOI, date range, dataset, and filters, a message explains that no matching images were found.</li>
    <li><b>Run Compute</b>, <b>Reset</b>, and <b>Log</b>: execute, clear inputs, and inspect status/errors.</li>
</ul>
<p><i>Large AOIs are logged as warnings. Very large AOIs show a confirmation dialog before processing.</i></p>
<p><i>* Required fields</i></p>
<p>To learn more about VirtuGhan, visit <a href="https://github.com/virtughan">GitHub</a> or <a href="https://virtughan.com">virtughan.com</a>.</p>
""",
            "extractor": """
<h3>Download (Extractor)</h3>
<p>Download saves source imagery for your AOI using the bands you choose. Each downloaded image includes all selected bands in one multi-band file. If multiple scenes match the selected date range, multiple files are downloaded.</p>
<p><b>Purpose:</b> Download satellite image bands as raster layers to your local machine.</p>
<p><b>Use Download when:</b> you want original band/image downloads instead of computed outputs.</p>
<p><b>Datasets:</b> Sentinel-2, Landsat 8/9, and Sentinel-1 (Experimental).</p>
<h4>Main fields</h4>
<ul>
    <li><b>Dataset *</b>: choose Sentinel-2, Landsat 8/9, or Sentinel-1 (Experimental). Cloud cover is not used for Sentinel-1.</li>
    <li><b>Start date *</b>, <b>End date *</b>: search period.</li>
    <li><b>Max cloud (%) *</b>: Search Images with cloud cover less than this value.</li>
    <li><b>Bands to download *</b>: select one or more bands that you want to download as images.</li>
    <li><b>Area of Interest *</b>: choose map extent, draw a rectangle/polygon, or select a polygon layer from the Layers panel. For layers with multiple polygon features, choose one combined AOI or download selected polygons separately as a batch.</li>
</ul>
<h4>Options and output</h4>
<ul>
    <li><b>Apply smart filter</b>: optional and off by default. If enabled, it selects representative scenes over time and can reduce the number of downloaded images, especially for date ranges longer than 1 year.</li>
    <li><b>Workers</b>: parallel download/processing count. Increase for faster runs on capable hardware. Start with the default shown in UI (2 on low-core devices, otherwise 4), then increase gradually. If your machine slows down or becomes unstable, reduce Workers.</li>
    <li><b>Output folder</b>: destination directory for downloaded rasters. Leave blank to use the QGIS temporary folder.</li>
    <li><b>Show matching scene footprints on map</b>: add matched scene footprints after run.</li>
    <li><b>Preview Matching Scenes</b>: review matching scenes before running. If no images match the selected AOI, date range, dataset, and filters, a message explains that no matching images were found.</li>
    <li><b>Download Images</b>, <b>Reset</b>, and <b>Log</b>: execute, clear inputs, and inspect status/errors.</li>
</ul>
<p><i>Large AOIs are logged as warnings. Very large AOIs show a confirmation dialog before downloading.</i></p>
<p><i>Note: ZIP output option is removed in this version.</i></p>
<p><i>* Required fields</i></p>
<p>To learn more about VirtuGhan, visit <a href="https://github.com/virtughan">GitHub</a> or <a href="https://virtughan.com">virtughan.com</a>.</p>
""",
            "tiler": """
<h3>Tiles (Tiler)</h3>
<p>Tiles creates a fast layer for visual exploration and can be used as a basemap in QGIS. Choose your formula or index first, then create the tile layer. You can create multiple tile layers with different indices and blend them later using layer opacity in QGIS. Tiles uses the best available imagery in your selected date range and applies your selected formula while keeping pan and zoom responsive.</p>
<p><b>Purpose:</b> Create and add map tiles for quick visual exploration of satellite imagery in QGIS.</p>
<p><b>Use Tiles when:</b> you want a fast basemap-like layer to inspect coverage and patterns before Download or Compute.</p>
<p><b>Datasets:</b> Sentinel-2, Landsat 8/9, and Sentinel-1 (Experimental).</p>
<h4>Main fields</h4>
<ul>
    <li><b>Backend URL *</b>: tile service endpoint.</li>
    <li><b>Layer Name *</b>: name shown in the QGIS Layers panel.</li>
    <li><b>Dataset *</b>: choose Sentinel-2, Landsat 8/9, or Sentinel-1 (Experimental). Cloud cover is not used for Sentinel-1.</li>
    <li><b>Start Date *</b>, <b>End Date *</b>: search period for source imagery.</li>
    <li><b>Cloud cover (%) *</b>: Search Images with cloud cover less than this value.</li>
    <li><b>Simple (Presets)</b>: choose a ready-made index or SAR formula for the selected dataset.</li>
    <li><b>Advanced (Manual)</b>: choose bands and write the formula used for tile rendering.</li>
    <li><b>Palette</b>: choose the color palette used to render formula tiles.</li>
    <li><b>Time series (aggregate)</b>: if enabled creates aggregated tile output across dates; choose <b>Aggregation</b> method.</li>
</ul>
<h4>Options and output</h4>
<ul>
    <li><b>Add XYZ Layer</b>: create/update the map tile layer in QGIS.</li>
    <li><b>Reset</b>: restore default form values.</li>
</ul>
<h4>Advanced Options</h4>
<ul>
    <li><b>Advanced Options</b>: expands/collapses Local Server settings.</li>
    <li><b>Run locally</b>: use embedded local server and auto-fill Backend URL from Host/Port.</li>
    <li><b>App Path (module:function)</b>: FastAPI app entrypoint.</li>
    <li><b>Host</b>, <b>Port</b>, <b>Workers</b>: local server binding/runtime options. In Tiler, Workers controls request concurrency. Increase for faster tile responses on capable hardware. Start with the default shown in UI (2 on low-core devices, otherwise 4), then increase gradually. If your machine slows down or becomes unstable, reduce Workers.</li>
    <li><b>Start server</b>, <b>Stop server</b>: control embedded local backend server.</li>
</ul>
<p><i>* Required fields</i></p>
<p>To learn more about VirtuGhan, visit <a href="https://github.com/virtughan">GitHub</a> or <a href="https://virtughan.com">virtughan.com</a>.</p>
""",
        "places": """
<h3>Places (Geocoding)</h3>
<p><b>Purpose:</b> Search locations from OpenStreetMap and jump the map canvas to your area of interest.</p>
<p><b>Use Places when:</b> you want to quickly navigate before running Compute, Download, or Tiles.</p>
<h4>How to use</h4>
<ul>
    <li>Type a place name, city, address, or landmark to get suggestions automatically.</li>
    <li>Select one result from the list and click <b>Go to Selected</b>.</li>
    <li>Double-clicking a result also moves the map.</li>
    <li>Clearing the input field resets the search and results list.</li>
</ul>
<p><i>Data source: OpenStreetMap Nominatim geocoding service.</i></p>
""",
        "results": """
<h3>Results</h3>
<p><b>Purpose:</b> View Compute image outputs directly inside VirtuGhan.</p>
<p><b>Use Results when:</b> you want a quick visual review of Aggregate, Timeseries, and Trend images without opening folders manually.</p>
<h4>Tabs</h4>
<ul>
    <li><b>Aggregate</b>: shows the aggregated preview image (for example custom_band_output_aggregate_colormap).</li>
    <li><b>Timeseries</b>: shows sequence images (suffix <b>_result_text</b>) with <b>Play</b>/<b>Pause</b> controls.</li>
    <li><b>Trend</b>: shows values-over-time plot image.</li>
</ul>
<h4>Run History</h4>
<ul>
    <li>Each Compute run is added to <b>Run History</b> at the top of Results. Batch Compute adds one result for each completed polygon.</li>
    <li>Select any history item to reload its Aggregate/Timeseries/Trend previews.</li>
    <li><b>Open Output Folder</b>: opens that run output location in your file explorer.</li>
    <li><b>Add Geo Outputs to Map</b>: loads georeferenced outputs from that run into the current QGIS project.</li>
    <li><b>Remove Entry</b>: removes only the selected history item from the list (does not delete files).</li>
    <li><b>Clear History</b>: removes all history items from the Results list.</li>
    <li><b>Save History</b>: saves current history to disk next to the QGIS project file.</li>
</ul>
<p><i>Session behavior: history is kept while QGIS is running. Save the QGIS project first to enable history save-to-disk.</i></p>
<p><i>Results content is refreshed after each new Compute run.</i></p>
""",
    "dependencies": """
<h3>Dependencies</h3>
<p><b>Repair Dependencies</b> checks core runtime packages including <b>virtughan</b>, <b>rasterio</b>, <b>numpy</b>, <b>attrs</b>, <b>matplotlib</b>, and local server packages used by Tiles. If healthy, it leaves them unchanged; if broken/missing, it repairs automatically.</p>
<p><b>Reinstall Dependencies</b> force-clears plugin-managed runtime folders and reinstalls dependencies from scratch.</p>
<p>An internet connection is required when packages need to be downloaded or reinstalled.</p>
<p>If one runtime folder is locked, installer automatically retries in the alternate runtime folder. If both are locked, restart QGIS and try again.</p>
<p><b>Uninstall dependencies during plugin uninstall</b>: when enabled, dependencies are removed when plugin files are removed from QGIS Plugin Manager.</p>
<p><i>After successful reinstall, restart QGIS for a clean runtime reload.</i></p>
""",
    "about": """
<h3>About VirtuGhan QGIS Plugin</h3>
<p>VirtuGhan QGIS Plugin brings VirtuGhan remote-sensing workflows into QGIS for tile preview, image download, and computed outputs such as spectral indices and time-series summaries.</p>
<p>It is most useful for smaller areas of interest, especially AOIs smaller than a single satellite scene. Instead of downloading or processing whole scenes first, VirtuGhan searches matching STAC imagery, reads only the needed cloud-optimized raster parts for the selected AOI, bands, dates, and filters, then renders tiles, downloads bands, or computes formula outputs.</p>
<p>Supported datasets include Sentinel-2, Landsat 8/9, and Sentinel-1 (Experimental).</p>
<h4>Links</h4>
<ul>
    <li><a href="https://virtughan.com/">virtughan.com</a></li>
    <li><a href="https://github.com/virtughan/qgis-plugin">QGIS plugin on GitHub</a></li>
    <li><a href="https://github.com/virtughan/virtughan">VirtuGhan on GitHub</a></li>
    <li><a href="https://pypi.org/project/virtughan/">VirtuGhan Python package on PyPI</a></li>
</ul>
""",
        }

        self._row_to_page = {}
        self._key_to_row = {}
        self._dependencies_spacer_item = None

        root = QHBoxLayout(self)
        root.setContentsMargins(6, 6, 6, 6)
        root.setSpacing(6)
        self._root_layout = root

        self.nav = QListWidget()
        self.nav.setObjectName("virtNav")
        self.nav.setSelectionMode(QAbstractItemViewCompat.SingleSelection)
        self.nav.setAlternatingRowColors(False)
        self.nav.setFixedWidth(150)
        self.nav.setHorizontalScrollBarPolicy(QtCompat.ScrollBarAlwaysOff)
        self.nav.setVerticalScrollMode(QAbstractItemViewCompat.ScrollPerPixel)
        self.nav.setSelectionBehavior(QAbstractItemViewCompat.SelectRows)
        self.nav.setFocusPolicy(QtCompat.NoFocus)
        self.nav.setFrameShape(QFrameCompat.NoFrame)
        self.nav.setIconSize(QSize(24, 24))
        self.nav.setSpacing(0)
        self.nav.setItemDelegate(_NavBusyDelegate(self.nav))

        self.pages = QStackedWidget()
        self.pages.setObjectName("virtPages")

        self.helpPane = QFrame()
        self.helpPane.setObjectName("virtHelpPane")
        self._help_is_minimized = False
        self.helpPane.setMinimumWidth(0)
        self.helpPane.setMaximumWidth(0)
        self.helpPane.setSizePolicy(QSizePolicyCompat.Preferred, QSizePolicyCompat.Expanding)
        help_layout = QVBoxLayout(self.helpPane)
        help_layout.setContentsMargins(6, 6, 6, 6)
        help_layout.setSpacing(4)

        self.helpTitle = QLabel("Help")
        self.helpTitle.setObjectName("virtHelpTitle")
        self.helpTitle.setText("<b>Help</b>")
        self.helpMinButton = QPushButton("–")
        self.helpMinButton.setObjectName("virtHelpMinButton")
        self.helpMinButton.setToolTip("Minimize/restore help")
        self.helpMinButton.setFixedWidth(24)
        self.helpCloseButton = QPushButton("×")
        self.helpCloseButton.setObjectName("virtHelpCloseButton")
        self.helpCloseButton.setToolTip("Close help")
        self.helpCloseButton.setFixedWidth(24)

        top_row = QHBoxLayout()
        top_row.addWidget(self.helpTitle)
        top_row.addStretch(1)
        top_row.addWidget(self.helpMinButton)
        top_row.addWidget(self.helpCloseButton)

        self.helpText = QTextBrowser()
        self.helpText.setObjectName("virtHelpText")
        self.helpText.setOpenExternalLinks(True)
        self.helpText.setReadOnly(True)
        self.helpText.setVerticalScrollBarPolicy(QtCompat.ScrollBarAsNeeded)
        self.helpText.setHorizontalScrollBarPolicy(QtCompat.ScrollBarAsNeeded)

        help_layout.addLayout(top_row)
        help_layout.addWidget(self.helpText, 1)

        root.addWidget(self.nav)
        root.addWidget(self.pages, 1)
        root.addWidget(self.helpPane)
        root.setStretch(0, 0)
        root.setStretch(1, 4)
        root.setStretch(2, 0)

        self.helpMinButton.clicked.connect(self._toggle_help_minimize)
        self.helpCloseButton.clicked.connect(self._close_help)
        self.helpPane.setVisible(False)
        self._set_compact_mode()

        self._add_page(
            "Compute",
            EngineDockWidget(self.iface),
            load_icon("static/images/icons/compute.svg", QStyleCompat.SP_ComputerIcon),
            key="engine",
        )
        self._add_page(
            "Download",
            ExtractorDockWidget(self.iface),
            load_icon("static/images/icons/download.svg", QStyleCompat.SP_ArrowDown),
            key="extractor",
        )
        self._add_page(
            "Tiles",
            TilerDockWidget(self.iface),
            load_icon("static/images/icons/tiles.svg", QStyleCompat.SP_DirIcon),
            key="tiler",
        )
        self._add_separator()
        self._add_page(
            "Search",
            GeocodingPlaceWidget(self.iface),
            load_icon("static/images/icons/search.svg", QStyleCompat.SP_FileDialogContentsView),
            key="places",
        )
        self._results_widget = ResultsWidget(self.iface, self)
        self._add_page(
            "Results",
            self._results_widget,
            load_icon("static/images/icons/results.svg", QStyleCompat.SP_FileDialogDetailedView),
            key="results",
        )
        self._add_separator()
        self._add_dependencies_spacer()
        self._add_page(
            "Dependencies",
            self._build_dependencies_page(),
            load_icon("static/images/icons/dependencies.svg", QStyleCompat.SP_FileDialogInfoView),
            key="dependencies",
        )
        self._add_page(
            "About",
            self._build_about_page(),
            load_icon("static/images/icons/about.svg", QStyleCompat.SP_FileDialogInfoView),
            key="about",
        )

        self.nav.currentRowChanged.connect(self._on_nav_changed)
        QTimer.singleShot(0, self._update_dependencies_spacer)

        # select initial page
        start_index = self._key_to_row.get(start_page.lower(), self._key_to_row.get("engine", 0))
        self.nav.setCurrentRow(start_index)
        self._set_help_for_row(start_index)

        # Styling 
        self.setStyleSheet("""
            QDialog { background: palette(window); }

            /* LEFT NAV */
            QListWidget#virtNav, QListView#virtNav, QListView#virtNav::viewport {
                background: #494d57;             
                color: #e9e9e9;
                border: none;
                outline: none;
            }
            
            QListWidget#virtNav::item {
                padding: 8px 10px;
                font-size: 15px;
                margin: 0;
                border: none;
            }
            QListWidget#virtNav::item:hover {
                background: #2a2f38;
            }
            QListWidget#virtNav::item:selected {
                background: #394150;               
                color: #ffffff;
            }

            /* RIGHT PAGES */
            QStackedWidget#virtPages {
                background: palette(window);
            }

            QFrame#virtHelpPane {
                border: 1px solid palette(mid);
                border-radius: 3px;
                background: palette(base);
            }

            QTextBrowser#virtHelpText {
                border: none;
                background: palette(base);
            }
        """)

    def _on_nav_changed(self, row: int):
        page_index = self._row_to_page.get(row)
        if page_index is None:
            return
        self.pages.setCurrentIndex(page_index)
        self._set_help_for_row(row)

    def _build_dependencies_page(self) -> QWidget:
        page = QWidget()
        layout = QVBoxLayout(page)
        layout.setContentsMargins(12, 12, 12, 12)
        layout.setSpacing(10)

        title = QLabel("Manage Runtime Dependencies")
        title.setStyleSheet("font-weight: 600; font-size: 14px;")
        layout.addWidget(title)

        desc = QLabel(
            "Use these actions only when dependency installation is broken or you need a clean reinstall."
        )
        desc.setWordWrap(True)
        layout.addWidget(desc)

        repair_btn = QPushButton("Repair Dependencies")
        repair_btn.clicked.connect(self._on_repair_dependencies)
        layout.addWidget(repair_btn)

        reinstall_btn = QPushButton("Reinstall Dependencies")
        reinstall_btn.clicked.connect(self._on_uninstall_dependencies)
        layout.addWidget(reinstall_btn)

        uninstall_with_plugin_check = QCheckBox("Uninstall dependencies during plugin uninstall")
        uninstall_with_plugin_check.setChecked(get_uninstall_on_plugin_uninstall())
        uninstall_with_plugin_check.toggled.connect(self._on_toggle_uninstall_with_plugin)
        layout.addWidget(uninstall_with_plugin_check)

        self._cleanup_help_label = QLabel(
            "Note: On some systems, uninstall can leave locked dependency files.\n"
            "For complete cleanup after uninstall and after closing QGIS, use one of these commands:\n\n"
            "Windows (PowerShell):\n"
            "Remove-Item -Recurse -Force \"$env:APPDATA\\QGIS\\QGIS3\\profiles\\default\\virtughan_runtime\",\"$env:APPDATA\\QGIS\\QGIS3\\profiles\\default\\virtughan_runtime_fallback\"\n\n"
            "macOS (Terminal):\n"
            "rm -rf \"$HOME/Library/Application Support/QGIS/QGIS3/profiles/default/virtughan_runtime\" \"$HOME/Library/Application Support/QGIS/QGIS3/profiles/default/virtughan_runtime_fallback\"\n\n"
            "Linux (Terminal):\n"
            "rm -rf \"$HOME/.local/share/QGIS/QGIS3/profiles/default/virtughan_runtime\" \"$HOME/.local/share/QGIS/QGIS3/profiles/default/virtughan_runtime_fallback\"\n\n"
            "Safety: QGIS profile paths can differ across devices. Confirm your actual QGIS profile path before running delete commands."
        )
        self._cleanup_help_label.setWordWrap(True)
        self._cleanup_help_label.setVisible(uninstall_with_plugin_check.isChecked())
        layout.addWidget(self._cleanup_help_label)

        layout.addStretch(1)
        return page

    def _build_about_page(self) -> QWidget:
        page = QWidget()
        layout = QVBoxLayout(page)
        layout.setContentsMargins(12, 12, 12, 12)
        layout.setSpacing(8)

        text = QTextBrowser(page)
        text.setOpenExternalLinks(True)
        text.setReadOnly(True)
        text.setHtml(self._help_by_key.get("about", ""))

        layout.addWidget(text, 1)
        return page

    def _on_toggle_uninstall_with_plugin(self, checked: bool):
        set_uninstall_on_plugin_uninstall(bool(checked))
        try:
            self._cleanup_help_label.setVisible(bool(checked))
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

    def _on_repair_dependencies(self):
        reply = QMessageBox.question(
            self,
            "VirtuGhan",
            "This will check core runtime dependencies and repair only if needed.\n\nContinue?",
            QMessageBoxCompat.Yes | QMessageBoxCompat.No,
            QMessageBoxCompat.No,
        )
        if reply != QMessageBoxCompat.Yes:
            return

        ok = interactive_install_dependencies(self, force_reinstall=False)
        if ok:
            QMessageBox.information(
                self,
                "VirtuGhan",
                "Dependency check completed.\n\nCore dependencies are healthy or were repaired successfully.",
            )
        else:
            details = get_last_bootstrap_error() or "Dependency installation failed."
            lower_details = details.lower()
            if "locked" in lower_details or "access is denied" in lower_details or "winerror 5" in lower_details:
                QMessageBox.warning(
                    self,
                    "VirtuGhan",
                    "Dependency repair could not continue because runtime folders are locked.\n\n"
                    "If one folder is locked, alternate-folder install is attempted automatically. "
                    "If both are locked, restart QGIS and try again.\n\n"
                    f"Details:\n{details}",
                )
            else:
                QMessageBox.critical(self, "VirtuGhan", f"Dependency repair failed:\n\n{details}")

    def _on_uninstall_dependencies(self):
        reply = QMessageBox.question(
            self,
            "VirtuGhan",
            "This will force a clean reinstall: clear runtime dependencies, clear pip cache, and install again.\n\nContinue?",
            QMessageBoxCompat.Yes | QMessageBoxCompat.No,
            QMessageBoxCompat.No,
        )
        if reply != QMessageBoxCompat.Yes:
            return

        cleaned = repair_runtime_dependencies(clear_pip_cache=True)
        if not cleaned:
            details = get_last_bootstrap_error() or "Some dependency files could not be cleared."
            QMessageBox.warning(
                self,
                "VirtuGhan",
                "Dependency cleanup completed with warnings.\n\n"
                "Reinstall will continue and may use the alternate runtime folder if available.\n\n"
                f"Details:\n{details}",
            )

        ok = interactive_install_dependencies(self, force_reinstall=True)
        if ok:
            QMessageBox.information(
                self,
                "VirtuGhan",
                "Dependencies reinstalled successfully.\n\nPlease restart QGIS.",
            )
        else:
            details = get_last_bootstrap_error() or "Dependency reinstall failed."
            lower_details = details.lower()
            is_lock_warning = (
                "failed to remove some dependency files" in lower_details
                or "locked" in lower_details
                or "winerror 5" in lower_details
                or "access is denied" in lower_details
            )
            if is_lock_warning:
                QMessageBox.information(
                    self,
                    "VirtuGhan",
                    "Runtime folders are locked or in use.\n\n"
                    "If one folder is locked, alternate-folder reinstall is attempted automatically. "
                    "If both are locked, restart QGIS and try again.",
                )
            else:
                QMessageBox.critical(
                    self,
                    "VirtuGhan",
                    "Dependency reinstall failed.\n\n"
                    "Please check details below:\n\n"
                    f"{details}",
                )

    def _set_help_for_row(self, row: int):
        key = None
        for k, mapped_row in self._key_to_row.items():
            if mapped_row == row:
                key = k
                break
        if key is None:
            key = "engine"
        self._set_help_content(key)

    def _set_help_content(self, key: str):
        k = (key or "engine").strip().lower()
        title = k.capitalize()
        text = self._help_by_key.get(k, self._help_by_key["engine"])
        self.helpTitle.setText(f"<b>{title} Help</b>")
        self.helpText.setHtml(text)

    def show_help_for(self, key: str):
        k = (key or "engine").strip().lower()
        self._set_help_content(k)
        self._help_is_minimized = False
        self._apply_help_mode(minimized=False)
        self.helpMinButton.setText("–")
        self.helpPane.setVisible(True)
        self._resize_for_help_state(visible=True)

        try:
            row = self._key_to_row[k]
            if self.nav.currentRow() != row:
                self.nav.setCurrentRow(row)
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

    def show_results_for_output(self, output_dir: str, auto_open: bool = False, run_metadata: dict | None = None):
        summary = self._results_widget.add_result(output_dir, run_metadata=run_metadata)
        if auto_open:
            row = self._key_to_row.get("results")
            if row is not None and self.nav.currentRow() != row:
                self.nav.setCurrentRow(row)
        return summary

    def show_page(self, key: str):
        row = self._key_to_row.get((key or "").strip().lower())
        if row is not None and self.nav.currentRow() != row:
            self.nav.setCurrentRow(row)

    def set_tab_busy(self, key: str, busy: bool):
        row = self._key_to_row.get((key or "").strip().lower())
        if row is None:
            return
        item = self.nav.item(row)
        if item is None:
            return
        item.setData(NAV_BUSY_ROLE, bool(busy))
        self.nav.viewport().update(self.nav.visualItemRect(item))

    def get_results_history_snapshot(self):
        return self._results_widget.get_history_snapshot()

    def set_results_history(self, history_entries: list[dict] | None):
        self._results_widget.set_history(history_entries)

    def _toggle_help_minimize(self):
        if not self.helpPane.isVisible():
            self.helpPane.setVisible(True)
        self._help_is_minimized = not self._help_is_minimized
        if self._help_is_minimized:
            self._apply_help_mode(minimized=True)
            self.helpMinButton.setText("□")
        else:
            self._apply_help_mode(minimized=False)
            self.helpMinButton.setText("–")
        self._resize_for_help_state(visible=True)

    def _close_help(self):
        self._apply_help_hidden()
        self.helpPane.setMinimumWidth(0)
        self.helpPane.setMaximumWidth(0)
        self.helpPane.setVisible(False)
        self._help_is_minimized = False
        self.helpMinButton.setText("–")
        self._set_compact_mode()

    def _apply_help_mode(self, minimized: bool):
        if minimized:
            self.helpPane.setMinimumWidth(110)
            self.helpPane.setMaximumWidth(260)
            self._root_layout.setStretch(1, 5)
            self._root_layout.setStretch(2, 1)
        else:
            self.helpPane.setMinimumWidth(180)
            self.helpPane.setMaximumWidth(520)
            self._root_layout.setStretch(1, 4)
            self._root_layout.setStretch(2, 2)

    def _apply_help_hidden(self):
        self._root_layout.setStretch(1, 1)
        self._root_layout.setStretch(2, 0)

    def _set_compact_mode(self):
        self.setMinimumSize(self._compact_min_width, self._compact_min_height)
        self.setMaximumSize(16777215, 16777215)
        self.resize(self._base_width, self._height)
        self._apply_help_hidden()

    def _set_expandable_mode(self):
        self.setMinimumSize(self._compact_min_width, self._compact_min_height)
        self.setMaximumSize(16777215, 16777215)

    def _resize_for_help_state(self, visible: bool):
        if not visible:
            self._set_compact_mode()
            return
        self._set_expandable_mode()
        help_w = self._help_minimized_width if self._help_is_minimized else self._help_expanded_width
        self.resize(self._base_width + help_w + self._help_padding, self.height())
        self._ensure_window_visible_on_screen()

    def _ensure_window_visible_on_screen(self):
        """Keep the hub dialog fully visible within the available screen area."""
        try:
            target_rect = None
            main_window = self.iface.mainWindow() if self.iface else None
            if main_window is not None:
                target_rect = main_window.frameGeometry()

            if target_rect is None:
                screen = self.windowHandle().screen() if self.windowHandle() else QApplication.primaryScreen()
                if screen is not None:
                    target_rect = screen.availableGeometry()

            if target_rect is None:
                return

            margin = 12
            frame = self.frameGeometry()

            new_x = frame.x()
            new_y = frame.y()

            max_right = int(target_rect.right() - margin)
            min_left = int(target_rect.left() + margin)
            max_bottom = int(target_rect.bottom() - margin)
            min_top = int(target_rect.top() + margin)

            if frame.right() > max_right:
                new_x -= (frame.right() - max_right)
            if frame.left() < min_left:
                new_x += (min_left - frame.left())

            if frame.bottom() > max_bottom:
                new_y -= (frame.bottom() - max_bottom)
            if frame.top() < min_top:
                new_y += (min_top - frame.top())

            self.move(int(new_x), int(new_y))
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

    def showEvent(self, event):
        super().showEvent(event)
        self._update_dependencies_spacer()
        if self._centered_once:
            return
        self._centered_once = True
        try:
            # Keep the tool panel on the right so it does not cover the map center.
            target_rect = None
            main_window = self.iface.mainWindow() if self.iface else None
            if main_window is not None:
                target_rect = main_window.frameGeometry()

            if target_rect is None:
                screen = self.windowHandle().screen() if self.windowHandle() else QApplication.primaryScreen()
                if screen is not None:
                    target_rect = screen.availableGeometry()

            if target_rect is not None:
                margin = 12
                x = int(target_rect.right() - self.width() - margin)
                y = int(target_rect.top() + max(20, margin))
                self.move(x, y)
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

    def resizeEvent(self, event):
        super().resizeEvent(event)
        self._update_dependencies_spacer()

    def _add_page(self, title: str, content_widget: QWidget, icon: QIcon, key: str):
        # Strip dock chrome so it looks like a plain page
        if isinstance(content_widget, QDockWidget):
            dock = content_widget
            dock.setFeatures(QDockWidgetCompat.NoDockWidgetFeatures)
            dock.setAllowedAreas(QtCompat.NoDockWidgetArea)
            dock.setTitleBarWidget(QWidget(dock))
            target_widget = dock
        else:
            target_widget = content_widget

        scroller = QScrollArea()
        scroller.setObjectName("virtPageScroller")
        scroller.setWidgetResizable(True)
        scroller.setFrameShape(QFrameCompat.NoFrame)
        scroller.setHorizontalScrollBarPolicy(QtCompat.ScrollBarAsNeeded)
        scroller.setVerticalScrollBarPolicy(QtCompat.ScrollBarAsNeeded)
        scroller.setWidget(target_widget)

        # Wrap the dock in a plain QWidget page
        page = QWidget()
        lay = QVBoxLayout(page)
        lay.setContentsMargins(0, 0, 0, 0)
        lay.addWidget(scroller)
        page_index = self.pages.count()
        self.pages.addWidget(page)

        # Sidebar item with enforced height
        item = QListWidgetItem(icon, title)
        item.setSizeHint(QSize(220, 42))
        item.setData(NAV_BUSY_ROLE, False)
        row_index = self.nav.count()
        self.nav.addItem(item)
        self._row_to_page[row_index] = page_index
        self._key_to_row[(key or "").strip().lower()] = row_index

    def _add_separator(self):
        top_space = QListWidgetItem("")
        top_space.setFlags(QtCompat.NoItemFlags)
        top_space.setSizeHint(QSize(200, 10))
        self.nav.addItem(top_space)

        line = QListWidgetItem("")
        line.setFlags(QtCompat.NoItemFlags)
        line.setSizeHint(QSize(200, 14))
        self.nav.addItem(line)

        line_widget = QWidget()
        line_layout = QVBoxLayout(line_widget)
        line_layout.setContentsMargins(8, 5, 8, 5)
        line_layout.setSpacing(0)
        hline = QFrame()
        hline.setFrameShape(QFrameCompat.HLine)
        hline.setFrameShadow(QFrameCompat.Plain)
        hline.setStyleSheet("color: #9aa0aa;")
        line_layout.addWidget(hline)
        self.nav.setItemWidget(line, line_widget)

        bottom_space = QListWidgetItem("")
        bottom_space.setFlags(QtCompat.NoItemFlags)
        bottom_space.setSizeHint(QSize(200, 12))
        self.nav.addItem(bottom_space)

    def _add_dependencies_spacer(self):
        spacer = QListWidgetItem("")
        spacer.setFlags(QtCompat.NoItemFlags)
        spacer.setSizeHint(QSize(200, 0))
        self.nav.addItem(spacer)
        self._dependencies_spacer_item = spacer

    def _update_dependencies_spacer(self):
        if self._dependencies_spacer_item is None:
            return
        try:
            viewport_height = self.nav.viewport().height()
            used_height = 0
            for index in range(self.nav.count()):
                item = self.nav.item(index)
                if item is None or item is self._dependencies_spacer_item:
                    continue
                used_height += item.sizeHint().height()

            spacer_height = max(0, viewport_height - used_height)
            self._dependencies_spacer_item.setSizeHint(QSize(200, spacer_height))
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

    def _has_busy_tabs(self) -> bool:
        try:
            for i in range(self.nav.count()):
                item = self.nav.item(i)
                if item is not None and bool(item.data(NAV_BUSY_ROLE)):
                    return True
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            return False
        return False

    def closeEvent(self, event):
        if self._has_busy_tabs():
            try:
                self.hide()
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                pass
            try:
                self.iface.messageBar().pushWarning(
                    "VirtuGhan",
                    "A VirtuGhan task is still running. The window was hidden and will reopen instead of creating a new instance.",
                )
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                pass
            event.ignore()
            return
        super().closeEvent(event)
