#!/usr/bin/env python3
"""
MPD → OBS WebSocket bridge.

Re-broadcasts MPD playback state as `mpd:state` CustomEvents on the OBS
WebSocket bus. The loading overlay (loading/index.html) already listens for
those events to drive its now-playing terminal line — this script replaces
the custom music/index.html browser daemon as the source of truth.

Payload shape matches the original daemon's broadcastState() so the overlay
needs zero changes:
    { _type, index, total, title, artist, trackTitle, album, year, genre,
      file, currentTime, duration, paused,
      nextTitle, nextArtist, nextTrackTitle, nextFile, nextTitles,
      coverPath, coverHash,
      audio: { format, samplerate, bits, channels, bitrate } }
File-level audio info comes from MPD `status` (`audio` is "rate:bits:ch",
`bitrate` is kbps) and the file extension (FORMAT label — FLAC/MP3/OPUS/…).
Lossless tracks (FLAC, ALAC, WAV) report their actual bit depth; MP3/Opus
report their decode word size, which is informational only.
Next-track fields are populated when MPD reports `nextsong` in its status.
`nextTitles` is up to 3 display strings peeked from the queue starting at
the current song's successor — used by the Music Box overlay to show
"coming up" lines. The single `nextTitle`/`nextArtist`/etc. fields mirror
nextTitles[0] for overlays that only need one.

Library stats (artists/albums/songs/db_playtime) come from MPD `stats`.
That command is cheap but not free, and the results barely change between
ticks (only when files are added/removed) — so we cache and refresh every
STATS_REFRESH_SEC. Exposed under the `stats` key:
    stats: { artists, albums, songs, dbPlaytime }   # dbPlaytime is human-formatted

Cover art resolution (run once per track change, cached):
    1. Sibling file in song's directory matching folder/cover/front ×
       jpg/jpeg/png/webp (case-insensitive). Covers the Kolekcja layout
       where ~/HDD/Music/Kolekcja symlinks to /media/nvme/Music/Kolekcja
       and each album dir ships a folder.jpg.
    2. MPD `readpicture` then `albumart` commands for embedded art (yt-dlp
       --embed-thumbnail tracks under the rest of ~/HDD/Music).
Bytes are written to ./cover.jpg (next to this script). Overlays load
`<img src="../bridges/cover.jpg?v=<coverHash>">` so a hash bump cache-busts
cleanly. coverHash null = no art available; overlays should show a
placeholder. Reads MPD library root from MPD_MUSIC_DIR env var, default
~/HDD/Music (matches mpd.conf).

Reads OBS WS credentials from ../vendor/obs-config.js (auto-generated by
scripts/setup.sh from plugin_config/obs-websocket/config.json — single source of truth).

Run via systemd:  systemctl --user enable --now obs-mpd-bridge.service
Tail logs with:   journalctl --user -u obs-mpd-bridge -f
"""

import asyncio
import base64
import hashlib
import json
import os
import re
import sys
import time
from pathlib import Path

import websockets
from mpd.asyncio import MPDClient

HERE          = Path(__file__).resolve().parent
OBS_CONFIG_JS = HERE.parent / "vendor" / "obs-config.js"
MPD_HOST      = os.environ.get("MPD_HOST", "localhost")
MPD_PORT      = int(os.environ.get("MPD_PORT", "6600"))
MPD_MUSIC_DIR = Path(os.environ.get("MPD_MUSIC_DIR", "~/HDD/Music")).expanduser()
TICK_SECONDS  = 1.0   # broadcast cadence; matches the old daemon's setInterval(broadcastState, 1000)
STATS_REFRESH_SEC = 30.0  # how often to re-poll MPD `stats` for library counts
COVER_OUT     = HERE / "cover.jpg"  # canonical cover bytes; overlays cache-bust via ?v=<coverHash>
COVER_STEMS   = ("folder", "cover", "front")
COVER_EXTS    = (".jpg", ".jpeg", ".png", ".webp")


# ─── config + auth ──────────────────────────────────────────────────────────

def load_obs_config(path: Path):
    """Extract `url` and `password` from the JS config file by regex —
    avoids requiring a JS parser for two trivial string assignments."""
    text = path.read_text()
    url_m = re.search(r"url:\s*'([^']+)'", text)
    pw_m  = re.search(r"password:\s*'([^']*)'", text)
    if not url_m:
        raise RuntimeError(f"no `url` field in {path}")
    return url_m.group(1), (pw_m.group(1) if pw_m else "")


