# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
validation/s101_fc.py
---------------------
A reader for the IHO **S-101 Feature Catalogue** (S-100 5.2 FC XML), used by the
S-158-101 conformance checks. It distils the 2 MB catalogue into the parts the
auditor needs:

* every **feature/information type** (by its camelCase code, which matches the
  S-101 cell's in-cell feature dictionary) with its **attribute bindings** and
  their multiplicity — so "mandatory" = binding lower bound >= 1;
* every **attribute** (by code) with its value type and, for enumerations, the
  set of permitted listed-value codes.

Pure stdlib (ElementTree) — no GDAL, no QGIS. The catalogue is bundled under
``s101_fc/`` and parsed once, then cached.
"""

import glob
import os

try:
    from ..enc_xml_safe import parse_root
except (ImportError, ValueError):
    from enc_xml_safe import parse_root


# The FC namespace version differs between editions (S100FC/5.0 in FC 1.1.0,
# S100FC/5.2 in FC 2.1.0), so it is derived per-file from the root tag rather than
# hardcoded. Only the Base namespace (multiplicity bounds) is stable at 5.0.
_BASE = "{http://www.iho.int/S100Base/5.0}"

# The FC edition used for production audits when a cell does not (or cannot) pin an
# older one — always the newest bundled catalogue.
PRODUCTION_VERSION = "2.1.0"

_HERE = os.path.dirname(os.path.abspath(__file__))
_FC_DIR = os.path.join(os.path.dirname(_HERE), "s101_fc")   # plugin_root/s101_fc
_CACHE = {}
_BY_VERSION = None          # {version_str: FeatureCatalogue}, lazily built


class Attribute:
    __slots__ = ("code", "value_type", "enum_codes", "enum_labels")

    def __init__(self, code, value_type, enum_codes, enum_labels=None):
        self.code = code
        self.value_type = value_type
        self.enum_codes = enum_codes            # set[str] for enumerations
        self.enum_labels = enum_labels or {}    # {value_code: human label}


class FeatureType:
    __slots__ = ("code", "use_type", "primitives", "bindings")

    def __init__(self, code, use_type, primitives, bindings):
        self.code = code
        self.use_type = use_type                # 'meta' / 'geographic' / …
        self.primitives = primitives            # set[str]
        self.bindings = bindings                # {attr_code: (lower, upper)}

    def mandatory(self):
        """Attribute codes bound as mandatory (multiplicity lower bound >= 1)."""
        return {c for c, (lo, _hi) in self.bindings.items() if lo >= 1}


class FeatureCatalogue:
    def __init__(self, version=""):
        self.version = version
        self.feature_types = {}                 # code -> FeatureType
        self.info_types = {}                    # code -> FeatureType
        self.attributes = {}                    # code -> Attribute

    def type_for(self, code):
        return self.feature_types.get(code) or self.info_types.get(code)

    def is_enumeration(self, attr_code):
        a = self.attributes.get(attr_code)
        return bool(a and a.value_type == "enumeration")

    def enum_codes(self, attr_code):
        a = self.attributes.get(attr_code)
        return a.enum_codes if a else set()


# -- parsing ------------------------------------------------------------------
def _text(el, fc, tag):
    c = el.find(fc + tag)
    return c.text.strip() if c is not None and c.text else ""


def _multiplicity(binding, fc):
    m = binding.find(fc + "multiplicity")
    if m is None:
        return (0, -1)
    lo_el = m.find(_BASE + "lower")
    up_el = m.find(_BASE + "upper")
    try:
        lo = int(lo_el.text) if lo_el is not None and lo_el.text else 0
    except ValueError:
        lo = 0
    hi = -1                                     # -1 == unbounded
    if up_el is not None and up_el.get("infinite") != "true":
        try:
            hi = int(up_el.text) if up_el.text else -1
        except ValueError:
            hi = -1
    return (lo, hi)


def _bindings(type_el, fc, tag):
    out = {}
    for b in type_el.findall(fc + tag):
        aref = b.find(fc + "attribute")
        code = aref.get("ref") if aref is not None else None
        if code:
            out[code] = _multiplicity(b, fc)
    return out


def _parse(path):
    root = parse_root(path)
    # Derive the FC namespace from the root tag ({uri}LocalName) so both the 5.0
    # (FC 1.1.0) and 5.2 (FC 2.1.0) editions parse with one code path.
    fc = root.tag.split("}")[0] + "}" if "}" in root.tag else ""
    cat = FeatureCatalogue(version=_text(root, fc, "versionNumber"))
    # attributes (simple: value type + enum codes; complex: sub-bindings)
    for sa in root.iter(fc + "S100_FC_SimpleAttribute"):
        code = _text(sa, fc, "code")
        if not code:
            continue
        vt = _text(sa, fc, "valueType")
        enum = set()
        labels = {}
        if vt == "enumeration":
            for lv in sa.iter(fc + "listedValue"):
                cv = lv.find(fc + "code")
                if cv is not None and cv.text:
                    c = cv.text.strip()
                    enum.add(c)
                    lb = lv.find(fc + "label")
                    if lb is not None and lb.text:
                        labels[c] = lb.text.strip()
        cat.attributes[code] = Attribute(code, vt, enum, labels)
    for ca in root.iter(fc + "S100_FC_ComplexAttribute"):
        code = _text(ca, fc, "code")
        if code and code not in cat.attributes:
            cat.attributes[code] = Attribute(code, "complex", set())
    # feature + information types
    for ft in root.iter(fc + "S100_FC_FeatureType"):
        code = _text(ft, fc, "code")
        if not code:
            continue
        prims = {p.text.strip() for p in ft.findall(fc + "permittedPrimitives")
                 if p.text}
        cat.feature_types[code] = FeatureType(
            code, _text(ft, fc, "featureUseType"), prims,
            _bindings(ft, fc, "attributeBinding"))
    for it in root.iter(fc + "S100_FC_InformationType"):
        code = _text(it, fc, "code")
        if not code:
            continue
        cat.info_types[code] = FeatureType(
            code, "information", set(), _bindings(it, fc, "attributeBinding"))
    return cat


def _load(path):
    if path not in _CACHE:
        try:
            _CACHE[path] = _parse(path)
        except Exception:                       # noqa: BLE001
            _CACHE[path] = None
    return _CACHE[path]


def _version_key(v):
    """('1.1.0') -> (1, 1, 0) for ordering; unparseable parts sort low."""
    out = []
    for part in str(v).split("."):
        try:
            out.append(int(part))
        except ValueError:
            out.append(-1)
    return tuple(out)


def _by_version():
    """{version_str: FeatureCatalogue} across every bundled catalogue, built once.
    Lets the auditor pick the FC edition a dataset was authored against instead of
    always binding the newest one."""
    global _BY_VERSION
    if _BY_VERSION is None:
        _BY_VERSION = {}
        for p in sorted(glob.glob(os.path.join(_FC_DIR, "*.xml"))):
            fc = _load(p)
            if fc is not None and fc.version:
                _BY_VERSION.setdefault(fc.version, fc)
    return _BY_VERSION


def catalogue(path=None, version=None):
    """A bundled S-101 Feature Catalogue, parsed and cached.

    * ``path``    — parse that exact file;
    * ``version`` — the catalogue whose versionNumber matches (best-effort: exact,
      else same major.minor, else the production default);
    * neither     — the newest bundled catalogue (production default).

    Returns None if no catalogue is installed or it fails to parse.
    """
    if path is not None:
        return _load(path)
    cats = _by_version()
    if not cats:
        return None
    if version:
        if version in cats:
            return cats[version]
        mm = _version_key(version)[:2]
        for v, fc in cats.items():
            if _version_key(v)[:2] == mm:
                return fc
    if PRODUCTION_VERSION in cats:
        return cats[PRODUCTION_VERSION]
    return cats[max(cats, key=_version_key)]     # newest available


def catalogue_for_edition(product_edition):
    """The FC edition a dataset should be validated against, from its declared
    product edition (DSID ``PRED`` / product-spec version). S-101 product edition
    1.x pairs with FC 1.1.0; everything else uses the newest (production) FC."""
    v = (str(product_edition or "").strip())
    if v.startswith("1."):
        fc = catalogue(version="1.1.0")
        if fc is not None:
            return fc
    return catalogue()


def select_for_types(type_names):
    """Pick the bundled FC edition that recognises the most of the given feature
    type names. A dataset authored against an older FC carries that edition's type
    names (e.g. ``BeaconCardinal``, renamed to ``CardinalBeacon`` in FC 2.1.0); an
    FC that does not know a type silently skips its bindings, so validating against
    the wrong edition drops real findings. Ties break toward the newest edition, so
    modern datasets keep using the production catalogue. Empty input → the default.
    """
    cats = _by_version()
    if not cats:
        return None
    names = {t for t in (type_names or ()) if t}
    if not names:
        return catalogue()
    best, best_key = None, None
    for v, fc in cats.items():
        score = sum(1 for t in names if fc.type_for(t) is not None)
        key = (score, _version_key(v))
        if best_key is None or key > best_key:
            best, best_key = fc, key
    return best
