# SPDX-FileCopyrightText: 2025 XLeitstelle Planen und Bauen <xleitstelle@gv.hamburg.de>
# SPDX-FileContributor: Tobias Kraft <tobias.kraft@gv.hamburg.de>
#
# SPDX-License-Identifier: EUPL-1.2

"""Build a QgsRuleBasedRenderer from a CartoSym YAML style file.

Reads raw PyYAML dicts — no Pydantic or ruamel.yaml dependency — so this
module runs inside QGIS with only PyYAML and qgis.core available.

Geometry dimension sub-rules (sysId=dataLayer.featuresGeometryDimensions) are
evaluated at build time: matching branches are flattened into the parent, and
non-matching branches are discarded entirely.
"""

from __future__ import annotations

from pathlib import Path
from typing import Any

import yaml
from qgis.core import (
    Qgis,
    QgsFillSymbol,
    QgsFontMarkerSymbolLayer,
    QgsLineSymbol,
    QgsMarkerLineSymbolLayer,
    QgsMarkerSymbol,
    QgsProperty,
    QgsRuleBasedRenderer,
    QgsSimpleFillSymbolLayer,
    QgsSimpleLineSymbolLayer,
    QgsSimpleMarkerSymbolLayer,
    QgsSimpleMarkerSymbolLayerBase,
    QgsSVGFillSymbolLayer,
    QgsSvgMarkerSymbolLayer,
    QgsSymbol,
    QgsSymbolLayer,
)
from qgis.PyQt.QtCore import Qt
from qgis.PyQt.QtGui import QColor

from xmas_plugin.util.helpers import get_plugin_root

_GLOBAL_UNIT = Qgis.RenderUnit.MapUnits

_IMAGE_PATH = Path(get_plugin_root()) / "resources/styles/svg"

_CAP_STYLE_MAP: dict[str, Qt.PenCapStyle] = {
    "butt": Qt.PenCapStyle.FlatCap,
    "flat": Qt.PenCapStyle.FlatCap,
    "square": Qt.PenCapStyle.SquareCap,
    "round": Qt.PenCapStyle.RoundCap,
}

_JOIN_STYLE_MAP: dict[str, Qt.PenJoinStyle] = {
    "bevel": Qt.PenJoinStyle.BevelJoin,
    "miter": Qt.PenJoinStyle.MiterJoin,
    "round": Qt.PenJoinStyle.RoundJoin,
}

_PLACEMENT_MAP: dict[str, Qgis.MarkerLinePlacement] = {
    "Interval": Qgis.MarkerLinePlacement.Interval,
    "Vertex": Qgis.MarkerLinePlacement.Vertex,
    "InnerVertices": Qgis.MarkerLinePlacement.InnerVertices,
    "LastVertex": Qgis.MarkerLinePlacement.LastVertex,
    "FirstVertex": Qgis.MarkerLinePlacement.FirstVertex,
    "CentralPoint": Qgis.MarkerLinePlacement.CentralPoint,
    "CurvePoint": Qgis.MarkerLinePlacement.CurvePoint,
    "SegmentCenter": Qgis.MarkerLinePlacement.SegmentCenter,
}

# geometry string → CartoSym dimension integer
_GEOM_DIMENSION: dict[str, int] = {"polygon": 2, "line": 1, "point": 0}

# CartoSym dimension → QGIS symbol class
_GEOM_SYMBOL: dict[int, type[QgsSymbol]] = {
    2: QgsFillSymbol,
    1: QgsLineSymbol,
    0: QgsMarkerSymbol,
}

# Vendor-namespaced field aliases — must match Field(alias=...) in cartosym.py
_A_PATTERN_STROKE = "vendor.xleitstelle.patternStroke"
_A_OFFSET = "vendor.xleitstelle.offset"
_A_ID = "vendor.xleitstelle.id"


# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------


def build_renderer(
    yaml_path: str | Path,
    geometry: str,
) -> QgsRuleBasedRenderer:
    """Build a QgsRuleBasedRenderer from a CartoSym YAML style file.

    Args:
        yaml_path: Path to the YAML style definition file.
        geometry: One of "polygon", "line", "point".

    Returns:
        A QgsRuleBasedRenderer ready for QgsVectorLayer.setRenderer().
    """
    with open(yaml_path, encoding="utf-8") as f:
        data = yaml.safe_load(f)

    layer_dim = _GEOM_DIMENSION[geometry]
    root_rule = QgsRuleBasedRenderer.Rule(None)
    for rule_dict in data.get("stylingRules", []):
        for rule in _build_qgis_rules(rule_dict, layer_dim):
            root_rule.appendChild(rule)
    return QgsRuleBasedRenderer(root_rule)


