"""Generic animated sprite driven by data-defined animation states.

The sprite sheet format expects animations laid out in rows:
- Each row contains frames for one animation type and direction
- Frames are ordered left-to-right in the sprite sheet
- Tile size determines the dimensions of each frame
- Left-facing animations can be auto-generated by flipping right-facing frames

Animation states are defined via AnimationStateConfig objects (typically built
from JSON by the content registry).  The sprite runs a priority-based state
machine: the highest-priority *active* state plays.
"""

import logging

import arcade
from PIL import Image
from PIL.Image import Transpose

from pedre.conf import settings
from pedre.sprites.types import AnimationStateConfig

logger = logging.getLogger(__name__)


class AnimatedSprite(arcade.Sprite):
    """Generic animated sprite with a data-driven priority-based state machine.

    States are named (e.g. "idle", "walk", "appear") and can be directional
    (variants _up/_down/_left/_right) or non-directional.  The caller activates
    states via request_state() / release_state(); the sprite always plays the
    highest-priority active state.

    "idle" is the implicit base state — it is always active and is the lowest
    priority (priority 0 by convention).

    Attributes:
        tile_size: Size of each frame in the sprite sheet (pixels).
        current_direction: Current facing direction ("up", "down", "left", "right").
        current_frame: Current frame index in the active animation.
        animation_speed: Time between frames in seconds.
        animation_timer: Accumulator for frame timing.
        animation_textures: Dict mapping texture keys → list[arcade.Texture].
    """

    def __init__(
        self,
        sprite_sheet_path: str,
        *,
        tile_size: int = settings.TILE_SIZE,
        scale: float = 1.0,
        center_x: float = 0.0,
        center_y: float = 0.0,
        states: dict[str, AnimationStateConfig],
    ) -> None:
        """Initialize the animated sprite.

        Args:
            sprite_sheet_path: Path to the sprite sheet PNG file.
            tile_size: Size of each frame in pixels. Default is settings.TILE_SIZE.
            scale: Sprite scale multiplier. Default is 1.0.
            center_x: Initial X position in world coordinates.
            center_y: Initial Y position in world coordinates.
            states: Dict of state name → AnimationStateConfig defining all
                    animation states for this sprite.
        """
        super().__init__(scale=scale)

        self.tile_size = tile_size
        self.center_x = center_x
        self.center_y = center_y

        self._state_configs: dict[str, AnimationStateConfig] = dict(states)
        self._active_states: set[str] = {"idle"}  # idle always active
        self._completion_flags: dict[str, bool] = {name: False for name, cfg in states.items() if not cfg.loop}
        self._current_playing: str | None = None

        self.current_direction = "down"
        self.current_frame = 0
        self.animation_speed = 0.15
        self.animation_timer = 0.0

        self.animation_textures: dict[str, list[arcade.Texture]] = {}
        self._init_texture_keys()
        self._load_textures(sprite_sheet_path)
        self._set_initial_texture()

    @classmethod
    def from_definition(
        cls,
        sprite_def: dict,
        *,
        center_x: float = 0.0,
        center_y: float = 0.0,
        scale: float | None = None,
        tile_size: int | None = None,
    ) -> AnimatedSprite:
        """Create an AnimatedSprite from a sprite registry definition dict.

        Args:
            sprite_def: Sprite definition dict from the content registry.
                Expected keys: ``sprite_sheet``, ``frame_width``, ``states``.
            center_x: Initial X position in world coordinates.
            center_y: Initial Y position in world coordinates.
            scale: Sprite scale. If None, defaults to 1.0.
            tile_size: Frame size override. If None, uses ``frame_width`` from definition.

        Returns:
            Configured AnimatedSprite ready for use.
        """
        states: dict[str, AnimationStateConfig] = {
            name: AnimationStateConfig.from_dict(name, data) for name, data in sprite_def.get("states", {}).items()
        }
        return cls(
            sprite_def["sprite_sheet"],
            tile_size=tile_size if tile_size is not None else int(sprite_def["frame_width"]),
            scale=scale if scale is not None else 1.0,
            center_x=center_x,
            center_y=center_y,
            states=states,
        )

    def request_state(self, name: str) -> None:
        """Activate a named animation state.

        For looping states (walk) the state stays active until release_state()
        is called.  For one-shot states (appear, interact) it is removed from
        active states automatically when the animation completes.

        Args:
            name: State name as defined in the states dict.
        """
        if name not in self._state_configs:
            logger.warning("request_state: unknown state '%s'", name)
            return
        self._active_states.add(name)
        # Reset completion flag so the animation plays fresh
        if name in self._completion_flags:
            self._completion_flags[name] = False
        # If this state has higher priority than what's currently playing,
        # reset frame/timer so it starts from the beginning.
        playing = self._resolve_playing_state()
        if playing != self._current_playing:
            self.current_frame = 0
            self.animation_timer = 0.0

    def release_state(self, name: str) -> None:
        """Deactivate a named animation state (for continuous states like walk).

        Args:
            name: State name to deactivate.
        """
        if name == "idle":
            return  # idle can never be released
        self._active_states.discard(name)

    def is_state_complete(self, name: str) -> bool:
        """Return True if a one-shot state has completed its animation.

        Args:
            name: State name.
        """
        return self._completion_flags.get(name, False)

    def reset_state(self, name: str) -> None:
        """Reset the completion flag for a one-shot state so it can be re-triggered.

        Args:
            name: State name.
        """
        if name in self._completion_flags:
            self._completion_flags[name] = False

    def has_state(self, name: str) -> bool:
        """Return True if this sprite has the named animation state defined.

        Args:
            name: State name.
        """
        return name in self._state_configs

    def mark_state_complete(self, name: str) -> None:
        """Mark a one-shot state as already complete (used when restoring saved state).

        Args:
            name: State name.
        """
        if name in self._completion_flags:
            self._completion_flags[name] = True

    def set_direction(self, direction: str) -> None:
        """Set the current facing direction, resetting the animation frame.

        Args:
            direction: One of "up", "down", "left", "right".
        """
        if direction in ("up", "down", "left", "right") and direction != self.current_direction:
            logger.debug("Direction changed from %s to %s", self.current_direction, direction)
            self.current_direction = direction
            self.current_frame = 0
            self.animation_timer = 0.0

    def update_animation(self, delta_time: float = 1 / 60, *args: object, **kwargs: object) -> None:
        """Advance the animation state machine by one frame.

        Args:
            delta_time: Seconds since last call.
            *args: Accepted for backward-compat; ignored here.
            **kwargs: Accepted for backward-compat; ignored here.
        """
        playing = self._resolve_playing_state()

        if playing is None:
            return

        cfg = self._state_configs[playing]

        # Detect state switch → reset frame
        if playing != self._current_playing:
            self.current_frame = 0
            self.animation_timer = 0.0
            self._current_playing = playing

        # Resolve the texture key for the current direction
        tex_key = self._texture_key(cfg)
        textures = self.animation_textures.get(tex_key, [])
        if not textures:
            return

        # Clamp frame in case direction changed and new anim is shorter
        if self.current_frame >= len(textures):
            self.current_frame = 0

        # Update displayed texture
        self.texture = textures[self.current_frame]

        # Advance timer
        self.animation_timer += delta_time
        if self.animation_timer < self.animation_speed:
            return

        self.animation_timer = 0.0

        if cfg.loop:
            self.current_frame = (self.current_frame + 1) % len(textures)
        else:
            next_frame = self.current_frame + 1
            if next_frame >= len(textures):
                # One-shot complete
                self._on_state_complete(playing, cfg)
            else:
                self.current_frame = next_frame

    def _resolve_playing_state(self) -> str | None:
        """Return the highest-priority currently active state name."""
        if not self._active_states:
            return None
        return max(
            self._active_states,
            key=lambda n: self._state_configs[n].priority if n in self._state_configs else -1,
        )

    def _texture_key(self, cfg: AnimationStateConfig) -> str:
        """Return the animation_textures key for the given state config."""
        if cfg.directional:
            return f"{cfg.name}_{self.current_direction}"
        return cfg.name

    def _on_state_complete(self, name: str, cfg: AnimationStateConfig) -> None:
        """Handle completion of a one-shot animation state."""
        logger.debug("%s animation complete", name)
        self._completion_flags[name] = True
        self._active_states.discard(name)
        self.current_frame = 0
        self._current_playing = None  # force re-resolve next frame

        if cfg.on_complete == "hide":
            self.visible = False
        # Regardless of on_complete value, fall through to idle next update.

    def _init_texture_keys(self) -> None:
        """Pre-populate animation_textures with empty lists for all state variants."""
        for name, cfg in self._state_configs.items():
            if cfg.auto_from:
                # Textures will be copied from the source state later — still need keys
                if cfg.directional:
                    for direction in ("up", "down", "left", "right"):
                        self.animation_textures.setdefault(f"{name}_{direction}", [])
                else:
                    self.animation_textures.setdefault(name, [])
            elif cfg.directional:
                for direction in ("up", "down", "left", "right"):
                    self.animation_textures.setdefault(f"{name}_{direction}", [])
            else:
                self.animation_textures.setdefault(name, [])

    def _load_textures(self, sprite_sheet_path: str) -> None:
        """Load all animation textures from the sprite sheet."""
        sprite_sheet = Image.open(sprite_sheet_path)
        logger.info(
            "Loading sprite sheet: %dx%d, tile_size=%d, states: %s",
            sprite_sheet.width,
            sprite_sheet.height,
            self.tile_size,
            ", ".join(self._state_configs.keys()),
        )

        # First pass: load all states that have explicit frame data
        for name, cfg in self._state_configs.items():
            if cfg.auto_from:
                continue  # handled in second pass

            if cfg.directional:
                self._load_directional_state(sprite_sheet, name, cfg)
            else:
                self._load_nondirectional_state(sprite_sheet, name, cfg)

        # Second pass: states that auto-generate from another state
        for name, cfg in self._state_configs.items():
            if not cfg.auto_from:
                continue
            source = cfg.auto_from
            if cfg.directional:
                for direction in ("up", "down", "left", "right"):
                    src_key = f"{source}_{direction}"
                    dst_key = f"{name}_{direction}"
                    if self.animation_textures.get(src_key):
                        self.animation_textures[dst_key] = list(reversed(self.animation_textures[src_key]))
            else:
                src_key = source
                dst_key = name
                if self.animation_textures.get(src_key):
                    self.animation_textures[dst_key] = list(reversed(self.animation_textures[src_key]))

    def _load_directional_state(self, sprite_sheet: Image.Image, name: str, cfg: AnimationStateConfig) -> None:
        """Load textures for a directional state, handling left/right auto-flip."""
        if not cfg.directions:
            return

        dirs = cfg.directions

        # Up and Down — no flipping needed
        for direction in ("up", "down"):
            if direction in dirs:
                d = dirs[direction]
                self._load_frames(
                    sprite_sheet,
                    f"sprite_{name}_{direction}",
                    f"{name}_{direction}",
                    d["frames"],
                    d["row"],
                    self.tile_size,
                    self.animation_textures,
                )

        # Left / Right — with auto-flip support
        self._load_paired_animations(
            sprite_sheet,
            f"sprite_{name}",
            name,
            dirs.get("left", {}).get("frames"),
            dirs.get("left", {}).get("row"),
            dirs.get("right", {}).get("frames"),
            dirs.get("right", {}).get("row"),
        )

    def _load_nondirectional_state(self, sprite_sheet: Image.Image, name: str, cfg: AnimationStateConfig) -> None:
        """Load textures for a non-directional (single-sequence) state."""
        if cfg.frames is None or cfg.row is None:
            return

        frame_count = cfg.frames
        row = cfg.row

        if cfg.reverse_load:
            # Load in reverse order (sheet stores visible→invisible; playback is invisible→visible)
            for frame_num in range(frame_count - 1, -1, -1):
                left = frame_num * self.tile_size
                top = row * self.tile_size
                frame_image = sprite_sheet.crop((left, top, left + self.tile_size, top + self.tile_size))
                texture = arcade.Texture(
                    name=f"sprite_{name}_{frame_count - 1 - frame_num}",
                    image=frame_image,
                )
                self.animation_textures[name].append(texture)
        else:
            self._load_frames(
                sprite_sheet,
                f"sprite_{name}",
                name,
                frame_count,
                row,
                self.tile_size,
                self.animation_textures,
            )

    def _load_paired_animations(
        self,
        sprite_sheet: Image.Image,
        texture_prefix: str,
        base_name: str,
        left_frames: int | None,
        left_row: int | None,
        right_frames: int | None,
        right_row: int | None,
    ) -> None:
        """Load a left/right animation pair with auto-flip support.

        Handles three cases:
        1. Both sides defined — loads each independently.
        2. Only left defined — loads left, generates right by flipping.
        3. Only right defined — loads right, generates left by flipping.
        """
        if left_frames is not None and left_row is not None:
            if right_frames is not None and right_row is not None:
                self._load_frames(
                    sprite_sheet,
                    f"{texture_prefix}_left",
                    f"{base_name}_left",
                    left_frames,
                    left_row,
                    self.tile_size,
                    self.animation_textures,
                )
                self._load_frames(
                    sprite_sheet,
                    f"{texture_prefix}_right",
                    f"{base_name}_right",
                    right_frames,
                    right_row,
                    self.tile_size,
                    self.animation_textures,
                )
            else:
                self._load_frames(
                    sprite_sheet,
                    f"{texture_prefix}_left",
                    f"{base_name}_left",
                    left_frames,
                    left_row,
                    self.tile_size,
                    self.animation_textures,
                )
                self._load_frames(
                    sprite_sheet,
                    f"{texture_prefix}_right",
                    f"{base_name}_right",
                    left_frames,
                    left_row,
                    self.tile_size,
                    self.animation_textures,
                    flip=True,
                )
        elif right_frames is not None and right_row is not None:
            self._load_frames(
                sprite_sheet,
                f"{texture_prefix}_right",
                f"{base_name}_right",
                right_frames,
                right_row,
                self.tile_size,
                self.animation_textures,
            )
            self._load_frames(
                sprite_sheet,
                f"{texture_prefix}_left",
                f"{base_name}_left",
                right_frames,
                right_row,
                self.tile_size,
                self.animation_textures,
                flip=True,
            )

    @staticmethod
    def _load_frames(
        sprite_sheet: Image.Image,
        texture_name_prefix: str,
        dict_key: str,
        frame_count: int,
        row_index: int,
        tile_size: int,
        animation_textures: dict[str, list[arcade.Texture]],
        *,
        flip: bool = False,
    ) -> None:
        """Load animation frames from a sprite sheet row."""
        for frame_num in range(frame_count):
            left = frame_num * tile_size
            top = row_index * tile_size
            frame_image = sprite_sheet.crop((left, top, left + tile_size, top + tile_size))
            if flip:
                frame_image = frame_image.transpose(Transpose.FLIP_LEFT_RIGHT)
            texture = arcade.Texture(
                name=f"{texture_name_prefix}_{frame_num}",
                image=frame_image,
            )
            animation_textures[dict_key].append(texture)

    def _set_initial_texture(self) -> None:
        """Set the initial displayed texture after loading."""
        for direction in ("down", "right", "up", "left"):
            key = f"idle_{direction}"
            if self.animation_textures.get(key):
                self.texture = self.animation_textures[key][0]
                self.current_direction = direction
                return
            key = f"walk_{direction}"
            if self.animation_textures.get(key):
                self.texture = self.animation_textures[key][0]
                self.current_direction = direction
                return

        # Try any non-directional state (e.g. appear-only sprite)
        for name, cfg in self._state_configs.items():
            if not cfg.directional and self.animation_textures.get(name):
                self.texture = self.animation_textures[name][0]
                return

        logger.warning("No animation textures loaded")
