"""WebSocket peripheral API.

Bridges LVA state to a separate peripheral container (LEDs, buttons, HAT boards).

Protocol (JSON over WebSocket):
  Events  (LVA → peripheral): {"event": "<name>", "data": {...}}
  Commands (peripheral → LVA): {"command": "<name>", "data": {...}}
  Snapshot (on connect):       {"event": "snapshot", "data": {...}}

Feedback events emitted by LVA
-------------------------------
  wake_word_detected
  listening
  stt_text        data: {"text": str}
              Emitted when HA returns the recognised speech transcript.
              Use this to display what the user said on a screen or LED ticker.
  thinking
  tts_text        data: {"text": str}
              Emitted when HA returns the assistant's response text, just
              before TTS audio begins playing.
              Use this to display the assistant's reply on a screen.
  tts_speaking
  tts_finished
  pipeline_error  data: {"reason": <str>}
              Emitted when the voice pipeline reports an error (STT failure,
              intent error, etc.). NOT emitted when HA disconnects — that
              uses the separate ``disconnected`` event below.
              Peripheral containers should show a brief red error animation
              (e.g. 3 red flashes then off) and then return to idle.
  disconnected
              Emitted when the HA TCP connection is lost.
              Connected leds with peripheral containers should
              show a red twinkle / "no connection" animation and keep retrying
              until they see a ``zeroconf`` event with status "connected".
              NOTE: if LVA itself is not running the peripheral container will
              see a WebSocket connection failure on its end — that is also
              a "disconnected" condition to handle with the same animation.
  idle
  muted                 data: {"muted": true/false}
  timer_ticking   data: {"id": str, "name": str, "total_seconds": int, "seconds_left": int}
  timer_updated   data: {"id": str, "name": str, "total_seconds": int, "seconds_left": int}
  timer_ringing   data: {"id": str, "name": str, "total_seconds": int, "seconds_left": int}
  media_player_playing  Emitted when HA sends music/media to the music_player
                        (non-announcement playback). Not emitted for TTS or
                        voice pipeline announcements — those use tts_speaking.
  volume_changed        data: {"volume": 0.0–1.0}
  volume_muted          data: {"muted": true/false}
  zeroconf              data: {"status": "getting_started" | "connected"}
  light_command         data: {"object_id": str, "state": bool, "brightness": float,
                              "red": float, "green": float, "blue": float, "effect": str}
              Fires when HA changes a Light entity that a peripheral
              previously registered via register_light. The peripheral
              matches on object_id and applies the new state. The
              effect names are those the peripheral declared at
              registration; e.g. "Voice Assistant" runs the pipeline
              animations.

Commands accepted from the peripheral container
------------------------------------------------
  start_listening
  stop_pipeline     Abort the active voice pipeline at any phase — listening,
                    thinking, speaking or wake word active. Calls satellite.stop() which
                    cleans up STT streaming, sends VoiceAssistantAnnounceFinished
                    to HA, unducking music, and emits idle to peripherals.
  mute_mic
  unmute_mic
  volume_up
  volume_down
  set_volume        data: {"volume": 0.0–1.0}
  stop_timer_ringing
  pause_media_player
  resume_media_player
  stop_media_player
  button_single_press
  button_double_press
  button_triple_press
  button_long_press
  register_light    data: {"name": str, "object_id": str, "effects": [str],
                           "supports_rgb": bool, "supports_brightness": bool}
              The peripheral declares an LED Light it wants exposed in
              HA. LVA creates a matching ESPHome Light entity (visible
              as light.<satellite>_<object_id>) and routes HA changes
              back to the peripheral as light_command events. Send
              once after connecting; duplicate registrations for the
              same object_id are ignored.
  register_button
              The peripheral declares that it has physical buttons and
              wants a Button Press event entity exposed in HA. LVA
              creates a ButtonEventSensorEntity (visible as
              event.<satellite>_button_press_event) that fires
              single_press, double_press, triple_press, and long_press
              events to Home Assistant when the corresponding
              button_* commands are sent. Send once after connecting;
              duplicate registrations are ignored.
"""

from __future__ import annotations

import asyncio
import json
import logging
import time
from enum import Enum
from typing import TYPE_CHECKING, Any, Dict, Optional, Set

