"""Audio playback control using python-mpv."""

from __future__ import annotations

import asyncio
import locale
import logging
import os
import sys
import threading
import time
from collections.abc import Callable
from enum import StrEnum, auto
from pathlib import Path
from typing import Any

if sys.platform == "win32":
    # python-mpv uses ctypes to find mpv DLLs on PATH.  Package managers like
    # scoop/chocolatey install mpv to an app directory that isn't directly on
    # PATH (only shims are), so we locate the DLL and add its directory.
    _MPV_DLL_NAMES = ("libmpv-2.dll", "mpv-2.dll", "mpv-1.dll")

    def _dll_on_path() -> bool:
        for d in os.environ.get("PATH", "").split(os.pathsep):
            if d and any((Path(d) / n).exists() for n in _MPV_DLL_NAMES):
                return True
        return False

    if not _dll_on_path():
        _home = Path.home()
        _scoop = Path(os.environ.get("SCOOP", str(_home / "scoop")))
        _candidates = [
            _scoop / "apps" / "mpv" / "current",
            _scoop / "apps" / "mpv-git" / "current",
            Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "mpv",
            Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")) / "mpv",
        ]
        for _d in _candidates:
            if _d.is_dir() and any((_d / n).exists() for n in _MPV_DLL_NAMES):
                os.environ["PATH"] = str(_d) + os.pathsep + os.environ.get("PATH", "")
                break

try:
    import mpv
except OSError as _exc:
    if sys.platform == "win32":
        raise OSError(
            "Cannot find mpv library (libmpv-2.dll).\n\n"
            "If you installed mpv via scoop, the DLL may be missing from PATH.\n"
            "Try:  scoop install mpv\n"
            "Then verify the DLL exists:  dir %USERPROFILE%\\scoop\\apps\\mpv\\current\\libmpv-2.dll\n\n"
            "If the DLL is missing, install the mpv-dev or mpv-git package, or download\n"
            "libmpv from https://sourceforge.net/projects/mpv-player-windows/files/libmpv/\n"
            "and place the DLL in the mpv directory or somewhere on your PATH."
        ) from _exc
    raise

logger = logging.getLogger(__name__)


class PlayerEvent(StrEnum):
    """Events emitted by the Player."""

    TRACK_CHANGE = auto()
    TRACK_END = auto()
    POSITION_CHANGE = auto()
    ERROR = auto()
    VOLUME_CHANGE = auto()
    PAUSE_CHANGE = auto()


# Type alias for callback functions.
PlayerCallback = Callable[..., Any]


