from __future__ import annotations

import inspect
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal

from .cache import ResolvedTrackCache
from .lyrics import format_lyrics, parse_lyrics
from .models import Category, ResolvedTrack, Track


BASE_URL = "https://www.gggmusic.com/"
LoopMode = Literal["list", "single"]
SEARCH_LIMIT = 100


@dataclass(frozen=True)
class _ListPayload:
    item: Category | Track


async def _maybe_await(value: Any) -> Any:
    if inspect.isawaitable(value):
        return await value
    return value


def _t(value: str) -> str:
    try:
        return value.encode("ascii").decode("unicode_escape")
    except UnicodeEncodeError:
        return value


START_FAILED = _t("\u542f\u52a8\u5931\u8d25\uff1a")
STOP_FAILED = _t("\u505c\u6b62\u5931\u8d25\uff1a")
LOOP_MODE_LABEL = _t("\u5faa\u73af\u6a21\u5f0f\uff1a")
LOADING_LABEL = _t("\u6b63\u5728\u52a0\u8f7d ")
RESOLVING_LABEL = _t("\u6b63\u5728\u89e3\u6790 ")
PLAY_FAILED = _t("\u64ad\u653e\u5931\u8d25\uff1a")
NOW_PLAYING_LABEL = _t("\u6b63\u5728\u64ad\u653e\uff1a")
LOAD_FAILED = _t("\u52a0\u8f7d\u5931\u8d25\uff1a")
LOADED_PREFIX = _t("\u5df2\u52a0\u8f7d ")
LOADED_SUFFIX = _t(" \u9996\u6b4c\u66f2\u3002\u9009\u62e9\u6b4c\u66f2\u5f00\u59cb\u64ad\u653e\u3002")
ARTIST_LABEL = _t("\u6b4c\u624b\uff1a")
ALBUM_LABEL = _t("\u4e13\u8f91\uff1a")
UNKNOWN_ALBUM = _t("\u672a\u77e5\u4e13\u8f91")


def _label_text(value: Category | Track) -> str:
    if isinstance(value, Category):
        return f"[{value.kind}] {value.name}"
    if value.category:
        return f"{value.title} - {value.category}"
    return value.title


def _item_from_payload(payload: Category | Track) -> Any:
    from textual.widgets import Label, ListItem

    item = ListItem(Label(_label_text(payload)))
    item.data = _ListPayload(payload)
    return item


def _format_time(seconds: float) -> str:
    seconds = max(0, int(seconds))
    minutes, remainder = divmod(seconds, 60)
    return f"{minutes:02d}:{remainder:02d}"


def _progress_bar(current_seconds: float, duration_seconds: float | None, *, width: int = 22) -> str:
    if duration_seconds is None or duration_seconds <= 0:
        return f"[{'-' * width}] {_format_time(current_seconds)} / --:--"
    ratio = min(1.0, max(0.0, current_seconds / duration_seconds))
    filled = min(width, int(round(ratio * width)))
    return f"[{'#' * filled}{'-' * (width - filled)}] {_format_time(current_seconds)} / {_format_time(duration_seconds)}"


EMOJI_VISUAL_TICKS_PER_FRAME = 4
EMOJI_VISUAL_FRAMES = [
    r"""        /\_/\\
       ( o.o )
      /|  ^  |\\
     /_|_____|_\\
        /   \\
       /_____\\""",
    r"""        /\_/\\
       ( ^.^ )
      /|  *  |\\
     /_|_____|_\\
       _/   \_
      /_______\\""",
    r"""        /\_/\\
       ( >.< )
      /|  v  |\\
     /_|_____|_\\
        /   \\
      _/_____\_""",
    r"""        /\_/\\
       ( -.- )
      /|  ~  |\\
     /_|_____|_\\
       /  |  \\
      /___|___\\""",
    r"""        /\_/\\
       ( =w= )
        |  +  |
       _|_____|
      /  /   \\
     /__/_____\\""",
    r"""        /\_/\\
       (=^.^=)
      /|  #  |\\
     /_|_____|_\\
       /  _  \\
      /__/ \__\\""",
    r"""        /\_/\\
       ( o_~ )
      /|  @  |\\
     /_|_____|_\\
        /| |\\
       /_| |_\\""",
    r"""        /\_/\\
       ( ^o^ )
      /|  x  |\\
     /_|_____|_\\
      \  / \  /
       \/___\/""",
    r"""        /\_/\\
       ( 0.0 )
      /|  !  |\\
     /_|_____|_\\
       /  |  \\
      /___|___\\""",
    r"""        /\_/\\
       ( ^_^ )
        |  =  |
       _|_____|
      /  /   \\
     /__/_____\\""",
]

