"""Realtime UI event hub for Assistant Avatar WebSocket clients."""

from __future__ import annotations

import asyncio
import json
import logging
import time
from threading import Lock
from typing import Any

from fastapi import WebSocket

from app.core.config import settings

LOGGER = logging.getLogger("sentineledge.ui_events")


class EventHub:
    """Thread-safe singleton-style broadcaster for assistant UI events."""

    def __init__(self) -> None:
        self._lock = Lock()
        self._clients: dict[WebSocket, asyncio.AbstractEventLoop] = {}
        self._event_seq = 0
        self._last_state: dict[str, Any] = {"type": "state", "state": "idle"}
        self._last_event: dict[str, Any] = dict(self._last_state)
        self._last_event_by_cycle: dict[str, dict[str, Any]] = {}
        self._ui_state: dict[str, Any] = {
            "cycle_id": None,
            "ui_phase": "idle",
            "transcript": "",
            "response": "",
            "is_speaking_lock": False,
            "hold_until_monotonic": None,
            "hold_seconds": 0.0,
            "voice_state": "idle",
            "stt_backend": "unknown",
            "capture_backend": "unknown",
            "input_device_name": "none",
            "input_device_index": None,
            "input_sample_rate": None,
            "input_channels": 1,
            "input_dtype": "int16",
            "microphone_available": False,
            "microphone_error": None,
            "input_level_rms": 0.0,
            "input_level_peak": 0,
            "input_level_norm": 0.0,
            "last_event_type": "state",
            "last_event_state": "idle",
            "last_updated_ts": time.time(),
        }

    def register(self, websocket: WebSocket) -> None:
        """Register one connected websocket client."""
        loop = asyncio.get_running_loop()
        with self._lock:
            self._clients[websocket] = loop
            total = len(self._clients)
            active_cycle = self._ui_state.get("cycle_id")
            ui_phase = self._ui_state.get("ui_phase")
        LOGGER.info(
            "UI EventHub register -> clients=%s active_cycle=%s ui_phase=%s",
            total,
            active_cycle,
            ui_phase,
        )

    def unregister(self, websocket: WebSocket) -> None:
        """Unregister one websocket client."""
        with self._lock:
            self._clients.pop(websocket, None)
            total = len(self._clients)
            active_cycle = self._ui_state.get("cycle_id")
        LOGGER.info("UI EventHub unregister -> clients=%s active_cycle=%s", total, active_cycle)

    @property
    def client_count(self) -> int:
        """Return current number of connected websocket clients."""
        with self._lock:
            return len(self._clients)

    def get_last_state(self) -> dict[str, Any]:
        """Return a copy of the last known state payload."""
        with self._lock:
            return dict(self._last_state)

    def get_last_event(self) -> dict[str, Any]:
        """Return a copy of the last broadcast payload."""
        with self._lock:
            return dict(self._last_event)

    @staticmethod
    def _clamped_positive_float(value: Any, default: float = 0.0) -> float:
        try:
            parsed = float(value)
        except (TypeError, ValueError):
            return default
        return max(parsed, 0.0)

    @staticmethod
    def _normalized_cycle_id(payload: dict[str, Any]) -> str | None:
        raw = payload.get("cycle_id")
        if raw is None:
            return None
        cycle_id = str(raw).strip()
        return cycle_id or None

    def _trim_cycle_cache_locked(self, max_cycles: int = 128) -> None:
        while len(self._last_event_by_cycle) > max_cycles:
            oldest_cycle_id = next(iter(self._last_event_by_cycle))
            self._last_event_by_cycle.pop(oldest_cycle_id, None)

    def _apply_payload_to_ui_state_locked(
        self,
        payload: dict[str, Any],
        now_wall: float,
        now_mono: float,
    ) -> None:
        event_type = str(payload.get("type", "unknown"))
        cycle_id = self._normalized_cycle_id(payload)
        state = str(payload.get("state", ""))

        self._ui_state["last_event_type"] = event_type
        self._ui_state["last_event_state"] = state
        self._ui_state["last_updated_ts"] = now_wall

        current_cycle = self._ui_state.get("cycle_id")
        ui_phase = self._ui_state.get("ui_phase")
        hold_until = self._ui_state.get("hold_until_monotonic")

        # Promote a new active cycle when explicit cycle-bound event arrives.
        if cycle_id and cycle_id != current_cycle and event_type in {"state", "transcript", "response"}:
            self._ui_state["cycle_id"] = cycle_id
            self._ui_state["ui_phase"] = "active"
            self._ui_state["transcript"] = ""
            self._ui_state["response"] = ""
            self._ui_state["is_speaking_lock"] = False
            self._ui_state["hold_until_monotonic"] = None
            self._ui_state["hold_seconds"] = 0.0
            current_cycle = cycle_id
            ui_phase = "active"

        if event_type == "transcript" and cycle_id and cycle_id == current_cycle:
            self._ui_state["transcript"] = str(payload.get("text", "")).strip()
            if self._ui_state["ui_phase"] == "idle":
                self._ui_state["ui_phase"] = "active"

        if event_type == "response" and cycle_id and cycle_id == current_cycle:
            self._ui_state["response"] = str(payload.get("text", "")).strip()
            if self._ui_state["ui_phase"] == "idle":
                self._ui_state["ui_phase"] = "active"

        if event_type == "clear":
            if cycle_id and cycle_id == current_cycle and self._ui_state.get("ui_phase") == "idle":
                self._ui_state["transcript"] = ""
                self._ui_state["response"] = ""
            return

        if event_type == "voice_status":
            self._ui_state["voice_state"] = str(payload.get("state", self._ui_state.get("voice_state", "idle")) or "idle")
            self._ui_state["stt_backend"] = str(payload.get("backend", self._ui_state.get("stt_backend", "unknown")) or "unknown")
            self._ui_state["capture_backend"] = str(
                payload.get("capture_backend", self._ui_state.get("capture_backend", self._ui_state.get("stt_backend", "unknown")))
                or self._ui_state.get("stt_backend", "unknown"),
            )
            self._ui_state["input_device_name"] = str(
                payload.get("input_device_name", self._ui_state.get("input_device_name", "none")) or "none",
            )
            self._ui_state["input_device_index"] = payload.get("input_device_index")
            self._ui_state["input_sample_rate"] = payload.get("input_sample_rate", self._ui_state.get("input_sample_rate"))
            self._ui_state["input_channels"] = payload.get("input_channels", self._ui_state.get("input_channels", 1))
            self._ui_state["input_dtype"] = payload.get("input_dtype", self._ui_state.get("input_dtype", "int16"))
            self._ui_state["microphone_available"] = bool(payload.get("microphone_available"))
            raw_error = payload.get("microphone_error")
            self._ui_state["microphone_error"] = str(raw_error).strip() if isinstance(raw_error, str) and raw_error.strip() else None
            self._ui_state["input_level_rms"] = float(payload.get("input_level_rms", self._ui_state.get("input_level_rms", 0.0)) or 0.0)
            self._ui_state["input_level_peak"] = int(payload.get("input_level_peak", self._ui_state.get("input_level_peak", 0)) or 0)
            self._ui_state["input_level_norm"] = float(payload.get("input_level_norm", self._ui_state.get("input_level_norm", 0.0)) or 0.0)
            return

        if event_type != "state":
            return

        self._ui_state["voice_state"] = state or self._ui_state.get("voice_state", "idle")

        if state == "thinking" and cycle_id and cycle_id == current_cycle:
            self._ui_state["ui_phase"] = "active"
            self._ui_state["is_speaking_lock"] = False
            self._ui_state["hold_until_monotonic"] = None
            self._ui_state["hold_seconds"] = 0.0
            return

        if state == "speaking" and cycle_id and cycle_id == current_cycle:
            self._ui_state["ui_phase"] = "active"
            self._ui_state["is_speaking_lock"] = True
            self._ui_state["hold_until_monotonic"] = None
            self._ui_state["hold_seconds"] = 0.0
            return

        if state == "paused":
            self._ui_state["ui_phase"] = "paused"
            self._ui_state["cycle_id"] = None
            self._ui_state["transcript"] = ""
            self._ui_state["response"] = ""
            self._ui_state["is_speaking_lock"] = False
            self._ui_state["hold_until_monotonic"] = None
            self._ui_state["hold_seconds"] = 0.0
            return

        if state in {"audio_finished", "cooldown"} and cycle_id and cycle_id == current_cycle:
            hold_seconds = self._clamped_positive_float(payload.get("seconds"), 0.0)
            self._ui_state["ui_phase"] = "hold"
            self._ui_state["is_speaking_lock"] = False
            self._ui_state["hold_seconds"] = hold_seconds
            self._ui_state["hold_until_monotonic"] = now_mono + hold_seconds
            return

        if state == "idle":
            # Keep active/hold visual state if an early idle is emitted before hold expires.
            if cycle_id and cycle_id == current_cycle:
                if bool(self._ui_state.get("is_speaking_lock", False)):
                    LOGGER.warning(
                        "UI EventHub ignoring premature idle for cycle=%s while speaking_lock=true",
                        cycle_id,
                    )
                    return
                hold_active = isinstance(hold_until, (int, float)) and now_mono < hold_until
                if hold_active:
                    LOGGER.warning(
                        "UI EventHub ignoring premature idle for cycle=%s hold_remaining=%.3fs",
                        cycle_id,
                        hold_until - now_mono,
                    )
                    return
                self._ui_state["ui_phase"] = "idle"
                self._ui_state["is_speaking_lock"] = False
                self._ui_state["hold_until_monotonic"] = None
                self._ui_state["hold_seconds"] = 0.0
                self._ui_state["transcript"] = ""
                self._ui_state["response"] = ""
                return

            if self._ui_state["ui_phase"] == "paused":
                self._ui_state["ui_phase"] = "idle"
                self._ui_state["cycle_id"] = None
                self._ui_state["transcript"] = ""
                self._ui_state["response"] = ""
                self._ui_state["is_speaking_lock"] = False
                self._ui_state["hold_until_monotonic"] = None
                self._ui_state["hold_seconds"] = 0.0
                return

            # Non-cycle idle/listening updates are allowed only when fully idle.
            if self._ui_state["ui_phase"] == "idle":
                self._ui_state["is_speaking_lock"] = False
                self._ui_state["hold_until_monotonic"] = None
                self._ui_state["hold_seconds"] = 0.0

    def get_ui_state(self) -> dict[str, Any]:
        """Return resilient UI state for reconnect hydration."""
        with self._lock:
            now_mono = time.monotonic()
            hold_until = self._ui_state.get("hold_until_monotonic")
            if isinstance(hold_until, (int, float)):
                hold_remaining = max(0.0, hold_until - now_mono)
            else:
                hold_remaining = 0.0
            return {
                "cycle_id": self._ui_state.get("cycle_id"),
                "uiPhase": self._ui_state.get("ui_phase", "idle"),
                "transcript": self._ui_state.get("transcript", ""),
                "response": self._ui_state.get("response", ""),
                "is_speaking_lock": bool(self._ui_state.get("is_speaking_lock", False)),
                "hold_remaining": hold_remaining,
                "voice_state": self._ui_state.get("voice_state", "idle"),
                "stt_backend": self._ui_state.get("stt_backend", "unknown"),
                "capture_backend": self._ui_state.get("capture_backend", self._ui_state.get("stt_backend", "unknown")),
                "input_device_name": self._ui_state.get("input_device_name", "none"),
                "input_device_index": self._ui_state.get("input_device_index"),
                "input_sample_rate": self._ui_state.get("input_sample_rate"),
                "input_channels": self._ui_state.get("input_channels", 1),
                "input_dtype": self._ui_state.get("input_dtype", "int16"),
                "microphone_available": bool(self._ui_state.get("microphone_available", False)),
                "microphone_error": self._ui_state.get("microphone_error"),
                "input_level_rms": float(self._ui_state.get("input_level_rms", 0.0) or 0.0),
                "input_level_peak": int(self._ui_state.get("input_level_peak", 0) or 0),
                "input_level_norm": float(self._ui_state.get("input_level_norm", 0.0) or 0.0),
                "last_event": dict(self._last_event),
                "last_state": dict(self._last_state),
                "last_updated_ts": self._ui_state.get("last_updated_ts"),
            }

    def get_reconnect_state_event(self) -> dict[str, Any]:
        """Return synthetic state event for websocket bootstrap compatibility."""
        snapshot = self.get_ui_state()
        ui_phase = str(snapshot.get("uiPhase", "idle"))
        cycle_id = snapshot.get("cycle_id")
        last_event = snapshot.get("last_event") or {}
        event_seq = last_event.get("event_seq")

        if ui_phase == "hold":
            payload: dict[str, Any] = {"type": "state", "state": "cooldown", "seconds": snapshot.get("hold_remaining", 0.0)}
            payload["cycle_id"] = cycle_id
            if isinstance(event_seq, int):
                payload["event_seq"] = event_seq
            return payload

        if ui_phase == "active":
            payload = {"type": "state", "state": "speaking" if snapshot.get("is_speaking_lock") else "thinking"}
            payload["cycle_id"] = cycle_id
            if isinstance(event_seq, int):
                payload["event_seq"] = event_seq
            return payload

        if ui_phase == "paused":
            payload = {"type": "state", "state": "paused", "cycle_id": cycle_id}
            if isinstance(event_seq, int):
                payload["event_seq"] = event_seq
            return payload

        payload = {"type": "state", "state": "idle", "cycle_id": cycle_id}
        if isinstance(event_seq, int):
            payload["event_seq"] = event_seq
        return payload

    def snapshot(self) -> dict[str, Any]:
        """Return debug snapshot with clients and latest payloads."""
        with self._lock:
            now_mono = time.monotonic()
            hold_until = self._ui_state.get("hold_until_monotonic")
            if isinstance(hold_until, (int, float)):
                hold_remaining = max(0.0, hold_until - now_mono)
            else:
                hold_remaining = 0.0
            return {
                "client_count": len(self._clients),
                "last_state": dict(self._last_state),
                "last_event": dict(self._last_event),
                "ui_state": {
                    "cycle_id": self._ui_state.get("cycle_id"),
                    "uiPhase": self._ui_state.get("ui_phase", "idle"),
                    "transcript": self._ui_state.get("transcript", ""),
                    "response": self._ui_state.get("response", ""),
                    "is_speaking_lock": bool(self._ui_state.get("is_speaking_lock", False)),
                    "hold_remaining": hold_remaining,
                    "voice_state": self._ui_state.get("voice_state", "idle"),
                    "stt_backend": self._ui_state.get("stt_backend", "unknown"),
                    "capture_backend": self._ui_state.get("capture_backend", self._ui_state.get("stt_backend", "unknown")),
                    "input_device_name": self._ui_state.get("input_device_name", "none"),
                    "input_device_index": self._ui_state.get("input_device_index"),
                    "input_sample_rate": self._ui_state.get("input_sample_rate"),
                    "input_channels": self._ui_state.get("input_channels", 1),
                    "input_dtype": self._ui_state.get("input_dtype", "int16"),
                    "microphone_available": bool(self._ui_state.get("microphone_available", False)),
                    "microphone_error": self._ui_state.get("microphone_error"),
                    "input_level_rms": float(self._ui_state.get("input_level_rms", 0.0) or 0.0),
                    "input_level_peak": int(self._ui_state.get("input_level_peak", 0) or 0),
                    "input_level_norm": float(self._ui_state.get("input_level_norm", 0.0) or 0.0),
                    "last_event": dict(self._last_event),
                    "last_state": dict(self._last_state),
                    "last_updated_ts": self._ui_state.get("last_updated_ts"),
                },
            }

    @staticmethod
    async def _send_json_text(websocket: WebSocket, payload_json: str) -> None:
        """Send pre-serialized JSON text to one websocket."""
        await websocket.send_text(payload_json)

    @staticmethod
    def _payload_for_log(payload: dict[str, Any]) -> dict[str, Any]:
        """Return a privacy-aware payload copy for structured logs."""
        redacted_keys = {"text", "response", "transcript", "message", "prompt"}
        sanitized: dict[str, Any] = {}
        for key, value in payload.items():
            if key in redacted_keys and isinstance(value, str):
                if settings.privacy_mode:
                    sanitized[key] = f"<redacted:{len(value)}>"
                else:
                    sanitized[key] = value
                continue
            if isinstance(value, str) and ("/Users/" in value or "\\Users\\" in value):
                sanitized[key] = "<path-redacted>"
                continue
            sanitized[key] = value
        return sanitized

    def broadcast(self, payload: dict[str, Any]) -> None:
        """Broadcast one JSON payload to all connected websocket clients."""
        now_wall = time.time()
        now_mono = time.monotonic()

        with self._lock:
            self._event_seq += 1
            payload_to_send = dict(payload)
            if "cycle_id" not in payload_to_send:
                payload_to_send["cycle_id"] = None
            payload_to_send["event_seq"] = self._event_seq
            try:
                payload_json = json.dumps(payload_to_send, ensure_ascii=False)
            except (TypeError, ValueError):
                LOGGER.warning("Skipping non-serializable UI event payload: %r", payload)
                return

            cycle_id = self._normalized_cycle_id(payload_to_send)
            self._last_event = dict(payload_to_send)
            if payload_to_send.get("type") == "state":
                self._last_state = dict(payload_to_send)
            if cycle_id:
                self._last_event_by_cycle[cycle_id] = dict(payload_to_send)
                self._trim_cycle_cache_locked()
            self._apply_payload_to_ui_state_locked(payload=payload_to_send, now_wall=now_wall, now_mono=now_mono)
            targets = list(self._clients.items())
            total = len(targets)

        LOGGER.info(
            "UI EventHub broadcast ts_ms=%s seq=%s clients=%s type=%s state=%s cycle_id=%s payload=%s",
            int(now_wall * 1000),
            payload_to_send.get("event_seq"),
            total,
            payload_to_send.get("type"),
            payload_to_send.get("state"),
            cycle_id,
            self._payload_for_log(payload_to_send),
        )
        if total == 0 and cycle_id:
            LOGGER.info("UI EventHub retained cycle payload with no clients cycle_id=%s", cycle_id)

        for websocket, loop in targets:
            try:
                future = asyncio.run_coroutine_threadsafe(
                    self._send_json_text(websocket, payload_json),
                    loop,
                )

                def _on_done(done_future, ws: WebSocket = websocket) -> None:  # type: ignore[no-untyped-def]
                    try:
                        exc = done_future.exception()
                    except Exception:
                        exc = True
                    if exc is not None:
                        LOGGER.warning("UI EventHub send failed: %s", exc)
                        self.unregister(ws)

                future.add_done_callback(_on_done)
            except Exception as exc:  # pragma: no cover - defensive runtime path
                LOGGER.warning("UI EventHub schedule failed: %s", exc)
                self.unregister(websocket)


event_hub = EventHub()
