"""Convert the closed DrawingML effect subset into project SVG filters.

One classifiable outer shadow or one glow maps to the public shadow/glow
contract. Every other source effect receives explicit blocking metadata; the
importer never relabels it as another native effect or drops it silently.
"""

from __future__ import annotations

import math
import re
from dataclasses import dataclass
from xml.etree import ElementTree as ET

from pptx_effects import unsupported_effect_metadata
from pptx_shapes.formula import OOXML_COORDINATE_MAX

from .color_resolver import COLOR_TAGS, ColorPalette, resolve_color
from .emu_units import NS, emu_to_px, fmt_num, format_ooxml_alpha


_OOXML_INTEGER_RE = re.compile(r"[+-]?\d+")
_OOXML_HEX_COLOR_RE = re.compile(r"[0-9A-Fa-f]{6}")
_DRAWINGML_NAMESPACE = NS["a"]
_DRAWINGML_TAG_PREFIX = f"{{{_DRAWINGML_NAMESPACE}}}"
_EFFECT_CONTAINER_NAMES = frozenset({"effectLst", "effectDag"})
_OUTER_SHADOW_ATTRIBUTES = frozenset({
    "algn",
    "blurRad",
    "dir",
    "dist",
    "kx",
    "ky",
    "rotWithShape",
    "sx",
    "sy",
})
_OUTER_SHADOW_ALIGNMENTS = frozenset({
    "b",
    "bl",
    "br",
    "ctr",
    "l",
    "r",
    "t",
    "tl",
    "tr",
})


@dataclass(frozen=True)
class EffectResult:
    """One supported SVG filter or one explicit unsupported-source marker."""

    filter_id: str | None = None
    defs: tuple[str, ...] = ()
    metadata: tuple[tuple[str, str], ...] = ()

    @classmethod
    def unsupported(cls, reason: str) -> "EffectResult":
        return cls(metadata=tuple(unsupported_effect_metadata(reason).items()))


def unsupported_target_effect_metadata(
    sp_pr: ET.Element | None,
    target: str,
) -> dict[str, str]:
    """Mark source effects that cannot attach to this SVG target type."""
    if sp_pr is None:
        return {}
    effect_names: list[str] = []
    for container in sp_pr:
        if (
            not isinstance(container.tag, str)
            or _local_name(container) not in _EFFECT_CONTAINER_NAMES
        ):
            continue
        container_name = _local_name(container)
        if container_name == "effectDag":
            effect_names.append(container_name)
            continue
        effect_names.extend(
            _local_name(child)
            for child in container
            if isinstance(child.tag, str)
        )
    if not effect_names:
        return {}
    return unsupported_effect_metadata(
        f"unsupported-effect-target:{target}:" + ",".join(effect_names)
    )


def convert_effects(
    sp_pr: ET.Element | None,
    palette: ColorPalette | None,
    *,
    id_prefix: str = "fx",
    id_seq: list[int] | None = None,
    target_rotation_degrees: float = 0.0,
) -> EffectResult:
    """Return one supported filter or blocking metadata for source effects."""
    if sp_pr is None:
        return EffectResult()
    containers = [
        child
        for child in sp_pr
        if isinstance(child.tag, str)
        and _local_name(child) in _EFFECT_CONTAINER_NAMES
    ]
    if not containers:
        return EffectResult()
    container_names = [_local_name(child) for child in containers]
    if len(containers) != 1:
        return EffectResult.unsupported(
            "multiple-effect-containers:" + ",".join(container_names)
        )
    container = containers[0]
    container_name = container_names[0]
    if not container.tag.startswith(_DRAWINGML_TAG_PREFIX):
        return EffectResult.unsupported(
            f"invalid-effect-container-namespace:{container_name}"
        )
    if container_name == "effectDag":
        return EffectResult.unsupported("unsupported-effect-container:effectDag")
    effects = [
        child
        for child in container
        if isinstance(child.tag, str)
    ]
    if not effects:
        return EffectResult()
    names = [child.tag.split("}", 1)[-1] for child in effects]
    if len(effects) != 1:
        return EffectResult.unsupported(
            "multiple-effects:" + ",".join(names)
        )

    effect = effects[0]
    effect_name = names[0]
    if not effect.tag.startswith(_DRAWINGML_TAG_PREFIX):
        return EffectResult.unsupported(
            f"invalid-effect-namespace:{effect_name}"
        )
    try:
        if effect_name == "outerShdw":
            unsupported_attributes = _unsupported_outer_shadow_attributes(
                effect,
                target_rotation_degrees=target_rotation_degrees,
            )
            if unsupported_attributes:
                return EffectResult.unsupported(
                    "unsupported-effect-attributes:outerShdw:"
                    + ",".join(unsupported_attributes)
                )
            primitives = _outer_shadow(effect, palette)
        elif effect_name == "glow":
            primitives = _glow(effect, palette)
        else:
            return EffectResult.unsupported(
                f"unsupported-effect:{effect_name}"
            )
    except (OverflowError, TypeError, ValueError) as exc:
        return EffectResult.unsupported(
            f"invalid-effect:{effect_name}:{exc}"
        )

    if id_seq is None:
        id_seq = [0]
    id_seq[0] += 1
    filter_id = f"{id_prefix}{id_seq[0]}"

    # Filter region needs to extend beyond the bounding box to render shadows
    # and glows; choose generous defaults.
    filter_x = "-25%"
    filter_y = "-25%"
    filter_w = "150%"
    filter_h = "150%"

    defs_xml = (
        f'<filter id="{filter_id}" x="{filter_x}" y="{filter_y}" '
        f'width="{filter_w}" height="{filter_h}">'
        + primitives
        + "</filter>"
    )
    return EffectResult(filter_id=filter_id, defs=(defs_xml,))