async def _fetch_text(client: Any, url: str) -> str:
    try:
        return await _maybe_await(client.fetch_text(url))
    except TypeError:
        if url != BASE_URL:
            raise
        return await _maybe_await(client.fetch_text())


async def _resolve_track(track: Track, client: Any) -> ResolvedTrack:
    from .resolver import resolve_thread

    attempts = (
        lambda: resolve_thread(client, track),
        lambda: resolve_thread(client, track.url),
        lambda: resolve_thread(track.url),
    )
    last_error: Exception | None = None
    for attempt in attempts:
        try:
            return await _maybe_await(attempt())
        except TypeError as exc:
            last_error = exc
    html = await _fetch_text(client, track.url)
    try:
        return await _maybe_await(resolve_thread(html, track.url))
    except TypeError:
        if last_error is not None:
            raise last_error
        raise


def _build_app_class() -> type[Any]:
    try:
        from textual.app import App, ComposeResult
        from textual.binding import Binding
        from textual.containers import Horizontal, Vertical
        from textual.widgets import Button, Footer, Header, Input, Label, ListView, Static
    except ModuleNotFoundError as exc:  # pragma: no cover - exercised without optional dep.
        raise RuntimeError(_t("\u8fd0\u884c TUI \u9700\u8981\u5b89\u88c5 Textual\u3002\u8bf7\u5148\u6267\u884c `pip install -e .` \u5b89\u88c5\u9879\u76ee\u4f9d\u8d56\u3002")) from exc

    class TrackListView(ListView):
        BINDINGS = [
            Binding("left", "previous_page", _t("\u4e0a\u4e00\u9875"), show=False),
            Binding("right", "next_page", _t("\u4e0b\u4e00\u9875"), show=False),
        ]

        async def _on_key(self, event: Any) -> None:
            app = self.app
            if event.key == "down" and self.children and self.index == len(self.children) - 1:
                if hasattr(app, "_load_next_page") and await app._load_next_page():
                    event.stop()
                    event.prevent_default()
                    return
            if event.key == "up" and self.children and self.index == 0:
                if hasattr(app, "_load_previous_page") and await app._load_previous_page():
                    event.stop()
                    event.prevent_default()
                    return
            if event.key == "right":
                if hasattr(app, "_load_next_page") and await app._load_next_page():
                    event.stop()
                    event.prevent_default()
                    return
            if event.key == "left":
                if hasattr(app, "_load_previous_page") and await app._load_previous_page():
                    event.stop()
                    event.prevent_default()
                    return
            await super()._on_key(event)

        async def action_cursor_down(self) -> None:
            await _maybe_await(super().action_cursor_down())

        async def action_cursor_up(self) -> None:
            await _maybe_await(super().action_cursor_up())

        async def action_next_page(self) -> None:
            app = self.app
            if hasattr(app, "_load_next_page"):
                await app._load_next_page()

        async def action_previous_page(self) -> None:
            app = self.app
            if hasattr(app, "_load_previous_page"):
                await app._load_previous_page()
    class SadTuiPlayerApp(App):
        CSS = """
        Screen { layout: vertical; }
        #list-page, #player-page { height: 1fr; }
        #content { height: 1fr; }
        .pane { border: solid $primary; height: 1fr; }
        #categories { width: 32; }
        #tracks { width: 1fr; }
        #search-bar { height: 3; }
        #search-input { width: 1fr; height: 3; }
        #search-button { width: 10; height: 3; }
        #player-button { width: 12; height: 3; }
        #search-input { border: round $primary 40%; background: $surface; }
        #search-input:focus { border: round $primary; background-tint: $primary 8%; }
        #search-button, #player-button { border: round $primary 60%; background: $surface; color: $foreground; text-style: bold; }
        #search-button:focus, #player-button:focus { border: round $accent; background-tint: $accent 12%; color: $accent; }
        #search-button:hover, #player-button:hover { border: round $accent; background-tint: $accent 8%; }
        #search-button.-active, #player-button.-active { border: round $success; background-tint: $success 16%; color: $success; }
        #player-page { display: none; }
        #player-left { width: 40%; min-width: 34; border: solid $secondary; padding: 1 2; }
        #player-right { width: 1fr; border: solid $primary; padding: 1 2; }
        #now-title { text-style: bold; color: $accent; }
        #visualizer { height: 1fr; content-align: center middle; color: $accent; text-style: bold; }
        #progress { height: 3; content-align: center middle; color: $success; }
        #loop-mode { dock: bottom; height: 3; color: $warning; }
        #lyrics-title { height: 1; content-align: center middle; }
        #lyrics { height: 1fr; content-align: center middle; }
        #status { height: 4; border: solid $accent; padding: 0 1; }
        """

        BINDINGS = [
            ("q", "quit", _t("\u9000\u51fa")),
            ("r", "reload", _t("\u91cd\u8f7d")),
            ("s", "stop", _t("\u505c\u6b62")),
            ("space", "toggle_pause", _t("\u6682\u505c/\u7ee7\u7eed")),
            ("n", "next_track", _t("\u4e0b\u4e00\u66f2")),
            ("p", "previous_track", _t("\u4e0a\u4e00\u66f2")),
            ("m", "toggle_loop_mode", _t("\u5faa\u73af\u6a21\u5f0f")),
            ("escape", "back_to_list", _t("\u8fd4\u56de\u5217\u8868")),
            ("b", "back_to_list", _t("\u8fd4\u56de\u5217\u8868")),
            ("v", "show_player", _t("\u64ad\u653e\u9875")),
            Binding("left", "previous_page_global", _t("\u4e0a\u4e00\u9875"), show=False, priority=True),
            Binding("right", "next_page_global", _t("\u4e0b\u4e00\u9875"), show=False, priority=True),
            ("pageup", "page_up", _t("\u4e0a\u7ffb\u9875")),
            ("pagedown", "page_down", _t("\u4e0b\u7ffb\u9875")),
            ("home", "list_home", _t("\u9876\u90e8")),
            ("end", "list_end", _t("\u5e95\u90e8")),
        ]

        TITLE = _t("TUI\u64ad\u653e\u5668")

        def __init__(self) -> None:
            super().__init__()
            self.client: Any | None = None
            self.player: Any | None = None
            self.current_category: Category | None = None
            self.current_track: Track | None = None
            self.current_resolved: ResolvedTrack | None = None
            self.current_tracks: list[Track] = []
            self.loop_mode: LoopMode = "list"
            self._visual_frame = 0
            self._visual_tick = 0
            self._lyric_lines = []
            self._cache = ResolvedTrackCache(Path(tempfile.gettempdir()) / "sad-tui-player-cache" / "resolved.json")
            self._executor = ThreadPoolExecutor(max_workers=2)
            self.current_page_url = BASE_URL
            self.previous_page_url: str | None = None
            self.next_page_url: str | None = None
            self._was_playing = False
            self._auto_advance_pending = False

        def compose(self) -> ComposeResult:
            yield Header()
            with Vertical(id="list-page"):
                with Horizontal(id="search-bar"):
                    yield Input(placeholder=_t("\u641c\u7d22\u6b4c\u66f2/\u6b4c\u624b"), id="search-input")
                    yield Button(_t("\u641c\u7d22"), id="search-button", variant="primary")
                    yield Button(_t("\u64ad\u653e\u9875"), id="player-button")
                with Horizontal(id="content"):
                    with Vertical(classes="pane", id="categories"):
                        yield Label(_t("\u5206\u7c7b/\u699c\u5355"))
                        yield ListView(id="category-list")
                    with Vertical(classes="pane", id="tracks"):
                        yield Label(_t("\u6b4c\u66f2\u5217\u8868"))
                        yield TrackListView(id="track-list")
            with Horizontal(id="player-page"):
                with Vertical(id="player-left"):
                    yield Static("-", id="now-title")
                    yield Static(_t("\u6b4c\u624b\uff1a-"), id="now-artist")
                    yield Static(_t("\u4e13\u8f91\uff1a\u672a\u77e5\u4e13\u8f91"), id="now-album")
                    yield Static("", id="visualizer")
                    yield Static(_progress_bar(0, None), id="progress")
                    yield Static(_t("\u5faa\u73af\u6a21\u5f0f\uff1a\u5217\u8868\u5faa\u73af"), id="loop-mode")
                with Vertical(id="player-right"):
                    yield Static(_t("\u6b4c\u8bcd"), id="lyrics-title")
                    yield Static(_t("\u6682\u65e0\u6b4c\u8bcd"), id="lyrics")
            yield Static(_t("\u542f\u52a8\u4e2d..."), id="status")
            yield Footer()

        async def on_mount(self) -> None:
            self._set_status(_t("\u6b63\u5728\u52a0\u8f7d\u54d5\u54d5\u54d5\u97f3\u4e50\u5206\u7c7b..."))
            try:
                from .client import GggClient
                from .player import Player

                self.client = GggClient()
                self.player = Player()
            except Exception as exc:
                self._set_status(f"{START_FAILED}{exc}")
                return
            self.set_interval(0.25, self._tick_visualizer)
            await self._load_url(BASE_URL, select_first_category=False)

        async def on_list_view_selected(self, event: ListView.Selected) -> None:
            payload = getattr(event.item, "data", None)
            if not isinstance(payload, _ListPayload):
                return
            if isinstance(payload.item, Category):
                await self._select_category(payload.item)
            else:
                await self._play_track(payload.item)

        async def on_input_submitted(self, event: Input.Submitted) -> None:
            if event.input.id == "search-input":
                await self._search(event.value)

        async def on_button_pressed(self, event: Button.Pressed) -> None:
            if event.button.id == "search-button":
                await self._search(self.query_one("#search-input", Input).value)
            elif event.button.id == "player-button":
                self.action_show_player()

        def _search_input_has_focus(self) -> bool:
            try:
                return bool(self.query_one("#search-input", Input).has_focus)
            except Exception:
                return False

        def _track_list_has_focus(self) -> bool:
            try:
                return bool(self.query_one("#track-list", ListView).has_focus)
            except Exception:
                return False

        async def action_next_page_global(self) -> None:
            if self._search_input_has_focus():
                return
            await self._load_next_page()

        async def action_previous_page_global(self) -> None:
            if self._search_input_has_focus():
                return
            await self._load_previous_page()

        async def _load_next_page_from_boundary(self) -> bool:
            track_list = self.query_one("#track-list", ListView)
            if not track_list.children or not self.next_page_url:
                return False
            if track_list.index != len(track_list.children) - 1:
                return False
            return await self._load_next_page()

        async def _load_previous_page_from_boundary(self) -> bool:
            track_list = self.query_one("#track-list", ListView)
            if not track_list.children or not self.previous_page_url or track_list.index != 0:
                return False
            return await self._load_previous_page()

        async def _load_next_page(self) -> bool:
            if not self.next_page_url:
                return False
            await self._load_url(self.next_page_url, select_first_category=False)
            self._set_track_index_after_refresh(0)
            return True

        async def _load_previous_page(self) -> bool:
            if not self.previous_page_url:
                return False
            await self._load_url(self.previous_page_url, select_first_category=False)
            new_list = self.query_one("#track-list", ListView)
            if new_list.children:
                self._set_track_index_after_refresh(len(new_list.children) - 1)
            return True

        async def action_reload(self) -> None:
            if self.current_category is not None:
                await self._select_category(self.current_category)
            else:
                await self._load_url(BASE_URL, select_first_category=False)

        async def action_stop(self) -> None:
            if self.player is None:
                self._set_status(_t("\u64ad\u653e\u5668\u5c1a\u672a\u521d\u59cb\u5316\u3002"))
                return
            try:
                await _maybe_await(self.player.stop())
            except Exception as exc:
                self._set_status(f"{STOP_FAILED}{exc}")
                return
            self.current_track = None
            self.current_resolved = None
            self._was_playing = False
            self._auto_advance_pending = False
            self._update_progress()
            self._set_status(_t("\u5df2\u505c\u6b62\u3002"))

        def action_toggle_pause(self) -> None:
            if self.player is None:
                self._set_status(_t("\u64ad\u653e\u5668\u5c1a\u672a\u521d\u59cb\u5316\u3002"))
                return
            try:
                paused = self.player.toggle_pause()
            except AttributeError:
                if getattr(self.player, "paused", False):
                    self.player.resume()
                    paused = False
                else:
                    self.player.pause()
                    paused = True
            self._was_playing = not paused
            self._set_status(_t("\u5df2\u6682\u505c\u3002") if paused else _t("\u7ee7\u7eed\u64ad\u653e\u3002"))

        async def action_next_track(self) -> None:
            await self._play_relative(1)

        async def action_previous_track(self) -> None:
            await self._play_relative(-1)

        def action_toggle_loop_mode(self) -> None:
            self.loop_mode = "single" if self.loop_mode == "list" else "list"
            self._update_loop_mode()
            self._set_status(f"{LOOP_MODE_LABEL}{self._loop_label()}")

        def action_back_to_list(self) -> None:
            self.query_one("#player-page").display = False
            self.query_one("#list-page").display = True
            self._set_status(_t("\u5df2\u8fd4\u56de\u5217\u8868\uff0c\u6b4c\u66f2\u7ee7\u7eed\u64ad\u653e\u3002"))

        def action_show_player(self) -> None:
            if self.current_resolved is None:
                self._set_status(_t("\u5f53\u524d\u6ca1\u6709\u6b63\u5728\u64ad\u653e\u7684\u6b4c\u66f2\u3002"))
                return
            self.query_one("#list-page").display = False
            self.query_one("#player-page").display = True

        def action_page_up(self) -> None:
            self.query_one("#track-list", ListView).action_page_up()

        def action_page_down(self) -> None:
            self.query_one("#track-list", ListView).action_page_down()

        def action_list_home(self) -> None:
            self.query_one("#track-list", ListView).index = 0

        def action_list_end(self) -> None:
            track_list = self.query_one("#track-list", ListView)
            if track_list.children:
                track_list.index = len(track_list.children) - 1

        async def on_unmount(self) -> None:
            self._executor.shutdown(wait=False, cancel_futures=True)
            if self.player is None:
                return
            try:
                await _maybe_await(self.player.stop())
            except Exception:
                pass

        async def _select_category(self, category: Category) -> None:
            self.current_category = category
            self._set_status(f"{LOADING_LABEL}{category.name}...")
            await self._load_url(category.url, select_first_category=False)

        async def _search(self, keyword: str) -> None:
            keyword = keyword.strip()
            if not keyword:
                self._set_status(_t("\u8bf7\u8f93\u5165\u641c\u7d22\u5173\u952e\u8bcd\u3002"))
                return
            if self.client is None:
                self._set_status(_t("\u5ba2\u6237\u7aef\u5c1a\u672a\u521d\u59cb\u5316\u3002"))
                return
            self._set_status(_t("\u6b63\u5728\u641c\u7d22\uff1a") + f"{keyword}...")
            try:
                tracks = await self._search_tracks(keyword, limit=SEARCH_LIMIT)
            except Exception as exc:
                self._set_status(_t("\u641c\u7d22\u5931\u8d25\uff1a") + str(exc))
                return

            self.current_category = None
            self.current_tracks = tracks
            if hasattr(self.client, "search_url"):
                self.current_page_url = self.client.search_url(keyword)
            self.previous_page_url = None
            self.next_page_url = None
            self._replace_items("#track-list", tracks)
            self._set_status(_t("\u641c\u7d22 ") + keyword + _t(" \u627e\u5230 ") + str(len(tracks)) + _t(" \u9996\u6b4c\u66f2\u3002\u9009\u62e9\u6b4c\u66f2\u5f00\u59cb\u64ad\u653e\u3002"))

        async def _search_tracks(self, keyword: str, *, limit: int) -> list[Track]:
            from .parser import extract_tracks

            tracks: list[Track] = []
            seen: set[str] = set()
            page = 1
            while len(tracks) < limit:
                html = await _maybe_await(self.client.search(keyword, range_=page))
                page_tracks = extract_tracks(html, BASE_URL)
                added = 0
                for track in page_tracks:
                    if track.url in seen:
                        continue
                    seen.add(track.url)
                    tracks.append(track)
                    added += 1
                    if len(tracks) >= limit:
                        break
                if not page_tracks or added == 0:
                    break
                page += 1
            return tracks

        async def _play_track(self, track: Track) -> None:
            if self.client is None or self.player is None:
                self._set_status(_t("\u5ba2\u6237\u7aef\u6216\u64ad\u653e\u5668\u5c1a\u672a\u521d\u59cb\u5316\u3002"))
                return
            self.current_track = track
            self._set_status(f"{RESOLVING_LABEL}{track.title}...")
            try:
                resolved = self._cache.get(track.url)
                if resolved is None:
                    resolved = await _maybe_await(_resolve_track(track, self.client))
                    self._cache.set(track.url, resolved)
                await _maybe_await(self.player.play(resolved.url))
            except Exception as exc:
                self._set_status(f"{PLAY_FAILED}{exc}")
                return
            self.current_resolved = resolved
            self._was_playing = True
            self._auto_advance_pending = False
            self._show_player_page(resolved)
            self._prefetch_next_track()
            suffix = f" - {resolved.artist}" if resolved.artist else ""
            self._set_status(f"{NOW_PLAYING_LABEL}{resolved.title or track.title}{suffix}")

        async def _play_relative(self, step: int) -> None:
            if not self.current_tracks:
                self._set_status(_t("\u5f53\u524d\u5217\u8868\u4e3a\u7a7a\u3002"))
                return
            if self.loop_mode == "single" and self.current_track is not None:
                await self._play_track(self.current_track)
                return
            try:
                index = self.current_tracks.index(self.current_track) if self.current_track else -1
            except ValueError:
                index = -1
            next_index = (index + step) % len(self.current_tracks)
            await self._play_track(self.current_tracks[next_index])

        async def _load_url(self, url: str, *, select_first_category: bool) -> None:
            if self.client is None:
                self._set_status(_t("\u5ba2\u6237\u7aef\u5c1a\u672a\u521d\u59cb\u5316\u3002"))
                return
            try:
                html = await _fetch_text(self.client, url)
            except Exception as exc:
                self._set_status(f"{LOAD_FAILED}{exc}")
                return

            from .parser import extract_categories, extract_page_links, extract_tracks, infer_rank_page_links

            self.current_page_url = url
            self.previous_page_url, self.next_page_url = extract_page_links(html, BASE_URL)
            if self.previous_page_url is None and self.next_page_url is None:
                self.previous_page_url, self.next_page_url = infer_rank_page_links(url, BASE_URL)

            if url == BASE_URL:
                categories = extract_categories(html, BASE_URL)
                self._replace_items("#category-list", categories)
                if select_first_category and categories:
                    await self._select_category(categories[0])

            tracks = extract_tracks(html, BASE_URL)
            self.current_tracks = tracks
            self._replace_items("#track-list", tracks)
            self._set_status(f"{LOADED_PREFIX}{len(tracks)}{LOADED_SUFFIX}")

        def _replace_items(self, selector: str, values: list[Category] | list[Track]) -> None:
            list_view = self.query_one(selector, ListView)
            list_view.clear()
            for value in values:
                list_view.append(_item_from_payload(value))

        def _show_player_page(self, resolved: ResolvedTrack) -> None:
            self.query_one("#list-page").display = False
            self.query_one("#player-page").display = True
            self.query_one("#now-title", Static).update(resolved.title or "-")
            self.query_one("#now-artist", Static).update(f"{ARTIST_LABEL}{resolved.artist or '-'}")
            self.query_one("#now-album", Static).update(f"{ALBUM_LABEL}{resolved.album or UNKNOWN_ALBUM}")
            self._lyric_lines = parse_lyrics(resolved.lyrics)
            self._update_lyrics()
            self._update_loop_mode()

        def _prefetch_next_track(self) -> None:
            if self.player is None or self.client is None or self.loop_mode == "single" or not self.current_tracks or self.current_track is None:
                return
            try:
                index = self.current_tracks.index(self.current_track)
            except ValueError:
                return
            next_track = self.current_tracks[(index + 1) % len(self.current_tracks)]

            def work() -> None:
                resolved = self._cache.get(next_track.url)
                if resolved is None:
                    resolved = _resolve_track_sync(next_track, self.client)
                    self._cache.set(next_track.url, resolved)
                self.player.prefetch(resolved.url)

            self._executor.submit(work)

        def _tick_visualizer(self) -> None:
            try:
                visualizer = self.query_one("#visualizer", Static)
            except Exception:
                return
            if self._player_is_playing():
                self._visual_tick = (self._visual_tick + 1) % EMOJI_VISUAL_TICKS_PER_FRAME
                if self._visual_tick == 0:
                    self._visual_frame = (self._visual_frame + 1) % len(EMOJI_VISUAL_FRAMES)
            visualizer.update(EMOJI_VISUAL_FRAMES[self._visual_frame % len(EMOJI_VISUAL_FRAMES)])
            self._update_progress()
            self._update_lyrics()
            self._check_auto_advance()
        def _check_auto_advance(self) -> None:
            if self.player is None or self.current_track is None or self.current_resolved is None:
                self._was_playing = False
                return
            is_playing = self._player_is_playing()
            is_paused = bool(getattr(self.player, "is_paused", getattr(self.player, "paused", False)))
            if self._was_playing and not is_playing and not is_paused and not self._auto_advance_pending:
                self._auto_advance_pending = True
                self.call_later(self._auto_advance_after_track_end)
            self._was_playing = is_playing

        async def _auto_advance_after_track_end(self) -> None:
            if self.player is None or self.current_track is None or self.current_resolved is None:
                self._auto_advance_pending = False
                return
            if self._player_is_playing():
                self._auto_advance_pending = False
                return
            try:
                await self._play_relative(1)
            finally:
                self._auto_advance_pending = False

        def _player_is_playing(self) -> bool:
            if self.player is None or not hasattr(self.player, "is_playing"):
                return False
            try:
                return bool(self.player.is_playing())
            except Exception:
                return False

        def _update_lyrics(self) -> None:
            if self.current_resolved is None:
                return
            seconds = 0.0
            if self.player is not None and hasattr(self.player, "position_seconds"):
                seconds = self.player.position_seconds()
            text = format_lyrics(self.current_resolved.lyrics, current_seconds=seconds, lines=self._lyric_lines)
            self.query_one("#lyrics", Static).update(text)

        def _update_progress(self) -> None:
            seconds = 0.0
            duration: float | None = None
            if self.player is not None and hasattr(self.player, "position_seconds"):
                try:
                    seconds = self.player.position_seconds()
                except Exception:
                    seconds = 0.0
            if self.player is not None and hasattr(self.player, "duration_seconds"):
                try:
                    duration = self.player.duration_seconds()
                except Exception:
                    duration = None
            self.query_one("#progress", Static).update(_progress_bar(seconds, duration))

        def _update_loop_mode(self) -> None:
            self.query_one("#loop-mode", Static).update(f"{LOOP_MODE_LABEL}{self._loop_label()}")

        def _loop_label(self) -> str:
            return _t("\u5355\u66f2\u5faa\u73af") if self.loop_mode == "single" else _t("\u5217\u8868\u5faa\u73af")

        def _set_status(self, message: str) -> None:
            self.query_one("#status", Static).update(message)

        def _set_track_index_after_refresh(self, index: int) -> None:
            def set_index() -> None:
                track_list = self.query_one("#track-list", ListView)
                if track_list.children:
                    track_list.index = max(0, min(index, len(track_list.children) - 1))

            self.call_after_refresh(set_index)

    return SadTuiPlayerApp


def _resolve_track_sync(track: Track, client: Any) -> ResolvedTrack:
    from .resolver import resolve_thread

    try:
        return resolve_thread(client, track)
    except TypeError:
        return resolve_thread(client, track.url)


def main() -> int:
    try:
        app_class = _build_app_class()
    except RuntimeError as exc:
        try:
            from rich.console import Console

            Console(stderr=True).print(f"[red]{exc}[/red]")
        except ModuleNotFoundError:
            print(str(exc), file=sys.stderr)
        return 1
    app_class().run()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
