"""CSS-first styling for rendered XY marks.

DOM chrome already accepts arbitrary safe CSS declarations.  Marks are drawn
by WebGL, SVG, and the native rasterizer, so they cannot honestly accept the
entire browser cascade.  This module defines the smaller, internal CSS subset
that all renderers can compile to XY's existing trace style vocabulary.

The input contract is ordinary CSS: kebab-case property names and CSS values.
Python snake_case aliases remain accepted for ergonomics and backwards
compatibility, but normalized output uses CSS names.
Unsupported declarations fail before data ingestion rather than being silently
ignored by one renderer.
"""

from __future__ import annotations

import re
from collections.abc import Mapping
from typing import Any, TypeAlias

import numpy as np

from . import _validate

StyleValue: TypeAlias = str | int | float
StyleMapping: TypeAlias = Mapping[str, StyleValue]

_NUMBER = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
_PX_RE = re.compile(rf"^\s*({_NUMBER})(?:px)?\s*$", re.IGNORECASE)

_LINE_KINDS = frozenset({"line", "step", "stairs", "ecdf"})
_SIMPLE_STROKE_KINDS = frozenset({"segments", "errorbar", "contour", "stem"})
_AREA_KINDS = frozenset({"area", "error_band"})
_POINT_KINDS = frozenset({"scatter"})
_RECT_KINDS = frozenset({"histogram", "hist", "bar", "column"})
_FILL_KINDS = frozenset({"box", "violin"})
_MESH_KINDS = frozenset({"triangle_mesh"})
# A ribbon is filled and outlined but never gradient-filled through `style`:
# its two end colors are channels, not per-trace state, so "fill" is
# deliberately absent from its property set and the guard below rejects a
# `linear-gradient(...)` on it with the standard message.
_RIBBON_KINDS = frozenset({"ribbon"})
# Funnel per-stage colors are a channel (categorical by stage, or explicit
# per-stage paints), so like ribbon its style carries no "fill".
_FUNNEL_KINDS = frozenset({"funnel"})
_DENSITY_KINDS = frozenset({"heatmap", "hexbin"})

_AXIS_COLOR_PROPERTIES = frozenset(
    {"grid_color", "axis_color", "tick_color", "tick_label_color", "label_color"}
)
_AXIS_LENGTH_PROPERTIES = frozenset(
    {
        "grid_width",
        "axis_width",
        "tick_length",
        "tick_label_pad",
        "tick_padding",
        "tick_width",
    }
)
_AXIS_SIZE_PROPERTIES = frozenset({"tick_size", "tick_label_size", "label_size"})
_AXIS_FONT_PROPERTIES = frozenset({"label_font_family", "label_font_style", "label_font_weight"})
_AXIS_COMPAT_PROPERTIES = frozenset({"grid_dash", "grid_opacity"})
_AXIS_DASH_STYLES = frozenset({"solid", "dashed", "dotted", "dashdot"})
_AXIS_DIRECTIONS = frozenset({"in", "out", "inout"})

# Polyline stroke geometry. All three mark renderers (WebGL, SVG, native
# rasterizer) draw these caps identically; XY defaults to `round` rather than
# the CSS initial value `butt` because the native rasterizer is the reference
# for static export and has always drawn round. Set the property to opt into
# the CSS initial value.
#
# `stroke-linejoin` is deliberately absent. SVG and the native rasterizer both
# implement all three joins, but the WebGL client draws polylines as one
# instanced quad per segment with no join geometry at all, so a `miter` there
# would render as the overlap the segments already produce. Accepting a
# declaration one renderer silently ignores is exactly what this module exists
# to prevent, so the property waits for the client. The gap — including the
# joins the three renderers already disagree on by default — is a row in
# `xy.styling.capabilities`.
LINE_CAPS = frozenset({"butt", "round", "square"})
DEFAULT_LINE_CAP = "round"

