"""DrawingML <a:ln> -> SVG stroke conversion.

Reverse of svg_to_pptx/drawingml/styles.py build_stroke_xml.

Produces an SVG attribute dict with stroke / stroke-width / stroke-opacity /
stroke-dasharray / stroke-linecap / stroke-linejoin / marker-start /
marker-end (markers also need a <defs> entry which is returned alongside).
"""

from __future__ import annotations

from dataclasses import dataclass, field
from xml.etree import ElementTree as ET

from pptx_shapes.formula import validate_ooxml_line_width

from .color_resolver import (
    ColorPalette,
    find_color_elem,
    resolve_color,
    resolve_solid_fill_color,
    validate_no_fill,
)
from .emu_units import NS, emu_to_px, fmt_num, format_ooxml_alpha


@dataclass
class StrokeResult:
    """Resolved stroke: SVG attributes to apply + optional <defs> for markers."""

    attrs: dict[str, str] = field(default_factory=dict)
    defs: list[str] = field(default_factory=list)


# Reverse of svg_to_pptx DASH_PRESETS (preset name -> dasharray).
PRST_DASH_TO_ARRAY = {
    "solid": None,           # no dasharray
    "dot": "1 3",
    "dash": "4 4",
    "lgDash": "8 4",
    "dashDot": "4 4 1 4",
    "lgDashDot": "8 4 2 4",
    "lgDashDotDot": "8 4 2 4 2 4",
    "sysDash": "3 3",
    "sysDot": "1 3",
    "sysDashDot": "3 3 1 3",
    "sysDashDotDot": "3 3 1 3 1 3",
}
_OOXML_INT_MAX = 2**31 - 1
_LINE_PAINT_TAGS = {
    f"{{{NS['a']}}}{name}": name
    for name in (
        "noFill",
        "solidFill",
        "gradFill",
        "pattFill",
        "blipFill",
        "grpFill",
    )
}

# DrawingML cap -> SVG stroke-linecap
CAP_MAP = {
    "rnd": "round",
    "sq": "square",
    "flat": "butt",
}


