# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
validation/settings_doc.py
--------------------------
A human-readable **Auditor settings & methodology** document, for expert
peer-review. It is independent of any single audit run — it describes HOW the
auditor is configured:

  * every check available, its severity, and whether it has been validated
    against an official IHO test dataset (the ``validated`` flag);
  * which Inland ENC Feature Catalogues are installed and how one is chosen for a
    cell's PRED;
  * the non-standard / custom methodology choices a reviewer must know about.

Pure stdlib (no QGIS/GDAL), so it is headlessly testable and renders the same
self-contained HTML style as the audit report.
"""
import html
from datetime import datetime, timezone


_SEV_LABEL = {"C": "Critical", "A": "Alert", "E": "Error", "W": "Warning"}
_SEV_COLOUR = {"C": "#c0392b", "A": "#7d3c98", "E": "#d68910", "W": "#2471a3"}
_SEV_ORDER = ("C", "A", "E", "W")


# Curated methodology choices a peer reviewer must be aware of — the places this
# auditor deviates from, or extends, a vanilla S-58 run. (title, detail).
CUSTOM_CHOICES = [
    ("Geometry-quality check QC1 (local, not S-58)",
     "QC1 'redundant collinear vertices' is an ENCoreGIS quality advisory, not "
     "an S-58 conformance requirement. GIS-sourced production tends to densify "
     "geometry (a straight line that needs 2 vertices arrives as 100), which "
     "bloats the product. QC1 measures, per line/area feature, how many "
     "vertices a Douglas-Peucker pass at 1e-6 degrees (about 0.1 m, far below "
     "chart accuracy) would discard, and reports the feature as a Warning only "
     "when at least 20 vertices AND more than half of the feature's vertices "
     "add no shape. It never affects the pass/fail verdict."),
    ("Minimum-standard verdict",
     "An ENC PASSES the minimum standard when it has NO Critical findings "
     "(S-58 8.0.0 1.3). Error, Warning and Alert findings never change the "
     "pass/fail verdict."),
    ("Alert tier (local, not S-58)",
     "ALERT is a local advisory tier, not an S-58 severity. It is shown "
     "directly under the Critical section but never fails the dataset — for "
     "guidance that is correct-but-not-mandatory."),
    ("Hybrid Inland ENC — per-object-class precedence",
     "Products are treated as hybrids (Inland ENC with added maritime objects, "
     "mixed in one cell). When an Inland ENC Feature Catalogue is attached, each "
     "object class it defines is validated against the FC (the Inland ENC FC "
     "checks own it) and the matching maritime checks (547 / 20a / 2000d / 507) "
     "SKIP that class; classes the FC does not define fall back to the maritime "
     "S-57 catalogue."),
    ("Inland ENC FC value domain — union, canonical case",
     "The Inland ENC FC value check (IE2000) accepts a code valid in EITHER the "
     "FC OR the S-57 catalogue, matched on the canonical upper-case acronym. The "
     "FC lists only the inland-relevant enumerations and relies on S-57 for the "
     "rest, while it adds inland-specific values; the union is the hybrid-correct "
     "rule and the canonical case is what stops valid inland values (e.g. "
     "CATSLC=2) reading as out-of-domain."),
    ("Maritime vs Inland discrimination (by PRSP)",
     "Each cell's product is classified from its DSID PRSP: PRSP 1 = Maritime "
     "ENC, PRSP 10 = Inland ENC. ONLY Inland cells are validated against an "
     "Inland ENC FC; maritime cells use the S-57 catalogue alone. So a maritime "
     "product is never validated against an Inland FC, even when it shares a "
     "source with Inland cells."),
    ("Feature-Catalogue selection by PRED (per cell)",
     "For an Inland cell, the FC is chosen by its declared edition (DSID PRED): "
     "the installed FC whose own edition matches is attached; a bare 'X.Y' PRED "
     "also accepts an 'X.Y.Z' FC; if none matches, the newest installed Inland FC "
     "is the fallback. Selection is PER CELL, so a mixed-edition set (e.g. cells "
     "of PRED 2.3 AND 2.4 together) validates each cell against the edition it "
     "declares. An explicit FC path (QSettings ENCoreGIS/ienc_fc_path) overrides."),
    ("Reader engine",
     "The default reader is the GDAL S-57 driver. A GDAL-free native ISO 8211 "
     "reader and a 'diff' mode (run both and compare) are available for "
     "verification."),
    ("Single exchange set only",
     "A source holding more than one exchange-set catalogue (CATALOG.0xx) is "
     "refused; cells with no catalogue are audited 'loose' and the exchange-set "
     "checks are skipped and noted."),
    ("Validation status of checks",
     "A check marked NOT validated has been implemented from the standard/FC and "
     "verified against production cells and headless tests, but NOT against an "
     "official IHO test dataset. Treat its findings as provisional. The Inland "
     "ENC FC checks and the Alert-tier checks are not validated this way (no "
     "official test dataset exists for them)."),
]


def _esc(x):
    return html.escape(str(x)) if x is not None else ""


_S158_PRODUCT_LABEL = {
    "100": "S-100 core / Universal Model (run on the S-101 vector product)",
    "101": "S-101 ENC (vector — native ISO 8211 reader)",
    "102": "S-102 bathymetric surface (HDF5 via GDAL)",
    "104": "S-104 water level (HDF5 via GDAL)",
    "111": "S-111 surface currents (HDF5 via GDAL)",
}
_S158_SEV_LABEL = {"C": "Critical", "E": "Error", "W": "Warning"}


def _s158_methodology_html():
    """The S-158 (S-100 Tier-1) section of the methodology document: what is
    validated, how it is read, and the full per-product check inventory with the
    implemented / provisional-(*) / catalogued status of every check."""
    try:
        from . import s100, s101
    except Exception:                                   # noqa: BLE001
        return ""
    products = ("100", "101", "102", "104", "111")
    # summary tiles + table
    srows, total_all, impl_all, val_all = [], 0, 0, 0
    for p in products:
        try:
            cat = s100.catalogue(p)
        except Exception:                               # noqa: BLE001
            continue
        if p == "101":
            impl, total = s101.coverage()
        elif p == "100":
            impl, total = s101.core_coverage()
        else:
            impl, total = s100.coverage(p)
        nval = sum(1 for rec in cat if s100.is_validated(rec.get("id")))
        total_all += total
        impl_all += impl
        val_all += nval
        cnt = {"C": 0, "E": 0, "W": 0}
        for rec in cat:
            cnt[s100.SEVERITY.get(rec.get("cls", ""), "W")] = \
                cnt.get(s100.SEVERITY.get(rec.get("cls", ""), "W"), 0) + 1
        srows.append(
            "<tr>"
            f"<td>S-158-{p}</td><td>{_esc(_S158_PRODUCT_LABEL.get(p, p))}</td>"
            f"<td class='num'>{total}</td>"
            f"<td class='num'>{cnt['C']}</td><td class='num'>{cnt['E']}</td>"
            f"<td class='num'>{cnt['W']}</td>"
            f"<td class='num'>{impl}</td>"
            f"<td class='num'>{nval}</td>"
            f"<td class='num'>{total - impl}</td></tr>")

    # per-product full inventory (collapsible), every check with its status
    details = []
    for p in products:
        try:
            cat = s100.catalogue(p)
        except Exception:                               # noqa: BLE001
            continue
        if p == "101":
            impl_ids = {cid for cid, _ in s101.CHECKS_101}
        elif p == "100":
            impl_ids = {cid for cid, _ in s101.CHECKS_100}
        else:
            impl_ids = {cid for cid, _ in s100.CHECKS.get(p, [])}
        body = []
        for rec in cat:
            cid = rec.get("id")
            sev = s100.SEVERITY.get(rec.get("cls", ""), "W")
            sc = _SEV_COLOUR.get(sev, "#000")
            if cid not in impl_ids:
                stat = "<span class='muted'>&#9675; catalogued (not implemented)</span>"
            elif s100.is_validated(cid):
                stat = "<span class='ok'>&#10004; validated</span>"
            else:
                stat = "<span class='no'>&#42; provisional</span>"
            body.append(
                "<tr>"
                f"<td class='num'>{_esc(cid)}</td>"
                f"<td><div class='cmsg'>{_esc(rec.get('message', ''))}</div></td>"
                f"<td><span class='badge' style='background:{sc}'>"
                f"{_S158_SEV_LABEL.get(sev, sev)}</span></td>"
                f"<td>{stat}</td></tr>")
        details.append(
            f"<details><summary><b>S-158-{p}</b> "
            f"<span class='muted'>{_esc(_S158_PRODUCT_LABEL.get(p, p))} — "
            f"{len(cat)} checks</span></summary>"
            "<table class='t'><thead><tr><th>Ref</th><th>Check</th>"
            "<th>Severity</th><th>Status</th></tr></thead>"
            f"<tbody>{''.join(body)}</tbody></table></details>")

    return (
        '<h2>S-100 Tier-1 validation <span class="muted">(IHO S-158)</span></h2>'
        '<p>When the source contains S-100 Tier-1 products the Auditor validates '
        'them against the official <b>IHO S-158</b> validation-check catalogues '
        'instead of S-58. <b>S-101</b> is read with our own <b>native ISO 8211 '
        'engine</b> (never GDAL); <b>S-102 / S-104 / S-111</b> are read from HDF5 '
        'via GDAL’s multidimensional API. Every implemented check runs on '
        'every audit. A check is <b>validated</b> only once its behaviour is '
        'confirmed against an official IHO S-158 test dataset '
        '(github.com/iho-ohi); until then it runs but is marked <b>provisional '
        '(*)</b> in the picker, results tree and report. Checks that are '
        'catalogued but whose executable predicate is not yet written are listed '
        'as <b>NOT-IMPLEMENTED</b> so the full scope stays visible.</p>'
        '<p>Calibration against the official <b>IHO S-101 test datasets</b> '
        '(the 32 S-101 1.2 cells and the purpose-built S-158/V1 error datasets '
        'with their expected-results reports) is under way: the first checks have '
        'been confirmed and are no longer provisional, and each S-101 cell is '
        'validated against the <b>Feature Catalogue edition it was authored '
        'with</b> (FC 1.1.0 or 2.1.0) — many feature types were renamed between '
        'editions, so binding a dataset to the wrong edition would silently drop '
        'findings. A check is only marked validated after its findings match the '
        'official expected-results exactly <i>and</i> it raises no findings on the '
        'conformant reference cells.</p>'
        f'<div class="meta"><b>S-158 checks bundled:</b> {total_all} '
        f'({impl_all} implemented &mdash; {val_all} validated, '
        f'{impl_all - val_all} provisional; {total_all - impl_all} '
        f'catalogued-only)</div>'
        "<table class='t'><thead><tr><th>Catalogue</th><th>Product</th>"
        "<th>Checks</th><th>C</th><th>E</th><th>W</th><th>Impl.</th>"
        "<th>Valid.</th><th>Not impl.</th></tr></thead>"
        f"<tbody>{''.join(srows)}</tbody></table>"
        + "".join(details))


def build_settings_html(registry, version="", profile="", generated=None,
                        fcs=None, fc_dir="", explicit_fc=""):
    """Render the settings/methodology document.

    registry  -- iterable of CheckMeta (the loaded check registry).
    version   -- plugin version string.
    profile   -- citation baseline label (e.g. 'S-58 8.0.0 / UOC 4.4.0').
    fcs       -- list of {edition, file, classes, attributes, date} for the
                 installed Inland ENC Feature Catalogues.
    fc_dir    -- folder the FCs are read from (shown for transparency).
    explicit_fc -- a pinned FC path (QSettings override), if any.
    """
    metas = list(registry)
    fcs = fcs or []
    gen = (generated or datetime.now(timezone.utc)).strftime("%Y-%m-%d %H:%M UTC")

    n_total = len(metas)
    n_val = sum(1 for m in metas if m.validated)
    n_unval = n_total - n_val
    n_alert = sum(1 for m in metas if m.severity == "A")
    n_ienc = sum(1 for m in metas
                 if m.category == "Inland ENC Feature Catalogue")

    # ── Custom choices (curated + derived counts).
    choice_items = "".join(
        f"<li><b>{_esc(t)}.</b> {_esc(d)}</li>" for t, d in CUSTOM_CHOICES)

    # ── FC table.
    if fcs:
        fc_rows = "".join(
            "<tr>"
            f"<td>{_esc(f.get('edition'))}</td>"
            f"<td>{_esc(f.get('file'))}</td>"
            f"<td class='num'>{_esc(f.get('classes'))}</td>"
            f"<td class='num'>{_esc(f.get('attributes'))}</td>"
            f"<td>{_esc(f.get('date'))}</td></tr>"
            for f in sorted(fcs, key=lambda x: str(x.get('edition'))))
        fc_table = (
            "<table class='t'><thead><tr><th>FC edition</th><th>File</th>"
            "<th>Classes</th><th>Attributes</th><th>FC date</th></tr></thead>"
            f"<tbody>{fc_rows}</tbody></table>")
    else:
        fc_table = ("<p class='muted'>No Inland ENC Feature Catalogue is "
                    "installed; Inland ENC FC checks are inert and Inland value "
                    "domains fall back to the S-57 catalogue.</p>")
    fc_meta = (f"<div class='meta'><b>FC folder:</b> {_esc(fc_dir) or '&mdash;'}"
               "</div>")
    if explicit_fc:
        fc_meta += (f"<div class='meta'><b>Pinned FC (override):</b> "
                    f"{_esc(explicit_fc)}</div>")

    # ── Checks inventory, grouped by category, then severity/name.
    cats = {}
    for m in metas:
        cats.setdefault(m.category or "Other", []).append(m)
    sev_rank = {s: i for i, s in enumerate(_SEV_ORDER)}
    check_blocks = []
    for cat in sorted(cats):
        rows = sorted(cats[cat],
                      key=lambda m: (sev_rank.get(m.severity, 9), str(m.name)))
        body = []
        for m in rows:
            sc = _SEV_COLOUR.get(m.severity, "#000")
            val = ("<span class='ok'>&#10004; validated</span>" if m.validated
                   else "<span class='no'>&#10008; not validated</span>")
            body.append(
                "<tr>"
                f"<td class='num'>{_esc(m.number)}</td>"
                f"<td><div class='cname'>{_esc(m.name)}</div>"
                f"<div class='cmsg'>{_esc(m.message)}</div></td>"
                f"<td><span class='badge' style='background:{sc}'>"
                f"{_SEV_LABEL.get(m.severity, m.severity)}</span></td>"
                f"<td>{val}</td>"
                f"<td>{_esc(m.conformity)}</td></tr>")
        check_blocks.append(
            f"<h2>{_esc(cat)} <span class='muted'>({len(rows)} checks)</span>"
            "</h2><table class='t'><thead><tr><th>Ref</th><th>Check</th>"
            "<th>Severity</th><th>Validation</th><th>Conformity</th></tr>"
            f"</thead><tbody>{''.join(body)}</tbody></table>")

    # ── S-100 Tier-1 (S-158) methodology + full per-product check inventory.
    s158_block = _s158_methodology_html()

    return f"""<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8">
