# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
enc_s101_portrayal.py
---------------------
Official IHO S-101 Portrayal Catalogue engine (Python port) — foundation layer.

S-101 portrayal is a set of Lua rules that emit S-100 Part 9 *drawing
instructions*; a host resolves those against the catalogue's colour profiles,
symbols, line styles and area fills. QGIS has no Lua VM, so we re-implement the
host + rules in Python, driven by the catalogue's own data. This is built
renderer-neutral so it can also feed the S-100 server later.

Phase 1 (this module so far): the COLOUR PROFILES — the Day/Dusk/Night colour
tables that every symbol, line, area and text instruction resolves against.

Catalogue location: the IHO catalogue is third-party data, so it is NOT bundled;
we resolve it from (in order) the ENC_S101_CATALOG env var, a bundled
``s101portrayal/PortrayalCatalog`` folder if the office dropped one in, or the
development clone under the S-100 server reference tree.
"""

import os
import re
import tempfile
import xml.etree.ElementTree as ET          # namespace registration + tree.write

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

_HEX_RE = re.compile(r"\.f([A-Za-z0-9]+)\s*\{\s*fill:\s*(#[0-9A-Fa-f]{6})",
                     re.IGNORECASE)
_STROKE_RE = re.compile(r"\.s([A-Za-z0-9]+)\s*\{\s*stroke:\s*(#[0-9A-Fa-f]{6})",
                        re.IGNORECASE)

# palette name -> SVG style sheet file
_PALETTE_CSS = {"Day": "daySvgStyle.css",
                "Dusk": "duskSvgStyle.css",
                "Night": "nightSvgStyle.css"}

_CATALOG_DIR = None
_PROFILES = None                                        # {palette: {token: hex}}


def _candidate_dirs():
    here = os.path.dirname(os.path.abspath(__file__))
    env = os.environ.get("ENC_S101_CATALOG")
    cands = []
    if env:
        cands.append(env)
    cands.append(os.path.join(here, "s101portrayal", "PortrayalCatalog"))
    # Development clone (S-100 server reference tree).
    cands.append(os.path.expanduser(
        "~/Documents/s100-server/reference/"
        "s101-portrayal-catalogue/PortrayalCatalog"))
    return cands


def catalog_dir():
    """Resolve the S-101 Portrayal Catalogue directory, or None if not found."""
    global _CATALOG_DIR
    if _CATALOG_DIR is not None:
        return _CATALOG_DIR or None
    for d in _candidate_dirs():
        if d and os.path.isdir(os.path.join(d, "ColorProfiles")):
            _CATALOG_DIR = d
            return d
    _CATALOG_DIR = ""                                   # cache "not found"
    return None


def _parse_css(path):
    """One SVG style sheet -> {token: '#RRGGBB'} (fill preferred, else stroke)."""
    out = {}
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as fh:
            text = fh.read()
    except OSError:
        return out
    for m in _STROKE_RE.finditer(text):                 # stroke first
        out[m.group(1).upper()] = m.group(2)
    for m in _HEX_RE.finditer(text):                    # fill wins on conflict
        out[m.group(1).upper()] = m.group(2)
    return out


def color_profiles():
    """{'Day'|'Dusk'|'Night': {TOKEN: '#RRGGBB'}} from the catalogue CSS, cached.
    Empty dict if the catalogue can't be found."""
    global _PROFILES
    if _PROFILES is not None:
        return _PROFILES
    _PROFILES = {}
    cat = catalog_dir()
    if cat:
        sym = os.path.join(cat, "Symbols")
        for palette, css in _PALETTE_CSS.items():
            table = _parse_css(os.path.join(sym, css))
            if table:
                _PROFILES[palette] = table
    return _PROFILES


def color_hex(token, palette="Day"):
    """'#RRGGBB' for a colour token in a palette (falls back to Day, then None)."""
    prof = color_profiles()
    if not prof:
        return None
    pal = prof.get(palette) or prof.get("Day") or {}
    return pal.get((token or "").upper())


def color(token, palette="Day"):
    """Resolve a colour token + palette to a QColor (or None). Qt-dependent;
    safe to call only inside QGIS."""
    hexv = color_hex(token, palette)
    if not hexv:
        return None
    try:
        from qgis.PyQt.QtGui import QColor
        return QColor(hexv)
    except Exception:                                   # noqa: BLE001
        return None


# ── Symbol library: bake self-contained, palette-coloured SVGs ───────────────
_SVG_NS = "http://www.w3.org/2000/svg"
_BAKE_CACHE = {}                                        # (name, palette) -> path
_DIM_RE = re.compile(r"([\d.]+)\s*mm")


def _bake_dir():
    d = os.path.join(tempfile.gettempdir(), "enc_s101_symbols")
    os.makedirs(d, exist_ok=True)
    return d


