"""
gui.py — Modular GUI system for Powernet.

Provides a declarative, spritesheet-aware GUI handler with animated
button states, labels, panels and more.

Usage
-----
::

    gui = GUIHandler()
    gui.button(cx, cy, 380, 64, "PLAY", on_click=start_game, anchor="center")
    gui.label(cx, cy - 40, "Choose an option", font_size=20, anchor="center")

    # Inside the game loop:
    gui.handle_events(events)
    gui.update(dt)
    gui.draw(screen)
"""

from __future__ import annotations

import math
from pathlib import Path
from typing import Callable, Optional

import pygame

_FONTS_DIR = Path(__file__).parent.parent / "assets" / "fonts"


# ---------------------------------------------------------------------------
# Theme
# ---------------------------------------------------------------------------


class UITheme:
    """Central store for colours, font sizes and shared resources."""

    # Colour palette (matches ui_panels.png colour scheme)
    BG          = (12,  21,  32)
    PANEL       = (17,  28,  45)
    PANEL_LIGHT = (22,  38,  60)

    ACCENT      = (232, 137,  12)   # electric orange
    ACCENT_GLOW = (255, 160,  30)
    BLUE        = ( 32, 110, 200)   # electric blue
    BLUE_BRIGHT = ( 60, 150, 240)

    TEXT        = (210, 220, 235)
    TEXT_DIM    = ( 70,  95, 130)
    TEXT_TITLE  = (240, 160,  20)

    BORDER      = ( 40,  65, 105)
    BORDER_HOV  = ( 60, 120, 200)
    BORDER_PRESS= (180, 100,  10)

    # Button state colours
    BTN_NORMAL_BG     = (15,  30,  50)
    BTN_NORMAL_BAR    = (40,  80, 145)
    BTN_HOVER_BG      = (18,  40,  70)
    BTN_HOVER_BAR     = (60, 130, 230)
    BTN_PRESSED_BG    = (22,  18,  10)
    BTN_PRESSED_BAR   = (220, 120,   0)

    _FONT_PATH = _FONTS_DIR / "Ithaca" / "Ithaca.ttf"

    # Colorblind palette overrides (set by apply_colorblind)
    _colorblind: bool = False

    @classmethod
    def apply_colorblind(cls, enabled: bool) -> None:
        """Switch between standard and high-contrast colorblind palette."""
        cls._colorblind = enabled
        if enabled:
            cls.TEXT     = (240, 248, 255)   # near-white for max contrast
            cls.TEXT_DIM = (160, 185, 220)   # much brighter dim text
            cls.BORDER   = ( 80, 120, 180)   # more visible border
        else:
            cls.TEXT     = (210, 220, 235)
            cls.TEXT_DIM = ( 70,  95, 130)
            cls.BORDER   = ( 40,  65, 105)

    def __init__(self) -> None:
        self._cache: dict[int, pygame.font.Font] = {}

    def font(self, size: int) -> pygame.font.Font:
        """Return (cached) Ithaca font at *size* pt."""
        if size not in self._cache:
            try:
                self._cache[size] = pygame.font.Font(str(self._FONT_PATH), size)
            except FileNotFoundError:
                self._cache[size] = pygame.font.SysFont("monospace", size)
        return self._cache[size]


theme = UITheme()


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _lerp(a: float, b: float, t: float) -> float:
    return a + (b - a) * t


def _lerp_color(
    a: tuple[int, int, int],
    b: tuple[int, int, int],
    t: float,
) -> tuple[int, int, int]:
    return (
        int(_lerp(a[0], b[0], t)),
        int(_lerp(a[1], b[1], t)),
        int(_lerp(a[2], b[2], t)),
    )


def _smooth(current: float, target: float, speed: float, dt: float) -> float:
    """Exponential-decay smoothing."""
    return current + (target - current) * min(1.0, speed * dt)


# ---------------------------------------------------------------------------
# Base element
# ---------------------------------------------------------------------------


class UIElement:
    """Base class for all GUI elements."""

    def __init__(self, x: int, y: int) -> None:
        self.x = x
        self.y = y
        self.visible: bool = True
        self.enabled: bool = True

    @property
    def rect(self) -> pygame.Rect:
        raise NotImplementedError

    def handle_event(self, event: pygame.event.Event) -> bool:  # noqa: ARG002
        """Return True if the event was consumed."""
        return False

    def update(self, dt: float) -> None:  # noqa: ARG002
        pass

    def draw(self, surface: pygame.Surface) -> None:  # noqa: ARG002
        pass


# ---------------------------------------------------------------------------
# Button
# ---------------------------------------------------------------------------