# ---------------------------------------------------------------------------
# Rule tree
# ---------------------------------------------------------------------------


def _is_geom_dim_selector(selector: Any) -> bool:
    return (
        isinstance(selector, dict)
        and selector.get("op") == "="
        and isinstance(selector.get("args"), list)
        and len(selector["args"]) == 2
        and isinstance(selector["args"][0], dict)
        and selector["args"][0].get("sysId") == "dataLayer.featuresGeometryDimensions"
    )


def _build_qgis_rules(
    rule_dict: dict[str, Any],
    layer_dim: int,
) -> list[QgsRuleBasedRenderer.Rule]:
    """Return zero or more QGIS rules for one CartoSym StylingRule dict.

    Geometry-dimension selectors are evaluated here at build time:
    non-matching branches return an empty list, matching branches are
    flattened (their symbolizer and nested rules bubble up to the caller).
    """
    selector = rule_dict.get("selector")

    # Standalone geom-dim rule (top-level / backward-compat): flatten into siblings.
    if _is_geom_dim_selector(selector):
        dim_val = selector["args"][1]
        if int(dim_val) != layer_dim:
            return []
        nested = rule_dict.get("nestedRules") or []
        per_layer = [r for r in nested if not r.get("selector")]
        children = [r for r in nested if r.get("selector")]
        sym_dict = rule_dict.get("symbolizer")
        symbol = (
            _build_symbol(sym_dict, layer_dim)
            if sym_dict
            else _collapse_per_layer(per_layer, layer_dim)
        )
        rules: list[QgsRuleBasedRenderer.Rule] = []
        if symbol is not None:
            r = QgsRuleBasedRenderer.Rule(symbol)
            r.setRuleKey(f"{{{rule_dict[_A_ID]}}}")
            _set_description(r, rule_dict.get("$comment"))
            rules.append(r)
        for nr in children:
            rules.extend(_build_qgis_rules(nr, layer_dim))
        return rules

    # Normal rule (featuretype / attr / group).
    # Per-layer children (no selector) collapse into this rule's symbol.
    # Geom-dim children are consumed inline: their symbol hoists up here and
    # their attr sub-rules become this rule's children — no filterless rules emitted.
    nested = rule_dict.get("nestedRules") or []
    per_layer = [r for r in nested if not r.get("selector")]
    sel_children = [r for r in nested if r.get("selector")]

    sym_dict = rule_dict.get("symbolizer")
    symbol = (
        _build_symbol(sym_dict, layer_dim)
        if sym_dict
        else _collapse_per_layer(per_layer, layer_dim)
    )

    qgis_children: list[QgsRuleBasedRenderer.Rule] = []
    for nr in sel_children:
        if _is_geom_dim_selector(nr.get("selector")):
            dim_val = nr["selector"]["args"][1]
            if int(dim_val) != layer_dim:
                continue  # wrong geometry — discard entire branch
            # Hoist geom-dim: its symbol → this rule; its attr children → this rule's children
            geom_nested = nr.get("nestedRules") or []
            geom_per_layer = [r for r in geom_nested if not r.get("selector")]
            geom_children = [r for r in geom_nested if r.get("selector")]
            if symbol is None:
                nr_sym = nr.get("symbolizer")
                symbol = (
                    _build_symbol(nr_sym, layer_dim)
                    if nr_sym
                    else _collapse_per_layer(geom_per_layer, layer_dim)
                )
            for gc in geom_children:
                qgis_children.extend(_build_qgis_rules(gc, layer_dim))
        else:
            qgis_children.extend(_build_qgis_rules(nr, layer_dim))

    rule = QgsRuleBasedRenderer.Rule(symbol)
    rule.setRuleKey(f"{{{rule_dict[_A_ID]}}}")
    _set_label(rule, rule_dict.get("name"))
    _set_description(rule, rule_dict.get("$comment"))
    if selector:
        expr = _cql2_to_qgis(selector)
        if expr:
            rule.setFilterExpression(expr)
    for child in qgis_children:
        rule.appendChild(child)
    return [rule]


