from __future__ import annotations

import inspect
import json
import struct
import threading
import time
from collections.abc import Callable
from pathlib import Path

import lz4.block
import pytest

import k1link.device_plugins.xgrids_k1.viewer.runtime as runtime_module
from k1link.data_plane import (
    ConsumerFrameContext,
    DecodedDeviceStatusView,
    DecodedPointCloudView,
    DecodedPoseView,
    NormalizationError,
)
from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC
from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
from k1link.device_plugins.xgrids_k1.viewer.runtime import VisualizationRuntime
from k1link.viewer.rerun_bridge import RerunBridge


class FakeRecording:
    def __init__(self) -> None:
        self.logs: list[tuple[str, object, bool]] = []

    def serve_grpc(self, **_: object) -> str:
        return "rerun+http://127.0.0.1:9876/proxy"

    def log(self, path: str, entity: object, *, static: bool = False) -> None:
        self.logs.append((path, entity, static))

    def set_time(self, _timeline: str, **_value: object) -> None:
        return

    def send_blueprint(self, _blueprint: object, **_: object) -> None:
        return

    def disconnect(self) -> None:
        return

    def flush(self, **_: object) -> None:
        return


def _message(
    topic: str,
    payload: bytes,
    *,
    source: str = "live_mqtt",
) -> StreamMessage:
    return StreamMessage(
        sequence=9,
        topic=topic,
        payload=payload,
        received_at_epoch_ns=1_784_124_315_186_225_000,
        received_monotonic_ns=100,
        source=source,
    )


def _varint(value: int) -> bytes:
    encoded = bytearray()
    while value > 0x7F:
        encoded.append((value & 0x7F) | 0x80)
        value >>= 7
    encoded.append(value)
    return bytes(encoded)


def _key(number: int, wire_type: int) -> bytes:
    return _varint((number << 3) | wire_type)


def _uint(number: int, value: int) -> bytes:
    return _key(number, 0) + _varint(value)


def _sint(number: int, value: int) -> bytes:
    zigzag = (value << 1) ^ (value >> 63)
    return _uint(number, zigzag & 0xFFFFFFFFFFFFFFFF)


def _bytes(number: int, value: bytes) -> bytes:
    return _key(number, 2) + _varint(len(value)) + value


def _fixed32(number: int, value: float) -> bytes:
    return _key(number, 5) + struct.pack("<f", value)


def _fixed64(number: int, value: float) -> bytes:
    return _key(number, 1) + struct.pack("<d", value)


def _lio_header() -> bytes:
    return b"".join(
        (
            _uint(1, 7),
            _sint(2, 123456),
            _sint(3, 1000),
            _bytes(4, b"device-redacted"),
            _bytes(5, b"session-redacted"),
        )
    )


def _lio_point_payload() -> bytes:
    point = _sint(1, 1000) + _sint(2, -2000) + _sint(3, 500) + _uint(4, 0x44)
    report = _bytes(1, _lio_header()) + _bytes(2, point)
    compressed = lz4.block.compress(report, store_size=False)
    return _uint(3, len(report)) + _bytes(4, compressed)


def _lio_pose_payload() -> bytes:
    position = _fixed64(1, 1.25) + _fixed64(2, -2.5) + _fixed64(3, 3.75)
    orientation = _fixed64(1, 0.0) + _fixed64(2, 0.0) + _fixed64(3, 0.0) + _fixed64(4, 1.0)
    pose = _bytes(1, position) + _bytes(2, orientation)
    stamped = _sint(1, 987654321) + _bytes(2, pose)
    return _bytes(1, _lio_header()) + _bytes(2, stamped) + _fixed32(3, 12.5) + _fixed32(4, 0.001)


def _context() -> ConsumerFrameContext:
    return ConsumerFrameContext(
        sequence=1,
        captured_at_epoch_ns=1,
        received_monotonic_ns=None,
        processing_started_monotonic_ns=1,
        encoded_size_bytes=1,
        live=False,
    )