def resolve_stroke(
    sp_pr: ET.Element | None,
    palette: ColorPalette | None,
    *,
    id_prefix: str = "m",
    id_seq: list[int] | None = None,
    style_stroke_default: str | None = None,
) -> StrokeResult:
    """Resolve <a:ln> child of <p:spPr>.

    Returns:
        StrokeResult.attrs is empty if no <a:ln> present (caller falls back to
        the spec default). If <a:ln> exists with <a:noFill/>, attrs has
        stroke="none".
    """
    if sp_pr is None:
        return StrokeResult()

    ln = sp_pr.find("a:ln", NS)
    if ln is None:
        return StrokeResult()

    attrs: dict[str, str] = {}
    defs: list[str] = []

    compound = ln.attrib.get("cmpd")
    if compound not in {None, "sng"}:
        raise ValueError(
            f"Unsupported DrawingML compound line: {compound!r}"
        )
    alignment = ln.attrib.get("algn")
    if alignment not in {None, "ctr"}:
        raise ValueError(
            f"Unsupported DrawingML line alignment: {alignment!r}"
        )

    # Width (a:ln@w in EMU)
    width_emu = ln.attrib.get("w")
    if width_emu is not None:
        try:
            width_value = int(width_emu)
        except (ValueError, TypeError):
            raise ValueError(
                f"Invalid DrawingML line width: {width_emu!r}"
            ) from None
        validate_ooxml_line_width(width_value)
        width_px = emu_to_px(width_value)
        attrs["stroke-width"] = fmt_num(width_px, 5)

    # Cap
    cap = ln.attrib.get("cap")
    if cap is not None:
        if cap not in CAP_MAP:
            raise ValueError(f"Unsupported DrawingML line cap: {cap!r}")
        attrs["stroke-linecap"] = CAP_MAP[cap]

    # Fill: noFill / solidFill / gradFill
    paints = [child for child in ln if child.tag in _LINE_PAINT_TAGS]
    if len(paints) > 1:
        raise ValueError("DrawingML line must contain at most one paint")
    paint = paints[0] if paints else None
    paint_name = _LINE_PAINT_TAGS.get(paint.tag) if paint is not None else None
    if paint_name not in {None, "noFill", "solidFill", "gradFill"}:
        raise ValueError(f"Unsupported DrawingML line paint: {paint_name}")
    if paint_name == "noFill":
        validate_no_fill(paint)
        attrs["stroke"] = "none"
    elif paint_name == "solidFill":
        hex_, alpha = resolve_solid_fill_color(paint, palette)
        attrs["stroke"] = hex_
        if alpha < 1.0:
            attrs["stroke-opacity"] = format_ooxml_alpha(alpha)
    elif paint_name == "gradFill":
        # Approximate gradient stroke as the first stop color (SVG supports
        # gradient strokes via fill="url()" but it adds a lot of plumbing;
        # first-stop is the registered import normalization).
        first_gs = paint.find("a:gsLst/a:gs", NS)
        if first_gs is None:
            raise ValueError("DrawingML gradient line requires a color stop")
        color_elem = find_color_elem(first_gs)
        hex_, alpha = resolve_color(color_elem, palette)
        if hex_ is None:
            raise ValueError(
                "DrawingML gradient line first color cannot be resolved"
            )
        attrs["stroke"] = hex_
        if alpha < 1.0:
            attrs["stroke-opacity"] = format_ooxml_alpha(alpha)

    # Dash pattern
    preset_tag = f"{{{NS['a']}}}prstDash"
    custom_tag = f"{{{NS['a']}}}custDash"
    dashes = [child for child in ln if child.tag in {preset_tag, custom_tag}]
    if len(dashes) > 1:
        raise ValueError("DrawingML line must contain at most one dash")
    dash = dashes[0] if dashes else None
    if dash is not None and dash.tag == preset_tag:
        if (
            set(dash.attrib) != {"val"}
            or list(dash)
            or (dash.text or "").strip()
        ):
            raise ValueError("Invalid DrawingML preset dash structure")
        preset = dash.attrib["val"]
        if preset not in PRST_DASH_TO_ARRAY:
            raise ValueError(
                f"Unsupported DrawingML preset dash: {preset!r}"
            )
        dasharray = PRST_DASH_TO_ARRAY[preset]
        if dasharray:
            attrs["stroke-dasharray"] = dasharray
    elif dash is not None:
        cust_dash = dash
        if cust_dash.attrib or (cust_dash.text or "").strip():
            raise ValueError("Invalid DrawingML custom dash structure")
        ds_parts: list[str] = []
        sw = float(attrs.get("stroke-width", "1") or "1") or 1.0
        dash_stops = list(cust_dash)
        expected_tag = f"{{{NS['a']}}}ds"
        if not dash_stops or any(
            ds.tag != expected_tag
            or set(ds.attrib) != {"d", "sp"}
            or list(ds)
            for ds in dash_stops
        ):
            raise ValueError("Invalid DrawingML custom dash structure")
        for ds in dash_stops:
            # d, sp are percentages of stroke width (1000ths)
            values: dict[str, int] = {}
            for name in ("d", "sp"):
                raw_value = ds.attrib[name]
                try:
                    value = int(raw_value)
                except (ValueError, TypeError):
                    raise ValueError(
                        f"Invalid DrawingML custom dash {name}: "
                        f"{raw_value!r}"
                    ) from None
                if not 0 < value <= _OOXML_INT_MAX:
                    raise ValueError(
                        f"DrawingML custom dash {name}={value} is "
                        "outside the positive OOXML integer range"
                    )
                values[name] = value
            d_pct = values["d"]
            sp_pct = values["sp"]
            ds_parts.append(fmt_num(d_pct / 100000.0 * sw, 10))
            ds_parts.append(fmt_num(sp_pct / 100000.0 * sw, 10))
        attrs["stroke-dasharray"] = " ".join(ds_parts)

    # Join
    join_names = {
        f"{{{NS['a']}}}round": "round",
        f"{{{NS['a']}}}bevel": "bevel",
        f"{{{NS['a']}}}miter": "miter",
    }
    joins = [child for child in ln if child.tag in join_names]
    if len(joins) > 1:
        raise ValueError("DrawingML line must contain at most one join")
    if joins:
        join = joins[0]
        linejoin = join_names[join.tag]
        if list(join):
            raise ValueError("Invalid DrawingML line join structure")
        if linejoin in {"round", "bevel"} and join.attrib:
            raise ValueError("Invalid DrawingML line join structure")
        if linejoin == "miter":
            if set(join.attrib) - {"lim"}:
                raise ValueError("Invalid DrawingML line join structure")
            limit = join.attrib.get("lim")
            if limit != "800000":
                raise ValueError(
                    f"Unsupported DrawingML miter limit: {limit!r}"
                )
        attrs["stroke-linejoin"] = linejoin

    # Arrow markers (head / tail)
    if id_seq is None:
        id_seq = [0]
    for which, attr in (("headEnd", "marker-start"), ("tailEnd", "marker-end")):
        endpoints = ln.findall(f"a:{which}", NS)
        if len(endpoints) > 1:
            raise ValueError(
                f"DrawingML line must contain at most one {which}"
            )
        if not endpoints:
            continue
        end_elem = endpoints[0]
        if (
            set(end_elem.attrib) - {"type", "w", "len"}
            or list(end_elem)
            or (end_elem.text or "").strip()
        ):
            raise ValueError(f"Invalid DrawingML {which} structure")
        marker_color = attrs.get("stroke") or style_stroke_default or "#000000"
        marker_id, marker_def = _build_arrow_marker(
            end_elem,
            marker_color,
            id_prefix=id_prefix,
            seq=id_seq,
            reversed_=(which == "headEnd"),
        )
        if marker_id is None:
            continue
        defs.append(marker_def)
        attrs[attr] = f"url(#{marker_id})"

    return StrokeResult(attrs=attrs, defs=defs)


