"""Linux media controller using MPRIS D-Bus interface."""

import asyncio
import logging
import os
import subprocess
import threading
from typing import Any, Optional
from urllib.parse import unquote, urlparse

from ..models import MediaState, MediaStatus
from .media_controller import MediaController

logger = logging.getLogger(__name__)

# Cap remote artwork downloads so a hostile / huge Spotify image can't
# blow up RAM. Real album art rarely exceeds ~2 MB; 8 MB is a comfortable
# upper bound that also covers loss-less PNGs.
_MAX_ART_BYTES = 8 * 1024 * 1024
_ART_FETCH_TIMEOUT = 5.0  # seconds

# Linux-specific imports
try:
    import dbus
    from dbus.mainloop.glib import DBusGMainLoop

    DBUS_AVAILABLE = True
except ImportError:
    DBUS_AVAILABLE = False
    logger.warning("D-Bus libraries not available")


class LinuxMediaController(MediaController):
    """Media controller for Linux using MPRIS D-Bus interface."""

    MPRIS_PATH = "/org/mpris/MediaPlayer2"
    MPRIS_INTERFACE = "org.mpris.MediaPlayer2.Player"
    MPRIS_PREFIX = "org.mpris.MediaPlayer2."
    PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties"

    def __init__(self):
        if not DBUS_AVAILABLE:
            raise RuntimeError(
                "Linux media control requires dbus-python package. "
                "Install with: sudo apt-get install python3-dbus"
            )
        # The session-bus connection is deferred until first use. Connecting
        # in __init__ raised during app startup whenever the user's session
        # bus wasn't ready yet — common under systemd (service starts
        # before logind set up /run/user/<uid>/bus), under SSH-without-X11,
        # and in headless CI. Failing here killed the whole lifespan; now
        # MPRIS calls return "idle" until the bus appears, and other
        # endpoints (health, scripts, browser, …) keep working.
        self._bus_lock = threading.Lock()
        self._bus = None  # type: ignore[assignment]
        self._bus_init_logged = False
        # Cached art bytes keyed by the mpris:artUrl currently in flight.
        # Lock guards the swap from the status thread vs the artwork handler.
        self._art_lock = threading.Lock()
        self._art_url: Optional[str] = None
        self._art_bytes: Optional[bytes] = None

    def _get_bus(self):
        """Lazily connect to the session bus; returns None if unavailable."""
        if self._bus is not None:
            return self._bus
        with self._bus_lock:
            if self._bus is not None:
                return self._bus
            try:
                DBusGMainLoop(set_as_default=True)
                self._bus = dbus.SessionBus()
                logger.info("Connected to D-Bus session bus")
                return self._bus
            except Exception as e:
                # Log once at INFO to avoid log spam if every status poll fails.
                if not self._bus_init_logged:
                    logger.info(
                        "D-Bus session bus not available (%s). "
                        "MPRIS calls will return 'idle' until DBUS_SESSION_BUS_ADDRESS"
                        " is set and the bus is reachable. Under systemd, ensure"
                        " `loginctl enable-linger <user>` is set.",
                        e,
                    )
                    self._bus_init_logged = True
                return None

    def _get_active_player(self) -> Optional[str]:
        """Find an active MPRIS media player on the bus."""
        bus = self._get_bus()
        if bus is None:
            return None
        try:
            bus_names = bus.list_names()
            mpris_players = [
                name for name in bus_names if name.startswith(self.MPRIS_PREFIX)
            ]

            if not mpris_players:
                return None

            # Prefer players that are currently playing
            for player in mpris_players:
                try:
                    proxy = self._bus.get_object(player, self.MPRIS_PATH)
                    props = dbus.Interface(proxy, self.PROPERTIES_INTERFACE)
                    status = props.Get(self.MPRIS_INTERFACE, "PlaybackStatus")
                    if status == "Playing":
                        return player
                except Exception:
                    continue

            # Return the first available player
            return mpris_players[0]

        except Exception as e:
            logger.error(f"Failed to get active player: {e}")
            return None

    def _get_player_interface(self, player_name: str):
        """Get the MPRIS player interface."""
        proxy = self._bus.get_object(player_name, self.MPRIS_PATH)
        return dbus.Interface(proxy, self.MPRIS_INTERFACE)

    def _get_properties_interface(self, player_name: str):
        """Get the properties interface for a player."""
        proxy = self._bus.get_object(player_name, self.MPRIS_PATH)
        return dbus.Interface(proxy, self.PROPERTIES_INTERFACE)

    def _get_property(self, player_name: str, property_name: str) -> Any:
        """Get a property from the player."""
        try:
            props = self._get_properties_interface(player_name)
            return props.Get(self.MPRIS_INTERFACE, property_name)
        except Exception as e:
            logger.debug(f"Failed to get property {property_name}: {e}")
            return None

    def _get_volume_pulseaudio(self) -> tuple[int, bool]:
        """Get volume using pactl (PulseAudio/PipeWire)."""
        try:
            # Get default sink volume
            result = subprocess.run(
                ["pactl", "get-sink-volume", "@DEFAULT_SINK@"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            if result.returncode == 0:
                # Parse volume from output like "Volume: front-left: 65536 / 100% / 0.00 dB"
                for part in result.stdout.split("/"):
                    if "%" in part:
                        volume = int(part.strip().rstrip("%"))
                        break
                else:
                    volume = 100
            else:
                volume = 100

            # Get mute status
            result = subprocess.run(
                ["pactl", "get-sink-mute", "@DEFAULT_SINK@"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            muted = "yes" in result.stdout.lower() if result.returncode == 0 else False

            return volume, muted

        except Exception as e:
            logger.error(f"Failed to get volume via pactl: {e}")
            return 100, False

    def _set_volume_pulseaudio(self, volume: int) -> bool:
        """Set volume using pactl."""
        try:
            result = subprocess.run(
                ["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{volume}%"],
                capture_output=True,
                timeout=5,
            )
            return result.returncode == 0
        except Exception as e:
            logger.error(f"Failed to set volume: {e}")
            return False

    def _toggle_mute_pulseaudio(self) -> bool:
        """Toggle mute using pactl, returns new mute state."""
        try:
            result = subprocess.run(
                ["pactl", "set-sink-mute", "@DEFAULT_SINK@", "toggle"],
                capture_output=True,
                timeout=5,
            )
            if result.returncode == 0:
                _, muted = self._get_volume_pulseaudio()
                return muted
            return False
        except Exception as e:
            logger.error(f"Failed to toggle mute: {e}")
            return False

    def _sync_get_status(self) -> MediaStatus:
        """Synchronous status read (called from a worker thread)."""
        status = MediaStatus()

        volume, muted = self._get_volume_pulseaudio()
        status.volume = volume
        status.muted = muted

        player_name = self._get_active_player()
        if player_name is None:
            status.state = MediaState.IDLE
            return status

        playback_status = self._get_property(player_name, "PlaybackStatus")
        if playback_status == "Playing":
            status.state = MediaState.PLAYING
        elif playback_status == "Paused":
            status.state = MediaState.PAUSED
        elif playback_status == "Stopped":
            status.state = MediaState.STOPPED
        else:
            status.state = MediaState.IDLE

        metadata = self._get_property(player_name, "Metadata")
        if metadata:
            status.title = str(metadata.get("xesam:title", "")) or None
            artists = metadata.get("xesam:artist", [])
            if artists:
                status.artist = str(artists[0]) if isinstance(artists, list) else str(artists)
            status.album = str(metadata.get("xesam:album", "")) or None
            art_url = str(metadata.get("mpris:artUrl", "")) or None
            status.album_art_url = art_url
            # Invalidate cached bytes when the track changes. Real fetch
            # happens lazily in get_album_art() so the status hot path
            # never blocks on HTTP.
            with self._art_lock:
                if art_url != self._art_url:
                    self._art_url = art_url
                    self._art_bytes = None
            length = metadata.get("mpris:length", 0)
            if length:
                status.duration = int(length) / 1_000_000

        position = self._get_property(player_name, "Position")
        if position is not None:
            status.position = int(position) / 1_000_000

        status.source = player_name.replace(self.MPRIS_PREFIX, "")
        return status

    async def get_status(self) -> MediaStatus:
        """Get current media playback status (off the event loop)."""
        # pactl + DBus calls each take 5-100ms on a Pi and would block every
        # other coroutine on the server. Run them in a worker thread.
        return await asyncio.to_thread(self._sync_get_status)

    def _call_player(self, method_name: str) -> bool:
        player_name = self._get_active_player()
        if player_name is None:
            return False
        try:
            player = self._get_player_interface(player_name)
            getattr(player, method_name)()
            return True
        except Exception as e:
            logger.error(f"Failed to call player.{method_name}: {e}")
            return False

    async def play(self) -> bool:
        return await asyncio.to_thread(self._call_player, "Play")

    async def pause(self) -> bool:
        return await asyncio.to_thread(self._call_player, "Pause")

    async def stop(self) -> bool:
        return await asyncio.to_thread(self._call_player, "Stop")

    async def next_track(self) -> bool:
        return await asyncio.to_thread(self._call_player, "Next")

    async def previous_track(self) -> bool:
        return await asyncio.to_thread(self._call_player, "Previous")

    async def set_volume(self, volume: int) -> bool:
        return await asyncio.to_thread(self._set_volume_pulseaudio, volume)

    async def toggle_mute(self) -> bool:
        return await asyncio.to_thread(self._toggle_mute_pulseaudio)

    def _sync_seek(self, position: float) -> bool:
        player_name = self._get_active_player()
        if player_name is None:
            return False
        try:
            player = self._get_player_interface(player_name)
            player.SetPosition(
                self._get_property(player_name, "Metadata").get("mpris:trackid", "/"),
                int(position * 1_000_000),
            )
            return True
        except Exception as e:
            logger.error(f"Failed to seek: {e}")
            return False

    async def seek(self, position: float) -> bool:
        return await asyncio.to_thread(self._sync_seek, position)

    async def open_file(self, file_path: str) -> bool:
        """Open a media file with the default system player (Linux).

        Uses xdg-open to open the file with the default application.

        Args:
            file_path: Absolute path to the media file

        Returns:
            True if successful, False otherwise
        """
        try:
            process = await asyncio.create_subprocess_exec(
                'xdg-open', file_path,
                stdout=asyncio.subprocess.DEVNULL,
                stderr=asyncio.subprocess.DEVNULL
            )
            await process.wait()
            logger.info(f"Opened file with default player: {file_path}")
            return True
        except Exception as e:
            logger.error(f"Failed to open file {file_path}: {e}")
            return False

    def _fetch_art_sync(self, url: str) -> Optional[bytes]:
        """Resolve an ``mpris:artUrl`` to raw bytes (file://, http(s)://).

        Other schemes (data:, ftp:, …) are rejected — we only support the
        two cases real-world MPRIS providers use. The HTTP path is capped
        at _MAX_ART_BYTES and the file path is read with a size guard so a
        symlink to /dev/zero can't OOM the server.
        """
        try:
            parsed = urlparse(url)
        except ValueError:
            return None

        scheme = parsed.scheme.lower()
        if scheme == "file":
            path = unquote(parsed.path)
            try:
                size = os.stat(path).st_size
                if size <= 0 or size > _MAX_ART_BYTES:
                    return None
                with open(path, "rb") as f:
                    return f.read(_MAX_ART_BYTES)
            except OSError as e:
                logger.debug("Could not read local art %s: %s", path, e)
                return None

        if scheme in ("http", "https"):
            import urllib.error
            import urllib.request

            req = urllib.request.Request(url, headers={"User-Agent": "media-server/0.x"})
            try:
                with urllib.request.urlopen(req, timeout=_ART_FETCH_TIMEOUT) as resp:
                    # Cap reads to defend against unbounded responses.
                    return resp.read(_MAX_ART_BYTES + 1)[:_MAX_ART_BYTES] or None
            except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
                logger.debug("Could not fetch remote art %s: %s", url, e)
                return None

        logger.debug("Unsupported art URL scheme: %s", scheme)
        return None

    async def get_album_art(self) -> Optional[bytes]:
        """Return cached MPRIS art, fetching on first access per track."""
        with self._art_lock:
            url = self._art_url
            cached = self._art_bytes
        if cached is not None:
            return cached
        if not url:
            return None
        data = await asyncio.to_thread(self._fetch_art_sync, url)
        # Store even on None so we don't re-hammer a 404 every second.
        with self._art_lock:
            if url == self._art_url:
                self._art_bytes = data
        return data