def test_k1_normalizer_emits_transport_neutral_point_clouds() -> None:
    modern = normalize_k1_message(
        _message("lixel/application/report/lio_pcl", _lio_point_payload()),
        processing_started_monotonic_ns=50,
    )
    legacy_payload = struct.pack("<III", 16, 0, 0) + struct.pack(
        "<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
    )
    legacy = normalize_k1_message(
        _message("RealtimePointcloud", legacy_payload, source="legacy_tsv"),
        processing_started_monotonic_ns=60,
    )

    assert isinstance(modern, DecodedPointCloudView)
    assert modern.positions_xyz == ((1.0, -2.0, 0.5),)
    assert modern.intensities == bytes((0x44,))
    assert modern.colors_rgb is None
    assert modern.context.source_device_alias == "device-redacted"
    assert modern.context.source_session_alias == "session-redacted"
    assert modern.context.live is True
    assert not hasattr(modern, "topic")
    assert not hasattr(modern, "payload")

    assert isinstance(legacy, DecodedPointCloudView)
    assert legacy.positions_xyz == ((1.0, -2.0, 3.0),)
    assert legacy.intensities == bytes((40,))
    assert legacy.colors_rgb == bytes((10, 20, 30))
    assert legacy.context.source_device_alias is None
    assert legacy.context.live is False


def test_k1_normalizer_emits_transport_neutral_poses() -> None:
    modern = normalize_k1_message(
        _message("lixel/application/report/lio_pose", _lio_pose_payload()),
        processing_started_monotonic_ns=50,
    )
    legacy_payload = struct.pack("<ffffffff", 1.0, 2.0, 3.0, 99.0, 1.0, 0.0, 0.0, 0.0)
    legacy = normalize_k1_message(
        _message("RealtimePath", legacy_payload, source="legacy_tsv"),
        processing_started_monotonic_ns=60,
    )

    assert isinstance(modern, DecodedPoseView)
    assert modern.frame_id == "map"
    assert modern.child_frame_id == "sensor"
    assert modern.position_xyz == (1.25, -2.5, 3.75)
    assert modern.orientation_xyzw == (0.0, 0.0, 0.0, 1.0)
    assert modern.context.source_device_alias == "device-redacted"
    assert modern.context.source_session_alias == "session-redacted"

    assert isinstance(legacy, DecodedPoseView)
    assert legacy.position_xyz == (1.0, 2.0, 3.0)
    assert legacy.orientation_xyzw == (0.0, 0.0, 0.0, 1.0)
    assert legacy.context.source_device_alias is None


def test_known_invalid_and_unknown_channels_have_distinct_outcomes() -> None:
    with pytest.raises(NormalizationError) as caught:
        normalize_k1_message(
            _message("RealtimePointcloud", b"short"),
            processing_started_monotonic_ns=1,
        )
    assert "RealtimePointcloud" not in str(caught.value)

    assert (
        normalize_k1_message(
            _message("lixel/application/report/unverified", b"opaque"),
            processing_started_monotonic_ns=1,
        )
        is None
    )
    # The status channel is observed but its payload schema is not verified;
    # fail closed instead of inventing normalized status fields.
    assert (
        normalize_k1_message(
            _message("lixel/application/report/device_status", b"opaque"),
            processing_started_monotonic_ns=1,
        )
        is None
    )


def test_canonical_contracts_reject_misaligned_data_and_support_status() -> None:
    with pytest.raises(ValueError, match="RGB byte count"):
        DecodedPointCloudView(
            context=_context(),
            frame_id="map",
            positions_xyz=((0.0, 0.0, 0.0),),
            colors_rgb=b"\x00\x01",
        )

    status = DecodedDeviceStatusView(context=_context(), state="ready")
    assert status.state == "ready"


def test_rerun_bridge_source_has_no_vendor_protocol_or_raw_transport_knowledge() -> None:
    source = inspect.getsource(__import__("k1link.viewer.rerun_bridge", fromlist=["*"]))
    for forbidden in (
        "k1link.device_plugins.xgrids_k1.protocol",
        "StreamMessage",
        "RealtimePointcloud",
        "RealtimePath",
        "lio_pcl",
        "lio_pose",
        ".topic",
        ".payload",
    ):
        assert forbidden not in source


def test_visual_runtime_has_no_implicit_vendor_normalizer() -> None:
    source = inspect.getsource(
        __import__(
            "k1link.device_plugins.xgrids_k1.viewer.runtime",
            fromlist=["*"],
        )
    )

    assert "k1link.device_plugins.xgrids_k1.protocol" not in source
    assert "normalize_k1_message" not in source
    assert "normalizer: CanonicalNormalizer" in source


