from __future__ import annotations

import math
import queue
import threading
import time
from collections.abc import Callable
from contextlib import suppress
from datetime import UTC, datetime
from pathlib import Path
from typing import Literal, Protocol, TypedDict

from k1link.artifacts import utc_now_iso, write_json_atomic
from k1link.compute.live_perception import (
    LivePerceptionResultFrame,
    decode_live_perception_result,
)
from k1link.data_plane import DecodedDataPlaneView, NormalizationError
from k1link.device_plugins.xgrids_k1.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt
from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
from k1link.device_plugins.xgrids_k1.viewer.replay import iter_replay_messages
from k1link.viewer.metrics import BridgeMetrics, MetricsSnapshot
from k1link.viewer.rerun_bridge import DEFAULT_GRPC_PORT, RerunBridge, RerunSceneSettings

RuntimePhase = Literal[
    "idle",
    "starting_live",
    "live",
    "replay",
    "stopping",
    "error",
]
LIVE_CAPTURE_CLOCK_READY_TIMEOUT_SECONDS = 20.0
SourceMode = Literal["idle", "live", "replay"]
StateCallback = Callable[[], None]
BridgeFactory = Callable[..., RerunBridge]
# TODO: replace the mixed-modality FIFO with a latest point-cloud slot and a
# bounded pose queue. The compact queue protects acquisition from a slow
# visualizer, but under sustained pressure it can still evict pose messages.
PREVIEW_QUEUE_SIZE = 4
PERCEPTION_PREVIEW_QUEUE_SIZE = 2


class CanonicalNormalizer(Protocol):
    def __call__(
        self,
        message: StreamMessage,
        *,
        processing_started_monotonic_ns: int,
    ) -> DecodedDataPlaneView | None: ...


class RawMessageObserver(Protocol):
    def __call__(self, message: StreamMessage, metrics: BridgeMetrics) -> bool: ...


class RuntimeSnapshot(TypedDict):
    phase: RuntimePhase
    message: str
    source_mode: SourceMode
    source_ready: bool
    foxglove_ws_url: str | None
    foxglove_viewer_url: str | None
    rerun_grpc_url: str | None
    viewer_settings: dict[str, object]
    metrics: MetricsSnapshot