def _set_label(rule: QgsRuleBasedRenderer.Rule, name: str | None) -> None:
    if name:
        rule.setLabel(name)


def _set_description(rule: QgsRuleBasedRenderer.Rule, comment: str | None) -> None:
    if comment:
        rule.setDescription(comment)


# ---------------------------------------------------------------------------
# Symbol builder
# ---------------------------------------------------------------------------


def _build_symbol(sym_dict: dict[str, Any], layer_dim: int) -> QgsSymbol | None:
    """Build a QgsSymbol from a single CartoSym symbolizer dict."""
    layers = _build_symbolizer_to_layers(sym_dict, layer_dim)
    if not layers:
        return None
    sym = _GEOM_SYMBOL.get(layer_dim, QgsFillSymbol)(layers)
    sym.setOutputUnit(_GLOBAL_UNIT)
    return sym


def _collapse_per_layer(
    per_layer_rules: list[dict[str, Any]],
    layer_dim: int,
) -> QgsSymbol | None:
    """Collapse selector-less per-layer StylingRule dicts into one QgsSymbol."""
    all_layers: list[QgsSymbolLayer] = []
    for r in per_layer_rules:
        layers = _build_symbolizer_to_layers(r.get("symbolizer") or {}, layer_dim)
        if layers and r.get(_A_ID):
            layers[0].setId(f"{{{r[_A_ID]}}}")
        all_layers.extend(layers)
    if not all_layers:
        return None
    sym = _GEOM_SYMBOL.get(layer_dim, QgsFillSymbol)(all_layers)
    sym.setOutputUnit(_GLOBAL_UNIT)
    return sym


def _build_symbolizer_to_layers(
    sym_dict: dict[str, Any],
    layer_dim: int,
) -> list[QgsSymbolLayer]:
    """Extract QgsSymbolLayer list from one CartoSym symbolizer dict."""
    fill = sym_dict.get("fill")
    stroke = sym_dict.get("stroke")
    pattern_stroke = sym_dict.get(_A_PATTERN_STROKE)
    marker_d = sym_dict.get("marker")

    layers: list[QgsSymbolLayer] = []
    stroke_consumed = False

    if fill:
        fill_pattern = fill.get("pattern")
        if fill_pattern:
            layers.append(_build_svg_fill_layer(fill_pattern, stroke))
        else:
            # stroke here is the fill's own outline — incorporate it directly
            layers.append(_build_simple_fill_layer(fill, stroke))
        stroke_consumed = True

    if stroke and not stroke_consumed:
        layers.append(_build_simple_line_layer(stroke))

    if pattern_stroke:
        layers.append(_build_marker_line_layer(pattern_stroke))

    if marker_d:
        for elem in marker_d.get("elements") or []:
            lyr = _build_graphic_marker_layer(elem)
            if lyr is not None:
                layers.append(lyr)

    return layers


# ---------------------------------------------------------------------------
# Symbol layer builders
# ---------------------------------------------------------------------------


def _build_simple_fill_layer(
    fill: dict[str, Any],
    stroke: dict[str, Any] | None = None,
) -> QgsSimpleFillSymbolLayer:
    layer = QgsSimpleFillSymbolLayer()

    color_val = fill.get("color")
    opacity = _scalar(fill.get("opacity"), 1.0)
    if _is_property_ref(color_val):
        layer.setDataDefinedProperty(
            QgsSymbolLayer.Property.FillColor,
            QgsProperty.fromField(color_val["property"]),
        )
    elif color_val is not None:
        layer.setColor(_read_color(color_val, opacity))

    if stroke:
        sc = stroke.get("color")
        so = _scalar(stroke.get("opacity"), 1.0)
        if _is_property_ref(sc):
            layer.setDataDefinedProperty(
                QgsSymbolLayer.Property.StrokeColor,
                QgsProperty.fromField(sc["property"]),
            )
        elif sc is not None:
            layer.setStrokeColor(_read_color(sc, so))
        sw = stroke.get("width")
        if _is_property_ref(sw):
            layer.setDataDefinedProperty(
                QgsSymbolLayer.Property.StrokeWidth,
                QgsProperty.fromField(sw["property"]),
            )
        else:
            layer.setStrokeWidth(_uv(sw, 0.26))
        layer.setStrokeWidthUnit(_GLOBAL_UNIT)
        js = stroke.get("join")
        if js:
            layer.setPenJoinStyle(_JOIN_STYLE_MAP.get(js, Qt.PenJoinStyle.BevelJoin))
    else:
        layer.setStrokeStyle(Qt.PenStyle.NoPen)

    return layer