_MARK_KINDS = tuple(
    sorted(
        _LINE_KINDS
        | _SIMPLE_STROKE_KINDS
        | _AREA_KINDS
        | _POINT_KINDS
        | _RECT_KINDS
        | _FILL_KINDS
        | _MESH_KINDS
        | _RIBBON_KINDS
        | _FUNNEL_KINDS
        | _DENSITY_KINDS
    )
)


# Compiled style dicts keyed by their exact input items. Charts rebuild with
# the same handful of style dicts (rc-derived axis chrome, repeated mark
# styles), and compilation — normalization, native CSS parsing, px/opacity
# checks — is a pure function of (kind, label, dict), so each distinct input
# compiles once per process instead of once per chart build (the pyplot perf
# guardrail counts on this). Only successful compilations are cached; the
# type names keep e.g. True from hitting a cached 1 (equal, same hash,
# different verdict).
_COMPILE_CACHE: dict[tuple, dict[str, Any]] = {}


def _compile_cached(cache_kind: str, label: str, value: Any, compute: Any) -> dict[str, Any]:
    if value is None or isinstance(value, dict):
        try:
            items = (
                () if value is None else tuple((k, v, type(v).__name__) for k, v in value.items())
            )
            key = (cache_kind, label, items)
            hit = _COMPILE_CACHE.get(key)
        except TypeError:  # unhashable value: compile (and likely raise) below
            key = hit = None
        if hit is not None:
            # Fresh top-level dict and fresh lists (dasharrays): callers own
            # their copy outright, nothing aliases the cache.
            return {k: list(v) if isinstance(v, list) else v for k, v in hit.items()}
    else:
        key = None
    out = compute()
    if key is not None:
        if len(_COMPILE_CACHE) > 4096:
            _COMPILE_CACHE.clear()
        _COMPILE_CACHE[key] = {k: list(v) if isinstance(v, list) else v for k, v in out.items()}
    return out


def normalize_css_style(value: StyleMapping | None, label: str = "style") -> dict[str, StyleValue]:
    """Validate and canonicalize a CSS declaration mapping.

    The shared declaration grammar provides injection safety and strict checks
    for known colors, lengths, and numeric properties.  Canonicalization makes
    serialization and schema-driven generation deterministic.
    """
    if value is None:
        return {}
    raw = dict(value)
    gradient_fills: dict[str, StyleValue] = {}
    ordinary: dict[str, StyleValue] = {}
    for key, item in raw.items():
        canonical = _validate._css_property_name(key).lower() if isinstance(key, str) else key
        if (
            canonical == "fill"
            and isinstance(item, str)
            and item.strip().lower().startswith("linear-gradient(")
        ):
            _validate.mark_fill(item, f"{label}[{key!r}]")
            gradient_fills[key] = item
        else:
            ordinary[key] = item
    validated = _validate.style_mapping(ordinary, label)
    validated.update(gradient_fills)
    out: dict[str, StyleValue] = {}
    for key, item in validated.items():
        canonical = _validate._css_property_name(key).lower()
        if canonical in out and out[canonical] != item:
            raise ValueError(
                f"{label} defines {canonical!r} more than once through CSS/Python aliases"
            )
        out[canonical] = item
    return out


def _parse_px(value: StyleValue, label: str) -> float:
    if isinstance(value, (int, float)) and not isinstance(value, bool):
        return float(value)
    elif isinstance(value, str):
        match = _PX_RE.match(value)
        if match is None:
            raise ValueError(f"{label} must be a finite CSS px length")
        return float(match.group(1))
    else:  # pragma: no cover - normalize_css_style rejects this first
        raise ValueError(f"{label} must be a finite CSS px length")


def _px(value: StyleValue, label: str, *, positive: bool = False) -> float:
    number = _parse_px(value, label)
    if not np.isfinite(number) or number < 0 or (positive and number <= 0):
        qualifier = "positive " if positive else "non-negative "
        raise ValueError(f"{label} must be a {qualifier}finite CSS px length")
    return number


def _signed_px(value: StyleValue, label: str) -> float:
    number = _parse_px(value, label)
    if not np.isfinite(number):
        raise ValueError(f"{label} must be a finite CSS px length")
    return number


