"""Pygame UI entry point for Connect 4."""

from __future__ import annotations

import array
import math
import os
import sys

# pygame 2.6.x: font/sysfont circular import breaks on Python 3.14+ (pygame#4795).
if sys.version_info >= (3, 14):
    sys.stderr.write(
        "connect4-game requires Python 3.13.x; pygame 2.6.x is not compatible with "
        "Python 3.14+ font modules. See https://github.com/pygame/pygame/issues/4795\n"
    )
    raise SystemExit(1)

import pygame
import pygame.freetype as pgft

from api import advance_bot_if_needed, create_players
from base_models import (
    BotDifficulty,
    GameException,
    GameOutcome,
    Player,
    PlayerType,
    WinningLine,
)

SCREEN_WIDTH = 1100
SCREEN_HEIGHT = 760
GRID_ORIGIN_X = 250
GRID_ORIGIN_Y = 140
CELL_SIZE = 80
WIN_LINE_WIDTH = 6
BUTTON_RECT = pygame.Rect(430, 30, 240, 44)
HOME_RECT = pygame.Rect(320, 30, 100, 44)

BG = (28, 30, 36)
BOARD = (33, 81, 165)
EMPTY = (235, 235, 235)
RED = (220, 40, 40)
YELLOW = (242, 204, 34)
TEXT = (240, 240, 240)
EMOJI_BG = (255, 255, 255)
HIGHLIGHT = (0, 180, 120)
ERROR = (235, 90, 90)
PLAYER_PANEL_WIDTH = 210
PLAYER_PANEL_Y = GRID_ORIGIN_Y - 10
PLAYER_LEFT_X = 10
PLAYER_RIGHT_X = 840
NAME_FIELD_WIDTH = 220
NAME_FIELD_HEIGHT = 28
NAME_FIELD_Y = 412