class Button(UIElement):
    """
    A rectangular button with smooth hover / press animations.

    Visual anatomy::

        ┌──┬──────────────────────────┐
        │▮▮│  LABEL TEXT              │
        └──┴──────────────────────────┘
          ↑ accent bar (4 px)

    States: normal → hover → pressed, blended smoothly.
    """

    _ANIM_SPEED = 10.0

    def __init__(
        self,
        x: int,
        y: int,
        w: int,
        h: int,
        text: str,
        *,
        font_size: int = 32,
        on_click: Optional[Callable[[], None]] = None,
        anchor: str = "topleft",
        icon: Optional[str] = None,
        icon_surf: Optional[pygame.Surface] = None,
    ) -> None:
        # Resolve anchor before storing x/y
        dummy = pygame.Rect(0, 0, w, h)
        setattr(dummy, anchor, (x, y))
        super().__init__(dummy.x, dummy.y)

        self.w = w
        self.h = h
        self.text = text
        self.font_size = font_size
        self.on_click = on_click
        self.icon = icon  # optional single character prefix
        self.icon_surf = icon_surf  # optional image icon

        self._hover: float = 0.0
        self._press: float = 0.0
        self._is_pressed: bool = False
        self._is_hovered: bool = False

        self._text_surf: Optional[pygame.Surface] = None
        self._icon_char_surf: Optional[pygame.Surface] = None

    @property
    def rect(self) -> pygame.Rect:
        return pygame.Rect(self.x, self.y, self.w, self.h)

    # ---- events ----

    def handle_event(self, event: pygame.event.Event) -> bool:
        if not self.visible or not self.enabled:
            return False
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            if self.rect.collidepoint(event.pos):
                self._is_pressed = True
                return True
        elif event.type == pygame.MOUSEBUTTONUP and event.button == 1:
            was = self._is_pressed
            self._is_pressed = False
            if was and self.rect.collidepoint(event.pos) and self.on_click:
                self.on_click()
                return True
        return False

    # ---- update ----

    def update(self, dt: float) -> None:
        self._is_hovered = self.rect.collidepoint(pygame.mouse.get_pos())
        self._hover = _smooth(self._hover, 1.0 if self._is_hovered else 0.0, self._ANIM_SPEED, dt)
        self._press = _smooth(self._press, 1.0 if self._is_pressed else 0.0, self._ANIM_SPEED, dt)

    # ---- draw ----

    def draw(self, surface: pygame.Surface) -> None:
        if not self.visible:
            return
        r = self.rect
        h = self._hover
        p = self._press

        # Background
        bg = _lerp_color(
            _lerp_color(theme.BTN_NORMAL_BG, theme.BTN_HOVER_BG, h),
            theme.BTN_PRESSED_BG,
            p,
        )
        bar_col = _lerp_color(
            _lerp_color(theme.BTN_NORMAL_BAR, theme.BTN_HOVER_BAR, h),
            theme.BTN_PRESSED_BAR,
            p,
        )
        border_col = _lerp_color(
            _lerp_color(theme.BORDER, theme.BORDER_HOV, h),
            theme.BORDER_PRESS,
            p,
        )

        # Main rect
        pygame.draw.rect(surface, bg, r, border_radius=3)

        # Accent bar (left edge, 5 px wide)
        bar_rect = pygame.Rect(r.x, r.y + 1, 5, r.h - 2)
        pygame.draw.rect(surface, bar_col, bar_rect, border_radius=2)

        # Border
        pygame.draw.rect(surface, border_col, r, width=1, border_radius=3)

        # Hover glow
        if h > 0.01:
            glow = pygame.Surface((r.w + 12, r.h + 12), pygame.SRCALPHA)
            ga = int(40 * h)
            glow_color = (*bar_col, ga)
            pygame.draw.rect(glow, glow_color, glow.get_rect(), border_radius=5)
            surface.blit(glow, (r.x - 6, r.y - 6), special_flags=pygame.BLEND_ALPHA_SDL2)

        # Text + optional icon
        press_offset = int(p * 2)
        text_col = _lerp_color(
            _lerp_color(theme.TEXT_DIM, theme.TEXT, h),
            theme.ACCENT_GLOW,
            p,
        )

        if self._text_surf is None:
            self._text_surf = theme.font(self.font_size).render(self.text, True, theme.TEXT)

        text = theme.font(self.font_size).render(self.text, True, text_col)
        pad_left = 18  # left of text (after accent bar)

        if self.icon and self._icon_char_surf is None:
            self._icon_char_surf = theme.font(self.font_size).render(self.icon, True, bar_col)

        x_cursor = r.x + pad_left
        # Image icon (pygame.Surface) takes priority over text-char icon
        if self.icon_surf is not None:
            isz = min(r.h - 10, self.icon_surf.get_height())
            scaled = pygame.transform.scale(self.icon_surf, (isz, isz)) if isz != self.icon_surf.get_height() else self.icon_surf
            iy = r.y + (r.h - isz) // 2 + press_offset
            surface.blit(scaled, (x_cursor, iy))
            x_cursor += isz + 8
        elif self.icon:
            ic = theme.font(self.font_size).render(self.icon, True, bar_col)
            iy = r.y + (r.h - ic.get_height()) // 2 + press_offset
            surface.blit(ic, (x_cursor, iy))
            x_cursor += ic.get_width() + 10

        ty = r.y + (r.h - text.get_height()) // 2 + press_offset
        surface.blit(text, (x_cursor, ty))

        # Right arrow
        if h > 0.01:
            arrow = theme.font(max(14, self.font_size - 10)).render("›", True, (*bar_col, int(200 * h)))
            ax = r.right - arrow.get_width() - 14
            ay = r.y + (r.h - arrow.get_height()) // 2 + press_offset
            surface.blit(arrow, (ax, ay))


# ---------------------------------------------------------------------------
# SmallButton  (quit / icon button)
# ---------------------------------------------------------------------------