def _opacity(value: StyleValue, label: str) -> float:
    try:
        number = float(value)
    except (TypeError, ValueError) as exc:
        raise ValueError(f"{label} must be a number between 0 and 1") from exc
    return _validate.opacity(number, label)


def _dasharray(value: StyleValue, label: str) -> list[float] | None:
    if isinstance(value, str):
        if value.strip().lower() == "none":
            return None
        tokens = [token for token in re.split(r"[\s,]+", value.strip()) if token]
    elif isinstance(value, (int, float)):
        tokens = [value]
    else:  # pragma: no cover - normalize_css_style rejects this first
        tokens = []
    lengths = [_px(token, f"{label}[{i}]", positive=True) for i, token in enumerate(tokens)]
    if not 2 <= len(lengths) <= 8:
        raise ValueError(f"{label} must contain between 2 and 8 positive CSS px lengths")
    return lengths


def _keyword(value: StyleValue, allowed: frozenset[str], label: str) -> str:
    if not isinstance(value, str) or value not in allowed:
        raise ValueError(f"{label} must be one of {sorted(allowed)}")
    return value


def _paint(value: StyleValue, label: str) -> str:
    return _validate.css_color(value, label)


def _fill(value: StyleValue, label: str) -> tuple[str, Any]:
    if isinstance(value, str) and value.strip().lower().startswith("linear-gradient("):
        _validate.mark_fill(value, label)
        return "fill", value.strip()
    return "color", _paint(value, label)


def _set(result: dict[str, Any], key: str, value: Any, source: str, seen: dict[str, str]) -> None:
    previous = seen.get(key)
    if previous is not None and result[key] != value:
        raise ValueError(f"style properties {previous!r} and {source!r} set conflicting {key!r}")
    result[key] = value
    seen[key] = source


def _supported_mark_style_properties(kind: str) -> tuple[str, ...]:
    """Return canonical CSS properties supported by a rendered mark kind."""
    if kind not in _MARK_KINDS:
        raise ValueError(f"unknown mark kind {kind!r}; expected one of {_MARK_KINDS}")
    props = {"opacity"}
    if kind in _LINE_KINDS:
        props |= {
            "stroke",
            "stroke-width",
            "stroke-opacity",
            "stroke-dasharray",
            "stroke-linecap",
        }
    elif kind in _SIMPLE_STROKE_KINDS:
        props |= {"stroke", "stroke-width", "stroke-opacity"}
    elif kind in _AREA_KINDS:
        props |= {
            "fill",
            "fill-opacity",
            "stroke",
            "stroke-width",
            "stroke-opacity",
            "stroke-dasharray",
        }
        if kind == "error_band":
            props.discard("stroke-dasharray")
    elif kind in _POINT_KINDS:
        props |= {
            "fill",
            "fill-opacity",
            "stroke",
            "stroke-width",
            "stroke-opacity",
            "marker-shape",
        }
    elif kind in _RECT_KINDS:
        props |= {
            "fill",
            "fill-opacity",
            "stroke",
            "stroke-width",
            "stroke-opacity",
            "border-radius",
            "wedge-gap",
        }
    elif kind in _FILL_KINDS:
        props |= {"fill", "fill-opacity"}
        # Box bodies use the shared rectangle renderer, so their border is a
        # real cross-renderer stroke. Violin marks are adjacent density bands:
        # stroking every band would draw internal seams rather than one
        # distribution outline, so keep their smaller fill-only contract.
        if kind == "box":
            props |= {"stroke", "stroke-width", "stroke-opacity"}
    elif kind in _RIBBON_KINDS | _FUNNEL_KINDS:
        # No "fill": a ribbon's two end colors — and a funnel's per-stage
        # colors — are channels. Leaving the property out is what makes
        # `style={"fill": "linear-gradient(...)"}` raise instead of silently
        # painting one paint across per-mark geometry.
        props |= {"fill-opacity", "stroke", "stroke-width", "stroke-opacity"}
    elif kind in _MESH_KINDS:
        props |= {
            "fill",
            "fill-opacity",
            "stroke",
            "stroke-width",
            "stroke-opacity",
        }
    elif kind in _DENSITY_KINDS:
        props |= {"fill-opacity"}
    return tuple(sorted(props))