def _build_simple_line_layer(stroke: dict[str, Any]) -> QgsSimpleLineSymbolLayer:
    layer = QgsSimpleLineSymbolLayer()

    color_val = stroke.get("color")
    opacity = _scalar(stroke.get("opacity"), 1.0)
    if _is_property_ref(color_val):
        layer.setDataDefinedProperty(
            QgsSymbolLayer.Property.StrokeColor,
            QgsProperty.fromField(color_val["property"]),
        )
    elif color_val is not None:
        layer.setColor(_read_color(color_val, opacity))

    width_val = stroke.get("width")
    if _is_property_ref(width_val):
        layer.setDataDefinedProperty(
            QgsSymbolLayer.Property.StrokeWidth,
            QgsProperty.fromField(width_val["property"]),
        )
    else:
        layer.setWidth(_uv(width_val, 0.26))
    layer.setWidthUnit(_GLOBAL_UNIT)

    offset_val = stroke.get(_A_OFFSET)
    if offset_val is not None and not _is_property_ref(offset_val):
        layer.setOffset(_uv(offset_val, 0.0))
    layer.setOffsetUnit(_GLOBAL_UNIT)

    layer.setPenCapStyle(
        _CAP_STYLE_MAP.get(stroke.get("cap", ""), Qt.PenCapStyle.FlatCap)
    )
    layer.setPenJoinStyle(
        _JOIN_STYLE_MAP.get(stroke.get("join", ""), Qt.PenJoinStyle.BevelJoin)
    )

    dash = stroke.get("dashPattern")
    if dash is not None and not _is_property_ref(dash):
        layer.setCustomDashVector([float(v) for v in dash])
        layer.setCustomDashPatternUnit(_GLOBAL_UNIT)
        layer.setUseCustomDashPattern(True)
    elif _is_property_ref(dash):
        layer.setDataDefinedProperty(
            QgsSymbolLayer.Property.CustomDash,
            QgsProperty.fromField(dash["property"]),
        )

    return layer


def _build_svg_fill_layer(
    pattern: dict[str, Any],
    stroke: dict[str, Any] | None,
) -> QgsSVGFillSymbolLayer:
    image_path = ""
    if pattern.get("type") == "Image":
        image_path = (pattern.get("image") or {}).get("path", "")
    layer = QgsSVGFillSymbolLayer(_resolve_image_path(image_path))

    _transform = pattern.get("transform") or {}
    _scaling = _transform.get("scaling")
    size_val = _scaling[0] if isinstance(_scaling, list) and _scaling else None
    layer.setPatternWidth(_uv(size_val, 10.0))
    layer.setPatternWidthUnit(_GLOBAL_UNIT)

    if stroke:
        color_val = stroke.get("color")
        opacity = _scalar(stroke.get("opacity"), 1.0)
        if color_val is not None and not _is_property_ref(color_val):
            layer.setSvgStrokeColor(_read_color(color_val, opacity))
        layer.setSvgStrokeWidth(_uv(stroke.get("width"), 0.26))
        layer.setSvgStrokeWidthUnit(_GLOBAL_UNIT)

    return layer


def _build_marker_line_layer(ps: dict[str, Any]) -> QgsMarkerLineSymbolLayer:
    layer = QgsMarkerLineSymbolLayer()
    layer.setInterval(_uv(ps.get("patternGap"), 3.0))
    layer.setIntervalUnit(_GLOBAL_UNIT)
    layer.setOffset(_uv(ps.get(_A_OFFSET), 0.0))
    layer.setOffsetUnit(_GLOBAL_UNIT)
    layer.setOffsetAlongLine(_uv(ps.get("patternInitialGap"), 0.0))
    layer.setOffsetAlongLineUnit(_GLOBAL_UNIT)

    placements = Qgis.MarkerLinePlacements()
    for p in ps.get("placements") or ["Interval"]:
        placements |= _PLACEMENT_MAP.get(p, Qgis.MarkerLinePlacement.Interval)
    layer.setPlacements(placements)
    layer.setRotateSymbols(bool(ps.get("rotate", True)))

    sub_graphic = ps.get("pattern")
    if sub_graphic:
        marker_lyr = _build_graphic_marker_layer(sub_graphic)
        if marker_lyr is not None:
            sub_sym = QgsMarkerSymbol([marker_lyr])
            sub_sym.setOutputUnit(_GLOBAL_UNIT)
            layer.setSubSymbol(sub_sym)

    return layer