def _color_alpha(elem: ET.Element, palette: ColorPalette | None) -> tuple[str, float]:
    direct_children = [
        child
        for child in elem
        if isinstance(child.tag, str)
    ]
    colors = [
        child
        for child in direct_children
        if _local_name(child) in COLOR_TAGS
    ]
    if len(colors) != 1:
        reason = "missing-color" if not colors else "multiple-colors"
        raise ValueError(reason)
    if len(direct_children) != 1:
        extras = [
            _local_name(child)
            for child in direct_children
            if child is not colors[0]
        ]
        raise ValueError("unexpected-effect-child:" + ",".join(extras))
    color = colors[0]
    if not color.tag.startswith(_DRAWINGML_TAG_PREFIX):
        raise ValueError(f"invalid-color-namespace:{_local_name(color)}")
    _validate_color(color, palette)
    hex_, alpha = resolve_color(color, palette)
    if hex_ is None:
        raise ValueError(f"unresolvable-color:{_local_name(color)}")
    return hex_, alpha


def _validate_color(
    color: ET.Element,
    palette: ColorPalette | None,
) -> None:
    """Validate the color subset resolved into one SVG filter paint."""
    color_name = _local_name(color)
    if color_name == "srgbClr":
        raw = color.get("val", "")
        if _OOXML_HEX_COLOR_RE.fullmatch(raw) is None:
            raise ValueError(f"invalid-color:{color_name}")
    elif color_name == "schemeClr":
        raw = (color.get("val") or "").strip()
        resolved = palette.resolve_scheme(raw) if palette is not None else None
        if resolved is None or _OOXML_HEX_COLOR_RE.fullmatch(resolved) is None:
            raise ValueError(f"unresolvable-color:{color_name}")
    elif color_name == "sysClr":
        if not (color.get("val") or "").strip():
            raise ValueError(f"invalid-color:{color_name}")
        raw = color.get("lastClr") or ""
        if _OOXML_HEX_COLOR_RE.fullmatch(raw) is None:
            raise ValueError(f"unresolvable-color:{color_name}")
    elif color_name == "hslClr":
        _required_integer(color, "hue", 0, 21_599_999)
        _required_integer(color, "sat", 0, 100000)
        _required_integer(color, "lum", 0, 100000)
    elif color_name == "scrgbClr":
        for attr in ("r", "g", "b"):
            _required_integer(color, attr, 0, 100000)
    elif not (color.get("val") or "").strip():
        raise ValueError(f"invalid-color:{color_name}")

def _required_integer(
    elem: ET.Element,
    attr: str,
    minimum: int,
    maximum: int,
) -> int:
    raw = elem.get(attr)
    if raw is None:
        raise ValueError(f"missing-{_local_name(elem)}-{attr}")
    token = raw.strip()
    if _OOXML_INTEGER_RE.fullmatch(token) is None:
        raise ValueError(f"invalid-{_local_name(elem)}-{attr}")
    value = int(token)
    if not minimum <= value <= maximum:
        raise ValueError(f"invalid-{_local_name(elem)}-{attr}")
    return value


def _local_name(elem: ET.Element) -> str:
    return elem.tag.rsplit("}", 1)[-1]


def _effect_integer(
    elem: ET.Element,
    attr: str,
    *,
    default: int = 0,
    non_negative: bool = False,
    maximum: int | None = None,
) -> int:
    raw = elem.get(attr)
    if raw is None:
        value = default
    else:
        token = raw.strip()
        if _OOXML_INTEGER_RE.fullmatch(token) is None:
            raise ValueError(f"{attr}={raw!r}")
        value = int(token)
    if (
        (non_negative and value < 0)
        or (maximum is not None and value > maximum)
    ):
        raise ValueError(f"{attr}={raw!r}")
    return value