def symbol_svg(name, palette="Day"):
    """Path to a self-contained SVG for a catalogue symbol with the palette's
    colours inlined (the catalogue SVGs reference an EXTERNAL palette CSS, which
    QGIS' SVG renderer ignores — so we resolve every class to explicit
    fill/stroke and drop the layout helpers). Cached per (name, palette)."""
    key = (name, palette)
    if key in _BAKE_CACHE and os.path.exists(_BAKE_CACHE[key]):
        return _BAKE_CACHE[key]
    cat = catalog_dir()
    if not cat:
        return None
    src = os.path.join(cat, "Symbols", name + ".svg")
    if not os.path.isfile(src):
        return None
    pal = color_profiles().get(palette) or color_profiles().get("Day") or {}
    try:
        ET.register_namespace("", _SVG_NS)
        tree = parse_tree(src)
        for el in tree.getroot().iter():
            cls = el.get("class")
            if cls is None:
                continue
            classes = cls.split()
            if "layout" in classes:                     # symbolBox/svgBox/pivot
                el.set("fill", "none")
                el.set("stroke", "none")
                el.set("display", "none")
            else:
                for c in classes:
                    if c == "f0":
                        el.set("fill", "none")
                    elif c == "sl":
                        el.set("stroke-linecap", "round")
                        el.set("stroke-linejoin", "round")
                    elif c[:1] == "f" and len(c) > 1:
                        hx = pal.get(c[1:].upper())
                        if hx:
                            el.set("fill", hx)
                    elif c[:1] == "s" and len(c) > 1:
                        hx = pal.get(c[1:].upper())
                        if hx:
                            el.set("stroke", hx)
            del el.attrib["class"]
        out = os.path.join(_bake_dir(), f"{name}__{palette}.svg")
        tree.write(out, encoding="utf-8", xml_declaration=True)
        _BAKE_CACHE[key] = out
        return out
    except Exception:                                   # noqa: BLE001
        return None


def symbol_size_mm(name):
    """(width_mm, height_mm) of a catalogue symbol from its SVG, or None."""
    cat = catalog_dir()
    if not cat:
        return None
    src = os.path.join(cat, "Symbols", name + ".svg")
    try:
        with open(src, "r", encoding="utf-8", errors="replace") as fh:
            head = fh.read(2048)
        w = _DIM_RE.search(head[head.find("width="):head.find("width=") + 30])
        h = _DIM_RE.search(head[head.find("height="):head.find("height=") + 30])
        if w and h:
            return float(w.group(1)), float(h.group(1))
    except Exception:                                   # noqa: BLE001
        pass
    return None


def marker_layer(name, palette="Day", rotation=0.0):
    """A QGIS SVG-marker layer for a baked S-101 symbol (for build_symbol's point
    path). Mirrors enc_s52_portrayal.marker_layer (which uses raster sprites)."""
    svg = symbol_svg(name, palette)
    if not svg:
        return None
    try:
        from qgis.core import QgsSvgMarkerSymbolLayer
    except Exception:                                   # noqa: BLE001
        return None
    sl = QgsSvgMarkerSymbolLayer(svg)
    size = symbol_size_mm(name)
    if size:
        sl.setSize(max(size))
    if rotation:
        sl.setAngle(float(rotation))
    return sl


# ── Line styles + area fills (parsed to a neutral model) ─────────────────────
def _localname(tag):
    return tag.rsplit("}", 1)[-1]


def parse_linestyle(name):
    """Parse LineStyles/<name>.xml → {'width','color','interval','dashes':[(start,
    length),…]}. Colour is a token (resolve via color()). None if absent."""
    cat = catalog_dir()
    if not cat:
        return None
    p = os.path.join(cat, "LineStyles", name + ".xml")
    if not os.path.isfile(p):
        return None
    try:
        root = parse_root(p)
        out = {"width": None, "color": None, "interval": None, "dashes": []}
        for el in root.iter():
            ln = _localname(el.tag)
            if ln == "intervalLength":
                out["interval"] = float(el.text or 0)
            elif ln == "pen":
                out["width"] = float(el.get("width") or 0)
            elif ln == "color" and out["color"] is None:
                out["color"] = (el.text or "").strip()
            elif ln == "dash":
                s = el.find("./{*}start")
                length = el.find("./{*}length")
                out["dashes"].append(
                    (float(s.text) if s is not None else 0.0,
                     float(length.text) if length is not None else 0.0))
        return out
    except Exception:                                   # noqa: BLE001
        return None


def parse_areafill(name):
    """Parse AreaFills/<name>.xml → {'symbol': ref or None, 'placement': [...]}.
    A symbol-pattern fill references a symbol laid out on a vector lattice."""
    cat = catalog_dir()
    if not cat:
        return None
    p = os.path.join(cat, "AreaFills", name + ".xml")
    if not os.path.isfile(p):
        return None
    try:
        root = parse_root(p)
        out = {"symbol": None, "placement": []}
        for el in root.iter():
            ln = _localname(el.tag)
            if ln == "symbol":
                out["symbol"] = el.get("reference")
            elif ln in ("v1", "v2"):
                x = el.find("./{*}x")
                y = el.find("./{*}y")
                out["placement"].append(
                    (float(x.text) if x is not None else 0.0,
                     float(y.text) if y is not None else 0.0))
        return out
    except Exception:                                   # noqa: BLE001
        return None