def compile_mark_style(
    kind: str, value: StyleMapping | None, label: str | None = None
) -> dict[str, Any]:
    """Compile standard CSS declarations into a mark builder's keyword args."""
    label = label or f"{kind} style"
    return _compile_cached(
        f"mark:{kind}", label, value, lambda: _compile_mark_style(kind, value, label)
    )


def _compile_mark_style(kind: str, value: StyleMapping | None, label: str) -> dict[str, Any]:
    style = normalize_css_style(value, label)
    supported = set(_supported_mark_style_properties(kind))
    unknown = sorted(set(style) - supported)
    if unknown:
        expected = ", ".join(sorted(supported))
        raise ValueError(
            f"{label} has unsupported CSS property/properties {unknown}; "
            f"{kind} supports: {expected}"
        )

    out: dict[str, Any] = {}
    seen: dict[str, str] = {}
    for prop, raw in style.items():
        if prop == "opacity":
            _set(out, "opacity", _opacity(raw, f"{label}['opacity']"), prop, seen)
        elif prop == "fill-opacity":
            _set(
                out,
                "fill_opacity",
                _opacity(raw, f"{label}['fill-opacity']"),
                prop,
                seen,
            )
        elif prop == "stroke-opacity":
            _set(
                out,
                "stroke_opacity",
                _opacity(raw, f"{label}['stroke-opacity']"),
                prop,
                seen,
            )
        elif prop == "fill":
            target, paint = _fill(raw, f"{label}[{prop!r}]")
            if target == "fill" and kind not in _AREA_KINDS | _RECT_KINDS:
                raise ValueError(f"{label}[{prop!r}] gradients are not supported by {kind}")
            if kind in _LINE_KINDS | _SIMPLE_STROKE_KINDS:
                target = "color"
            _set(out, target, paint, prop, seen)
        elif prop == "stroke":
            target = "line_color" if kind == "area" else "color"
            if kind in _POINT_KINDS | _RECT_KINDS | _MESH_KINDS | _RIBBON_KINDS | _FUNNEL_KINDS | {
                "box"
            }:
                target = "stroke"
            _set(out, target, _paint(raw, f"{label}['stroke']"), prop, seen)
        elif prop == "stroke-width":
            target = "line_width" if kind in _AREA_KINDS else "width"
            if kind in _POINT_KINDS | _RECT_KINDS | _MESH_KINDS | _RIBBON_KINDS | _FUNNEL_KINDS | {
                "box"
            }:
                target = "stroke_width"
            _set(out, target, _px(raw, f"{label}['stroke-width']"), prop, seen)
        elif prop == "stroke-dasharray":
            _set(out, "dash", _dasharray(raw, f"{label}['stroke-dasharray']"), prop, seen)
        elif prop == "stroke-linecap":
            _set(out, "linecap", _keyword(raw, LINE_CAPS, f"{label}['stroke-linecap']"), prop, seen)
        elif prop == "marker-shape":
            _set(out, "symbol", _validate.point_symbol(raw, f"{label}['marker-shape']"), prop, seen)
        elif prop == "border-radius":
            _set(out, "corner_radius", _px(raw, f"{label}['border-radius']"), prop, seen)
        elif prop == "wedge-gap":
            # Gap between neighbouring polar wedges, in px. Deliberately a
            # LENGTH and not an angle: an angular pad's gap is `r · dtheta`
            # wide, so it tapers to nothing at the hole (see
            # `_wedge_edge_inset` in _svg.py). Ignored outside coords="polar".
            _set(out, "wedge_gap", _px(raw, f"{label}['wedge-gap']"), prop, seen)
    return out