def obs_auth_response(password: str, salt: str, challenge: str) -> str:
    """OBS WebSocket v5 auth: base64(sha256(b64(sha256(pw+salt)) + challenge))."""
    secret_b64 = base64.b64encode(
        hashlib.sha256((password + salt).encode()).digest()
    ).decode()
    return base64.b64encode(
        hashlib.sha256((secret_b64 + challenge).encode()).digest()
    ).decode()


# ─── state translation ──────────────────────────────────────────────────────

def _flatten_tag(v):
    """MPD returns multi-value tags as lists (e.g. two ARTIST lines for a
    feature collab). Join with `, ` and trim; treat empty as None."""
    if v is None:
        return None
    if isinstance(v, list):
        v = ", ".join(x for x in v if x)
    v = v.strip()
    return v or None


def _song_display(song: dict) -> tuple:
    """Extract (display, artist, track_title) from an MPD song dict.

    Display string is built `Artist - Title` from ID3/Vorbis tags when both
    are present, then degrades through tag-only → filename stem (already
    "Artist - Title" for the NCS Directory naming convention) → None."""
    artist     = _flatten_tag(song.get("artist") or song.get("albumartist"))
    track_name = _flatten_tag(song.get("title"))

    if artist and track_name:
        display = f"{artist} - {track_name}"
    elif track_name:
        display = track_name
    elif song.get("file"):
        display = Path(song["file"]).stem.strip() or None
    else:
        display = None

    return display, artist, track_name


def _format_dbplaytime(seconds: float) -> str:
    """MPD's db_playtime is the total runtime of every file in the library
    in seconds. Render the most-significant two units (47d 12h, 5h 22m, …)
    so the stats line stays compact even at multi-day libraries."""
    s = int(seconds or 0)
    days, s = divmod(s, 86400)
    hours, s = divmod(s, 3600)
    mins,  _ = divmod(s, 60)
    if days:  return f"{days}d {hours}h"
    if hours: return f"{hours}h {mins}m"
    if mins:  return f"{mins}m"
    return "0m"


def _status_bool(status: dict, key: str) -> bool:
    """MPD status flags arrive as "0"/"1" strings; normalize to bool."""
    return str(status.get(key, "0")).strip() == "1"


def _status_volume(status: dict) -> int | None:
    """Volume is usually 0..100, but MPD can report -1 when unavailable."""
    raw = status.get("volume")
    if raw is None:
        return None
    try:
        volume = int(raw)
    except (TypeError, ValueError):
        return None
    return volume if volume >= 0 else None


# Map file extension → display label. Anything not listed falls through to
# the uppercased extension so unusual codecs still render something readable.
_FORMAT_LABELS = {
    "flac": "FLAC",
    "mp3":  "MP3",
    "opus": "OPUS",
    "ogg":  "OGG",
    "oga":  "OGG",
    "m4a":  "AAC",
    "mp4":  "AAC",
    "aac":  "AAC",
    "wav":  "WAV",
    "wv":   "WAVPACK",
    "ape":  "APE",
    "alac": "ALAC",
    "webm": "WEBM",
    "wma":  "WMA",
}


def _detect_format(uri: str | None) -> str | None:
    if not uri:
        return None
    ext = Path(uri).suffix.lower().lstrip(".")
    if not ext:
        return None
    return _FORMAT_LABELS.get(ext, ext.upper())


def _parse_audio(audio_str: str | None) -> tuple[int | None, int | None, int | None]:
    """Decode MPD's `audio` status string `samplerate:bits:channels`.
    `bits` may be `f` (float) or `dsd<N>` for DSD streams — return None for
    anything we can't render as a plain integer; the overlay just skips it."""
    if not audio_str:
        return None, None, None
    parts = audio_str.split(":")
    if len(parts) < 3:
        return None, None, None
    def _maybe_int(v):
        try:    return int(v)
        except (TypeError, ValueError): return None
    return _maybe_int(parts[0]), _maybe_int(parts[1]), _maybe_int(parts[2])


def _audio_info(status: dict, song: dict) -> dict:
    sr, bits, ch = _parse_audio(status.get("audio"))
    bitrate = None
    raw_br = status.get("bitrate")
    if raw_br is not None:
        try:
            bitrate = int(raw_br)
        except (TypeError, ValueError):
            bitrate = None
        # MPD reports 0 kbps for paused/stopped; surface as missing instead
        # of a misleading "0 kbps".
        if bitrate is not None and bitrate <= 0:
            bitrate = None
    return {
        "format":     _detect_format(song.get("file")),
        "samplerate": sr,
        "bits":       bits,
        "channels":   ch,
        "bitrate":    bitrate,
    }


