# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
enc_s52_portrayal.py
--------------------
S-52 front-end for the unified portrayal engine — portrays S-57 / Inland-ENC
features by sourcing the S-52 Presentation Library from the OpenCPN
``chartsymbols.xml`` (open data), emitting the common drawing instructions that
``enc_portrayal.build_symbol`` renders.

Provides, from chartsymbols.xml + the rastersymbols-{day,dusk,dark}.png sprites:
  • colour tables (DAY_BRIGHT / DUSK / NIGHT) → color_hex(token, palette)
  • the Look-Up Tables (LUPT) → select_lookup(acronym, attrs, geom) → S-52
    instruction string ("AC(DEPVS);LS(SOLD,1,CHGRN);CS(DEPARE03)")
  • a symbol provider → symbol_png(name, palette): crops the symbol's bitmap out
    of the palette sprite sheet into a standalone PNG for a QGIS raster marker.

CS(<proc>) conditional-symbology calls are expanded by enc_s52_csp before the
instructions reach the interpreter. The library is third-party (OpenCPN, GPLv2)
and NOT bundled — resolved from an env override or the local OpenCPN install.
"""

import os
import tempfile

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

_PALETTE_TABLE = {"Day": "DAY_BRIGHT", "Dusk": "DUSK", "Night": "NIGHT"}
_PALETTE_SPRITE = {"Day": "rastersymbols-day.png",
                   "Dusk": "rastersymbols-dusk.png",
                   "Night": "rastersymbols-dark.png"}

# Default S-52 symbology table per geometry (ECDIS-style: simplified points,
# plain area boundaries, line table for lines).
_DEFAULT_TABLE = {"point": "Simplified", "area": "Plain", "line": "Lines"}

_DATA_DIR = None
_PARSED = None                                          # the parsed catalogue
_SPRITE_CACHE = {}                                      # (name, palette) -> png


def _log(msg):
    try:
        from qgis.core import QgsMessageLog, Qgis
        QgsMessageLog.logMessage(msg, "ENCore GIS", Qgis.Info)
    except Exception:                                   # noqa: BLE001
        pass


def data_dir():
    """Locate the OpenCPN s57data dir (holds chartsymbols.xml + sprites)."""
    global _DATA_DIR
    if _DATA_DIR is not None:
        return _DATA_DIR or None
    cands = []
    env = os.environ.get("ENC_S52_S57DATA")
    if env:
        cands.append(env)
    cands += [r"C:/Program Files (x86)/OpenCPN/s57data",
              r"C:/Program Files/OpenCPN/s57data",
              "/usr/share/opencpn/s57data",
              "/Applications/OpenCPN.app/Contents/SharedSupport/s57data"]
    for d in cands:
        if d and os.path.isfile(os.path.join(d, "chartsymbols.xml")):
            _DATA_DIR = d
            return d
    _DATA_DIR = ""
    return None


# ── Parse chartsymbols.xml (colours, lookups, symbols) ───────────────────────
def _parse():
    global _PARSED
    if _PARSED is not None:
        return _PARSED
    _PARSED = {"colors": {}, "lookups": {}, "symbols": {}}
    d = data_dir()
    if not d:
        return _PARSED
    try:
        root = parse_root(os.path.join(d, "chartsymbols.xml"))
    except Exception as e:                              # noqa: BLE001
        _log(f"S-52: chartsymbols.xml parse failed: {e}")
        return _PARSED

    # colour-tables: {table_name: {token: '#rrggbb'}}
    for ct in root.iter("color-table"):
        name = ct.get("name")
        table = {}
        for c in ct.findall("color"):
            try:
                table[c.get("name")] = "#{:02X}{:02X}{:02X}".format(
                    int(c.get("r")), int(c.get("g")), int(c.get("b")))
            except (TypeError, ValueError):
                continue
        if name:
            _PARSED["colors"][name] = table

    # lookups (LUPT): {class: [ {table, type, prio, conds:[(attr,val)], instr} ]}
    for lk in root.iter("lookup"):
        cls = lk.get("name")
        if not cls:
            continue
        conds = []
        for ac in lk.findall("attrib-code"):
            txt = (ac.text or "").strip()
            if len(txt) >= 6:
                conds.append((txt[:6], txt[6:]))        # acronym(6) + value
        instr_el = lk.find("instruction")
        _PARSED["lookups"].setdefault(cls, []).append({
            "table": (lk.findtext("table-name") or "").strip(),
            "type": (lk.findtext("type") or "").strip(),
            "conds": conds,
            "instr": (instr_el.text if instr_el is not None else "") or "",
        })

    # symbols: {name: {x,y,w,h,px,py}} from the bitmap graphics-location
    for tag in ("symbol", "pattern", "line-style"):
        for sym in root.iter(tag):
            name = sym.findtext("name")
            bm = sym.find("bitmap")
            if not name or bm is None:
                continue
            gl = bm.find("graphics-location")
            pv = bm.find("pivot")
            if gl is None:
                continue
            try:
                _PARSED["symbols"][name] = {
                    "x": int(gl.get("x")), "y": int(gl.get("y")),
                    "w": int(bm.get("width")), "h": int(bm.get("height")),
                    "px": int(pv.get("x")) if pv is not None else 0,
                    "py": int(pv.get("y")) if pv is not None else 0,
                }
            except (TypeError, ValueError):
                continue
    _log(f"S-52: parsed {len(_PARSED['colors'])} colour-tables, "
         f"{len(_PARSED['lookups'])} classes, {len(_PARSED['symbols'])} symbols")
    return _PARSED


# ── Colours ──────────────────────────────────────────────────────────────────
def color_hex(token, palette="Day"):
    cols = _parse()["colors"]
    table = cols.get(_PALETTE_TABLE.get(palette, "DAY_BRIGHT")) \
        or cols.get("DAY_BRIGHT") or {}
    return table.get((token or "").upper())


# ── LUPT selection ───────────────────────────────────────────────────────────
def select_lookup(acronym, attrs, geom_type, table=None):
    """Best-matching S-52 instruction string for a feature. attrs: {acr: value
    (str)}. Picks the candidate (right table + geometry) whose attribute
    conditions ALL hold and that has the MOST conditions (most specific); falls
    back to the class's unconditional lookup, then the QUESMRK default."""
    parsed = _parse()
    cands = parsed["lookups"].get(acronym)
    if not cands:
        return None
    gt = (geom_type or "").lower()
    want_type = ("Area" if gt.startswith(("area", "poly")) else
                 "Line" if gt.startswith("line") else "Point")
    want_table = table or _DEFAULT_TABLE.get(
        "area" if want_type == "Area" else
        "line" if want_type == "Line" else "point")

    def matches(lk):
        for acr, val in lk["conds"]:
            fv = attrs.get(acr)
            if fv is None or str(fv) != val:
                return False
        return True

    pool = [lk for lk in cands if lk["type"] == want_type]
    if not pool:
        pool = cands
    typed = [lk for lk in pool if lk["table"] == want_table] or pool
    matching = [lk for lk in typed if matches(lk)]
    if matching:
        best = max(matching, key=lambda lk: len(lk["conds"]))
        return best["instr"]
    uncond = [lk for lk in typed if not lk["conds"]]
    if uncond:
        return uncond[0]["instr"]
    return typed[0]["instr"] if typed else None