def test_runtime_counts_transport_and_normalization_failures_before_rerun(
    tmp_path: Path,
) -> None:
    topic = b"RealtimePointcloud"
    payload = b"short"
    capture = tmp_path / "mqtt.raw.k1mqtt"
    capture.write_bytes(RAW_MAGIC + FRAME_HEADER.pack(len(topic), len(payload)) + topic + payload)
    recording = FakeRecording()

    def bridge_factory(**kwargs: object) -> RerunBridge:
        return RerunBridge(
            recording_factory=lambda _: recording,  # type: ignore[arg-type]
            **kwargs,  # type: ignore[arg-type]
        )

    runtime = VisualizationRuntime(
        bridge_factory=bridge_factory,
        normalizer=normalize_k1_message,
    )
    runtime.start_replay(capture, speed=0.0)
    deadline = time.monotonic() + 5.0
    while runtime.snapshot()["phase"] != "idle" and time.monotonic() < deadline:
        time.sleep(0.01)

    snapshot = runtime.snapshot()
    assert snapshot["phase"] == "idle"
    assert snapshot["metrics"]["messages_received"] == 1
    assert snapshot["metrics"]["payload_bytes"] == len(payload)
    assert snapshot["metrics"]["decode_errors"] == 1
    assert snapshot["metrics"]["pcl_frames"] == 0
    assert not any(path == "/world/points" for path, _, _ in recording.logs)
    runtime.close()


def test_live_runtime_surfaces_preamble_failure_as_terminal_error(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    def fail_preamble(*_: object, **__: object) -> None:
        raise OSError("synthetic evidence directory failure")

    monkeypatch.setattr(runtime_module, "_write_live_session_preamble", fail_preamble)
    runtime = VisualizationRuntime(normalizer=normalize_k1_message)

    with pytest.raises(RuntimeError, match="session clock"):
        runtime.start_live(
            "192.168.1.20",
            tmp_path / "unwritable-evidence",
            duration_seconds=1.0,
            project_name="Preamble failure test",
        )
    deadline = time.monotonic() + 2.0
    snapshot = runtime.snapshot()
    while snapshot["phase"] == "starting_live" and time.monotonic() < deadline:
        time.sleep(0.01)
        snapshot = runtime.snapshot()

    assert snapshot["phase"] == "error"
    assert snapshot["source_mode"] == "live"
    assert snapshot["source_ready"] is False
    assert "synthetic evidence directory failure" in snapshot["message"]
    runtime.close()


def test_live_start_waits_for_capture_clock_before_returning(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    entered_capture = threading.Event()
    allow_clock = threading.Event()
    returned = threading.Event()
    failures: list[BaseException] = []
    recording = FakeRecording()

    def bridge_factory(**kwargs: object) -> RerunBridge:
        return RerunBridge(
            recording_factory=lambda _: recording,  # type: ignore[arg-type]
            **kwargs,  # type: ignore[arg-type]
        )

    def delayed_capture(
        _host: str,
        _out_dir: Path,
        *,
        on_clock_established: Callable[[], None],
        should_stop: Callable[[], bool],
        **_kwargs: object,
    ) -> dict[str, object]:
        entered_capture.set()
        assert allow_clock.wait(timeout=2.0)
        on_clock_established()
        while not should_stop():
            time.sleep(0.005)
        return {"message_count": 0}

    monkeypatch.setattr(runtime_module, "capture_mqtt", delayed_capture)
    runtime = VisualizationRuntime(
        bridge_factory=bridge_factory,
        normalizer=normalize_k1_message,
    )

    def start() -> None:
        try:
            runtime.start_live(
                "192.168.1.20",
                tmp_path / "delayed-clock",
                duration_seconds=30.0,
                project_name="Clock barrier test",
            )
        except BaseException as exc:
            failures.append(exc)
        finally:
            returned.set()

    caller = threading.Thread(target=start)
    caller.start()
    assert entered_capture.wait(timeout=2.0)
    assert not returned.wait(timeout=0.05)
    allow_clock.set()
    assert returned.wait(timeout=2.0)
    assert failures == []
    runtime.stop()
    caller.join(timeout=2.0)
    runtime.close()


def test_live_session_manifest_retains_project_display_name(tmp_path: Path) -> None:
    out_dir = tmp_path / "evidence-session"

    runtime_module._write_live_session_preamble(
        out_dir,
        "192.168.1.20",
        60.0,
        "K1 Route Alpha",
    )

    manifest = json.loads((out_dir / "manifest.redacted.json").read_text(encoding="utf-8"))
    assert manifest["project_name"] == "K1 Route Alpha"
    assert "192.168.1.20" not in json.dumps(manifest)
