# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
sourceval_ui.py — the Source validation tab (Qt / QGIS).
================================================================================

The GUI for the Source Validator. Mirrors the Auditor panel's shape — a shared
ENC source above the tabs, a worker thread, a results tree, and the same
export / flags-layer actions — and adds the recipe builder.

v1 scope (agreed): point geometry only; a per-recipe buffer (0–5 m) is the
proximity tolerance; sources are picked from layers QGIS already knows;
enumerated attributes bind on human-readable labels (the value-map dialog).

All heavy/GDAL/native work is deferred into the worker; importing this module at
plugin load stays light. The pure-Python engine it drives (the ``sourceval``
package) is headless-tested; this Qt layer is verified by running it in QGIS.
"""

import os
from datetime import datetime


def _documents_dir():
    """The user's native Documents folder, as the default location for recipe
    and report file dialogs. On strict-policy machines many folders are not
    writable, but Documents almost always is. Falls back to home, then cwd."""
    try:
        from qgis.PyQt.QtCore import QStandardPaths
        for loc in (QStandardPaths.DocumentsLocation,
                    QStandardPaths.HomeLocation):
            path = QStandardPaths.writableLocation(loc)
            if path and os.path.isdir(path):
                return path
    except Exception:                                 # noqa: BLE001
        pass
    return os.path.expanduser("~") or os.getcwd()


def _alive(obj):
    """True if a wrapped Qt/C++ object is still valid. QGIS can delete the
    underlying QgsVectorLayer (e.g. the user removed it) while a Python wrapper
    lingers; touching it then raises RuntimeError. Guard with this."""
    if obj is None:
        return False
    try:
        from qgis.PyQt import sip
        if sip.isdeleted(obj):
            return False
    except Exception:                                 # noqa: BLE001
        pass
    try:
        obj.isValid()                                 # raises if C++ gone
    except RuntimeError:
        return False
    except Exception:                                 # noqa: BLE001
        return True
    return True


def _foid_from_lnam(lnam):
    """Parse a 16-hex S-57 Long Name back into (AGEN, FIDN, FIDS) so a loaded
    hybrid layer carrying an LNAM field reconciles with the same identity a
    native read produces. None if absent or malformed."""
    if not lnam:
        return None
    s = str(lnam).strip()
    if len(s) != 16:
        return None
    try:
        return (int(s[0:4], 16), int(s[4:12], 16), int(s[12:16], 16))
    except ValueError:
        return None


def _qgis_user():
    """The current QGIS/OS user login name, for the filename convention. Tries
    the QGIS account name (the @user_account_name expression variable), then the
    global expression scope, then the OS — so it works headless too."""
    try:
        from qgis.core import QgsApplication
        name = QgsApplication.userLoginName()
        if name:
            return name
    except Exception:                                 # noqa: BLE001
        pass
    try:
        from qgis.core import QgsExpressionContextUtils
        name = QgsExpressionContextUtils.globalScope().variable(
            "user_account_name")
        if name:
            return str(name)
    except Exception:                                 # noqa: BLE001
        pass
    try:
        import getpass
        return getpass.getuser()
    except Exception:                                 # noqa: BLE001
        return "user"


def export_basename(process, source_path):
    """Build the export/recipe stem per the filename convention:
    ``yyyymmdd_user_process_source`` — the source clipped to 10 characters and
    every part reduced to filesystem-safe characters. ``process`` is e.g.
    "sourcevalidator", "auditor", or "recipe"; ``source_path`` is the chart
    source folder/zip (or any name to use as the source token)."""
    from .sourceval.naming import build_filename, source_stem
    date = datetime.now().strftime("%Y%m%d")
    return build_filename(date, _qgis_user(), process,
                          source_stem(source_path))


def _open_file(path):
    """Open a saved file in the OS default handler (browser for HTML)."""
    try:
        from qgis.PyQt.QtGui import QDesktopServices
        from qgis.PyQt.QtCore import QUrl
        QDesktopServices.openUrl(QUrl.fromLocalFile(path))
    except Exception:                                 # noqa: BLE001
        pass


from qgis.PyQt.QtCore import Qt, QThread, pyqtSignal, QVariant
from qgis.PyQt.QtWidgets import (
    QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QComboBox,
    QDoubleSpinBox, QTableWidget, QTableWidgetItem, QTreeWidget,
    QTreeWidgetItem, QFileDialog, QGroupBox, QFormLayout, QDialog,
    QDialogButtonBox, QHeaderView, QAbstractItemView, QMessageBox,
    QListWidget, QListWidgetItem, QMenu, QSizePolicy,
)
from qgis.PyQt.QtGui import QColor, QBrush
from qgis.core import (
    QgsMessageLog, Qgis, QgsProject, QgsVectorLayer, QgsWkbTypes,
    QgsField, QgsFeature, QgsGeometry,
)


def _log(msg, level=Qgis.Info):
    QgsMessageLog.logMessage(msg, "ENCore GIS — SourceVal", level)


# ---------------------------------------------------------------------------
# Worker: stage the ENC source, extract chart points, reconcile against source
# ---------------------------------------------------------------------------
class SourceValWorker(QThread):
    """Off-thread run. The source features are materialised on the UI thread and
    passed in (``source_features``) so this thread touches only the ENC side and
    the pure-Python reconcile."""

    finished = pyqtSignal(object, object)        # report, context
    failed = pyqtSignal(str)
    progress = pyqtSignal(str)

    def __init__(self, source, catalogue_dir, recipe, source_features,
                 chart_features=None, coverage=None,
                 native_codes=None, native_cells=None, product="S-57",
                 coverage_cells=None):
        super().__init__()
        self.source = source
        self.catalogue_dir = catalogue_dir
        self.recipe = recipe
        self.source_features = source_features
        self.chart_features = chart_features      # set → pre-read ENC mode
        self.coverage = coverage                  # prepared coverage polygons
        self.native_codes = native_codes or []    # S-57 acr / S-101 type_name
        self.native_cells = native_cells or []    # cell paths to read
        self.product = product                    # "S-57" | "S-101"
        # [(path, product)] to native-read the footprint from when no ENC
        # Coverage layer is loaded — so a run needs no layers loaded first.
        self.coverage_cells = coverage_cells or []

    def run(self):
        try:
            from .validation import Catalogue
            from .sourceval import (reconcile, reconcile_lines,
                                    reconcile_areas, extract_points,
                                    extract_lines, extract_areas)
            catalogue = Catalogue(self.catalogue_dir)
            context = {"source": self.source, "cells": []}
            # LINE recipes reconcile with the generalization dead-band
            # (geomatch); AREA recipes with the coverage-clipped symmetric
            # difference (areamatch); points keep the v1 flow.
            kind = getattr(self.recipe, "geometry", "point")
            extract = {"line": extract_lines,
                       "area": extract_areas}.get(kind, extract_points)

            if self.chart_features is not None:
                # ENC side already read (pre-staged) — used by tests.
                chart = self.chart_features
            elif self.native_codes and self.native_cells and \
                    self.product == "S-101":
                # Native S-101 read: parse each cell once, extract the chosen
                # feature type(s) at the recipe geometry. Attributes keyed by
                # their S-101 names (the loaded layer's data model).
                from .sourceval import enc_features_s101 as s101x
                self.progress.emit(
                    f"Reading {len(self.native_cells)} S-101 cell(s)…")
                chart, names = [], []
                for path in self.native_cells:
                    handle = s101x.open_cell(path)
                    if handle is None:
                        continue
                    names.append(handle.name)
                    for ftype in self.native_codes:
                        chart.extend(s101x.extract(handle, ftype, kind))
                context["cells"] = sorted(set(names))
            elif self.native_codes and self.native_cells:
                # Native-read the chosen object layer's class(es) across exactly
                # the cells the scan found them in. Already WGS84 lon/lat, raw
                # S-57 attributes. Each feature carries its real cell name.
                from .s57 import S57RecordSet
                self.progress.emit(
                    f"Reading {len(self.native_cells)} ENC cell(s)…")
                rs = S57RecordSet.from_paths(self.native_cells)
                chart = []
                for cell in rs.cells:
                    for code in self.native_codes:
                        chart.extend(extract(cell, code, catalogue))
                context["cells"] = sorted({c.name for c in rs.cells})
            else:
                # Fallback: scan the whole source for a single class.
                from .enc_scanner import scan_for_charts
                from .s57 import S57RecordSet
                self.progress.emit("Staging ENC source…")
                best_files, *_ = scan_for_charts(self.source)
                cell_paths = list(best_files.values())
                context["cells"] = sorted(best_files.keys())
                if not cell_paths:
                    self.finished.emit(None, context)
                    return
                self.progress.emit("Reading ENC cells…")
                rs = S57RecordSet.from_paths(cell_paths)
                chart = []
                for cell in rs.cells:
                    chart.extend(extract(cell, self.recipe.enc_class,
                                         catalogue))

            # No ENC Coverage layer loaded? Derive the footprint natively from
            # the cells so the source is still clipped to the charted area (no
            # layers to load first).
            # Coverage cells: the pushed/entries list, else — bulletproof — the
            # very cells we just native-read the chart from (they exist; they
            # produced the chart features), tagged with this run's product.
            cov_cells = self.coverage_cells or [
                (p, self.product) for p in self.native_cells]
            if self.coverage is None and cov_cells:
                try:
                    from .sourceval.coverage_cells import \
                        coverage_polygons_from_cells
                    from .sourceval import prepare_coverage
                    self.progress.emit(
                        f"Reading chart coverage from "
                        f"{len(cov_cells)} cell(s)…")
                    polys = coverage_polygons_from_cells(
                        cov_cells, catalogue)
                    context["coverage_polys"] = len(polys)
                    if polys:
                        self.coverage = prepare_coverage(polys)
                        context["coverage_source"] = "cells"
                    else:
                        context["coverage_warn"] = (
                            "no M_COVR/DataCoverage polygons found in the "
                            "cells — whole source compared")
                except Exception as exc:                # noqa: BLE001
                    context["coverage_warn"] = repr(exc)

            self.progress.emit(
                f"Reconciling {len(chart)} chart vs "
                f"{len(self.source_features)} source features…")
            run_reconcile = {"line": reconcile_lines,
                             "area": reconcile_areas}.get(kind, reconcile)
            report = run_reconcile(chart, self.source_features, self.recipe,
                                   catalogue, coverage=self.coverage)
            context["n_chart"] = len(chart)
            context["n_source"] = len(self.source_features)
            context["clip_mode"] = getattr(report, "clip_mode", "none")
            self.finished.emit(report, context)
        except Exception as exc:                      # noqa: BLE001
            self.failed.emit(repr(exc))


# ---------------------------------------------------------------------------
# Value-map dialog: bind enumerated ENC labels -> source values
# ---------------------------------------------------------------------------
class ValueMapDialog(QDialog):
    """Define how a source field is interpreted for one mapping.

    For an *enumerated* ENC attribute: a grid binding each ENC label to a source
    value. For any attribute (enumerated or free-text/numeric): a list of source
    values that should be treated as NULL/empty — to absorb the database that
    writes "L" (or "-", "n/a") to mean "no value".
    """

    def __init__(self, parent, enc_attr, labels, source_values,
                 current=None, current_nulls=None, enumerated=True):
        super().__init__(parent)
        self.setWindowTitle(f"Map values — {enc_attr}")
        self.resize(440, 420)
        self._labels = labels
        self._enumerated = enumerated
        lay = QVBoxLayout(self)

        if enumerated and labels:
            lay.addWidget(QLabel(
                f"Bind each <b>{enc_attr}</b> meaning to the matching source "
                "value:"))
            self.table = QTableWidget(len(labels), 2)
            self.table.setHorizontalHeaderLabels(["ENC value", "Source value"])
            self.table.horizontalHeader().setSectionResizeMode(
                QHeaderView.Stretch)
            self.table.verticalHeader().setVisible(False)
            current = current or {}
            for row, label in enumerate(labels):
                item = QTableWidgetItem(label)
                item.setFlags(Qt.ItemIsEnabled)
                self.table.setItem(row, 0, item)
                combo = QComboBox()
                combo.setEditable(True)
                combo.addItem("")
                for v in source_values:
                    combo.addItem(str(v))
                if label in current:
                    combo.setEditText(str(current[label]))
                else:
                    for v in source_values:
                        if str(v).strip().casefold() == label.casefold():
                            combo.setEditText(str(v))
                            break
                self.table.setCellWidget(row, 1, combo)
            lay.addWidget(self.table)
        else:
            self.table = None
            lay.addWidget(QLabel(
                f"<b>{enc_attr}</b> is free-text / numeric — values are compared "
                "directly. You can still declare source values that mean empty "
                "below."))

        # NULL-sentinel field — applies to every attribute type.
        lay.addWidget(QLabel(
            "Source values that mean <b>NULL / empty</b> "
            "(comma-separated, no spaces or brackets — e.g. "
            "<tt>L,-,n/a</tt>):"))
        from qgis.PyQt.QtWidgets import QLineEdit
        self.null_edit = QLineEdit(",".join(current_nulls or []))
        self.null_edit.setToolTip(
            "Some source databases store a placeholder instead of leaving a "
            "field blank. List those here and they are treated as empty, so "
            "they do not register as mismatches.")
        lay.addWidget(self.null_edit)

        bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        bb.accepted.connect(self.accept)
        bb.rejected.connect(self.reject)
        lay.addWidget(bb)

    def null_values(self):
        # Tolerant of stray spaces and wrapping/loose parentheses, so a user who
        # types "(L, -, n/a)" or "L,-,n/a" gets the same clean list either way.
        raw = self.null_edit.text().strip().strip("()")
        out = []
        for tok in raw.split(","):
            t = tok.strip().strip("()").strip()
            if t:
                out.append(t)
        return out

    def value_map(self):
        """The enumerated label -> source-value mapping from the dialog grid."""
        out = {}
        if self.table is None:
            return out
        for row, label in enumerate(self._labels):
            combo = self.table.cellWidget(row, 1)
            val = combo.currentText().strip()
            if val:
                out[label] = val
        return out


# ---------------------------------------------------------------------------
# The panel
# ---------------------------------------------------------------------------
class SourceValidationPanel(QWidget):
    """The Source validation tab."""

    def __init__(self, iface, plugin_dir):
        super().__init__()
        self.iface = iface
        self.plugin_dir = plugin_dir
        self._source = ""
        self._catalogue = None
        self._report = None
        self._context = None
        self._worker = None
        # Known-fault exclusions held on the panel so they survive _recipe_from_ui
        # rebuilds; written into every recipe and applied to the next run. Flagged
        # one-by-one from the results list, removed via the "Known faults" dialog.
        self._exclusions = {"classes": [], "enc": [], "source": []}
        self._recipe_path = None     # last saved/loaded recipe, for write-back
        self._scanned_classes = []   # object-class acronyms from the scan
        self._scan_entries = []      # full object-layer scan manifest (ENC side)
        self._coverage_cells = []    # [(path, product)] to native-read coverage
        self._loaded_map = {}        # (usage, display) -> loaded layer id
        self._object_layer_ids = []  # ids of object layers loaded by the workflow
        self._browsed_layers = []    # sources picked via Browse… (kept alive)
        self._value_maps = {}        # row -> {enc_label: source_value}
        self._null_values = {}       # row -> [source NULL sentinels]
        self._build_ui()

    # -- catalogue (lazy) -----------------------------------------------------
    def catalogue(self):
        """The S-57 attribute catalogue (lazily loaded, cached)."""
        if self._catalogue is None:
            from .validation import Catalogue
            self._catalogue = Catalogue(
                os.path.join(self.plugin_dir, "s57catalogue"))
        return self._catalogue

    # -- shared source --------------------------------------------------------
    def set_source(self, path):
        """Set the validation source path (the ENC folder/zip being checked)."""
        self._source = path or ""
        if self._source:
            self.source_lbl.setText(f"ENC source: {self._source}")
        else:
            self.source_lbl.setText("Set an ENC source above (Folder / Zip).")

    # -- UI -------------------------------------------------------------------
    def _build_ui(self):
        outer = QVBoxLayout(self)
        outer.setContentsMargins(10, 10, 10, 10)
        outer.setSpacing(8)

        self.source_lbl = QLabel("Set an ENC source above (Folder / Zip).")
        self.source_lbl.setWordWrap(True)
        outer.addWidget(self.source_lbl)

        # ---- recipe box -----------------------------------------------------
        box = QGroupBox("Validation recipe")
        form = QFormLayout(box)

        # ENC side: one selector, listing the object layers loaded by the
        # scan→load workflow. Pick one, pick a source, map, run.
        enc_row = QHBoxLayout()
        self.enc_combo = QComboBox()
        self.enc_combo.setToolTip(
            "The object layer to validate — every layer found by the object-"
            "layer scan (per usage band), whether or not it's loaded. The cells "
            "are read directly on run, so no load is required.")
        self.enc_combo.currentIndexChanged.connect(self._on_enc_changed)
        self.enc_reload_btn = QPushButton("↻")
        self.enc_reload_btn.setFixedWidth(32)
        self.enc_reload_btn.setToolTip("Reload the scanned object layers.")
        self.enc_reload_btn.clicked.connect(self._reload_enc_layers)
        enc_row.addWidget(self.enc_combo, 1)
        enc_row.addWidget(self.enc_reload_btn)
        form.addRow("ENC object layer:", enc_row)

        src_row = QHBoxLayout()
        self.source_combo = QComboBox()
        self.source_combo.setToolTip(
            "A point vector layer. Pick one loaded in the project, or use "
            "Browse… to choose one straight from the Data Source Manager "
            "without loading it.")
        self.browse_src_btn = QPushButton("Browse…")
        self.browse_src_btn.setToolTip(
            "Pick a source from QGIS's registered connections / files "
            "(Data Source Manager) without adding it to the project.")
        self.browse_src_btn.clicked.connect(self._browse_source)
        self.reload_src_btn = QPushButton("↻")
        self.reload_src_btn.setFixedWidth(32)
        self.reload_src_btn.setToolTip("Reload the list of project layers.")
        self.reload_src_btn.clicked.connect(self._reload_sources)
        src_row.addWidget(self.source_combo, 1)
        src_row.addWidget(self.browse_src_btn)
        src_row.addWidget(self.reload_src_btn)
        form.addRow("Source layer:", src_row)

        self.buffer_spin = QDoubleSpinBox()
        self.buffer_spin.setRange(0.0, 50.0)
        self.buffer_spin.setDecimals(2)          # down to 0.01 m for survey data
        self.buffer_spin.setSingleStep(0.5)
        self.buffer_spin.setValue(10.0)
        self.buffer_spin.setSuffix(" m")
        self.buffer_spin.setToolTip(
            "The geometry-scrutiny dial — low = exacting, high = loose.\n\n"
            "POINT source: two points are the same feature if within this "
            "distance. Match it to the data's positional accuracy: floating "
            "AtoN differ by metres (~10–25 m); survey-grade morphology is "
            "cm-accurate (POSACC / SOUACC), so type e.g. 0.10 there.\n\n"
            "LINE source: this is the geometry tolerance. The chart passes "
            "silently while it stays within 2× this of the source (the "
            "generalisation corridor); only an OBVIOUS difference is flagged — "
            "a contiguous stretch outside the corridor, or a worst deviation "
            "beyond 3× this. A future mode will read POSACC/CATZOC and set "
            "this per feature automatically.")
        form.addRow("Match buffer:", self.buffer_spin)

        # Source is limited to the chart's coverage automatically: the loaded
        # ENC Coverage (M_COVR) hybrid layer is found in the project and used as
        # the clip. No control needed — see _coverage_layers() / _run().

        id_row = QHBoxLayout()
        self.id_enc_combo = QComboBox()
        self.id_enc_combo.addItem("(none)")
        self.id_enc_combo.addItem("FOID")
        self.id_src_combo = QComboBox()
        self.id_src_combo.addItem("(none)")
        id_row.addWidget(QLabel("ENC:"))
        id_row.addWidget(self.id_enc_combo, 1)
        id_row.addWidget(QLabel("Source:"))
        id_row.addWidget(self.id_src_combo, 1)
        form.addRow("Identity (optional):", id_row)

        outer.addWidget(box)

        # ---- attribute mappings --------------------------------------------
        map_head = QHBoxLayout()
        map_head.addWidget(QLabel("Attribute mappings:"))
        map_head.addStretch(1)
        self.add_map_btn = QPushButton("Add")
        self.add_map_btn.clicked.connect(self._add_mapping_row)
        self.del_map_btn = QPushButton("Remove")
        self.del_map_btn.clicked.connect(self._remove_mapping_row)
        map_head.addWidget(self.add_map_btn)
        map_head.addWidget(self.del_map_btn)
        outer.addLayout(map_head)

        self.map_table = QTableWidget(0, 3)
        self.map_table.setHorizontalHeaderLabels(
            ["ENC attribute", "Source field", "Values"])
        self.map_table.horizontalHeader().setSectionResizeMode(
            QHeaderView.Stretch)
        self.map_table.setSelectionBehavior(QAbstractItemView.SelectRows)
        outer.addWidget(self.map_table)

        # ---- recipe persistence + run --------------------------------------
        rec_head = QLabel("Recipe:")
        rec_head.setStyleSheet("font-weight:bold; margin-top:6px;")
        outer.addWidget(rec_head)

        rec_row = QHBoxLayout()
        self.save_btn = QPushButton("Save")
        self.save_btn.setToolTip("Save to the current recipe file "
                                 "(asks where to, the first time).")
        self.save_btn.clicked.connect(self._save_recipe)
        self.saveas_btn = QPushButton("Save as")
        self.saveas_btn.setToolTip("Save to a new recipe file.")
        self.saveas_btn.clicked.connect(self._save_recipe_as)
        self.load_btn = QPushButton("Load")
        self.load_btn.setToolTip("Load a recipe from file.")
        self.load_btn.clicked.connect(self._load_recipe)
        self.clear_btn = QPushButton("Clear")
        self.clear_btn.setToolTip("Reset the recipe form to empty.")
        self.clear_btn.clicked.connect(self._clear_recipe)
        self.run_btn = QPushButton("Execute")
        self.run_btn.setToolTip("Run the source validation with this recipe.")
        self.run_btn.clicked.connect(self._run)
        for b in (self.save_btn, self.saveas_btn, self.load_btn,
                  self.clear_btn, self.run_btn):
            rec_row.addWidget(b, 1)                    # equal stretch
        outer.addLayout(rec_row)

        self.summary = QLabel("Build a recipe, then run.")
        # Wrap, and don't let a long run-notes string dictate the panel width
        # (which would widen the whole dock and disturb the user's layout).
        self.summary.setWordWrap(True)
        self.summary.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
        outer.addWidget(self.summary)

        # ---- results --------------------------------------------------------
        self.results = QTreeWidget()
        self.results.setHeaderLabels(["Check / feature", "Detail", "Known fault"])
        self.results.setColumnWidth(0, 320)
        self.results.setColumnWidth(2, 150)
        # A per-finding "known fault" affordance lives in column 2. Handled by a
        # single itemClicked signal rather than ~1000 live row buttons, so it
        # scales to a full run while behaving like a button per listing.
        self.results.itemClicked.connect(self._on_result_clicked)
        self.results.setContextMenuPolicy(Qt.CustomContextMenu)
        self.results.customContextMenuRequested.connect(self._results_menu)
        # Let the tree scroll horizontally rather than push the dock wider when a
        # finding's detail text is long.
        self.results.header().setStretchLastSection(False)
        self.results.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel)
        self.results.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding)
        outer.addWidget(self.results, 1)

        exp_row = QHBoxLayout()
        self.html_btn = QPushButton("Export HTML…")
        self.html_btn.setEnabled(False)
        self.html_btn.clicked.connect(self._export_html)
        self.csv_btn = QPushButton("Export CSV…")
        self.csv_btn.setEnabled(False)
        self.csv_btn.clicked.connect(self._export_csv)
        self.flags_btn = QPushButton("Add flags layer")
        self.flags_btn.setEnabled(False)
        self.flags_btn.clicked.connect(self._add_flags_layer)
        exp_row.addWidget(self.html_btn)
        exp_row.addWidget(self.csv_btn)
        exp_row.addWidget(self.flags_btn)
        self.faults_btn = QPushButton("Known faults…")
        self.faults_btn.setToolTip(
            "Review and remove the known-fault exclusions stored in this recipe.")
        self.faults_btn.clicked.connect(self._manage_known_faults)
        exp_row.addWidget(self.faults_btn)
        exp_row.addStretch(1)
        outer.addLayout(exp_row)
        self._refresh_faults_button()

        self._reload_sources()

    # -- source / class discovery --------------------------------------------
    def _point_layers(self):
        """Candidate source layers: points (v1), lines and polygons
        (geometry conflation with the generalization dead-band)."""
        out = []
        for lid, layer in QgsProject.instance().mapLayers().items():
            if (isinstance(layer, QgsVectorLayer)
                    and layer.geometryType() in (QgsWkbTypes.PointGeometry,
                                                 QgsWkbTypes.LineGeometry,
                                                 QgsWkbTypes.PolygonGeometry)):
                out.append(layer)
        return out

    def _reload_sources(self):
        project_layers = self._point_layers()
        # Source side: project point layers + browsed-in sources (not added to
        # the project). Stored in a parallel list; the combo carries the index
        # into it as item data, with a "(select layer)" placeholder at -1 so a
        # recipe whose source isn't loaded lands on the placeholder, not a wrong
        # layer.
        browsed = [lyr for lyr in self._browsed_layers if _alive(lyr)]
        self._source_layers = project_layers + browsed
        try:
            self.source_combo.currentIndexChanged.disconnect(
                self._on_source_changed)
        except (TypeError, RuntimeError):
            pass
        self.source_combo.clear()
        self.source_combo.addItem("(select layer)", -1)
        for k, layer in enumerate(project_layers):
            self.source_combo.addItem(layer.name(), k)
        for k, layer in enumerate(browsed, start=len(project_layers)):
            self.source_combo.addItem(f"(browsed) {layer.name()}", k)
        self.source_combo.currentIndexChanged.connect(self._on_source_changed)
        self._on_source_changed()

    def _browse_source(self):
        """Pick a source straight from QGIS's Data Source Manager / Browser
        (registered connections, GeoPackages, files…) without loading it into
        the project. The chosen layer is held by the panel for reading only."""
        try:
            from qgis.gui import QgsDataSourceSelectDialog
        except ImportError:
            QMessageBox.information(
                self, "Source validation",
                "This QGIS build has no data-source select dialog; add the "
                "layer to the project instead, then pick it here.")
            return
        dlg = QgsDataSourceSelectDialog()
        dlg.setWindowTitle("Pick a source from the Data Source Manager")
        if not dlg.exec():
            return
        uri = dlg.uri()
        if uri is None or not uri.isValid():
            return
        layer = QgsVectorLayer(uri.uri, uri.name or "source", uri.providerKey)
        if not layer.isValid():
            QMessageBox.warning(self, "Source validation",
                                "That source could not be opened as a vector "
                                "layer.")
            return
        if layer.geometryType() not in (QgsWkbTypes.PointGeometry,
                                        QgsWkbTypes.LineGeometry,
                                        QgsWkbTypes.PolygonGeometry):
            QMessageBox.information(
                self, "Source validation",
                "Source validation uses point, line or polygon layers; that source is not "
                "points.")
            return
        self._browsed_layers.append(layer)
        self._reload_sources()
        idx = self.source_combo.findText(f"(browsed) {layer.name()}")
        if idx >= 0:
            self.source_combo.setCurrentIndex(idx)

    def _current_source_layer(self):
        k = self.source_combo.currentData()
        layers = getattr(self, "_source_layers", [])
        if k is None or k < 0 or k >= len(layers):
            return None                               # "(select layer)"
        layer = layers[k]
        return layer if _alive(layer) else None

    def _on_source_changed(self, *args):
        layer = self._current_source_layer()
        self.id_src_combo.clear()
        self.id_src_combo.addItem("(none)")
        if layer is not None:
            try:
                for f in layer.fields():
                    self.id_src_combo.addItem(f.name())
            except RuntimeError:
                pass                                  # layer deleted mid-call

    # -- ENC side: every object layer the scan found (native-read on run) ------
    def _enc_entry(self):
        """The selected scan-manifest entry (dict), or None."""
        i = self.enc_combo.currentData()
        if i is None or not (0 <= i < len(self._scan_entries)):
            return None
        return self._scan_entries[i]

    def _enc_entry_layer(self, entry):
        """The loaded hybrid layer for this entry — matched on (usage, display)
        as the loader names them — or None if it isn't loaded."""
        if not entry:
            return None
        key = (entry.get("usage"), entry.get("display"))
        lid = self._loaded_map.get(key)
        if not lid:
            return None
        layer = QgsProject.instance().mapLayer(lid)
        return layer if (layer is not None and layer.isValid()) else None

    def _enc_field_names(self):
        """Attributes for building the recipe. If the entry's hybrid layer is
        loaded, use ITS fields (the real data model the user sees); otherwise
        fall back to the catalogue's permissible attributes for the class(es)."""
        entry = self._enc_entry()
        if entry is None:
            return []
        layer = self._enc_entry_layer(entry)
        if layer is not None:
            return [f.name() for f in layer.fields()]
        if entry.get("product") == "S-101":
            # No S-57 catalogue for S-101 attribute names — sample one cell so
            # the recipe's attribute picker is populated even when the S-101
            # layer isn't loaded into the project.
            return self._s101_attr_names(entry)
        cat = self.catalogue()
        attrs = set()
        for code in entry.get("codes", []):
            attrs.update(cat.permissible_attrs.get(code, []) or [])
        return sorted(attrs)

    def _s101_attr_names(self, entry):
        """Attribute names present on the entry's S-101 feature type, read from
        the first cell (cheap; the picker is built on demand). [] on failure."""
        try:
            from .sourceval import enc_features_s101 as s101x
        except (ImportError, ValueError):
            from sourceval import enc_features_s101 as s101x
        names = set()
        for path in (entry.get("cells") or [])[:1]:
            handle = s101x.open_cell(path)
            if handle is None:
                continue
            for ftype in entry.get("codes", []):
                for kind in ("point", "line", "area"):
                    for cf in s101x.extract(handle, ftype, kind):
                        names.update(cf.attrs.keys())
        return sorted(names)

    def _on_enc_changed(self, *args):
        self._populate_id_enc_attrs()

    def _coverage_cells_from_entries(self):
        """[(path, product)] for every cell across the scan manifest — the
        footprint source when no ENC Coverage layer is loaded. Self-contained:
        the manifest entries (what the ENC picker lists) always carry their
        cells + product, so this works regardless of scan/reload timing."""
        seen = {}
        for e in (self._scan_entries or []):
            prod = e.get("product", "S-57")
            for p in (e.get("cells") or []):
                seen.setdefault(p, prod)
        return list(seen.items())

    def set_scan_manifest(self, entries, coverage_cells=None):
        """Receive the full object-layer scan result and list EVERY found object
        layer (per usage band) in the ENC selector — loaded or not. The cells are
        native-read on run, so nothing has to be loaded into the project first.
        ``coverage_cells`` = [(path, product)] for all scanned cells, so the run
        can native-read the chart footprint without a loaded Coverage layer.
        Called by the dock after an object-layer scan."""
        self._scan_entries = list(entries or [])
        self._coverage_cells = list(coverage_cells or [])
        self._reload_enc_layers()

    def set_loaded_layer_map(self, loaded_map):
        """Receive {(usage, display): layer_id} for the hybrid object layers the
        workflow loaded, so the validator can build recipes against — and read —
        the real loaded data model rather than catalogue attributes. Accumulated
        across loads."""
        proj = QgsProject.instance()
        for key, lid in (loaded_map or {}).items():
            if proj.mapLayer(lid) is not None:
                self._loaded_map[tuple(key)] = lid
        # prune stale
        self._loaded_map = {k: v for k, v in self._loaded_map.items()
                            if proj.mapLayer(v) is not None}
        self._populate_id_enc_attrs()

    def _reload_enc_layers(self):
        """Repopulate the ENC selector from the scan manifest."""
        current = self.enc_combo.currentText()
        self.enc_combo.blockSignals(True)
        self.enc_combo.clear()
        self.enc_combo.addItem("(select layer)", None)
        for i, entry in enumerate(self._scan_entries):
            self.enc_combo.addItem(entry.get("label", f"layer {i}"), i)
        self.enc_combo.blockSignals(False)
        if current:
            j = self.enc_combo.findText(current)
            if j >= 0:
                self.enc_combo.setCurrentIndex(j)
        if self.enc_combo.count() == 0:
            self.summary.setText(
                "No object layers yet — run the object-layer scan first "
                "(Charts → Scan object layers).")
        self._populate_id_enc_attrs()

    def set_object_layers(self, layer_ids):
        """Receive the ids of object layers loaded by the scan→load workflow.
        Kept only so a coverage layer loaded this session is remembered;
        coverage itself is found automatically at run time (see
        _coverage_layers). Accumulated across loads (union)."""
        proj = QgsProject.instance()
        existing = list(self._object_layer_ids)
        for lid in (layer_ids or []):
            if lid not in existing:
                existing.append(lid)
        self._object_layer_ids = [lid for lid in existing
                                  if proj.mapLayer(lid) is not None]

    def _coverage_layers(self):
        """Every loaded ENC Coverage (M_COVR) polygon layer in the project,
        found by name. These clip the source automatically — no user choice."""
        out = []
        for layer in QgsProject.instance().mapLayers().values():
            try:
                if (isinstance(layer, QgsVectorLayer) and layer.isValid()
                        and layer.geometryType() == QgsWkbTypes.PolygonGeometry):
                    name = layer.name().lower()
                    if ("coverage" in name or "m_covr" in name
                            or "covr" in name):
                        out.append(layer)
            except Exception:                         # noqa: BLE001
                continue
        return out

    def _populate_id_enc_attrs(self):
        self.id_enc_combo.clear()
        self.id_enc_combo.addItem("(none)")
        self.id_enc_combo.addItem("FOID")
        for acr in self._enc_field_names():
            self.id_enc_combo.addItem(acr)

    # -- attribute-mapping table ---------------------------------------------
    def _attr_combo_for_class(self):
        combo = QComboBox()
        for name in self._enc_field_names():
            combo.addItem(name)
        if combo.count() == 0:               # unknown class/layer: offer all attrs
            cat = self.catalogue()
            for acr in sorted(cat.attr_acronym_to_code):
                combo.addItem(acr)
        return combo

    def _source_field_combo(self, preset=None):
        combo = QComboBox()
        layer = self._current_source_layer()
        if layer is not None:
            try:
                for f in layer.fields():
                    combo.addItem(f.name())
            except RuntimeError:
                pass                                  # layer deleted mid-call
        # Preserve a recipe's stored field even when its source layer isn't in
        # this session, so the mapping survives and the user just repicks the
        # source (recipe sharing across areas/usages).
        if preset and combo.findText(preset) < 0:
            combo.addItem(preset)
        if preset:
            combo.setCurrentIndex(max(0, combo.findText(preset)))
        return combo

    def _add_mapping_row(self):
        row = self.map_table.rowCount()
        self.map_table.insertRow(row)
        self.map_table.setCellWidget(row, 0, self._attr_combo_for_class())
        self.map_table.setCellWidget(row, 1, self._source_field_combo())
        btn = QPushButton("Map values…")
        btn.clicked.connect(lambda _=False, r=row: self._edit_values(r))
        self.map_table.setCellWidget(row, 2, btn)

    def _remove_mapping_row(self):
        row = self.map_table.currentRow()
        if row >= 0:
            self.map_table.removeRow(row)
            self._value_maps.pop(row, None)
            self._null_values.pop(row, None)

    def _edit_values(self, row):
        cat = self.catalogue()
        attr_combo = self.map_table.cellWidget(row, 0)
        src_combo = self.map_table.cellWidget(row, 1)
        if attr_combo is None or src_combo is None:
            return
        enc_attr = attr_combo.currentText()
        source_field = src_combo.currentText()
        enumerated = cat.is_enumerated(enc_attr)
        labels = list(cat.enum_labels(enc_attr).values()) if enumerated else []
        source_values = self._scan_source_values(source_field)
        dlg = ValueMapDialog(
            self, enc_attr, labels, source_values,
            current=self._value_maps.get(row),
            current_nulls=self._null_values.get(row),
            enumerated=enumerated)
        if dlg.exec() == QDialog.Accepted:
            self._value_maps[row] = dlg.value_map()
            self._null_values[row] = dlg.null_values()

    def _scan_source_values(self, field):
        layer = self._current_source_layer()
        if layer is None or not field:
            return []
        from .sourceval.sources import QgisVectorSource
        try:
            return QgisVectorSource(layer).distinct_values(field)
        except Exception as exc:                      # noqa: BLE001
            _log(f"distinct-value scan failed: {exc!r}", Qgis.Warning)
            return []

    # -- recipe <-> UI --------------------------------------------------------
    def _recipe_from_ui(self):
        from .sourceval import Recipe, AttributeMapping
        cat = self.catalogue()
        identity = None
        enc_key = self.id_enc_combo.currentText()
        src_key = self.id_src_combo.currentText()
        if enc_key not in ("(none)", "") and src_key not in ("(none)", ""):
            identity = {"enc": enc_key, "source": src_key}
        mappings = []
        for row in range(self.map_table.rowCount()):
            attr_combo = self.map_table.cellWidget(row, 0)
            src_combo = self.map_table.cellWidget(row, 1)
            if attr_combo is None or src_combo is None:
                continue
            enc_attr = attr_combo.currentText()
            source_field = src_combo.currentText()
            if not enc_attr or not source_field:
                continue
            enumerated = cat.is_enumerated(enc_attr)
            mappings.append(AttributeMapping(
                enc_attr, source_field, enumerated=enumerated,
                value_map=self._value_maps.get(row, {}),
                null_values=self._null_values.get(row, [])))
        entry = self._enc_entry()
        enc_class = entry["label"] if entry else ""
        enc_display = entry.get("display", "") if entry else ""
        layer = self._current_source_layer()
        # The source layer's geometry selects the recipe kind: line/polygon
        # sources reconcile geometry with the generalization dead-band;
        # buffer_m is then the tolerance.
        geometry = "point"
        if layer is not None:
            gt = layer.geometryType()
            if gt == QgsWkbTypes.LineGeometry:
                geometry = "line"
            elif gt == QgsWkbTypes.PolygonGeometry:
                geometry = "area"
        return Recipe(
            name=enc_class,
            enc_class=enc_class,
            enc_display=enc_display,
            geometry=geometry,
            source_ref=layer.name() if layer else "",
            buffer_m=self.buffer_spin.value(),
            restrict_to_area=False,        # bbox path retired; coverage is the
            area_margin_m=0.0,             # area limit, found automatically
            coverage_ref="",               # coverage is auto (loaded ENC Coverage)
            enc_exclude_classes=list(self._exclusions["classes"]),
            enc_exclude=[dict(e) for e in self._exclusions["enc"]],
            source_exclude=[dict(e) for e in self._exclusions["source"]],
            identity=identity, mappings=mappings)

    def _apply_recipe_to_ui(self, recipe):
        missing = []
        # ENC object layer — match in three steps so a recipe shared across areas
        # or usage bands still binds: (1) exact label; (2) "similar" — same
        # display (e.g. "Aids to Navigation") in a different usage band; (3) fall
        # back to the "(select layer)" placeholder and flag it for the user.
        i = self.enc_combo.findText(recipe.enc_class) if recipe.enc_class else -1
        if i < 0 and getattr(recipe, "enc_display", ""):
            for k in range(self.enc_combo.count()):
                idx = self.enc_combo.itemData(k)
                if idx is None:
                    continue
                entry = self._scan_entries[idx]
                if entry.get("display") == recipe.enc_display:
                    i = k
                    break
            if i >= 0:
                missing.append(
                    f"ENC layer matched by similarity ({recipe.enc_display})")
        if i >= 0:
            self.enc_combo.setCurrentIndex(i)
        elif recipe.enc_class:
            missing.append(f"ENC layer “{recipe.enc_class}” — select one")
            self.enc_combo.setCurrentIndex(0)         # "(select layer)"
        self._populate_id_enc_attrs()
        self.buffer_spin.setValue(min(50.0, max(0.0, recipe.buffer_m)))

        # Pull the recipe's known-fault exclusions into the panel's store so they
        # apply to the next run and show in the Known-faults dialog.
        self._exclusions = {
            "classes": [c.upper() for c in
                        getattr(recipe, "enc_exclude_classes", [])],
            "enc": [dict(e) for e in getattr(recipe, "enc_exclude", [])],
            "source": [dict(e) for e in getattr(recipe, "source_exclude", [])]}
        self._refresh_faults_button()

        # Source — by name, else land on "(select layer)" so the mapping (which
        # keeps its stored field names below) can be rebound to a chosen source.
        j = self.source_combo.findText(recipe.source_ref) if recipe.source_ref \
            else -1
        if j >= 0:
            self.source_combo.setCurrentIndex(j)
        elif recipe.source_ref:
            missing.append(f"source layer “{recipe.source_ref}” — select one")
            self.source_combo.setCurrentIndex(0)      # "(select layer)"

        # Identity — set if the fields exist, else leave at "(none)".
        if recipe.identity:
            ei = self.id_enc_combo.findText(recipe.identity.get("enc", ""))
            self.id_enc_combo.setCurrentIndex(ei if ei >= 0 else 0)
            si = self.id_src_combo.findText(recipe.identity.get("source", ""))
            self.id_src_combo.setCurrentIndex(si if si >= 0 else 0)
        else:
            self.id_enc_combo.setCurrentIndex(0)
            self.id_src_combo.setCurrentIndex(0)

        # mappings
        self.map_table.setRowCount(0)
        self._value_maps = {}
        self._null_values = {}
        for m in recipe.mappings:
            self._add_mapping_row()
            row = self.map_table.rowCount() - 1
            ac = self.map_table.cellWidget(row, 0)
            sc = self.map_table.cellWidget(row, 1)
            ai = ac.findText(m.enc_attr)
            if ai < 0:
                ac.addItem(m.enc_attr)
                ai = ac.findText(m.enc_attr)
            ac.setCurrentIndex(max(0, ai))
            si = sc.findText(m.source_field)
            if si < 0:
                sc.addItem(m.source_field)
                si = sc.findText(m.source_field)
            sc.setCurrentIndex(max(0, si))
            self._value_maps[row] = dict(m.value_map)
            self._null_values[row] = list(m.null_values)

        if missing:
            self.summary.setText(
                "Recipe loaded. Needs attention: " + "; ".join(missing) + ".")

    def _save_recipe(self):
        """Save to the current recipe file; if there isn't one yet, behave like
        Save as."""
        if not self._recipe_path:
            return self._save_recipe_as()
        try:
            self._recipe_from_ui().save(self._recipe_path)
        except Exception as exc:                      # noqa: BLE001
            QMessageBox.warning(self, "Source validation",
                                f"Could not save recipe:\n{exc!r}")
            return
        self.iface.messageBar().pushInfo(
            "ENCore GIS — Source validation",
            f"Recipe saved to {self._recipe_path}")

    def _save_recipe_as(self):
        recipe = self._recipe_from_ui()
        stem = export_basename(
            "recipe",
            recipe.source_ref or recipe.enc_display or recipe.enc_class)
        start = os.path.join(_documents_dir(), stem + ".json")
        path, _ = QFileDialog.getSaveFileName(
            self, "Save recipe as", start, "Recipe (*.json)")
        if not path:
            return
        recipe.save(path)
        self._recipe_path = path
        self.iface.messageBar().pushInfo(
            "ENCore GIS — Source validation", f"Recipe saved to {path}")

    def _clear_recipe(self):
        """Reset the recipe form to empty — selections, identity, mappings, and
        known-fault exclusions — without touching loaded layers or the source
        list. Forgets the current recipe file."""
        self.enc_combo.setCurrentIndex(0)             # "(select layer)"
        self.source_combo.setCurrentIndex(0)
        self.id_enc_combo.setCurrentIndex(0)
        self.id_src_combo.setCurrentIndex(0)
        self.buffer_spin.setValue(10.0)
        self.map_table.setRowCount(0)
        self._value_maps = {}
        self._null_values = {}
        self._exclusions = {"classes": [], "enc": [], "source": []}
        self._recipe_path = None
        self._refresh_faults_button()
        self.summary.setText("Recipe cleared.")

    def _load_recipe(self):
        from .sourceval import Recipe
        path, _ = QFileDialog.getOpenFileName(
            self, "Load recipe", _documents_dir(), "Recipe (*.json)")
        if not path:
            return
        try:
            recipe = Recipe.load(path)
        except Exception as exc:                      # noqa: BLE001
            QMessageBox.warning(self, "Source validation",
                                f"Could not load recipe:\n{exc!r}")
            return
        self._apply_recipe_to_ui(recipe)
        self._recipe_path = path
        self.summary.setText(f"Loaded recipe: {os.path.basename(path)}")

    # -- run ------------------------------------------------------------------
    def _run(self):
        entry = self._enc_entry()
        if entry is None:
            QMessageBox.information(
                self, "Source validation",
                "Pick an ENC object layer. They appear here after you run the "
                "object-layer scan (Charts → Scan object layers).")
            return
        recipe = self._recipe_from_ui()
        problems = recipe.problems()
        if problems:
            QMessageBox.information(self, "Source validation",
                                    "Recipe not ready:\n• " +
                                    "\n• ".join(problems))
            return
        src_layer = self._current_source_layer()
        if src_layer is None:
            QMessageBox.information(self, "Source validation",
                                    "Choose a source layer.")
            return

        from .sourceval.sources import QgisVectorSource
        notes = []
        crs_problems = []

        # Source side — reprojected to lon/lat here.
        try:
            src_prov = QgisVectorSource(
                src_layer,
                id_field=recipe.identity["source"] if recipe.identity else None)
            source_features = src_prov.features()
        except Exception as exc:                      # noqa: BLE001
            QMessageBox.warning(self, "Source validation",
                                f"Could not read the source layer:\n{exc!r}")
            return
        notes.append("Source " + src_prov.crs_note())
        if not src_prov.crs_ok or src_prov.out_of_range:
            crs_problems.append(src_prov.crs_note())

        # ENC side — prefer the loaded hybrid layer (its real data model, read +
        # reprojected here) when this entry is loaded; otherwise native-read the
        # cells in the worker thread (WGS84, raw S-57 attributes).
        chart_features = None
        native_codes = []
        native_cells = []
        product = entry.get("product", "S-57")
        enc_layer = self._enc_entry_layer(entry)
        if enc_layer is not None:
            from .sourceval import ChartFeature
            try:
                enc_prov = QgisVectorSource(enc_layer)
                enc_feats = enc_prov.features()
                chart_features = []
                for i, sf in enumerate(enc_feats, 1):
                    # Per-feature class + FOID come from the hybrid layer's own
                    # fields when present (S57CLASS carries the merged core
                    # class; LNAM the S-57 long name), so class-scope and the
                    # denylist behave the same as a native read. Fall back to the
                    # composite label / no-FOID when the merge didn't expose them.
                    klass = (sf.attrs.get("S57CLASS")
                             or sf.attrs.get("OBJL_ACR")
                             or recipe.enc_class)
                    foid = _foid_from_lnam(sf.attrs.get("LNAM"))
                    chart_features.append(
                        ChartFeature(i, foid, klass, sf.lon, sf.lat,
                                     sf.attrs, cell=enc_layer.name()))
            except Exception as exc:                  # noqa: BLE001
                QMessageBox.warning(self, "Source validation",
                                    f"Could not read the ENC layer:\n{exc!r}")
                return
            notes.append(f"ENC: loaded layer “{enc_layer.name()}” "
                         f"({len(chart_features)} feature(s)). "
                         + enc_prov.crs_note())
            if not enc_prov.crs_ok or enc_prov.out_of_range:
                crs_problems.append(enc_prov.crs_note())
        else:
            native_codes = entry.get("codes", [])
            native_cells = entry.get("cells", [])
            notes.append(f"ENC: native {product} read of "
                         f"{', '.join(native_codes)} across "
                         f"{len(native_cells)} cell(s).")

        # Coverage clip — automatic. Prefer a loaded ENC Coverage (M_COVR)
        # polygon layer; otherwise the worker native-reads the footprint from
        # the scanned cells (coverage_cells below), so NO layer need be loaded.
        coverage = None
        coverage_cells = []
        cov_layers = self._coverage_layers()
        if cov_layers:
            try:
                from .sourceval.sources import coverage_polygons_from_layer
                from .sourceval import prepare_coverage
                polys = []
                for lyr in cov_layers:
                    polys.extend(coverage_polygons_from_layer(lyr))
                coverage = prepare_coverage(polys) if polys else None
                if coverage:
                    names = ", ".join(lyr.name() for lyr in cov_layers)
                    notes.append(f"Coverage: {names} ({len(coverage)} polygon(s)).")
                else:
                    notes.append("ENC Coverage layer(s) found but empty.")
            except Exception as exc:                  # noqa: BLE001
                QMessageBox.warning(
                    self, "Source validation",
                    f"Could not read the ENC Coverage layer:\n{exc!r}\n\n"
                    "Proceeding with the coverage read from the cells.")
                coverage = None
        if coverage is None:
            # Self-contained: derive the cells to read the footprint from the
            # scan MANIFEST itself (each entry carries its cells + product), so
            # this never depends on a separately-pushed list or scan timing.
            coverage_cells = (self._coverage_cells
                              or self._coverage_cells_from_entries())
            if coverage_cells:
                notes.append(f"Coverage: read from {len(coverage_cells)} "
                             "scanned cell(s) — no layer needed.")
            else:
                notes.append("No coverage available — whole source compared. "
                             "Run an object-layer scan first.")

        # CRS gate: the metre buffer is only meaningful if both sides are in a
        # known CRS. Warn (but let the user proceed knowingly) if not.
        if crs_problems:
            ans = QMessageBox.warning(
                self, "Source validation — CRS check",
                "The buffer match reconciles both layers to WGS84 lon/lat and "
                "measures metres geodesically. These layers may not reproject "
                "cleanly:\n\n• " + "\n• ".join(crs_problems) +
                "\n\nRun anyway?",
                QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
            if ans != QMessageBox.Yes:
                return

        for n in notes:
            _log(n)
        self.run_btn.setEnabled(False)
        self.summary.setText("Running…  " + "  ".join(notes))
        self.results.clear()
        self._worker = SourceValWorker(
            self._source, os.path.join(self.plugin_dir, "s57catalogue"),
            recipe, source_features, chart_features=chart_features,
            native_codes=native_codes, native_cells=native_cells,
            coverage=coverage, product=product,
            coverage_cells=coverage_cells)
        self._worker.progress.connect(self.summary.setText)
        self._worker.finished.connect(self._on_finished)
        self._worker.failed.connect(self._on_failed)
        self._worker.start()

    def _on_failed(self, msg):
        self.run_btn.setEnabled(True)
        self.summary.setText("Run failed.")
        QMessageBox.warning(self, "Source validation", msg)

    def _on_finished(self, report, context):
        self.run_btn.setEnabled(True)
        self._report = report
        self._context = context
        if report is None:
            self.summary.setText("No ENC cells found in the source.")
            return
        n = len(report.findings)
        if n == 0:
            msg = "Good job buddy! no discrepancies with this recipe"
        else:
            msg = (f"{n} discrepancy(ies) — {context.get('n_chart', 0)} chart "
                   f"vs {context.get('n_source', 0)} source features.")
            ooa = getattr(report, "source_out_of_area", 0)
            if ooa:
                msg += (f"  ({ooa} source feature(s) outside the chart area, "
                        f"skipped.)")
        self.summary.setText(msg)
        self._populate_results(report)
        self.html_btn.setEnabled(True)
        self.csv_btn.setEnabled(True)
        self.flags_btn.setEnabled(any(f.geom_wkt for f in report.findings))

    def _populate_results(self, report):
        from .sourceval import checks
        self.results.clear()
        by_check = {}
        for f in report.findings:
            by_check.setdefault(f.check, []).append(f)
        for meta in checks.ALL:
            items = by_check.get(meta, [])
            top = QTreeWidgetItem(
                [f"{meta.name}  ({len(items)})", meta.message])
            if items:
                top.setForeground(0, QBrush(QColor("#b00020")))
            for f in items:
                leaf = QTreeWidgetItem([f"  {f.klass} {f.fid}", f.detail, ""])
                if f.geom_wkt:
                    leaf.setData(0, Qt.UserRole, f.geom_wkt)
                # Known-fault affordance (column 2). A feature can only be
                # excluded if it has a stable identity the reconciler can filter
                # on: a source id, or an ENC LNAM. ENC findings without an LNAM
                # (a loaded layer that didn't expose one) are shown as such rather
                # than offered an exclusion that wouldn't actually take effect.
                payload = getattr(f, "exclude", None)
                if self._payload_flaggable(payload):
                    leaf.setData(2, Qt.UserRole, payload)
                    if self._is_excluded(payload):
                        self._show_leaf_excluded(leaf)
                    else:
                        leaf.setText(2, "⚑ flag known fault")
                        leaf.setForeground(2, QBrush(QColor("#0a58ca")))
                elif payload and payload[0] == "enc":
                    leaf.setText(2, "— no LNAM")
                    leaf.setForeground(2, QBrush(QColor("#999")))
                    leaf.setToolTip(2, "No LNAM on this feature — cannot flag "
                                       "as a known fault from a loaded layer "
                                       "without an LNAM field.")
                top.addChild(leaf)
            self.results.addTopLevelItem(top)
            top.setExpanded(bool(items))

    # -- known faults (recipe exclusions) ------------------------------------
    @staticmethod
    def _payload_flaggable(payload):
        """A finding is flaggable only with a stable identity: a source id, or a
        non-empty ENC LNAM."""
        if not payload:
            return False
        if payload[0] == "source":
            return str(payload[1]) != ""
        if payload[0] == "enc":
            return bool(payload[1])
        return False

    def _is_excluded(self, payload):
        if not payload:
            return False
        if payload[0] == "source":
            sid = str(payload[1])
            return any(str(e.get("id")) == sid
                       for e in self._exclusions["source"])
        lnam = (payload[1] or "").upper()
        return any(str(e.get("lnam", "")).upper() == lnam
                   for e in self._exclusions["enc"])

    def _add_exclusion(self, payload):
        """Append a known-fault to the in-memory recipe exclusions (deduped).
        Returns True if newly added."""
        if self._is_excluded(payload):
            return False
        if payload[0] == "source":
            self._exclusions["source"].append(
                {"id": str(payload[1]), "reason": ""})
        else:
            self._exclusions["enc"].append(
                {"lnam": (payload[1] or "").upper(),
                 "objnam": payload[2] or "", "reason": ""})
        return True

    def _show_leaf_excluded(self, leaf):
        leaf.setText(2, "✓ known fault")
        leaf.setForeground(2, QBrush(QColor("#2e7d32")))
        for col in (0, 1):
            leaf.setForeground(col, QBrush(QColor("#9e9e9e")))
        leaf.setData(2, Qt.UserRole, None)        # consumed; no double-add

    def _on_result_clicked(self, item, column):
        if column != 2:
            return
        payload = item.data(2, Qt.UserRole)
        if not payload:
            return
        if self._add_exclusion(payload):
            self._show_leaf_excluded(item)
            self._refresh_faults_button()
            self._write_back_recipe()

    def _results_menu(self, pos):
        """Right-click a finding: flag the single feature, or — for an ENC
        finding — exclude its whole class from the recipe in one go (the BCN
        'by design absent from the source' case)."""
        item = self.results.itemAt(pos)
        if item is None:
            return
        payload = item.data(2, Qt.UserRole)
        menu = QMenu(self.results)
        act_feat = None
        act_class = None
        if self._payload_flaggable(payload) and not self._is_excluded(payload):
            act_feat = menu.addAction("⚑ Flag this feature as a known fault")
        # Whole-class exclusion: ENC payloads carry the per-feature class in
        # slot 3. Offer it when we have a real class that isn't already excluded.
        klass = (payload[3] if payload and payload[0] == "enc"
                 and len(payload) > 3 else "")
        if klass and klass.upper() not in self._exclusions["classes"]:
            act_class = menu.addAction(
                f"Exclude all “{klass}” from this recipe")
        if not menu.actions():
            return
        chosen = menu.exec(self.results.viewport().mapToGlobal(pos))
        if chosen is None:
            return
        if chosen is act_feat:
            if self._add_exclusion(payload):
                self._show_leaf_excluded(item)
                self._refresh_faults_button()
                self._write_back_recipe()
        elif chosen is act_class:
            self._exclusions["classes"].append(klass.upper())
            self._refresh_faults_button()
            self._write_back_recipe()
            self.summary.setText(
                f"Excluded all “{klass}” from this recipe — applies next run.")

    def _refresh_faults_button(self):
        n = (len(self._exclusions["enc"]) + len(self._exclusions["source"])
             + len(self._exclusions["classes"]))
        if hasattr(self, "faults_btn"):
            self.faults_btn.setText(
                f"Known faults ({n})…" if n else "Known faults…")

    def _write_back_recipe(self):
        """Persist exclusions to the recipe file if we have one, so they survive
        the session. Without a saved recipe they stay in memory and apply to the
        next run; they're written when the user saves the recipe."""
        if not self._recipe_path:
            self.summary.setText(
                "Known fault flagged — applies to the next run. Save the recipe "
                "to keep it for future sessions.")
            return
        try:
            self._recipe_from_ui().save(self._recipe_path)
            self.summary.setText(
                f"Known fault flagged and saved to "
                f"{os.path.basename(self._recipe_path)}.")
        except Exception as exc:                  # noqa: BLE001
            QMessageBox.warning(self, "Source validation",
                                f"Could not write the recipe:\n{exc!r}")

    def _manage_known_faults(self):
        """List the recipe's known-fault exclusions and let the user remove any.
        Covers the ENC denylist (LNAM), the source denylist (id), and any
        whole-class exclusions stored in the recipe."""
        rows = []   # (label, kind, key)
        for e in self._exclusions["classes"]:
            rows.append((f"ENC class excluded:  {e}", "classes", e))
        for e in self._exclusions["enc"]:
            nm = e.get("objnam") or ""
            ln = e.get("lnam") or ""
            rows.append((f"ENC feature:  {nm or '(no name)'}   [{ln}]",
                         "enc", ln))
        for e in self._exclusions["source"]:
            rows.append((f"Source feature:  {e.get('id')}", "source",
                         str(e.get("id"))))

        dlg = QDialog(self)
        dlg.setWindowTitle("Known faults in this recipe")
        v = QVBoxLayout(dlg)
        if rows:
            v.addWidget(QLabel("Tick any to remove, then Remove selected. "
                               "Removed faults reappear as findings next run."))
        else:
            v.addWidget(QLabel("No known faults stored in this recipe yet.\n"
                               "Flag them from the results list after a run."))
        lst = QListWidget()
        lst.setSelectionMode(QListWidget.NoSelection)
        for label, kind, key in rows:
            it = QListWidgetItem(label)
            it.setFlags(it.flags() | Qt.ItemIsUserCheckable)
            it.setCheckState(Qt.Unchecked)
            it.setData(Qt.UserRole, (kind, key))
            lst.addItem(it)
        v.addWidget(lst, 1)

        btns = QHBoxLayout()
        rm = QPushButton("Remove selected")
        rm.setEnabled(bool(rows))
        close = QPushButton("Close")
        btns.addStretch(1)
        btns.addWidget(rm)
        btns.addWidget(close)
        v.addLayout(btns)

        def _remove():
            drop = [lst.item(i).data(Qt.UserRole) for i in range(lst.count())
                    if lst.item(i).checkState() == Qt.Checked]
            if not drop:
                dlg.accept()
                return
            for kind, key in drop:
                if kind == "classes":
                    self._exclusions["classes"] = [
                        c for c in self._exclusions["classes"] if c != key]
                elif kind == "enc":
                    self._exclusions["enc"] = [
                        e for e in self._exclusions["enc"]
                        if str(e.get("lnam", "")).upper() != key.upper()]
                else:
                    self._exclusions["source"] = [
                        e for e in self._exclusions["source"]
                        if str(e.get("id")) != key]
            self._refresh_faults_button()
            self._write_back_recipe()
            dlg.accept()

        rm.clicked.connect(_remove)
        close.clicked.connect(dlg.reject)
        dlg.resize(460, 360)
        dlg.exec()

    # -- exports (mirror the Auditor) ----------------------------------------
    def _export_html(self):
        if not self._report:
            return
        path, _ = QFileDialog.getSaveFileName(
            self, "Export source-validation report",
            os.path.join(_documents_dir(),
                         export_basename("sourcevalidator", self._source)
                         + ".html"),
            "HTML document (*.html)")
        if not path:
            return
        ctx = self._context or {}
        from .sourceval import validator_html
        enc_name = source_name = ""
        try:
            entry = self._enc_entry()
            enc_name = entry["label"] if entry else ""
            slyr = self._current_source_layer()
            source_name = slyr.name() if slyr else ctx.get("source", "")
        except Exception:                             # noqa: BLE001
            pass
        html = validator_html(
            self._report, enc_name=enc_name,
            source_name=source_name, context=ctx)
        with open(path, "w", encoding="utf-8") as fh:
            fh.write(html)
        self.iface.messageBar().pushInfo(
            "ENCore GIS — Source validation", f"Report exported to {path}")
        _open_file(path)

    def _export_csv(self):
        if not self._report:
            return
        path, _ = QFileDialog.getSaveFileName(
            self, "Export CSV",
            os.path.join(_documents_dir(),
                         export_basename("sourcevalidator", self._source)
                         + ".csv"),
            "CSV files (*.csv)")
        if not path:
            return
        with open(path, "w", encoding="utf-8", newline="") as fh:
            fh.write(self._report.to_csv())
        self.iface.messageBar().pushInfo(
            "ENCore GIS — Source validation", f"Exported to {path}")

    def _add_flags_layer(self):
        if not self._report:
            return
        layer = QgsVectorLayer(
            "Point?crs=EPSG:4326", "ENC source-validation findings", "memory")
        dp = layer.dataProvider()
        dp.addAttributes([
            QgsField("severity", QVariant.String),
            QgsField("check", QVariant.String),
            QgsField("cell", QVariant.String),
            QgsField("class", QVariant.String),
            QgsField("fid", QVariant.String),
            QgsField("detail", QVariant.String),
        ])
        layer.updateFields()
        feats = []
        for f in self._report.findings:
            if not f.geom_wkt:
                continue
            feat = QgsFeature(layer.fields())
            feat.setGeometry(QgsGeometry.fromWkt(f.geom_wkt))
            feat.setAttributes([
                f.check.severity, f.check.name, f.cell, f.klass,
                str(f.fid), f.detail])
            feats.append(feat)
        dp.addFeatures(feats)
        layer.updateExtents()
        QgsProject.instance().addMapLayer(layer)
        self.iface.messageBar().pushInfo(
            "ENCore GIS — Source validation",
            f"Added {len(feats)} finding location(s) to the map.")
