# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
auditor_ui.py
-------------
The **Auditor** tab: audit a chosen folder or zip of S-57 cells against the
S-58 checks and produce an exportable pass/fail document.

Unlike the older Validation tab (which validated whatever charts were ticked in
the Charts tab), the Auditor is self-contained: it takes the shared ENC source
and stages it LEANLY via ``enc_scanner.stage_for_audit`` — a fast,
single-exchange-set gate (no edition dedup, no per-cell DSID pass) so the checks
start running quickly. It then runs the engine and shows a **per-check
PASS/FAIL** list grouped by severity using each check's stable plain-language
name. The S-58 number rides along as a citation.

Single-exchange-set policy (``stage_for_audit``): a source with **more than one**
CATALOG.0xx is refused ("Auditor is only available for single exchange sets"); a
source with **one** is audited in full; a source with **none** (loose cell files)
is audited but the Exchange-set checks are skipped and the report notes that
CATALOG.031/exchange-set structure were not tested.

Exports:
  * **HTML audit document** — the full per-check report (``Report.to_html``).
  * **CSV (per check)**      — one row per check, PASS/FAIL (``to_check_csv``).
  * **CSV (findings)**       — one row per finding, the detail (``to_csv``).
  * **Flags layer**          — placed findings as an in-memory point layer.