def compile_axis_style(
    value: StyleMapping | None, label: str = "axis style"
) -> dict[str, StyleValue]:
    """Validate renderer-backed axis appearance and normalize it to wire keys.

    Axis chrome is partly canvas-painted and partly DOM, so it has a strict
    cross-renderer vocabulary just like marks. Pixel lengths accept numbers or
    CSS ``px`` strings and are serialized as finite numbers for every renderer.
    The more explicit ``tick_label_*`` keys are retained for the pyplot adapter
    and can differ from the tick-mark paint.
    """
    return _compile_cached("axis", label, value, lambda: _compile_axis_style(value, label))


def _compile_axis_style(
    value: StyleMapping | None, label: str = "axis style"
) -> dict[str, StyleValue]:
    style = normalize_css_style(value, label)
    supported = (
        _AXIS_COLOR_PROPERTIES
        | _AXIS_LENGTH_PROPERTIES
        | _AXIS_SIZE_PROPERTIES
        | _AXIS_FONT_PROPERTIES
        | _AXIS_COMPAT_PROPERTIES
        | {"tick_direction", "tick_label_anchor"}
    )
    out: dict[str, StyleValue] = {}
    sources: dict[str, str] = {}
    for css_prop, raw in style.items():
        prop = css_prop.replace("-", "_")
        if prop not in supported:
            expected = ", ".join(sorted(supported))
            raise ValueError(f"{label} has unsupported property {css_prop!r}; supports: {expected}")
        if prop in _AXIS_COLOR_PROPERTIES:
            parsed: StyleValue = _paint(raw, f"{label}[{css_prop!r}]")
        elif prop in {"tick_label_pad", "tick_padding"}:
            parsed = _signed_px(raw, f"{label}[{css_prop!r}]")
        elif prop in _AXIS_LENGTH_PROPERTIES:
            parsed = _px(raw, f"{label}[{css_prop!r}]")
        elif prop in _AXIS_SIZE_PROPERTIES:
            parsed = _px(raw, f"{label}[{css_prop!r}]", positive=True)
        elif prop == "label_font_style":
            if not isinstance(raw, str) or raw not in {"normal", "italic", "oblique"}:
                raise ValueError(f"{label}[{css_prop!r}] must be 'normal', 'italic', or 'oblique'")
            parsed = raw
        elif prop in {"label_font_family", "label_font_weight"}:
            if not isinstance(raw, (str, int, float)) or isinstance(raw, bool):
                raise ValueError(f"{label}[{css_prop!r}] must be a CSS font value")
            parsed = raw
        elif prop == "grid_opacity":
            parsed = _opacity(raw, f"{label}[{css_prop!r}]")
        elif prop == "grid_dash":
            if not isinstance(raw, str) or raw not in _AXIS_DASH_STYLES:
                raise ValueError(
                    f"{label}[{css_prop!r}] must be one of {sorted(_AXIS_DASH_STYLES)}"
                )
            parsed = raw
        elif prop == "tick_label_anchor":
            parsed = _validate.axis_tick_label_anchor(raw, f"{label}[{css_prop!r}]")
        else:
            if not isinstance(raw, str) or raw not in _AXIS_DIRECTIONS:
                raise ValueError(f"{label}[{css_prop!r}] must be one of {sorted(_AXIS_DIRECTIONS)}")
            parsed = raw
        canonical = "tick_padding" if prop == "tick_label_pad" else prop
        _set(out, canonical, parsed, css_prop, sources)
    return out


def _opacity_channels(compiled: Mapping[str, Any]) -> dict[str, float]:
    """Return renderer-only fill/stroke alpha channels from compiled CSS."""
    return {
        key: float(compiled[key]) for key in ("fill_opacity", "stroke_opacity") if key in compiled
    }


__all__ = [
    "DEFAULT_LINE_CAP",
    "LINE_CAPS",
    "StyleMapping",
    "StyleValue",
    "compile_axis_style",
    "compile_mark_style",
    "normalize_css_style",
]
