"""Entity - Base class for all drawable objects."""

from __future__ import annotations

import math
from abc import ABC, abstractmethod
from collections.abc import Collection, Iterator
from typing import TYPE_CHECKING, Any, Literal

from ..animation.builders import build_follow, build_move, build_scale, build_spin
from ..animation.shared import (
    _tag_with_chain,
    add_generic_animate,
    apply_chain,
    apply_loop,
    clear_all_animations,
    consume_chain_delay,
)
from ..animation import typed_methods
from ..color import Color, ColorLike
from ..gradient import Gradient
from .binding import Binding
from .connection import Connection
from .coord import Coord, CoordLike
from .relcoord import RelCoord, RelCoordLike
from .positions import NAMED_POSITIONS, AnchorSpec
from .svg_utils import svg_num
from .tangent import get_angle_at, perpendicular_shift


if TYPE_CHECKING:
    from ..animation.models import EasingLike, RepeatLike
    from ..config.caps import CapName
    from ..config.styles import PathStyle
    from ..entities.path import Path
    from .pathable import FullPathable, Pathable
    from .surface import Surface


class Entity(ABC):
    """
    Base class for all drawable objects in PyFreeform.

    Entities are objects with identity - they can be moved, connected,
    and tracked. Unlike raw primitives, entities maintain relationships
    with other entities.

    Attributes:
        position: Current position (center point for most entities)
        surface: The surface containing this entity (Cell, Scene, or CellGroup)
        connections: Connections involving this entity

    Subclasses must implement:
        - `anchor(name)`: Return anchor point by name
        - `anchor_names`: Property listing available anchor names
        - `to_svg()`: Render to SVG element string
    """

    def __init__(self, x: float = 0, y: float = 0, z_index: int = 0) -> None:
        """
        Initialize entity at position.

        Args:
            x: Initial x coordinate.
            y: Initial y coordinate.
            z_index: Layer ordering (higher = on top). Default 0.
        """
        self._position = Coord(x, y)
        self._surface: Surface | None = None
        self._connections: dict[Connection, None] = {}
        self._data: dict[str, Any] = {}
        self._z_index = z_index

        # Relative coordinate storage
        self._relative_at: RelCoord | None = None
        self._reference: Surface | Entity | None = None
        self._along_path: Pathable | None = None
        self._along_t: float = 0.5
        self._along_offset: float | None = None
        self._resolving: bool = False

        # Non-destructive transforms (accumulated, resolved at render time)
        self._rotation: float = 0.0
        self._scale_factor: float = 1.0

        # Opacity (overridden by subclasses with entity-specific defaults)
        self.opacity: float = 1.0

        # Animations (renderer-agnostic data)
        self._animations: list = []
        self._chain_delay: float = 0.0
        self._chain_id: int | None = None
        self._chain_next_seq: int = 0

    @property
    def z_index(self) -> int:
        """Layer ordering (higher values render on top)."""
        return self._z_index

    @z_index.setter
    def z_index(self, value: int) -> None:
        self._z_index = value

    @property
    def rotation(self) -> float:
        """Rotation angle in degrees (accumulated, non-destructive)."""
        return self._rotation

    @rotation.setter
    def rotation(self, value: float) -> None:
        self._rotation = float(value)

    @property
    def scale_factor(self) -> float:
        """Scale factor (accumulated, non-destructive). Default 1.0."""
        return self._scale_factor

    @scale_factor.setter
    def scale_factor(self, value: float) -> None:
        self._scale_factor = float(value)

    @property
    def rotation_center(self) -> Coord:
        """The center point for rotation and scaling transforms.

        Default: entity position. Override for entities where the
        natural pivot differs (e.g., Rect center, Polygon centroid).
        """
        return self.position

    def ref_frame(self) -> tuple[float, float, float, float]:
        """Return (x, y, width, height) of this entity's bounding box.

        Provides a unified interface for both Entity and Surface,
        eliminating the need for isinstance checks when resolving
        relative coordinates.
        """
        min_x, min_y, max_x, max_y = self.bounds()
        return (min_x, min_y, max_x - min_x, max_y - min_y)

    # --- Position resolution ---

    def _resolve_relative(self, rx: float, ry: float) -> Coord | None:
        """Resolve (rx, ry) fractions against this entity's reference frame.

        Returns None if no reference frame is available.
        """
        ref = self._reference or self._surface
        if ref is None:
            return None
        if self._resolving:
            raise ValueError("Circular position reference detected")
        self._resolving = True
        try:
            ref_x, ref_y, ref_w, ref_h = ref.ref_frame()
            return Coord(ref_x + rx * ref_w, ref_y + ry * ref_h)
        finally:
            self._resolving = False

    def _resolve_size(self, fraction: float, dimension: str = "min") -> float | None:
        """Resolve a size fraction against this entity's reference frame.

        Args:
            fraction: Size as fraction of reference dimension.
            dimension: "width", "height", or "min" (min of both).

        Returns None if no reference frame is available.
        """
        ref = self._reference or self._surface
        if ref is None:
            return None
        _, _, ref_w, ref_h = ref.ref_frame()
        if dimension == "width":
            return fraction * ref_w
        if dimension == "height":
            return fraction * ref_h
        # "min"
        return fraction * min(ref_w, ref_h)

    def _resolve_position(self) -> Coord:
        """Resolve position from the most specific mode (along > relative > pixel)."""
        if self._along_path is not None:
            pos = self._along_path.point_at(self._along_t)
            if self._along_offset is not None and self._surface is not None:
                angle = get_angle_at(self._along_path, self._along_t)
                # Isotropic scaling (like radius) — consistent shift regardless of path angle.
                px = self._along_offset * min(self._surface._width, self._surface._height)
                pos = perpendicular_shift(pos, angle, px)
            return pos
        if self._relative_at is not None:
            result = self._resolve_relative(*self._relative_at)
            if result is not None:
                return result
        return self._position

    def _has_relative_properties(self) -> bool:
        """Return True if this entity has any relative state.

        Subclasses override to include entity-specific relative properties
        (e.g. relative_radius, relative_width, relative_vertices).
        """
        return self._relative_at is not None or self._along_path is not None

    @property
    def is_relative(self) -> bool:
        """True when any property is relative (entity reacts to container changes).

        Check individual properties (``.at``, ``.relative_radius``, etc.)
        to see which specific properties are tracked.
        """
        return self._has_relative_properties()

    def _resolve_to_absolute(self) -> None:
        """Convert all relative properties to absolute values.

        The base class resolves **position** (relative binding → concrete
        ``Coord``).  Subclasses extend this to also resolve **sizing**
        (e.g. radius, width/height) and **geometry** (e.g. vertices,
        endpoints) from relative fractions to absolute values.

        This is an explicit opt-in escape hatch.  The framework never
        calls it automatically — only user code that needs pixel values.
        """
        if self._relative_at is not None or self._along_path is not None:
            self._position = self._resolve_position()
            self._relative_at = None
            self._along_path = None
            self._along_offset = None

    @property
    def position(self) -> Coord:
        """Current position of the entity (computed from relative coords if set)."""
        return self._resolve_position()

    @position.setter
    def position(self, value: CoordLike) -> None:
        """Set position in pixels (clears relative bindings)."""
        value = Coord.coerce(value)
        self._position = value
        self._relative_at = None
        self._along_path = None
        self._along_offset = None

    @property
    def x(self) -> float:
        """X coordinate of position."""
        return self._resolve_position().x

    @property
    def y(self) -> float:
        """Y coordinate of position."""
        return self._resolve_position().y

    @property
    def at(self) -> RelCoord | None:
        """Relative position as (rx, ry) fractions, or None if absolute mode."""
        return self._relative_at

    @at.setter
    def at(self, value: RelCoordLike | None) -> None:  # RelCoord | tuple[float, float]
        """Set relative position (clears along binding)."""
        if value is not None:
            value = RelCoord.coerce(value)
        self._relative_at = value
        if value is not None:
            self._along_path = None
            self._along_offset = None

    @property
    def surface(self) -> Surface | None:
        """The surface (Cell, Scene, or CellGroup) containing this entity, if any."""
        return self._surface

    @surface.setter
    def surface(self, value: Surface | None) -> None:
        """Set the containing surface."""
        self._surface = value

    def surface_position_at(self, rx: float, ry: float) -> tuple[float, float]:
        """Convert surface-relative (rx, ry) to absolute pixel coordinates.

        Uses this entity's containing surface as the reference frame.
        Returns *(rx, ry)* unchanged if the entity has no surface.
        """
        if self._surface is None:
            return (rx, ry)
        return (
            self._surface._x + rx * self._surface._width,
            self._surface._y + ry * self._surface._height,
        )

    @property
    def connections(self) -> Collection[Connection]:
        """Connections involving this entity (insertion-ordered)."""
        return self._connections.keys()

    @property
    def data(self) -> dict[str, Any]:
        """Custom data dictionary for this entity."""
        return self._data

    def add_connection(self, connection: Connection) -> None:
        """Register a connection with this entity."""
        self._connections[connection] = None

    def remove_connection(self, connection: Connection) -> None:
        """Remove a connection from this entity."""
        self._connections.pop(connection, None)

    # --- Binding ---

    @property
    def binding(self) -> Binding | None:
        """Current positioning binding, or None if absolutely positioned.

        Returns a ``Binding`` describing how this entity is positioned:
        relative (``at``), along a path (``along`` + ``t``), or ``None``
        for absolute mode.
        """
        if self._along_path is not None:
            return Binding(
                along=self._along_path,
                t=self._along_t,
                along_offset=self._along_offset,
                reference=self._reference,
            )
        if self._relative_at is not None:
            return Binding(at=self._relative_at, reference=self._reference)
        return None

    @binding.setter
    def binding(self, value: Binding | None) -> None:
        """Set positioning binding (clears previous mode)."""
        if value is None:
            self._relative_at = None
            self._along_path = None
            self._along_t = 0.5
            self._along_offset = None
            self._reference = None
            return
        if value.along is not None:
            self._along_path = value.along
            self._along_t = value.t
            self._along_offset = value.along_offset
            self._relative_at = None
        elif value.at is not None:
            self._relative_at = value.at
            self._along_path = None
            self._along_t = 0.5
            self._along_offset = None
        if value.reference is not None:
            self._reference = value.reference

    # --- Movement methods ---

    def _move_to(self, x: float | Coord, y: float | None = None) -> Entity:
        """
        Move entity to absolute pixel position (clears relative bindings).

        Args:
            x: X coordinate, or a Coord.
            y: Y coordinate (required if x is not a Coord).

        Returns:
            self, for method chaining.
        """
        if isinstance(x, Coord):
            self._position = x
        elif y is not None:
            self._position = Coord(x, y)
        else:
            raise ValueError("Must provide both x and y, or a Coord")
        self._relative_at = None
        self._along_path = None
        self._along_offset = None
        return self

    def _move_by(self, dx: float = 0, dy: float = 0) -> Entity:
        """
        Move entity by a pixel offset.

        In relative mode, converts the pixel offset to a fraction adjustment.
        In along mode, resolves to pixels first, then applies offset.

        Args:
            dx: Horizontal offset in pixels.
            dy: Vertical offset in pixels.

        Returns:
            self, for method chaining.
        """
        if self._along_path is not None:
            # Resolve to absolute position, apply offset, switch to absolute mode
            current = self._resolve_position()
            self._position = Coord(current.x + dx, current.y + dy)
            self._along_path = None
            self._along_offset = None
            self._relative_at = None
            return self
        if self._relative_at is not None:
            ref = self._reference or self._surface
            if ref is not None:
                # Convert pixel offset to fraction offset
                _, _, ref_w, ref_h = ref.ref_frame()
                drx = dx / ref_w if ref_w > 0 else 0
                dry = dy / ref_h if ref_h > 0 else 0
                rx, ry = self._relative_at
                self._relative_at = RelCoord(rx + drx, ry + dry)
                return self
        self._position = Coord(self._position.x + dx, self._position.y + dy)
        return self

    def move_to_surface(self, surface: Surface, at: RelCoordLike = "center") -> Entity:
        """
        Move entity to a position within a surface (stores relative coords).

        Args:
            surface: The target surface (Cell, Scene, or CellGroup).
            at: RelCoordLike within surface - either a RelCoord / (rx, ry) tuple
                where (0,0) is top-left and (1,1) is bottom-right,
                or a named position like "center", "top_left", etc.

        Returns:
            self, for method chaining.
        """

        self._surface = surface
        if isinstance(at, str) and at in NAMED_POSITIONS:
            at = NAMED_POSITIONS[at]
        else:
            raise TypeError(
                f'Cannot use "{at}" for move_to_surface. String must be in NAMED_POSITIONS (e.g. "center")'
            )
        at = RelCoord.coerce(at)
        self._relative_at = at
        self._along_path = None
        self._along_offset = None
        self._reference = None
        return self

    # --- Connection methods ---

    def connect(
        self,
        other: Entity | Surface,
        start_anchor: AnchorSpec = "center",
        end_anchor: AnchorSpec = "center",
        *,
        path: Path | None = None,
        curvature: float | None = None,
        visible: bool = True,
        width: float = 1,
        color: ColorLike = "black",
        z_index: int = 0,
        cap: CapName = "round",
        start_cap: CapName | None = None,
        end_cap: CapName | None = None,
        opacity: float = 1.0,
        color_brightness: float | None = None,
        style: PathStyle | None = None,
        segments: int = 32,
    ) -> Connection:
        """
        Create a connection to another entity or surface.

        Args:
            other: The entity or surface to connect to.
            start_anchor: Anchor spec on this entity (name, RelCoord, or tuple).
            end_anchor: Anchor spec on the other object.
            path:   Custom path geometry (e.g. Path.Wave()). For simple arcs
                    use ``curvature`` instead.
            curvature:  Arc curvature (-1 to 1). Positive bows left,
                        negative bows right. Cannot be used with ``path``.
            visible: Whether the connection renders. Default True.
            width: Line width in pixels.
            color: Line color.
            z_index: Layer order (higher = on top).
            cap: Cap style for both ends.
            start_cap: Override cap for start end (e.g. "arrow").
            end_cap: Override cap for end end (e.g. "arrow").
            opacity: Opacity (0.0 transparent to 1.0 opaque).
            color_brightness: Brightness multiplier 0.0 (black) to 1.0 (unchanged).
            style: PathStyle object (overrides individual params).
            segments: Number of Bézier segments for path rendering.

        Returns:
            The created Connection.
        """

        return Connection(
            start=self,
            end=other,
            start_anchor=start_anchor,
            end_anchor=end_anchor,
            path=path,
            curvature=curvature,
            visible=visible,
            width=width,
            color=color,
            z_index=z_index,
            cap=cap,
            start_cap=start_cap,
            end_cap=end_cap,
            opacity=opacity,
            color_brightness=color_brightness,
            style=style,
            segments=segments,
        )

    # --- Distance methods ---

    def distance_to(self, other: Entity | Surface | Coord | tuple[float, float]) -> float:
        """
        Euclidean pixel distance from this entity's position to another.

        Accepts: Entity (uses position), Cell/Surface (uses center),
        Coord, or (x, y) tuple.

        Args:
            other: An Entity, Surface/Cell, Coord, or (x, y) tuple.

        Returns:
            Distance in pixels.
        """
        if isinstance(other, Entity):
            target = other.position
        elif isinstance(other, Coord):
            target = other
        elif isinstance(other, tuple):
            target = Coord(*other)
        elif hasattr(other, "center"):
            target = Coord.coerce(other.center)
        else:
            raise TypeError(
                f"Expected Entity, Surface, Coord, or tuple, got {type(other).__name__}"
            )
        return self.position.distance_to(target)

    # --- Transform methods ---

    def _to_world_space(self, model_point: Coord) -> Coord:
        """Transform a model-space point to world space.

        Applies scale then rotation around ``rotation_center``.
        Identity transforms are short-circuited.
        """
        if self._rotation == 0 and self._scale_factor == 1.0:
            return model_point
        center = self.rotation_center
        delta = model_point - center
        if self._scale_factor != 1.0:
            delta = Coord(delta.x * self._scale_factor, delta.y * self._scale_factor)
        if self._rotation != 0:
            delta = delta.rotated(math.radians(self._rotation))
        return center + delta

    def _build_svg_transform(self) -> str:
        """Build SVG ``transform`` attribute string for current rotation/scale.

        Returns an empty string when both are identity, otherwise a
        fully-formed ``' transform="..."'`` attribute (leading space included).
        """
        has_rot = self._rotation != 0
        has_scale = self._scale_factor != 1.0
        if not has_rot and not has_scale:
            return ""
        center = self.rotation_center
        cx, cy = svg_num(center.x), svg_num(center.y)
        ncx, ncy = svg_num(-center.x), svg_num(-center.y)
        if has_rot and not has_scale:
            return f' transform="rotate({svg_num(self._rotation)} {cx} {cy})"'
        if has_scale and not has_rot:
            s = svg_num(self._scale_factor)
            return f' transform="translate({cx},{cy}) scale({s}) translate({ncx},{ncy})"'
        # Both rotation and scale
        s = svg_num(self._scale_factor)
        return (
            f' transform="translate({cx},{cy})'
            f" rotate({svg_num(self._rotation)})"
            f" scale({s})"
            f' translate({ncx},{ncy})"'
        )

    def _orbit_around(self, angle: float, origin: Coord) -> None:
        """Orbit this entity's rotation_center around *origin* by *angle* degrees.

        Computes the new orbital location lazily from ``rotation_center``
        (which resolves relative coordinates on access), then shifts
        position via ``_move_by`` (which preserves relative mode).
        Does **not** accumulate ``_rotation``.
        """
        center = self.rotation_center
        new_center = center.rotated(math.radians(angle), origin=origin)
        self._move_by(new_center.x - center.x, new_center.y - center.y)

    def _scale_around(self, factor: float, origin: Coord) -> None:
        """Scale this entity's position relative to *origin* (orbit only).

        Computes the new position lazily from ``rotation_center``
        (which resolves relative coordinates on access), then shifts
        position via ``_move_by`` (which preserves relative mode).
        Does **not** accumulate ``_scale_factor``.
        """
        center = self.rotation_center
        new_center = origin + (center - origin) * factor
        self._move_by(new_center.x - center.x, new_center.y - center.y)

    def rotate(self, angle: float, origin: CoordLike | None = None) -> Entity:
        """
        Rotate entity by *angle* degrees.

        Without *origin*, rotates in place. With *origin*, also moves
        the entity around that point (like orbiting).

        Args:
            angle: Rotation in degrees (counterclockwise).
            origin: Point to rotate around. If ``None``, rotates in
                    place around the entity's natural center.

        Returns:
            self, for method chaining.
        """
        if origin is not None:
            self._orbit_around(angle, Coord.coerce(origin))
        self._rotation = (self._rotation + angle) % 360
        return self

    def scale(self, factor: float, origin: CoordLike | None = None) -> Entity:
        """
        Scale entity by *factor*.

        Without *origin*, scales around the entity's natural center.
        With *origin*, also moves the entity toward/away from that point.

        Args:
            factor: Scale multiplier (1.0 = no change, 2.0 = double).
            origin: Point to scale around. If ``None``, scales around
                    the entity's natural center.

        Returns:
            self, for method chaining.
        """
        if origin is not None:
            self._scale_around(factor, Coord.coerce(origin))
        self._scale_factor *= factor
        return self

    def offset_from(self, anchor_spec: AnchorSpec, dx: float = 0, dy: float = 0) -> Coord:
        """
        Get a point offset from an anchor.

        Sugar for ``entity.anchor(spec) + Coord(dx, dy)``.

        Args:
            anchor_spec: Anchor name, RelCoord, or (rx, ry) tuple.
            dx: Horizontal offset in pixels.
            dy: Vertical offset in pixels.

        Returns:
            The offset point.
        """
        return self.anchor(anchor_spec) + Coord(dx, dy)

    def place_beside(
        self,
        other: Entity,
        side: Literal["right", "left", "above", "below"] = "right",
        gap: float = 0,
    ) -> Entity:
        """
        Position this entity beside another within the same surface.

        Both entities must be in the same surface (cell, merged region,
        or scene).  The ``gap`` is a fraction of the surface dimension
        (e.g. ``gap=0.05`` is 5% of the surface width/height).

        Args:
            other: Reference entity (must share the same surface).
            side: "right", "left", "above", or "below".
            gap: Gap as a fraction of the surface dimension.

        Returns:
            self, for method chaining.

        Raises:
            ValueError: If entities are not in the same surface.
        """
        if self._surface is None or other._surface is None:
            raise ValueError("Both entities must be in a surface for place_beside")
        if self._surface is not other._surface:
            raise ValueError(
                "Entities must be in the same surface. "
                "Use grid.merge() to create a larger shared surface."
            )
        ob = other.relative_bounds()
        sb = self.relative_bounds()
        s_rw = sb[2] - sb[0]
        s_rh = sb[3] - sb[1]
        o_cx = (ob[0] + ob[2]) / 2
        o_cy = (ob[1] + ob[3]) / 2

        if side == "right":
            new_rx, new_ry = ob[2] + gap + s_rw / 2, o_cy
        elif side == "left":
            new_rx, new_ry = ob[0] - gap - s_rw / 2, o_cy
        elif side == "above":
            new_rx, new_ry = o_cx, ob[1] - gap - s_rh / 2
        elif side == "below":
            new_rx, new_ry = o_cx, ob[3] + gap + s_rh / 2
        else:
            raise ValueError(f"Invalid side '{side}'. Use 'right', 'left', 'above', 'below'.")

        self.binding = None
        self.at = RelCoord(new_rx, new_ry)
        return self

    # --- Abstract methods for subclasses ---

    @property
    @abstractmethod
    def anchor_names(self) -> list[str]:
        """List of available anchor names for this entity."""

    def anchor(self, spec: AnchorSpec = "center") -> Coord:
        """
        Get anchor point by name or relative coordinate.

        Args:
            spec: Anchor specification. Can be:
                - A string name (e.g., "center", "start", "top_left", "v0")
                - A RelCoord or (rx, ry) tuple (0.0-1.0 fractions of bounding box)

        Returns:
            The anchor position as a Coord.

        Raises:
            ValueError: If string name is not valid for this entity.
        """
        if isinstance(spec, str):
            return self._named_anchor(spec)
        rc = RelCoord.coerce(spec)
        return self._anchor_from_relcoord(rc)

    @abstractmethod
    def _named_anchor(self, name: str) -> Coord:
        """Entity-specific named anchor resolution.

        Each entity type defines its own named anchors here.
        Called by ``anchor()`` when given a string.
        """

    def _anchor_from_relcoord(self, rc: RelCoord) -> Coord:
        """Resolve a RelCoord against this entity's axis-aligned bounding box.

        Maps (0,0) to the min corner and (1,1) to the max corner of ``bounds()``.
        Override for rotation-aware entities (e.g., Rect).
        """
        min_x, min_y, max_x, max_y = self.bounds()
        return Coord(
            min_x + rc.rx * (max_x - min_x),
            min_y + rc.ry * (max_y - min_y),
        )

    def relative_anchor(self, spec: AnchorSpec = "center") -> RelCoord:
        """Anchor position as a fraction of the containing surface.

        Like ``anchor()`` but returns a ``RelCoord`` instead of pixels,
        expressing the position as fractions (0.0–1.0) of the surface.

        Args:
            spec: Anchor name (e.g. "center", "top_left") or RelCoord.

        Returns:
            RelCoord within the containing surface.

        Raises:
            ValueError: If the entity is not in a surface.
        """
        if self._surface is None:
            raise ValueError("Entity must be in a surface for relative_anchor")
        return self._surface._absolute_to_relative(self.anchor(spec))

    def relative_bounds(self) -> tuple[float, float, float, float]:
        """Bounds as fractions of the containing surface.

        Returns the bounding box expressed as ``(min_rx, min_ry, max_rx,
        max_ry)`` where each value is a fraction (0.0–1.0) of the
        surface's width or height.

        Returns:
            Tuple of (min_rx, min_ry, max_rx, max_ry).

        Raises:
            ValueError: If the entity is not in a surface.
        """
        if self._surface is None:
            raise ValueError("Entity must be in a surface for relative_bounds")
        min_x, min_y, max_x, max_y = self.bounds()
        tl = self._surface._absolute_to_relative(Coord(min_x, min_y))
        br = self._surface._absolute_to_relative(Coord(max_x, max_y))
        return (tl.rx, tl.ry, br.rx, br.ry)

    @abstractmethod
    def to_svg(self) -> str:
        """Return the SVG element string for this entity (e.g. ``<rect .../>``).

        Subclasses must render all visual properties and any SMIL animations
        attached to this entity.
        """

    @abstractmethod
    def bounds(self, *, visual: bool = False) -> tuple[float, float, float, float]:
        """
        Get bounding box of this entity.

        Args:
            visual: If True, include stroke width in the bounds so the
                    result reflects the rendered (visual) extent rather
                    than pure geometry. Default is False (geometric bounds).

        Returns:
            Tuple of (min_x, min_y, max_x, max_y).
        """

    # =========================================================================
    # ANIMATION API
    # =========================================================================

    @property
    def animations(self) -> list:
        """Current animations on this entity (copy)."""
        return list(self._animations)

    def then(self, gap: float = 0) -> Entity:
        """Chain: start the next animation after all current ones finish.

        Computes when all current animations end, and offsets the next
        animation call by that amount (plus an optional gap).

        Args:
            gap: Optional pause in seconds between the end of current
                animations and the start of the next one.

        Returns:
            Self, for method chaining.

        Example::

            dot.animate_fade(to=0.0, duration=1.0).then().animate_spin(360, duration=1.0)
            dot.animate_fade(to=0.0, duration=1.0).then(0.5).animate_spin(360, duration=1.0)
            # Bounce the whole chain with .loop():
            dot.animate_fade(to=0.3, duration=1.5).then().animate_spin(360, duration=2.0)
            dot.loop(bounce=True)
        """
        apply_chain(self, gap)
        return self

    # animate_fade assigned from factory (supports both to= and keyframes= modes)
    animate_fade = typed_methods.animate_fade

    def animate_move(
        self,
        to: RelCoordLike | None = None,
        *,
        by: RelCoordLike | None = None,
        duration: float = 1.0,
        delay: float = 0.0,
        easing: EasingLike = "ease-in-out",
        hold: bool = True,
        repeat: RepeatLike = False,
        bounce: bool = False,
    ) -> Entity:
        """Animate position using relative coordinates.

        Two modes:

        - **Absolute**: ``entity.animate_move(to=(0.8, 0.5))``
        - **Relative**: ``entity.animate_move(by=(0.1, 0))``

        Args:
            to: Target position as RelCoord or (rx, ry) tuple.
                Mutually exclusive with *by*.
            by: Movement offset as RelCoord or (drx, dry) tuple.
                Mutually exclusive with *to*.
            duration: Duration in seconds.
            delay: Seconds before animation starts.
            easing: Speed curve (default "ease-in-out" for natural motion).
            hold: Hold final value after completion.
            repeat: ``False`` = play once (default), ``True`` = loop forever,
                ``int >= 2`` = loop N times.
            bounce: If ``True``, alternate direction each cycle.

        Returns:
            Self, for method chaining.

        Raises:
            ValueError: If both *to* and *by* are given, or neither.
        """
        delay = consume_chain_delay(self, delay)
        new_anims = build_move(
            self,
            to,
            by=by,
            duration=duration,
            delay=delay,
            easing=easing,
            hold=hold,
            repeat=repeat,
            bounce=bounce,
        )
        for anim in new_anims:
            _tag_with_chain(self, anim)
        self._animations.extend(new_anims)
        return self

    def animate_spin(
        self,
        angle: float = 360,
        *,
        duration: float = 1.0,
        delay: float = 0.0,
        easing: EasingLike = "linear",
        hold: bool = True,
        repeat: RepeatLike = False,
        bounce: bool = False,
        pivot: RelCoordLike | None = None,
    ) -> Entity:
        """Animate rotation.

        Args:
            angle: Total rotation in degrees (default 360 = full turn).
            duration: Duration in seconds.
            delay: Seconds before animation starts.
            easing: Speed curve (default "linear" for constant rotation).
            hold: Hold final value after completion.
            repeat: ``False`` = play once (default), ``True`` = loop forever,
                ``int >= 2`` = loop N times.
            bounce: If ``True``, alternate direction each cycle.
            pivot: Custom rotation center as ``(rx, ry)`` surface-relative fractions.
                Defaults to the entity's natural ``rotation_center``.
                Useful for orbits (``pivot=sun_pos``) or spinning from an end point.
                Does **not** follow the entity if it is also being moved.

        Returns:
            Self, for method chaining.
        """
        delay = consume_chain_delay(self, delay)
        anim = build_spin(
            self,
            angle,
            duration=duration,
            delay=delay,
            easing=easing,
            hold=hold,
            repeat=repeat,
            bounce=bounce,
            pivot=pivot,
        )
        _tag_with_chain(self, anim)
        self._animations.append(anim)
        return self

    def animate_scale(
        self,
        to: float,
        *,
        duration: float = 1.0,
        delay: float = 0.0,
        easing: EasingLike = "ease-in-out",
        hold: bool = True,
        repeat: RepeatLike = False,
        bounce: bool = False,
        pivot: RelCoordLike | None = None,
    ) -> Entity:
        """Animate scale factor.

        Args:
            to: Target scale factor (1.0 = original, 2.0 = double, 0.5 = half).
            duration: Duration in seconds.
            delay: Seconds before animation starts.
            easing: Speed curve (default "ease-in-out").
            hold: Hold final value after completion.
            repeat: ``False`` = play once (default), ``True`` = loop forever,
                ``int >= 2`` = loop N times.
            bounce: If ``True``, alternate direction each cycle.
            pivot: Custom scale origin as ``(rx, ry)`` surface-relative fractions.
                Defaults to the entity's natural ``rotation_center``.
                Does **not** follow the entity if it is also being moved.

        Returns:
            Self, for method chaining.
        """
        delay = consume_chain_delay(self, delay)
        anim = build_scale(
            self,
            to,
            duration=duration,
            delay=delay,
            easing=easing,
            hold=hold,
            repeat=repeat,
            bounce=bounce,
            pivot=pivot,
        )
        _tag_with_chain(self, anim)
        self._animations.append(anim)
        return self

    def animate_follow(
        self,
        path: FullPathable,
        *,
        duration: float = 1.0,
        delay: float = 0.0,
        easing: EasingLike = "linear",
        hold: bool = True,
        rotate: bool | float = False,
        repeat: RepeatLike = False,
        bounce: bool = False,
    ) -> Entity:
        """Animate along a path.

        Args:
            path: A Pathable object (Wave, Spiral, etc.) to follow.
            duration: Duration in seconds.
            delay: Seconds before animation starts.
            easing: Speed curve.
            hold: Hold final value after completion.
            rotate: True for auto-rotation along tangent,
                    float for fixed angle.
            repeat: ``False`` = play once (default), ``True`` = loop forever,
                ``int >= 2`` = loop N times.
            bounce: If ``True``, alternate direction each cycle.

        Returns:
            Self, for method chaining.
        """
        delay = consume_chain_delay(self, delay)
        anim = build_follow(
            path,
            duration=duration,
            delay=delay,
            easing=easing,
            hold=hold,
            rotate=rotate,
            repeat=repeat,
            bounce=bounce,
        )
        _tag_with_chain(self, anim)
        self._animations.append(anim)
        return self

    def animate(
        self,
        prop: str,
        *,
        to: Any | None = None,
        keyframes: dict[float, Any] | list[Any] | None = None,
        duration: float = 1.0,
        delay: float = 0.0,
        easing: EasingLike = "linear",
        hold: bool = True,
        repeat: RepeatLike = False,
        bounce: bool = False,
    ) -> Entity:
        """Animate any property.

        Two modes:

        - **Simple**: ``entity.animate("opacity", to=0.0, duration=2.0)``
        - **Keyframes (dict)**: ``entity.animate("opacity", keyframes={0: 1, 1: 0.3, 2: 1})``
        - **Keyframes (list)**: ``entity.animate("fill", keyframes=["red", "blue", "red"], duration=2.0)``

        Args:
            prop: Property name (e.g., "opacity", "r", "fill", "width").
            to: Target value (simple mode).
            keyframes: Dict of {time_seconds: value}, or list of values
                (evenly spaced over *duration*).
            duration: Duration for simple mode, or total duration
                when keyframes is a list.
            delay: Seconds before animation starts.
            easing: Speed curve.
            hold: Hold final value after completion.
            repeat: ``False`` = play once (default), ``True`` = loop forever,
                ``int >= 2`` = loop N times.
            bounce: If ``True``, alternate direction each cycle.

        Returns:
            Self, for method chaining.

        Raises:
            ValueError: If neither ``to`` nor ``keyframes`` is provided.
        """
        add_generic_animate(
            self,
            prop,
            to=to,
            keyframes=keyframes,
            duration=duration,
            delay=delay,
            easing=easing,
            hold=hold,
            repeat=repeat,
            bounce=bounce,
        )
        return self

    def loop(self, *, bounce: bool = False, times: RepeatLike = True) -> None:
        """Loop all animations on this entity.

        Terminal method — returns ``None``. Call this as the last step
        after building your animation chain, *not* in the middle.

        Stamps ``bounce`` and ``repeat`` onto **every** animation currently
        on the entity. Its primary use case is finishing a ``.then()`` chain
        so the whole sequence repeats as one unit::

            # Natural fit — the chain [move → fade] loops together:
            dot.animate_move(...).then().animate_fade(...).loop(bounce=True)

        For a single animation, prefer passing ``repeat=`` and ``bounce=``
        directly to the ``animate_*`` call — it is self-contained and
        impossible to accidentally apply to the wrong animation::

            # Preferred:
            dot.animate_fade(to=0.0, duration=1.0, repeat=True, bounce=True)

            # Works but unnecessary indirection:
            dot.animate_fade(to=0.0, duration=1.0).loop(bounce=True)

        .. warning::
            If an entity has **multiple animations with different desired
            settings**, use per-animation ``repeat=``/``bounce=`` rather
            than ``.loop()``. Because ``.loop()`` stamps every animation
            on the entity, it can silently apply ``bounce`` to an animation
            you didn't intend — for example, reversing an ``animate_follow``
            while only meaning to bounce an ``animate_radius``.

        Args:
            bounce: If ``True``, alternate direction each cycle
                (forward → backward → forward ...). Default ``False``.
            times: ``True`` = loop forever (default); ``int >= 2`` = loop
                exactly N times. Values of ``False``, ``0``, or ``1``
                raise ``ValueError`` because they don't actually loop.

        Raises:
            ValueError: If *times* does not represent a real loop
                (``False``, ``0``, ``1``, or any negative integer).
            ValueError: If no animations have been added yet.

        Note:
            For bounced finite loops (``bounce=True, times=N``), the
            value held after completion depends on parity: *odd N* freezes
            at the **end** value, *even N* freezes at the **start** value
            (the last bounce cycle reverses back). Only relevant when
            ``hold=True`` (the ``animate_*`` default).
        """
        apply_loop(self, bounce=bounce, times=times)

    def clear_animations(self) -> Entity:
        """Remove all animations from this entity.

        Returns:
            Self, for method chaining.
        """
        clear_all_animations(self)
        return self

    # =========================================================================
    # SVG DEFS COLLECTION (used by renderers)
    # =========================================================================

    def get_required_markers(self) -> list[tuple[str, str]]:
        """Collect SVG marker definitions needed by this entity.

        Returns:
            List of (marker_id, marker_svg) tuples. Empty by default.
        """
        return []

    def get_required_paths(self) -> list[tuple[str, str]]:
        """Collect SVG path definitions needed by this entity (e.g. textPath).

        Returns:
            List of (path_id, path_svg) tuples. Empty by default.
        """
        return []

    def _iter_paints(self) -> Iterator[Color | Gradient]:
        """Yield all paint objects (``Color`` or ``Gradient``) on this entity.

        Override in subclasses that store fill, stroke, or color values.
        Used by :meth:`get_required_gradients` to collect defs.
        """
        return iter([])

    def get_required_gradients(self) -> list[tuple[str, str]]:
        """Collect SVG gradient definitions needed by this entity.

        Returns:
            List of (gradient_id, gradient_svg) tuples. Empty by default.
        """
        return [
            (p.gradient_id, p.to_svg_def()) for p in self._iter_paints() if isinstance(p, Gradient)
        ]

    def inner_bounds(self) -> tuple[float, float, float, float]:
        """
        Largest axis-aligned rectangle fully inside this entity.

        Override for non-rectangular shapes (e.g. circles, ellipses)
        to return the inscribed rectangle. Default: same as bounds().

        Returns:
            Tuple of (min_x, min_y, max_x, max_y).
        """
        return self.bounds()

    def rotated_bounds(
        self,
        angle: float,
        *,
        visual: bool = False,
    ) -> tuple[float, float, float, float]:
        """Tight AABB of this entity's geometry rotated by *angle* degrees
        around the origin.

        The default rotates the four corners of ``bounds()``.  Subclasses
        override with exact analytical formulas (e.g. Bezier extrema,
        ellipse extents) so that ``EntityGroup`` can compose tight bounds
        without sampling.
        """
        b = self.bounds(visual=visual)
        if angle == 0:
            return b
        rad = math.radians(angle)
        cos_a, sin_a = math.cos(rad), math.sin(rad)
        corners = ((b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3]))
        rx = [x * cos_a - y * sin_a for x, y in corners]
        ry = [x * sin_a + y * cos_a for x, y in corners]
        return (min(rx), min(ry), max(rx), max(ry))

    # --- Rotation algorithms for fitting ---

    @staticmethod
    def _compute_optimal_angle(w: float, h: float, W: float, H: float) -> float:
        """O(1) angle (degrees) that maximizes min(W/bbox_w, H/bbox_h).

        Uses the AABB rotation identity:
            bbox_w(θ) = w·|cos θ| + h·|sin θ|
            bbox_h(θ) = w·|sin θ| + h·|cos θ|

        Only three candidate angles: 0°, 90°, and the balanced angle.
        """
        if w < 1e-9 or h < 1e-9 or W < 1e-9 or H < 1e-9:
            return 0.0

        def scale_at(angle_rad: float) -> float:
            cos_a = abs(math.cos(angle_rad))
            sin_a = abs(math.sin(angle_rad))
            bw = w * cos_a + h * sin_a
            bh = w * sin_a + h * cos_a
            return min(W / bw, H / bh)

        candidates = [0.0, math.pi / 2]

        numerator = H * w - W * h
        denominator = W * w - H * h
        if abs(denominator) > 1e-9:
            theta = math.atan2(numerator, denominator)
            theta = theta % math.pi
            if theta > math.pi / 2:
                theta -= math.pi / 2
            if 0 < theta < math.pi / 2:
                candidates.append(theta)

        best = max(candidates, key=scale_at)
        return math.degrees(best)

    @staticmethod
    def _compute_aspect_match_angle(
        w: float,
        h: float,
        target_w: float,
        target_h: float,
    ) -> float:
        """O(1) angle (degrees) that makes bbox aspect ratio ≈ target_w/target_h.

        Solves: (w·cos θ + h·sin θ) / (w·sin θ + h·cos θ) = r
        giving:  tan θ = (w - r·h) / (r·w - h)
        """
        if w < 1e-9 or h < 1e-9 or target_w < 1e-9 or target_h < 1e-9:
            return 0.0

        r = target_w / target_h
        current_ratio = w / h
        if abs(current_ratio - r) < 1e-6:
            return 0.0  # already matching

        numerator = w - r * h
        denominator = r * w - h

        if abs(denominator) < 1e-9:
            return 90.0

        theta_deg = math.degrees(math.atan(numerator / denominator))

        # If outside [0, 90°], target ratio isn't achievable —
        # return whichever boundary is closer.
        if theta_deg < 0 or theta_deg > 90:
            ratio_90 = h / w
            return 0.0 if abs(current_ratio - r) <= abs(ratio_90 - r) else 90.0

        return theta_deg

    def fit_within(
        self,
        target: Entity | tuple[float, float, float, float],
        scale: float = 1.0,
        recenter: bool = True,
        *,
        at: RelCoordLike | None = None,
        visual: bool = True,
        rotate: bool = False,
        match_aspect: bool = False,
    ) -> Entity:
        """
        Scale and position entity to fit within another entity's inner bounds.

        Args:
            target: Entity (uses inner_bounds()) or raw (min_x, min_y, max_x, max_y) tuple.
            scale: Fraction of target inner bounds to fill (0.0-1.0].
            recenter:   If True, center entity within target after scaling.
                        Ignored when ``at`` is provided.
            at: Optional relative position (rx, ry) within the target's
                inner bounds, where (0,0) is top-left and (1,1) is
                bottom-right. Available space is constrained by the
                nearest edges so the entity never overflows.
            visual: If True (default), include stroke width when measuring
                    bounds so stroked entities don't overflow after fitting.
                    Set to False for pure geometric fitting.
            rotate: If True, find the rotation angle that maximizes how
                    much of the target space the entity fills before
                    scaling.
            match_aspect:   If True, rotate the entity so its bounding box
                            aspect ratio matches the target's. Mutually
                            exclusive with ``rotate``.

        Returns:
            self, for method chaining.

        Example:
            ```python
            dot = cell.add_dot(radius=0.5, color="navy")
            label = cell.add_text("0.5", color="white", font_size=50)
            label.fit_within(dot)
            # Position in top-left of a rect's inner bounds:
            label.fit_within(rect, at=(0.25, 0.25))
            ```
        """
        if rotate and match_aspect:
            raise ValueError("rotate and match_aspect are mutually exclusive")

        if not (0.0 < scale <= 1.0):
            raise ValueError(f"scale must be between 0.0 and 1.0, got {scale}")

        # Resolve target bounds
        if isinstance(target, Entity):
            t_min_x, t_min_y, t_max_x, t_max_y = target.inner_bounds()
        else:
            t_min_x, t_min_y, t_max_x, t_max_y = target

        target_w = t_max_x - t_min_x
        target_h = t_max_y - t_min_y

        if rotate or match_aspect:
            b = self.bounds(visual=visual)
            w, h = b[2] - b[0], b[3] - b[1]
            if w > 1e-9 and h > 1e-9:
                if at is not None:
                    rx, ry = RelCoord.coerce(at)
                    avail_w = min(rx, 1 - rx) * 2 * target_w * scale
                    avail_h = min(ry, 1 - ry) * 2 * target_h * scale
                else:
                    avail_w = target_w * scale
                    avail_h = target_h * scale
                angle = (
                    Entity._compute_optimal_angle(w, h, avail_w, avail_h)
                    if rotate
                    else Entity._compute_aspect_match_angle(w, h, avail_w, avail_h)
                )
                self.rotate(angle)

        if at is not None:
            # --- Position-aware mode ---
            rx, ry = RelCoord.coerce(at)
            if not (0.0 < rx < 1.0 and 0.0 < ry < 1.0):
                raise ValueError(f"at=({rx}, {ry}) must be inside the bounds (0.0-1.0 exclusive).")

            # Target position in absolute coordinates
            target_x = t_min_x + rx * target_w
            target_y = t_min_y + ry * target_h

            # Available space constrained by nearest edge
            available_w = min(target_x - t_min_x, t_max_x - target_x) * 2 * scale
            available_h = min(target_y - t_min_y, t_max_y - target_y) * 2 * scale

            # Get entity's bounding box
            e_min_x, e_min_y, e_max_x, e_max_y = self.bounds(visual=visual)
            entity_w = e_max_x - e_min_x
            entity_h = e_max_y - e_min_y

            # Scale factors
            scale_x = available_w / entity_w if entity_w > 0 else 1.0
            scale_y = available_h / entity_h if entity_h > 0 else 1.0
            factor = min(scale_x, scale_y)

            entity_center = Coord(
                (e_min_x + e_max_x) / 2,
                (e_min_y + e_max_y) / 2,
            )
            if abs(factor - 1.0) > 0.001:
                self.scale(factor, origin=entity_center)

            # Move to target position
            new_bounds = self.bounds(visual=visual)
            new_cx = (new_bounds[0] + new_bounds[2]) / 2
            new_cy = (new_bounds[1] + new_bounds[3]) / 2
            self._move_by(target_x - new_cx, target_y - new_cy)

            return self

        # --- Default mode (at=None) ---
        target_cx = (t_min_x + t_max_x) / 2
        target_cy = (t_min_y + t_max_y) / 2

        # Get entity's bounding box
        e_min_x, e_min_y, e_max_x, e_max_y = self.bounds(visual=visual)
        entity_w = e_max_x - e_min_x
        entity_h = e_max_y - e_min_y

        # Calculate available space
        available_w = target_w * scale
        available_h = target_h * scale

        # Scale factors
        scale_x = available_w / entity_w if entity_w > 0 else 1.0
        scale_y = available_h / entity_h if entity_h > 0 else 1.0
        factor = min(scale_x, scale_y)

        # Scale around entity center if needed
        entity_cx = (e_min_x + e_max_x) / 2
        entity_cy = (e_min_y + e_max_y) / 2

        if abs(factor - 1.0) > 0.001:
            self.scale(factor, origin=Coord(entity_cx, entity_cy))

        # Recenter within target
        if recenter:
            new_bounds = self.bounds(visual=visual)
            new_cx = (new_bounds[0] + new_bounds[2]) / 2
            new_cy = (new_bounds[1] + new_bounds[3]) / 2
            self._move_by(target_cx - new_cx, target_cy - new_cy)

        return self

    def fit_to_surface(
        self,
        scale: float = 1.0,
        recenter: bool = True,
        *,
        at: RelCoordLike | None = None,
        visual: bool = True,
        rotate: bool = False,
        match_aspect: bool = False,
    ) -> Entity:
        """
        Automatically scale and position entity to fit within its surface bounds.

        Convenience wrapper around :meth:`fit_within` that uses the
        containing surface as the target region.

        Args:
            scale:  Fraction of available space to fill (0.0-1.0).
                    1.0 = fill entire surface, 0.85 = use 85%.
            recenter:   If True, center entity in surface after scaling.
                        Ignored when ``at`` is provided.
            at: RelCoordLike within the surface as (rx, ry) fractions. Constrains
                fitting to the space available at that point so the entity
                doesn't overflow. (0.5, 0.5) uses the full surface (default).
            visual: If True (default), include stroke width in bounds
                    measurement so stroked shapes don't overflow.
            rotate: If True, auto-rotate to maximize surface coverage.
            match_aspect:   If True, rotate to match the surface's aspect ratio.
                            Mutually exclusive with ``rotate``.

        Returns:
            self, for method chaining

        Raises:
            ValueError: If entity has no surface, scale is out of range,
                        or both ``rotate`` and ``match_aspect`` are True.
            TypeError:  If ``at`` is a string (named anchors sit on surface
                        edges where available space is 0).

        Example:
            ```python
            ellipse = cell.add_ellipse(rx=2.0, ry=1.2, rotation=45)
            ellipse.fit_to_surface(0.85)  # Auto-constrain to 85% of cell
            dot = cell.add_dot(radius=0.8)
            dot.fit_to_surface(1.0, at=(0.25, 0.25))  # Fit in top-left quadrant
            ```
        """
        if self._surface is None:
            raise ValueError("Cannot fit to surface: entity has no surface")
        surf = self._surface
        return self.fit_within(
            (surf.x, surf.y, surf.x + surf.width, surf.y + surf.height),
            scale,
            recenter,
            at=at,
            visual=visual,
            rotate=rotate,
            match_aspect=match_aspect,
        )

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}(x={self.x}, y={self.y})"