class SmallButton(UIElement):
    """Compact text button with underline hover effect."""

    _ANIM_SPEED = 12.0

    def __init__(
        self,
        x: int,
        y: int,
        text: str,
        *,
        font_size: int = 18,
        on_click: Optional[Callable[[], None]] = None,
        anchor: str = "topleft",
        color: tuple = theme.TEXT_DIM,
        hover_color: tuple = theme.ACCENT,
        icon_surf: Optional[pygame.Surface] = None,
    ) -> None:
        super().__init__(x, y)
        self.text = text
        self.font_size = font_size
        self.on_click = on_click
        self.anchor = anchor
        self.color = color
        self.hover_color = hover_color
        self.icon_surf = icon_surf
        self._hover: float = 0.0
        self._is_pressed: bool = False
        self._resolved = False  # anchor resolved after first draw

    @property
    def rect(self) -> pygame.Rect:
        surf = theme.font(self.font_size).render(self.text, True, self.color)
        r = surf.get_rect()
        setattr(r, self.anchor, (self.x, self.y))
        return pygame.Rect(r.x - 6, r.y - 4, r.w + 12, r.h + 8)

    def handle_event(self, event: pygame.event.Event) -> bool:
        if not self.visible or not self.enabled:
            return False
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            if self.rect.collidepoint(event.pos):
                self._is_pressed = True
                return True
        elif event.type == pygame.MOUSEBUTTONUP and event.button == 1:
            was = self._is_pressed
            self._is_pressed = False
            if was and self.rect.collidepoint(event.pos) and self.on_click:
                self.on_click()
                return True
        return False

    def update(self, dt: float) -> None:
        hovered = self.rect.collidepoint(pygame.mouse.get_pos())
        self._hover = _smooth(self._hover, 1.0 if hovered else 0.0, self._ANIM_SPEED, dt)

    def draw(self, surface: pygame.Surface) -> None:
        if not self.visible:
            return
        col = _lerp_color(self.color, self.hover_color, self._hover)
        text_surf = theme.font(self.font_size).render(self.text, True, col)
        r = self.rect
        # Offset for icon
        icon_w = 0
        if self.icon_surf is not None:
            isz = text_surf.get_height()
            icon_w = isz + 6
        total_w = icon_w + text_surf.get_width()
        start_x = r.x + (r.w - total_w) // 2
        ty = r.y + (r.h - text_surf.get_height()) // 2
        if self.icon_surf is not None:
            isz = text_surf.get_height()
            scaled = pygame.transform.scale(self.icon_surf, (isz, isz))
            surface.blit(scaled, (start_x, ty))
            start_x += isz + 6
        surface.blit(text_surf, (start_x, ty))

        # Underline on hover
        if self._hover > 0.01:
            uy = ty + text_surf.get_height() + 1
            line_surf = pygame.Surface((int(text_surf.get_width() * self._hover), 1), pygame.SRCALPHA)
            line_surf.fill((*col, int(180 * self._hover)))
            surface.blit(line_surf, (start_x, uy))

        # Subtle border on hover
        if self._hover > 0.1:
            border_surf = pygame.Surface((r.w, r.h), pygame.SRCALPHA)
            border_surf.fill((0, 0, 0, 0))
            pygame.draw.rect(border_surf, (*self.hover_color, int(60 * self._hover)), border_surf.get_rect(), width=1)
            surface.blit(border_surf, r.topleft)


# ---------------------------------------------------------------------------
# Label
# ---------------------------------------------------------------------------


class Label(UIElement):
    """Static text label with optional drop shadow."""

    def __init__(
        self,
        x: int,
        y: int,
        text: str,
        *,
        font_size: int = 24,
        color: tuple = theme.TEXT,
        anchor: str = "topleft",
        shadow: bool = False,
        shadow_offset: tuple[int, int] = (3, 3),
        shadow_color: tuple = (0, 0, 0),
        shadow_alpha: int = 160,
    ) -> None:
        super().__init__(x, y)
        self.text = text
        self.font_size = font_size
        self.color = color
        self.anchor = anchor
        self.shadow = shadow
        self.shadow_offset = shadow_offset
        self.shadow_color = shadow_color
        self.shadow_alpha = shadow_alpha

    @property
    def rect(self) -> pygame.Rect:
        s = theme.font(self.font_size).render(self.text, True, self.color)
        r = s.get_rect()
        setattr(r, self.anchor, (self.x, self.y))
        return r

    def draw(self, surface: pygame.Surface) -> None:
        if not self.visible:
            return
        s = theme.font(self.font_size).render(self.text, True, self.color)
        r = s.get_rect()
        setattr(r, self.anchor, (self.x, self.y))
        if self.shadow:
            sh = theme.font(self.font_size).render(self.text, True, self.shadow_color)
            sh.set_alpha(self.shadow_alpha)
            surface.blit(sh, (r.x + self.shadow_offset[0], r.y + self.shadow_offset[1]))
        surface.blit(s, r)


# ---------------------------------------------------------------------------
# GlowLabel  (for the title)
# ---------------------------------------------------------------------------