# ── Symbol provider: crop sprite → standalone PNG ────────────────────────────
def symbol_png(name, palette="Day"):
    """Crop a symbol's bitmap out of the palette sprite sheet into a standalone
    PNG (QGIS raster-marker source). Cached per (name, palette). Returns
    (path, pivot_x, pivot_y, w, h) or None."""
    key = (name, palette)
    if key in _SPRITE_CACHE and os.path.exists(_SPRITE_CACHE[key][0]):
        return _SPRITE_CACHE[key]
    info = _parse()["symbols"].get(name)
    d = data_dir()
    if not info or not d:
        return None
    try:
        from qgis.PyQt.QtGui import QImage
    except Exception:                                   # noqa: BLE001
        return None
    sprite = os.path.join(d, _PALETTE_SPRITE.get(palette, "rastersymbols-day.png"))
    img = QImage(sprite)
    if img.isNull():
        return None
    sub = img.copy(info["x"], info["y"], info["w"], info["h"])
    if sub.isNull():
        return None
    outdir = os.path.join(tempfile.gettempdir(), "enc_s52_symbols")
    os.makedirs(outdir, exist_ok=True)
    out = os.path.join(outdir, f"{name}__{palette}.png")
    sub.save(out, "PNG")
    res = (out, info["px"], info["py"], info["w"], info["h"])
    _SPRITE_CACHE[key] = res
    return res