# ---------------------------------------------------------------------------
# Arrow marker generation
# ---------------------------------------------------------------------------

# Bucket -> markerWidth/markerHeight ratio (in stroke widths).  These values
# are the stable representatives of the SVG-to-DrawingML bucket thresholds,
# so importing and exporting preserves both ``w`` and ``len`` categories.
SIZE_BUCKET = {"sm": 1.5, "med": 2.5, "lg": 3.5}


def _build_arrow_marker(
    end_elem: ET.Element,
    stroke_color: str,
    *,
    id_prefix: str,
    seq: list[int],
    reversed_: bool,
) -> tuple[str | None, str]:
    """Build an SVG <marker> def for an <a:headEnd>/<a:tailEnd>."""
    typ = end_elem.attrib.get("type")
    if typ not in {
        None,
        "none",
        "triangle",
        "stealth",
        "arrow",
        "diamond",
        "oval",
    }:
        raise ValueError(f"Unsupported DrawingML line-end type: {typ!r}")

    w_b = end_elem.attrib.get("w", "med")
    l_b = end_elem.attrib.get("len", "med")
    for dimension, bucket in (("width", w_b), ("length", l_b)):
        if bucket not in SIZE_BUCKET:
            raise ValueError(
                f"Unsupported DrawingML line-end {dimension} bucket: "
                f"{bucket!r}"
            )
    if typ is None or typ == "none":
        return None, ""
    if stroke_color.strip().lower() == "none":
        raise ValueError(
            "DrawingML line end requires a visible line paint"
        )
    mw = SIZE_BUCKET[l_b]
    mh = SIZE_BUCKET[w_b]

    seq[0] += 1
    marker_id = f"{id_prefix}arrow{seq[0]}"

    # SVG markers are drawn in their own viewBox; we use a 0..10 box and place
    # the path so refX is at the line endpoint.
    if typ == "triangle":
        path = "M 0 0 L 10 5 L 0 10 z"
    elif typ == "stealth":
        path = "M 0 0 L 10 5 L 0 10 L 3 5 z"
    elif typ == "arrow":
        path = "M 0 0 L 10 5 L 0 10"
    elif typ == "diamond":
        path = "M 0 5 L 5 0 L 10 5 L 5 10 z"
    elif typ == "oval":
        path = ""  # use circle below

    if typ == "oval":
        body = f'<circle cx="5" cy="5" r="4" fill="{stroke_color}"/>'
    elif typ == "arrow":
        body = (
            f'<path d="{path}" fill="none" stroke="{stroke_color}"/>'
        )
    else:
        body = f'<path d="{path}" fill="{stroke_color}"/>'

    orient = "auto-start-reverse" if reversed_ else "auto"
    # Note: stroke="none" prevents marker from inheriting parent stroke.
    marker_def = (
        f'<marker id="{marker_id}" viewBox="0 0 10 10" '
        f'refX="{"0" if reversed_ else "10"}" refY="5" '
        f'markerWidth="{fmt_num(mw, 2)}" markerHeight="{fmt_num(mh, 2)}" '
        f'orient="{orient}" markerUnits="strokeWidth">'
        f"{body}</marker>"
    )
    return marker_id, marker_def
