# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
validation/s158.py
------------------
Categorical S-158 dispatcher for S-100 Tier-1 products. Given a chart source
(folder or .zip exchange set) it detects which products are present and routes
each to its validation engine:

    S-101 (vector)   -> validation.s101  (native ISO 8211 engine, GDAL-free)
    S-102/104/111    -> validation.s100  (HDF5 via GDAL multidim)

This is the single seam the auditor UI calls: ``audit_source(source)`` returns a
:class:`S158Audit` with one report per S-101 dataset and one per HDF5 file, plus a
per-product coverage summary so the report can show implemented vs catalogued.
"""

import os

from . import s100
from . import s101


# -- discovery ----------------------------------------------------------------
def _s101_cells(source):
    """S-101 ``.000`` cell paths under a folder or inside a .zip. Name-based
    (101-prefixed, S-100 file-naming convention); GDAL-free."""
    out = []
    if source and os.path.isdir(source):
        for root, _dirs, files in os.walk(source):
            for fn in files:
                if fn.lower().endswith(".000") and fn[:3] == "101":
                    out.append(os.path.join(root, fn))
    elif source and os.path.isfile(source) and source.lower().endswith(".000"):
        if os.path.basename(source)[:3] == "101":
            out.append(source)
    return sorted(out)


def _hdf5_datasets(source, product):
    """HDF5 dataset paths for a coverage product via that product's own finder
    (handles folder walks and .zip extraction, incl. the temp cache)."""
    mod = {"102": "enc_s102", "104": "enc_s104", "111": "enc_s111"}[product]
    try:
        try:
            m = __import__("ENCoreGIS." + mod, fromlist=["datasets_from_source"])
        except Exception:                                # noqa: BLE001
            m = __import__(mod, fromlist=["datasets_from_source"])
        return list(m.datasets_from_source(source) or [])
    except Exception:                                    # noqa: BLE001
        return []


def _is_product_name(name):
    """Name-only test: does this filename look like an S-100 Tier-1 product?"""
    b = os.path.basename(name).lower()
    if b.endswith(".000") and b[:3] == "101":
        return True
    if b.endswith((".h5", ".hdf5")) and b[:3] in ("102", "104", "111"):
        return True
    return False


def looks_like_s100(source):
    """Cheap name-only test (no zip extraction, no GDAL) for whether a source
    holds any S-100 Tier-1 product. Used to route to the S-158 auditor before the
    heavier :func:`detect`."""
    import zipfile
    if source and os.path.isdir(source):
        for _root, _dirs, files in os.walk(source):
            if any(_is_product_name(f) for f in files):
                return True
        return False
    if source and os.path.isfile(source):
        if _is_product_name(source):
            return True
        if zipfile.is_zipfile(source):
            try:
                with zipfile.ZipFile(source) as z:
                    return any(_is_product_name(m) for m in z.namelist())
            except Exception:                            # noqa: BLE001
                return False
    return False


def product_codes(source):
    """Cheap name-only SET of S-100 Tier-1 product codes present in the source
    (no zip extraction / GDAL) — used to build the picker's per-product test list
    before the (heavier) full audit."""
    import zipfile
    codes = set()

    def _add(name):
        b = os.path.basename(name).lower()
        if b.endswith(".000") and b[:3] == "101":
            codes.add("101")
        elif b.endswith((".h5", ".hdf5")) and b[:3] in ("102", "104", "111"):
            codes.add(b[:3])

    def _add_zip_members(zpath):
        try:
            with zipfile.ZipFile(zpath) as z:
                for m in z.namelist():
                    _add(m)
        except Exception:                                # noqa: BLE001
            pass

    if source and os.path.isdir(source):
        for _root, _dirs, files in os.walk(source):
            for f in files:
                _add(f)
                # a coverage product may be delivered as a .zip inside the folder
                # (matches what detect()/datasets_from_source actually reads).
                if f.lower().endswith(".zip"):
                    _add_zip_members(os.path.join(_root, f))
    elif source and os.path.isfile(source):
        if zipfile.is_zipfile(source):
            try:
                with zipfile.ZipFile(source) as z:
                    for m in z.namelist():
                        _add(m)
            except Exception:                            # noqa: BLE001
                pass
        else:
            _add(source)
    # the S-158-100 Universal Model core checks co-run on the S-101 vector product
    if "101" in codes:
        codes.add("100")
    return codes


def detect(source):
    """{product_code: [dataset_paths]} for every S-100 Tier-1 product found in
    the source. S-101 cells are one logical dataset (a list of cell paths); each
    HDF5 file is its own dataset."""
    found = {}
    cells = _s101_cells(source)
    if cells:
        found["101"] = cells
    for product in ("102", "104", "111"):
        ds = _hdf5_datasets(source, product)
        if ds:
            found[product] = ds
    return found


# -- audit --------------------------------------------------------------------
class S158Audit:
    """Aggregate result of validating a whole source. ``reports`` maps product ->
    list of S100Report (S-101 has exactly one; each HDF5 file has one)."""

    def __init__(self, source):
        self.source = source
        self.reports = {}                                # product -> [S100Report]

    def products(self):
        return sorted(self.reports)

    def all_reports(self):
        for product in self.products():
            for rep in self.reports[product]:
                yield rep

    def finding_count(self):
        return sum(len(r.findings) for r in self.all_reports())

    def summary(self):
        """[(product, n_datasets, n_findings, implemented, total)] per product."""
        rows = []
        for product in self.products():
            reps = self.reports[product]
            nf = sum(len(r.findings) for r in reps)
            if product == "101":
                impl, total = s101.coverage()
            elif product == "100":
                impl, total = s101.core_coverage()
            else:
                impl, total = s100.coverage(product)
            rows.append((product, len(reps), nf, impl, total))
        return rows


def audit_source(source, products=None, check_ids=None):
    """Detect and validate every S-100 Tier-1 product in ``source``. ``products``
    optionally restricts to a subset (e.g. ['101']); ``check_ids`` optionally
    restricts to a subset of catalogue check ids (None = run every implemented
    check). Returns an S158Audit."""
    audit = S158Audit(source)
    found = detect(source)
    for product, datasets in found.items():
        if products and product not in products:
            continue
        if product == "101":
            audit.reports["101"] = [
                s101.run(datasets, source=source, only=check_ids)]
        else:
            audit.reports[product] = [
                s100.run(p, product=product, only=check_ids) for p in datasets]
    # The S-158-100 Universal Model core checks apply to the S-101 vector product;
    # run them on the same cells and report under product code '100'.
    cells_101 = found.get("101")
    if cells_101 and (not products or "100" in products):
        audit.reports["100"] = [
            s101.run_core(cells_101, source=source, only=check_ids)]
    return audit