class Connect4UI:
    """Pygame application shell for setup and game loop."""

    def __init__(self) -> None:
        pygame.init()
        pygame.mixer.init(frequency=44100, size=-16, channels=1)
        self._screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
        pygame.display.set_caption("Connect 4")
        self._clock = pygame.time.Clock()
        self._font = pygame.font.SysFont("arial", 24)
        self._small_font = pygame.font.SysFont("arial", 20)
        self._emoji_ft = self._resolve_emoji_freetype_font(20)
        self._legacy_emoji_font = self._resolve_legacy_emoji_font(20)
        self._medal_number_font = pygame.font.SysFont("arial", 22)
        self._drop_sound = self._create_drop_sound()
        self._bell_sound = self._create_bell_sound()
        self._completed_sound_game_ids: set[str] = set()
        self._hand_cursor_active = False

        self._session = None
        self._player_configs = [
            {"name": "Player 1", "type": PlayerType.HUMAN, "difficulty": None},
            {
                "name": "Player 2",
                "type": PlayerType.BOT,
                "difficulty": BotDifficulty.EASY,
            },
        ]
        self._game_started = False
        self._error_message = ""
        self._running = True
        # 0 = player 1 name field, 1 = player 2, None = not editing
        self._name_focus: int | None = None
        self._name_max_len = 40
        pygame.key.set_repeat(400, 35)

    def run(self) -> None:
        """Run the UI event/render loop."""
        while self._running:
            self._handle_events()
            self._draw()
            self._clock.tick(30)
        pygame.quit()

    def _handle_events(self) -> None:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                self._running = False
                return

            if event.type == pygame.KEYDOWN and not self._game_started:
                if self._name_focus is not None:
                    self._handle_name_key(event)
                else:
                    self._handle_setup_key(event)

            if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
                if HOME_RECT.collidepoint(event.pos):
                    self._on_home()
                elif BUTTON_RECT.collidepoint(event.pos):
                    self._on_start_new_game()
                elif not self._game_started and self._name_field_rect(120).collidepoint(
                    event.pos
                ):
                    self._name_focus = 0
                elif not self._game_started and self._name_field_rect(810).collidepoint(
                    event.pos
                ):
                    self._name_focus = 1
                elif not self._game_started:
                    self._name_focus = None
                elif self._game_started:
                    self._on_grid_click(event.pos[0])

        if self._game_started and self._session is not None:
            current_game = self._session.get_current_game()
            if (
                current_game is not None
                and current_game.data().status.name == "RUNNING"
            ):
                try:
                    bot_result = advance_bot_if_needed(current_game)
                    if bot_result is not None:
                        self._drop_sound.play()
                except GameException as exc:
                    self._error_message = exc.message
            self._maybe_play_game_over_bell(current_game)
            self._update_cursor(current_game)
        else:
            self._set_hand_cursor(False)

    def _handle_setup_key(self, event: pygame.event.Event) -> None:
        key = event.key
        if key == pygame.K_1:
            self._player_configs[0]["type"] = PlayerType.HUMAN
            self._player_configs[0]["difficulty"] = None
        elif key == pygame.K_2:
            self._player_configs[0]["type"] = PlayerType.BOT
            self._player_configs[0]["difficulty"] = BotDifficulty.EASY
        elif key == pygame.K_3:
            self._player_configs[1]["type"] = PlayerType.HUMAN
            self._player_configs[1]["difficulty"] = None
        elif key == pygame.K_4:
            self._player_configs[1]["type"] = PlayerType.BOT
            self._player_configs[1]["difficulty"] = BotDifficulty.EASY
        elif key == pygame.K_q and self._player_configs[0]["type"] == PlayerType.BOT:
            self._player_configs[0]["difficulty"] = BotDifficulty.EASY
        elif key == pygame.K_w and self._player_configs[0]["type"] == PlayerType.BOT:
            self._player_configs[0]["difficulty"] = BotDifficulty.MEDIUM
        elif key == pygame.K_e and self._player_configs[0]["type"] == PlayerType.BOT:
            self._player_configs[0]["difficulty"] = BotDifficulty.HARD
        elif key == pygame.K_a and self._player_configs[1]["type"] == PlayerType.BOT:
            self._player_configs[1]["difficulty"] = BotDifficulty.EASY
        elif key == pygame.K_s and self._player_configs[1]["type"] == PlayerType.BOT:
            self._player_configs[1]["difficulty"] = BotDifficulty.MEDIUM
        elif key == pygame.K_d and self._player_configs[1]["type"] == PlayerType.BOT:
            self._player_configs[1]["difficulty"] = BotDifficulty.HARD

    def _name_field_rect(self, panel_x: int) -> pygame.Rect:
        """Return the clickable name input area for a player preview column."""
        return pygame.Rect(panel_x, NAME_FIELD_Y, NAME_FIELD_WIDTH, NAME_FIELD_HEIGHT)

    def _default_player_name(self, index: int) -> str:
        return "Player 1" if index == 0 else "Player 2"

    def _normalize_name_field(self, index: int) -> None:
        raw = str(self._player_configs[index]["name"]).strip()
        if not raw:
            self._player_configs[index]["name"] = self._default_player_name(index)

    def _handle_name_key(self, event: pygame.event.Event) -> None:
        """Handle keyboard while a name field is focused."""
        idx = self._name_focus
        if idx is None:
            return
        key = event.key
        name = str(self._player_configs[idx]["name"])

        if key == pygame.K_BACKSPACE:
            self._player_configs[idx]["name"] = name[:-1]
            return
        if key == pygame.K_TAB:
            self._normalize_name_field(idx)
            self._name_focus = 1 - idx
            return
        if key == pygame.K_ESCAPE:
            self._normalize_name_field(idx)
            self._name_focus = None
            return
        if key == pygame.K_RETURN or key == pygame.K_KP_ENTER:
            self._normalize_name_field(idx)
            self._name_focus = None
            return

        ch = event.unicode
        if ch and ch.isprintable() and len(name) < self._name_max_len:
            self._player_configs[idx]["name"] = name + ch

    def _on_start_new_game(self) -> None:
        self._normalize_name_field(0)
        self._normalize_name_field(1)
        self._name_focus = None
        if self._session is None:
            p1 = Player(
                name=str(self._player_configs[0]["name"]),
                id=1,
                type=self._player_configs[0]["type"],
                difficulty=self._player_configs[0]["difficulty"],
            )
            p2 = Player(
                name=str(self._player_configs[1]["name"]),
                id=2,
                type=self._player_configs[1]["type"],
                difficulty=self._player_configs[1]["difficulty"],
            )
            self._session = create_players((p1, p2))
        else:
            p1 = Player(
                name=str(self._player_configs[0]["name"]),
                id=1,
                type=self._player_configs[0]["type"],
                difficulty=self._player_configs[0]["difficulty"],
            )
            p2 = Player(
                name=str(self._player_configs[1]["name"]),
                id=2,
                type=self._player_configs[1]["type"],
                difficulty=self._player_configs[1]["difficulty"],
            )
            # Reconfigure bot difficulty but keep existing session stats.
            self._session.get_player_session_stats()
            self._session.set_players((p1, p2))
        self._session.start_new_game()
        self._game_started = True
        self._error_message = ""

    def _on_home(self) -> None:
        """Return to the home/setup screen without resetting session stats."""
        if self._session is not None:
            # Ensure a completed game's result is aggregated before leaving.
            self._session.get_player_session_stats()
            current_game = self._session.get_current_game()
            if current_game is not None:
                p1, p2 = current_game.data().players
                self._player_configs[0]["name"] = p1.name
                self._player_configs[1]["name"] = p2.name
        self._game_started = False
        self._error_message = ""

    def _on_grid_click(self, mouse_x: int) -> None:
        if self._session is None:
            return
        current_game = self._session.get_current_game()
        if current_game is None:
            return
        game_data = current_game.data()
        if game_data.status.name != "RUNNING":
            return

        current_player = self._player_by_id(
            game_data.next_move_player_id, game_data.players
        )
        if current_player.type != PlayerType.HUMAN:
            return

        column = (mouse_x - GRID_ORIGIN_X) // CELL_SIZE
        if column < 0 or column >= 7:
            return
        try:
            current_game.place_disc(current_player, column)
            self._drop_sound.play()
            self._error_message = ""
        except GameException as exc:
            self._error_message = exc.message

    def _draw(self) -> None:
        self._screen.fill(BG)
        self._draw_start_button()
        self._draw_home_button()
        if not self._game_started:
            self._draw_setup_instructions()
        else:
            self._draw_game()
        pygame.display.flip()

    def _draw_start_button(self) -> None:
        pygame.draw.rect(self._screen, (60, 90, 180), BUTTON_RECT, border_radius=8)
        text = self._font.render("Start A New Game", True, TEXT)
        self._screen.blit(text, (BUTTON_RECT.x + 26, BUTTON_RECT.y + 8))

    def _draw_home_button(self) -> None:
        pygame.draw.rect(self._screen, (60, 90, 180), HOME_RECT, border_radius=8)
        text = self._font.render("Home", True, TEXT)
        self._screen.blit(text, (HOME_RECT.x + 16, HOME_RECT.y + 8))

    def _draw_setup_instructions(self) -> None:
        lines = [
            "Create players using keyboard:",
            "1 = P1 Human, 2 = P1 Bot",
            "3 = P2 Human, 4 = P2 Bot",
            "P1 difficulty: Q/EASY W/MEDIUM E/HARD",
            "P2 difficulty: A/EASY S/MEDIUM D/HARD",
            "Click name boxes to edit · Tab/Enter to finish",
            "Then click 'Start A New Game'",
        ]
        y = 140
        for line in lines:
            text = self._font.render(line, True, TEXT)
            self._screen.blit(text, (300, y))
            y += 40
        self._draw_player_preview(120, self._player_configs[0], 0)
        self._draw_player_preview(810, self._player_configs[1], 1)

    def _draw_player_preview(self, x: int, cfg: dict, player_index: int) -> None:
        field = self._name_field_rect(x)
        pygame.draw.rect(self._screen, (40, 42, 48), field, border_radius=6)
        border_color = HIGHLIGHT if self._name_focus == player_index else (90, 92, 100)
        pygame.draw.rect(self._screen, border_color, field, width=2, border_radius=6)
        display_name = str(cfg["name"])
        name_surf = self._font.render(display_name, True, TEXT)
        clip_w = NAME_FIELD_WIDTH - 12
        if name_surf.get_width() > clip_w:
            self._screen.blit(
                name_surf,
                (field.x + 6, field.y + 2),
                (0, 0, clip_w, name_surf.get_height()),
            )
        else:
            self._screen.blit(name_surf, (field.x + 6, field.y + 2))
        if self._name_focus == player_index:
            caret_x = field.x + 6 + min(name_surf.get_width(), clip_w)
            pygame.draw.line(
                self._screen,
                TEXT,
                (caret_x, field.y + 4),
                (caret_x, field.y + NAME_FIELD_HEIGHT - 5),
                2,
            )

        type_text = self._small_font.render(f"type: {cfg['type'].name}", True, TEXT)
        self._screen.blit(type_text, (x, 460))
        difficulty_name = cfg["difficulty"].name if cfg["difficulty"] else "N/A"
        diff_text = self._small_font.render(
            f"difficulty: {difficulty_name}", True, TEXT
        )
        self._screen.blit(diff_text, (x, 495))

    def _draw_game(self) -> None:
        if self._session is None:
            return
        current_game = self._session.get_current_game()
        if current_game is None:
            return
        game_data = current_game.data()
        stats = self._session.get_player_session_stats()
        p1, p2 = game_data.players

        self._draw_player_panel(
            PLAYER_LEFT_X, p1, stats[0], game_data.next_move_player_id == p1.id
        )
        self._draw_player_panel(
            PLAYER_RIGHT_X, p2, stats[1], game_data.next_move_player_id == p2.id
        )
        self._draw_grid(
            game_data.grid,
            game_data.winning_line,
            game_data.winner_id,
        )

        status_text = self._status_text(game_data.outcome)
        self._screen.blit(self._font.render(status_text, True, TEXT), (360, 665))
        if self._error_message:
            self._screen.blit(
                self._small_font.render(self._error_message, True, ERROR), (260, 700)
            )

    def _draw_player_panel(self, x: int, player: Player, stats, is_turn: bool) -> None:
        disc_color = RED if player.id == 1 else YELLOW
        name_text = self._font.render(player.name, True, TEXT)
        name_x = x + 12
        name_y = PLAYER_PANEL_Y + 15
        self._screen.blit(name_text, (name_x, name_y))
        disc_x = min(name_x + name_text.get_width() + 18, x + PLAYER_PANEL_WIDTH - 18)
        disc_y = PLAYER_PANEL_Y + 28
        if is_turn and player.type == PlayerType.HUMAN:
            self._draw_disc_light_effect(disc_x, disc_y)
        pygame.draw.circle(self._screen, disc_color, (disc_x, disc_y), 10)
        if player.type == PlayerType.BOT:
            self._draw_bot_icon(disc_x, disc_y)
        elif player.type == PlayerType.HUMAN:
            self._draw_ninja_icon(disc_x, disc_y)

        self._screen.blit(
            self._small_font.render(f"Games played: {stats.games_played}", True, TEXT),
            (x + 12, PLAYER_PANEL_Y + 52),
        )
        self._screen.blit(
            self._small_font.render(
                f"won/lost/draw: {stats.games_won}/{stats.games_lost}/{stats.games_draw}",
                True,
                TEXT,
            ),
            (x + 12, PLAYER_PANEL_Y + 85),
        )
        self._draw_medals(x + 12, PLAYER_PANEL_Y + 151, stats.games_won)

    def _cell_center(self, row: int, col: int) -> tuple[int, int]:
        """Pixel center of the disc at ``grid[row][col]``."""
        cx = GRID_ORIGIN_X + col * CELL_SIZE + CELL_SIZE // 2
        cy = GRID_ORIGIN_Y + row * CELL_SIZE + CELL_SIZE // 2
        return (cx, cy)

    def _draw_grid(
        self,
        grid: list[list[int]],
        winning_line: WinningLine | None,
        winner_id: int | None,
    ) -> None:
        pygame.draw.rect(
            self._screen,
            BOARD,
            (
                GRID_ORIGIN_X - 10,
                GRID_ORIGIN_Y - 10,
                CELL_SIZE * 7 + 20,
                CELL_SIZE * 6 + 20,
            ),
            border_radius=14,
        )
        for row in range(6):
            for col in range(7):
                value = grid[row][col]
                color = EMPTY if value == 0 else RED if value == 1 else YELLOW
                cx, cy = self._cell_center(row, col)
                pygame.draw.circle(self._screen, color, (cx, cy), CELL_SIZE // 2 - 7)

        if winning_line is not None and winner_id is not None:
            line_color = RED if winner_id == 1 else YELLOW
            start = self._cell_center(winning_line[0][0], winning_line[0][1])
            end = self._cell_center(winning_line[3][0], winning_line[3][1])
            start, end = self._extend_win_line_endpoints(start, end)
            pygame.draw.line(self._screen, line_color, start, end, width=WIN_LINE_WIDTH)

    def _extend_win_line_endpoints(
        self, start: tuple[int, int], end: tuple[int, int]
    ) -> tuple[tuple[int, int], tuple[int, int]]:
        """Lengthen the segment slightly so the stroke clearly caps the four discs."""
        pad = 12
        dx = end[0] - start[0]
        dy = end[1] - start[1]
        length = math.hypot(dx, dy)
        if length < 1e-6:
            return (start, end)
        ux, uy = dx / length, dy / length
        return (
            (int(start[0] - ux * pad), int(start[1] - uy * pad)),
            (int(end[0] + ux * pad), int(end[1] + uy * pad)),
        )

    def _status_text(self, outcome: GameOutcome | None) -> str:
        if outcome == GameOutcome.PLAYER_1_WIN:
            return "Player 1 won!"
        if outcome == GameOutcome.PLAYER_2_WIN:
            return "Player 2 won!"
        if outcome == GameOutcome.DRAW:
            return "Game draw!"
        return "Game in progress"

    @staticmethod
    def _player_by_id(player_id: int, players: tuple[Player, Player]) -> Player:
        return players[0] if players[0].id == player_id else players[1]

    @staticmethod
    def _emoji_font_file_paths() -> tuple[str, ...]:
        """Known paths to fonts with color emoji tables (CBDT/CBLC, COLR, etc.)."""
        if sys.platform == "darwin":
            return ("/System/Library/Fonts/Apple Color Emoji.ttc",)
        if sys.platform.startswith("linux"):
            return (
                "/usr/share/fonts/noto/NotoColorEmoji.ttf",
                "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
            )
        if sys.platform == "win32":
            windir = os.environ.get("WINDIR", "C:\\Windows")
            return (os.path.join(windir, "Fonts", "seguiemj.ttf"),)
        return ()

    @staticmethod
    def _resolve_emoji_freetype_font(size: int) -> pgft.Font | None:
        """Load color emoji via FreeType; ``pygame.font`` often fails or tints to gray."""
        for path in Connect4UI._emoji_font_file_paths():
            if not os.path.isfile(path):
                continue
            try:
                return pgft.Font(path, size=size)
            except (OSError, pygame.error):
                continue
        return None

    @staticmethod
    def _resolve_legacy_emoji_font(size: int) -> pygame.font.Font:
        """Fallback ``pygame.font`` when no color font file is available."""
        available = {name.lower() for name in pygame.font.get_fonts()}
        candidates = (
            "Apple Color Emoji",
            "Noto Color Emoji",
            "Segoe UI Emoji",
        )
        for candidate in candidates:
            if candidate.lower() in available:
                return pygame.font.SysFont(candidate, size)
        return pygame.font.SysFont("arial", size)

    def _render_emoji_glyph(self, text: str) -> pygame.Surface | None:
        """Rasterize one emoji; prefers FreeType + color font for correct appearance.

        FreeType tints the glyph with ``fgcolor``. With Apple Color Emoji the result
        is a monochrome mask; white on our white plate was invisible, so we tint
        black for contrast.
        """
        if self._emoji_ft is not None:
            try:
                surf, _ = self._emoji_ft.render(text, fgcolor=(0, 0, 0))
                if surf.get_width() > 0 and surf.get_height() > 0:
                    return surf
            except pygame.error:
                pass
        try:
            surf = self._legacy_emoji_font.render(text, True, (0, 0, 0))
            if surf.get_width() > 0 and surf.get_height() > 0:
                return surf
        except pygame.error:
            pass
        return None

    @staticmethod
    def _emoji_glyph_unusable(surf: pygame.Surface) -> bool:
        """True if the glyph is empty, transparent, or a dark gray legacy placeholder.

        FreeType tints Apple Color Emoji to a black mask ``(0, 0, 0, a)``; the center
        often has ``a > 200``. The old rule ``(r+g+b) < 100`` matched that and wrongly
        sent 🥷 to the vector fallback while 🤖 (lower ``a`` at center) used the emoji.
        """
        w, h = surf.get_size()
        if w <= 0 or h <= 0:
            return True
        r, g, b, a = surf.get_at((w // 2, h // 2))
        if a < 15:
            return True
        rgb = r + g + b
        # Dark *gray* tofu from legacy fonts, not pure-black FreeType silhouette.
        if a > 200 and 0 < rgb < 100:
            return True
        return False

    def _blit_emoji_with_white_bg(
        self, glyph: pygame.Surface, x: int, y: int, pad: int = 2
    ) -> None:
        """Draw a white rounded plate behind a color emoji so it reads on dark UI."""
        w, h = glyph.get_size()
        if w <= 0 or h <= 0:
            return
        rect = pygame.Rect(x - pad, y - pad, w + 2 * pad, h + 2 * pad)
        radius = max(4, min(w, h) // 5)
        pygame.draw.rect(self._screen, EMOJI_BG, rect, border_radius=radius)
        self._screen.blit(glyph, (x, y))

    def _draw_medals(self, start_x: int, y: int, games_won: int) -> None:
        """Draw one medal icon for each won game (max 10 per row)."""
        medals_per_row = 10
        row_height = 24
        medal_step_x = 26
        for idx in range(games_won):
            row = idx // medals_per_row
            col = idx % medals_per_row
            medal_x = start_x + col * medal_step_x
            medal_y = y + row * row_height
            self._draw_medal_icon(medal_x, medal_y)

    def _draw_bot_icon(self, disc_x: int, disc_y: int) -> None:
        """Draw U+1F916 (🤖) beside the disc using the emoji font, or a vector fallback."""
        robot_surface = self._render_emoji_glyph("\U0001f916")
        if robot_surface is None or self._emoji_glyph_unusable(robot_surface):
            self._draw_custom_bot_icon(disc_x, disc_y)
            return
        w, h = robot_surface.get_size()
        bx = disc_x + 10 + 8
        by = disc_y - h // 2
        self._blit_emoji_with_white_bg(robot_surface, bx, by)

    def _draw_custom_bot_icon(self, disc_x: int, disc_y: int) -> None:
        """Small robot head when the emoji glyph is missing (tofu / empty)."""
        cx = disc_x + 10 + 14
        cy = disc_y
        gray = (180, 185, 195)
        dark = (90, 95, 105)
        head = pygame.Rect(cx - 8, cy - 7, 16, 14)
        pygame.draw.rect(self._screen, gray, head, border_radius=3)
        pygame.draw.rect(self._screen, dark, head, width=1, border_radius=3)
        pygame.draw.circle(self._screen, (35, 40, 50), (cx - 4, cy - 1), 2)
        pygame.draw.circle(self._screen, (35, 40, 50), (cx + 4, cy - 1), 2)
        pygame.draw.line(self._screen, dark, (cx, cy - 7), (cx, cy - 12), 2)
        pygame.draw.circle(self._screen, (200, 75, 75), (cx, cy - 12), 2)

    def _draw_ninja_icon(self, disc_x: int, disc_y: int) -> None:
        """Draw U+1F977 (🥷) beside the disc using the emoji font, or a vector fallback."""
        ninja_surface = self._render_emoji_glyph("\U0001f977")
        if ninja_surface is None or self._emoji_glyph_unusable(ninja_surface):
            self._draw_custom_ninja_icon(disc_x, disc_y)
            return
        w, h = ninja_surface.get_size()
        bx = disc_x + 10 + 8
        by = disc_y - h // 2
        self._blit_emoji_with_white_bg(ninja_surface, bx, by)

    def _draw_custom_ninja_icon(self, disc_x: int, disc_y: int) -> None:
        """Small ninja mask icon when the emoji glyph is missing (tofu / empty)."""
        cx = disc_x + 10 + 14
        cy = disc_y
        cloth = (55, 55, 62)
        band = (35, 38, 48)
        tie = (200, 60, 55)
        # Mask / hood silhouette
        pygame.draw.ellipse(self._screen, cloth, (cx - 9, cy - 8, 18, 15))
        pygame.draw.arc(
            self._screen, band, (cx - 7, cy - 5, 14, 10), 0.2, math.pi - 0.2, 2
        )
        pygame.draw.line(self._screen, tie, (cx, cy + 5), (cx - 4, cy + 12), 2)
        pygame.draw.line(self._screen, tie, (cx, cy + 5), (cx + 4, cy + 12), 2)

    def _draw_medal_icon(self, x: int, y: int) -> None:
        """Draw a sports medal icon (🏅) with a safe fallback."""
        medal_surface = self._render_emoji_glyph("\U0001f3c5")
        if medal_surface is None or self._emoji_glyph_unusable(medal_surface):
            self._draw_custom_medal(x + 10, y)
            return
        w, h = medal_surface.get_size()

        # Center the emoji at the requested y coordinate.
        my = y - h // 2
        self._blit_emoji_with_white_bg(medal_surface, x, my)

    def _draw_custom_medal(self, center_x: int, center_y: int) -> None:
        """Fallback drawn medal (gold base with a centered '4')."""
        gold = (235, 193, 52)
        gold_rim = (160, 120, 20)

        # Base medallion (centered on center_y so the inscribed number is truly centered).
        pygame.draw.circle(self._screen, gold, (center_x, center_y), 11)
        pygame.draw.circle(
            self._screen,
            gold_rim,
            (center_x, center_y),
            11,
            width=2,
        )
        pygame.draw.circle(
            self._screen,
            (245, 220, 120),
            (center_x, center_y),
            6,
        )

        # Inscribe '4' in the middle (Connect 4).
        number_surface = self._medal_number_font.render("4", True, gold_rim)
        num_w, num_h = number_surface.get_size()
        self._screen.blit(
            number_surface, (center_x - num_w // 2, center_y - num_h // 2)
        )

    def _draw_disc_light_effect(self, center_x: int, center_y: int) -> None:
        """Draw an animated glow for the active player's disc.

        The glow is drawn as concentric OUTLINES (annulus rings) so it never
        looks like a second filled disc.
        """
        # Slower than frame rate so the glow feels intentional.
        t = (pygame.time.get_ticks() % 2200) / 2200.0
        glow_surface = pygame.Surface((64, 64), pygame.SRCALPHA)
        cx, cy = 32, 32

        # Rings are drawn as annuli outside the 10px filled disc to avoid any
        # “second disc” illusion.
        rings = (
            (14, 32),  # inner
            (18, 24),
            (22, 16),
            (26, 10),  # outer
        )
        ring_count = len(rings)
        phase = t * ring_count
        for i, (radius, base_alpha) in enumerate(rings):
            local = phase - i
            # Triangular envelope from 0..1..2 so brightness slides outward.
            if local < 0 or local > 2:
                alpha = 0
            elif local < 1:
                alpha = int(base_alpha * local)
            else:
                alpha = int(base_alpha * (2 - local))
            if alpha <= 0:
                continue
            ring_width = 4
            pygame.draw.circle(
                glow_surface,
                (70, 210, 150, alpha),
                (cx, cy),
                radius,
                width=ring_width,
            )

        # Keep highlights centered and subtle.
        pygame.draw.circle(glow_surface, (255, 255, 255, 28), (28, 28), 3)
        self._screen.blit(glow_surface, (center_x - 32, center_y - 32))

    def _maybe_play_game_over_bell(self, current_game) -> None:
        """Play a bell exactly once when a game reaches completed state."""
        if current_game is None:
            return
        game_data = current_game.data()
        if game_data.status.name != "COMPLETED":
            return
        if game_data.game_id in self._completed_sound_game_ids:
            return
        self._bell_sound.play()
        self._completed_sound_game_ids.add(game_data.game_id)

    def _update_cursor(self, current_game) -> None:
        """Set hand cursor when human can place a disc."""
        if current_game is None:
            self._set_hand_cursor(False)
            return
        game_data = current_game.data()
        if game_data.status.name != "RUNNING":
            self._set_hand_cursor(False)
            return
        current_player = self._player_by_id(
            game_data.next_move_player_id,
            game_data.players,
        )
        self._set_hand_cursor(current_player.type == PlayerType.HUMAN)

    def _set_hand_cursor(self, enabled: bool) -> None:
        """Apply desired system cursor, avoiding repeated calls."""
        if enabled == self._hand_cursor_active:
            return
        try:
            cursor = (
                pygame.SYSTEM_CURSOR_HAND if enabled else pygame.SYSTEM_CURSOR_ARROW
            )
            pygame.mouse.set_cursor(cursor)
            self._hand_cursor_active = enabled
        except pygame.error:
            # Some environments don't support system cursors.
            self._hand_cursor_active = False

    @staticmethod
    def _create_drop_sound() -> pygame.mixer.Sound:
        """Create a short synthetic 'drop' tone for disc placement."""
        sample_rate = 44100
        duration_seconds = 0.08
        amplitude = 8000
        frequency = 520.0
        frame_count = int(sample_rate * duration_seconds)
        frames = array.array("h")
        for index in range(frame_count):
            envelope = 1.0 - (index / frame_count)
            sample = int(
                amplitude
                * envelope
                * math.sin(2.0 * math.pi * frequency * (index / sample_rate))
            )
            frames.append(sample)
        return pygame.mixer.Sound(buffer=frames.tobytes())

    @staticmethod
    def _create_bell_sound() -> pygame.mixer.Sound:
        """Create a short bell-like tone for game over."""
        sample_rate = 44100
        duration_seconds = 2.0
        amplitude = 6000
        frame_count = int(sample_rate * duration_seconds)
        frames = array.array("h")
        for index in range(frame_count):
            t = index / sample_rate
            envelope = math.exp(-1.2 * t)
            sample = math.sin(2.0 * math.pi * 880.0 * t) + 0.5 * math.sin(
                2.0 * math.pi * 1320.0 * t
            )
            frames.append(int(amplitude * envelope * sample))
        return pygame.mixer.Sound(buffer=frames.tobytes())


def run_cli() -> None:
    """Entry point for the ``connect4`` console script (``pip install`` / ``uv``)."""
    Connect4UI().run()