class GlowLabel(UIElement):
    """
    A label that renders multiple blurred copies behind the text to
    create a neon-glow effect — without requiring scipy / PIL.

    Set ``beat`` each frame (0.0–1.0) to add a music-reactive glow burst
    on top of the regular pulse animation.
    """

    def __init__(
        self,
        x: int,
        y: int,
        text: str,
        *,
        font_size: int = 120,
        color: tuple = theme.TEXT_TITLE,
        glow_color: tuple = theme.ACCENT_GLOW,
        anchor: str = "center",
        pulse: bool = True,
        pulse_speed: float = 1.4,
        pulse_range: tuple[float, float] = (0.6, 1.0),
    ) -> None:
        super().__init__(x, y)
        self.text = text
        self.font_size = font_size
        self.color = color
        self.glow_color = glow_color
        self.anchor = anchor
        self.pulse = pulse
        self.pulse_speed = pulse_speed
        self.pulse_range = pulse_range
        self._t: float = 0.0
        self._surf: pygame.Surface  # assigned in _ensure_built
        self._glow_layers: list[pygame.Surface] = []
        self._glow_base_alphas: list[int] = []
        self._built = False

        #: Set externally (0.0–1.0) each frame for music-reactive burst.
        self.beat: float = 0.0

    @property
    def rect(self) -> pygame.Rect:
        self._ensure_built()
        r = self._surf.get_rect()  # type: ignore[union-attr]
        setattr(r, self.anchor, (self.x, self.y))
        return r

    def _ensure_built(self) -> None:
        if self._built:
            return
        f = theme.font(self.font_size)
        self._surf = f.render(self.text, True, self.color)

        # Build glow layers: render text slightly larger in glow colour
        # to create a soft bloom effect.
        glow_sizes = [
            (self.font_size + 8,  80),
            (self.font_size + 16, 50),
            (self.font_size + 26, 25),
        ]
        self._glow_layers = []
        self._glow_base_alphas = []
        for gs, alpha in glow_sizes:
            gf = theme.font(gs)
            gs_surf = gf.render(self.text, True, self.glow_color)
            gs_surf.set_alpha(alpha)
            self._glow_layers.append(gs_surf)
            self._glow_base_alphas.append(alpha)

        self._built = True

    def update(self, dt: float) -> None:
        if self.pulse:
            self._t += dt * self.pulse_speed

    def draw(self, surface: pygame.Surface) -> None:
        if not self.visible:
            return
        self._ensure_built()

        # Smooth sine pulse
        pulse_factor = 1.0
        if self.pulse:
            lo, hi = self.pulse_range
            pulse_factor = lo + (hi - lo) * (math.sin(self._t) * 0.5 + 0.5)

        # Music-reactive beat boost (additive on top of pulse)
        beat_boost = 1.0 + self.beat * 1.8

        r = self._surf.get_rect()  # type: ignore[union-attr]
        setattr(r, self.anchor, (self.x, self.y))

        for layer, base_alpha in zip(self._glow_layers, self._glow_base_alphas):
            lr = layer.get_rect(center=r.center)
            a = int(base_alpha * pulse_factor * beat_boost)
            layer.set_alpha(min(255, a))
            surface.blit(layer, lr)

        surface.blit(self._surf, r)  # type: ignore[arg-type]


# ---------------------------------------------------------------------------
# Panel
# ---------------------------------------------------------------------------


class Panel(UIElement):
    """A semi-transparent background panel with an optional border."""

    def __init__(
        self,
        x: int,
        y: int,
        w: int,
        h: int,
        *,
        color: tuple = theme.PANEL,
        alpha: int = 210,
        border_color: Optional[tuple] = theme.BORDER,
        border_width: int = 1,
        border_radius: int = 4,
        anchor: str = "topleft",
    ) -> None:
        dummy = pygame.Rect(0, 0, w, h)
        setattr(dummy, anchor, (x, y))
        super().__init__(dummy.x, dummy.y)
        self.w = w
        self.h = h
        self.color = color
        self.alpha = alpha
        self.border_color = border_color
        self.border_width = border_width
        self.border_radius = border_radius

    @property
    def rect(self) -> pygame.Rect:
        return pygame.Rect(self.x, self.y, self.w, self.h)

    def draw(self, surface: pygame.Surface) -> None:
        if not self.visible:
            return
        surf = pygame.Surface((self.w, self.h), pygame.SRCALPHA)
        surf.fill((*self.color, self.alpha))
        surface.blit(surf, (self.x, self.y))
        if self.border_color and self.border_width > 0:
            pygame.draw.rect(
                surface,
                self.border_color,
                self.rect,
                width=self.border_width,
                border_radius=self.border_radius,
            )


# ---------------------------------------------------------------------------
# Divider
# ---------------------------------------------------------------------------


class Divider(UIElement):
    """Horizontal rule, optionally with a centre label."""

    def __init__(
        self,
        x: int,
        y: int,
        w: int,
        *,
        color: tuple = theme.BORDER,
        alpha: int = 90,
        anchor: str = "topleft",
    ) -> None:
        dummy = pygame.Rect(0, 0, w, 2)
        setattr(dummy, anchor, (x, y))
        super().__init__(dummy.x, dummy.y)
        self.w = w
        self.color = color
        self.alpha = alpha

    @property
    def rect(self) -> pygame.Rect:
        return pygame.Rect(self.x, self.y, self.w, 2)

    def draw(self, surface: pygame.Surface) -> None:
        if not self.visible:
            return
        s = pygame.Surface((self.w, 2), pygame.SRCALPHA)
        s.fill((*self.color, self.alpha))
        surface.blit(s, (self.x, self.y))


# ---------------------------------------------------------------------------
# GUIHandler
# ---------------------------------------------------------------------------