# Sprite-pixel → mm scale, tuned so symbols are legible on screen, with a floor
# so even small symbols stay recognisable (the "always visible" sweep).
_PX_TO_MM = 0.12
_MIN_SYMBOL_MM = 3.0


def marker_layer(name, palette, rotation=0.0):
    """A QGIS raster-marker layer for an S-52 symbol (cropped from the sprite
    sheet), anchored at the symbol's pivot. For build_symbol's point path."""
    res = symbol_png(name, palette)
    if not res:
        return None
    path, px, py, w, h = res
    try:
        from qgis.core import QgsRasterMarkerSymbolLayer
        from qgis.PyQt.QtCore import QPointF
    except Exception:                                   # noqa: BLE001
        return None
    sl = QgsRasterMarkerSymbolLayer(path)
    sl.setSize(max(max(w, h) * _PX_TO_MM, _MIN_SYMBOL_MM))
    # Offset so the pivot (not the bitmap centre) lands on the feature point.
    sl.setOffset(QPointF((w / 2.0 - px) * _PX_TO_MM,
                         (h / 2.0 - py) * _PX_TO_MM))
    if rotation:
        sl.setAngle(float(rotation))
    return sl


def apply_s52_engine(layer, acronym, kind, palette="Day", ctx=None):
    """Render an S-57/IENC class layer with the S-52 engine: for each feature
    select its LUPT instruction, expand CSPs, build a symbol; identical results
    are grouped into a categorized renderer (so every feature gets exactly its
    S-52 portrayal). Returns True if real S-52 portrayal was applied, False if
    the class has no usable LUPT (caller then falls back to QML/neutral)."""
    try:
        from qgis.core import (QgsCategorizedSymbolRenderer, QgsRendererCategory,
                               QgsField)
        from qgis.PyQt.QtCore import QVariant
        from . import enc_portrayal
    except (ImportError, ValueError):
        try:
            from qgis.core import (QgsCategorizedSymbolRenderer,
                                   QgsRendererCategory, QgsField)
            from qgis.PyQt.QtCore import QVariant
            import enc_portrayal
        except Exception:                               # noqa: BLE001
            return False
    ctx = ctx or {}
    names = [f.name() for f in layer.fields()]
    sig_symbol, fid_sig, any_real = {}, {}, False
    for ft in layer.getFeatures():
        attrs = {n: ft[n] for n in names if ft[n] not in (None, "")}
        instrs = portray(acronym, attrs, kind, palette, ctx)
        if instrs:
            any_real = True
        sig = repr(instrs)
        fid_sig[ft.id()] = sig
        if sig not in sig_symbol:
            sig_symbol[sig] = enc_portrayal.build_symbol(
                instrs, kind, palette, color_hex, marker_layer=marker_layer)
    if not any_real or not sig_symbol:
        return False
    if "s52key" not in names:
        layer.dataProvider().addAttributes([QgsField("s52key", QVariant.String)])
        layer.updateFields()
    idx = layer.fields().indexFromName("s52key")
    layer.dataProvider().changeAttributeValues(
        {fid: {idx: sig} for fid, sig in fid_sig.items()})
    cats = [QgsRendererCategory(sig, sym, "") for sig, sym in sig_symbol.items()]
    layer.setRenderer(QgsCategorizedSymbolRenderer("s52key", cats))
    return True


def portray(acronym, attrs, geom_type, palette="Day", ctx=None):
    """Full S-52 portrayal for one feature → primitive drawing instructions
    (selected LUPT instruction with CS() expanded by the ported CSPs). ``ctx``
    carries the mariner contours {shallow, safety, deep}."""
    try:
        from . import enc_portrayal, enc_s52_csp
    except (ImportError, ValueError):
        import enc_portrayal
        import enc_s52_csp
    instr = select_lookup(acronym, attrs or {}, geom_type)
    if not instr:
        return []
    return enc_s52_csp.expand(enc_portrayal.parse_s52(instr),
                              attrs or {}, ctx or {})