<title>Auditor settings &amp; methodology</title>
<style>
 body{{font:14px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
   color:#1a1a1a;max-width:1000px;margin:24px auto;padding:0 18px}}
 h1{{font-size:22px;margin:0 0 4px}}
 h2{{font-size:16px;margin:22px 0 6px;border-bottom:1px solid #eee;padding-bottom:4px}}
 .banner{{color:#fff;background:#34495e;padding:10px 14px;border-radius:6px;
   font-weight:600;margin:14px 0}}
 .meta{{color:#444;font-size:13px;margin:4px 0}}
 .meta b{{color:#1a1a1a}}
 .tiles{{display:flex;gap:10px;flex-wrap:wrap;margin:12px 0 6px}}
 .tile{{border:1px solid #ddd;border-radius:6px;padding:8px 14px;min-width:90px}}
 .tile .n{{font-size:20px;font-weight:700}}
 .tile .l{{font-size:12px;color:#666}}
 .choices{{background:#f4f6f8;border:1px solid #dde3e8;border-radius:6px;
   padding:10px 16px;margin:10px 0}}
 .choices li{{margin:6px 0;font-size:13px}}
 table.t{{border-collapse:collapse;width:100%;margin:4px 0 8px}}
 table.t th{{text-align:left;font-size:12px;color:#666;border-bottom:1px solid #ddd;
   padding:4px 8px}}
 table.t td{{padding:5px 8px;vertical-align:top;border-bottom:1px solid #f2f2f2}}
 td.num{{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}}
 .badge{{color:#fff;font-size:11px;font-weight:700;border-radius:4px;
   padding:2px 7px}}
 .cname{{font-weight:600}}
 .cmsg{{color:#555;font-size:12px}}
 .muted{{color:#999;font-weight:400}}
 .ok{{color:#1e8449;font-weight:600;font-size:12px}}
 .no{{color:#c0392b;font-weight:600;font-size:12px}}
 footer{{margin:24px 0 8px;color:#999;font-size:11px;border-top:1px solid #eee;
   padding-top:8px}}
</style></head><body>
<h1>Auditor settings &amp; methodology</h1>
<div class="banner">For peer review — describes how this auditor is configured,
 independent of any single run.</div>
<div class="meta"><b>Plugin version:</b> {_esc(version) or "&mdash;"}</div>
<div class="meta"><b>Citation baseline:</b> {_esc(profile) or "&mdash;"}</div>
<div class="meta"><b>Generated:</b> {gen}</div>
<div class="tiles">
 <div class="tile"><div class="n">{n_total}</div><div class="l">checks available</div></div>
 <div class="tile"><div class="n" style="color:#1e8449">{n_val}</div><div class="l">validated*</div></div>
 <div class="tile"><div class="n" style="color:#c0392b">{n_unval}</div><div class="l">not validated</div></div>
 <div class="tile"><div class="n" style="color:#7d3c98">{n_alert}</div><div class="l">alert-tier</div></div>
 <div class="tile"><div class="n">{n_ienc}</div><div class="l">Inland ENC FC</div></div>
</div>
<p class="muted">* validated = confirmed against an official IHO test dataset.</p>

<h2>Custom choices &amp; methodology</h2>
<div class="choices"><ul>{choice_items}</ul></div>

<h2>Inland ENC Feature Catalogues <span class="muted">(selected by PRED)</span></h2>
{fc_meta}
{fc_table}

{''.join(check_blocks)}
{s158_block}
<footer>Generated by ENCoreGIS Auditor — settings &amp; methodology document.
 Check names are stable documentation keys; references/severities are citations
 against the stated baseline and may change between editions.</footer>
</body></html>"""