class GUIHandler:
    """
    Manages a flat list of GUI elements and provides a builder API.

    Example
    -------
    ::

        gui = GUIHandler()
        gui.panel(cx, cy, 400, 300, anchor="center")
        gui.button(cx, cy - 40, 360, 56, "PLAY", anchor="center",
                   on_click=lambda: print("play!"))
        gui.label(cx, cy, "v0.1", font_size=14, color=theme.TEXT_DIM)

        # game loop
        gui.handle_events(events)
        gui.update(dt)
        gui.draw(screen)
    """

    def __init__(self) -> None:
        self._elements: list[UIElement] = []
        self._named: dict[str, UIElement] = {}

    # ---- collection management ----

    def add(self, element: UIElement, *, name: Optional[str] = None) -> UIElement:
        """Add an element; optionally register it under *name* for later lookup."""
        self._elements.append(element)
        if name:
            self._named[name] = element
        return element

    def get(self, name: str) -> Optional[UIElement]:
        return self._named.get(name)

    def remove(self, element: UIElement) -> None:
        self._elements.remove(element)

    def clear(self) -> None:
        self._elements.clear()
        self._named.clear()

    # ---- loop methods ----

    def handle_events(self, events: list[pygame.event.Event]) -> None:
        for event in events:
            for el in reversed(self._elements):
                if el.handle_event(event):
                    break

    def update(self, dt: float) -> None:
        for el in self._elements:
            el.update(dt)

    def draw(self, surface: pygame.Surface) -> None:
        for el in self._elements:
            el.draw(surface)

    # ---- builder shortcuts ----

    def button(
        self,
        x: int,
        y: int,
        w: int,
        h: int,
        text: str,
        *,
        font_size: int = 30,
        on_click: Optional[Callable[[], None]] = None,
        anchor: str = "topleft",
        icon: Optional[str] = None,
        icon_surf: Optional[pygame.Surface] = None,
        name: Optional[str] = None,
    ) -> Button:
        el = Button(x, y, w, h, text, font_size=font_size, on_click=on_click,
                    anchor=anchor, icon=icon, icon_surf=icon_surf)
        self.add(el, name=name)
        return el

    def small_button(
        self,
        x: int,
        y: int,
        text: str,
        *,
        font_size: int = 18,
        on_click: Optional[Callable[[], None]] = None,
        anchor: str = "topleft",
        color: tuple = theme.TEXT_DIM,
        hover_color: tuple = theme.ACCENT,
        icon_surf: Optional[pygame.Surface] = None,
        name: Optional[str] = None,
    ) -> SmallButton:
        el = SmallButton(x, y, text, font_size=font_size, on_click=on_click,
                         anchor=anchor, color=color, hover_color=hover_color,
                         icon_surf=icon_surf)
        self.add(el, name=name)
        return el

    def label(
        self,
        x: int,
        y: int,
        text: str,
        *,
        font_size: int = 24,
        color: tuple = theme.TEXT,
        anchor: str = "topleft",
        shadow: bool = False,
        name: Optional[str] = None,
    ) -> Label:
        el = Label(x, y, text, font_size=font_size, color=color, anchor=anchor, shadow=shadow)
        self.add(el, name=name)
        return el

    def glow_label(
        self,
        x: int,
        y: int,
        text: str,
        *,
        font_size: int = 120,
        color: tuple = theme.TEXT_TITLE,
        glow_color: tuple = theme.ACCENT_GLOW,
        anchor: str = "center",
        pulse: bool = True,
        name: Optional[str] = None,
    ) -> GlowLabel:
        el = GlowLabel(x, y, text, font_size=font_size, color=color,
                       glow_color=glow_color, anchor=anchor, pulse=pulse)
        self.add(el, name=name)
        return el

    def panel(
        self,
        x: int,
        y: int,
        w: int,
        h: int,
        *,
        color: tuple = theme.PANEL,
        alpha: int = 210,
        border_color: Optional[tuple] = theme.BORDER,
        border_width: int = 1,
        anchor: str = "topleft",
        name: Optional[str] = None,
    ) -> Panel:
        el = Panel(x, y, w, h, color=color, alpha=alpha,
                   border_color=border_color, border_width=border_width, anchor=anchor)
        self.add(el, name=name)
        return el

    def divider(
        self,
        x: int,
        y: int,
        w: int,
        *,
        anchor: str = "topleft",
        name: Optional[str] = None,
    ) -> Divider:
        el = Divider(x, y, w, anchor=anchor)
        self.add(el, name=name)
        return el

    # TextInput is defined below GUIHandler; use gui.add(TextInput(...)) directly.


# ---------------------------------------------------------------------------
# TextInput
# ---------------------------------------------------------------------------


