# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
enc_dock_widget.py
Dockable panel for the ENCore GIS plugin.
"""

import os
import re
from qgis.PyQt.QtWidgets import (
    QDockWidget, QWidget, QVBoxLayout, QHBoxLayout,
    QPushButton, QLineEdit, QFileDialog, QListWidget,
    QListWidgetItem, QLabel, QAbstractItemView, QProgressBar,
    QSizePolicy, QFrame, QDoubleSpinBox, QSpinBox, QGridLayout, QComboBox,
    QTabWidget, QPlainTextEdit, QTreeWidget, QTreeWidgetItem,
    QTreeWidgetItemIterator, QCheckBox,
    QTableWidget, QTableWidgetItem, QSplitter, QHeaderView, QMenu,
)
from qgis.PyQt.QtCore import Qt, QThread, pyqtSignal, QLocale, QUrl
from qgis.PyQt.QtGui import QFont, QValidator, QDesktopServices
from qgis.core import (
    QgsMessageLog, Qgis, QgsProject, QgsCoordinateReferenceSystem,
    QgsExpressionContextUtils,
)
from qgis.gui import QgsProjectionSelectionWidget

from .enc_scanner import (
    scan_for_charts, scan_for_object_layers, scan_for_object_layers_s101,
    classify_cell, usage_band, usage_label,
)
from .enc_loader import load_layers, register_layers
from .enc_config import get_config
from .auditor_ui import AuditorPanel
from .sourceval_ui import SourceValidationPanel


# ── Bundled user manual ──────────────────────────────────────────────────────
MANUAL_PATH = os.path.join(os.path.dirname(__file__), "help",
                           "ENCoreGIS_UserManual.html")


def open_user_manual(parent=None):
    """Open the bundled, self-contained user-manual HTML in the system browser.
    Shared by the dock's Help button and the plugin menu's User Manual item.
    Opening in the default browser avoids any QtWebEngine dependency."""
    if os.path.isfile(MANUAL_PATH):
        QDesktopServices.openUrl(QUrl.fromLocalFile(MANUAL_PATH))
        return
    from qgis.PyQt.QtWidgets import QMessageBox
    QMessageBox.information(
        parent, "ENCore GIS — User Manual",
        "The bundled user manual could not be found at:\n\n"
        f"{MANUAL_PATH}\n\nReinstalling the plugin should restore it.")


# ── Background workers ───────────────────────────────────────────────────────

class ChartScanWorker(QThread):
    """STAGE 1: stage the source (folder/zip), correct update placement, and
    return chart winners + validation events."""
    finished = pyqtSignal(dict, list, list, str)  # best_files, skipped, validation, staging
    error    = pyqtSignal(str)
    status   = pyqtSignal(str)

    def __init__(self, source):
        super().__init__()
        self.source = source

    def run(self):
        try:
            self.status.emit("Scanning for charts…")
            best_files, skipped, validation, staging = scan_for_charts(self.source)
            self.finished.emit(best_files, skipped, validation, staging)
        except Exception as e:
            self.error.emit(str(e))


class RemoteFetchWorker(QThread):
    """STAGE 0 (remote sources): fetch a URL into the profile staging cache
    (enc_remote) and hand back the LOCAL folder/file for the normal scan.
    Insecure transports are refused inside enc_remote — no override exists."""
    finished = pyqtSignal(str, list)  # local path, policy warnings
    error    = pyqtSignal(str)
    status   = pyqtSignal(str)

    def __init__(self, url, authcfg):
        super().__init__()
        self.url = url
        self.authcfg = authcfg

    def run(self):
        try:
            try:
                from .enc_remote import fetch
            except (ImportError, ValueError):
                from enc_remote import fetch
            local, warns = fetch(
                self.url, authcfg=self.authcfg,
                progress=lambda s: self.status.emit(f"Remote: {s}"))
            self.finished.emit(local, warns)
        except Exception as e:                          # noqa: BLE001
            self.error.emit(str(e))


class ObjectScanWorker(QThread):
    """STAGE 2: build the object-layer index across the chosen (checked) charts.

    Emits a {product -> layer_index} dict so a hybrid (mixed S-57 + S-101)
    selection returns BOTH indexes — the S-57 index keyed by class acronym and
    the S-101 index keyed by feature type — read by their own native scanners.
    Single-product scans emit a one-entry dict ({"S-57": …} or {"S-101": …})."""
    finished = pyqtSignal(object)  # { "S-57": layer_index, "S-101": layer_index }
    error    = pyqtSignal(str)
    status   = pyqtSignal(str)

    def __init__(self, best_files, product="S-57", chart_products=None):
        super().__init__()
        self.best_files = best_files
        self.product = product
        # { dsnm -> "S-57"|"S-101" } so a mixed scan can split the cells and run
        # the correct scanner on each — an S-101 cell read by the S-57 scanner
        # yields nothing (it reads S-57 OBJL codes), which is the bug this fixes.
        self.chart_products = chart_products or {}

    def run(self):
        try:
            self.status.emit("Scanning for object layers…")
            result = {}
            if self.product == "S-101":
                result["S-101"] = scan_for_object_layers_s101(self.best_files)
            elif self.product == "mixed":
                s57 = {n: p for n, p in self.best_files.items()
                       if self.chart_products.get(n) != "S-101"}
                s101 = {n: p for n, p in self.best_files.items()
                        if self.chart_products.get(n) == "S-101"}
                if s57:
                    result["S-57"] = scan_for_object_layers(s57)
                if s101:
                    result["S-101"] = scan_for_object_layers_s101(s101)
            else:
                result["S-57"] = scan_for_object_layers(self.best_files)
            self.finished.emit(result)
        except Exception as e:
            self.error.emit(str(e))


class LoadWorker(QThread):
    """Background thread that runs a chart load without freezing the UI.

    Given the user's selection it calls load_layers() off the GUI thread,
    streaming progress/status back through Qt signals, and emits `finished`
    with the session GeoPackage path and the layer manifest (or `error`)."""
    finished    = pyqtSignal(str, list)   # session GPKG path, layer manifest
    error       = pyqtSignal(str)
    status      = pyqtSignal(str)
    progress    = pyqtSignal(int, int)
    subprogress = pyqtSignal(int, str)

    def __init__(self, layer_index, selection, target_crs="EPSG:3857",
                 want_coverage=False, coverage_usages=None, best_files=None,
                 beautiful=False):
        super().__init__()
        self.layer_index     = layer_index
        self.selection       = selection          # [(usage_band, code), …]
        self.target_crs      = target_crs
        self.want_coverage   = want_coverage
        self.coverage_usages = coverage_usages or set()
        self.best_files      = best_files or {}
        self.beautiful       = beautiful          # pre-load render mode
        self._cancel         = False

    def request_cancel(self):
        """Ask the load to stop at the next layer boundary (cooperative). Called
        on plugin/QGIS shutdown so the worker stops touching QGIS objects before
        the main thread tears them down — see load_layers' cancel check."""
        self._cancel = True

    def run(self):
        try:
            gpkg_path, manifest = load_layers(
                self.layer_index,
                self.selection,
                feedback_fn=self.status.emit,
                progress_fn=self.progress.emit,
                subprogress_fn=self.subprogress.emit,
                target_crs=self.target_crs,
                want_coverage=self.want_coverage,
                coverage_usages=self.coverage_usages,
                best_files=self.best_files,
                beautiful=self.beautiful,
                cancel_fn=lambda: self._cancel,
            )
            self.finished.emit(gpkg_path or "", manifest or [])
        except Exception as e:
            self.error.emit(str(e))


class TileBakeWorker(QThread):
    """Background bake of the just-staged cells into the Chart Viewer's two
    vector-tile planes (enc_tile_bake.bake_planes). It is QGIS-FREE — pure native
    decode + raw GDAL/OGR, NO QgsVectorLayer — so unlike LoadWorker it is safe to
    run while the user works in the GUI and during shutdown. Kicked off the moment
    a chart scan finishes, so opening the viewer later feels instant."""
    finished = pyqtSignal(dict)        # {"wet": path|None, "dry": path|None}
    error = pyqtSignal(str)
    progress = pyqtSignal(int, int, str)   # done, total, message

    def __init__(self, paths, out_dir):
        super().__init__()
        self.paths = list(paths)
        self.out_dir = out_dir
        self._cancel = False

    def request_cancel(self):
        self._cancel = True

    def run(self):
        try:
            from .enc_tile_bake import bake_planes
        except (ImportError, ValueError):
            from enc_tile_bake import bake_planes
        try:
            res = bake_planes(self.paths, self.out_dir,
                              should_stop=lambda: self._cancel,
                              progress=lambda d, t, m: self.progress.emit(d, t, m))
            if not self._cancel:
                self.finished.emit(res or {})
        except Exception as e:                          # noqa: BLE001
            if not self._cancel:
                self.error.emit(str(e))


# ── Dock widget ──────────────────────────────────────────────────────────────