def _build_graphic_marker_layer(
    graphic: dict[str, Any],
) -> QgsSymbolLayer | None:
    """Build a QGIS marker layer from a CartoSym Graphic dict (Image/Dot/Text)."""
    gtype = graphic.get("type")
    _transform = graphic.get("transform") or {}
    _scaling = _transform.get("scaling")
    size_val = _scaling[0] if isinstance(_scaling, list) and _scaling else None
    _orientation = _transform.get("orientation")
    angle_val = _orientation.get("deg") if isinstance(_orientation, dict) else None

    if gtype == "Image":
        path = (graphic.get("image") or {}).get("path", "")
        layer = QgsSvgMarkerSymbolLayer(_resolve_image_path(path))
        if _is_property_ref(size_val):
            layer.setDataDefinedProperty(
                QgsSymbolLayer.Property.Size,
                QgsProperty.fromField(size_val["property"]),
            )
        elif _is_cql2_expr(size_val):
            layer.setDataDefinedProperty(
                QgsSymbolLayer.Property.Size,
                QgsProperty.fromExpression(_cql2_to_qgis(size_val)),
            )
        else:
            layer.setSize(_uv(size_val, 5.0))
        layer.setSizeUnit(_GLOBAL_UNIT)
        if _is_property_ref(angle_val):
            layer.setDataDefinedProperty(
                QgsSymbolLayer.Property.Angle,
                QgsProperty.fromField(angle_val["property"]),
            )
        elif _is_cql2_expr(angle_val):
            layer.setDataDefinedProperty(
                QgsSymbolLayer.Property.Angle,
                QgsProperty.fromExpression(_cql2_to_qgis(angle_val)),
            )
        elif angle_val is not None:
            layer.setAngle(float(angle_val))
        return layer

    if gtype == "Dot":
        shape, _ = QgsSimpleMarkerSymbolLayerBase.decodeShape("circle")
        layer = QgsSimpleMarkerSymbolLayer(shape)
        if _is_property_ref(size_val):
            layer.setDataDefinedProperty(
                QgsSymbolLayer.Property.Size,
                QgsProperty.fromField(size_val["property"]),
            )
        elif _is_cql2_expr(size_val):
            layer.setDataDefinedProperty(
                QgsSymbolLayer.Property.Size,
                QgsProperty.fromExpression(_cql2_to_qgis(size_val)),
            )
        else:
            layer.setSize(_uv(size_val, 2.0))
        layer.setSizeUnit(_GLOBAL_UNIT)
        if _is_property_ref(angle_val):
            layer.setDataDefinedProperty(
                QgsSymbolLayer.Property.Angle,
                QgsProperty.fromField(angle_val["property"]),
            )
        elif _is_cql2_expr(angle_val):
            layer.setDataDefinedProperty(
                QgsSymbolLayer.Property.Angle,
                QgsProperty.fromExpression(_cql2_to_qgis(angle_val)),
            )
        elif angle_val is not None:
            layer.setAngle(float(angle_val))
        return layer

    if gtype == "Text":
        text_raw = graphic.get("text", "?")
        char = "?" if isinstance(text_raw, dict) else str(text_raw)
        font_d = graphic.get("font") or {}
        face = font_d.get("face", "Arial")
        # face may be a plain str (most common) or a CharacterExpression-unwrapped value
        font_name = face if isinstance(face, str) else "Arial"
        layer = QgsFontMarkerSymbolLayer(font_name, char)
        if _is_property_ref(text_raw):
            layer.setDataDefinedProperty(
                QgsSymbolLayer.Property.Character,
                QgsProperty.fromField(text_raw["property"]),
            )
        elif _is_cql2_expr(text_raw):
            layer.setDataDefinedProperty(
                QgsSymbolLayer.Property.Character,
                QgsProperty.fromExpression(_cql2_to_qgis(text_raw)),
            )
        if _is_property_ref(size_val):
            layer.setDataDefinedProperty(
                QgsSymbolLayer.Property.Size,
                QgsProperty.fromField(size_val["property"]),
            )
        elif _is_cql2_expr(size_val):
            layer.setDataDefinedProperty(
                QgsSymbolLayer.Property.Size,
                QgsProperty.fromExpression(_cql2_to_qgis(size_val)),
            )
        else:
            layer.setSize(_uv(size_val, 3.0))
        layer.setSizeUnit(_GLOBAL_UNIT)
        if _is_property_ref(angle_val):
            layer.setDataDefinedProperty(
                QgsSymbolLayer.Property.Angle,
                QgsProperty.fromField(angle_val["property"]),
            )
        elif _is_cql2_expr(angle_val):
            layer.setDataDefinedProperty(
                QgsSymbolLayer.Property.Angle,
                QgsProperty.fromExpression(_cql2_to_qgis(angle_val)),
            )
        elif angle_val is not None:
            layer.setAngle(float(angle_val))
        return layer

    return None


