# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
sourceval/recipe.py — the validation recipe (pure Python, JSON on disk).
========================================================================

A *recipe* is the user-authored link between one ENC object layer and one
external source. It is the saveable / loadable unit of the Source Validator.

Design notes baked in here:
  * The source is referenced by a **name** (a layer QGIS already knows), never by
    a connection string with credentials — secrets stay in QGIS's own store, the
    recipe file stays shareable.
  * Enumerated attributes bind on **human-readable labels** ("red"), not raw
    codes (3). ``AttributeMapping.value_map`` is therefore keyed by ENC *label*
    → source value. The catalogue resolves label ⟷ code at run time.
  * Versioned format (``RECIPE_VERSION``) so recipes survive plugin updates.
"""

import json

RECIPE_VERSION = "1.0"


class AttributeMapping:
    """Maps one ENC attribute to one source field, with an optional value map.

    enc_attr      ENC attribute acronym, e.g. "COLOUR".
    source_field  source column name, e.g. "objectKleur".
    enumerated    True  → ``value_map`` is keyed by ENC label ("red" → "rood")
                  False → free-text/numeric: compared directly (value_map empty,
                          or a small literal translation table).
    value_map     {enc_value_or_label : source_value}.
    unmapped      policy for a source value the map does not cover when comparing:
                  "flag" (treat as a discrepancy) or "ignore".
    """

    def __init__(self, enc_attr, source_field, enumerated=False,
                 value_map=None, unmapped="flag", null_values=None):
        self.enc_attr = enc_attr
        self.source_field = source_field
        self.enumerated = bool(enumerated)
        self.value_map = dict(value_map or {})
        self.unmapped = unmapped if unmapped in ("flag", "ignore") else "flag"
        # Source values that should be treated as empty/NULL even though the
        # database stores them as a literal — e.g. an AtoN register that writes
        # "L" (or "-", "n/a") to mean "no value". Compared case-insensitively.
        self.null_values = [str(v) for v in (null_values or [])]

    def is_null(self, value):
        """True if ``value`` is one of the configured NULL sentinels."""
        if value is None:
            return True
        v = str(value).strip().casefold()
        return v == "" or any(v == n.strip().casefold() for n in self.null_values)

    def to_dict(self):
        return {
            "enc_attr": self.enc_attr,
            "source_field": self.source_field,
            "enumerated": self.enumerated,
            "value_map": self.value_map,
            "unmapped": self.unmapped,
            "null_values": self.null_values,
        }

    @classmethod
    def from_dict(cls, d):
        if not isinstance(d, dict):
            raise ValueError(
                f"mapping must be an object, got {type(d).__name__}")
        missing = [k for k in ("enc_attr", "source_field") if not d.get(k)]
        if missing:
            raise ValueError("mapping is missing required field(s): "
                             + ", ".join(missing))
        return cls(
            enc_attr=d["enc_attr"],
            source_field=d["source_field"],
            enumerated=d.get("enumerated", False),
            value_map=d.get("value_map"),
            unmapped=d.get("unmapped", "flag"),
            null_values=d.get("null_values"),
        )


class Recipe:
    """One ENC-layer ⟷ source mapping.

    name        free label for the recipe.
    enc_class   ENC object-class acronym to validate, e.g. "BOYLAT".
    source_ref  name/id of a source QGIS already knows (vector layer).
    buffer_m    proximity tolerance in metres (0–5 in v1): a source point is the
                "same feature" as a chart point if within this distance.
    identity    optional {"enc": "<attr|FOID>", "source": "<field>"} enabling the
                same-id-different-location check. None → that check is skipped.
    mappings    [AttributeMapping] — the attribute/value comparisons to run.
    """

    def __init__(self, name="", enc_class="", source_ref="", buffer_m=10.0,
                 identity=None, mappings=None, version=RECIPE_VERSION,
                 restrict_to_area=True, area_margin_m=250.0, coverage_ref="",
                 enc_exclude_classes=None, enc_exclude=None,
                 source_exclude=None, enc_display="", geometry="point"):
        self.name = name
        self.enc_class = enc_class
        # 'point' (v1 behaviour), 'line' (geomatch dead-band) or 'area'
        # (coverage-clipped symmetric difference — see sourceval/areamatch.py).
        # For line/area recipes buffer_m is the geometry TOLERANCE in metres.
        self.geometry = (geometry if geometry in ("point", "line", "area")
                         else "point")
        # The class/display label without the usage band (e.g. "Aids to
        # Navigation"), so a recipe saved against one usage band can match the
        # equivalent layer in another when shared across areas/usages.
        self.enc_display = enc_display
        self.source_ref = source_ref
        self.buffer_m = float(buffer_m)
        self.identity = dict(identity) if identity else None
        self.mappings = list(mappings or [])
        self.version = version
        # Restrict the "in source, not in chart" check to the chart's own area.
        # If coverage_ref names a coverage polygon layer, the check is clipped to
        # that exact footprint; otherwise it falls back to the padded bounding
        # box (restrict_to_area / area_margin_m).
        self.restrict_to_area = bool(restrict_to_area)
        self.area_margin_m = float(area_margin_m)
        self.coverage_ref = coverage_ref
        # Known-fault exclusions, applied to the population BEFORE reconciliation
        # and disclosed in the report. Stored as the *excluded* set so a recipe
        # with none behaves exactly as before, and a class newly appearing in the
        # data is compared by default rather than silently dropped.
        #   enc_exclude_classes : [S-57 class acronym] removed from the ENC side
        #   enc_exclude         : [{"lnam","objnam","reason"}] ENC denylist
        #   source_exclude      : [{"id","reason"}] source denylist
        self.enc_exclude_classes = [c.upper() for c in (enc_exclude_classes or [])]
        self.enc_exclude = [dict(e) for e in (enc_exclude or [])]
        self.source_exclude = [dict(e) for e in (source_exclude or [])]

    # -- persistence ----------------------------------------------------------
    def to_dict(self):
        return {
            "version": self.version,
            "name": self.name,
            "enc_class": self.enc_class,
            "enc_display": self.enc_display,
            "geometry": self.geometry,
            "source_ref": self.source_ref,
            "buffer_m": self.buffer_m,
            "identity": self.identity,
            "restrict_to_area": self.restrict_to_area,
            "area_margin_m": self.area_margin_m,
            "coverage_ref": self.coverage_ref,
            "enc_exclude_classes": list(self.enc_exclude_classes),
            "enc_exclude": [dict(e) for e in self.enc_exclude],
            "source_exclude": [dict(e) for e in self.source_exclude],
            "mappings": [m.to_dict() for m in self.mappings],
        }

    @classmethod
    def from_dict(cls, d):
        if not isinstance(d, dict):
            raise ValueError(
                f"recipe must be a JSON object, got {type(d).__name__}")
        mappings = []
        for i, m in enumerate(d.get("mappings", []) or []):
            try:
                mappings.append(AttributeMapping.from_dict(m))
            except ValueError as exc:
                raise ValueError(f"mapping #{i + 1}: {exc}") from exc
        return cls(
            name=d.get("name", ""),
            enc_class=d.get("enc_class", ""),
            enc_display=d.get("enc_display", ""),
            geometry=d.get("geometry", "point"),
            source_ref=d.get("source_ref", ""),
            buffer_m=d.get("buffer_m", 10.0),
            identity=d.get("identity"),
            restrict_to_area=d.get("restrict_to_area", True),
            area_margin_m=d.get("area_margin_m", 250.0),
            coverage_ref=d.get("coverage_ref", ""),
            enc_exclude_classes=d.get("enc_exclude_classes", []),
            enc_exclude=d.get("enc_exclude", []),
            source_exclude=d.get("source_exclude", []),
            mappings=mappings,
            version=d.get("version", RECIPE_VERSION),
        )

    def to_json(self, indent=2):
        return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)

    @classmethod
    def from_json(cls, text):
        return cls.from_dict(json.loads(text))

    # -- exclusion membership (fast lookups for reconcile) ---------------------
    def excluded_class_set(self):
        return {c.upper() for c in self.enc_exclude_classes}

    def excluded_lnam_set(self):
        return {str(e.get("lnam", "")).upper()
                for e in self.enc_exclude if e.get("lnam")}

    def excluded_source_set(self):
        return {str(e.get("id", "")) for e in self.source_exclude
                if e.get("id") not in (None, "")}

    def save(self, path):
        with open(path, "w", encoding="utf-8") as fh:
            fh.write(self.to_json())

    @classmethod
    def load(cls, path):
        with open(path, "r", encoding="utf-8") as fh:
            return cls.from_json(fh.read())

    # -- validation of the recipe itself --------------------------------------
    def problems(self):
        """Return a list of human-readable problems that would stop a run
        (empty list = ready to run). Lets the GUI grey out Run with a reason."""
        out = []
        if not self.enc_class:
            out.append("No ENC object layer chosen.")
        if not self.source_ref:
            out.append("No source chosen.")
        if not (0.0 <= self.buffer_m <= 50.0):
            out.append("Buffer must be between 0 and 50 m.")
        for m in self.mappings:
            if not m.enc_attr or not m.source_field:
                out.append("An attribute mapping is incomplete.")
                break
        return out

    def __repr__(self):
        return (f"<Recipe {self.name!r} {self.enc_class}→{self.source_ref} "
                f"buffer={self.buffer_m}m maps={len(self.mappings)}>")