class TextInput(UIElement):
    """
    Single-line interactive text input.

    Supports focus (click to activate), cursor blinking, placeholder text,
    backspace, delete, Ctrl+A (select-all / clear), and pygame TEXTINPUT
    events for proper unicode entry.
    """

    _BLINK = 0.52

    def __init__(
        self,
        x: int,
        y: int,
        w: int,
        h: int,
        *,
        placeholder: str = "",
        max_length: int = 32,
        font_size: int = 24,
        anchor: str = "topleft",
    ) -> None:
        dummy = pygame.Rect(0, 0, w, h)
        setattr(dummy, anchor, (x, y))
        super().__init__(dummy.x, dummy.y)
        self.w = w
        self.h = h
        self.placeholder = placeholder
        self.max_length = max_length
        self.font_size = font_size

        self.text: str = ""
        self.focused: bool = False
        self._cursor_on: bool = True
        self._cursor_t: float = 0.0
        self._hover: float = 0.0

    @property
    def rect(self) -> pygame.Rect:
        return pygame.Rect(self.x, self.y, self.w, self.h)

    def handle_event(self, event: pygame.event.Event) -> bool:
        if not self.visible or not self.enabled:
            return False

        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            prev = self.focused
            self.focused = self.rect.collidepoint(event.pos)
            if self.focused:
                pygame.key.start_text_input()
            elif prev:
                pygame.key.stop_text_input()
            return self.focused or prev

        if not self.focused:
            return False

        if event.type == pygame.TEXTINPUT:
            self.text = (self.text + event.text)[: self.max_length]
            return True

        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_BACKSPACE:
                self.text = self.text[:-1]
                return True
            if event.key == pygame.K_DELETE:
                self.text = ""
                return True
            if event.key == pygame.K_a and (event.mod & pygame.KMOD_CTRL):
                self.text = ""
                return True
            # Consume tab/enter to avoid unexpected propagation
            if event.key in (pygame.K_TAB, pygame.K_RETURN, pygame.K_KP_ENTER):
                self.focused = False
                pygame.key.stop_text_input()
                return True

        return False

    def update(self, dt: float) -> None:
        hovered = self.rect.collidepoint(pygame.mouse.get_pos())
        self._hover = _smooth(self._hover, 1.0 if hovered else 0.0, 10.0, dt)
        if self.focused:
            self._cursor_t += dt
            if self._cursor_t >= self._BLINK:
                self._cursor_t -= self._BLINK
                self._cursor_on = not self._cursor_on
        else:
            self._cursor_on = False
            self._cursor_t = 0.0

    def draw(self, surface: pygame.Surface) -> None:
        if not self.visible:
            return
        r = self.rect
        pad = 12

        # Background
        bg = _lerp_color(theme.PANEL, theme.PANEL_LIGHT, self._hover)
        bg_surf = pygame.Surface((r.w, r.h), pygame.SRCALPHA)
        bg_surf.fill((*bg, 235))
        surface.blit(bg_surf, r.topleft)

        # Border
        if self.focused:
            border = theme.ACCENT
            bw = 2
        elif self._hover > 0.1:
            border = theme.BORDER_HOV
            bw = 1
        else:
            border = theme.BORDER
            bw = 1
        pygame.draw.rect(surface, border, r, width=bw, border_radius=3)

        # Text / placeholder
        show_ph = not self.text
        raw = self.placeholder if show_ph else self.text
        col = theme.TEXT_DIM if show_ph else theme.TEXT
        font = theme.font(self.font_size)

        # Trim text from the left if it overflows
        display = raw
        available = r.w - 2 * pad - 4
        while display and font.size(display)[0] > available:
            display = display[1:]

        ts = font.render(display, True, col)
        ty = r.y + (r.h - ts.get_height()) // 2
        surface.blit(ts, (r.x + pad, ty))

        # Blinking cursor
        if self.focused and self._cursor_on and not show_ph:
            cx = r.x + pad + ts.get_width() + 2
            pygame.draw.line(surface, theme.ACCENT,
                             (cx, ty + 3), (cx, ty + ts.get_height() - 3), 2)


# ---------------------------------------------------------------------------
# Alert  (slide-in notification — "Now Playing" etc.)
# ---------------------------------------------------------------------------


class Alert:
    """
    A temporary slide-in notification that appears, holds, then slides out.

    Not a UIElement subclass — manage it directly in your scene/draw loop.

    States:  slide_in → hold → slide_out → done
    """

    _SLIDE_SPEED = 6.0   # fraction per second for slide

    def __init__(
        self,
        text: str,
        *,
        duration: float = 3.5,
        font_size: int = 17,
        icon: str = "",
        corner: str = "topright",   # "topright" | "bottomright"
        margin: int = 16,
    ) -> None:
        self.text = text
        self.duration = duration
        self.font_size = font_size
        self.icon = icon
        self.corner = corner
        self.margin = margin

        self._t: float = 0.0
        self._phase: str = "slide_in"   # slide_in | hold | slide_out | done
        self._slide: float = 0.0        # 0 = hidden, 1 = fully visible

        self._surf: Optional[pygame.Surface] = None

    @property
    def done(self) -> bool:
        return self._phase == "done"

    def _build(self) -> pygame.Surface:
        font = theme.font(self.font_size)
        text_s = font.render(self.text, True, theme.TEXT)

        pad_x, pad_y = 14, 10
        icon_w = 0
        icon_s: Optional[pygame.Surface] = None

        if self.icon:
            icon_s = theme.font(self.font_size + 2).render(self.icon, True, theme.ACCENT)
            icon_w = icon_s.get_width() + 8

        w = pad_x * 2 + icon_w + text_s.get_width()
        h = pad_y * 2 + (max(icon_s.get_height(), text_s.get_height()) if icon_s else text_s.get_height())

        surf = pygame.Surface((w, h), pygame.SRCALPHA)
        pygame.draw.rect(surf, (*theme.PANEL, 230), surf.get_rect(), border_radius=4)
        pygame.draw.rect(surf, (*theme.ACCENT, 180), surf.get_rect(), width=1, border_radius=4)
        pygame.draw.rect(surf, (*theme.ACCENT, 220), pygame.Rect(0, 0, 3, h), border_radius=2)

        x_off = pad_x
        if icon_s:
            iy = (h - icon_s.get_height()) // 2
            surf.blit(icon_s, (x_off, iy))
            x_off += icon_s.get_width() + 8
        ty = (h - text_s.get_height()) // 2
        surf.blit(text_s, (x_off, ty))
        return surf

    def update(self, dt: float) -> None:
        if self._phase == "done":
            return

        self._t += dt

        if self._phase == "slide_in":
            self._slide = _smooth(self._slide, 1.0, self._SLIDE_SPEED, dt)
            if self._slide > 0.99:
                self._slide = 1.0
                self._phase = "hold"
                self._t = 0.0

        elif self._phase == "hold":
            if self._t >= self.duration:
                self._phase = "slide_out"
                self._t = 0.0

        elif self._phase == "slide_out":
            self._slide = _smooth(self._slide, 0.0, self._SLIDE_SPEED, dt)
            if self._slide < 0.01:
                self._phase = "done"

    def draw(self, surface: pygame.Surface) -> None:
        if self._phase == "done" or self._slide < 0.01:
            return
        if self._surf is None:
            self._surf = self._build()

        sw, sh = self._surf.get_size()
        scrw, scrh = surface.get_size()

        # Position: slide in from the right edge
        visible_x = scrw - self.margin - sw
        hidden_x  = scrw + 10
        x = int(hidden_x + (visible_x - hidden_x) * self._slide)

        if self.corner == "topright":
            y = self.margin
        else:
            y = scrh - self.margin - sh

        surface.blit(self._surf, (x, y))