class VisualizationRuntime:
    """Own one bounded live/replay source and one deterministic publisher thread."""

    def __init__(
        self,
        *,
        on_state_change: StateCallback | None = None,
        grpc_port: int = DEFAULT_GRPC_PORT,
        bridge_factory: BridgeFactory | None = None,
        normalizer: CanonicalNormalizer,
        message_observer: RawMessageObserver | None = None,
    ) -> None:
        self._lock = threading.Lock()
        self._on_state_change = on_state_change
        self._thread: threading.Thread | None = None
        self._stop_event = threading.Event()
        self._phase: RuntimePhase = "idle"
        self._message = "Готово. Включите устройство и начните с поиска по Bluetooth."
        self._source_mode: SourceMode = "idle"
        self._source_ready = False
        self._foxglove_ws_url: str | None = None
        self._foxglove_viewer_url: str | None = None
        self._rerun_grpc_url: str | None = None
        self._grpc_port = grpc_port
        self._bridge_factory = bridge_factory or RerunBridge
        self._normalizer = normalizer
        self._message_observer = message_observer
        self._bridge: RerunBridge | None = None
        self._closed = False
        self._scene_settings = RerunSceneSettings()
        self._metrics = BridgeMetrics()
        self._perception_messages: queue.Queue[LivePerceptionResultFrame] = queue.Queue(
            maxsize=PERCEPTION_PREVIEW_QUEUE_SIZE
        )

    def snapshot(self) -> RuntimeSnapshot:
        with self._lock:
            return {
                "phase": self._phase,
                "message": self._message,
                "source_mode": self._source_mode,
                "source_ready": self._source_ready,
                "foxglove_ws_url": self._foxglove_ws_url,
                "foxglove_viewer_url": self._foxglove_viewer_url,
                "rerun_grpc_url": self._rerun_grpc_url,
                "viewer_settings": self._scene_settings.as_dict(),
                "metrics": self._metrics.snapshot(),
            }

    def update_scene_settings(self, settings: RerunSceneSettings) -> RuntimeSnapshot:
        with self._lock:
            self._scene_settings = settings
        self._notify()
        return self.snapshot()

    def publish_perception_result(self, encoded: bytes) -> bool:
        """Admit one validated latest-wins AI result without blocking acquisition."""

        frame = decode_live_perception_result(encoded)
        with self._lock:
            active = self._source_mode in {"live", "replay"} and not self._closed
        if not active:
            return False
        try:
            self._perception_messages.put_nowait(frame)
            return True
        except queue.Full:
            pass
        with suppress(queue.Empty):
            self._perception_messages.get_nowait()
            self._perception_messages.task_done()
            self._metrics.perception_dropped()
        try:
            self._perception_messages.put_nowait(frame)
            return True
        except queue.Full:
            return False

    def start_replay(self, path: Path, *, speed: float = 1.0, loop: bool = False) -> None:
        resolved = path.expanduser().resolve()
        if not resolved.is_file():
            raise ValueError("файл записи не найден на этом компьютере")
        if not math.isfinite(speed) or speed < 0:
            raise ValueError("скорость повтора должна быть неотрицательным числом")
        # Validate the reviewed shape before changing runtime state.
        iterator = iter_replay_messages(resolved)
        try:
            next(iterator)
        except StopIteration as exc:
            raise ValueError("в записи нет сообщений") from exc
        finally:
            iterator.close()

        self._start(
            source_mode="replay",
            phase="replay",
            message=f"Запускаем повтор записи: {resolved.name}",
            target=lambda: self._run_replay(resolved, speed=speed, loop=loop),
        )

    def start_live(
        self,
        host: str,
        out_dir: Path,
        *,
        duration_seconds: float | None = None,
        project_name: str,
    ) -> None:
        if duration_seconds is not None and (
            not math.isfinite(duration_seconds) or duration_seconds <= 0
        ):
            raise ValueError("длительность приёма должна быть больше нуля")
        clock_established = threading.Event()
        self._start(
            source_mode="live",
            phase="starting_live",
            message="Запускаем приём MQTT и локальный мост визуализации.",
            target=lambda: self._run_live(
                host,
                out_dir.expanduser().resolve(),
                duration_seconds=duration_seconds,
                project_name=project_name,
                clock_established=clock_established,
            ),
        )
        deadline = time.monotonic() + LIVE_CAPTURE_CLOCK_READY_TIMEOUT_SECONDS
        while not clock_established.wait(timeout=0.05):
            snapshot = self.snapshot()
            with self._lock:
                thread_alive = self._thread is not None and self._thread.is_alive()
            if snapshot["phase"] == "error" or not thread_alive:
                raise RuntimeError("live capture завершился до фиксации общего session clock")
            if time.monotonic() >= deadline:
                with suppress(RuntimeError):
                    self.stop()
                raise RuntimeError("live capture clock не был готов за отведённое время")

    def stop(self, *, wait_seconds: float = 5.0) -> None:
        notify_only = False
        with self._lock:
            thread = self._thread
            if thread is None or not thread.is_alive():
                self._phase = "idle"
                self._source_mode = "idle"
                self._source_ready = False
                self._message = "Активного потока нет."
                notify_only = True
            else:
                self._phase = "stopping"
                self._message = "Останавливаем поток и сохраняем полученные данные."
                self._stop_event.set()
        self._notify()
        if notify_only:
            return
        assert thread is not None
        thread.join(timeout=wait_seconds)
        if thread.is_alive():
            raise RuntimeError("поток не завершился за отведённое время; повторите остановку")

    def close(self, *, wait_seconds: float = 5.0) -> None:
        """Stop the active source and release the process-wide visual bridge."""
        with self._lock:
            self._closed = True
            thread = self._thread
            if thread is not None and thread.is_alive():
                self._phase = "stopping"
                self._message = "Завершаем локальный поток и визуальный мост."
                self._stop_event.set()
            else:
                self._phase = "idle"
                self._source_mode = "idle"
                self._source_ready = False
                self._message = "Локальный поток завершён."
        self._notify()

        if thread is not None and thread.is_alive():
            thread.join(timeout=wait_seconds)

        with self._lock:
            thread_alive = self._thread is not None and self._thread.is_alive()
            bridge = None if thread_alive else self._bridge
            if not thread_alive:
                self._bridge = None
                self._rerun_grpc_url = None
        if thread_alive:
            self._notify()
            raise RuntimeError(
                "поток не завершился за отведённое время; runtime и evidence lease сохранены"
            )
        if bridge is not None:
            try:
                bridge.close()
            except BaseException as exc:
                self._finish_error(
                    f"Ошибка завершения визуального моста: {type(exc).__name__}: {exc}"
                )
                raise
        self._notify()

    def _start(
        self,
        *,
        source_mode: SourceMode,
        phase: RuntimePhase,
        message: str,
        target: Callable[[], None],
    ) -> None:
        with self._lock:
            if self._closed:
                raise RuntimeError("runtime завершён; перезапустите локальный сервер")
            if self._thread is not None and self._thread.is_alive():
                raise RuntimeError("поток уже запущен; сначала остановите текущую сессию")
            self._stop_event = threading.Event()
            self._metrics = BridgeMetrics()
            self._phase = phase
            self._source_mode = source_mode
            self._source_ready = False
            self._message = message
            self._foxglove_ws_url = None
            self._foxglove_viewer_url = None
            self._thread = threading.Thread(
                target=lambda: self._run_target_safely(target, source_mode),
                name=f"k1-{source_mode}-session",
                daemon=True,
            )
            self._thread.start()
        self._notify()

    def _run_target_safely(
        self,
        target: Callable[[], None],
        source_mode: SourceMode,
    ) -> None:
        """Convert setup failures before the pipeline into observable runtime state."""

        try:
            target()
        except BaseException as exc:
            with self._lock:
                closed = self._closed
            if closed:
                return
            self._finish_error(f"Ошибка {source_mode}-источника: {type(exc).__name__}: {exc}")

    def _run_replay(self, path: Path, *, speed: float, loop: bool) -> None:
        def produce(put: Callable[[StreamMessage], None]) -> str:
            while not self._stop_event.is_set():
                first_source_ns: int | None = None
                replay_started_ns = time.monotonic_ns()
                count = 0
                for message in iter_replay_messages(path):
                    if self._stop_event.is_set():
                        return "Повтор записи остановлен."
                    source_ns = (
                        message.received_monotonic_ns
                        if message.received_monotonic_ns is not None
                        else message.received_at_epoch_ns
                    )
                    if first_source_ns is None:
                        first_source_ns = source_ns
                    if speed > 0:
                        target_ns = replay_started_ns + int((source_ns - first_source_ns) / speed)
                        remaining = (target_ns - time.monotonic_ns()) / 1_000_000_000
                        if remaining > 0 and self._stop_event.wait(remaining):
                            return "Повтор записи остановлен."
                    put(message)
                    count += 1
                if not loop:
                    return f"Повтор завершён: обработано сообщений MQTT — {count}."
            return "Повтор записи остановлен."

        self._run_pipeline(produce, running_phase="replay")

    def _run_live(
        self,
        host: str,
        out_dir: Path,
        *,
        duration_seconds: float | None,
        project_name: str,
        clock_established: threading.Event,
    ) -> None:
        _write_live_session_preamble(out_dir, host, duration_seconds, project_name)

        def produce(put: Callable[[StreamMessage], None]) -> str:
            def on_message(message: CapturedMqttMessage) -> None:
                put(
                    StreamMessage(
                        sequence=message.sequence,
                        topic=message.topic,
                        payload=message.payload,
                        received_at_epoch_ns=message.received_at_epoch_ns,
                        received_monotonic_ns=message.received_monotonic_ns,
                        source="live_mqtt",
                    )
                )

            summary = capture_mqtt(
                host,
                out_dir / "captures" / "mqtt_live",
                duration_seconds=duration_seconds,
                on_clock_established=clock_established.set,
                on_ready=lambda: self._set_running(
                    "live",
                    "Приём запущен. Теперь дважды нажмите физическую кнопку устройства.",
                ),
                on_message_recorded=on_message,
                should_stop=self._stop_event.is_set,
            )
            return f"Приём остановлен. Сохранено сообщений: {summary['message_count']}."

        try:
            self._run_pipeline(produce, running_phase="live")
        finally:
            _finalize_live_session(out_dir, self.snapshot())

    def _run_pipeline(
        self,
        producer: Callable[[Callable[[StreamMessage], None]], str],
        *,
        running_phase: RuntimePhase,
    ) -> None:
        messages: queue.Queue[StreamMessage] = queue.Queue(maxsize=PREVIEW_QUEUE_SIZE)
        source_done = threading.Event()
        publisher_ready = threading.Event()
        publisher_aborted = threading.Event()
        publisher_error: list[BaseException] = []
        while True:
            try:
                self._perception_messages.get_nowait()
                self._perception_messages.task_done()
            except queue.Empty:
                break

        def enqueue(message: StreamMessage) -> None:
            # A plugin may consume non-visual status before the bounded preview
            # FIFO. This keeps control telemetry from evicting visual frames
            # without embedding a vendor decoder in the visual runtime.
            if self._message_observer is not None and self._message_observer(
                message, self._metrics
            ):
                self._notify()
                return
            try:
                messages.put_nowait(message)
                return
            except queue.Full:
                pass
            try:
                messages.get_nowait()
                messages.task_done()
            except queue.Empty:
                pass
            self._metrics.preview_dropped()
            try:
                messages.put_nowait(message)
            except queue.Full:
                self._metrics.preview_dropped()

        def publish() -> None:
            bridge: RerunBridge | None = None
            try:
                with self._lock:
                    bridge = self._bridge
                    if self._closed:
                        publisher_aborted.set()
                if publisher_aborted.is_set():
                    publisher_ready.set()
                    return
                if bridge is None:
                    candidate = self._bridge_factory(
                        grpc_port=self._grpc_port,
                        metrics=self._metrics,
                        settings_provider=self._current_scene_settings,
                    )
                    bridge = candidate
                    with self._lock:
                        if self._closed:
                            publisher_aborted.set()
                        else:
                            self._bridge = candidate
                if publisher_aborted.is_set():
                    publisher_ready.set()
                    return
                assert bridge is not None
                bridge.begin_session(self._metrics)
                with self._lock:
                    if self._closed:
                        publisher_aborted.set()
                    else:
                        self._rerun_grpc_url = bridge.grpc_url
                    if running_phase == "replay" and not self._closed and self._phase != "stopping":
                        self._phase = running_phase
                        self._source_ready = True
                        self._message = "Локальный Rerun-мост готов; источник данных запущен."
                publisher_ready.set()
                self._notify()
                if publisher_aborted.is_set():
                    return
                while (
                    not source_done.is_set()
                    or not messages.empty()
                    or not self._perception_messages.empty()
                ):
                    if (
                        self._stop_event.is_set()
                        and source_done.is_set()
                        and messages.empty()
                        and self._perception_messages.empty()
                    ):
                        break
                    try:
                        perception = self._perception_messages.get_nowait()
                    except queue.Empty:
                        perception = None
                    if perception is not None:
                        try:
                            bridge.process_perception(perception)
                        finally:
                            self._perception_messages.task_done()
                        continue
                    try:
                        message = messages.get(timeout=0.05)
                    except queue.Empty:
                        continue
                    try:
                        processing_started_ns = time.monotonic_ns()
                        self._metrics.received(len(message.payload))
                        try:
                            envelope = self._normalizer(
                                message,
                                processing_started_monotonic_ns=processing_started_ns,
                            )
                        except NormalizationError:
                            self._metrics.decode_error()
                        else:
                            if envelope is not None:
                                bridge.process(envelope)
                    finally:
                        messages.task_done()
                    if self._metrics.snapshot()["messages_received"] % 10 == 0:
                        self._notify()
            except BaseException as exc:
                with self._lock:
                    closed = self._closed
                if not closed:
                    publisher_error.append(exc)
                else:
                    publisher_aborted.set()
                publisher_ready.set()
                self._stop_event.set()
            finally:
                if bridge is not None:
                    with self._lock:
                        close_bridge = self._closed and (
                            self._bridge is bridge or publisher_aborted.is_set()
                        )
                        if close_bridge and self._bridge is bridge:
                            self._bridge = None
                            self._rerun_grpc_url = None
                    if close_bridge:
                        try:
                            bridge.close()
                        except BaseException as exc:
                            # Process shutdown must not become a false successful
                            # idle state when the native bridge failed to close.
                            publisher_error.append(exc)

        def join_publisher() -> None:
            # Never orphan a publisher: the session thread remains its owner.
            # On process shutdown both are daemon threads, so an irrecoverably
            # blocked native call cannot prevent the operating system from exit.
            while publisher.is_alive():
                publisher.join(timeout=0.25)

        publisher = threading.Thread(target=publish, name="k1-rerun-publisher", daemon=True)
        publisher.start()
        if not publisher_ready.wait(timeout=15.0):
            self._finish_error("Локальный мост визуализации не запустился за 15 секунд.")
            source_done.set()
            self._stop_event.set()
            join_publisher()
            return
        if publisher_aborted.is_set():
            source_done.set()
            self._stop_event.set()
            join_publisher()
            return
        if publisher_error:
            self._finish_error(
                "Ошибка локального моста визуализации: "
                f"{type(publisher_error[0]).__name__}: {publisher_error[0]}"
            )
            source_done.set()
            join_publisher()
            return

        final_message = "Поток остановлен."
        try:
            final_message = producer(enqueue)
        except CaptureError as exc:
            self._finish_error(f"Ошибка приёма MQTT: {exc}")
        except (OSError, RuntimeError, ValueError) as exc:
            self._finish_error(f"Ошибка источника: {type(exc).__name__}: {exc}")
        finally:
            source_done.set()
            join_publisher()
            if publisher_error:
                self._finish_error(
                    f"Ошибка публикации: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
                )
            elif self.snapshot()["phase"] != "error":
                self._finish_idle(final_message)

    def _set_running(self, phase: RuntimePhase, message: str) -> None:
        with self._lock:
            if self._phase != "stopping":
                self._phase = phase
                self._source_ready = True
                self._message = message
        self._notify()

    def _finish_idle(self, message: str) -> None:
        with self._lock:
            self._phase = "idle"
            self._source_mode = "idle"
            self._source_ready = False
            self._message = message
            self._foxglove_ws_url = None
            self._foxglove_viewer_url = None
        self._notify()

    def _finish_error(self, message: str) -> None:
        with self._lock:
            self._phase = "error"
            self._source_ready = False
            self._message = message
            self._foxglove_ws_url = None
            self._foxglove_viewer_url = None
        self._notify()

    def _current_scene_settings(self) -> RerunSceneSettings:
        with self._lock:
            return self._scene_settings

    def _notify(self) -> None:
        callback = self._on_state_change
        if callback is not None:
            callback()