# Module-level cache: MPD stats barely move between ticks (only on file
# add/remove), so we refresh every STATS_REFRESH_SEC and reuse otherwise.
_stats_cache = {"data": None, "next_refresh": 0.0}


async def refresh_stats(client) -> dict | None:
    now = time.monotonic()
    if _stats_cache["data"] and now < _stats_cache["next_refresh"]:
        return _stats_cache["data"]
    try:
        s = await client.stats()
        _stats_cache["data"] = {
            "artists":    int(s.get("artists") or 0),
            "albums":     int(s.get("albums") or 0),
            "songs":      int(s.get("songs") or 0),
            "dbPlaytime": _format_dbplaytime(float(s.get("db_playtime") or 0)),
        }
        _stats_cache["next_refresh"] = now + STATS_REFRESH_SEC
    except Exception as e:
        # Keep last-known on transient failure — overlays prefer stale to
        # blank for slow-moving counters like this.
        print(f"[stats] refresh failed: {e}", flush=True)
    return _stats_cache["data"]


def mpd_to_event(status: dict, song: dict, next_songs: list | None = None,
                 cover_path: str | None = None,
                 cover_hash: str | None = None,
                 stats: dict | None = None) -> dict:
    """Translate `status` + `currentsong` (+ optional `next_songs`, the next
    up to 3 queued tracks) into the overlay payload. The single `nextTitle`
    family mirrors `nextTitles[0]` for overlays that only need one."""
    paused   = status.get("state", "stop") != "play"
    elapsed  = float(status.get("elapsed") or 0)
    duration = float(status.get("duration") or song.get("duration") or 0)

    display, artist, track_name = _song_display(song)
    album = _flatten_tag(song.get("album"))
    # MPD's date tag is whatever the file carries — ID3v2 TYER/TDRC, Vorbis
    # DATE, etc. Often a 4-digit year, sometimes a full ISO date. Take the
    # leading 4 digits when present so consumers always get a clean year.
    raw_date = _flatten_tag(song.get("date") or song.get("originaldate"))
    year = None
    if raw_date:
        m = re.match(r"\s*(\d{4})", raw_date)
        year = m.group(1) if m else raw_date
    genre = _flatten_tag(song.get("genre"))
    playback = {
        "volume":  _status_volume(status),
        "repeat":  _status_bool(status, "repeat"),
        "random":  _status_bool(status, "random"),
        "single":  _status_bool(status, "single"),
        "consume": _status_bool(status, "consume"),
    }

    next_titles = []
    next_display, next_artist, next_track, next_file = None, None, None, None
    if next_songs:
        for ns in next_songs:
            d, a, t = _song_display(ns)
            if d is not None:
                next_titles.append(d)
        if next_songs[0]:
            next_display, next_artist, next_track = _song_display(next_songs[0])
            next_file = next_songs[0].get("file")

    return {
        "index":       int(status["song"]) if "song" in status else -1,
        "total":       int(status.get("playlistlength") or 0),
        "title":       display,
        "artist":      artist,
        "trackTitle":  track_name,
        "album":       album,
        "year":        year,
        "genre":       genre,
        "file":        song.get("file"),
        "currentTime": elapsed,
        "duration":    duration,
        "paused":      paused,
        "nextTitle":   next_display,
        "nextArtist":  next_artist,
        "nextTrackTitle":   next_track,
        "nextFile":    next_file,
        "nextTitles":  next_titles,
        "coverPath":   cover_path,
        "coverHash":   cover_hash,
        "playback":    playback,
        "stats":       stats,
        "audio":       _audio_info(status, song),
    }


class SharedState:
    def __init__(self):
        self.data = {
            "index": -1, "total": 0,
            "title": None, "artist": None, "trackTitle": None,
            "album": None, "year": None, "genre": None, "file": None,
            "currentTime": 0, "duration": 0, "paused": True,
            "nextTitle": None, "nextArtist": None, "nextTrackTitle": None, "nextFile": None,
            "nextTitles": [],
            "coverPath": None, "coverHash": None,
            "playback": {"volume": None, "repeat": False, "random": False, "single": False, "consume": False},
            "stats": None,
            "audio": {"format": None, "samplerate": None, "bits": None, "channels": None, "bitrate": None},
        }
        self.changed = asyncio.Event()


# ─── cover art resolution ───────────────────────────────────────────────────

class CoverState:
    """Caches the last resolved (uri → hash) so we only re-run cover lookup
    on actual track change, not every status tick. The hash also lets the
    overlay cache-bust without us re-reading the file each second."""
    def __init__(self):
        self.last_uri:  str | None = None
        self.last_hash: str | None = None


_cover_state = CoverState()


