# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
enc_describe.py
---------------
Human-readable description of S-57 objects for the Chart Viewer pick report
(reusable, QGIS-free — the caller passes plain dicts + lon/lat).

Turns a picked feature (+ its LNAM/LNAM_REFS-linked equipment) into:
  • a header sentence  — e.g.
      "SONGA — green starboard-hand lateral mark (buoy), conical shape —
       with topmark [cone, point up, green] and light [Isophase green 8s]"
  • the position in WGS84 degrees-minutes (3 decimals), for point objects
  • the raw attributes per object, decoded against the datamodel

Coded attribute values are decoded with the bundled catalogue
(``Catalogue.label_for`` / ``enum_labels`` from s57expectedinput.csv). Relations
are resolved by the caller via LNAM / LNAM_REFS (the S-57 master/slave links),
not by guesswork — see the viewer's _on_pick.
"""

# S-57 AtoN families (uppercase acronyms).
ATON_BODIES = {
    "BOYLAT", "BOYCAR", "BOYINB", "BOYISD", "BOYSAW", "BOYSPP",
    "BCNLAT", "BCNCAR", "BCNISD", "BCNSAW", "BCNSPP",
}
TOPMARK_CLASS = "TOPMAR"
LIGHT_CLASS = "LIGHTS"
FOG_CLASS = "FOGSIG"

# The category attribute that names each body class.
_CAT_ATTR = {
    "BOYLAT": "CATLAM", "BCNLAT": "CATLAM",
    "BOYCAR": "CATCAM", "BCNCAR": "CATCAM",
    "BOYSPP": "CATSPM", "BCNSPP": "CATSPM",
    "BOYINB": "CATINB",
}

# Light character abbreviation → full word (for the header sentence).
_LITCHR_WORD = {
    "F": "Fixed", "FL": "Flashing", "LFL": "Long-flashing", "Q": "Quick",
    "VQ": "Very-quick", "UQ": "Ultra-quick", "IQ": "Interrupted-quick",
    "IVQ": "Interrupted-very-quick", "IUQ": "Interrupted-ultra-quick",
    "ISO": "Isophase", "OC": "Occulting", "MO": "Morse",
    "FFL": "Fixed-and-flashing", "FLLFL": "Flashing-and-long-flashing",
    "OCFL": "Occulting-and-flashing", "AL": "Alternating",
}


def format_dm(lon, lat):
    """WGS84 lon/lat (decimal degrees) → "DD° MM.mmm′ N, DDD° MM.mmm′ E"."""
    def one(v, pos, neg, width):
        hemi = pos if v >= 0 else neg
        v = abs(v)
        d = int(v)
        m = (v - d) * 60.0
        if round(m, 3) >= 60.0:                 # carry guard
            d += 1
            m = 0.0
        return f"{d:0{width}d}° {m:06.3f}′ {hemi}"
    return f"{one(lat, 'N', 'S', 2)}, {one(lon, 'E', 'W', 3)}"


# S-101 enumeration decoding: {attributeName: {valueCode: label}}, from the bundled
# S-101 Feature Catalogue. Lazily loaded once (the FC parse is ~0.05 s, cached), and
# only consulted for camelCase S-101 attribute names — never for S-57 acronyms
# (all-uppercase), so an S-57-only pick never touches the FC.
_S101_ENUM = None


def _s101_enum_map():
    global _S101_ENUM
    if _S101_ENUM is None:
        _S101_ENUM = {}
        try:
            from .validation import s101_fc
        except (ImportError, ValueError):
            try:
                from validation import s101_fc
            except Exception:                   # noqa: BLE001
                s101_fc = None
        fc = s101_fc.catalogue() if s101_fc is not None else None
        if fc is not None:
            for code, a in fc.attributes.items():
                if a.enum_labels:
                    _S101_ENUM[code] = a.enum_labels
    return _S101_ENUM


def _s101_labels(attr, value):
    """Decode a coded S-101 attribute value via the S-101 FC, or None if ``attr``
    is not a known S-101 enumeration (e.g. it is an S-57 acronym, or an S-101 real/
    text attribute). Multi-valued (comma/semicolon) values decode element-wise."""
    if not attr or attr.isupper():              # S-57 acronyms are uppercase → skip
        return None
    labels = _s101_enum_map().get(attr)
    if not labels:
        return None
    out = []
    for tok in str(value).replace(";", ",").split(","):
        tok = tok.strip()
        if tok:
            out.append(labels.get(tok, tok))
    return ", ".join(out) if out else None


def _labels(cat, attr, value):
    """Decode a (possibly multi-valued) coded attribute to text, e.g.
    (COLOUR, "3,1") → "red, white". Tries the S-101 Feature Catalogue for S-101
    attribute names, then the S-57 catalogue; falls back to the raw token."""
    if value in (None, ""):
        return ""
    s101 = _s101_labels(attr, value)
    if s101 is not None:
        return s101
    out = []
    for tok in str(value).replace(";", ",").split(","):
        tok = tok.strip()
        if not tok:
            continue
        try:
            out.append(cat.label_for(attr, tok))
        except Exception:                       # noqa: BLE001
            out.append(tok)
    return ", ".join(out)


def _name(attrs):
    return (attrs.get("OBJNAM") or attrs.get("NOBJNM")
            or attrs.get("featureName") or attrs.get("name") or "").strip()


def light_phrase(attrs, cat):
    """e.g. "Isophase green 8s 5M" from LITCHR/COLOUR/SIGGRP/SIGPER/VALNMR."""
    char = _labels(cat, "LITCHR", attrs.get("LITCHR"))
    char = _LITCHR_WORD.get(char.upper(), char) if char else ""
    parts = [char] if char else []
    grp = (attrs.get("SIGGRP") or "").strip().strip("()")
    if grp and grp not in ("1",):
        parts.append(f"({grp})")
    col = _labels(cat, "COLOUR", attrs.get("COLOUR"))
    if col:
        parts.append(col)
    per = (attrs.get("SIGPER") or "").strip()
    if per:
        parts.append(f"{per}s")
    rng = (attrs.get("VALNMR") or "").strip()
    if rng:
        parts.append(f"{rng}M")
    return " ".join(parts).strip()


def topmark_phrase(attrs, cat):
    shp = _labels(cat, "TOPSHP", attrs.get("TOPSHP"))
    col = _labels(cat, "COLOUR", attrs.get("COLOUR"))
    return ", ".join(p for p in (shp, col) if p)


def body_phrase(acr, attrs, cat, cfg):
    """Sentence for an AtoN body (buoy/beacon): colour, category, shape."""
    colour = _labels(cat, "COLOUR", attrs.get("COLOUR"))
    cat_attr = _CAT_ATTR.get(acr)
    category = _labels(cat, cat_attr, attrs.get(cat_attr)) if cat_attr else ""
    shape_attr = "BOYSHP" if acr.startswith("BOY") else "BCNSHP"
    shape = _labels(cat, shape_attr, attrs.get(shape_attr))
    kind = "buoy" if acr.startswith("BOY") else "beacon"
    bits = []
    if colour:
        bits.append(colour)
    if category:
        bits.append(category)
    else:
        bits.append(cfg.display_name(acr).lower())
    head = " ".join(bits) + f" ({kind})"
    if shape:
        head += f", {shape} shape"
    return head


def object_phrase(acr, attrs, cat, cfg):
    """Generic header for a non-AtoN object: class name + a couple of decoded
    key attributes (category / colour) when present."""
    name = cfg.display_name(acr)
    extras = []
    for a in ("CATCOA", "CATSLC", "CATLMK", "COLOUR", "CATACH", "CATREA",
              "RESTRN", "CATBRG"):
        if a in attrs:
            v = _labels(cat, a, attrs.get(a))
            if v:
                extras.append(v)
    return name + (f" — {', '.join(extras)}" if extras else "")


def _is_point(obj):
    return obj.get("lonlat") is not None


def header_sentence(primary, related, cat, cfg):
    acr = (primary.get("acr") or "").upper()
    nm = _name(primary.get("attrs", {}))
    if acr in ATON_BODIES:
        body = body_phrase(acr, primary["attrs"], cat, cfg)
    else:
        body = object_phrase(acr, primary.get("attrs", {}), cat, cfg)
    sentence = (f"{nm} — {body}") if nm else body
    # Fold in linked equipment, resolved by LNAM/LNAM_REFS.
    tops = [r for r in related if (r.get("acr") or "").upper() == TOPMARK_CLASS]
    lights = [r for r in related if (r.get("acr") or "").upper() == LIGHT_CLASS]
    fogs = [r for r in related if (r.get("acr") or "").upper() == FOG_CLASS]
    if tops:
        tp = topmark_phrase(tops[0]["attrs"], cat)
        sentence += f" — with topmark [{tp}]" if tp else " — with topmark"
    if lights:
        lp = light_phrase(lights[0]["attrs"], cat)
        sentence += f" and light [{lp}]" if lp else " and a light"
    if fogs:
        sentence += " and a fog signal"
    return sentence


def _raw_block(obj, cat, cfg, hide=("rcid", "zoomLevel", "LNAM_REFS")):
    acr = obj.get("acr") or ""
    title = cfg.display_name(acr) if acr else (obj.get("name") or "object")
    nm = _name(obj.get("attrs", {}))
    lines = [f"[{acr}] {title}" + (f" — {nm}" if nm else "")]
    for k in sorted(obj.get("attrs", {})):
        if k in hide:
            continue
        v = obj["attrs"][k]
        if v in (None, ""):
            continue
        dec = _labels(cat, k, v)
        if dec and dec != str(v):
            lines.append(f"    {k} = {v}  ({dec})")
        else:
            lines.append(f"    {k} = {v}")
    return "\n".join(lines)


def position_dm(primary):
    """WGS84 degrees-minutes string for a point primary, else None."""
    if not _is_point(primary):
        return None
    lon, lat = primary["lonlat"]
    return format_dm(lon, lat)


def object_tables(primary, related, cat, cfg,
                  hide=("rcid", "zoomLevel", "LNAM_REFS")):
    """Per-object rows for the collapsible raw-data tables: a list of
    {"title", "rows": [(attribute, value, meaning), ...]} — primary first, then
    each LNAM-linked object."""
    out = []
    for o in [primary] + list(related):
        acr = o.get("acr") or ""
        title = (f"[{acr}] {cfg.display_name(acr)}" if acr
                 else (o.get("name") or "object"))
        nm = _name(o.get("attrs", {}))
        if nm:
            title += f" — {nm}"
        rows = []
        for k in sorted(o.get("attrs", {})):
            if k in hide:
                continue
            v = o["attrs"][k]
            if v in (None, ""):
                continue
            dec = _labels(cat, k, v)
            rows.append((k, str(v), dec if dec and dec != str(v) else ""))
        out.append({"title": title, "rows": rows})
    return out


def build_report(primary, related, cat, cfg):
    """Full pick-report text: human header, position (DM) for points, then the
    raw datamodel attributes for the primary and each linked object."""
    out = [header_sentence(primary, related, cat, cfg)]
    if _is_point(primary):
        lon, lat = primary["lonlat"]
        out.append(f"in position: {format_dm(lon, lat)}  (WGS84)")
    out.append("")
    out.append("— Raw data (per datamodel) —")
    out.append(_raw_block(primary, cat, cfg))
    for r in related:
        out.append("")
        out.append(_raw_block(r, cat, cfg))
    return "\n".join(out)