from aioesphomeapi.api_pb2 import MediaPlayerStateResponse  # type: ignore[attr-defined]  # pylint: disable=no-name-in-module
from aioesphomeapi.model import MediaPlayerState  # type: ignore[import]

if TYPE_CHECKING:
    from .models import ServerState

_LOGGER = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Public enumerations
# ---------------------------------------------------------------------------


class LVAEvent(str, Enum):
    """Events broadcast from LVA to peripheral clients."""

    WAKE_WORD_DETECTED = "wake_word_detected"
    LISTENING = "listening"
    STT_TEXT = "stt_text"
    THINKING = "thinking"
    TTS_TEXT = "tts_text"
    TTS_SPEAKING = "tts_speaking"
    TTS_FINISHED = "tts_finished"
    PIPELINE_ERROR = "pipeline_error"
    DISCONNECTED = "disconnected"
    IDLE = "idle"
    MUTED = "muted"
    TIMER_TICKING = "timer_ticking"
    TIMER_UPDATED = "timer_updated"
    TIMER_RINGING = "timer_ringing"
    MEDIA_PLAYER_PLAYING = "media_player_playing"
    VOLUME_CHANGED = "volume_changed"
    VOLUME_MUTED = "volume_muted"
    ZEROCONF = "zeroconf"
    LIGHT_COMMAND = "light_command"


class LVACommand(str, Enum):
    """Commands accepted from peripheral clients."""

    START_LISTENING = "start_listening"
    STOP_PIPELINE = "stop_pipeline"
    MUTE_MIC = "mute_mic"
    UNMUTE_MIC = "unmute_mic"
    VOLUME_UP = "volume_up"
    VOLUME_DOWN = "volume_down"
    SET_VOLUME = "set_volume"
    STOP_TIMER_RINGING = "stop_timer_ringing"
    STOP_MEDIA_PLAYER = "stop_media_player"
    PAUSE_MEDIA_PLAYER = "pause_media_player"
    RESUME_MEDIA_PLAYER = "resume_media_player"
    BUTTON_SINGLE_PRESS = "button_single_press"
    BUTTON_DOUBLE_PRESS = "button_double_press"
    BUTTON_TRIPLE_PRESS = "button_triple_press"
    BUTTON_LONG_PRESS = "button_long_press"
    REGISTER_LIGHT = "register_light"
    REGISTER_BUTTON = "register_button"


# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------