class DecimalSpinBox(QDoubleSpinBox):
    """A QDoubleSpinBox that accepts BOTH '.' and ',' as the decimal mark,
    regardless of the user's locale.

    Without this, a user in a comma-locale (e.g. NL) who types "6.1" has the
    dot treated as a group separator, so it reads as 61 instead of 6.1.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # Use a C locale so the dot is the canonical decimal mark for display.
        self.setLocale(QLocale(QLocale.C))

    def _normalise(self, text):
        # Strip the suffix, swap any comma to a dot, return a clean number str.
        s = text
        suffix = self.suffix()
        if suffix and s.endswith(suffix):
            s = s[: -len(suffix)]
        return s.strip().replace(",", ".")

    def validate(self, text, pos):
        norm = self._normalise(text)
        if norm in ("", "-", "+"):
            return (QValidator.Intermediate, text, pos)
        try:
            float(norm)
            return (QValidator.Acceptable, text, pos)
        except ValueError:
            return (QValidator.Invalid, text, pos)

    def valueFromText(self, text):
        norm = self._normalise(text)
        try:
            return float(norm)
        except ValueError:
            return self.value()


class ENCDockWidget(QDockWidget):
    """The ENCore GIS panel — the plugin's whole UI.

    Hosts the two-stage scan/load workflow (Chart Control live-styling vars,
    Pre-load CRS/render controls, and the per-usage layer browser) and drives
    the background scan/load worker threads. One instance per QGIS session."""

    # Sentinel stored in a list item's UserRole to mark the ENC Coverage hybrid
    # layer (vs. a normal S-57 code). Chosen to never collide with a real code.
    COVERAGE_SENTINEL = "__ENC_COVERAGE__"

    def __init__(self, iface):
        super().__init__("ENCore GIS")
        self.iface = iface
        self._layer_index = {}     # S-57 winners' object layers (stage 2 output)
        self._layer_index_s101 = {}  # S-101 winners' feature-type index (hybrid)
        self._best_files  = {}     # ALL stage-1 winners {dsnm -> staged .000}
        self._worker      = None
        self._staging_dir = None   # temp staging dir from stage 1 (we own it)
        self._charts_scanned = False
        self._remote_local = ""    # staged local copy of a Remote-mode source
        self._s102_files = []      # S-102 bathymetry datasets found in the source
        self._s111_files = []      # S-111 surface-current datasets found
        self._s104_files = []      # S-104 water-level datasets found
        self._ecdis_viewer = None  # the Chart Viewer (ECDIS) window, if open
        self.session_gpkgs = set()  # scratch GPKGs made this session; cleaned
        # up on plugin unload (QGIS close/disable)
        self._tile_worker = None    # background vector-tile bake (scan-time)
        self._tile_cache_dir = None  # ephemeral MBTiles dir (deleted on rescan/unload)
        self._tile_cache = None     # {"wet": path|None, "dry": path|None} when ready
        self._scan_status_msg = ""  # base status, so tile progress can append
        # Vector-tileset table rows: list of dicts {name, dir, saved, wet, dry,
        # decode, coverage, created, size}. The one with saved=False is the current
        # EPHEMERAL bake (temp dir); saved=True rows live in the profile library.
        self._tilesets = []
        self._tileset_updating = False   # guard the name-edit signal

        self.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
        self._build_ui()
        self._load_saved_tilesets()      # populate the table from the library

    def _build_ui(self):
        container = QWidget()
        layout = QVBoxLayout(container)
        layout.setContentsMargins(8, 8, 8, 8)
        layout.setSpacing(6)

        # (Title omitted — the dock header already shows "ENCore GIS".) The Help
        # button ("?") lives at the end of the ENC source row below, so it costs
        # no extra vertical space.

        # The Chart Control + Pre-load controls live in their own first tab
        # ("Chart control") to keep the dock body uncluttered. ENC source and
        # the two scan buttons stay above the tab system.
        controls_tab = QWidget()
        controls_layout = QVBoxLayout(controls_tab)
        controls_layout.setContentsMargins(4, 4, 4, 4)
        controls_layout.setSpacing(6)

        # Chart Control.
        params_label = QLabel("Chart Control")
        params_font = QFont()
        params_font.setBold(True)
        params_label.setFont(params_font)
        cc_caption = QLabel("live — restyles loaded layers")
        _cc_cf = QFont()
        _cc_cf.setItalic(True)
        cc_caption.setFont(_cc_cf)
        cc_caption.setStyleSheet("color: gray;")
        cc_hdr = QHBoxLayout()
        cc_hdr.setSpacing(8)
        cc_hdr.addWidget(params_label)
        cc_hdr.addWidget(cc_caption)
        cc_hdr.addStretch(1)
        controls_layout.addLayout(cc_hdr)

        # Two columns: left = depth/sector spin boxes, right = Soundings mode.
        cc_row = QHBoxLayout()
        cc_row.setSpacing(16)

        # ── Left column: the four value spin boxes ──────────────────────────
        params_grid = QGridLayout()
        params_grid.setHorizontalSpacing(6)
        params_grid.setVerticalSpacing(4)

        self.shallow_spin = DecimalSpinBox()
        self.shallow_spin.setRange(0.0, 9999.0)
        self.shallow_spin.setDecimals(1)
        self.shallow_spin.setSingleStep(0.5)
        self.shallow_spin.setValue(4.0)
        self.shallow_spin.setSuffix(" m")
        self.shallow_spin.setToolTip(
            "Shallow depth contour. Water shallower than this gets the darkest "
            "blue depth shade.")

        self.safety_spin = DecimalSpinBox()
        self.safety_spin.setRange(0.0, 9999.0)
        self.safety_spin.setDecimals(1)
        self.safety_spin.setSingleStep(0.5)
        self.safety_spin.setValue(6.0)
        self.safety_spin.setSuffix(" m")
        self.safety_spin.setToolTip(
            "Your safety contour — the key shallow/deep boundary for your "
            "vessel. Also drives which soundings are emphasised.")

        self.deep_spin = DecimalSpinBox()
        self.deep_spin.setRange(0.0, 9999.0)
        self.deep_spin.setDecimals(1)
        self.deep_spin.setSingleStep(0.5)
        self.deep_spin.setValue(10.0)
        self.deep_spin.setSuffix(" m")
        self.deep_spin.setToolTip(
            "Deep depth contour. Water deeper than this is shown white "
            "(open water).")

        # Extra contour — an additional user contour drawn RED. From S-102 in the
        # Chart Viewer; on the vector DEPCNT (S-57 + S-101) the matching contour
        # is bolded red. Off by default (the checkbox behind the value box).
        self.extra_spin = DecimalSpinBox()
        self.extra_spin.setRange(0.0, 9999.0)
        self.extra_spin.setDecimals(1)
        self.extra_spin.setSingleStep(0.5)
        self.extra_spin.setValue(8.0)
        self.extra_spin.setSuffix(" m")
        self.extra_spin.setToolTip(
            "An extra contour, drawn RED. Built from S-102 in the Chart Viewer; "
            "on vector charts the matching DEPCNT contour is highlighted red.")
        self.extra_chk = QCheckBox("extra contour")
        self.extra_chk.setToolTip("Draw the extra red contour at the value on "
                                  "the left.")

        self.sector_spin = QSpinBox()
        self.sector_spin.setRange(0, 1000000)
        self.sector_spin.setSingleStep(100)
        self.sector_spin.setValue(1000)
        self.sector_spin.setSuffix(" m")
        self.sector_spin.setToolTip(
            "How far light sector wedges are drawn from each light, in metres. "
            "Purely a portrayal length, not a real range.")

        # Values never exceed ~4 characters, so keep the boxes narrow.
        # Cap height too, so rows stay tight instead of stretching apart.
        for sb in (self.shallow_spin, self.safety_spin, self.deep_spin,
                   self.extra_spin, self.sector_spin):
            sb.setMaximumWidth(90)
            sb.setMaximumHeight(24)

        params_grid.addWidget(QLabel("Shallow:"),         0, 0)
        params_grid.addWidget(self.shallow_spin,          0, 1)
        params_grid.addWidget(QLabel("Safety:"),          1, 0)
        params_grid.addWidget(self.safety_spin,           1, 1)
        params_grid.addWidget(QLabel("Deep:"),            2, 0)
        params_grid.addWidget(self.deep_spin,             2, 1)
        params_grid.addWidget(QLabel("Extra:"),           3, 0)
        params_grid.addWidget(self.extra_spin,            3, 1)
        params_grid.addWidget(self.extra_chk,             3, 2)
        params_grid.addWidget(QLabel("Sector distance:"), 4, 0)
        params_grid.addWidget(self.sector_spin,           4, 1)
        cc_row.addLayout(params_grid)
        cc_row.setAlignment(params_grid, Qt.AlignTop)

        # ── Right column: Soundings portrayal mode ──────────────────────────
        sound_col = QVBoxLayout()
        sound_col.setSpacing(4)
        sound_col.addWidget(QLabel("Soundings:"))
        self.sounding_combo = QComboBox()
        self.sounding_combo.setToolTip(
            "Which depth soundings to show: all of them, only those above your "
            "safety contour, or only those below it.")
        # Item text + stored integer value written to enc_sounding_portrayal.
        self.sounding_combo.addItem("Show all", 0)
        self.sounding_combo.addItem("Above safety", 1)
        self.sounding_combo.addItem("Below safety", 2)
        sound_col.addWidget(self.sounding_combo)
        sound_col.addStretch(1)
        cc_row.addLayout(sound_col)
        cc_row.setStretch(0, 0)
        cc_row.setStretch(1, 1)

        controls_layout.addLayout(cc_row)

        # (The "Charts → Viewer" button now lives in the workflow bar above the
        # tabs, alongside the other three workflow steps.)

        # ── S-100 overlays (Chart Viewer) — central control for the S-102/S-104/
        #    S-111 overlays that render in the Chart Viewer. Enabled per what a
        #    scan detects; drive an open viewer live (and are applied when it
        #    opens). The time slider stays at the bottom of the viewer.
        s100_hdr = QHBoxLayout()
        s100_label = QLabel("S-100 overlays")
        s100_label.setFont(params_font)
        s100_cap = QLabel("shown in the Chart Viewer")
        s100_cap.setFont(_cc_cf)
        s100_cap.setStyleSheet("color: gray;")
        s100_hdr.addWidget(s100_label)
        s100_hdr.addWidget(s100_cap)
        s100_hdr.addStretch(1)
        controls_layout.addLayout(s100_hdr)
        self.dk_s102_chk = QCheckBox("Portray S-102 bathymetry")
        self.dk_s102_chk.setToolTip(
            "Show the S-102 bathymetric surface in the depth-band colours "
            "(replaces DEPARE/DEPCNT/SOUNDG on its coverage).")
        self.dk_s102_contour_chk = QCheckBox("safety contour")
        self.dk_s102_snd_chk = QCheckBox("soundings")
        self.dk_s111_chk = QCheckBox("Surface currents (S-111)")
        self.dk_s111_chk.setToolTip(
            "Overlay S-111 surface-current arrows; scrub the time slider at the "
            "bottom of the Chart Viewer.")
        self.dk_s111_depth_combo = QComboBox()
        self.dk_s111_depth_combo.setToolTip("S-111 depth layer.")
        self.dk_s104_chk = QCheckBox("Water level (S-104) — tidal engine")
        self.dk_s104_chk.setToolTip(
            "Apply the S-104 water level for the selected time to the wet layers: "
            "depth bands/contours re-classify at the tide-adjusted depth and the "
            "sounding labels are raised + turn red. A red 'tidal engine live' "
            "banner shows in the viewer. Chart Control depths are left unchanged.")
        for _w in (self.dk_s102_chk, self.dk_s102_contour_chk, self.dk_s102_snd_chk,
                   self.dk_s111_chk, self.dk_s111_depth_combo, self.dk_s104_chk):
            _w.setEnabled(False)
        # Lay the controls out as a small TREE — sub-options nest under their
        # product. The checkboxes/combo keep ALL their existing wiring; the tree
        # only hosts them (setItemWidget), so only the presentation changes.
        self.s100_tree = QTreeWidget()
        self.s100_tree.setHeaderHidden(True)
        self.s100_tree.setSelectionMode(QAbstractItemView.NoSelection)
        self.s100_tree.setFocusPolicy(Qt.NoFocus)
        self.s100_tree.setIndentation(14)

        def _s100_leaf(parent, widget):
            it = QTreeWidgetItem(parent)
            self.s100_tree.setItemWidget(it, 0, widget)
            return it

        it_s102 = _s100_leaf(self.s100_tree, self.dk_s102_chk)
        _s100_leaf(it_s102, self.dk_s102_contour_chk)
        _s100_leaf(it_s102, self.dk_s102_snd_chk)
        it_s102.setExpanded(True)
        it_s111 = _s100_leaf(self.s100_tree, self.dk_s111_chk)
        _depth_w = QWidget()
        _dl = QHBoxLayout(_depth_w)
        _dl.setContentsMargins(0, 0, 0, 0)
        _dl.setSpacing(4)
        _dl.addWidget(QLabel("Depth:"))
        _dl.addWidget(self.dk_s111_depth_combo, 1)
        _s100_leaf(it_s111, _depth_w)
        it_s111.setExpanded(True)
        _s100_leaf(self.s100_tree, self.dk_s104_chk)    # top-level: water level
        self.s100_tree.setFixedHeight(186)              # ~6 compact rows
        controls_layout.addWidget(self.s100_tree)
        self.dk_s102_chk.toggled.connect(self._on_dk_s102)
        self.dk_s102_contour_chk.toggled.connect(self._apply_s100_to_viewer)
        self.dk_s102_snd_chk.toggled.connect(self._apply_s100_to_viewer)
        self.dk_s111_chk.toggled.connect(self._apply_s100_to_viewer)
        self.dk_s111_depth_combo.currentIndexChanged.connect(
            lambda _i: self._apply_s100_to_viewer())
        self.dk_s104_chk.toggled.connect(self._apply_s100_to_viewer)

        # The pre-load CRS + render controls live in the Object layers tab now,
        # directly under the layer list — their placement there makes their
        # meaning self-evident, so no header/caption is needed here.
        controls_layout.addStretch(1)

        # The three depth contours must stay ordered with a 0.5 m gap
        # (shallow < safety < deep). Dialing one into another nudges the
        # neighbour rather than blocking — see _enforce_depth_order.
        self._enforcing = False
        self.shallow_spin.valueChanged.connect(
            lambda *_: self._enforce_depth_order("shallow"))
        self.safety_spin.valueChanged.connect(
            lambda *_: self._enforce_depth_order("safety"))
        self.deep_spin.valueChanged.connect(
            lambda *_: self._enforce_depth_order("deep"))
        self.sector_spin.valueChanged.connect(self._write_project_variables)
        self.sounding_combo.currentIndexChanged.connect(
            self._write_project_variables)
        self.extra_spin.valueChanged.connect(self._write_project_variables)
        self.extra_chk.toggled.connect(self._write_project_variables)

        # Write defaults now so the variables exist immediately
        self._write_project_variables()

        # ── Source input with an explicit Folder / Zip mode toggle ──────────
        src_label = QLabel("ENC source:")
        src_font = QFont()
        src_font.setBold(True)
        src_label.setFont(src_font)
        layout.addWidget(src_label)
        mode_row = QHBoxLayout()
        self.source_mode = QComboBox()
        self.source_mode.addItem("Folder", "folder")
        self.source_mode.addItem("Zip", "zip")
        self.source_mode.addItem("Remote", "remote")
        self.source_mode.setFixedWidth(80)
        self.source_mode.currentIndexChanged.connect(self._on_source_mode)
        mode_row.addWidget(self.source_mode)
        self.folder_edit = QLineEdit()
        self.folder_edit.setPlaceholderText("Select a folder containing ENC files…")
        mode_row.addWidget(self.folder_edit)
        self.browse_btn = QPushButton("Browse…")
        self.browse_btn.setFixedWidth(75)
        self.browse_btn.setToolTip(
            "Pick the folder or zip that holds your ENC exchange set "
            "(the .000 files and their updates).")
        self.browse_btn.clicked.connect(self._browse)
        mode_row.addWidget(self.browse_btn)
        # Compact Help button on the same row — opens the bundled user manual.
        self.help_btn = QPushButton("?")
        self.help_btn.setFixedWidth(28)
        self.help_btn.setToolTip("Open the ENCoreGIS user manual "
                                 "(opens in your web browser).")
        self.help_btn.clicked.connect(lambda: open_user_manual(self))
        mode_row.addWidget(self.help_btn)
        layout.addLayout(mode_row)

        # ── Workflow bar: the four steps on one row, left to right ──────────
        #   Scan for charts → Charts → Viewer → Scan for object layers →
        #   Object layers → Project.
        scan_row = QHBoxLayout()
        self.scan_charts_btn = QPushButton("Scan for charts")
        self.scan_charts_btn.setToolTip(
            "Step 1: find all charts in the source, keep the newest edition of "
            "each, and list them in the Charts tab.")
        self.scan_charts_btn.clicked.connect(self._scan_charts)

        # Step 1b — bake the scanned charts into Chart-Viewer vector tiles. Manual:
        # nothing bakes on scan; the user starts it here when they want tiles.
        self.chart2tiles_btn = QPushButton("Charts → Tiles")
        self.chart2tiles_btn.setToolTip(
            "Bake the scanned charts into vector tiles for the Chart Viewer. "
            "Runs in the background; the baked set appears in the Charts tab, "
            "where you can open, save or delete it. If this exact chart set is "
            "already a saved tileset, it is reused without re-baking.")
        self.chart2tiles_btn.clicked.connect(self._chart2tiles)
        self.chart2tiles_btn.setEnabled(False)          # enabled after a chart scan

        self.scan_objects_btn = QPushButton("Scan for object layers")
        self.scan_objects_btn.setToolTip(
            "Step 2: read the ticked charts and list which object layers "
            "(depths, buoys, coastline…) are available to load.")
        self.scan_objects_btn.clicked.connect(self._scan_objects)
        # Object-layer scan is disabled until a chart scan has happened.
        self.scan_objects_btn.setEnabled(False)

        self.load_btn = QPushButton("Object layers → Project")
        self.load_btn.setEnabled(False)
        self.load_btn.setToolTip(
            "Build the ticked object layers from the selected charts and add "
            "them to your QGIS project, styled and in the chosen CRS.")
        self.load_btn.clicked.connect(self._load)

        for _b in (self.scan_charts_btn, self.chart2tiles_btn,
                   self.scan_objects_btn, self.load_btn):
            scan_row.addWidget(_b)
        layout.addLayout(scan_row)

        # ── The big tabbed area: Chart control · Charts · Object layers ──────
        self.tabs = QTabWidget()

        # Tab 0 — Chart control: the live styling + pre-load controls.
        self._controls_tab_index = self.tabs.addTab(controls_tab, "Chart control")

        # Tab 1 — Charts: split vertically. TOP = the checkable chart list ("Charts
        # to use", scrolls); BOTTOM = the vector-tileset table (one row per baked
        # set: name · size · created · Save/Delete/Load).
        charts_tab = QWidget()
        charts_layout = QVBoxLayout(charts_tab)
        charts_layout.setContentsMargins(4, 4, 4, 4)
        charts_split = QSplitter(Qt.Vertical)

        # -- top: charts-to-use list ------------------------------------------
        charts_top = QWidget()
        top_l = QVBoxLayout(charts_top)
        top_l.setContentsMargins(0, 0, 0, 0)
        charts_header = QHBoxLayout()
        charts_header.addWidget(QLabel("Charts to use:"))
        self.charts_all_btn = QPushButton("All")
        self.charts_all_btn.setFixedWidth(40)
        self.charts_all_btn.setToolTip("Tick every chart in the list.")
        self.charts_all_btn.clicked.connect(self._charts_check_all)
        self.charts_none_btn = QPushButton("None")
        self.charts_none_btn.setFixedWidth(45)
        self.charts_none_btn.setToolTip("Untick every chart in the list.")
        self.charts_none_btn.clicked.connect(self._charts_check_none)
        charts_header.addStretch()
        charts_header.addWidget(self.charts_all_btn)
        charts_header.addWidget(self.charts_none_btn)
        top_l.addLayout(charts_header)
        self.charts_list = QListWidget()
        self.charts_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        top_l.addWidget(self.charts_list, 1)
        charts_split.addWidget(charts_top)

        # -- bottom: vector-tileset table -------------------------------------
        charts_bot = QWidget()
        bot_l = QVBoxLayout(charts_bot)
        bot_l.setContentsMargins(0, 0, 0, 0)
        ts_header = QHBoxLayout()
        ts_header.addWidget(QLabel("Vector tilesets  (bold = saved):"))
        ts_header.addStretch()
        self.tileset_import_btn = QPushButton("Import…")
        self.tileset_import_btn.setToolTip(
            "Import a shared tileset (.enctileset.zip) into the library — "
            "baked once by a colleague, loaded here without re-baking. From "
            "a file, or fetched straight from a URL (TLS-only). The "
            "container is checksum-verified and unsafe content is refused.")
        _imp_menu = QMenu(self.tileset_import_btn)
        _imp_menu.addAction("From file…", self._tileset_import)
        _imp_menu.addAction("From URL… (https/ftps/sftp)",
                            self._tileset_import_url)
        self.tileset_import_btn.setMenu(_imp_menu)
        ts_header.addWidget(self.tileset_import_btn)
        bot_l.addLayout(ts_header)
        self.tileset_table = QTableWidget(0, 7)
        self.tileset_table.setHorizontalHeaderLabels(
            ["Name", "Size", "Created", "", "", "", ""])
        self.tileset_table.verticalHeader().setVisible(False)
        self.tileset_table.setSelectionMode(QAbstractItemView.NoSelection)
        self.tileset_table.setEditTriggers(
            QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed)
        hh = self.tileset_table.horizontalHeader()
        hh.setSectionResizeMode(0, QHeaderView.Stretch)
        for _c in range(1, 7):
            hh.setSectionResizeMode(_c, QHeaderView.ResizeToContents)
        self.tileset_table.itemChanged.connect(self._on_tileset_name_edited)
        bot_l.addWidget(self.tileset_table, 1)
        charts_split.addWidget(charts_bot)

        charts_split.setStretchFactor(0, 3)
        charts_split.setStretchFactor(1, 2)
        charts_layout.addWidget(charts_split, 1)
        self._charts_tab_index = self.tabs.addTab(charts_tab, "Charts")

        # Tab 2 — Objectlayers: a tree grouped by usage band → object layers.
        objects_tab = QWidget()
        objects_layout = QVBoxLayout(objects_tab)
        objects_layout.setContentsMargins(4, 4, 4, 4)
        layer_header = QHBoxLayout()
        layer_header.addWidget(QLabel("Available object layers (by usage):"))
        self.select_all_btn = QPushButton("All")
        self.select_all_btn.setFixedWidth(40)
        self.select_all_btn.setToolTip(
            "Select every object layer across all usage bands.")
        self.select_all_btn.clicked.connect(self._select_all)
        self.select_none_btn = QPushButton("None")
        self.select_none_btn.setFixedWidth(45)
        self.select_none_btn.setToolTip("Clear the whole selection.")
        self.select_none_btn.clicked.connect(self._select_none)
        layer_header.addStretch()
        layer_header.addWidget(self.select_all_btn)
        layer_header.addWidget(self.select_none_btn)
        objects_layout.addLayout(layer_header)
        self.layer_tree = QTreeWidget()
        self.layer_tree.setHeaderHidden(True)
        self.layer_tree.setSelectionMode(QAbstractItemView.MultiSelection)
        self.layer_tree.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        objects_layout.addWidget(self.layer_tree, 1)

        # Pre-load controls, right under the list and above the Load button —
        # so the user reviews CRS + render mode at the point of loading.
        crs_row = QHBoxLayout()
        crs_row.setSpacing(6)
        crs_row.addWidget(QLabel("Load CRS:"))
        self.crs_widget = QgsProjectionSelectionWidget()
        self.crs_widget.setToolTip(
            "Coordinate system the loaded layers are reprojected into. Default "
            "EPSG:3857; pick a local projected (metre) CRS if you prefer. Must "
            "be projected, not lat/long.")
        self.crs_widget.setCrs(QgsCoordinateReferenceSystem("EPSG:3857"))
        crs_row.addWidget(self.crs_widget, 1)
        objects_layout.addLayout(crs_row)

        render_row = QHBoxLayout()
        render_row.setSpacing(6)
        render_row.addWidget(QLabel("Render:"))
        self.render_combo = QComboBox()
        self.render_combo.setToolTip(
            "How the next load builds geometry.\n"
            "Fast: raw merged data + symbology — quick; cell seams visible.\n"
            "Beautiful: cleaned geometry (fix → dissolve → singleparts), "
            "seam-free — slower.\n"
            "Pre-load only: applies to the next load; loaded layers are "
            "unaffected.")
        self.render_combo.addItem("Fast (raw, quick)", False)
        self.render_combo.addItem("Beautiful (clean, slower)", True)
        self.render_combo.setCurrentIndex(0)   # default Fast
        render_row.addWidget(self.render_combo, 1)
        objects_layout.addLayout(render_row)
        self._objects_tab_index = self.tabs.addTab(objects_tab, "Object layers")

        # Tab 3 — Process log: surfaces the plugin's log in-dock.
        log_tab = QWidget()
        log_layout = QVBoxLayout(log_tab)
        log_layout.setContentsMargins(4, 4, 4, 4)
        self.log_view = QPlainTextEdit()
        self.log_view.setReadOnly(True)
        self.log_view.setMaximumBlockCount(2000)
        log_layout.addWidget(self.log_view, 1)
        self._log_tab_index = self.tabs.addTab(log_tab, "Process log")

        # Tab 4 — Auditor: audit a chosen folder or zip of cells against IHO
        # S-58 (ed. 8.0.0) and export a per-check pass/fail document. The tab is
        # self-contained — it stages and reads the source's .000 cells directly
        # via OGR (so it audits the source data, not the portrayal-processed
        # GeoPackage) and does not depend on the Charts-tab selection.
        self.auditor_panel = AuditorPanel(
            self.iface, os.path.dirname(__file__))
        self.tabs.addTab(self.auditor_panel, "Auditor")

        # Tab 5 — Source validation. Cross-checks ENC content against an
        # external source of truth (the authority's own AtoN / wreck / mark
        # registers) via user-built recipes. Like the Auditor it uses the shared
        # ENC source above the tabs; the source side is any point vector layer
        # QGIS already knows. v1: point geometry, a 0–5 m match buffer, and
        # enumerated attributes bound on human-readable labels.
        self.sourceval_panel = SourceValidationPanel(
            self.iface, os.path.dirname(__file__))
        self.tabs.addTab(self.sourceval_panel, "Source validation")

        # Both the Auditor and the Source Validator read the shared ENC source
        # above the tabs (no own picker), so they validate the same source as
        # the rest of the workflow.
        self.folder_edit.textChanged.connect(self.auditor_panel.set_source)
        self.auditor_panel.set_source(self.folder_edit.text())
        self.folder_edit.textChanged.connect(self.sourceval_panel.set_source)
        self.sourceval_panel.set_source(self.folder_edit.text())

        layout.addWidget(self.tabs, 1)
        layout.addWidget(_separator())

        # Status + progress
        self.status_label = QLabel("")
        self.status_label.setWordWrap(True)
        layout.addWidget(self.status_label)

        prog_row = QHBoxLayout()
        self.layer_prog_label = QLabel("Layers:")
        self.layer_prog_label.setFixedWidth(55)
        prog_row.addWidget(self.layer_prog_label)
        self.layer_progress = QProgressBar()
        self.layer_progress.setTextVisible(True)
        self.layer_progress.setFormat("%v / %m layers")
        prog_row.addWidget(self.layer_progress)
        layout.addLayout(prog_row)

        # Second bar: within-layer processing % (e.g. DEPARE fix/dissolve)
        sub_row = QHBoxLayout()
        self.sub_prog_label = QLabel("Step:")
        self.sub_prog_label.setFixedWidth(55)
        sub_row.addWidget(self.sub_prog_label)
        self.sub_progress = QProgressBar()
        self.sub_progress.setTextVisible(True)
        self.sub_progress.setRange(0, 100)
        self.sub_progress.setFormat("%p%")
        sub_row.addWidget(self.sub_progress)
        layout.addLayout(sub_row)

        self._set_progress_visible(False)

        # (The "Object layers → Project" load button now lives in the workflow
        # bar above the tabs, as the fourth workflow step.)

        # ── Vector-tile bake progress (bottom) ──────────────────────────────
        # Shows while the background bake for the Chart Viewer runs after a chart
        # scan; hidden otherwise. The viewer mirrors this bar if opened early.
        self.tile_status_label = QLabel("")
        self.tile_status_label.setStyleSheet("color: gray;")
        self.tile_status_label.setVisible(False)
        self.tile_progress = QProgressBar()
        self.tile_progress.setTextVisible(True)
        self.tile_progress.setVisible(False)
        layout.addWidget(self.tile_status_label)
        layout.addWidget(self.tile_progress)
        self.setWidget(container)

    # ── Slots ────────────────────────────────────────────────────────────────

    def _on_source_mode(self):
        """Update the placeholder text (and the Browse button's role) to match
        the selected source mode. In Remote mode the button opens the access
        settings (QGIS auth config + the insecure opt-in) instead of a picker."""
        mode = self.source_mode.currentData()
        if mode == "zip":
            self.folder_edit.setPlaceholderText("Select a .zip exchange set…")
        elif mode == "remote":
            self.folder_edit.setPlaceholderText(
                "https:// · ftps:// · ftpes:// · sftp:// URL of the exchange set…")
        else:
            self.folder_edit.setPlaceholderText(
                "Select a folder containing ENC files…")
        remote = mode == "remote"
        self.browse_btn.setText("Access…" if remote else "Browse…")
        self.browse_btn.setToolTip(
            "Credentials for remote sources, stored in the QGIS "
            "authentication database. Insecure connections (plain http/ftp) "
            "are refused — no exceptions." if remote else
            "Pick the folder or zip that holds your ENC exchange set "
            "(the .000 files and their updates).")

    def _browse(self):
        """Open the picker matching the current mode (Folder or Zip), or the
        access-settings dialog for a Remote source."""
        mode = self.source_mode.currentData()
        if mode == "remote":
            self._remote_access()
            return
        start = self.folder_edit.text() or os.path.expanduser("~")
        if mode == "zip":
            path, _ = QFileDialog.getOpenFileName(
                self, "Select ENC zip exchange set", start,
                "ENC exchange set (*.zip);;All files (*)"
            )
        else:
            path = QFileDialog.getExistingDirectory(
                self, "Select ENC folder", start
            )
        if path:
            self.folder_edit.setText(path)

    # ── Remote sources (STAGE 0) ─────────────────────────────────────────────
    def _remote_authcfg(self):
        """The persisted auth-config id for remote sources. The credentials
        themselves live in the encrypted QGIS auth database — we only keep
        the opaque config id."""
        from qgis.core import QgsSettings
        return QgsSettings().value("encoregis/remote_authcfg", "", type=str)

    def _remote_access(self):
        """The Remote access dialog: pick/create a QGIS authentication
        configuration (method Basic). Security is not configurable —
        insecure transports are simply refused."""
        from qgis.PyQt.QtWidgets import (QDialog, QVBoxLayout, QLabel,
                                         QDialogButtonBox)
        from qgis.core import QgsSettings
        try:
            from qgis.gui import QgsAuthConfigSelect
        except (ImportError, ValueError):
            QgsAuthConfigSelect = None
        dlg = QDialog(self)
        dlg.setWindowTitle("Remote source — access")
        lay = QVBoxLayout(dlg)
        lay.addWidget(QLabel(
            "Credentials — a <b>Basic</b> (username/password) configuration "
            "from the QGIS authentication database:"))
        sel = None
        if QgsAuthConfigSelect is not None:
            sel = QgsAuthConfigSelect(dlg)
            cur = self._remote_authcfg()
            if cur:
                sel.setConfigId(cur)
            lay.addWidget(sel)
        note = QLabel(
            "Connections are TLS-only: https://, ftps:// (implicit), "
            "ftpes:// (explicit), ftp:// (upgraded with AUTH TLS, refused if "
            "the server can't), sftp:// (system OpenSSH, key-based; the "
            "configuration above supplies the username). Certificates are "
            "always verified — plain http/ftp and unverified TLS are "
            "refused, with no override.")
        note.setWordWrap(True)
        lay.addWidget(note)
        bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        bb.accepted.connect(dlg.accept)
        bb.rejected.connect(dlg.reject)
        lay.addWidget(bb)
        if dlg.exec() == QDialog.Accepted and sel is not None:
            QgsSettings().setValue("encoregis/remote_authcfg", sel.configId())

    def source_path(self):
        """The LOCAL path of the current source: the folder/zip from the edit
        box, or — for a Remote source — the staged local copy of the URL
        (empty until its fetch has run)."""
        if self.source_mode.currentData() == "remote":
            return getattr(self, "_remote_local", "") or ""
        return self.folder_edit.text().strip()

    def _source_valid(self, src):
        """A source is valid if it's an existing folder or an existing .zip."""
        if not src:
            return False
        if os.path.isdir(src):
            return True
        if os.path.isfile(src) and src.lower().endswith(".zip"):
            return True
        return False

    # ── STAGE 1: scan for charts ───────────────────────────────────────────
    def _scan_charts(self):
        if self.source_mode.currentData() == "remote":
            self._start_remote_fetch(self.folder_edit.text().strip())
            return
        src = self.folder_edit.text().strip()
        if not self._source_valid(src):
            self._set_status("Please select a valid folder or .zip first.",
                             error=True)
            return
        self._remote_local = ""          # a local scan drops any staged remote
        self._begin_chart_scan(src)

    def _start_remote_fetch(self, url):
        """STAGE 0 (Remote mode): fetch the URL into the profile staging cache,
        then run the normal chart scan on the local copy. The URL is policy-
        checked up front so a refused scheme fails fast with a clear reason."""
        try:
            try:
                from .enc_remote import classify_url
            except (ImportError, ValueError):
                from enc_remote import classify_url
            classify_url(url)
        except Exception as e:                          # noqa: BLE001
            self._set_status(str(e), error=True)
            return
        self.scan_charts_btn.setEnabled(False)
        self._set_status("Fetching remote source…")
        self._worker = RemoteFetchWorker(url, self._remote_authcfg())
        self._worker.finished.connect(self._on_remote_done)
        self._worker.error.connect(self._on_remote_error)
        self._worker.status.connect(self._set_status)
        self._worker.start()

    def _on_remote_done(self, local, warns):
        self._remote_local = local
        if warns:
            self._append_validation_log(
                [{"level": "warning", "message": w} for w in warns])
        # The Auditor / Source Validator read the shared ENC source — hand
        # them the STAGED local copy of the remote URL.
        try:
            self.auditor_panel.set_source(local)
            self.sourceval_panel.set_source(local)
        except Exception:                               # noqa: BLE001
            pass
        self.scan_charts_btn.setEnabled(True)
        self._begin_chart_scan(local)

    def _on_remote_error(self, msg):
        self.scan_charts_btn.setEnabled(True)
        self._set_status(f"Remote fetch failed: {msg}", error=True)

    def _begin_chart_scan(self, src):
        # Reset downstream state — a fresh chart scan invalidates object layers.
        self._cleanup_staging()
        self.cleanup_tile_cache()        # drop any prior viewer tiles
        self._scan_status_msg = ""
        self.charts_list.clear()
        self.layer_tree.clear()
        self._layer_index = {}
        self._layer_index_s101 = {}
        self._charts_scanned = False
        self.scan_objects_btn.setEnabled(False)
        self.load_btn.setEnabled(False)
        self.scan_charts_btn.setEnabled(False)
        self.chart2tiles_btn.setEnabled(False)
        self._set_status("Scanning for charts…")
        self.tabs.setCurrentIndex(self._charts_tab_index)  # show Charts tab

        self._worker = ChartScanWorker(src)
        self._worker.finished.connect(self._on_charts_done)
        self._worker.error.connect(self._on_error)
        self._worker.status.connect(self._set_status)
        self._worker.start()

    def _on_charts_done(self, best_files, skipped, validation, staging):
        self._best_files   = best_files
        self._staging_dir  = staging
        self.scan_charts_btn.setEnabled(True)

        # Classify the source product so the same workflow routes S-101 to the
        # native engine (the S-57 path is unchanged). Name-based via the cell's
        # DSNM — the fixed NW-Europe S-101 FNC ("101"+producer) — falling back to
        # an authoritative DSID read for anything ambiguous.
        self._chart_products = {}
        for dsnm, p in best_files.items():
            self._chart_products[dsnm] = (
                "S-101" if dsnm.upper().startswith("101") else classify_cell(p))
        prods = set(self._chart_products.values())
        self._product = ("S-101" if prods == {"S-101"}
                         else "S-57" if prods == {"S-57"} or not prods
                         else "mixed")

        # Surface validation events in the Process log tab.
        self._append_validation_log(validation)

        n = len(best_files)
        if n == 0:
            self._set_status("No charts found in that source.", error=True)
            if skipped:
                self._log_skipped(skipped)
            return

        # Populate the Charts tab: one checkable row per winner, default checked.
        self.charts_list.clear()
        for dsnm in sorted(best_files.keys()):
            item = QListWidgetItem(dsnm)
            item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
            item.setCheckState(Qt.Checked)
            item.setData(Qt.UserRole, dsnm)
            self.charts_list.addItem(item)

        self._charts_scanned = True
        self.scan_objects_btn.setEnabled(True)

        # Auto-detect co-located S-102 bathymetry datasets (folder OR .zip) so
        # the Chart Viewer can offer them without the user doing anything.
        self._s102_files = []
        try:
            from .enc_s102 import datasets_from_source
            self._s102_files = datasets_from_source(self.source_path())
        except (ImportError, ValueError):
            try:
                from enc_s102 import datasets_from_source
                self._s102_files = datasets_from_source(self.source_path())
            except Exception:
                self._s102_files = []
        except Exception:
            self._s102_files = []

        # S-111 surface currents — presence detection for the dock S-100 controls.
        self._s111_files = []
        try:
            try:
                from .enc_s111 import datasets_from_source as _s111_dfs
            except (ImportError, ValueError):
                from enc_s111 import datasets_from_source as _s111_dfs
            self._s111_files = _s111_dfs(self.source_path())
        except Exception:                               # noqa: BLE001
            self._s111_files = []

        # S-104 water level — presence detection for the dock S-100 tidal engine.
        self._s104_files = []
        try:
            try:
                from .enc_s104 import datasets_from_source as _s104_dfs
            except (ImportError, ValueError):
                from enc_s104 import datasets_from_source as _s104_dfs
            self._s104_files = _s104_dfs(self.source_path())
        except Exception:                               # noqa: BLE001
            self._s104_files = []
        self._sync_s100_controls()

        # A stacked S-100 exchange set carries other Tier-1 products alongside the
        # S-101 charts (S-102 bathymetry, S-104 water level, S-111 currents). List
        # them in the chart overview so the user sees the whole set — but greyed
        # out and NOT selectable (full handling comes later); only the S-101/S-57
        # cells above stay checkable.
        self._append_tier1_product_rows()

        n_issues = sum(1 for v in validation if v.get("level") != "info")
        self._scan_status_msg = (
            f"Found {n} chart(s)"
            + (f" · {len(self._s102_files)} S-102 dataset(s)"
               if self._s102_files else "")
            + (f" · {len(skipped)} skipped" if skipped else "")
            + (f" · {n_issues} structure issue(s) — see Process log"
               if n_issues else "")
        )
        self._set_status(self._scan_status_msg)
        if skipped:
            self._log_skipped(skipped)

        # Tiles are NOT built automatically any more — baking is a deliberate step.
        # Enable the "chart2tiles" button so the user can start it when they want
        # (or reuse a matching saved tileset with no re-bake). Re-scanning a saved
        # set purely to run the extractor/validator/auditor therefore costs nothing.
        self.chart2tiles_btn.setEnabled(bool(best_files))

        # Rule 3: warn (non-blocking) if charts from DIFFERENT producers overlap in
        # the SAME usage band beyond a 100 m tolerance — the user resolves it by
        # unticking one of each pair. Same-producer overlaps are handled elsewhere
        # (staging dedup for same usage; the CATCOV=1 mosaic for different usages).
        try:
            self._warn_coverage_conflicts()
        except Exception as e:                          # noqa: BLE001
            QgsMessageLog.logMessage(
                f"ENCore GIS: coverage-conflict check skipped: {e}",
                "ENCore GIS", Qgis.Warning)

    def _warn_coverage_conflicts(self):
        """Detect + report cross-producer, same-usage-band coverage conflicts
        (CATCOV=1 overlap surviving a 100 m erosion). Non-blocking: lists the
        conflicting cells in the Process log + a status line; the user unticks."""
        best = self._best_files or {}
        if len(best) < 2:
            return
        try:
            from .enc_scanner import usage_band, read_dsid_full
            from . import enc_coverage
            from .enc_loader import _native_class_layer
        except (ImportError, ValueError):
            from enc_scanner import usage_band, read_dsid_full
            import enc_coverage
            from enc_loader import _native_class_layer

        # Pass 1 (cheap): usage band (from the cell name) + producing agency
        # (native DSID read) per cell. Only bands with >1 distinct producer can
        # conflict, so the expensive coverage read is limited to those.
        meta, band_agens = {}, {}
        for name, path in best.items():
            ub = usage_band(name)
            try:
                agen = (read_dsid_full(path).get("AGEN") or "").strip()
            except Exception:                           # noqa: BLE001
                agen = ""
            meta[name] = (ub, agen)
            band_agens.setdefault(ub, set()).add(agen)
        hot = {ub for ub, ags in band_agens.items()
               if len({a for a in ags if a}) > 1}
        if not hot:
            return

        # Pass 2: CATCOV=1 coverage (EPSG:4326) for the candidate cells only,
        # read natively (M_COVR); enc_coverage does the metric erode + overlap.
        # This only runs when >1 producer is present in a band (rare), so the
        # per-cell native read is bounded; pump the event loop to stay responsive.
        from qgis.PyQt.QtWidgets import QApplication
        self._set_status("Checking cross-producer coverage overlaps…")
        coverages = []
        for name, path in best.items():
            ub, agen = meta[name]
            if ub not in hot:
                continue
            QApplication.processEvents()
            try:
                lyr = _native_class_layer(path, "M_COVR")
                if lyr is None:
                    continue
                ci = lyr.fields().lookupField("CATCOV")
                rows = [(f.geometry().asWkt(),
                         f.attribute(ci) if ci >= 0 else None)
                        for f in lyr.getFeatures()
                        if f.geometry() is not None and not f.geometry().isEmpty()]
                wkt = enc_coverage.catcov1_union_wkt(rows)
                if wkt:
                    coverages.append({"cell": name, "usage": ub,
                                      "agen": agen, "wkt": wkt})
            except Exception:                           # noqa: BLE001
                continue

        conflicts = enc_coverage.find_conflicts(coverages)
        if not conflicts:
            return
        self.log_view.appendPlainText(
            f"\n⚠ {len(conflicts)} cross-producer coverage conflict(s) — same "
            "usage band, different producer, overlapping data (CATCOV=1):")
        for a, b, ub, ag_a, ag_b in conflicts:
            self.log_view.appendPlainText(
                f"   • U{ub}:  {a} (agency {ag_a})  ↔  {b} (agency {ag_b})")
        self.log_view.appendPlainText(
            "   Untick one of each pair from 'Charts to use' if you don't want "
            "both loaded/baked.")
        self._set_status(
            f"⚠ {len(conflicts)} overlapping chart(s) from different producers — "
            "see Process log.", error=True)

    def _chart2tiles(self):
        """Manual 'chart2tiles' step: bake the scanned charts into the Chart
        Viewer's vector-tile planes. If this exact chart set is already a SAVED
        tileset, reuse it (no re-bake); otherwise start the background bake. The
        resulting set shows in the Charts tab (open / save / delete there)."""
        if not self._best_files:
            self._set_status("Scan for charts first.", error=True)
            return
        chosen = self._checked_charts()                     # honour the tick boxes
        if not chosen:
            self._set_status("Tick at least one chart to build tiles.", error=True)
            return
        self.tabs.setCurrentIndex(self._charts_tab_index)   # show the tileset table
        self._tiled_names = sorted(chosen.keys())           # identity of THIS bake
        saved = self._find_saved_tileset(self._tiled_names)
        if saved is not None:
            self._adopt_saved_tileset(saved)
            self._set_status(
                f"Using saved tileset '{saved.get('name')}' — no re-bake.")
        else:
            self._start_tile_bake(list(chosen.values()))

    def _find_saved_tileset(self, cells):
        """A saved tileset whose cell set matches ``cells`` (sorted chart names),
        or None. Lets a re-scan reuse existing tiles instead of re-baking."""
        want = list(cells or [])
        if not want:
            return None
        for t in getattr(self, "_tilesets", []):
            if t.get("saved") and (t.get("cells") or []) == want \
                    and t.get("wet") and os.path.exists(t.get("wet") or ""):
                return t
        return None

    def _adopt_saved_tileset(self, entry):
        """Use an already-saved tileset for this scan (no bake): point the viewer's
        tile cache at it."""
        self.cleanup_tile_cache()                       # stop/clear any prior bake
        self._tile_cache = {"wet": entry.get("wet"), "dry": entry.get("dry"),
                            "decode": entry.get("decode")}
        self._set_tile_status(f" · using saved tiles '{entry.get('name')}' "
                              "(no re-bake)")
        QgsMessageLog.logMessage(
            f"ENCore GIS: scan matches saved tileset '{entry.get('name')}' — "
            "skipping tile bake", "ENCore GIS", Qgis.Info)

    # ── Background vector-tile bake (Chart Viewer) ─────────────────────────
    def _start_tile_bake(self, paths):
        """Kick off the background bake of the staged cells into the Chart
        Viewer's vector-tile planes. Ephemeral cache — replaced on rescan,
        deleted on unload."""
        self.cleanup_tile_cache()                       # drop any previous bake
        # The previous ephemeral (unsaved) set's temp dir is now gone — drop its
        # table row (saved sets live elsewhere and are kept).
        if any(not t.get("saved") for t in self._tilesets):
            self._tilesets = [t for t in self._tilesets if t.get("saved")]
            self._rebuild_tileset_table()
        if not paths:
            return
        try:
            import tempfile
            self._tile_cache_dir = tempfile.mkdtemp(prefix="enc_tilecache_")
            self._tile_worker = TileBakeWorker(paths, self._tile_cache_dir)
            self._tile_worker.finished.connect(self._on_tiles_done)
            self._tile_worker.error.connect(self._on_tiles_error)
            self._tile_worker.progress.connect(self._on_tile_progress)
            self._tile_worker.start()
            self.chart2tiles_btn.setEnabled(False)      # no overlapping bakes
            self._set_tile_status(" · building chart tiles…")
            self.tile_status_label.setText("Building vector tiles for portrayal…")
            self.tile_progress.setRange(0, 0)           # busy until first report
            self.tile_status_label.setVisible(True)
            self.tile_progress.setVisible(True)
        except Exception as e:                          # noqa: BLE001
            QgsMessageLog.logMessage(f"ENCore GIS: could not start tile bake: {e}",
                                     "ENCore GIS", Qgis.Warning)

    def _on_tile_progress(self, done, total, msg):
        """Drive the bottom tile-progress bar; also relayed to a viewer that is
        waiting for the bake (see ENCDockWidget._tile_progress_signal use)."""
        if total > 0:
            self.tile_progress.setRange(0, total)
            self.tile_progress.setValue(done)
        self.tile_status_label.setText(f"Building vector tiles for portrayal — {msg}")

    def _hide_tile_progress(self):
        self.tile_progress.setVisible(False)
        self.tile_status_label.setVisible(False)

    def _on_tiles_done(self, result):
        self._tile_cache = result or {}
        if self._best_files:
            self.chart2tiles_btn.setEnabled(True)       # allow a re-bake
        ready = [k for k, v in self._tile_cache.items() if v and k != "decode"]
        self._set_tile_status(" · chart tiles ready" if ready else "")
        self._hide_tile_progress()
        QgsMessageLog.logMessage(
            f"ENCore GIS: chart tiles ready ({', '.join(ready) or 'none'})",
            "ENCore GIS", Qgis.Info)
        # Add (or refresh) the EPHEMERAL row for this freshly-baked set.
        if ready and self._tile_cache_dir:
            try:
                from .enc_tilestore import dir_size
            except (ImportError, ValueError):
                from enc_tilestore import dir_size
            import time
            src = self.folder_edit.text().strip()
            default_name = (os.path.splitext(os.path.basename(src.rstrip("/\\")))[0]
                            or "tileset")[:10]
            self._tilesets = [t for t in self._tilesets if t.get("saved")]
            s102 = list(self._s102_files or [])
            s104 = list(getattr(self, "_s104_files", None) or [])
            s111 = list(getattr(self, "_s111_files", None) or [])
            self._tilesets.append({
                "name": default_name,
                "dir": self._tile_cache_dir,
                "saved": False,
                "wet": self._tile_cache.get("wet"),
                "dry": self._tile_cache.get("dry"),
                "decode": self._tile_cache.get("decode"),
                # ALL detected S-100 products travel with the set (copied on Save),
                # stored in a fixed order; the per-product COUNTS let Load slice the
                # coverage list back apart — robust even when files share a basename
                # (S-111 depth layers do), which a basename map cannot handle.
                "coverage": s102 + s104 + s111,
                "n_s102": len(s102), "n_s104": len(s104), "n_s111": len(s111),
                # identity for re-scan match = the cells actually baked (checked set)
                "cells": (getattr(self, "_tiled_names", None)
                          or sorted(self._best_files.keys())),
                "created": time.strftime("%Y-%m-%d %H:%M"),
                "size": dir_size(self._tile_cache_dir),
            })
            self._rebuild_tileset_table()

    def _on_tiles_error(self, msg):
        self._tile_cache = None
        if self._best_files:
            self.chart2tiles_btn.setEnabled(True)       # let the user retry
        self._set_tile_status("")
        self._hide_tile_progress()
        QgsMessageLog.logMessage(f"ENCore GIS: tile bake failed: {msg}",
                                 "ENCore GIS", Qgis.Warning)

    def _set_tile_status(self, suffix):
        """Append a tile-progress suffix to the last chart-scan status, so the
        bake state shows without clobbering the scan summary."""
        try:
            self._set_status((self._scan_status_msg or "") + suffix)
        except Exception:                               # noqa: BLE001
            pass

    def stop_tile_worker(self):
        """Cooperatively cancel + wait for the tile bake. Safe to call any time;
        the bake is QGIS-free so this never races QGIS teardown."""
        w = getattr(self, "_tile_worker", None)
        if w is not None:
            try:
                if w.isRunning():
                    w.request_cancel()
                    w.wait(10000)
            except RuntimeError:
                pass                                    # C++ QThread already gone
        self._tile_worker = None

    def cleanup_tile_cache(self):
        """Stop the bake and delete the ephemeral MBTiles dir. Two guards (our
        'enc_tilecache_' prefix AND under the system temp dir) so nothing else is
        ever removed. Called on rescan and on plugin unload."""
        self.stop_tile_worker()
        import tempfile
        import shutil
        d = getattr(self, "_tile_cache_dir", None)
        tmp_root = os.path.realpath(tempfile.gettempdir())
        if (d and os.path.isdir(d)
                and os.path.basename(d.rstrip("/\\")).startswith("enc_tilecache_")
                and os.path.realpath(d).startswith(tmp_root + os.sep)):
            try:
                shutil.rmtree(d, ignore_errors=True)
            except Exception:                           # noqa: BLE001
                pass
        self._tile_cache_dir = None
        self._tile_cache = None

    def _cleanup_staging(self):
        """Remove the previous staging temp dir, if any. Called at the start of
        a new chart scan (any prior load has long since copied data out to the
        GeoPackage, so the staged source files are safe to delete by then).

        Two guards, both of which must hold, so an in-place source folder can
        never be deleted: the directory's name carries our 'enc_explorer_stage_'
        prefix AND it physically lives under the system temp dir. For a clean
        folder the scanner returns the source folder itself to load in place —
        neither guard matches it."""
        import tempfile
        staged = self._staging_dir
        tmp_root = os.path.realpath(tempfile.gettempdir())
        if (staged and os.path.isdir(staged)
                and os.path.basename(staged.rstrip("/\\"))
            .startswith("enc_explorer_stage_")
                and os.path.realpath(staged).startswith(tmp_root + os.sep)):
            import shutil
            try:
                shutil.rmtree(staged, ignore_errors=True)
            except Exception:
                pass
        self._staging_dir = None

    def _checked_charts(self):
        """Return {dsnm -> path} for charts the user left checked."""
        chosen = {}
        for i in range(self.charts_list.count()):
            item = self.charts_list.item(i)
            if item.checkState() == Qt.Checked:
                dsnm = item.data(Qt.UserRole)
                if dsnm in self._best_files:
                    chosen[dsnm] = self._best_files[dsnm]
        return chosen

    def _charts_check_all(self):
        for i in range(self.charts_list.count()):
            it = self.charts_list.item(i)
            if it.flags() & Qt.ItemIsUserCheckable:      # skip greyed Tier-1 rows
                it.setCheckState(Qt.Checked)

    def _charts_check_none(self):
        for i in range(self.charts_list.count()):
            it = self.charts_list.item(i)
            if it.flags() & Qt.ItemIsUserCheckable:      # skip greyed Tier-1 rows
                it.setCheckState(Qt.Unchecked)

    # ── other S-100 Tier-1 products in a stacked set (listed, not selectable) ──
    _TIER1_OTHERS = (
        ("S-102", "bathymetric surface", "_s102_files"),
        ("S-104", "water level",         "_s104_files"),
        ("S-111", "surface currents",    "_s111_files"),
    )

    @staticmethod
    def _disambiguate_paths(paths):
        """Basenames for a set of dataset paths; when two share a basename (e.g.
        the S-111 depth layers 0-5/0-10/0-15 with an identical file name) fall
        back to ``parent/basename`` so each row is distinct."""
        bases = [os.path.basename(p) for p in paths]
        if len(set(bases)) == len(bases):
            return bases
        out = []
        for p in paths:
            parent = os.path.basename(os.path.dirname(p))
            b = os.path.basename(p)
            out.append(f"{parent}/{b}" if parent else b)
        return out

    def _append_tier1_product_rows(self):
        """Append greyed, non-selectable rows to the chart overview for the other
        S-100 Tier-1 products found in the source (S-102/S-104/S-111). Purely
        informational for now — no checkbox, disabled flags, skipped by
        ``_checked_charts`` (UserRole is None, not a ``_best_files`` key) and by
        the check-all/none helpers (no ItemIsUserCheckable flag). No-op when the
        source carries none of them."""
        for code, desc, attr in self._TIER1_OTHERS:
            paths = getattr(self, attr, None) or []
            if not paths:
                continue
            for lbl in self._disambiguate_paths(paths):
                item = QListWidgetItem(
                    f"{code}  ·  {lbl}   — {desc} (listed only)")
                item.setFlags(Qt.NoItemFlags)            # greyed + non-interactive
                item.setToolTip(
                    f"{code} {desc} dataset — shown for reference; not yet "
                    f"selectable in the viewer.")
                item.setData(Qt.UserRole, None)          # not a chart
                self.charts_list.addItem(item)

    # ── STAGE 2: scan for object layers (only checked charts) ──────────────
    def _scan_objects(self):
        if not self._charts_scanned:
            self._set_status("Scan for charts first.", error=True)
            return
        chosen = self._checked_charts()
        if not chosen:
            self._set_status("No charts checked — check at least one chart.",
                             error=True)
            return
        self.layer_tree.clear()
        self.load_btn.setEnabled(False)
        self.scan_objects_btn.setEnabled(False)
        self._set_status(f"Scanning {len(chosen)} chart(s) for object layers…")

        self._worker = ObjectScanWorker(
            chosen,
            product=getattr(self, "_product", "S-57"),
            chart_products=getattr(self, "_chart_products", {}))
        self._worker.finished.connect(self._on_objects_done)
        self._worker.error.connect(self._on_error)
        self._worker.status.connect(self._set_status)
        self._worker.start()

    def _on_objects_done(self, result):
        # result: { "S-57": layer_index, "S-101": layer_index } — either or both
        # present. Keep the two indexes apart: S-57 leaves carry (usage, code)
        # and load via the GDAL-free S-57 loader; S-101 leaves carry
        # ("S101", ftype, band) and load natively. A hybrid set shows both roots.
        self._layer_index = result.get("S-57", {})
        self._layer_index_s101 = result.get("S-101", {})
        self.scan_objects_btn.setEnabled(True)

        n_layers = len(self._layer_index) + len(self._layer_index_s101)
        if n_layers == 0:
            self._set_status("No object layers found in the checked charts.",
                             error=True)
            return

        # Build one tree carrying whichever product(s) the scan produced. Clear
        # once, then let each populate routine append its product root.
        self.layer_tree.clear()
        if self._layer_index:
            self._populate_layer_list(self._layer_index, clear=False)
        if self._layer_index_s101:
            self._populate_layer_list_s101(self._layer_index_s101, clear=False)
        self.tabs.setCurrentIndex(self._objects_tab_index)  # show Object layers tab
        self._set_status(f"Found {n_layers} object layer(s) across "
                         f"{len(self._checked_charts())} chart(s).")
        self.load_btn.setEnabled(True)

        # Hand the FULL scan result to the Source Validator's ENC selector, so it
        # lists every object layer the scan found (per usage band) — not just the
        # ones loaded into the project. The validator native-reads the cells for
        # whatever the user picks, so no load is required to validate.
        if hasattr(self, "sourceval_panel"):
            try:
                self.sourceval_panel.set_scan_manifest(
                    self._object_scan_manifest())
            except Exception as e:                    # noqa: BLE001
                self._log(f"Could not build validator scan manifest: {e}")

    # AtoN body classes consolidated under one "Aids to Navigation" entry
    # (topmarks fold in too but aren't separate validation targets).
    _ATON_BODIES = {"BOYLAT", "BOYCAR", "BOYSAW", "BOYSPP",
                    "BCNLAT", "BCNCAR", "BCNSAW", "BCNSPP", "BCNISD"}

    def _object_scan_manifest(self):
        """Mirror the object-layer tree as a flat list for the validator: one
        entry per object layer found, per usage band. Each entry carries the
        S-57 class code(s) and the cell paths to native-read.

        Returns [ {label, usage, codes:[acronym…], cells:[path…]} ] sorted by
        usage band (overview→berthing) then name."""
        li = self._layer_index or {}
        usage_codes = {}     # ub -> {code -> [paths]}
        for code, paths in li.items():
            for p in paths:
                cell = os.path.splitext(os.path.basename(p))[0]
                ub = usage_band(cell)
                usage_codes.setdefault(ub, {}).setdefault(code, []).append(p)

        from .enc_config import get_config
        cfg = get_config()
        entries = []
        for ub in sorted(usage_codes, reverse=True):
            codes = usage_codes[ub]
            ulabel = usage_label(ub)
            folded = {c for c in codes
                      if c.upper() in self._ATON_BODIES or c.upper() == "TOPMAR"}
            bodies = {c for c in codes if c.upper() in self._ATON_BODIES}
            if bodies:
                cells = sorted({p for c in bodies for p in codes[c]})
                entries.append({
                    "label": f"U{ub} {ulabel} — Aids to Navigation",
                    "display": "Aids to Navigation",   # matches the loaded layer
                    "usage": ub,
                    "codes": sorted({c.upper() for c in bodies}),
                    "cells": cells})
            for code in sorted(codes):
                cu = code.upper()
                if cu.startswith(("M_", "C_")) or code in folded:
                    continue          # meta/collection + folded AtoN members
                disp = cfg.display_name(code)
                entries.append({
                    "label": f"U{ub} {ulabel} — {disp} ({code})",
                    "display": disp,
                    "usage": ub,
                    "codes": [cu],
                    "cells": sorted(set(codes[code]))})
        return entries

    def _append_validation_log(self, validation):
        """Write staging/validation events into the Process log tab. If any are
        warnings/errors, mark the tab so the user notices."""
        if not validation:
            return
        has_issue = False
        for v in validation:
            level = v.get("level", "info")
            prefix = {"info": "·", "warning": "⚠", "error": "✗"}.get(level, "·")
            self.log_view.appendPlainText(f"{prefix} {v.get('message', '')}")
            if level != "info":
                has_issue = True
        # Mark the Process log tab when something needs attention.
        self.tabs.setTabText(
            self._log_tab_index,
            "Process log ⚠" if has_issue else "Process log")

    def _populate_layer_list_s101(self, layer_index, clear=True):
        """Fill the Object layers tree for an S-101 source, mirroring the S-57
        structure: S-101 → scale band → thematic group → feature type.

        Scale band is S-101's usage-band equivalent, read from the FNC
        (101+producer+band); the thematic groups are the SAME themes as S-57
        (Skin of the Earth, Navigation, …) via s101.grouping. A feature type can
        occur in cells of several scale bands, so it appears under each band it is
        present in; a leaf carries ('S101', feature_type, band) so Load fetches
        only that band's cells of that type."""
        from .s101.grouping import (s101_theme, s101_scale_band,
                                    s101_scale_band_label, THEME_ORDER,
                                    is_s101_aton_structure)
        if clear:
            self.layer_tree.clear()
        bold = QFont()
        bold.setBold(True)
        group_font = QFont()
        group_font.setItalic(True)

        # Bucket: {band -> {theme -> {ftype -> cell_count}}}
        bands = {}
        for ftype, paths in layer_index.items():
            theme = s101_theme(ftype)
            for p in paths:
                band = s101_scale_band(os.path.basename(p))
                (bands.setdefault(band, {}).setdefault(theme, {})
                 .setdefault(ftype, 0))
                bands[band][theme][ftype] += 1

        product_root = QTreeWidgetItem(self.layer_tree, ["S-101"])
        product_root.setFont(0, bold)
        product_root.setFlags(Qt.ItemIsEnabled)
        product_root.setData(0, Qt.UserRole, None)
        product_root.setExpanded(True)

        # Scale bands: larger band number = larger scale (more detail) on top,
        # matching the S-57 berthing-first ordering.
        def _band_key(b):
            return -1 if b is None else b
        for band in sorted(bands.keys(), key=_band_key, reverse=True):
            band_node = QTreeWidgetItem(product_root,
                                        [s101_scale_band_label(band)])
            band_node.setFont(0, bold)
            band_node.setFlags(Qt.ItemIsEnabled)
            band_node.setData(0, Qt.UserRole, None)
            # Collapsed by default: the scan opens to a clean S-101 → scale-band
            # overview; the user expands a band to drill into themes/types.
            band_node.setExpanded(False)

            themes = bands[band]
            present = {ft for th in themes.values() for ft in th}

            # ── Hybrid layers (per scale band), mirroring the S-57 tree: the
            #    synthesized ENC Coverage + Aids-to-Navigation entries, shown
            #    only when their source features are present in this band.
            want_cov = "DataCoverage" in present
            want_aton = any(is_s101_aton_structure(ft) for ft in present)
            if want_cov or want_aton:
                hyb = QTreeWidgetItem(band_node, ["Hybrid layers"])
                hyb.setFont(0, group_font)
                hyb.setFlags(Qt.ItemIsEnabled)
                hyb.setData(0, Qt.UserRole, None)
                hyb.setExpanded(True)
                if want_cov:
                    cov = QTreeWidgetItem(hyb,
                                          ["ENC Coverage  [DataCoverage + DSID]"])
                    cov.setData(0, Qt.UserRole, ("S101", "__coverage__", band))
                    cov.setToolTip(0,
                                   "One coverage polygon per chart (DataCoverage) with DSID "
                                   "metadata (edition, issue date, title, scale) for this "
                                   "scale band.")
                if want_aton:
                    aton = QTreeWidgetItem(
                        hyb, ["Aids to Navigation  [buoys + beacons + equipment]"])
                    aton.setData(0, Qt.UserRole, ("S101", "__aton__", band))
                    aton.setToolTip(0,
                                    "Merged buoys/beacons with their lights, fog signals, "
                                    "topmarks and racons folded on via the S-101 "
                                    "StructureEquipment association.")

            for theme in THEME_ORDER:
                if theme not in themes:
                    continue
                theme_node = QTreeWidgetItem(band_node, [theme])
                theme_node.setFont(0, group_font)
                theme_node.setFlags(Qt.ItemIsEnabled)
                theme_node.setData(0, Qt.UserRole, None)
                theme_node.setExpanded(True)
                for ftype in sorted(themes[theme].keys(), key=str):
                    n = themes[theme][ftype]
                    leaf = QTreeWidgetItem(theme_node,
                                           [f"{ftype}  [{n} cell(s)]"])
                    leaf.setData(0, Qt.UserRole, ("S101", ftype, band))

    def _populate_layer_list(self, layer_index, clear=True):
        """Fill the Objectlayers TREE, three levels deep:
        usage band → config group → object layers.

        Usage is the primary grouping (Harbour, Approach, …). Within each usage,
        the enc_layers.xml config groups (Depths, Cultural features, …) organise
        the object layers — the same grouping users found clearer than a flat
        per-usage list. An ENC Coverage entry appears under a "Hybrid layers"
        group within each usage that has M_COVR. Selecting a layer leaf marks it
        for load; the loader splits by usage automatically, so a leaf's code is
        all that's needed (display-only grouping — option (a)).
        """
        cfg = get_config()
        if clear:
            self.layer_tree.clear()

        # Build {usage_band -> {code -> count}} by deriving usage from each
        # source path's cell name.
        usage_codes = {}   # ub -> {code -> count}
        for code, paths in layer_index.items():
            for p in paths:
                cell = os.path.splitext(os.path.basename(p))[0]
                ub = usage_band(cell)
                usage_codes.setdefault(ub, {}).setdefault(code, 0)
                usage_codes[ub][code] += 1

        bold = QFont()
        bold.setBold(True)
        group_font = QFont()
        group_font.setItalic(True)

        # Product prefix: S-57 usage bands hang under one "S-57" top node (so the
        # tree reads S-57 → usage band → theme → layer, the mirror of the S-101
        # tree's S-101 → scale band → theme → feature type).
        product_root = QTreeWidgetItem(self.layer_tree, ["S-57"])
        product_root.setFont(0, bold)
        product_root.setFlags(Qt.ItemIsEnabled)
        product_root.setData(0, Qt.UserRole, None)
        product_root.setExpanded(True)

        for ub in sorted(usage_codes.keys(), reverse=True):
            codes = usage_codes[ub]            # {code -> count} present here
            available = set(codes.keys())
            label = usage_label(ub)

            usage_node = QTreeWidgetItem(product_root, [f"U{ub} — {label}"])
            usage_node.setFont(0, bold)
            usage_node.setFlags(Qt.ItemIsEnabled)      # parent not selectable
            usage_node.setData(0, Qt.UserRole, None)
            # Collapsed by default: the scan opens to a clean S-57 → usage-band
            # overview; the user expands a usage to drill into themes/layers.
            usage_node.setExpanded(False)

            shown = set()

            # ── Hybrid layers group (ENC Coverage), if M_COVR present here.
            if "M_COVR" in available:
                hyb = QTreeWidgetItem(usage_node, ["Hybrid layers"])
                hyb.setFont(0, group_font)
                hyb.setFlags(Qt.ItemIsEnabled)
                hyb.setData(0, Qt.UserRole, None)
                hyb.setExpanded(True)
                cov = QTreeWidgetItem(hyb, ["ENC Coverage  [M_COVR + DSID]"])
                # Leaf carries (usage_band, sentinel) so coverage is selected
                # per-usage, exactly like a normal object layer.
                cov.setData(0, Qt.UserRole, (ub, self.COVERAGE_SENTINEL))
                cov.setToolTip(0,
                               "One coverage polygon per chart (M_COVR, CATCOV=1) with DSID "
                               "metadata (EDTN, ISDT, DSNM, AGEN, CSCL, …) for this usage.")

            # ── Merged Aids-to-Navigation composite. If any floating buoy or
            #    fixed beacon class, or TOPMAR, is present, offer ONE "Aids to
            #    Navigation" leaf that loads the merged AtoN layer (buoy/beacon
            #    bodies + co-located topmarks + buoy-lights consolidated onto one
            #    row). The individual member codes are then suppressed from their
            #    config group below, so the user picks the whole mark, not its
            #    constituent classes.
            aton_family = {"BOYLAT", "BOYCAR", "BOYSAW", "BOYSPP",
                           "BCNLAT", "BCNCAR", "BCNSAW", "BCNSPP", "BCNISD",
                           "TOPMAR"}
            # Match case-insensitively: Inland-ENC cells name buoys/topmarks in
            # lowercase (boylat, topmar) while the family list is the standard
            # uppercase. A cell uses one spelling or the other, so matching on
            # upper() offers (and later merges) Inland AtoN exactly like the
            # standard. present_aton keeps the ACTUAL-cased present names so the
            # "hide member codes" step below suppresses the right tree entries.
            _aton_fam_upper = {c.upper() for c in aton_family}
            present_aton = {a for a in available if a.upper() in _aton_fam_upper}
            if present_aton:
                hyb2 = QTreeWidgetItem(usage_node, ["Hybrid layers"]) \
                    if "M_COVR" not in available else hyb
                if "M_COVR" not in available:
                    hyb2.setFont(0, group_font)
                    hyb2.setFlags(Qt.ItemIsEnabled)
                    hyb2.setData(0, Qt.UserRole, None)
                    hyb2.setExpanded(True)
                aton = QTreeWidgetItem(hyb2, ["Aids to Navigation  [buoys + beacons + topmarks]"])
                aton.setData(0, Qt.UserRole, (ub, "AtoN"))
                aton.setToolTip(0,
                                "Merged floating buoys (BOYLAT/BOYCAR/BOYSAW/BOYSPP) and "
                                "fixed beacons (BCNLAT/BCNCAR/BCNSAW/BCNSPP/BCNISD) with "
                                "their co-located topmarks (TOPMAR) and buoy-lights "
                                "consolidated onto one row — combined symbology and maptip. "
                                "S57CLASS marks each row as its buoy or beacon class.")
                # Hide the member codes from their normal config group.
                shown |= present_aton

            # ── Config groups, in config order, only those with layers present.
            for group_name, group_codes in cfg.groups:
                present = [c for c in group_codes if c in available and c not in shown]
                if not present:
                    continue
                grp = QTreeWidgetItem(usage_node, [group_name])
                grp.setFont(0, group_font)
                grp.setFlags(Qt.ItemIsEnabled)
                grp.setData(0, Qt.UserRole, None)
                grp.setExpanded(True)
                for code in present:
                    n = codes[code]
                    display = cfg.display_name(code)
                    leaf = QTreeWidgetItem(grp, [f"{display}  [{code}]"])
                    # Leaf carries (usage_band, code) so selection is per-usage.
                    leaf.setData(0, Qt.UserRole, (ub, code))
                    leaf.setToolTip(0,
                                    f"{display} ({code}) — in {n} cell(s) at {label} usage")
                    shown.add(code)

            # ── Anything not in the config XML → an "Other" group.
            other = sorted(available - shown)
            if other:
                grp = QTreeWidgetItem(usage_node, ["Other"])
                grp.setFont(0, group_font)
                grp.setFlags(Qt.ItemIsEnabled)
                grp.setData(0, Qt.UserRole, None)
                grp.setExpanded(True)
                for code in other:
                    n = codes[code]
                    leaf = QTreeWidgetItem(grp, [code])
                    leaf.setData(0, Qt.UserRole, (ub, code))
                    leaf.setToolTip(0,
                                    f"{code} — in {n} cell(s) at {label} usage")

    def _enforce_depth_order(self, driver):
        """Keep shallow < safety < deep with at least a 0.5 m gap. The spin box
        the user just changed is authoritative; its neighbours are nudged the
        minimum needed (up when a lower contour rises into the next, down when an
        upper contour falls into the one below) rather than blocking the edit.
        A final bottom-up pass guarantees the gap even at the range floor."""
        if self._enforcing:
            return
        self._enforcing = True
        try:
            gap, lo, hi = 0.5, 0.0, 9999.0
            sh = self.shallow_spin.value()
            sa = self.safety_spin.value()
            dp = self.deep_spin.value()
            if driver == "shallow":
                sa = max(sa, sh + gap)
                dp = max(dp, sa + gap)
            elif driver == "safety":
                sh = min(sh, sa - gap)
                dp = max(dp, sa + gap)
            else:  # deep
                sa = min(sa, dp - gap)
                sh = min(sh, sa - gap)
            # clamp to range, then guarantee the gaps bottom-up
            sh = min(max(sh, lo), hi)
            sa = min(max(sa, sh + gap), hi)
            dp = min(max(dp, sa + gap), hi)
            for spin, val in ((self.shallow_spin, sh),
                              (self.safety_spin, sa),
                              (self.deep_spin, dp)):
                if abs(spin.value() - val) > 1e-9:
                    spin.blockSignals(True)
                    spin.setValue(val)
                    spin.blockSignals(False)
            self._write_project_variables()
        finally:
            self._enforcing = False

    def _write_project_variables(self):
        """Write the four depth/sector values to the project as variables.

        Called live whenever a spin box changes, and again at load time.
        Styling expressions reference these via @enc_safety_contour etc., so
        after writing we trigger a repaint to re-portray DEPARE layers live.
        """
        project = QgsProject.instance()
        values = {
            "enc_shallow_contour": self.shallow_spin.value(),
            "enc_safety_contour":  self.safety_spin.value(),
            "enc_deep_contour":    self.deep_spin.value(),
            "enc_sector_distance": int(self.sector_spin.value()),
            "enc_sounding_portrayal": int(self.sounding_combo.currentData()),
            "enc_extra_contour":    self.extra_spin.value(),
            "enc_extra_contour_on": 1 if self.extra_chk.isChecked() else 0,
        }
        for name, value in values.items():
            QgsExpressionContextUtils.setProjectVariable(project, name, value)

        # Live re-portrayal: repaint any layer whose renderer depends on these
        # variables (DEPARE depth bands), then refresh the canvas.
        try:
            for layer in project.mapLayers().values():
                layer.triggerRepaint()
            self.iface.mapCanvas().refresh()
        except Exception:
            pass

        # Drive the ECDIS Chart Viewer live too (its layers share this project's
        # expression context, so a repaint re-portrays them with the new values).
        viewer = getattr(self, "_ecdis_viewer", None)
        if viewer is not None:
            try:
                viewer.notify_chart_control_changed()
            except Exception:
                pass

    def _open_chart_viewer(self):
        """Open the ECDIS-style Chart Viewer over the staged cells, read with
        the native iso8211 engine. Wired live to the Chart Control above."""
        try:
            try:
                from .enc_chartview import EcdisViewer
            except (ImportError, ValueError):
                from enc_chartview import EcdisViewer
            EcdisViewer.open_for(self)
            # Bring the Chart Control tab forward — it hosts the live styling +
            # S-100 overlay controls that drive the tiled viewer the user just opened.
            try:
                self.tabs.setCurrentIndex(self._controls_tab_index)
            except (AttributeError, RuntimeError):
                pass
        except Exception as e:                          # noqa: BLE001
            self._set_status(f"Chart Viewer failed to open: {e}", error=True)

    # ── S-100 overlay controls (central; drive the Chart Viewer) ────────────
    @staticmethod
    def _s111_depth_label(path):
        import re
        # Depth range is encoded in a path folder (e.g. ".../0-5/file.h5"); the
        # depth files otherwise share a basename, so this folder is preserved
        # through zip extraction + saved-set copies (see enc_s111 / enc_tilestore).
        for part in (path or "").replace("\\", "/").split("/"):
            if re.fullmatch(r"\d+-\d+", part):
                return f"{part} m"
        return os.path.basename(path or "")[:18] or "layer"

    def _sync_s100_controls(self):
        """Enable/populate the dock's S-100 overlay controls from what the last
        scan detected (S-102 / S-111 presence)."""
        has102 = bool(getattr(self, "_s102_files", None))
        has111 = bool(getattr(self, "_s111_files", None))
        has104 = bool(getattr(self, "_s104_files", None))
        self.dk_s102_chk.setEnabled(has102)
        self.dk_s102_contour_chk.setEnabled(has102 and self.dk_s102_chk.isChecked())
        self.dk_s102_snd_chk.setEnabled(has102 and self.dk_s102_chk.isChecked())
        self.dk_s111_chk.setEnabled(has111)
        self.dk_s111_depth_combo.setEnabled(has111)
        self.dk_s104_chk.setEnabled(has104)
        self.dk_s111_depth_combo.blockSignals(True)
        self.dk_s111_depth_combo.clear()
        for p in (getattr(self, "_s111_files", None) or []):
            self.dk_s111_depth_combo.addItem(self._s111_depth_label(p))
        self.dk_s111_depth_combo.blockSignals(False)
        if not has102:
            self.dk_s102_chk.setChecked(False)
        if not has111:
            self.dk_s111_chk.setChecked(False)
        if not has104:
            self.dk_s104_chk.setChecked(False)

    def _on_dk_s102(self, checked):
        self.dk_s102_contour_chk.setEnabled(checked)
        self.dk_s102_snd_chk.setEnabled(checked)
        self._apply_s100_to_viewer()

    def _apply_s100_to_viewer(self, *_):
        """Push the dock's S-100 overlay state onto an open Chart Viewer (its own
        controls are hidden — the dock is the single control surface). Safe when no
        viewer is open."""
        v = getattr(self, "_ecdis_viewer", None)
        if v is None:
            return
        try:
            v.s102_chk.setChecked(self.dk_s102_chk.isChecked())
            v.s102_contour_chk.setChecked(self.dk_s102_contour_chk.isChecked())
            v.s102_snd_chk.setChecked(self.dk_s102_snd_chk.isChecked())
            di = self.dk_s111_depth_combo.currentIndex()
            if 0 <= di < v.s111_depth_combo.count():
                v.s111_depth_combo.setCurrentIndex(di)
            v.s111_chk.setChecked(self.dk_s111_chk.isChecked())
            v.s104_chk.setChecked(self.dk_s104_chk.isChecked())
        except (RuntimeError, AttributeError):
            pass                                        # viewer gone / not ready

    # ── Vector-tileset table (save / load / delete baked sets) ──────────────
    def _load_saved_tilesets(self):
        """Populate the table from the persistent library (keeping any current
        ephemeral row). Called at startup and after save/delete."""
        try:
            from .enc_tilestore import list_saved
        except (ImportError, ValueError):
            try:
                from enc_tilestore import list_saved
            except Exception:                           # noqa: BLE001
                return
        try:
            saved = list_saved()
        except Exception:                               # noqa: BLE001
            saved = []
        eph = [t for t in getattr(self, "_tilesets", []) if not t.get("saved")]
        self._tilesets = saved + eph
        self._rebuild_tileset_table()

    def _rebuild_tileset_table(self):
        try:
            from .enc_tilestore import human_size
        except (ImportError, ValueError):
            from enc_tilestore import human_size
        tbl = self.tileset_table
        self._tileset_updating = True
        try:
            tbl.setRowCount(0)
            for entry in self._tilesets:
                r = tbl.rowCount()
                tbl.insertRow(r)
                it = QTableWidgetItem(entry.get("name") or "")
                fnt = it.font()
                fnt.setBold(bool(entry.get("saved")))    # bold = saved
                it.setFont(fnt)
                it.setData(Qt.UserRole, id(entry))
                it.setToolTip("Double-click to rename.")
                tbl.setItem(r, 0, it)
                si = QTableWidgetItem(human_size(entry.get("size") or 0))
                si.setFlags(si.flags() & ~Qt.ItemIsEditable)
                tbl.setItem(r, 1, si)
                ci = QTableWidgetItem(entry.get("created") or "")
                ci.setFlags(ci.flags() & ~Qt.ItemIsEditable)
                tbl.setItem(r, 2, ci)
                save_b = QPushButton("Save")
                save_b.setEnabled(not entry.get("saved"))
                save_b.setToolTip("Save this tileset to the library so it can be "
                                  "loaded in a future session.")
                save_b.clicked.connect(lambda _c=False, e=entry: self._tileset_save(e))
                tbl.setCellWidget(r, 3, save_b)
                del_b = QPushButton("Delete")
                del_b.setToolTip("Delete this tileset and all its files "
                                 "(the original loaded charts are untouched).")
                del_b.clicked.connect(lambda _c=False, e=entry: self._tileset_delete(e))
                tbl.setCellWidget(r, 4, del_b)
                load_b = QPushButton("Load")
                load_b.setToolTip("Open the Chart Viewer with this tileset.")
                load_b.clicked.connect(lambda _c=False, e=entry: self._tileset_load(e))
                tbl.setCellWidget(r, 5, load_b)
                share_b = QPushButton("Share…")
                share_b.setEnabled(bool(entry.get("saved")))
                share_b.setToolTip(
                    "Export this saved tileset as a .enctileset.zip a "
                    "colleague can Import — baked once, loaded anywhere "
                    "without re-baking." if entry.get("saved") else
                    "Save the tileset to the library first, then share it.")
                share_b.clicked.connect(
                    lambda _c=False, e=entry: self._tileset_share(e))
                tbl.setCellWidget(r, 6, share_b)
        finally:
            self._tileset_updating = False

    def _on_tileset_name_edited(self, item):
        if self._tileset_updating or item.column() != 0:
            return
        eid = item.data(Qt.UserRole)
        for e in self._tilesets:
            if id(e) == eid:
                e["name"] = item.text().strip() or e.get("name")
                break

    def _tileset_save(self, entry):
        if entry.get("saved"):
            return
        try:
            from .enc_tilestore import save_set, dir_size
        except (ImportError, ValueError):
            from enc_tilestore import save_set, dir_size
        m = save_set(entry.get("name"), entry.get("dir"), entry.get("coverage"),
                     meta={"cells": entry.get("cells") or [],
                           "n_s102": int(entry.get("n_s102") or 0),
                           "n_s104": int(entry.get("n_s104") or 0),
                           "n_s111": int(entry.get("n_s111") or 0)})
        if not m:
            self._set_status("Tileset save failed — see the ENCore GIS log.",
                             error=True)
            return
        entry.update({"saved": True, "dir": m["dir"], "wet": m.get("wet"),
                      "dry": m.get("dry"), "decode": m.get("decode"),
                      "coverage": m.get("coverage") or [],
                      "cells": m.get("cells") or entry.get("cells") or [],
                      "created": m.get("created"), "size": dir_size(m["dir"])})
        self._rebuild_tileset_table()
        self._set_status(f"Tileset '{entry['name']}' saved to the library.")

    def _tileset_delete(self, entry):
        from qgis.PyQt.QtWidgets import QMessageBox
        nm = entry.get("name") or "this tileset"
        if entry.get("saved"):
            msg = (f"Delete the saved tileset '{nm}'?\n\nThis permanently removes "
                   "all its files (tiles + copied S-102/S-104/S-111). The original "
                   "loaded charts are NOT touched.")
        else:
            msg = f"Discard the current unsaved tiles for '{nm}'?"
        if QMessageBox.question(
                self, "Delete tileset", msg,
                QMessageBox.Yes | QMessageBox.No, QMessageBox.No) != QMessageBox.Yes:
            return
        if entry.get("saved"):
            try:
                from .enc_tilestore import delete_set
            except (ImportError, ValueError):
                from enc_tilestore import delete_set
            delete_set(entry.get("dir"))
        else:
            self.cleanup_tile_cache()                    # ephemeral → drop temp dir
        self._tilesets = [t for t in self._tilesets if id(t) != id(entry)]
        self._rebuild_tileset_table()
        self._set_status(f"Tileset '{nm}' deleted.")

    def _tileset_share(self, entry):
        """Export a SAVED tileset as a shareable .enctileset.zip (tiles +
        coverage + checksummed exchange manifest) after an explicit IP/notice
        confirmation — the sharer answers for the source ENCs' licence."""
        from qgis.PyQt.QtWidgets import QMessageBox
        if not entry.get("saved"):
            self._set_status("Save the tileset to the library first, "
                             "then share it.", error=True)
            return
        nm = entry.get("name") or "tileset"
        if QMessageBox.question(
                self, "Share tileset",
                f"Export '{nm}' as a shareable tileset file?\n\n"
                "NOT FOR NAVIGATION. Baked tiles are chart-derived data: "
                "YOU are responsible for the licence/permission of the "
                "source ENCs and for respecting the producers' ownership "
                "and IP when passing this file on.",
                QMessageBox.Yes | QMessageBox.No,
                QMessageBox.No) != QMessageBox.Yes:
            return
        try:
            from .enc_tilestore import export_set, _slug, human_size
        except (ImportError, ValueError):
            from enc_tilestore import export_set, _slug, human_size
        start = os.path.join(os.path.expanduser("~"),
                             f"{_slug(nm)}.enctileset.zip")
        path, _f = QFileDialog.getSaveFileName(
            self, "Share tileset", start,
            "ENCoreGIS tileset (*.enctileset.zip);;Zip (*.zip)")
        if not path:
            return
        try:
            export_set(entry.get("dir"), path, extra_meta={
                "name": nm,
                "cells": entry.get("cells") or [],
                "n_s102": int(entry.get("n_s102") or 0),
                "n_s104": int(entry.get("n_s104") or 0),
                "n_s111": int(entry.get("n_s111") or 0)})
        except Exception as e:                          # noqa: BLE001
            self._set_status(f"Share failed: {e}", error=True)
            return
        self._set_status(
            f"Tileset '{nm}' shared → {path} "
            f"({human_size(os.path.getsize(path))}).")

    def _tileset_import(self):
        """Import a shared .enctileset.zip from a local file."""
        path, _f = QFileDialog.getOpenFileName(
            self, "Import tileset", os.path.expanduser("~"),
            "ENCoreGIS tileset (*.enctileset.zip *.zip)")
        if path:
            self._import_tileset_file(path)

    def _import_tileset_file(self, path):
        """Shared import tail (file picker AND URL fetch land here): validate
        + install the container (checksums, safe paths, no pickle, compatible
        format/schema) and list it in the table like any saved set."""
        try:
            from .enc_tilestore import import_set, list_saved
        except (ImportError, ValueError):
            from enc_tilestore import import_set, list_saved
        try:
            dst = import_set(path)
        except Exception as e:                          # noqa: BLE001
            self._set_status(f"Import refused: {e}", error=True)
            return
        for m in list_saved():
            if (m.get("dir") == dst
                    and not any(t.get("dir") == dst for t in self._tilesets)):
                self._tilesets.append(m)
        self._rebuild_tileset_table()
        self._set_status(
            "Tileset imported into the library — press Load to open it. "
            "NOT FOR NAVIGATION.")

    def _tileset_import_url(self):
        """Fetch a shared tileset straight from a URL (TLS-only, the same
        security rules and QGIS-auth credentials as Remote sources) into the
        staging cache, then run the normal validated import on it."""
        from qgis.PyQt.QtWidgets import QInputDialog, QLineEdit
        url, ok = QInputDialog.getText(
            self, "Import tileset from URL",
            "URL of a .enctileset.zip (https:// · ftps:// · ftpes:// · "
            "ftp:// (TLS) · sftp://):\n"
            "Insecure transports are refused. Credentials come from the "
            "QGIS authentication database (set under the Remote source "
            "mode's Access… dialog).",
            QLineEdit.Normal, "")
        url = (url or "").strip()
        if not ok or not url:
            return
        try:
            try:
                from .enc_remote import classify_url
            except (ImportError, ValueError):
                from enc_remote import classify_url
            classify_url(url)
        except Exception as e:                          # noqa: BLE001
            self._set_status(str(e), error=True)
            return
        self.tileset_import_btn.setEnabled(False)
        self._set_status("Fetching tileset from URL…")
        self._tileshare_worker = RemoteFetchWorker(url, self._remote_authcfg())
        self._tileshare_worker.finished.connect(self._on_tileset_url_done)
        self._tileshare_worker.error.connect(self._on_tileset_url_error)
        self._tileshare_worker.status.connect(self._set_status)
        self._tileshare_worker.start()

    def _on_tileset_url_done(self, local, _warns):
        self.tileset_import_btn.setEnabled(True)
        if os.path.isdir(local):
            # a directory URL was mirrored: accept it only if it holds exactly
            # one tileset container — otherwise the user must point at the file
            hits = [os.path.join(r, fn)
                    for r, _d, fns in os.walk(local) for fn in fns
                    if fn.lower().endswith(".enctileset.zip")]
            if len(hits) != 1:
                self._set_status(
                    "The URL must point at one .enctileset.zip file "
                    f"(the fetched folder holds {len(hits)}).", error=True)
                return
            local = hits[0]
        self._import_tileset_file(local)

    def _on_tileset_url_error(self, msg):
        self.tileset_import_btn.setEnabled(True)
        self._set_status(f"Tileset fetch failed: {msg}", error=True)

    def _tileset_load(self, entry):
        """Open the Chart Viewer with a specific tileset (ephemeral or saved), no
        re-scan: point the dock's tile inputs at this set and reconstruct the
        staged-cell list from the decode cache (build_one_cell reads the cache, so
        the original .000 files need not still exist)."""
        if not (entry.get("wet") and entry.get("decode")
                and os.path.exists(entry["decode"])):
            self._set_status("This tileset is incomplete and cannot be loaded.",
                             error=True)
            return
        self._tile_cache = {"wet": entry.get("wet"), "dry": entry.get("dry"),
                            "decode": entry.get("decode")}
        try:
            try:
                from .enc_tile_bake import read_decode_cache
            except (ImportError, ValueError):
                from enc_tile_bake import read_decode_cache
            cache = read_decode_cache(entry["decode"]) or {}
            self._best_files = {os.path.basename(p): p for p in cache.keys()}
            self._chart_products = {n: "S-57" for n in self._best_files}
        except Exception as e:                          # noqa: BLE001
            self._set_status(f"Tileset load failed: {e}", error=True)
            return
        s102, s104, s111 = self._tileset_products(entry)
        self._s102_files = s102
        self._s104_files = s104
        self._s111_files = s111
        self._sync_s100_controls()          # light up the dock's S-100 tree
        self._open_chart_viewer()

    @staticmethod
    def _tileset_products(entry):
        """(s102, s104, s111) absolute paths for a tileset entry. The coverage list
        is stored in s102+s104+s111 order; per-product COUNTS slice it back apart —
        robust even when files share a basename (S-111 depth layers do), which a
        basename map cannot resolve. Falls back for older set formats."""
        cov = entry.get("coverage") or []
        n102, n104, n111 = (entry.get("n_s102"), entry.get("n_s104"),
                            entry.get("n_s111"))
        if n102 is not None or n104 is not None or n111 is not None:
            a, b, c = int(n102 or 0), int(n104 or 0), int(n111 or 0)
            return (cov[:a], cov[a:a + b], cov[a + b:a + b + c])
        # legacy: set saved with per-product basename lists
        by_base = {os.path.basename(p): p for p in cov}

        def pick(key):
            vals = entry.get(key)
            if vals is None:
                return None
            return [by_base.get(os.path.basename(v), v) for v in vals]

        s102, s104, s111 = pick("s102"), pick("s104"), pick("s111")
        if s102 is None and s104 is None and s111 is None:
            return (list(cov), [], [])      # oldest sets: coverage was S-102 only
        return (s102 or [], s104 or [], s111 or [])

    def _resolve_target_crs(self):
        """Resolve and validate the Load-CRS picker → an authid/WKT string, or
        None (with an error status set) if it is invalid or geographic. Our
        portrayal/processing assume PROJECTED metres (zoomLevel radii, line
        widths, soundings spacing), so a geographic (degrees) CRS is refused.
        Shared by the S-57, S-101 and hybrid load paths."""
        crs = self.crs_widget.crs()
        if not crs.isValid():
            self._set_status("Chosen CRS is invalid. Pick a projected CRS "
                             "(default EPSG:3857).", error=True)
            return None
        if crs.isGeographic():
            self._set_status(
                f"{crs.authid()} is a geographic (unprojected) CRS. ENC "
                "Explorer needs a projected CRS in metres — e.g. EPSG:3857 "
                "or a local UTM/national grid. Please choose another.",
                error=True)
            return None
        return crs.authid() or crs.toWkt()

    def _s57_best_files(self):
        """The S-57 subset of the deduplicated charts, so the S-57 loader (and
        its coverage builder) never sees an S-101 cell in a hybrid set — reading
        one as S-57 finds no M_COVR and only wastes work / risks errors."""
        cp = getattr(self, "_chart_products", {})
        return {n: p for n, p in (self._best_files or {}).items()
                if cp.get(n) != "S-101"}

    def _collect_s57_selection(self):
        """Selected S-57 leaves → (pairs, coverage_usages). Each S-57 leaf lives
        under a usage node and carries (usage_band, code) [or the coverage
        sentinel]; S-101 leaves (("S101", …)) are skipped so a hybrid tree's two
        roots don't cross-contaminate. Picking DEPARE under Usage 4 loads ONLY
        the Usage 4 DEPARE (per-(usage, layer) selection)."""
        pairs = []                # [(usage_band, code), …]
        coverage_usages = set()   # usage bands the user picked ENC Coverage for
        for item in self.layer_tree.selectedItems():
            val = item.data(0, Qt.UserRole)
            if not val or val[0] == "S101":
                continue          # group node (None) or an S-101 leaf
            ub, code = val
            if code == self.COVERAGE_SENTINEL:
                coverage_usages.add(ub)
            elif (ub, code) not in pairs:
                pairs.append((ub, code))
        return pairs, coverage_usages

    def _collect_s101_selection(self):
        """Selected S-101 leaves → [(feature_type, band)]. Leaves carry
        ('S101', feature_type, band); band is honoured so selecting a type under
        Scale band 4 loads only band-4 cells of it."""
        selected = []
        for item in self.layer_tree.selectedItems():
            val = item.data(0, Qt.UserRole)
            if val and isinstance(val, tuple) and val[0] == "S101":
                ftype = val[1]
                band = val[2] if len(val) > 2 else None
                selected.append((ftype, band))
        return selected

    def _load(self):
        # Route by product. A hybrid (mixed) set loads BOTH: native S-101 layers
        # plus the GDAL-free S-57 stack, from the one Load gesture.
        product = getattr(self, "_product", "S-57")
        if product == "mixed":
            return self._load_mixed()
        if product == "S-101":
            return self._load_s101()

        pairs, coverage_usages = self._collect_s57_selection()
        if not pairs and not coverage_usages:
            self._set_status("Please select at least one layer.", error=True)
            return
        target_crs = self._resolve_target_crs()
        if target_crs is None:
            return
        self._write_project_variables()
        self._start_s57_load(pairs, coverage_usages, target_crs)

    def _load_mixed(self):
        """Hybrid load: native S-101 feature types AND the S-57 layer stack from
        a single Load. S-101 builds in-memory layers on the main thread (run
        first, synchronously); S-57 runs in the LoadWorker thread (started
        after). Either side may be empty — the user might pick only one
        product's layers in a hybrid scan."""
        pairs, coverage_usages = self._collect_s57_selection()
        s101_sel = self._collect_s101_selection()
        if not pairs and not coverage_usages and not s101_sel:
            self._set_status("Please select at least one layer.", error=True)
            return
        target_crs = self._resolve_target_crs()
        if target_crs is None:
            return
        self._write_project_variables()

        if s101_sel:
            self._run_s101_load(s101_sel, target_crs)
        if pairs or coverage_usages:
            self._start_s57_load(pairs, coverage_usages, target_crs)

    def _start_s57_load(self, pairs, coverage_usages, target_crs):
        """Launch the background S-57 LoadWorker for the chosen (usage, code)
        pairs and coverage usages into target_crs. Coverage is built only from
        the S-57 charts (see _s57_best_files)."""
        want_coverage = bool(coverage_usages)
        n = len(pairs) + len(coverage_usages)

        # Pre-load render mode (Fast=False / Beautiful=True). Read at launch;
        # this load uses whatever is set now and won't change if toggled later.
        beautiful = bool(self.render_combo.currentData())
        mode_label = "Beautiful" if beautiful else "Fast"

        self._set_status(
            f"Loading {n} layer(s) into {target_crs} ({mode_label})…")

        self.layer_progress.setRange(0, n)
        self.layer_progress.setValue(0)
        self._set_progress_visible(True)
        self.load_btn.setEnabled(False)
        self.scan_charts_btn.setEnabled(False)
        self.scan_objects_btn.setEnabled(False)

        self._worker = LoadWorker(self._layer_index, pairs,
                                  target_crs=target_crs,
                                  want_coverage=want_coverage,
                                  coverage_usages=coverage_usages,
                                  best_files=self._s57_best_files(),
                                  beautiful=beautiful)
        self._worker.finished.connect(self._on_load_done)
        self._worker.error.connect(self._on_error)
        self._worker.status.connect(self._set_status)
        self._worker.progress.connect(self._on_progress)
        self._worker.subprogress.connect(self._on_subprogress)
        self._worker.start()

    def _load_s101(self):
        """Load the selected S-101 feature types' cells onto the map via the
        native S-101 loader. Whole-cell load in this first version: selecting a
        feature type loads the cell(s) that contain it (all their layers); the
        user can toggle layers in QGIS. Runs on the main thread — the loader
        creates in-memory layers and adds them to the project, both of which must
        happen on the main thread anyway."""
        selected = self._collect_s101_selection()
        if not selected:
            self._set_status("Please select at least one S-101 feature type.",
                             error=True)
            return
        target_crs = self._resolve_target_crs()
        if target_crs is None:
            return
        # Chart Control values current at load (same as the S-57 path).
        self._write_project_variables()
        self._run_s101_load(selected, target_crs)

    def _run_s101_load(self, selected, target_crs):
        """Resolve the selected leaves to backing cells (from the S-101 index) and
        load them natively into target_crs. Handles both ordinary feature types
        (whole-cell native load) and the two synthesized Hybrid-layer sentinels —
        '__coverage__' (ENC Coverage) and '__aton__' (Aids to Navigation) — which
        build per-scale-band synthesized layers. Used by the pure-S-101 and the
        hybrid load paths."""
        from .s101.grouping import s101_scale_band, S101_ATON_STRUCTURES
        from .s101.qgis_loader import (load_s101_cells, load_s101_coverage,
                                       load_s101_aton)
        index = self._layer_index_s101 or {}

        def cells_for(ftypes, band):
            """De-duplicated cell paths backing any of ``ftypes``, restricted to
            ``band`` (None = any band)."""
            out, seen = [], set()
            for ft in ftypes:
                for p in index.get(ft, []):
                    if (band is not None
                            and s101_scale_band(os.path.basename(p)) != band):
                        continue
                    if p not in seen:
                        seen.add(p)
                        out.append(p)
            return out

        cov_bands = sorted({b for ft, b in selected if ft == "__coverage__"},
                           key=lambda x: (x is None, x))
        aton_bands = sorted({b for ft, b in selected if ft == "__aton__"},
                            key=lambda x: (x is None, x))
        normal = [(ft, b) for ft, b in selected
                  if ft not in ("__coverage__", "__aton__")]

        self._set_status(f"Loading S-101 selection into {target_crs}…")
        built, msgs = 0, []
        try:
            # Ordinary feature types → whole-cell native load (band-honoured).
            if normal:
                ftypes = {ft for ft, _b in normal}
                paths, seen = [], set()
                for ft, band in normal:
                    for p in index.get(ft, []):
                        if (band is not None
                                and s101_scale_band(os.path.basename(p)) != band):
                            continue
                        if p not in seen:
                            seen.add(p)
                            paths.append(p)
                if paths:
                    layers = load_s101_cells(
                        paths, add_to_project=True,
                        feature_types=ftypes, target_crs=target_crs)
                    built += len(layers)
                    msgs.append(f"{len(ftypes)} feature type(s) "
                                f"from {len(paths)} cell(s)")

            # Hybrid: ENC Coverage, one layer per selected band.
            for b in cov_bands:
                cells = cells_for(["DataCoverage"], b)
                if not cells:
                    continue
                layers = load_s101_coverage(cells, target_crs=target_crs,
                                            add_to_project=True, band=b)
                built += len(layers)
                if layers:
                    msgs.append(f"coverage (band {b})")

            # Hybrid: Aids to Navigation, one merged layer per selected band.
            for b in aton_bands:
                cells = cells_for(S101_ATON_STRUCTURES, b)
                if not cells:
                    continue
                layers = load_s101_aton(cells, target_crs=target_crs,
                                        add_to_project=True, band=b)
                built += len(layers)
                if layers:
                    msgs.append(f"AtoN (band {b})")
        except Exception as e:                            # noqa: BLE001
            self._on_error(str(e))
            return

        if built == 0:
            self._set_status("No S-101 cells back the selected layers.",
                             error=True)
            return
        self._set_status(f"Loaded {built} S-101 layer(s) into {target_crs}: "
                         + "; ".join(msgs) + ".")

    def _on_progress(self, current, total):
        self.layer_progress.setRange(0, total)
        self.layer_progress.setValue(current)

    def _on_subprogress(self, percent, label):
        self.sub_progress.setValue(percent)
        if label:
            self.sub_prog_label.setText("Step:")
            self.sub_progress.setFormat(f"{label} %p%")
        else:
            self.sub_progress.setFormat("%p%")

    def _on_load_done(self, gpkg_path="", manifest=None):
        # The worker BUILT the layers (heavy I/O + processing); we now REGISTER
        # them here, on the main thread — the only safe place to add map layers
        # to the project and to let an empty project adopt the load CRS. This
        # is what fixes the intermittent "layers displaced against the basemap"
        # bug: the old code added layers from the worker thread, which raced
        # with QGIS's main-thread project-CRS setup.
        target = self._worker.target_crs if self._worker else None
        added = []
        try:
            added = register_layers(gpkg_path, manifest or [], target_crs=target)
        except Exception as e:
            self._set_status(f"Could not register layers: {e}", error=True)

        # Track this session's scratch GeoPackage so it can be cleaned up when
        # the plugin unloads (QGIS close / disable), to avoid accumulating temp
        # files across sessions.
        if gpkg_path:
            self.session_gpkgs.add(gpkg_path)
        self._set_progress_visible(False)
        self.load_btn.setEnabled(True)
        self.scan_charts_btn.setEnabled(True)
        self.scan_objects_btn.setEnabled(self._charts_scanned)
        self._set_status("All layers added to the project ✓")
        # Give the Source Validator (a) the loaded coverage polygons and (b) a
        # map from each object layer's (usage, display) to its loaded layer id,
        # so the validator can build recipes against — and read — the SAME hybrid
        # data model the user sees, instead of the catalogue's theoretical attrs.
        if hasattr(self, "sourceval_panel"):
            ids = [lyr.id() for lyr in (added or []) if lyr is not None]
            self.sourceval_panel.set_object_layers(ids)
            loaded_map = {}
            for lyr in (added or []):
                if lyr is None:
                    continue
                m = re.match(r"^U(\d+)\s*\|\s*\d+\s+(.+?)\s+\([^)]*\)\s*$",
                             lyr.name())
                if m:
                    loaded_map[(int(m.group(1)), m.group(2).strip())] = lyr.id()
            try:
                self.sourceval_panel.set_loaded_layer_map(loaded_map)
            except Exception as e:                    # noqa: BLE001
                self._log(f"Could not map loaded layers for validator: {e}")

    def _on_error(self, msg):
        self._set_progress_visible(False)
        self.scan_charts_btn.setEnabled(True)
        self.scan_objects_btn.setEnabled(self._charts_scanned)
        self.load_btn.setEnabled(True)
        self._set_status(f"Error: {msg}", error=True)
        QgsMessageLog.logMessage(f"ENCore GIS error: {msg}", "ENCore GIS", Qgis.Critical)

    def _select_all(self):
        # Select every selectable leaf (skip usage parent nodes).
        it = QTreeWidgetItemIterator(self.layer_tree)
        while it.value():
            node = it.value()
            if node.data(0, Qt.UserRole) is not None:
                node.setSelected(True)
            it += 1

    def _select_none(self):
        self.layer_tree.clearSelection()

    def stop_worker(self):
        """Stop any running background worker and WAIT for it before teardown.

        Fixes the QGIS-close crash (heap corruption / access violation): a
        LoadWorker building QgsVectorLayers off the GUI thread — whose ctor pulls
        the main-thread QgsProject / QgsStyle singletons via loadDefaultStyle —
        must not keep running while QGIS destroys those on shutdown. We request a
        cooperative cancel (bounds the wait to one layer) and wait(); we never
        terminate() a QThread, as that can corrupt state itself."""
        for attr in ("_worker", "_tileshare_worker"):
            w = getattr(self, attr, None)
            if w is None:
                continue
            try:
                if w.isRunning():
                    if hasattr(w, "request_cancel"):
                        w.request_cancel()
                    if not w.wait(30000):
                        QgsMessageLog.logMessage(
                            "ENCore GIS: background worker did not stop within "
                            "30 s on shutdown.", "ENCore GIS", Qgis.Warning)
            except RuntimeError:
                pass   # the underlying C++ QThread is already gone

    def cleanup_session_files(self):
        """Delete this session's scratch GeoPackages (and their -wal/-shm/journal
        sidecars). Called on plugin unload — QGIS close or plugin disable — so
        temp GPKGs don't accumulate on the user's disk across sessions.

        Note: these are scratch files in the system temp dir that loaded layers
        point at. Deleting them on close is safe for the view/stage workflow; a
        user who needs durable output should export to a permanent GeoPackage
        (a separate, future feature). We only delete files THIS session created.
        """
        for gpkg in list(self.session_gpkgs):
            for suffix in ("", "-wal", "-shm", "-journal"):
                path = gpkg + suffix
                try:
                    if os.path.exists(path):
                        os.remove(path)
                except Exception as e:
                    QgsMessageLog.logMessage(
                        f"ENCore GIS: could not remove {path}: {e}",
                        "ENCore GIS", Qgis.Warning)
        self.session_gpkgs.clear()

    def _set_status(self, msg, error=False):
        color = "red" if error else "#333333"
        self.status_label.setText(msg)
        self.status_label.setStyleSheet(f"color: {color};")

    def _set_progress_visible(self, visible):
        self.layer_progress.setVisible(visible)
        self.layer_prog_label.setVisible(visible)
        self.sub_progress.setVisible(visible)
        self.sub_prog_label.setVisible(visible)
        if not visible:
            self.sub_progress.setValue(0)
            self.sub_progress.setFormat("%p%")

    def _log_skipped(self, skipped):
        QgsMessageLog.logMessage(
            "ENCore GIS – skipped files:\n" +
            "\n".join(f"  {p}: {r}" for p, r in skipped),
            "ENCore GIS", Qgis.Warning
        )


def _separator():
    sep = QFrame()
    sep.setFrameShape(QFrame.HLine)
    sep.setFrameShadow(QFrame.Sunken)
    return sep