def _unsupported_outer_shadow_attributes(
    elem: ET.Element,
    *,
    target_rotation_degrees: float,
) -> tuple[str, ...]:
    """Return source shadow attributes the local SVG filter cannot preserve."""
    unsupported = set(elem.attrib) - _OUTER_SHADOW_ATTRIBUTES
    neutral_transforms = (
        ("sx", 100000),
        ("sy", 100000),
        ("kx", 0),
        ("ky", 0),
    )
    for attr, neutral in neutral_transforms:
        if attr in elem.attrib and _effect_integer(elem, attr) != neutral:
            unsupported.add(attr)

    raw_alignment = elem.get("algn")
    if (
        raw_alignment is not None
        and raw_alignment.strip() not in _OUTER_SHADOW_ALIGNMENTS
    ):
        raise ValueError(f"algn={raw_alignment!r}")

    raw_rotates = elem.get("rotWithShape")
    if raw_rotates is None:
        rotates_with_shape = True
    else:
        token = raw_rotates.strip()
        if token in {"1", "true"}:
            rotates_with_shape = True
        elif token in {"0", "false"}:
            rotates_with_shape = False
        else:
            raise ValueError(f"rotWithShape={raw_rotates!r}")
    target_is_rotated = not math.isclose(
        math.remainder(target_rotation_degrees, 360.0),
        0.0,
        abs_tol=1e-9,
    )
    if rotates_with_shape and target_is_rotated:
        # CT_OuterShadowEffect defaults rotWithShape to true, while the local
        # SVG-to-PPTX mapping writes false. Blocking the visible distinction
        # prevents the next export from changing the source shadow direction.
        unsupported.add("rotWithShape")

    return tuple(sorted(unsupported))


def _direction_offset(elem: ET.Element) -> tuple[float, float]:
    """Read dir / dist into (dx, dy) px."""
    direction_units = _effect_integer(
        elem,
        "dir",
        non_negative=True,
        maximum=21_599_999,
    )
    dist_emu = _effect_integer(
        elem,
        "dist",
        non_negative=True,
        maximum=OOXML_COORDINATE_MAX,
    )
    direction_deg = direction_units / 60000.0
    dist_px = emu_to_px(dist_emu)
    rad = math.radians(direction_deg)
    return dist_px * math.cos(rad), dist_px * math.sin(rad)


def _blur_radius(elem: ET.Element, attr: str) -> float:
    return emu_to_px(_effect_integer(
        elem,
        attr,
        non_negative=True,
        maximum=OOXML_COORDINATE_MAX,
    ))


def _outer_shadow(
    elem: ET.Element,
    palette: ColorPalette | None,
) -> str:
    dx, dy = _direction_offset(elem)
    blur = _blur_radius(elem, "blurRad")
    dx_token = fmt_num(dx, 8)
    dy_token = fmt_num(dy, 8)
    if abs(float(dx_token)) <= 0.01 and abs(float(dy_token)) <= 0.01:
        raise ValueError("offset-is-not-classifiable")
    color, alpha = _color_alpha(elem, palette)
    # std deviation ~= blur radius / 2 (rough; PowerPoint shadows are larger)
    std = blur / 2.0
    # Use feDropShadow for compactness — it's well-supported in modern browsers.
    return (
        f'<feDropShadow dx="{dx_token}" dy="{dy_token}" '
        f'stdDeviation="{fmt_num(std, 8)}" '
        f'flood-color="{color}" '
        f'flood-opacity="{format_ooxml_alpha(alpha)}"/>'
    )


def _glow(elem: ET.Element, palette: ColorPalette | None) -> str:
    if elem.get("rad") is None:
        raise ValueError("missing-rad")
    rad = _blur_radius(elem, "rad")
    color, alpha = _color_alpha(elem, palette)
    # svg_to_pptx maps stdDeviation directly back to a:glow@rad.
    std = rad
    return (
        f'<feGaussianBlur in="SourceAlpha" stdDeviation="{fmt_num(std, 8)}" result="blurred"/>'
        f'<feFlood flood-color="{color}" '
        f'flood-opacity="{format_ooxml_alpha(alpha)}" result="flood"/>'
        f'<feComposite in="flood" in2="blurred" operator="in" result="glow"/>'
        f'<feMerge><feMergeNode in="glow"/><feMergeNode in="SourceGraphic"/></feMerge>'
    )
