# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
validation/report_diff.py
-------------------------
Whole-report diff between two validation Reports of the SAME cells run with
different feature sources — in practice GDAL's S-57 driver (``OgrDataset``) vs
the native ISO 8211 reader (``NativeDataset``). This is the verification artifact
that gates flipping the Auditor's default reader to native: if the native run
produces the same per-check verdicts and the same findings as GDAL across real
cells (and the producer's SevenCs/dKart oracle), the reader is proven equivalent.

Design: the two runs share the predicate engine (GEOS via OGR) and the catalogue
— only the *reader* differs — so a divergence here is a reader difference, not a
checking-logic difference.

Finding identity is deliberately **reader-independent**: GDAL's feature FID and
the native RCID are different numbering schemes, so the key is
``(check_number, cell, klass, detail)`` and NOT the fid. ``detail`` carries the
discriminating context a check emits (e.g. ``"COLOUR=99"``, ``"LNAM=..."``); for
checks whose detail embeds the fid this can over-report differences, which is the
safe direction for a verification diff a human reviews.

Pure stdlib — no GDAL, no Qt — so it is headlessly testable with hand-built
Report/Finding objects.
"""

import html


def _finding_key(f):
    """Reader-independent identity of a finding (see module docstring)."""
    return (str(f.check.number), f.cell or "", f.klass or "",
            (f.detail or "").strip())


class CheckDelta:
    """Per-check comparison row.

    status_a / status_b are "PASS" / "FAIL" / "ERROR" / "—" (not in scope).
    """

    __slots__ = ("number", "name", "severity", "status_a", "status_b",
                 "count_a", "count_b", "only_a", "only_b")

    def __init__(self, number, name, severity):
        self.number = number
        self.name = name
        self.severity = severity
        self.status_a = "—"
        self.status_b = "—"
        self.count_a = 0
        self.count_b = 0
        self.only_a = []     # [Finding] present in A, absent in B
        self.only_b = []     # [Finding] present in B, absent in A

    @property
    def agrees(self):
        """True when both runs ran this check, reached the same PASS/FAIL/ERROR
        verdict, and produced no reader-only findings."""
        return (self.status_a == self.status_b
                and not self.only_a and not self.only_b)

    @property
    def in_scope(self):
        return self.status_a != "—" or self.status_b != "—"


class ReportDiff:
    """The diff of two reports. ``label_a``/``label_b`` name the two runs."""

    def __init__(self, label_a="GDAL", label_b="Native"):
        self.label_a = label_a
        self.label_b = label_b
        self.deltas = []          # [CheckDelta] for every check in scope in either

    # -- queries --------------------------------------------------------------
    def divergent(self):
        """CheckDeltas where the two runs do not agree."""
        return [d for d in self.deltas if d.in_scope and not d.agrees]

    def agrees(self):
        """True iff every in-scope check agrees (the green light for flipping
        the default reader)."""
        return not self.divergent()

    def counts(self):
        """{'checks': n_in_scope, 'agree': n, 'divergent': n,
            'only_a': total findings only in A, 'only_b': total only in B}."""
        in_scope = [d for d in self.deltas if d.in_scope]
        div = self.divergent()
        return {
            "checks": len(in_scope),
            "agree": len(in_scope) - len(div),
            "divergent": len(div),
            "only_a": sum(len(d.only_a) for d in self.deltas),
            "only_b": sum(len(d.only_b) for d in self.deltas),
        }

    def summary_line(self):
        c = self.counts()
        verdict = "IDENTICAL" if self.agrees() else "DIVERGENT"
        return (f"{verdict}: {c['agree']}/{c['checks']} checks agree, "
                f"{c['divergent']} divergent — "
                f"{c['only_a']} findings only in {self.label_a}, "
                f"{c['only_b']} only in {self.label_b}.")

    # -- renderings -----------------------------------------------------------
    def to_text(self):
        lines = [self.summary_line(), ""]
        div = self.divergent()
        if not div:
            lines.append("All in-scope checks agree; the native reader matches "
                         f"{self.label_a} on these cells.")
            return "\n".join(lines)
        lines.append("Divergent checks:")
        for d in div:
            lines.append(
                f"  [{d.severity}] {d.number} {d.name}: "
                f"{self.label_a}={d.status_a}({d.count_a})  "
                f"{self.label_b}={d.status_b}({d.count_b})  "
                f"only-{self.label_a}={len(d.only_a)} "
                f"only-{self.label_b}={len(d.only_b)}")
            for f in d.only_a[:5]:
                lines.append(f"      only {self.label_a}: "
                             f"{f.cell}/{f.klass} {f.detail}")
            for f in d.only_b[:5]:
                lines.append(f"      only {self.label_b}: "
                             f"{f.cell}/{f.klass} {f.detail}")
        return "\n".join(lines)

    def to_html(self, title="Auditor engine diff (GDAL vs native reader)"):
        esc = html.escape
        c = self.counts()
        agree = self.agrees()
        verdict = "IDENTICAL" if agree else "DIVERGENT"
        vcol = "#1e8449" if agree else "#c0392b"
        out = [
            "<!doctype html><html><head><meta charset='utf-8'>",
            f"<title>{esc(title)}</title>",
            "<style>body{font-family:Segoe UI,Arial,sans-serif;margin:2em;}"
            "table{border-collapse:collapse;width:100%;}"
            "th,td{border:1px solid #ccc;padding:4px 8px;text-align:left;"
            "font-size:13px;}th{background:#f0f0f0;}"
            ".ok{color:#1e8449;}.bad{color:#c0392b;}"
            ".det{color:#555;font-size:12px;}</style></head><body>",
            f"<h2>{esc(title)}</h2>",
            f"<p><b style='color:{vcol}'>{verdict}</b> &mdash; "
            f"{c['agree']}/{c['checks']} in-scope checks agree, "
            f"{c['divergent']} divergent. "
            f"{c['only_a']} findings only in {esc(self.label_a)}, "
            f"{c['only_b']} only in {esc(self.label_b)}.</p>",
        ]
        div = self.divergent()
        if not div:
            out.append("<p class='ok'>The native reader matches "
                       f"{esc(self.label_a)} on every in-scope check.</p>")
        else:
            out.append("<h3>Divergent checks</h3>")
            out.append("<table><tr><th>Sev</th><th>Check</th><th>S-58</th>"
                       f"<th>{esc(self.label_a)}</th><th>{esc(self.label_b)}</th>"
                       f"<th>only {esc(self.label_a)}</th>"
                       f"<th>only {esc(self.label_b)}</th></tr>")
            for d in div:
                out.append(
                    f"<tr><td>{esc(d.severity)}</td><td>{esc(d.name)}</td>"
                    f"<td>{esc(str(d.number))}</td>"
                    f"<td>{esc(d.status_a)} ({d.count_a})</td>"
                    f"<td>{esc(d.status_b)} ({d.count_b})</td>"
                    f"<td>{len(d.only_a)}</td><td>{len(d.only_b)}</td></tr>")
                for lbl, fs in ((self.label_a, d.only_a),
                                (self.label_b, d.only_b)):
                    for f in fs[:10]:
                        out.append(
                            "<tr><td></td><td colspan='6' class='det'>only "
                            f"{esc(lbl)}: {esc(f.cell or '')}/"
                            f"{esc(f.klass or '')} {esc(f.detail or '')}</td></tr>")
            out.append("</table>")
        out.append("</body></html>")
        return "".join(out)


def diff_reports(report_a, report_b, label_a="GDAL", label_b="Native"):
    """Diff two Reports of the same cells (run A vs run B). See module docstring.

    Compares per-check PASS/FAIL/ERROR verdicts and the set of findings each
    check produced (by reader-independent key). Returns a ``ReportDiff``.
    """
    diff = ReportDiff(label_a=label_a, label_b=label_b)

    pc_a = {m.number: (m, status, fs) for m, status, fs in report_a.per_check()}
    pc_b = {m.number: (m, status, fs) for m, status, fs in report_b.per_check()}

    for number in sorted(set(pc_a) | set(pc_b), key=lambda x: str(x)):
        a = pc_a.get(number)
        b = pc_b.get(number)
        meta = (a or b)[0]
        d = CheckDelta(number, meta.name, meta.severity)
        if a is not None:
            d.status_a, fa = a[1], a[2]
            d.count_a = len(fa)
        else:
            fa = []
        if b is not None:
            d.status_b, fb = b[1], b[2]
            d.count_b = len(fb)
        else:
            fb = []
        keys_a = {}
        for f in fa:
            keys_a.setdefault(_finding_key(f), []).append(f)
        keys_b = {}
        for f in fb:
            keys_b.setdefault(_finding_key(f), []).append(f)
        for k, fs in keys_a.items():
            extra = len(fs) - len(keys_b.get(k, []))
            if extra > 0:
                d.only_a.extend(fs[:extra])
        for k, fs in keys_b.items():
            extra = len(fs) - len(keys_a.get(k, []))
            if extra > 0:
                d.only_b.extend(fs[:extra])
        diff.deltas.append(d)
    return diff