def _find_folder_cover(song_uri: str) -> Path | None:
    """Look in the song's own directory for folder/cover/front.{jpg,jpeg,
    png,webp}. Case-insensitive. song_uri is MPD-relative (e.g.
    "Kolekcja/VBS - Nikoś/01 - Track.mp3"); resolve() follows the symlink
    so /media/nvme/Music/Kolekcja files are found.

    Lookup is prioritized: stems in COVER_STEMS order × exts in COVER_EXTS
    order. So when an album ships both `folder.jpg` and `folder.webp` (the
    Kolekcja convention), the jpg wins — keeps the on-disk cache file's
    extension aligned with its bytes most of the time."""
    if not song_uri:
        return None
    try:
        full = (MPD_MUSIC_DIR / song_uri).resolve()
    except (OSError, RuntimeError):
        return None
    parent = full.parent
    if not parent.is_dir():
        return None
    # Snapshot dir as lower-name → Path so prioritized lookup is O(1) and
    # case-insensitive without re-scanning per candidate.
    try:
        by_lower = {p.name.lower(): p for p in parent.iterdir() if p.is_file()}
    except OSError:
        return None
    for stem in COVER_STEMS:
        for ext in COVER_EXTS:
            hit = by_lower.get(f"{stem}{ext}")
            if hit:
                return hit
    return None


async def _fetch_embedded_art(client, uri: str) -> bytes | None:
    """Try MPD `readpicture` first (preferred — works for any tagged file
    with embedded art) then fall back to `albumart` (older, MPD-specific
    sidecar lookup). Both stream chunks; we loop with offset until size
    is reached."""
    for cmd_name in ("readpicture", "albumart"):
        cmd = getattr(client, cmd_name, None)
        if cmd is None:
            continue
        try:
            buf = bytearray()
            offset = 0
            while True:
                resp = await cmd(uri, offset)
                if not isinstance(resp, dict):
                    break
                chunk = resp.get("binary")
                if not chunk:
                    break
                buf.extend(chunk)
                size = int(resp.get("size") or 0)
                offset = len(buf)
                if size and offset >= size:
                    break
            if buf:
                return bytes(buf)
        except Exception:
            # MPD raises when the file lacks embedded art for the given
            # command — that's the signal to try the next one.
            continue
    return None


async def resolve_cover(client, song_uri: str | None) -> tuple[str | None, str | None]:
    """Resolve cover art for the current song; return (coverPath, coverHash).
    Writes bytes to COVER_OUT only when the hash changes. Returns (None, None)
    when no art is available — overlays should render a placeholder.

    Caches by `song_uri`: re-running disk reads + MPD readpicture each tick
    would be wasteful. State is reset on track change (or song_uri=None).
    """
    if not song_uri:
        _cover_state.last_uri = None
        _cover_state.last_hash = None
        return None, None
    if song_uri == _cover_state.last_uri:
        if _cover_state.last_hash:
            return f"../bridges/{COVER_OUT.name}", _cover_state.last_hash
        return None, None
    _cover_state.last_uri = song_uri

    data: bytes | None = None
    folder = _find_folder_cover(song_uri)
    if folder:
        try:
            data = folder.read_bytes()
        except OSError:
            data = None
    if not data:
        try:
            data = await _fetch_embedded_art(client, song_uri)
        except Exception:
            data = None

    if not data:
        _cover_state.last_hash = None
        return None, None

    digest = hashlib.sha1(data).hexdigest()[:12]
    if digest != _cover_state.last_hash:
        try:
            COVER_OUT.write_bytes(data)
        except OSError as e:
            print(f"[cover] write failed: {e}", flush=True)
            _cover_state.last_hash = None
            return None, None
        _cover_state.last_hash = digest
    return f"../bridges/{COVER_OUT.name}", digest


# ─── MPD side ───────────────────────────────────────────────────────────────

async def mpd_loop(state: SharedState):
    """Poll MPD status every TICK_SECONDS, push into shared state."""
    while True:
        client = MPDClient()
        try:
            await client.connect(MPD_HOST, MPD_PORT)
            print(f"[mpd] connected → {MPD_HOST}:{MPD_PORT} "
                  f"(proto {client.mpd_version})", flush=True)
            while True:
                status = await client.status()
                song   = await client.currentsong()
                # Peek the next up-to-3 tracks. MPD's `nextsong` is the index
                # of the immediate successor; we walk forward from there until
                # we hit the queue end or 3 entries, whichever comes first.
                next_songs = []
                if "nextsong" in status:
                    start = int(status["nextsong"])
                    total = int(status.get("playlistlength") or 0)
                    for i in range(start, min(start + 3, total)):
                        try:
                            rows = await client.playlistinfo(i)
                            if rows:
                                next_songs.append(rows[0])
                        except Exception:
                            break  # transient — let it retry next tick
                cover_path, cover_hash = await resolve_cover(client, song.get("file"))
                stats = await refresh_stats(client)
                state.data = mpd_to_event(status, song, next_songs,
                                          cover_path, cover_hash, stats)
                state.changed.set()
                await asyncio.sleep(TICK_SECONDS)
        except Exception as e:
            print(f"[mpd] {type(e).__name__}: {e} — reconnect in 2s", flush=True)
            try: client.disconnect()
            except Exception: pass
            await asyncio.sleep(2)