class PeripheralAPIServer:
    """
    WebSocket server that bridges LVA state to a peripheral container.

    Usage
    -----
    1. Construct in ``__main__.main()``.
    2. ``peripheral_api.set_state(state)`` once ``ServerState`` exists.
    3. ``await peripheral_api.start()`` inside the running event loop.
    4. Call ``emit_event_sync()`` from any thread (mpv callbacks, audio thread).
    """

    DEFAULT_VOLUME_STEP: float = 0.05
    LATE_ENTITY_RECONNECT_DEBOUNCE_S: float = 1.5
    LATE_ENTITY_RECONNECT_COOLDOWN_S: float = 60.0

    def __init__(
        self,
        host: str = "0.0.0.0",
        port: int = 6055,
        volume_step: float = DEFAULT_VOLUME_STEP,
    ) -> None:
        self._host = host
        self._port = port
        self._volume_step = volume_step

        self._clients: Set[Any] = set()
        self._state: Optional[ServerState] = None
        self._loop: Optional[asyncio.AbstractEventLoop] = None
        self._server: Any = None

        # Last conversation exchange — sent in the snapshot to newly-connecting clients
        self._last_stt_text: Optional[str] = None
        self._last_tts_text: Optional[str] = None

        # Current event state — replayed to newly-connecting clients so they can
        # show the correct animation immediately without waiting for the next event.
        # Only "state" events are tracked (pipeline, timer, media, muted, idle).
        # Transient/informational events (stt_text, tts_text, volume_changed, etc.)
        # are not tracked because they carry no ongoing visual state.
        self._current_state: Optional[LVAEvent] = None
        self._current_state_data: Optional[Dict[str, Any]] = None

        # Safeguarded HA reconnect trigger for late entity registrations.
        self._pending_entity_reconnect_task: Optional[asyncio.Task] = None
        self._last_ha_reconnect_at: float = 0.0

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    def set_state(self, state: "ServerState") -> None:
        """Attach the shared ``ServerState`` so commands can read/mutate it."""
        self._state = state

    async def start(self) -> None:
        """Start the WebSocket server inside the running event loop."""
        try:
            from websockets.server import serve  # type: ignore[import]
        except ImportError:
            _LOGGER.error("websockets package not installed – peripheral API disabled. Install with: pip install websockets")
            return

        self._loop = asyncio.get_running_loop()
        self._server = await serve(self._handle_client, self._host, self._port)
        _LOGGER.info("Peripheral API listening at ws://%s:%d", self._host, self._port)

    async def stop(self) -> None:
        """Gracefully shut down the server and all client connections."""
        if self._pending_entity_reconnect_task is not None:
            self._pending_entity_reconnect_task.cancel()
            self._pending_entity_reconnect_task = None

        if self._server is not None:
            self._server.close()
            await self._server.wait_closed()
            _LOGGER.info("Peripheral API stopped")

    # ------------------------------------------------------------------
    # Client handling
    # ------------------------------------------------------------------

    async def _handle_client(self, websocket: Any) -> None:
        addr = getattr(websocket, "remote_address", "unknown")
        self._clients.add(websocket)
        _LOGGER.info("Peripheral client connected: %s", addr)

        await self._send_snapshot(websocket)

        try:
            async for raw in websocket:
                await self._dispatch_command(raw)
        except Exception as exc:  # pylint: disable=broad-except
            _LOGGER.debug("Peripheral client %s error: %s", addr, exc)
        finally:
            self._clients.discard(websocket)
            _LOGGER.info("Peripheral client disconnected: %s", addr)

    async def _send_snapshot(self, websocket: Any) -> None:
        """Push current LVA state to a newly connected peripheral client."""
        state = self._state
        if state is None:
            return

        payload = json.dumps(
            {
                "event": "snapshot",
                "data": {
                    "muted": state.muted,
                    "volume": round(state.volume, 3),
                    "ha_connected": state.connected,
                    "last_stt_text": self._last_stt_text,
                    "last_tts_text": self._last_tts_text,
                },
            }
        )
        try:
            await websocket.send(payload)
        except Exception:  # pylint: disable=broad-except
            return

        # Replay the current event state so the client immediately shows the
        # right animation — e.g. a timer ticking animation when reconnecting
        # mid-timer, or the muted indicator when reconnecting while muted.
        current_state = self._current_state
        if current_state is None:
            return

        if current_state == LVAEvent.DISCONNECTED and state.connected:
            return

        state_payload: Dict[str, Any] = {"event": current_state.value}
        if self._current_state_data:
            state_payload["data"] = self._current_state_data
        try:
            await websocket.send(json.dumps(state_payload))
        except Exception:  # pylint: disable=broad-except
            pass

    # ------------------------------------------------------------------
    # Command dispatch
    # ------------------------------------------------------------------

    async def _dispatch_command(self, raw: str) -> None:
        """Parse and execute a JSON command from the peripheral container."""
        try:
            msg: Dict[str, Any] = json.loads(raw)
        except json.JSONDecodeError:
            _LOGGER.warning("Peripheral: invalid JSON: %.200s", raw)
            return

        command: str = msg.get("command", "")
        if not command:
            return

        _LOGGER.debug("Peripheral command received: %s", command)

        state = self._state
        if state is None:
            return

        satellite = state.satellite

        if command == LVACommand.START_LISTENING:
            if satellite is None or state.muted:
                return
            satellite.start_listening()  # plays sound, then starts pipeline

        elif command == LVACommand.STOP_PIPELINE:
            # Stops the voice pipeline at any active phase:
            # listening, thinking, speaking or wake word active.
            if satellite is not None:
                satellite.stop()

        elif command == LVACommand.MUTE_MIC:
            if satellite is not None and not state.muted:
                satellite._set_muted(True)  # pylint: disable=protected-access
                await self._push_mute_switch(satellite, muted=True)

        elif command == LVACommand.UNMUTE_MIC:
            if satellite is not None and state.muted:
                satellite._set_muted(False)  # pylint: disable=protected-access
                await self._push_mute_switch(satellite, muted=False)

        elif command in (LVACommand.VOLUME_UP, LVACommand.VOLUME_DOWN):
            delta = self._volume_step if command == LVACommand.VOLUME_UP else -self._volume_step
            new_vol = max(0.0, min(1.0, state.volume + delta))
            vol_pct = int(round(new_vol * 100))

            state.music_player.set_volume(vol_pct)
            state.tts_player.set_volume(vol_pct)

            if state.media_player_entity is not None:
                state.media_player_entity.volume = new_vol
                state.media_player_entity.previous_volume = new_vol

                # Push the new volume to HA so its media player entity updates in real time
                if satellite is not None:

                    satellite.send_messages(
                        [
                            MediaPlayerStateResponse(
                                key=state.media_player_entity.key,
                                state=state.media_player_entity.state,
                                volume=new_vol,
                                muted=state.media_player_entity.muted,
                            )
                        ]
                    )

            # persist_volume also emits VOLUME_CHANGED via models.py
            state.persist_volume(new_vol)

        elif command == LVACommand.SET_VOLUME:
            data = msg.get("data", {})
            volume = data.get("volume")
            if not isinstance(volume, (int, float)):
                _LOGGER.warning("Peripheral: invalid volume in set_volume command: %s", volume)
                return

            new_vol = max(0.0, min(1.0, float(volume)))
            vol_pct = int(round(new_vol * 100))

            state.music_player.set_volume(vol_pct)
            state.tts_player.set_volume(vol_pct)

            if state.media_player_entity is not None:
                state.media_player_entity.volume = new_vol
                state.media_player_entity.previous_volume = new_vol

                # Push the new volume to HA so its media player entity updates in real time
                if satellite is not None:

                    satellite.send_messages(
                        [
                            MediaPlayerStateResponse(
                                key=state.media_player_entity.key,
                                state=state.media_player_entity.state,
                                volume=new_vol,
                                muted=state.media_player_entity.muted,
                            )
                        ]
                    )

            # persist_volume also emits VOLUME_CHANGED via models.py
            state.persist_volume(new_vol)

        elif command == LVACommand.STOP_TIMER_RINGING:
            if satellite is None:
                return
            if getattr(satellite, "_timer_finished", False):
                satellite._timer_finished = False  # pylint: disable=protected-access
                state.active_wake_words.discard(state.stop_word.id)
                state.tts_player.stop()
                satellite.unduck()
                await self.emit_event(LVAEvent.IDLE)

        elif command == LVACommand.STOP_MEDIA_PLAYER:
            state.music_player.stop()
            if state.media_player_entity is not None:

                state.media_player_entity.state = MediaPlayerState.IDLE
                if satellite is not None:
                    satellite.send_messages([self._create_media_player_response(MediaPlayerState.IDLE)])

        elif command == LVACommand.PAUSE_MEDIA_PLAYER:
            state.music_player.pause()
            if state.media_player_entity is not None:
                state.media_player_entity.state = MediaPlayerState.PAUSED
                if satellite is not None:
                    satellite.send_messages([self._create_media_player_response(MediaPlayerState.PAUSED)])

        elif command == LVACommand.RESUME_MEDIA_PLAYER:
            state.music_player.resume()
            if state.media_player_entity is not None:
                state.media_player_entity.state = MediaPlayerState.PLAYING
                if satellite is not None:
                    satellite.send_messages([self._create_media_player_response(MediaPlayerState.PLAYING)])

        elif command == LVACommand.BUTTON_SINGLE_PRESS:
            if state.button_event_sensor_entity is not None:
                state.button_event_sensor_entity.update_state("single_press")
                if satellite is not None:
                    satellite.send_messages([state.button_event_sensor_entity._get_state_message()])  # pylint: disable=protected-access

        elif command == LVACommand.BUTTON_DOUBLE_PRESS:
            state.tts_player.play(state.button_double_press_sound)
            if state.button_event_sensor_entity is not None:
                state.button_event_sensor_entity.update_state("double_press")
                if satellite is not None:
                    satellite.send_messages([state.button_event_sensor_entity._get_state_message()])  # pylint: disable=protected-access

        elif command == LVACommand.BUTTON_TRIPLE_PRESS:
            state.tts_player.play(state.button_triple_press_sound)
            if state.button_event_sensor_entity is not None:
                state.button_event_sensor_entity.update_state("triple_press")
                if satellite is not None:
                    satellite.send_messages([state.button_event_sensor_entity._get_state_message()])  # pylint: disable=protected-access

        elif command == LVACommand.BUTTON_LONG_PRESS:
            state.tts_player.play(state.button_long_press_sound)
            if state.button_event_sensor_entity is not None:
                state.button_event_sensor_entity.update_state("long_press")
                if satellite is not None:
                    satellite.send_messages([state.button_event_sensor_entity._get_state_message()])  # pylint: disable=protected-access

        elif command == LVACommand.REGISTER_LIGHT:
            self._register_light(msg.get("data") or {}, satellite)

        elif command == LVACommand.REGISTER_BUTTON:
            self._register_button(satellite)

    def _register_light(self, data: Dict[str, Any], satellite: Any) -> None:
        """Register a Light declared by a peripheral.

        Idempotent on object_id: repeat registrations (e.g. after a
        peripheral reconnect) keep the existing entity and its state.
        """
        from .models import LightRegistration  # local import to avoid a cycle

        object_id = str(data.get("object_id", "")).strip()
        if not object_id:
            _LOGGER.warning("register_light without object_id; ignoring")
            return

        state = self._state
        if state is None:
            return

        if any(spec.object_id == object_id for spec in state.pending_lights):
            # Same light already on file; nothing to do.
            return

        spec = LightRegistration(
            name=str(data.get("name", "LEDs")),
            object_id=object_id,
            icon=str(data.get("icon", "mdi:led-strip-variant")),
            effects=[str(e) for e in data.get("effects", []) if e],
            supports_rgb=bool(data.get("supports_rgb", True)),
            supports_brightness=bool(data.get("supports_brightness", True)),
        )
        state.pending_lights.append(spec)
        _LOGGER.info("Light registered: %s (effects=%s)", object_id, spec.effects)

        # If the satellite is already running, materialise the entity
        # now so future messages route correctly. HA only sees it
        # after the integration reconnects, but LVA stays consistent.
        if satellite is not None:
            satellite.register_pending_lights()

        self._schedule_ha_reconnect_for_late_entity("light", object_id)

    def _register_button(self, satellite: Any) -> None:
        """Register button press event support declared by a peripheral.

        Idempotent: repeat registrations from a reconnecting peripheral
        are a no-op — the existing entity and its accumulated event state
        are preserved.

        When the satellite is already running, the entity is materialised
        immediately so subsequent button_* commands route correctly.
        HA only sees the new entity after the integration is reloaded, but
        the same startup-wait window that applies to register_light applies
        here too (see ``--peripheral-startup-wait``).
        """
        state = self._state
        if state is None:
            return

        if state.pending_button:
            # Already registered; keep the existing entity.
            return

        state.pending_button = True
        _LOGGER.info("Button event sensor registered by peripheral")

        if satellite is not None:
            satellite.register_pending_button()

        self._schedule_ha_reconnect_for_late_entity("button", "button_press_event")

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    def _schedule_ha_reconnect_for_late_entity(self, entity_kind: str, entity_id: str) -> None:
        """Reconnect HA once for late peripheral entity registrations."""
        state = self._state
        if state is None or not state.connected or state.satellite is None:
            return

        if self._loop is None:
            return

        if self._pending_entity_reconnect_task is not None and not self._pending_entity_reconnect_task.done():
            _LOGGER.debug(
                "Coalescing late %s registration for '%s' into pending HA reconnect",
                entity_kind,
                entity_id,
            )
            return

        async def _trigger() -> None:
            try:
                await asyncio.sleep(self.LATE_ENTITY_RECONNECT_DEBOUNCE_S)

                state_now = self._state
                if state_now is None or not state_now.connected:
                    return

                satellite_now = state_now.satellite
                if satellite_now is None:
                    return

                now = time.monotonic()
                if now - self._last_ha_reconnect_at < self.LATE_ENTITY_RECONNECT_COOLDOWN_S:
                    _LOGGER.info(
                        "Skipping HA reconnect after late %s '%s': cooldown active",
                        entity_kind,
                        entity_id,
                    )
                    return

                transport = getattr(satellite_now, "_transport", None)
                if transport is None:
                    return

                self._last_ha_reconnect_at = now
                _LOGGER.info(
                    "Late %s '%s' registered after HA handshake; forcing one HA reconnect",
                    entity_kind,
                    entity_id,
                )
                transport.close()
            except asyncio.CancelledError:
                return

        self._pending_entity_reconnect_task = self._loop.create_task(_trigger())

    async def _push_mute_switch(self, satellite: Any, *, muted: bool) -> None:
        """Reflect a peripheral-triggered mute change to Home Assistant."""
        state = self._state
        if state is None or state.mute_switch_entity is None:
            return

        entity = state.mute_switch_entity
        entity._switch_state = muted  # pylint: disable=protected-access

        # pylint: disable=no-name-in-module
        from aioesphomeapi.api_pb2 import SwitchStateResponse  # type: ignore[attr-defined]

        satellite.send_messages([SwitchStateResponse(key=entity.key, state=muted)])

    # ------------------------------------------------------------------
    # Event emission
    # ------------------------------------------------------------------

    async def emit_event(
        self,
        event: LVAEvent,
        data: Optional[Dict[str, Any]] = None,
    ) -> None:
        """Broadcast an event to all connected peripheral clients."""
        # Cache the last conversation text so newly-connecting clients get it in the snapshot
        if event == LVAEvent.STT_TEXT and data:
            self._last_stt_text = data.get("text")
        elif event == LVAEvent.TTS_TEXT and data:
            self._last_tts_text = data.get("text")
        # Clear both sides at the start of a new pipeline run
        elif event == LVAEvent.LISTENING:
            self._last_stt_text = None
            self._last_tts_text = None

        # ----------------------------------------------------------------
        # Track current "state" so newly-connecting clients receive it on
        # connect via _send_snapshot.  Only persistent/visual states are
        # stored — transient informational events are skipped.
        # ----------------------------------------------------------------
        _STATE_EVENTS = {
            LVAEvent.WAKE_WORD_DETECTED,
            LVAEvent.LISTENING,
            LVAEvent.THINKING,
            LVAEvent.TTS_SPEAKING,
            LVAEvent.TTS_FINISHED,
            LVAEvent.IDLE,
            LVAEvent.MUTED,
            LVAEvent.TIMER_TICKING,
            LVAEvent.TIMER_RINGING,
            LVAEvent.MEDIA_PLAYER_PLAYING,
            LVAEvent.DISCONNECTED,
            LVAEvent.PIPELINE_ERROR,
        }
        if event in _STATE_EVENTS:
            self._current_state = event
            self._current_state_data = data or None
        elif event == LVAEvent.TIMER_UPDATED and self._current_state == LVAEvent.TIMER_TICKING:
            # Keep state as TIMER_TICKING but refresh the countdown data
            self._current_state_data = data or None

        if not self._clients:
            return

        payload: Dict[str, Any] = {"event": event.value}
        if data:
            payload["data"] = data

        raw_msg = json.dumps(payload)
        dead: Set[Any] = set()

        for ws in list(self._clients):
            try:
                await ws.send(raw_msg)
            except Exception:  # pylint: disable=broad-except
                dead.add(ws)

        self._clients -= dead
        _LOGGER.debug(
            "Peripheral event %-25s → %d client(s)",
            event.value,
            len(self._clients),
        )

    def emit_event_sync(
        self,
        event: LVAEvent,
        data: Optional[Dict[str, Any]] = None,
    ) -> None:
        """
        Thread-safe fire-and-forget event emission.

        Safe to call from mpv callbacks, the audio processing thread, or any
        non-async context while the asyncio event loop is running.
        """
        loop = self._loop
        if loop is None or loop.is_closed():
            return
        asyncio.run_coroutine_threadsafe(self.emit_event(event, data), loop)

    def _create_media_player_response(self, state: MediaPlayerState) -> MediaPlayerStateResponse:
        """Create a MediaPlayerStateResponse with current entity state."""
        assert self._state is not None
        media_entity = self._state.media_player_entity
        assert media_entity is not None
        return MediaPlayerStateResponse(
            key=media_entity.key,
            state=state,
            volume=media_entity.volume,
            muted=media_entity.muted,
        )