# ---------------------------------------------------------------------------
# Slider
# ---------------------------------------------------------------------------


class Slider(UIElement):
    """
    Horizontal drag slider.

    Parameters
    ----------
    min_val / max_val : int
        Inclusive value range.
    value : int
        Initial value.
    step : int
        Snap increment.
    format_fn : callable, optional
        ``format_fn(value) -> str`` for the value label.  If *None*,
        defaults to ``str(value)``.
    on_change : callable, optional
        Called as ``on_change(new_value)`` whenever the value changes.
    """

    _TRACK_H = 6
    _THUMB_R = 10

    def __init__(
        self,
        x: int,
        y: int,
        w: int,
        min_val: int,
        max_val: int,
        value: int,
        *,
        step: int = 1,
        font_size: int = 16,
        on_change: Optional[Callable[[int], None]] = None,
        anchor: str = "topleft",
        format_fn: Optional[Callable[[int], str]] = None,
    ) -> None:
        h = self._THUMB_R * 2 + 20   # includes label space below
        dummy = pygame.Rect(0, 0, w, h)
        setattr(dummy, anchor, (x, y))
        super().__init__(dummy.x, dummy.y)
        self.w = w
        self.h = h
        self.min_val = min_val
        self.max_val = max_val
        self._value = value
        self.step = step
        self.font_size = font_size
        self.on_change = on_change
        self.format_fn = format_fn or str
        self._dragging = False
        self._hover: float = 0.0

    @property
    def rect(self) -> pygame.Rect:
        return pygame.Rect(self.x, self.y, self.w, self.h)

    @property
    def value(self) -> int:
        return self._value

    @value.setter
    def value(self, v: int) -> None:
        self._value = max(self.min_val, min(self.max_val, v))

    # ---- private helpers ----

    def _track_rect(self) -> pygame.Rect:
        cy = self.y + self._THUMB_R + 2
        return pygame.Rect(
            self.x + self._THUMB_R,
            cy - self._TRACK_H // 2,
            self.w - self._THUMB_R * 2,
            self._TRACK_H,
        )

    def _thumb_center(self) -> tuple[int, int]:
        tr = self._track_rect()
        frac = (self._value - self.min_val) / max(1, self.max_val - self.min_val)
        cx = tr.x + int(frac * tr.w)
        cy = tr.y + tr.h // 2
        return cx, cy

    def _value_from_x(self, px: int) -> int:
        tr = self._track_rect()
        frac = (px - tr.x) / max(1, tr.w)
        frac = max(0.0, min(1.0, frac))
        raw = self.min_val + frac * (self.max_val - self.min_val)
        snapped = round(raw / self.step) * self.step
        return int(max(self.min_val, min(self.max_val, snapped)))

    # ---- events ----

    def handle_event(self, event: pygame.event.Event) -> bool:
        if not self.visible or not self.enabled:
            return False

        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            cx, cy = self._thumb_center()
            thumb_r = pygame.Rect(cx - self._THUMB_R, cy - self._THUMB_R,
                                  self._THUMB_R * 2, self._THUMB_R * 2)
            if thumb_r.collidepoint(event.pos) or self._track_rect().collidepoint(event.pos):
                self._dragging = True
                new_v = self._value_from_x(event.pos[0])
                if new_v != self._value:
                    self._value = new_v
                    if self.on_change:
                        self.on_change(self._value)
                return True

        elif event.type == pygame.MOUSEBUTTONUP and event.button == 1:
            self._dragging = False

        elif event.type == pygame.MOUSEMOTION and self._dragging:
            new_v = self._value_from_x(event.pos[0])
            if new_v != self._value:
                self._value = new_v
                if self.on_change:
                    self.on_change(self._value)
            return True

        return False

    # ---- update ----

    def update(self, dt: float) -> None:
        hovered = self.rect.collidepoint(pygame.mouse.get_pos())
        self._hover = _smooth(self._hover, 1.0 if (hovered or self._dragging) else 0.0, 10.0, dt)

    # ---- draw ----

    def draw(self, surface: pygame.Surface) -> None:
        if not self.visible:
            return

        tr = self._track_rect()
        cx, cy = self._thumb_center()

        # Track background
        track_col = _lerp_color(theme.BORDER, theme.BORDER_HOV, self._hover)
        pygame.draw.rect(surface, track_col, tr, border_radius=3)

        # Filled portion
        frac = (self._value - self.min_val) / max(1, self.max_val - self.min_val)
        filled = pygame.Rect(tr.x, tr.y, int(tr.w * frac), tr.h)
        if filled.w > 0:
            fill_col = _lerp_color(theme.BTN_HOVER_BAR, theme.ACCENT, self._hover)
            pygame.draw.rect(surface, fill_col, filled, border_radius=3)

        # Thumb
        thumb_col = _lerp_color(theme.BLUE_BRIGHT, theme.ACCENT, self._hover)
        r = self._THUMB_R
        pygame.draw.circle(surface, thumb_col, (cx, cy), r)
        pygame.draw.circle(surface, theme.TEXT, (cx, cy), r - 3)

        # Value label above thumb
        label = self.format_fn(self._value)
        lbl_s = theme.font(self.font_size).render(label, True, theme.TEXT)
        lx = max(tr.x, min(tr.right - lbl_s.get_width(), cx - lbl_s.get_width() // 2))
        surface.blit(lbl_s, (lx, self.y + self.h - lbl_s.get_height()))


# ---------------------------------------------------------------------------
# ListSelector  (◀ OPTION ▶)
# ---------------------------------------------------------------------------


class ListSelector(UIElement):
    """
    Cycles through a list of labelled options using ◀ / ▶ arrow buttons.

    Parameters
    ----------
    options : list[tuple[str, str]]
        Each entry is ``(key, display_label)``.
    value : str
        Initially selected key.
    on_change : callable, optional
        Called as ``on_change(key)`` when the selection changes.
    """

    _ARROW_W = 36
    _ANIM_SPEED = 12.0

    def __init__(
        self,
        x: int,
        y: int,
        w: int,
        h: int,
        options: list[tuple[str, str]],
        *,
        value: str = "",
        font_size: int = 18,
        on_change: Optional[Callable[[str], None]] = None,
        anchor: str = "topleft",
    ) -> None:
        dummy = pygame.Rect(0, 0, w, h)
        setattr(dummy, anchor, (x, y))
        super().__init__(dummy.x, dummy.y)
        self.w = w
        self.h = h
        self.options = options
        self.font_size = font_size
        self.on_change = on_change

        self._idx: int = 0
        if value:
            for i, (k, _) in enumerate(options):
                if k == value:
                    self._idx = i
                    break

        self._hover_left: float = 0.0
        self._hover_right: float = 0.0

    @property
    def rect(self) -> pygame.Rect:
        return pygame.Rect(self.x, self.y, self.w, self.h)

    @property
    def value(self) -> str:
        if not self.options:
            return ""
        return self.options[self._idx][0]

    @value.setter
    def value(self, key: str) -> None:
        for i, (k, _) in enumerate(self.options):
            if k == key:
                self._idx = i
                return

    def _left_rect(self) -> pygame.Rect:
        return pygame.Rect(self.x, self.y, self._ARROW_W, self.h)

    def _right_rect(self) -> pygame.Rect:
        return pygame.Rect(self.x + self.w - self._ARROW_W, self.y, self._ARROW_W, self.h)

    def _cycle(self, delta: int) -> None:
        if not self.options:
            return
        self._idx = (self._idx + delta) % len(self.options)
        if self.on_change:
            self.on_change(self.options[self._idx][0])

    def handle_event(self, event: pygame.event.Event) -> bool:
        if not self.visible or not self.enabled:
            return False
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            if self._left_rect().collidepoint(event.pos):
                self._cycle(-1)
                return True
            if self._right_rect().collidepoint(event.pos):
                self._cycle(1)
                return True
        return False

    def update(self, dt: float) -> None:
        mp = pygame.mouse.get_pos()
        self._hover_left  = _smooth(self._hover_left,  1.0 if self._left_rect().collidepoint(mp)  else 0.0, self._ANIM_SPEED, dt)
        self._hover_right = _smooth(self._hover_right, 1.0 if self._right_rect().collidepoint(mp) else 0.0, self._ANIM_SPEED, dt)

    def draw(self, surface: pygame.Surface) -> None:
        if not self.visible:
            return

        r = self.rect
        # Background
        bg_s = pygame.Surface((r.w, r.h), pygame.SRCALPHA)
        bg_s.fill((*theme.PANEL, 200))
        surface.blit(bg_s, r.topleft)
        pygame.draw.rect(surface, theme.BORDER, r, width=1, border_radius=3)

        # Left arrow
        la = self._ARROW_W
        lc = _lerp_color(theme.TEXT_DIM, theme.ACCENT, self._hover_left)
        ls = theme.font(self.font_size + 4).render("‹", True, lc)
        surface.blit(ls, (r.x + (la - ls.get_width()) // 2, r.y + (r.h - ls.get_height()) // 2))

        # Right arrow
        rc = _lerp_color(theme.TEXT_DIM, theme.ACCENT, self._hover_right)
        rs = theme.font(self.font_size + 4).render("›", True, rc)
        surface.blit(rs, (r.right - la + (la - rs.get_width()) // 2, r.y + (r.h - rs.get_height()) // 2))

        # Current label (centred)
        if self.options:
            label = self.options[self._idx][1]
            ls2 = theme.font(self.font_size).render(label, True, theme.TEXT)
            lx = r.x + la + (r.w - 2 * la - ls2.get_width()) // 2
            ly = r.y + (r.h - ls2.get_height()) // 2
            surface.blit(ls2, (lx, ly))