class Player:
    """Singleton audio player wrapping python-mpv.

    Configured for audio-only YouTube Music playback. Stream URLs are
    resolved externally (via StreamResolver) and passed directly to mpv.
    """

    _instance: Player | None = None
    _lock = threading.Lock()

    def __new__(cls) -> Player:
        with cls._lock:
            if cls._instance is None:
                cls._instance = super().__new__(cls)
                cls._instance._initialized = False
            return cls._instance

    def __init__(self) -> None:
        if self._initialized:
            return
        self._initialized = True

        self._current_track: dict | None = None
        self._callbacks: dict[PlayerEvent, list[PlayerCallback]] = {
            event: [] for event in PlayerEvent
        }
        self._loop: asyncio.AbstractEventLoop | None = None
        # Counter for end-file events to ignore.  Incremented when we
        # intentionally replace/stop a track (so the resulting end-file
        # from mpv doesn't trigger auto-advance).
        self._end_file_skip: int = 0
        self._skip_lock = threading.Lock()
        self._last_position_dispatch: float = 0.0
        # Strong references to dispatched async tasks so they aren't GC'd
        # before execution (the classic asyncio.create_task footgun).
        self._background_tasks: set[asyncio.Task] = set()

        self._mpv = self._init_mpv()

    def _init_mpv(self) -> mpv.MPV:
        """Create and configure a new mpv instance.

        Sets LC_NUMERIC locale, creates the MPV process, enables gapless
        audio if configured, and registers property observers and the
        end-file event callback.

        Prerequisites: ``_end_file_skip``, ``_skip_lock``, and ``_callbacks``
        must already be initialised on *self* before calling this method.
        """
        # mpv segfaults if LC_NUMERIC is not C.  Textual's async runtime
        # resets locale, so we must force it immediately before mpv init.
        # On Windows, Python 3.12+ links ucrtbase.dll — calling setlocale on
        # the legacy msvcrt.dll does nothing.  locale.setlocale() targets the
        # correct CRT on every platform, but on Linux it can interact badly
        # with thread-local locale, so we use ctypes there.
        import ctypes
        import ctypes.util

        if sys.platform == "win32":
            locale.setlocale(locale.LC_NUMERIC, "C")
        else:
            if sys.platform == "darwin":
                _libc_name = "libSystem.B.dylib"
            else:
                _libc_name = ctypes.util.find_library("c") or "libc.so.6"
            _libc = ctypes.CDLL(_libc_name)
            _libc.setlocale.restype = ctypes.c_char_p
            _libc.setlocale.argtypes = [ctypes.c_int, ctypes.c_char_p]
            _libc.setlocale(locale.LC_NUMERIC, b"C")

        from ytm_player.config.settings import get_settings

        settings = get_settings()

        instance = mpv.MPV(
            ytdl=False,
            video=False,
            terminal=False,
            input_default_bindings=False,
            input_vo_keyboard=False,
        )

        # Enable gapless playback if configured.
        if settings.playback.gapless:
            try:
                instance["gapless-audio"] = "yes"
            except Exception:
                logger.debug("Failed to enable gapless-audio")

        # Register mpv property observers and event handlers.
        instance.observe_property("time-pos", self._on_time_pos_change)
        instance.observe_property("pause", self._on_pause_change)

        @instance.event_callback("end-file")
        def _on_end_file(event: Any) -> None:
            # The reason code lives on event.data (MpvEventEndFile), not
            # on the MpvEvent itself.  Extract the integer reason:
            #   0 = EOF, 1 = RESTARTED, 2 = ABORTED, 3 = QUIT, 4 = ERROR, 5 = REDIRECT
            data = getattr(event, "data", None) if event else None
            reason = getattr(data, "reason", None) if data else None
            with self._skip_lock:
                if self._end_file_skip > 0:
                    self._end_file_skip -= 1
                    return
                if self._current_track is None:
                    return  # Already idle, nothing to do.
                # Capture track info before clearing — the app needs it
                # for history logging and autoplay decisions.
                ended_track = self._current_track
                # Clear so play() won't increment _end_file_skip for
                # an already-idle mpv.
                self._current_track = None
            # Only auto-advance on natural EOF (0).  Errors (4) are
            # dispatched separately.  Everything else (stop, redirect,
            # quit, restart) is intentional — ignore.
            if reason == 4:  # ERROR
                self._dispatch(PlayerEvent.ERROR, "stream error")
            elif reason is None or reason == 0:  # EOF
                self._dispatch(PlayerEvent.TRACK_END, {"reason": reason, "track": ended_track})

        return instance

    def _get_loop(self) -> asyncio.AbstractEventLoop | None:
        """Get the event loop, using the cached reference.

        Only ``set_event_loop()`` should write ``self._loop``.  If the
        cached loop is closed we return None but never clobber the field
        — otherwise a transient closed-loop check from mpv's callback
        thread would permanently destroy the reference.
        """
        loop = self._loop
        if loop is None or loop.is_closed():
            return None
        return loop

    def set_event_loop(self, loop: asyncio.AbstractEventLoop) -> None:
        """Explicitly set the asyncio event loop for callback dispatch."""
        self._loop = loop

    # ── Callback registration ───────────────────────────────────────

    def on(self, event: PlayerEvent, callback: PlayerCallback) -> None:
        """Register a callback for a player event (no duplicates)."""
        if callback not in self._callbacks[event]:
            self._callbacks[event].append(callback)

    def off(self, event: PlayerEvent, callback: PlayerCallback) -> None:
        """Unregister a callback for a player event."""
        try:
            self._callbacks[event].remove(callback)
        except ValueError:
            pass

    def clear_callbacks(self) -> None:
        """Remove all registered callbacks."""
        for event in PlayerEvent:
            self._callbacks[event].clear()

    def _dispatch(self, event: PlayerEvent, *args: Any) -> None:
        """Dispatch an event to all registered callbacks.

        If an asyncio event loop is available, callbacks are scheduled
        via call_soon_threadsafe to bridge from mpv's thread.
        """
        loop = self._get_loop()

        def _schedule_async(coro_fn: Any, call_args: tuple) -> None:
            """Create a task for an async callback with error handling."""

            async def _safe_wrapper() -> None:
                try:
                    await coro_fn(*call_args)
                except Exception:
                    logger.debug("Async callback error for %s", event, exc_info=True)

            task = asyncio.create_task(_safe_wrapper())
            self._background_tasks.add(task)
            task.add_done_callback(self._background_tasks.discard)

        def _safe_sync(sync_fn: Any, call_args: tuple) -> None:
            """Run a sync callback with error handling on the event loop."""
            try:
                sync_fn(*call_args)
            except Exception:
                logger.debug("Sync callback error for %s", event, exc_info=True)

        for cb in list(self._callbacks[event]):
            try:
                if loop is not None and not loop.is_closed():
                    if asyncio.iscoroutinefunction(cb):
                        loop.call_soon_threadsafe(_schedule_async, cb, args)
                    else:
                        loop.call_soon_threadsafe(_safe_sync, cb, args)
                else:
                    # No loop: only sync callbacks can run; skip async ones.
                    if asyncio.iscoroutinefunction(cb):
                        logger.warning(
                            "Dropping async %s callback — no event loop available", event
                        )
                    else:
                        cb(*args)
            except Exception:
                logger.debug("Error in %s callback", event, exc_info=True)

    # ── mpv observers ───────────────────────────────────────────────

    def _on_time_pos_change(self, _name: str, value: float | None) -> None:
        if value is not None:
            # Throttle position dispatches to ~2 per second (lyrics + UI).
            now = time.monotonic()
            if now - self._last_position_dispatch < 0.4:
                return
            self._last_position_dispatch = now
            self._dispatch(PlayerEvent.POSITION_CHANGE, value)

    def _on_pause_change(self, _name: str, value: bool | None) -> None:
        if value is not None:
            self._dispatch(PlayerEvent.PAUSE_CHANGE, value)

    # ── Properties ──────────────────────────────────────────────────

    @property
    def is_playing(self) -> bool:
        """True if a track is loaded and not paused."""
        try:
            return not self._mpv.pause and not self._mpv.idle_active
        except mpv.ShutdownError:
            return False

    @property
    def is_paused(self) -> bool:
        try:
            return bool(self._mpv.pause)
        except mpv.ShutdownError:
            return False

    @property
    def current_track(self) -> dict | None:
        return self._current_track

    @property
    def position(self) -> float:
        """Current playback position in seconds."""
        try:
            pos = self._mpv.time_pos
            return float(pos) if pos is not None else 0.0
        except mpv.ShutdownError:
            return 0.0

    @property
    def duration(self) -> float:
        """Duration of the current track in seconds."""
        try:
            dur = self._mpv.duration
            return float(dur) if dur is not None else 0.0
        except mpv.ShutdownError:
            return 0.0

    @property
    def volume(self) -> int:
        """Current volume level (0-100)."""
        try:
            vol = self._mpv.volume
            return int(vol) if vol is not None else 0
        except mpv.ShutdownError:
            return 0

    # ── Playback control ────────────────────────────────────────────

    async def play(self, url: str, track_info: dict) -> None:
        """Play a stream URL with associated track metadata."""
        with self._skip_lock:
            if self._current_track is not None:
                # A track is still playing — mpv will fire end-file for it
                # when we load the new URL.  Tell the callback to ignore it.
                self._end_file_skip += 1
            self._current_track = track_info
        try:
            await asyncio.to_thread(self._play_sync, url)
            self._dispatch(PlayerEvent.TRACK_CHANGE, track_info)
        except Exception as exc:
            self._current_track = None
            logger.error("Failed to play %s: %s", track_info.get("video_id", "?"), exc)
            self._dispatch(PlayerEvent.ERROR, exc)

    def _play_sync(self, url: str) -> None:
        """Synchronous mpv play call."""
        try:
            self._mpv.play(url)
            self._mpv.pause = False
        except mpv.ShutdownError:
            logger.warning("mpv crashed, attempting recovery...")
            if self._try_recover():
                self._mpv.play(url)
                self._mpv.pause = False
            else:
                raise RuntimeError("mpv recovery failed")

    async def pause(self) -> None:
        self._mpv.pause = True

    async def resume(self) -> None:
        self._mpv.pause = False

    async def toggle_pause(self) -> None:
        self._mpv.pause = not self._mpv.pause

    async def stop(self) -> None:
        if self._current_track is not None:
            with self._skip_lock:
                self._end_file_skip += 1
        self._mpv.stop()
        self._current_track = None

    async def seek(self, seconds: float) -> None:
        """Seek relative to the current position."""
        try:
            self._mpv.seek(seconds, reference="relative")
        except mpv.ShutdownError:
            pass

    async def seek_absolute(self, seconds: float) -> None:
        """Seek to an absolute position in seconds."""
        try:
            self._mpv.seek(seconds, reference="absolute")
        except mpv.ShutdownError:
            pass

    async def seek_start(self) -> None:
        """Seek to the beginning of the track."""
        await self.seek_absolute(0.0)

    async def set_volume(self, level: int) -> None:
        """Set volume to a specific level (clamped to 0-100)."""
        level = max(0, min(100, level))
        self._mpv.volume = level
        self._dispatch(PlayerEvent.VOLUME_CHANGE, level)

    async def change_volume(self, delta: int) -> None:
        """Adjust volume by a relative amount."""
        await self.set_volume(self.volume + delta)

    async def mute(self) -> None:
        """Toggle mute state."""
        self._mpv.mute = not self._mpv.mute

    # ── Health & Recovery ────────────────────────────────────────────

    @property
    def is_healthy(self) -> bool:
        """Check if the mpv instance is responsive."""
        try:
            _ = self._mpv.idle_active
            return True
        except (mpv.ShutdownError, OSError):
            return False

    def _try_recover(self) -> bool:
        """Attempt to re-create the mpv instance after a crash."""
        try:
            logger.info("Re-initializing mpv instance...")
            with self._skip_lock:
                self._end_file_skip = 0
            self._mpv = self._init_mpv()

            # Restore volume if possible.
            if self._loop and not self._loop.is_closed():
                try:
                    from ytm_player.config.settings import get_settings

                    self._mpv.volume = get_settings().playback.default_volume
                except Exception:
                    self._mpv.volume = 80

            logger.info("mpv recovery successful")
            return True
        except Exception:
            logger.exception("mpv recovery failed")
            return False

    # ── Lifecycle ───────────────────────────────────────────────────

    def shutdown(self) -> None:
        """Terminate the mpv instance. Call on application exit."""
        try:
            self._mpv.terminate()
        except mpv.ShutdownError:
            pass
        with Player._lock:
            Player._instance = None