def new_live_session_dir(sessions_root: Path) -> Path:
    stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
    base = sessions_root / f"{stamp}_viewer_live"
    candidate = base
    suffix = 1
    while candidate.exists():
        suffix += 1
        candidate = base.with_name(f"{base.name}_{suffix:02d}")
    return candidate


def _write_live_session_preamble(
    out_dir: Path,
    host: str,
    duration_seconds: float | None,
    project_name: str,
) -> None:
    out_dir.mkdir(parents=True, exist_ok=False)
    started_at_utc = utc_now_iso()
    write_json_atomic(
        out_dir / "manifest.redacted.json",
        {
            "schema_version": 1,
            "started_at_utc": started_at_utc,
            "started_monotonic_ns": time.monotonic_ns(),
            "operation": "k1_live_mqtt_to_rerun",
            "target": "owner-controlled K1 at redacted RFC1918 address",
            "requested_duration_seconds": duration_seconds,
            # Operator-provided display metadata only. It is never used as a
            # path component and is normalized/validated at the plugin API.
            "project_name": project_name,
            "raw_capture": "captures/mqtt_live/mqtt.raw.k1mqtt",
            "credential_storage": "none",
        },
    )
    notes = (
        "# K1 live visualization session\n\n"
        f"Started UTC: {started_at_utc}\n\n"
        "The local control API started a read-only MQTT subscription and Rerun "
        "preview. Raw MQTT evidence is written before preview decoding. The target "
        f"was a validated private IPv4 address ({host.rsplit('.', 1)[0]}.x).\n"
    )
    notes_path = out_dir / "operator-notes.md"
    notes_path.write_text(notes, encoding="utf-8")
    notes_path.chmod(0o600)


def _finalize_live_session(out_dir: Path, snapshot: RuntimeSnapshot) -> None:
    manifest_path = out_dir / "manifest.redacted.json"
    try:
        import json

        payload = json.loads(manifest_path.read_text(encoding="utf-8"))
        payload["completed_at_utc"] = utc_now_iso()
        payload["completed_monotonic_ns"] = time.monotonic_ns()
        payload["final_phase"] = snapshot["phase"]
        payload["aggregate_metrics"] = snapshot["metrics"]
        write_json_atomic(manifest_path, payload)
    except (OSError, ValueError):
        # The MQTT capture summary remains authoritative if final annotation fails.
        return