All GDAL/validation/scanner imports are deferred into the worker thread so
importing this module at plugin load stays light and never fails if GDAL is
momentarily unavailable.
"""

import os
import time

from qgis.PyQt.QtCore import Qt, QThread, QTimer, pyqtSignal
from qgis.PyQt.QtWidgets import (
    QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QCheckBox,
    QTreeWidget, QTreeWidgetItem, QFileDialog, QHeaderView, QMenu,
    QPlainTextEdit, QStackedWidget, QSizePolicy, QComboBox,
)
from qgis.PyQt.QtGui import QColor, QBrush
from qgis.core import (
    QgsMessageLog, Qgis, QgsVectorLayer, QgsField, QgsFeature, QgsGeometry,
    QgsProject, QgsCoordinateReferenceSystem, QgsCoordinateTransform,
    QgsPointXY, QgsRectangle,
)
from qgis.PyQt.QtCore import QVariant


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


# Per-profile version manifest line shown in the report header. A full
# per-profile manifest object is planned; for now this single baseline label is
# the one place to change when the citation baseline moves.
try:
    from .validation import S58_EDITION
except Exception:                                   # noqa: BLE001
    S58_EDITION = "8.0.0"
PROFILE_LABEL = f"S-58 {S58_EDITION} / UOC 4.4.0 (baseline)"

# Product discrimination by DSID PRSP (product specification): a maritime ENC
# declares PRSP 1, an Inland ENC declares PRSP 10. Only Inland products are
# validated against an IENC Feature Catalogue; maritime products use the S-57
# catalogue alone. This is what stops a maritime product (e.g. an NLHO ENC,
# PRED 2.0) being mis-treated as Inland when its PRED happens not to match any FC.
_INLAND_ENC_PRSP = {"10"}
_MARITIME_ENC_PRSP = {"1"}


def _product_kind(prsp):
    """Human product label from DSID PRSP: 'Inland ENC', 'Maritime ENC', or a
    generic 'ENC (PRSP x)' for anything else."""
    p = str(prsp).strip()
    if p in _INLAND_ENC_PRSP:
        return "Inland ENC"
    if p in _MARITIME_ENC_PRSP:
        return "Maritime ENC"
    return f"ENC (PRSP {p})" if p else "ENC (PRSP unknown)"


_SEV_COLOUR = {"C": "#c0392b", "A": "#7d3c98", "E": "#d68910", "W": "#2471a3"}
_SEV_LABEL = {"C": "Critical checks", "A": "Alert checks", "E": "Error checks",
              "W": "Warning checks"}
_STATUS_COLOUR = {"PASS": "#1e8449", "FAIL": "#c0392b", "ALERT": "#7d3c98",
                  "ERROR": "#6c3483"}
_STATUS_GLYPH = {"PASS": "\u2714", "FAIL": "\u2718", "ALERT": "\u26a0",
                 "ERROR": "\u26a0"}


class _AuditCancelled(Exception):
    """Raised from the progress callback to unwind a run the user aborted."""


class AuditWorker(QThread):
    """Stages a source leanly, runs the S-58 checks off the UI thread, and
    returns a Report plus a geometry index (for zoom/flags) and a context dict
    (source label, cell names, exchange mode / note) used by the export header.

    ``finished(report, geom_index, context)`` — report is ``None`` when no cells
    were found OR the source was refused for holding more than one exchange set
    (``context["denied"]`` carries the reason so the UI can explain).
    """

    progress = pyqtSignal(int, int, str)
    check_started = pyqtSignal(int, int, str, str)         # done,total,name,number
    step_done = pyqtSignal(int, int, str, str, float, int, str)
    staged = pyqtSignal(int)
    phase = pyqtSignal(str)                                 # engine-run phase label
    diff_ready = pyqtSignal(object)                         # ReportDiff (diff mode)
    finished = pyqtSignal(object, object, object)
    failed = pyqtSignal(str)
    aborted = pyqtSignal()

    def __init__(self, source, catalogue_dir, numbers=None, ienc_fc_path=None,
                 ienc_fc_dir=None, engine_mode="native"):
        super().__init__()
        self.source = source
        self.catalogue_dir = catalogue_dir
        self.numbers = numbers
        self.ienc_fc_path = ienc_fc_path     # explicit FC XML (a saved setting)
        self.ienc_fc_dir = ienc_fc_dir       # folder of edition-named FC XMLs
        # Reader engine: "native" (the in-house ISO 8211 reader, now the default —
        # no GDAL S-57 driver), "gdal" (legacy GDAL S-57 driver, kept for
        # cross-checking), or "diff" (run both and compare).
        self.engine_mode = engine_mode
        self._cancel = False

    def request_cancel(self):
        """Ask the run to stop. Cooperative: the progress callback checks this
        between checks and unwinds, so a long-running check finishes first."""
        self._cancel = True

    def run(self):
        staging_dir = None
        try:
            # Deferred imports: GDAL only touched once a run actually starts.
            from .enc_scanner import stage_for_audit
            from .validation import Catalogue, Validator
            from .validation.model import OgrDataset
            from .validation.geometry import GeomOps
            from .validation.engine import load_all_checks

            # Lean staging: gate on the exchange-set count and collect the base
            # cells fast (no per-cell DSID dedup). 'multi' is refused outright;
            # 'loose' (no CATALOG.0xx) audits the cells but skips + notes the
            # Exchange-set checks.
            staging = stage_for_audit(self.source)
            staging_dir = staging.staging_dir
            cell_paths = staging.cell_paths
            context = {
                "source": self.source,
                "cells": sorted({
                    os.path.splitext(os.path.basename(p))[0].upper()
                    for p in cell_paths}),
                "exchange_mode": staging.mode,
                "catalog_count": staging.catalog_count,
            }
            if staging.mode == "multi":
                context["denied"] = staging.message
                self.finished.emit(None, {}, context)
                return
            if not cell_paths:
                self.finished.emit(None, {}, context)
                return
            if staging.mode == "loose":
                context["exchange_note"] = (
                    "CATALOG.031 and exchange-set structure were not tested: no "
                    "exchange-set catalogue was found in the source (loose cell "
                    "files). The cells themselves were audited normally.")
            self.staged.emit(len(cell_paths))

            # Per-cell coding + edition (DSID): the product spec (PRSP), its
            # edition (PRED), the cell edition (EDTN), issue date (ISDT) and
            # agency (AGEN) of every cell going into the audit. Surfaced in the
            # report and used to pick the matching IENC Feature Catalogue by PRED.
            from .enc_scanner import read_dsid_full
            cell_meta = []
            for p in cell_paths:
                try:
                    d = read_dsid_full(p)
                except Exception:                           # noqa: BLE001
                    d = {}
                cell_meta.append({
                    "cell": os.path.splitext(os.path.basename(p))[0].upper(),
                    "PRSP": d.get("PRSP", ""), "PRED": d.get("PRED", ""),
                    "EDTN": d.get("EDTN", ""), "ISDT": d.get("ISDT", ""),
                    "AGEN": d.get("AGEN", ""),
                    "product": _product_kind(d.get("PRSP", "")),
                })
            context["cell_meta"] = sorted(cell_meta, key=lambda m: m["cell"])
            from collections import Counter
            # Only INLAND ENC cells are FC-eligible (discriminated by PRSP). PRED
            # counts for FC selection are taken over the Inland cells only, so a
            # maritime product never drives or receives an IENC FC.
            pred_counts = Counter(m["PRED"] for m in cell_meta
                                  if m["product"] == "Inland ENC" and m["PRED"])
            dominant_pred = pred_counts.most_common(1)[0][0] if pred_counts else ""
            context["dataset_pred"] = dominant_pred
            context["pred_breakdown"] = dict(pred_counts)
            context["product_breakdown"] = dict(
                Counter(m["product"] for m in cell_meta))

            load_all_checks()
            catalogue = Catalogue(self.catalogue_dir)
            # PER-CELL Inland ENC FC: each cell is validated against the FC
            # matching ITS declared edition (PRED). An explicit saved FC path
            # pins one FC for everything; otherwise each distinct PRED gets the
            # installed FC whose edition matches (newest as the fallback). So a
            # mixed set (e.g. cells of PRED 2.3 AND 2.4) validates each cell
            # against the right standard. Best-effort.
            from .validation.ienc_catalogue import (IencCatalogue,
                                                    find_fc_for_edition,
                                                    find_fc_xml)
            # Map EVERY cell: Inland cells -> their PRED; maritime/other cells ->
            # '' (a no-FC marker), so a maritime cell in a mixed set is never
            # routed to an Inland FC (ienc_for returns None for it).
            cell_pred = {m["cell"]: (m["PRED"] if m["product"] == "Inland ENC"
                                     else "") for m in cell_meta}
            explicit = (self.ienc_fc_path
                        if self.ienc_fc_path
                        and os.path.isfile(self.ienc_fc_path) else None)
            have_dir = bool(self.ienc_fc_dir and os.path.isdir(self.ienc_fc_dir))
            newest = find_fc_xml(self.ienc_fc_dir) if have_dir else None
            ienc_by_pred, fc_records, parsed = {}, [], {}
            for pred in sorted(pred_counts):
                path, why = None, ""
                if explicit:
                    path, why = explicit, "explicit setting"
                elif have_dir:
                    m = find_fc_for_edition(self.ienc_fc_dir, pred)
                    if m:
                        path, why = m, f"matched PRED {pred}"
                    elif newest:
                        path, why = newest, f"no FC for PRED {pred}; newest used"
                if not (path and os.path.isfile(path)):
                    continue
                try:
                    if path not in parsed:
                        parsed[path] = IencCatalogue.from_xml(path)
                    fc = parsed[path]
                except Exception as exc:                    # noqa: BLE001
                    context.setdefault("ienc_fc_errors", []).append(
                        f"PRED {pred}: {exc}")
                    _log(f"IENC FC for PRED {pred} could not load: {exc!r}",
                         Qgis.Warning)
                    continue
                ienc_by_pred[pred] = fc
                fc_records.append({
                    "pred": pred, "edition": fc.edition,
                    "file": os.path.basename(path), "why": why,
                    "features": len(fc.features),
                    "attributes": len(fc.attributes),
                    "cells": pred_counts.get(pred, 0)})
            if ienc_by_pred:
                primary = (ienc_by_pred.get(dominant_pred)
                           or next(iter(ienc_by_pred.values())))
                catalogue.attach_ienc_set(ienc_by_pred, cell_pred,
                                          primary=primary)
                context["ienc_fcs"] = fc_records
                _log(f"Inland ENC overlay: {len(ienc_by_pred)} FC edition(s) "
                     f"for PRED(s) {', '.join(sorted(ienc_by_pred))}.", Qgis.Info)
            # The native S-57 record set, built ONCE: it is both the source for a
            # native-reader dataset and the value for ds.s57 (the record-level
            # and exchange checks). Sharing it means a "diff" run does not parse
            # the cells twice.
            try:
                from .s57 import S57RecordSet
                record_set = S57RecordSet.from_paths(cell_paths)
            except Exception as exc:                    # noqa: BLE001
                record_set = None
                _log(f"Record model unavailable: {exc!r}", Qgis.Warning)

            # Exchange-set / catalogue scrutiny, built from the ORIGINAL source
            # (the staging dir holds only cells, not CATALOG.0xx).
            try:
                from .s57 import ExchangeSet
                exchange = ExchangeSet.from_source(self.source)
            except Exception as exc:                    # noqa: BLE001
                exchange = None
                _log(f"Exchange-set model unavailable: {exc!r}", Qgis.Warning)

            from .validation.engine import REGISTRY
            name_by_number = {str(m.number): m.name for m in REGISTRY}

            # In 'loose' mode (no CATALOG.0xx) the Exchange-set checks cannot be
            # tested — drop them from the run so they neither fire a spurious
            # "no catalogue" finding nor count as PASS; the report notes they
            # were not tested. The per-cell checks (incl. mandatory meta) stay.
            run_numbers = self.numbers
            if staging.mode == "loose":
                exch_numbers = {m.number for m in REGISTRY
                                if m.category == "Exchange set"}
                if run_numbers is None:
                    run_numbers = [m.number for m in REGISTRY
                                   if m.category != "Exchange set"]
                else:
                    run_numbers = [n for n in run_numbers
                                   if n not in exch_numbers]
                # Guard the Validator's "empty list == run everything" rule: if
                # every selected check was an Exchange-set check, there is
                # genuinely nothing testable on a catalogue-less source — refuse
                # rather than silently running the full suite.
                if not run_numbers:
                    context["denied"] = (
                        "Only Exchange-set checks were selected, but the source "
                        "has no exchange-set catalogue (loose cell files) — "
                        "nothing to run.")
                    self.finished.emit(None, {}, context)
                    return

            def cb(done, total, number):
                if self._cancel:
                    raise _AuditCancelled()
                self.progress.emit(done, total, str(number))
                self.check_started.emit(
                    done, total, name_by_number.get(str(number), str(number)),
                    str(number))

            def on_check(done, total, meta, seconds, n_found, status):
                self.step_done.emit(done, total, meta.name, str(meta.number),
                                    float(seconds), int(n_found), status)

            geom_ops = GeomOps()

            def build_dataset(kind):
                """``kind='gdal'`` → OgrDataset (GDAL S-57 driver); ``'native'``
                → NativeDataset with the OGR geometry bridge, so the GEOS spatial
                checks run on it unchanged and a diff isolates the *reader*. Both
                carry the shared native record set (ds.s57) and exchange model."""
                if kind == "native":
                    from .validation.model import NativeDataset
                    if record_set is None:
                        raise RuntimeError("native record set unavailable")
                    ds = NativeDataset(record_set, catalogue, ogr_geometry=True)
                else:
                    ds = OgrDataset(cell_paths)
                    # Cells GDAL could not read (e.g. arc/ARCC geometry) are
                    # skipped for the GDAL-based checks but still audited by the
                    # record-level checks via ds.s57.
                    le = getattr(ds, "load_errors", None) or []
                    if le:
                        context["load_errors"] = le
                        for cell, msg in le:
                            _log(f"GDAL could not read {cell}: {msg} — record-"
                                 f"level checks still apply.", Qgis.Warning)
                ds.s57 = record_set
                ds.exchange = exchange
                return ds

            def run_on(ds):
                return Validator(catalogue, geom_ops).run(
                    ds, numbers=run_numbers, progress=cb, on_check=on_check)

            mode = self.engine_mode
            if mode == "native":
                self.phase.emit("Native ISO 8211 reader (GDAL-free read)")
                dataset = build_dataset("native")
                report = run_on(dataset)
            elif mode == "diff":
                self.phase.emit("Phase 1/2 — GDAL S-57 driver")
                gdal_ds = build_dataset("gdal")
                gdal_report = run_on(gdal_ds)
                self.phase.emit("Phase 2/2 — native ISO 8211 reader")
                native_ds = build_dataset("native")
                native_report = run_on(native_ds)
                from .validation import diff_reports
                rdiff = diff_reports(gdal_report, native_report,
                                     label_a="GDAL", label_b="Native")
                context["engine_diff"] = rdiff
                self.diff_ready.emit(rdiff)
                # The report SHOWN is the current default reader (GDAL), so a diff
                # run still surfaces the familiar audit; the diff is the overlay.
                dataset, report = gdal_ds, gdal_report
            else:
                dataset = build_dataset("gdal")
                report = run_on(dataset)

            # Representative-point index for zoom/flags; stamp each finding.
            index = {}
            for f in dataset.features():
                if f.geom is None:
                    continue
                try:
                    rep = f.geom.PointOnSurface()
                except Exception:                       # noqa: BLE001
                    rep = None
                if rep is None:
                    try:
                        rep = f.geom.Centroid()
                    except Exception:                   # noqa: BLE001
                        rep = None
                if rep is not None:
                    index[(f.cell, f.klass, f.fid)] = rep.ExportToWkt()
            for fnd in report.findings:
                fnd.geom_wkt = index.get((fnd.cell, fnd.klass, fnd.fid))

            # Release any open zip handle held by the exchange-set model.
            try:
                if getattr(dataset, "exchange", None) is not None:
                    dataset.exchange.close()
            except Exception:                           # noqa: BLE001
                pass

            self.finished.emit(report, index, context)
        except _AuditCancelled:
            self.aborted.emit()
        except Exception as exc:                        # noqa: BLE001
            _log(f"Audit failed: {exc!r}", Qgis.Critical)
            self.failed.emit(str(exc))
        finally:
            # Remove the temp staging dir we extracted a zip into (folders are
            # read in place, so staging_dir is None then). Findings already hold
            # in-memory geometry clones, so the files are safe to delete now.
            if staging_dir:
                import shutil
                shutil.rmtree(staging_dir, ignore_errors=True)


class S158Worker(QThread):
    """Validates the S-100 Tier-1 products in a source against the IHO S-158
    catalogues (S-101 via our native ISO 8211 engine, S-102/104/111 via GDAL).
    Runs off the GUI thread — it builds only plain Python objects (no
    QgsVectorLayer), so it is safe off-thread."""

    done = pyqtSignal(object)                              # validation.s158.S158Audit
    failed = pyqtSignal(str)

    def __init__(self, source, check_ids=None):
        super().__init__()
        self.source = source
        self.check_ids = check_ids                        # None = run everything

    def run(self):
        try:
            from .validation import s158
            self.done.emit(s158.audit_source(
                self.source, check_ids=self.check_ids))
        except Exception as exc:                          # noqa: BLE001
            _log(f"S-158 audit failed: {exc!r}", Qgis.Critical)
            self.failed.emit(str(exc))


class AuditorPanel(QWidget):
    """The Auditor tab. Give it the iface and the plugin directory (to find
    s57catalogue/). It manages its own source selection and run lifecycle."""

    def __init__(self, iface, plugin_dir, parent=None):
        super().__init__(parent)
        self.iface = iface
        self.catalogue_dir = os.path.join(plugin_dir, "s57catalogue")
        self._source = None
        self._running = False
        self._worker = None
        self._s158_worker = None
        self._s158_audit = None
        self._source_is_s100 = False
        self._s158_products = set()
        self._report = None
        self._context = None
        self._diff = None                    # last ReportDiff (Compare runs)
        # Live "this check is still running" feedback for slow checks.
        self._cur = None                     # (done, total, name, t0)
        self._run_t0 = None
        self._tick = QTimer(self)
        self._tick.setInterval(500)
        self._tick.timeout.connect(self._on_tick)
        self._build_ui()

    # -- UI -------------------------------------------------------------------
    def _build_ui(self):
        layout = QVBoxLayout(self)
        layout.setContentsMargins(4, 4, 4, 4)

        # ── Always-on header: source · run/edit · show-passed · summary ──────
        # The source comes from the shared "ENC source" above the tab system
        # (fed in by the dock via set_source) so the Auditor is part of the same
        # workflow rather than a separate picker.
        self.source_lbl = QLabel("Set an ENC source above (Folder / Zip).")
        self.source_lbl.setWordWrap(True)
        self.source_lbl.setStyleSheet("color:#555;")
        layout.addWidget(self.source_lbl)

        ctl = QHBoxLayout()
        self.run_btn = QPushButton("Run audit")
        self.run_btn.setToolTip("Execute the selected audits.")
        self.run_btn.setEnabled(False)
        self.run_btn.clicked.connect(self._run)
        ctl.addWidget(self.run_btn)
        # Results-page button: aborts a running audit and returns to the picker;
        # when idle it just returns to the picker. Label flips in _run / _on_*.
        self.edit_tests_btn = QPushButton("\u2039 Tests to run")
        self.edit_tests_btn.setToolTip("Back to the test selection.")
        self.edit_tests_btn.clicked.connect(self._on_back_or_abort)
        self.edit_tests_btn.setVisible(False)
        ctl.addWidget(self.edit_tests_btn)
        ctl.addStretch()
        self.cb_show_pass = QCheckBox("Show passed")
        self.cb_show_pass.setChecked(True)
        self.cb_show_pass.setToolTip("Show checks that passed, not just failures.")
        self.cb_show_pass.stateChanged.connect(self._repopulate)
        ctl.addWidget(self.cb_show_pass)
        layout.addLayout(ctl)

        self.summary = QLabel("Choose tests, then run the audit.")
        self.summary.setWordWrap(True)
        layout.addWidget(self.summary)

        # ── Two-state body: a picker page and a results page ────────────────
        self.stack = QStackedWidget()
        layout.addWidget(self.stack, 1)

        # Page 0 — SELECT: the whole area is the tests-to-run overview. Every
        # registered check, grouped by severity, each with a checkbox (all
        # checked by default); unchecked checks are skipped on the next run.
        select_page = QWidget()
        sel = QVBoxLayout(select_page)
        sel.setContentsMargins(0, 0, 0, 0)

        # Reader engine selector. Default is the verified GDAL S-57 driver; the
        # native reader and the GDAL-vs-native diff are the stage-3 verification
        # path (the diff is the overlay that gates flipping the default).
        engine_row = QHBoxLayout()
        engine_row.addWidget(QLabel("Reader engine:"))
        self.engine_combo = QComboBox()
        self.engine_combo.addItem("Native ISO 8211 reader (default)", "native")
        self.engine_combo.addItem("GDAL S-57 driver", "gdal")
        self.engine_combo.addItem("Compare: native vs GDAL (diff)", "diff")
        self.engine_combo.setToolTip(
            "Native: read cells with the in-house ISO 8211 engine (no GDAL "
            "S-57 driver) — the default.\n"
            "GDAL: the legacy GDAL S-57 driver, kept for cross-checking.\n"
            "Compare: run both and report any per-check differences.")
        engine_row.addWidget(self.engine_combo, 1)
        sel.addLayout(engine_row)

        tests_row = QHBoxLayout()
        tests_row.addWidget(QLabel("Tests to run"))
        self.tests_count_lbl = QLabel("")
        self.tests_count_lbl.setStyleSheet("color:#555;")
        tests_row.addWidget(self.tests_count_lbl)
        tests_row.addStretch()
        self.tests_all_btn = QPushButton("All")
        self.tests_all_btn.setFixedWidth(44)
        self.tests_all_btn.clicked.connect(lambda: self._set_all_tests(True))
        self.tests_none_btn = QPushButton("None")
        self.tests_none_btn.setFixedWidth(50)
        self.tests_none_btn.clicked.connect(lambda: self._set_all_tests(False))
        self.settings_btn = QPushButton("Methodology…")
        self.settings_btn.setToolTip(
            "Export the Auditor settings & methodology document (checks + "
            "validation status, FC↔PRED mapping, custom choices) for "
            "peer review.")
        self.settings_btn.clicked.connect(self._export_settings)
        tests_row.addWidget(self.tests_all_btn)
        tests_row.addWidget(self.tests_none_btn)
        tests_row.addWidget(self.settings_btn)
        sel.addLayout(tests_row)

        self.tests_tree = QTreeWidget()
        self.tests_tree.setHeaderLabels(["S-58 / code", "Check name"])
        self.tests_tree.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        self.tests_tree.header().setSectionResizeMode(
            0, QHeaderView.ResizeToContents)
        self.tests_tree.itemChanged.connect(self._on_test_item_changed)
        sel.addWidget(self.tests_tree, 1)
        self._build_test_list()
        self.stack.addWidget(select_page)

        # Page 1 — RESULTS: status log + per-check detail + export/flags. The
        # status log is a live per-check trace with timings (doubles as a
        # profiler); the detail tree holds the pass/fail outcome.
        results_page = QWidget()
        res = QVBoxLayout(results_page)
        res.setContentsMargins(0, 0, 0, 0)
        res.addWidget(QLabel("Status log"))
        self.log = QPlainTextEdit()
        self.log.setReadOnly(True)
        self.log.setMaximumBlockCount(2000)
        self.log.setMaximumHeight(140)
        self.log.setPlaceholderText("Per-check progress will appear here.")
        try:
            from qgis.PyQt.QtGui import QFont
            self.log.setFont(QFont("Monospace", 9))
        except Exception:                               # noqa: BLE001
            pass
        res.addWidget(self.log)

        self.tree = QTreeWidget()
        self.tree.setHeaderLabels(["Check", "S-58 / detail"])
        self.tree.header().setSectionResizeMode(0, QHeaderView.ResizeToContents)
        self.tree.itemClicked.connect(self._on_item_clicked)
        res.addWidget(self.tree, 1)

        bottom = 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.setToolTip("Per-check pass/fail, or the detailed findings.")
        self.csv_btn.clicked.connect(self._export_csv_menu)
        self.flags_btn = QPushButton("Add flags layer")
        self.flags_btn.setEnabled(False)
        self.flags_btn.clicked.connect(self._add_flags_layer)
        # Only meaningful after a "Compare" run; hidden otherwise.
        self.diff_btn = QPushButton("Export engine-diff…")
        self.diff_btn.setToolTip("Export the GDAL-vs-native report comparison.")
        self.diff_btn.setVisible(False)
        self.diff_btn.clicked.connect(self._export_diff)
        bottom.addWidget(self.html_btn)
        bottom.addWidget(self.csv_btn)
        bottom.addWidget(self.flags_btn)
        bottom.addWidget(self.diff_btn)
        bottom.addStretch()
        res.addLayout(bottom)
        self.stack.addWidget(results_page)

        self._show_page("select")

    def _show_page(self, which):
        """Swap the Auditor body between the test picker and the results view,
        so each gets the whole tab."""
        self.stack.setCurrentIndex(0 if which == "select" else 1)
        self.edit_tests_btn.setVisible(which == "results")

    def _on_back_or_abort(self):
        """The results-page button. While an audit is running this aborts it
        (cooperative — it stops at the next check boundary) and returns to the
        picker; when idle it simply returns to the picker."""
        if self._running and getattr(self, "_worker", None) is not None:
            self._worker.request_cancel()
            self._log_line("\u2715 Aborting after the current check\u2026")
        self._show_page("select")

    def _on_aborted(self):
        self._stop_ticker()
        self._running = False
        self.run_btn.setEnabled(bool(self._source))
        self.edit_tests_btn.setText("\u2039 Tests to run")
        self.edit_tests_btn.setToolTip("Back to the test selection.")
        self.summary.setText("Audit aborted.")
        self._log_line("\u2715 Audit aborted.")

    # -- source selection -----------------------------------------------------
    def set_source(self, path):
        """Adopt the shared ENC source (folder or zip) chosen above the tabs.
        Called by the dock whenever that source changes; an empty path disables
        the run. While an audit is running it only updates the label."""
        path = (path or "").strip()
        self._source = path or None
        # Detect S-100 Tier-1 sources so the picker tells the user which standard
        # will actually be applied \u2014 S-158 for S-100 products, S-58 otherwise
        # (the S-58 test selection below does not apply to S-100 sources).
        is_s100 = False
        products = set()
        if self._source:
            try:
                from .validation import s158 as _s158
                products = _s158.product_codes(self._source)
                is_s100 = bool(products)
            except Exception:                             # noqa: BLE001
                is_s100 = False
        prev = (self._source_is_s100, self._s158_products)
        self._source_is_s100 = is_s100
        self._s158_products = products
        # Rebuild the test picker to match the loaded product (S-158 vs S-58) —
        # only when it actually changed, and never mid-run.
        if not self._running and prev != (is_s100, products):
            try:
                self._rebuild_test_picker()
            except Exception as exc:                      # noqa: BLE001
                _log(f"Test picker rebuild failed: {exc!r}", Qgis.Warning)
        if self._source:
            kind = "zip" if path.lower().endswith(".zip") else "folder"
            std = ("S-100 Tier-1 \u2192 IHO S-158" if is_s100
                   else f"IHO {PROFILE_LABEL}")
            self.source_lbl.setText(f"Auditing {kind}: {path}\n({std})")
        else:
            self.source_lbl.setText("Set an ENC source above (Folder / Zip).")
        if not self._running:
            self.run_btn.setEnabled(bool(self._source))
            if not self._source:
                self.summary.setText("")
            elif is_s100:
                self.summary.setText(
                    "S-100 Tier-1 source \u2014 will validate against IHO S-158 "
                    "(S-101 read natively \u00b7 S-102/104/111 via GDAL). The S-58 "
                    "test selection below does not apply. Click \u201cRun "
                    "audit\u201d.")
            else:
                self.summary.setText("Ready. Click \u201cRun audit\u201d.")

    # -- test selection -------------------------------------------------------
    _SEV_ORDER = ("C", "A", "E", "W")
    _SEV_TITLE = {"C": "Critical", "A": "Alert", "E": "Error", "W": "Warning"}

    def _build_test_list(self):
        """Populate the tests tree from the check registry, grouped by severity,
        every check ticked by default."""
        try:
            from .validation import load_all_checks
            registry = load_all_checks()
        except Exception as exc:                        # noqa: BLE001
            _log(f"Could not load check registry: {exc!r}", Qgis.Warning)
            return
        self.tests_tree.blockSignals(True)
        self.tests_tree.clear()
        groups = {}
        for meta in registry:
            groups.setdefault(meta.severity, []).append(meta)
        for sev in self._SEV_ORDER:
            metas = groups.get(sev, [])
            if not metas:
                continue
            parent = QTreeWidgetItem(
                [f"{self._SEV_TITLE.get(sev, sev)} ({len(metas)})", ""])
            parent.setFlags(parent.flags() | Qt.ItemIsUserCheckable
                            | Qt.ItemIsAutoTristate)
            parent.setForeground(0, QBrush(QColor(_SEV_COLOUR.get(sev, "#000"))))
            parent.setData(0, Qt.UserRole, None)        # marks a group row
            self.tests_tree.addTopLevelItem(parent)
            for meta in sorted(metas, key=lambda m: m.title):
                leaf = QTreeWidgetItem([str(meta.number), meta.title])
                leaf.setFlags(leaf.flags() | Qt.ItemIsUserCheckable)
                leaf.setCheckState(0, Qt.Checked)
                leaf.setData(0, Qt.UserRole, meta.number)
                # Full message + the stable doc key on hover.
                leaf.setToolTip(1, f"{meta.message}\n[{meta.name}]")
                parent.addChild(leaf)
            parent.setExpanded(False)
        self.tests_tree.blockSignals(False)
        self._update_tests_count()

    _S158_PICKER_LABEL = {"100": "S-100 core", "101": "S-101 ENC",
                          "102": "S-102 bathymetry",
                          "104": "S-104 water level", "111": "S-111 currents"}

    def _rebuild_test_picker(self):
        """Choose the test list that matches the loaded source: the S-158 checks
        for the S-100 Tier-1 products present, else the S-58 / Inland-ENC checks."""
        if getattr(self, "_source_is_s100", False) and self._s158_products:
            self._build_s158_test_list()
        else:
            self.tests_tree.setHeaderLabels(["S-58 / code", "Check name"])
            self._build_test_list()

    def _build_s158_test_list(self):
        """Populate the tests tree with the S-158 checks for the loaded S-100
        products, grouped product → severity → check, every check ticked. Checks
        not yet validated against an official IHO test dataset carry a '*'."""
        from .validation import s100
        self.tests_tree.setHeaderLabels(["S-158 / code", "Check name"])
        self.tests_tree.blockSignals(True)
        self.tests_tree.clear()
        sev_title = {"C": "Critical", "E": "Error", "W": "Warning"}
        sev_colour = {"C": "#c0392b", "E": "#d68910", "W": "#2471a3"}
        for product in sorted(self._s158_products):
            cat = s100.catalogue(product)
            plabel = self._S158_PICKER_LABEL.get(product, product)
            proot = QTreeWidgetItem(
                [f"S-158-{product} — {plabel} ({len(cat)})", ""])
            proot.setFlags(proot.flags() | Qt.ItemIsUserCheckable
                           | Qt.ItemIsAutoTristate)
            proot.setData(0, Qt.UserRole, None)          # group row
            bf = proot.font(0)
            bf.setBold(True)
            proot.setFont(0, bf)
            self.tests_tree.addTopLevelItem(proot)
            bysev = {"C": [], "E": [], "W": []}
            for rec in cat:
                sev = s100.SEVERITY.get(rec.get("cls", ""), "W")
                bysev.setdefault(sev, []).append(rec)
            for sev in ("C", "E", "W"):
                recs = bysev.get(sev) or []
                if not recs:
                    continue
                snode = QTreeWidgetItem(
                    [f"{sev_title[sev]} ({len(recs)})", ""])
                snode.setFlags(snode.flags() | Qt.ItemIsUserCheckable
                               | Qt.ItemIsAutoTristate)
                snode.setData(0, Qt.UserRole, None)      # group row
                snode.setForeground(0, QBrush(QColor(sev_colour[sev])))
                proot.addChild(snode)
                for rec in recs:
                    cid = rec.get("id")
                    star = "" if s100.is_validated(cid) else " *"
                    title = (rec.get("message", "") or "").split("(", 1)[0].strip()
                    leaf = QTreeWidgetItem([cid + star, title])
                    leaf.setFlags(leaf.flags() | Qt.ItemIsUserCheckable)
                    leaf.setCheckState(0, Qt.Checked)
                    leaf.setData(0, Qt.UserRole, cid)
                    leaf.setToolTip(1, f"{rec.get('message', '')}\nSolution: "
                                    f"{rec.get('solution', '')}")
                    snode.addChild(leaf)
                snode.setExpanded(False)
            proot.setExpanded(len(self._s158_products) == 1)
        self.tests_tree.blockSignals(False)
        self._update_tests_count()

    def _iter_test_leaves(self):
        """Every check LEAF (an item carrying a check id in UserRole), at any
        depth — so it works for the 2-level S-58 tree (severity → check) and the
        3-level S-158 tree (product → severity → check)."""
        def walk(item):
            for i in range(item.childCount()):
                c = item.child(i)
                if c.data(0, Qt.UserRole) is not None:
                    yield c
                else:
                    yield from walk(c)
        yield from walk(self.tests_tree.invisibleRootItem())

    def _set_all_tests(self, checked):
        self.tests_tree.blockSignals(True)
        state = Qt.Checked if checked else Qt.Unchecked
        for leaf in self._iter_test_leaves():
            leaf.setCheckState(0, state)
        # sync group rows at every depth (AutoTristate parents derive from
        # children, but set them explicitly so a collapsed group shows right).

        def sync(item):
            for i in range(item.childCount()):
                c = item.child(i)
                if c.data(0, Qt.UserRole) is None and \
                        (c.flags() & Qt.ItemIsUserCheckable):
                    c.setCheckState(0, state)
                sync(c)
        sync(self.tests_tree.invisibleRootItem())
        self.tests_tree.blockSignals(False)
        self._update_tests_count()

    def _on_test_item_changed(self, _item, _col):
        # Qt's AutoTristate keeps parent/child in sync; just refresh the count.
        self._update_tests_count()

    def _selected_numbers(self):
        """Check numbers the user left ticked, or None when all are ticked
        (None means 'run everything')."""
        chosen, total = [], 0
        for leaf in self._iter_test_leaves():
            total += 1
            if leaf.checkState(0) == Qt.Checked:
                chosen.append(leaf.data(0, Qt.UserRole))
        if total and len(chosen) == total:
            return None
        return chosen

    def _update_tests_count(self):
        total = sum(1 for _ in self._iter_test_leaves())
        chosen = sum(1 for leaf in self._iter_test_leaves()
                     if leaf.checkState(0) == Qt.Checked)
        self.tests_count_lbl.setText(f"{chosen}/{total} selected")

    # -- run ------------------------------------------------------------------
    def _ienc_fc_path(self):
        """An EXPLICIT Inland ENC Feature Catalogue XML to pin (a saved setting),
        or None. When None, the worker auto-selects the FC in _ienc_fc_dir() whose
        edition matches the cells' PRED."""
        try:
            from qgis.PyQt.QtCore import QSettings
            path = QSettings().value("ENCoreGIS/ienc_fc_path", "", type=str)
            if path and os.path.isfile(path):
                return path
        except Exception:                               # noqa: BLE001
            pass
        return None

    def _ienc_fc_dir(self):
        """Folder of edition-named IENC Feature Catalogue XMLs (<plugin>/ienc_fc/);
        the worker picks the one matching each run's PRED."""
        return os.path.join(os.path.dirname(self.catalogue_dir), "ienc_fc")

    def _run(self):
        if not self._source:
            self.summary.setText("Choose a folder or zip first.")
            return
        # S-100 Tier-1 products (S-101 / S-102 / S-104 / S-111) are validated
        # against the IHO S-158 catalogues by a separate engine (S-101 native,
        # HDF5 via GDAL). Route them there; S-57 / Inland ENC stay on the S-58
        # path below. Name-only detection so the decision is instant.
        try:
            from .validation import s158 as _s158
            is_s100 = _s158.looks_like_s100(self._source)
        except Exception:                                 # noqa: BLE001
            is_s100 = False
        if is_s100:
            self._run_s158()
            return
        self._s158_audit = None                           # S-58 (ENC) path
        numbers = self._selected_numbers()
        if numbers is not None and len(numbers) == 0:
            self.summary.setText("No tests selected — tick at least one test "
                                 "to run.")
            return
        self.run_btn.setEnabled(False)
        self._running = True
        self._show_page("results")
        self.edit_tests_btn.setText("\u2715 Abort \u00b7 back to tests")
        self.edit_tests_btn.setToolTip(
            "Abort the running audit and return to the test selection.")
        self.summary.setText("Staging and reading cells\u2026")
        self.tree.clear()
        self.log.clear()
        self._cur = None
        self._run_t0 = time.monotonic()
        self._tick.start()
        self._diff = None
        self.diff_btn.setVisible(False)
        for b in (self.html_btn, self.csv_btn, self.flags_btn):
            b.setEnabled(False)
        engine_mode = self.engine_combo.currentData() or "native"
        self._worker = AuditWorker(
            self._source, self.catalogue_dir, numbers=numbers,
            ienc_fc_path=self._ienc_fc_path(), ienc_fc_dir=self._ienc_fc_dir(),
            engine_mode=engine_mode)
        self._worker.staged.connect(self._on_staged)
        self._worker.progress.connect(self._on_progress)
        self._worker.check_started.connect(self._on_check_started)
        self._worker.step_done.connect(self._on_step_done)
        self._worker.phase.connect(self._on_phase)
        self._worker.diff_ready.connect(self._on_diff_ready)
        self._worker.finished.connect(self._on_finished)
        self._worker.failed.connect(self._on_failed)
        self._worker.aborted.connect(self._on_aborted)
        self._worker.start()

    def _on_staged(self, n):
        self.summary.setText(f"Auditing {n} cell(s)…")
        self._log_line(f"Staged {n} cell(s); running checks…")

    def _on_phase(self, label):
        """Engine-run phase label (e.g. the two passes of a Compare run)."""
        self._log_line(f"→ {label}")

    def _on_diff_ready(self, rdiff):
        """A Compare (GDAL-vs-native) run produced its report diff."""
        self._diff = rdiff
        self.diff_btn.setVisible(True)
        self.diff_btn.setEnabled(True)
        self._log_line("─" * 8 + " engine diff: " + rdiff.summary_line())
        for d in rdiff.divergent()[:12]:
            self._log_line(
                f"   ≠ {d.number} {d.name}: "
                f"{rdiff.label_a}={d.status_a}({d.count_a}) "
                f"{rdiff.label_b}={d.status_b}({d.count_b})")

    def _on_progress(self, done, total, number):
        # Superseded by _on_check_started (which also has the check name); kept
        # for compatibility if only the basic progress signal is connected.
        pass

    def _on_check_started(self, done, total, name, number):
        self._cur = (done, total, name, time.monotonic())
        self.summary.setText(f"Running {done}/{total}: {name} (#{number})…")

    def _on_step_done(self, done, total, name, number, seconds, findings,
                      status):
        if status == "error":
            glyph = "\u26a0"
        elif findings:
            glyph = "\u2718"
        else:
            glyph = "\u2714"
        extra = f"  \u2014 {findings} finding(s)" if findings else ""
        slow = "  (slow)" if seconds >= 3.0 else ""
        self._log_line(
            f"{glyph} {done:>2}/{total}  {seconds:6.2f}s  {name}{extra}{slow}")
        self._cur = None     # this check finished; ticker idles until next start

    def _on_tick(self):
        # Keeps the summary moving while a single check is busy, so a long check
        # never looks like a freeze.
        if self._cur is None:
            return
        done, total, name, t0 = self._cur
        elapsed = time.monotonic() - t0
        self.summary.setText(
            f"Running {done}/{total}: {name}\u2026 (elapsed {elapsed:0.0f}s)")

    def _log_line(self, text):
        self.log.appendPlainText(text)

    def _stop_ticker(self):
        self._tick.stop()
        self._cur = None

    def _reset_back_button(self):
        self.edit_tests_btn.setText("\u2039 Tests to run")
        self.edit_tests_btn.setToolTip("Back to the test selection.")

    def _on_failed(self, message):
        self._stop_ticker()
        self._running = False
        self.run_btn.setEnabled(True)
        self._reset_back_button()
        self.summary.setText(f"Audit failed: {message}")
        self._log_line(f"\u26a0 FAILED: {message}")
        self.iface.messageBar().pushWarning("ENCore GIS — Auditor", message)

    def _on_finished(self, report, geom_index, context):
        self._stop_ticker()
        self._running = False
        self.run_btn.setEnabled(True)
        self._reset_back_button()
        self._context = context
        self._report = report
        self._s158_audit = None                           # this was an S-58 run
        if report is None:
            denied = (context or {}).get("denied")
            if denied:
                # More than one exchange set in the source: the Auditor is
                # single-exchange-set only.
                self.summary.setText(denied)
                self._log_line("✘ " + denied)
                return
            self.summary.setText("No S-57 cells found in the chosen source.")
            self._log_line("No S-57 cells found in the chosen source.")
            return
        self._populate_tree(report)
        self.html_btn.setEnabled(True)
        self.csv_btn.setEnabled(bool(report.ran))
        self.flags_btn.setEnabled(any(f.geom_wkt for f in report.findings))
        note = (context or {}).get("exchange_note")
        if note:
            self._log_line("ℹ " + note)
        verdict = report.summary_line()
        self.summary.setText(verdict)
        # The verdict lives in the panel summary and the audit report; we do not
        # raise the QGIS message bar on completion (it is not an alarm-worthy
        # event — the user asked for end-of-audit results to stay in the report).
        if report.errors:
            _log(f"{len(report.errors)} check(s) could not run; see "
                 f"message log.", Qgis.Warning)
        wall = (time.monotonic() - self._run_t0) if self._run_t0 else None
        engine_s = report.total_seconds()
        tail = f" (engine {engine_s:0.1f}s)" if engine_s else ""
        self._log_line(
            "\u2500" * 8 + f" done: {verdict}" +
            (f" \u2014 {wall:0.1f}s wall{tail}" if wall is not None else ""))
        if report.slowest(1):
            m, sec, _nf = report.slowest(1)[0]
            self._log_line(f"slowest: {sec:0.2f}s  {m.name}")

    def _repopulate(self):
        if getattr(self, "_s158_audit", None) is not None:
            self._populate_tree_s158(self._s158_audit)
        elif self._report is not None:
            self._populate_tree(self._report)

    def _populate_tree(self, report):
        self.tree.clear()
        show_pass = self.cb_show_pass.isChecked()
        for sev, rows in report.per_check_by_severity():
            flagged = sum(1 for _m, s, _f in rows if s in ("FAIL", "ALERT"))
            shown = rows if show_pass else [r for r in rows if r[1] != "PASS"]
            head = QTreeWidgetItem(
                [f"{_SEV_LABEL.get(sev, sev)} — {flagged}/{len(rows)} flagged", ""])
            head.setForeground(0, QBrush(QColor(_SEV_COLOUR.get(sev, "#000"))))
            self.tree.addTopLevelItem(head)
            for meta, status, fs in shown:
                ci = QTreeWidgetItem(
                    [f"{_STATUS_GLYPH.get(status, '')} {status}  {meta.name}",
                     f"#{meta.number}  {meta.message}"])
                ci.setForeground(
                    0, QBrush(QColor(_STATUS_COLOUR.get(status, "#000"))))
                ci.setToolTip(
                    1, f"{meta.message}\nSolution: {meta.solution}\n"
                    f"Conformity: {meta.conformity}")
                head.addChild(ci)
                for f in fs:
                    leaf = QTreeWidgetItem(
                        [f"{f.cell} \u00b7 {f.klass} \u00b7 fid {f.fid}",
                         f.message])
                    if f.geom_wkt:
                        leaf.setData(0, Qt.UserRole, f.geom_wkt)
                    ci.addChild(leaf)
                ci.setExpanded(status in ("FAIL", "ALERT"))
            head.setExpanded(True)

    # -- S-158 (S-100 Tier-1) validation --------------------------------------
    def _run_s158(self):
        """Validate the source's S-100 Tier-1 products against the S-158
        catalogues, off the GUI thread. Shares the results view with the S-58
        audit; the tree and HTML export switch to the S-158 renderers."""
        # Honour the picker: None = every implemented check; else the ticked ids.
        selected = self._selected_numbers()
        if selected is not None and len(selected) == 0:
            self.summary.setText("No tests selected — tick at least one test "
                                 "to run.")
            return
        check_ids = None if selected is None else set(selected)
        self.run_btn.setEnabled(False)
        self._running = True
        self._report = None
        self._s158_audit = None
        self._show_page("results")
        self.edit_tests_btn.setText("‹ Tests to run")
        self.edit_tests_btn.setToolTip("Back to the test selection.")
        self.summary.setText("Validating S-100 products against IHO S-158…")
        self.tree.clear()
        self.log.clear()
        self._run_t0 = time.monotonic()
        self._tick.start()
        self._diff = None
        self.diff_btn.setVisible(False)
        for b in (self.html_btn, self.csv_btn, self.flags_btn):
            b.setEnabled(False)
        self._log_line("Detecting and validating S-100 Tier-1 products "
                       "(S-101 native · S-102/104/111 via GDAL)…")
        self._s158_worker = S158Worker(self._source, check_ids=check_ids)
        self._s158_worker.done.connect(self._on_s158_finished)
        self._s158_worker.failed.connect(self._on_failed)
        self._s158_worker.start()

    def _on_s158_finished(self, audit):
        self._stop_ticker()
        self._running = False
        self.run_btn.setEnabled(True)
        self._reset_back_button()
        self._s158_audit = audit
        self._report = None
        products = audit.products() if audit else []
        if not products:
            self.summary.setText("No S-100 Tier-1 products found in the source.")
            self._log_line("No S-100 Tier-1 products found.")
            return
        self._populate_tree_s158(audit)
        self.html_btn.setEnabled(True)
        self.csv_btn.setEnabled(False)
        self.flags_btn.setEnabled(False)
        parts = [f"S-158-{p}: {nf} finding(s) ({impl}/{tot})"
                 for p, _nd, nf, impl, tot in audit.summary()]
        self.summary.setText("  ·  ".join(parts))
        wall = (time.monotonic() - self._run_t0) if self._run_t0 else None
        self._log_line(
            "─" * 8 + f" done: {audit.finding_count()} finding(s) across "
            f"{len(products)} product(s)"
            + (f" — {wall:0.1f}s" if wall is not None else ""))
        self._log_line(
            "Legend:  ✔ PASS · ✘ FAIL · ○ NOT-IMPLEMENTED.  A trailing * marks a "
            "check that runs but is not yet validated against an official IHO "
            "S-158 test dataset (provisional).")

    _S158_STATUS_GLYPH = {"PASS": "✔", "FAIL": "✘", "N/I": "○"}
    _S158_STATUS_COLOUR = {"PASS": "#1e8449", "FAIL": "#c0392b", "N/I": "#999"}
    _S158_PROD_LABEL = {"100": "S-100 core", "101": "S-101 ENC",
                        "102": "S-102 bathymetry",
                        "104": "S-104 water level", "111": "S-111 currents"}

    def _populate_tree_s158(self, audit):
        """Fill the results tree with EVERY S-158 check for each loaded product,
        product → severity → check, each with a PASS / FAIL / NOT-IMPLEMENTED
        status. 'Show passed' also governs the (many) not-implemented rows so the
        default view lists every available test for the loaded products."""
        from .validation import s100, s101
        from .validation.s158_report import _aggregate
        self.tree.clear()
        show_pass = self.cb_show_pass.isChecked()
        sev_label = {"C": "Critical", "E": "Error", "W": "Warning"}
        sev_colour = {"C": "#c0392b", "E": "#d68910", "W": "#2471a3"}
        for product in audit.products():
            reports = audit.reports[product]
            cat = s100.catalogue(product)
            ran, fails, n_ds, _errs = _aggregate(product, reports)
            if product == "101":
                impl, total = s101.coverage()
            elif product == "100":
                impl, total = s101.core_coverage()
            else:
                impl, total = s100.coverage(product)
            n_fail = len(fails)
            plabel = self._S158_PROD_LABEL.get(product, product)
            proot = QTreeWidgetItem(
                [f"S-158-{product} — {plabel}  ({n_ds} dataset(s))",
                 f"{n_fail} failed · {impl}/{total} checks implemented"])
            bf = proot.font(0)
            bf.setBold(True)
            proot.setFont(0, bf)
            self.tree.addTopLevelItem(proot)
            bysev = {"C": [], "E": [], "W": []}
            for rec in cat:
                cid = rec.get("id")
                sev = s100.SEVERITY.get(rec.get("cls", ""), "W")
                status = ("FAIL" if cid in fails
                          else "PASS" if cid in ran else "N/I")
                bysev.setdefault(sev, []).append((rec, status))
            for sev in ("C", "E", "W"):
                rows = bysev.get(sev) or []
                shown = (rows if show_pass
                         else [r for r in rows if r[1] == "FAIL"])
                if not shown:
                    continue
                nf = sum(1 for _r, s in rows if s == "FAIL")
                snode = QTreeWidgetItem(
                    [f"{sev_label[sev]} — {nf}/{len(rows)} failed", ""])
                snode.setForeground(0, QBrush(QColor(sev_colour[sev])))
                proot.addChild(snode)
                for rec, status in shown:
                    cid = rec.get("id")
                    title = (rec.get("message", "") or "").split("(", 1)[0].strip()
                    # '*' = implemented but not yet validated against an official
                    # IHO test dataset (runs anyway; result is provisional).
                    star = ("" if status == "N/I" or s100.is_validated(cid)
                            else " *")
                    ci = QTreeWidgetItem(
                        [f"{self._S158_STATUS_GLYPH.get(status, '')} {status}  "
                         f"{cid}  {title}{star}", rec.get("message", "")])
                    ci.setForeground(0, QBrush(QColor(
                        self._S158_STATUS_COLOUR.get(status, "#000"))))
                    ci.setToolTip(1, f"{rec.get('message', '')}\nSolution: "
                                  f"{rec.get('solution', '')}")
                    snode.addChild(ci)
                    for label, finding in fails.get(cid, [])[:500]:
                        loc = (label + " · " if label else "") + finding.location
                        ci.addChild(QTreeWidgetItem([loc, finding.detail or ""]))
                    ci.setExpanded(status == "FAIL")
                snode.setExpanded(True)
            proot.setExpanded(True)

    # -- click-to-zoom --------------------------------------------------------
    def _on_item_clicked(self, item, _col):
        wkt = item.data(0, Qt.UserRole)
        if not wkt:
            return
        geom = QgsGeometry.fromWkt(wkt)
        if geom.isEmpty():
            return
        pt = geom.asPoint()
        src = QgsCoordinateReferenceSystem("EPSG:4326")
        canvas = self.iface.mapCanvas()
        dst = canvas.mapSettings().destinationCrs()
        try:
            tr = QgsCoordinateTransform(src, dst, QgsProject.instance())
            pt = tr.transform(QgsPointXY(pt))
        except Exception:                               # noqa: BLE001
            pt = QgsPointXY(pt)
        half = max(canvas.extent().width() * 0.02, 1e-4)
        canvas.setExtent(QgsRectangle(pt.x() - half, pt.y() - half,
                                      pt.x() + half, pt.y() + half))
        canvas.refresh()
        self.iface.mapCanvas().flashGeometries(
            [QgsGeometry.fromPointXY(pt)], dst)

    # -- exports --------------------------------------------------------------
    def _default_name(self, suffix):
        # Shared convention: yyyymmdd-auditor-<source folder/zip>.<ext>, placed in
        # the user's Documents folder by default (writable on strict-policy PCs).
        import os
        from .sourceval_ui import export_basename, _documents_dir
        stem = export_basename("auditor", self._source)
        return os.path.join(_documents_dir(), f"{stem}{suffix}")

    def _export_html(self):
        # S-158 audit of S-100 Tier-1 products uses its own (identically-styled)
        # renderer, listing every catalogue check per loaded product.
        if getattr(self, "_s158_audit", None) is not None:
            path, _ = QFileDialog.getSaveFileName(
                self, "Export S-158 validation report",
                self._default_name(".html"), "HTML document (*.html)")
            if not path:
                return
            from .validation.s158_report import audit_to_html
            html = audit_to_html(self._s158_audit, source=self._source or "")
            with open(path, "w", encoding="utf-8") as fh:
                fh.write(html)
            self._log_line(f"Wrote S-158 report → {path}")
            self.iface.messageBar().pushInfo(
                "ENCore GIS — Auditor", f"S-158 report exported to {path}")
            from .sourceval_ui import _open_file
            _open_file(path)                              # auto-open after saving
            return
        if not self._report:
            return
        path, _ = QFileDialog.getSaveFileName(
            self, "Export audit document", self._default_name(".html"),
            "HTML document (*.html)")
        if not path:
            return
        ctx = self._context or {}
        note = ctx.get("exchange_note")
        html = self._report.to_html(
            title="ENC audit report",
            source=ctx.get("source", ""),
            edition=PROFILE_LABEL,
            cells=ctx.get("cells"),
            notes=[note] if note else None,
            cell_meta=ctx.get("cell_meta"),
            ienc_fcs=ctx.get("ienc_fcs"))
        with open(path, "w", encoding="utf-8") as fh:
            fh.write(html)
        self.iface.messageBar().pushInfo(
            "ENCore GIS — Auditor", f"Audit document exported to {path}")
        from .sourceval_ui import _open_file
        _open_file(path)

    def _plugin_version(self):
        """Plugin version from metadata.txt (for the settings document header)."""
        try:
            meta = os.path.join(os.path.dirname(__file__), "metadata.txt")
            with open(meta, encoding="utf-8") as fh:
                for line in fh:
                    if line.startswith("version="):
                        return line.split("=", 1)[1].strip()
        except Exception:                               # noqa: BLE001
            pass
        return ""

    def _export_settings(self):
        """Export the Auditor settings & methodology document (run-independent):
        the check inventory + validation status, the installed Inland ENC FCs and
        how one is picked by PRED, and the custom methodology choices."""
        path, _ = QFileDialog.getSaveFileName(
            self, "Export auditor settings document",
            self._default_name("_settings.html"), "HTML document (*.html)")
        if not path:
            return
        from .validation.engine import load_all_checks
        from .validation.settings_doc import build_settings_html
        registry = load_all_checks()
        fc_dir = self._ienc_fc_dir()
        explicit = self._ienc_fc_path() or ""
        fcs = []
        try:
            from .validation.ienc_catalogue import (list_fc_editions,
                                                    IencCatalogue)
            for ed, p in sorted(list_fc_editions(fc_dir).items()):
                try:
                    c = IencCatalogue.from_xml(p)
                    fcs.append({"edition": c.edition or ed,
                                "file": os.path.basename(p),
                                "classes": len(c.features),
                                "attributes": len(c.attributes),
                                "date": c.date or ""})
                except Exception as e:                  # noqa: BLE001
                    fcs.append({"edition": ed, "file": os.path.basename(p),
                                "classes": "?", "attributes": "?",
                                "date": f"parse error: {e}"})
        except Exception:                               # noqa: BLE001
            pass
        doc = build_settings_html(
            registry, version=self._plugin_version(), profile=PROFILE_LABEL,
            fcs=fcs, fc_dir=fc_dir, explicit_fc=explicit)
        with open(path, "w", encoding="utf-8") as fh:
            fh.write(doc)
        self.iface.messageBar().pushInfo(
            "ENCore GIS — Auditor", f"Settings document exported to {path}")
        from .sourceval_ui import _open_file
        _open_file(path)

    def _export_diff(self):
        """Export the GDAL-vs-native engine comparison from a Compare run."""
        if not self._diff:
            return
        path, _ = QFileDialog.getSaveFileName(
            self, "Export engine diff", self._default_name("_enginediff.html"),
            "HTML document (*.html)")
        if not path:
            return
        with open(path, "w", encoding="utf-8") as fh:
            fh.write(self._diff.to_html())
        self.iface.messageBar().pushInfo(
            "ENCore GIS — Auditor", f"Engine diff exported to {path}")
        from .sourceval_ui import _open_file
        _open_file(path)

    def _export_csv_menu(self):
        menu = QMenu(self)
        a_checks = menu.addAction("Per-check pass/fail…")
        a_find = menu.addAction("Detailed findings…")
        chosen = menu.exec(self.csv_btn.mapToGlobal(
            self.csv_btn.rect().bottomLeft()))
        if chosen == a_checks:
            self._export_csv(self._report.to_check_csv(), "_checks.csv")
        elif chosen == a_find:
            self._export_csv(self._report.to_csv(), "_findings.csv")

    def _export_csv(self, text, suffix):
        if not self._report:
            return
        path, _ = QFileDialog.getSaveFileName(
            self, "Export CSV", self._default_name(suffix), "CSV files (*.csv)")
        if not path:
            return
        with open(path, "w", encoding="utf-8", newline="") as fh:
            fh.write(text)
        self.iface.messageBar().pushInfo(
            "ENCore GIS — Auditor", f"Exported to {path}")

    def _add_flags_layer(self):
        if not self._report:
            return
        layer = QgsVectorLayer(
            "Point?crs=EPSG:4326", "ENC audit findings", "memory")
        dp = layer.dataProvider()
        dp.addAttributes([
            QgsField("severity", QVariant.String),
            QgsField("status", QVariant.String),
            QgsField("check", QVariant.String),
            QgsField("s58", QVariant.String),
            QgsField("cell", QVariant.String),
            QgsField("class", QVariant.String),
            QgsField("fid", QVariant.String),
            QgsField("message", 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))
            name = f.source.name if f.source is not None else f.check.name
            feat.setAttributes([
                f.check.severity, "FAIL", name, str(f.check.number),
                f.cell, f.klass, str(f.fid), f.message])
            feats.append(feat)
        dp.addFeatures(feats)
        layer.updateExtents()
        QgsProject.instance().addMapLayer(layer)
        self.iface.messageBar().pushInfo(
            "ENCore GIS — Auditor",
            f"Added {len(feats)} placed finding(s) as a flags layer.")
