"""Stream playback state — testable store, foundation for multi-listener.

Today ``web_server.py`` keeps ONE module-global ``stream_state`` dict + one
``stream_lock`` (``web_server.py:747``). That means the whole server has a
single "currently playing" — every browser tab/device is a remote for the same
playback, and two listeners collide. Fixing that (the player-revamp Phase 3
goal) requires per-session state, but the global is woven through ~22 call
sites and isn't unit-testable where it lives.

This module lifts the state into a small, tested abstraction WITHOUT yet
changing behavior:

* ``StreamSession`` — one playback's state. Behaves like the old dict
  (``s["status"]``, ``s.get(...)``, ``s.update({...})``) so existing call sites
  work unchanged, but each carries its OWN lock so distinct sessions never
  block or clobber each other.
* ``StreamStateStore`` — a registry of named sessions. ``DEFAULT_SESSION`` is
  the single shared session that reproduces today's exact behavior; wiring the
  web server through it is a no-op refactor. When Phase 3 adds a per-request
  session id (browser/device), the store already supports it — that step is the
  only remaining (browser-side, unprovable-here) piece.

Pure Python, no Flask/DB. Fully unit-testable.
"""

from __future__ import annotations

import threading
from typing import Any, Dict, Iterator, List, Optional

DEFAULT_SESSION = "default"


def _fresh_state() -> Dict[str, Any]:
    """The stopped/empty baseline — matches web_server.py's original literal."""
    return {
        "status": "stopped",   # stopped | loading | queued | ready | error
        "progress": 0,
        "track_info": None,
        "file_path": None,
        # Set instead of file_path when a library track is played by proxying
        # the media server's own stream API (Navidrome/Subsonic, #809) rather
        # than reading the file off disk. /stream/audio proxies this URL.
        "stream_url": None,
        "error_message": None,
    }


class StreamSession:
    """One playback session's state, with its own lock.

    Dict-compatible for the operations the existing call sites use
    (``__getitem__``, ``__setitem__``, ``get``, ``update``) so lifting the
    global is a drop-in. ``lock`` is exposed so callers that did
    ``with stream_lock:`` keep that exact guard — now per-session.
    """

    def __init__(self, initial: Optional[Dict[str, Any]] = None):
        self._state: Dict[str, Any] = _fresh_state()
        if initial:
            self._state.update(initial)
        self.lock = threading.RLock()

    # -- dict-compatible surface (matches old stream_state usage) --
    def __getitem__(self, key: str) -> Any:
        return self._state[key]

    def __setitem__(self, key: str, value: Any) -> None:
        self._state[key] = value

    def __contains__(self, key: str) -> bool:
        return key in self._state

    def get(self, key: str, default: Any = None) -> Any:
        return self._state.get(key, default)

    def update(self, values: Dict[str, Any]) -> None:
        self._state.update(values)

    def snapshot(self) -> Dict[str, Any]:
        """A shallow copy — for emitting to clients without leaking the live
        dict (the old code read individual keys under the lock; a snapshot is
        the safe equivalent for the whole thing)."""
        return dict(self._state)

    def reset(self) -> None:
        """Return to the stopped/empty baseline (used by stop)."""
        self._state = _fresh_state()

    def replace(self, new_state: Dict[str, Any]) -> None:
        """Wholesale replace the backing dict (mirrors the old
        ``_set_stream_state`` global reassignment)."""
        self._state = dict(new_state)


class StreamStateStore:
    """Registry of named :class:`StreamSession` objects.

    ``get()`` lazily creates a session on first reference, so a brand-new
    session id just works. The default session reproduces the single-global
    behavior the app has today.
    """

    def __init__(self):
        self._sessions: Dict[str, StreamSession] = {}
        self._registry_lock = threading.RLock()

    def get(self, session_id: str = DEFAULT_SESSION) -> StreamSession:
        with self._registry_lock:
            session = self._sessions.get(session_id)
            if session is None:
                session = StreamSession()
                self._sessions[session_id] = session
            return session

    def has(self, session_id: str) -> bool:
        with self._registry_lock:
            return session_id in self._sessions

    def drop(self, session_id: str) -> bool:
        """Remove a session (e.g. on disconnect). Returns True if one existed.
        The default session is never dropped — it's the always-present shared
        playback."""
        if session_id == DEFAULT_SESSION:
            return False
        with self._registry_lock:
            return self._sessions.pop(session_id, None) is not None

    def session_ids(self) -> List[str]:
        with self._registry_lock:
            return list(self._sessions.keys())

    def active_ids(self) -> List[str]:
        """Session ids whose status is not 'stopped' — i.e. currently doing
        something. The signal multi-listener UI / cleanup will key off of."""
        with self._registry_lock:
            return [
                sid for sid, s in self._sessions.items()
                if s.get("status") != "stopped"
            ]

    def __iter__(self) -> Iterator[StreamSession]:
        with self._registry_lock:
            return iter(list(self._sessions.values()))