# ---------------------------------------------------------------------------
# CQL2-JSON → QGIS expression walker
# ---------------------------------------------------------------------------


def _cql2_to_qgis(node: Any) -> str:
    """Recursively convert a CQL2-JSON dict (loaded from YAML) to a QGIS expression string."""
    if isinstance(node, bool):
        return "TRUE" if node else "FALSE"
    if isinstance(node, str):
        return f"'{node}'"
    if isinstance(node, (int, float)):
        if isinstance(node, float) and node.is_integer():
            return str(int(node))
        return str(node)
    if isinstance(node, list):
        return f"({', '.join(_cql2_to_qgis(i) for i in node)})"
    if isinstance(node, dict):
        if "op" in node:
            op: str = node["op"]
            args: list = node.get("args", [])
            match op:
                case "and":
                    return " AND ".join(f"({_cql2_to_qgis(a)})" for a in args)
                case "or":
                    return " OR ".join(f"({_cql2_to_qgis(a)})" for a in args)
                case "not":
                    return f"NOT ({_cql2_to_qgis(args[0])})"
                case "=" | "<>" | "!=" | "<" | ">" | "<=" | ">=":
                    return f"{_cql2_to_qgis(args[0])} {op} {_cql2_to_qgis(args[1])}"
                case "isNull":
                    return f"{_cql2_to_qgis(args[0])} IS NULL"
                case "in":
                    vals = ", ".join(_cql2_to_qgis(v) for v in args[1])
                    return f"{_cql2_to_qgis(args[0])} IN ({vals})"
                case _:
                    # General function call fallback (e.g. array_contains, array_get, ...)
                    return f"{op}({', '.join(_cql2_to_qgis(a) for a in args)})"
        if "property" in node:
            prop = node["property"]
            if "." not in prop:
                return f"properties['{prop}']"
            parts = prop.split(".")
            result = f"properties['{parts[0]}']"
            for part in parts[1:]:
                result += f"[{part}]" if part.isdigit() else f"['{part}']"
            return result
        if node.get("sysId") == "dataLayer.id":
            return "featuretype"
    return str(node)


# ---------------------------------------------------------------------------
# Value helpers
# ---------------------------------------------------------------------------


def _is_property_ref(v: Any) -> bool:
    """True if v is a CQL2 property reference dict {"property": "fieldName"}."""
    return isinstance(v, dict) and "property" in v and len(v) == 1


def _is_cql2_expr(v: Any) -> bool:
    """True if v is a complex CQL2 expression dict (function call or operator)."""
    return isinstance(v, dict) and "op" in v


def _uv(v: Any, default: float = 0.0) -> float:
    """Extract a float from a CartoSym UnitValue dict {m: x}, literal, or None.

    Always call after _is_property_ref() — a property ref dict raises KeyError here.
    """
    if v is None:
        return default
    if isinstance(v, dict) and "m" in v:
        return float(v["m"])
    if isinstance(v, (int, float)) and not isinstance(v, bool):
        return float(v)
    return default


def _scalar(v: Any, default: float = 1.0) -> float:
    """Extract a plain scalar (opacity etc.) — no unit wrapping expected."""
    if v is None:
        return default
    if isinstance(v, (int, float)) and not isinstance(v, bool):
        return float(v)
    return default


def _read_color(color_val: list, opacity: float = 1.0) -> QColor:
    """Build a QColor from a CartoSym [r, g, b] float array and a 0–1 opacity."""
    r, g, b = int(color_val[0]), int(color_val[1]), int(color_val[2])
    return QColor(r, g, b, int(opacity * 255))


def _resolve_image_path(path: str) -> str:
    """Resolve relative image path."""
    return str(_IMAGE_PATH / path)