# ─── OBS WS side ────────────────────────────────────────────────────────────

async def obs_loop(state: SharedState, url: str, password: str):
    """Connect to OBS WS, identify, broadcast state changes as mpd:state.

    Quiet when OBS isn't running: logs only state transitions
    (down→up, up→down) and uses exponential backoff so the journal doesn't
    fill with reconnect spam during long OBS-off windows.
    """
    backoff = 2          # current retry delay in seconds
    BACKOFF_MAX = 30     # cap; OBS doesn't take long to wake up once started
    prev_status = None   # None | "up" | "down" — only log on transitions

    while True:
        try:
            async with websockets.connect(url, ping_interval=20) as ws:
                # ── Hello (op 0) → Identify (op 1) → Identified (op 2) ──
                hello = json.loads(await ws.recv())
                if hello.get("op") != 0:
                    raise RuntimeError(f"expected Hello, got op={hello.get('op')}")

                identify = {"rpcVersion": 1, "eventSubscriptions": 0}
                if hello["d"].get("authentication"):
                    a = hello["d"]["authentication"]
                    identify["authentication"] = obs_auth_response(
                        password, a["salt"], a["challenge"],
                    )
                await ws.send(json.dumps({"op": 1, "d": identify}))

                ident = json.loads(await ws.recv())
                if ident.get("op") != 2:
                    raise RuntimeError(f"expected Identified, got op={ident.get('op')}")
                if prev_status != "up":
                    print(f"[obs] connected → {url} (identified ✓)", flush=True)
                prev_status = "up"
                backoff = 2  # reset for next disconnect

                # ── Broadcast loop + recv drain ──
                # We send ~1 BroadcastCustomEvent per second; OBS replies to
                # each with an op=7 RequestResponse we don't care about. If we
                # never recv, the socket's read buffer fills and eventually
                # TCP backpressure stalls our sends — so a parallel task
                # consumes and discards everything inbound.
                async def drain():
                    async for _ in ws:
                        pass

                async def broadcast():
                    # Wake on every state change. A safety timeout slightly
                    # larger than TICK_SECONDS guards against a stalled MPD
                    # loop — we still emit a heartbeat so the overlay knows
                    # we're up.
                    while True:
                        try:
                            await asyncio.wait_for(
                                state.changed.wait(), timeout=TICK_SECONDS + 1.0,
                            )
                        except asyncio.TimeoutError:
                            pass
                        state.changed.clear()
                        payload = dict(state.data)
                        payload["_type"] = "mpd:state"
                        request = {
                            "op": 6,
                            "d": {
                                "requestType": "BroadcastCustomEvent",
                                "requestId":   f"r_{time.monotonic_ns()}",
                                "requestData": {"eventData": payload},
                            },
                        }
                        await ws.send(json.dumps(request))

                await asyncio.gather(drain(), broadcast())
        except Exception as e:
            # Only log the first time OBS becomes unreachable, or when an
            # established connection drops. Routine "still down" retries
            # are silent.
            if prev_status == "up":
                print(f"[obs] disconnected ({type(e).__name__}: {e}) — "
                      f"retrying with backoff", flush=True)
            elif prev_status is None:
                print(f"[obs] unreachable ({type(e).__name__}) — "
                      f"will keep retrying silently", flush=True)
            prev_status = "down"
            await asyncio.sleep(backoff)
            backoff = min(backoff * 2, BACKOFF_MAX)


# ─── entrypoint ─────────────────────────────────────────────────────────────

async def main():
    if not OBS_CONFIG_JS.exists():
        print(f"[err] missing {OBS_CONFIG_JS} — run scripts/setup.sh first",
              file=sys.stderr, flush=True)
        sys.exit(1)
    url, password = load_obs_config(OBS_CONFIG_JS)
    state = SharedState()
    await asyncio.gather(mpd_loop(state), obs_loop(state, url, password))


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        sys.exit(0)
