from __future__ import annotations

import hashlib
import math
import os
import threading
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal, TypedDict
from uuid import UUID, uuid4

import numpy as np
import rerun as rr
from rerun import blueprint as rrb

from k1link.data_plane import DecodedPointCloudView, DecodedPoseView, NormalizationError
from k1link.device_plugins.xgrids_k1.mqtt.capture import (
    CAPTURE_CLOCK_FILENAME,
    CAPTURE_CLOCK_ORIGIN_FILENAME,
    CaptureClockEnvelope,
    CaptureClockOrigin,
    CaptureFormatError,
    read_capture_clock_envelope,
    read_capture_clock_origin,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling import (
    ModelingReportDecodeError,
    ModelingTelemetry,
    decode_modeling_report,
    is_modeling_report_topic,
)
from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
from k1link.device_plugins.xgrids_k1.viewer.replay import (
    ReplayFormatError,
    detect_replay_format,
    iter_replay_messages,
)
from k1link.viewer.rerun_bridge import (
    MAX_TRAJECTORY_POSES,
    TRAJECTORY_APPEND_INTERVAL_NS,
    TRAJECTORY_FORCE_APPEND_NS,
    TRAJECTORY_MIN_DISTANCE_METERS,
    TRAJECTORY_PUBLISH_INTERVAL_NS,
    RerunSceneSettings,
    _parse_hex_color,
    _point_colors,
)

APPLICATION_ID = "nodedc_mission_core_recorded"
SESSION_TIMELINE = "session_time"
CAPTURE_TIMELINE = "capture_time"
JS_MAX_SAFE_INTEGER = (1 << 53) - 1
RECORDED_VIEW_POINT_DECIMATION_THRESHOLD = 100_000
RECORDED_VIEW_POINT_STRIDE = 4
RECORDED_VIEW_POINT_FRAME_STRIDE = 5

# Rerun keys viewer state by these IDs.  Reusing them for every settings-only
# blueprint update makes the update overwrite the existing scene instead of
# creating a fresh view/container (which would also reset the operator's eye
# position and layout).
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303")
RECORDED_CAMERA_VIEW_ID = UUID("5c1db75b-07cd-479a-903d-f4f2ed554513")
RECORDED_PERCEPTION_3D_VIEW_ID = UUID("0496bd2e-2b4d-4a4f-87b8-3ce4f9f7e114")
RECORDED_METRICS_VIEW_ID = UUID("f973fc11-0867-4732-ad3c-97008621fab7")
RecordedView = Literal["spatial", "perception", "perception3d", "metrics"]


class RrdExportSummary(TypedDict):
    schema_version: int
    input_path: str
    output_path: str
    recording_id: str
    timeline: str
    capture_timeline: str
    source_messages: int
    decoded_messages: int
    point_frames: int
    pose_frames: int
    modeling_reports: int
    ignored_messages: int
    points: int
    trajectory_poses: int
    trajectory_updates: int
    session_origin_monotonic_ns: int
    timeline_start_ns: int
    timeline_end_ns: int
    timeline_span_ns: int
    first_decoded_time_ns: int
    last_decoded_time_ns: int
    source_sha256: str
    rrd_sha256: str
    rrd_bytes: int


class RrdExportError(RuntimeError):
    """A raw capture could not be converted into a complete durable RRD."""


class RrdExportCancelled(RrdExportError):
    """A background RRD export was cooperatively cancelled."""


@dataclass(slots=True)
class _ExportCounters:
    source_messages: int = 0
    point_frames: int = 0
    pose_frames: int = 0
    modeling_reports: int = 0
    ignored_messages: int = 0
    points: int = 0
    first_decoded_time_ns: int | None = None
    last_decoded_time_ns: int | None = None

    @property
    def decoded_messages(self) -> int:
        return self.point_frames + self.pose_frames

    def observe_decoded(self, session_time_ns: int) -> None:
        if self.first_decoded_time_ns is None:
            self.first_decoded_time_ns = session_time_ns
        self.last_decoded_time_ns = session_time_ns


@dataclass(slots=True)
class _TrajectoryBuffer:
    positions: list[tuple[float, float, float]]
    last_append_time_ns: int | None = None
    last_publish_time_ns: int | None = None
    updates: int = 0

    @classmethod
    def empty(cls) -> _TrajectoryBuffer:
        return cls(positions=[])

    def process(
        self,
        recording: rr.RecordingStream,
        position: tuple[float, float, float],
        session_time_ns: int,
    ) -> None:
        if not self._append(position, session_time_ns):
            return
        if (
            self.last_publish_time_ns is not None
            and session_time_ns - self.last_publish_time_ns < TRAJECTORY_PUBLISH_INTERVAL_NS
        ):
            return
        self.last_publish_time_ns = session_time_ns
        self.updates += 1
        recording.log(
            "/world/trajectory",
            rr.LineStrips3D(
                [list(self.positions)],
                colors=[247, 248, 244, 255],
                radii=rr.Radius.ui_points(2.0),
            ),
        )

    def _append(self, position: tuple[float, float, float], session_time_ns: int) -> bool:
        if not self.positions:
            self.positions.append(position)
            self.last_append_time_ns = session_time_ns
            return True

        assert self.last_append_time_ns is not None
        elapsed_ns = session_time_ns - self.last_append_time_ns
        if elapsed_ns < TRAJECTORY_APPEND_INTERVAL_NS:
            return False
        if (
            math.dist(self.positions[-1], position) < TRAJECTORY_MIN_DISTANCE_METERS
            and elapsed_ns < TRAJECTORY_FORCE_APPEND_NS
        ):
            return False

        self.positions.append(position)
        self.last_append_time_ns = session_time_ns
        if len(self.positions) > MAX_TRAJECTORY_POSES:
            last = self.positions[-1]
            self.positions = self.positions[::2]
            if self.positions[-1] != last:
                self.positions.append(last)
        return True


def export_k1mqtt_to_rrd(
    input_path: Path,
    output_path: Path,
    *,
    capture_clock_path: Path | None = None,
    capture_clock_origin_path: Path | None = None,
    cancel_event: threading.Event | None = None,
    activity_callback: Callable[[], None] | None = None,
) -> RrdExportSummary:
    """Project a bounded-rate view of K1 data into one operator RRD.

    The raw capture remains the source of record. The derived RRD uses a
    recording-local duration timeline whose zero is the durable capture-clock
    origin for v2 recordings (or the first raw message for legacy captures).
    It never traverses the bounded live-preview queue. Point-cloud frames and
    very dense point batches are deterministically sampled for interactive
    rendering while counters, poses, capture boundaries and the native capture
    remain complete. AI jobs always read the complete native capture.

    The destination is replaced only after the temporary RRD has been closed,
    flushed and fsynced. Any decode, timing, sink or rename failure therefore
    leaves an existing destination artifact untouched.
    """

    _raise_if_cancelled(cancel_event)
    source = input_path.expanduser().resolve()
    destination = output_path.expanduser().resolve()
    _validate_paths(source, destination)
    destination.parent.mkdir(parents=True, exist_ok=True)

    recording_id = str(uuid4())
    temporary = destination.with_name(f".{destination.name}.{recording_id}.tmp")
    source_sha256 = _sha256_file(
        source,
        cancel_event=cancel_event,
        activity_callback=activity_callback,
    )
    settings = RerunSceneSettings()
    blueprint = _recorded_blueprint(settings)
    recording: rr.RecordingStream | None = None
    recording_closed = False
    published = False

    counters = _ExportCounters()
    trajectory = _TrajectoryBuffer.empty()
    capture_clock = _optional_capture_clock(source, capture_clock_path)
    capture_clock_origin = _optional_capture_clock_origin(
        source,
        capture_clock_origin_path,
    )
    if (
        capture_clock is not None
        and capture_clock_origin is not None
        and (
            capture_clock.started_at_epoch_ns != capture_clock_origin.started_at_epoch_ns
            or capture_clock.started_monotonic_ns != capture_clock_origin.started_monotonic_ns
        )
    ):
        raise RrdExportError("native capture clock does not match its durable origin")
    session_origin_ns: int | None = (
        capture_clock.started_monotonic_ns
        if capture_clock is not None
        else capture_clock_origin.started_monotonic_ns
        if capture_clock_origin is not None
        else None
    )
    previous_monotonic_ns: int | None = None
    last_source_time_ns: int | None = None
    last_source_capture_ns: int | None = None

    try:
        recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id)
        recording.set_sinks(
            rr.FileSink(temporary, write_footer=True),
            default_blueprint=blueprint,
        )
        _log_static_scene(recording)
        _log_session_origin(recording)

        for message in iter_replay_messages(source):
            _raise_if_cancelled(cancel_event)
            _notify_activity(activity_callback)
            counters.source_messages += 1
            monotonic_ns = message.received_monotonic_ns
            if monotonic_ns is None:
                raise RrdExportError(
                    "native capture metadata must provide received_monotonic_ns "
                    f"for message {message.sequence}"
                )
            if previous_monotonic_ns is not None and monotonic_ns < previous_monotonic_ns:
                raise RrdExportError(
                    f"native capture monotonic time decreases at message {message.sequence}"
                )
            if session_origin_ns is None:
                session_origin_ns = monotonic_ns
            if monotonic_ns < session_origin_ns or (
                capture_clock is not None and monotonic_ns > capture_clock.completed_monotonic_ns
            ):
                raise RrdExportError(
                    f"native capture message {message.sequence} is outside its clock envelope"
                )
            session_time_ns = monotonic_ns - session_origin_ns
            if session_time_ns > JS_MAX_SAFE_INTEGER:
                raise RrdExportError(
                    "session duration exceeds the exact JavaScript nanosecond range"
                )
            previous_monotonic_ns = monotonic_ns
            last_source_time_ns = session_time_ns
            last_source_capture_ns = message.received_at_epoch_ns

            if is_modeling_report_topic(message.topic):
                try:
                    telemetry = decode_modeling_report(message.payload)
                except ModelingReportDecodeError as exc:
                    raise RrdExportError(
                        f"known K1 modeling report {message.sequence} failed validation"
                    ) from exc
                _set_frame_time(
                    recording,
                    message.sequence,
                    session_time_ns,
                    message.received_at_epoch_ns,
                )
                _log_modeling_telemetry(recording, telemetry)
                counters.modeling_reports += 1
                continue

            try:
                decoded = normalize_k1_message(
                    message,
                    processing_started_monotonic_ns=monotonic_ns,
                )
            except NormalizationError as exc:
                raise RrdExportError(
                    f"known K1 frame {message.sequence} failed normalization"
                ) from exc
            if decoded is None:
                counters.ignored_messages += 1
                continue

            _set_frame_time(
                recording,
                decoded.context.sequence,
                session_time_ns,
                decoded.context.captured_at_epoch_ns,
            )
            counters.observe_decoded(session_time_ns)
            if isinstance(decoded, DecodedPointCloudView):
                counters.point_frames += 1
                counters.points += decoded.point_count
                if _should_publish_recorded_point_frame(counters.point_frames):
                    _log_points(recording, decoded, settings)
            elif isinstance(decoded, DecodedPoseView):
                position = (
                    float(decoded.position_xyz[0]),
                    float(decoded.position_xyz[1]),
                    float(decoded.position_xyz[2]),
                )
                _log_pose(recording, decoded, position)
                trajectory.process(recording, position, session_time_ns)
                counters.pose_frames += 1
            else:
                counters.ignored_messages += 1

        if session_origin_ns is None:
            raise RrdExportError("native capture contains no messages")
        if counters.decoded_messages == 0:
            raise RrdExportError("native capture contains no decodable point or pose frames")
        assert counters.first_decoded_time_ns is not None
        assert counters.last_decoded_time_ns is not None
        if capture_clock is None:
            assert last_source_time_ns is not None
            assert last_source_capture_ns is not None
            timeline_end_ns = last_source_time_ns
            timeline_end_epoch_ns = last_source_capture_ns
        else:
            timeline_end_ns = capture_clock.duration_ns
            timeline_end_epoch_ns = capture_clock.completed_at_epoch_ns
        # Materialize the durable capture completion independently of any
        # opaque MQTT packet. This is the exact envelope end and remains a
        # real RRD row for the strict browser buffering gate.
        _set_terminal_time(
            recording,
            timeline_end_ns,
            timeline_end_epoch_ns,
        )
        _log_session_end(recording)

        _raise_if_cancelled(cancel_event)
        recording.flush(timeout_sec=30.0)
        recording.disconnect()
        recording_closed = True
        _raise_if_cancelled(cancel_event)
        _fsync_file(temporary)
        rrd_bytes = temporary.stat().st_size
        if rrd_bytes <= 0:
            raise RrdExportError("Rerun produced an empty recording")
        rrd_sha256 = _sha256_file(
            temporary,
            cancel_event=cancel_event,
            activity_callback=activity_callback,
        )

        summary = RrdExportSummary(
            schema_version=1,
            input_path=str(source),
            output_path=str(destination),
            recording_id=recording_id,
            timeline=SESSION_TIMELINE,
            capture_timeline=CAPTURE_TIMELINE,
            source_messages=counters.source_messages,
            decoded_messages=counters.decoded_messages,
            point_frames=counters.point_frames,
            pose_frames=counters.pose_frames,
            modeling_reports=counters.modeling_reports,
            ignored_messages=counters.ignored_messages,
            points=counters.points,
            trajectory_poses=len(trajectory.positions),
            trajectory_updates=trajectory.updates,
            session_origin_monotonic_ns=session_origin_ns,
            timeline_start_ns=0,
            # Both endpoints are real RRD rows. This keeps camera media inside
            # one seekable session without advertising an unmaterialized tail.
            timeline_end_ns=timeline_end_ns,
            timeline_span_ns=timeline_end_ns,
            first_decoded_time_ns=counters.first_decoded_time_ns,
            last_decoded_time_ns=counters.last_decoded_time_ns,
            source_sha256=source_sha256,
            rrd_sha256=rrd_sha256,
            rrd_bytes=rrd_bytes,
        )
        _raise_if_cancelled(cancel_event)
        os.replace(temporary, destination)
        _fsync_directory(destination.parent)
        published = True
        return summary
    except (ReplayFormatError, OSError) as exc:
        raise RrdExportError(f"RRD export failed: {exc}") from exc
    finally:
        if recording is not None and not recording_closed:
            with suppress(BaseException):
                recording.disconnect()
        if not published:
            temporary.unlink(missing_ok=True)


def _validate_paths(source: Path, destination: Path) -> None:
    if not source.is_file():
        raise RrdExportError(f"raw capture does not exist: {source}")
    if source.suffix.casefold() != ".k1mqtt":
        raise RrdExportError("RRD export accepts only native .k1mqtt captures")
    if destination.suffix.casefold() != ".rrd":
        raise RrdExportError("RRD destination must use the .rrd suffix")
    if source == destination:
        raise RrdExportError("raw capture and RRD destination must be different files")
    try:
        replay_format = detect_replay_format(source)
    except ReplayFormatError as exc:
        raise RrdExportError(str(exc)) from exc
    if replay_format != "k1mqtt":
        raise RrdExportError("RRD export accepts only native K1MQTT captures")


def _optional_capture_clock(
    source: Path,
    explicit_path: Path | None,
) -> CaptureClockEnvelope | None:
    """Load the exact v2 capture envelope, retaining legacy-message fallback."""

    path = explicit_path if explicit_path is not None else source.with_name(CAPTURE_CLOCK_FILENAME)
    path = _confined_clock_artifact(source, path)
    try:
        path.lstat()
    except FileNotFoundError:
        return None
    except OSError as exc:
        raise RrdExportError("native capture clock is unavailable") from exc
    try:
        return read_capture_clock_envelope(path)
    except CaptureFormatError as exc:
        raise RrdExportError(f"native capture clock is invalid: {exc}") from exc


def _optional_capture_clock_origin(
    source: Path,
    explicit_path: Path | None,
) -> CaptureClockOrigin | None:
    path = (
        explicit_path
        if explicit_path is not None
        else source.with_name(CAPTURE_CLOCK_ORIGIN_FILENAME)
    )
    path = _confined_clock_artifact(source, path)
    try:
        path.lstat()
    except FileNotFoundError:
        return None
    except OSError as exc:
        raise RrdExportError("native capture clock origin is unavailable") from exc
    try:
        return read_capture_clock_origin(path)
    except CaptureFormatError as exc:
        raise RrdExportError(f"native capture clock origin is invalid: {exc}") from exc


def _confined_clock_artifact(source: Path, path: Path) -> Path:
    candidate = path.expanduser().absolute()
    try:
        if candidate.parent.resolve(strict=True) != source.parent.resolve(strict=True):
            raise RrdExportError("native capture clock escapes its source directory")
    except OSError as exc:
        raise RrdExportError("native capture clock directory is unavailable") from exc
    return candidate


def _recorded_blueprint(
    settings: RerunSceneSettings,
    *,
    include_initial_playback_state: bool = True,
    active_view: RecordedView = "spatial",
) -> rrb.Blueprint:
    accumulation = max(0.0, settings.accumulation_seconds)
    # An omitted range means Rerun's native latest-at query: the most recent
    # LiDAR frame at the cursor.  A zero-width range is *not* equivalent; it
    # only matches rows stamped at the cursor's exact nanosecond and therefore
    # makes an ordinary recorded scan appear empty.
    time_ranges: list[rr.VisibleTimeRange] | None = None
    if accumulation > 0:
        time_ranges = [
            rr.VisibleTimeRange(
                SESSION_TIMELINE,
                start=rr.TimeRangeBoundary.cursor_relative(seconds=-accumulation),
                end=rr.TimeRangeBoundary.cursor_relative(),
            )
        ]
    point_visualizer = rr.Points3D.from_fields(
        # A negative Radius value is Rerun's serialized representation
        # for UI points. Blueprint overrides broadcast this singleton
        # value across every recorded point without rewriting the store.
        radii=rr.Radius.ui_points(settings.point_size),
        # Recorded height/intensity/distance/RGB palettes are baked into
        # each Points3D row. A uniform custom color is the one color mode
        # that can be replaced safely by a singleton blueprint override.
        colors=(
            [_parse_hex_color(settings.custom_color)] if settings.palette == "custom" else None
        ),
    ).visualizer()
    point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID
    accumulated_time_ranges = (
        rrb.VisibleTimeRanges(time_ranges) if time_ranges is not None else None
    )
    point_overrides: list[Any] = [
        rrb.EntityBehavior(visible=settings.show_points),
        point_visualizer,
    ]
    trajectory_overrides: list[Any] = [
        rrb.EntityBehavior(visible=settings.show_trajectory),
    ]
    if accumulated_time_ranges is not None:
        point_overrides.append(accumulated_time_ranges)
        trajectory_overrides.append(accumulated_time_ranges)
    spatial_view = rrb.Spatial3DView(
        origin="/world",
        name="Пространственная сцена",
        background=[7, 8, 10, 255],
        line_grid=rrb.LineGrid3D(
            visible=settings.show_grid,
            color=[86, 91, 99, 110],
            stroke_width=0.75,
        ),
        overrides={
            # EntityBehavior is evaluated from the blueprint store and can
            # therefore hide/reveal already-recorded entities without
            # rewriting the data RRD. Keep the point visualizer alongside it
            # so size/color overrides remain active for the same entity.
            "/world/points": point_overrides,
            "/world/trajectory": trajectory_overrides,
            # Dynamic perception must not inherit the mapping view's
            # historical accumulation window. It is rendered latest-at in
            # the dedicated perception view below.
            "/world/perception": rrb.EntityBehavior(visible=False),
        },
        # Clear any prior view-level accumulation and scope the operator's
        # history window to mapping entities above. Dynamic perception then
        # remains latest-at even when both layers share this world view.
        time_ranges=rrb.VisibleTimeRanges([]),
    )
    spatial_view.id = RECORDED_SPATIAL_VIEW_ID
    camera_view = rrb.Spatial2DView(
        origin="/perception/camera",
        name="Камера · распознавание",
        background=[7, 8, 10, 255],
    )
    camera_view.id = RECORDED_CAMERA_VIEW_ID
    perception_3d_view = rrb.Spatial3DView(
        # Present derived cuboids in the calibrated world scene instead of an
        # isolated /world/perception subtree.  No visible time range is set on
        # this view: points and cuboids therefore remain latest-at and do not
        # accumulate into the overlapping-box failure mode.
        origin="/world",
        name="Сегментация и объекты · 3D",
        background=[7, 8, 10, 255],
        line_grid=rrb.LineGrid3D(
            visible=settings.show_grid,
            color=[86, 91, 99, 110],
            stroke_width=0.75,
        ),
        overrides={
            "/world/points": rrb.EntityBehavior(visible=settings.show_points),
            "/world/trajectory": rrb.EntityBehavior(
                visible=settings.show_trajectory,
            ),
            "/world/perception": rrb.EntityBehavior(visible=True),
            "/world/perception/lidar": rrb.EntityBehavior(visible=False),
        },
    )
    perception_3d_view.id = RECORDED_PERCEPTION_3D_VIEW_ID
    metrics_view = rrb.TimeSeriesView(
        origin="/metrics/device",
        name="Маршрут и время",
    )
    metrics_view.id = RECORDED_METRICS_VIEW_ID
    active_tab = {"spatial": 0, "perception": 1, "perception3d": 2, "metrics": 3}[active_view]
    root_container = rrb.Tabs(
        spatial_view,
        camera_view,
        perception_3d_view,
        metrics_view,
        active_tab=active_tab,
    )
    root_container.id = RECORDED_ROOT_CONTAINER_ID

    if include_initial_playback_state:
        # This state is appropriate only while opening a newly exported RRD.
        # Settings-only blueprint messages must not pause an already playing
        # recording or mutate the host-owned panel state.
        return rrb.Blueprint(
            root_container,
            rrb.TimePanel(
                timeline=SESSION_TIMELINE,
                play_state="paused",
                state="hidden",
            ),
            auto_layout=False,
            auto_views=False,
            collapse_panels=True,
        )

    return rrb.Blueprint(
        root_container,
        auto_layout=False,
        auto_views=False,
        collapse_panels=False,
    )


def recorded_blueprint_rrd(
    settings: RerunSceneSettings,
    *,
    application_id: str = APPLICATION_ID,
    recording_id: str,
) -> bytes:
    """Serialize a small active blueprint update for an already-open recording.

    The returned RRD contains blueprint-store messages only; it never copies the
    recorded data store and is therefore safe to push through a WebViewer log
    channel when operator display settings change.
    """

    recording = rr.RecordingStream(
        application_id,
        recording_id=recording_id,
        send_properties=False,
    )
    stream = rr.binary_stream(recording)
    try:
        recording.send_blueprint(
            _recorded_blueprint(
                settings,
                include_initial_playback_state=False,
            ),
            make_active=True,
            make_default=False,
        )
        payload = stream.read(flush=True, flush_timeout_sec=5.0)
    except Exception as exc:
        raise RrdExportError("failed to serialize recorded blueprint") from exc
    finally:
        with suppress(Exception):
            recording.disconnect()
    if not payload or not payload.startswith(b"RRF2") or len(payload) > 1_048_576:
        raise RrdExportError("serialized recorded blueprint is invalid")
    return payload


def _log_static_scene(recording: rr.RecordingStream) -> None:
    recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
    recording.log(
        "/world/sensor_pose",
        rr.TransformAxes3D(axis_length=0.45, show_frame=False),
        static=True,
    )
    recording.log(
        "/metrics/device/route_distance_meters",
        rr.SeriesLines(names=["Дистанция, м"], colors=[[67, 191, 255, 255]]),
        static=True,
    )
    recording.log(
        "/metrics/device/speed_meters_per_second",
        rr.SeriesLines(names=["Скорость, м/с"], colors=[[255, 193, 92, 255]]),
        static=True,
    )
    recording.log(
        "/metrics/device/scan_elapsed_seconds",
        rr.SeriesLines(names=["Время сканирования, с"], colors=[[126, 224, 160, 255]]),
        static=True,
    )


def _log_session_origin(recording: rr.RecordingStream) -> None:
    """Materialize the declared zero of ``session_time`` outside the 3D scene."""

    recording.set_time(
        SESSION_TIMELINE,
        duration=np.timedelta64(0, "ns"),
    )
    recording.log(
        "/__mission_core/session_origin",
        rr.AnyValues(session_origin=True),
    )


def _log_session_end(recording: rr.RecordingStream) -> None:
    recording.log(
        "/__mission_core/session_end",
        rr.AnyValues(session_end=True),
    )


def _log_modeling_telemetry(
    recording: rr.RecordingStream,
    telemetry: ModelingTelemetry,
) -> None:
    recording.log(
        "/metrics/device/route_distance_meters",
        rr.Scalars([telemetry.move_distance_meters]),
    )
    recording.log(
        "/metrics/device/speed_meters_per_second",
        rr.Scalars([telemetry.move_speed_meters_per_second]),
    )
    recording.log(
        "/metrics/device/scan_elapsed_seconds",
        rr.Scalars([telemetry.elapsed_seconds]),
    )


def _set_frame_time(
    recording: rr.RecordingStream,
    sequence: int,
    session_time_ns: int,
    capture_time_ns: int,
) -> None:
    recording.set_time(
        SESSION_TIMELINE,
        duration=np.timedelta64(session_time_ns, "ns"),
    )
    recording.set_time(
        CAPTURE_TIMELINE,
        timestamp=np.datetime64(capture_time_ns, "ns"),
    )
    recording.set_time("message_sequence", sequence=sequence)


def _set_terminal_time(
    recording: rr.RecordingStream,
    session_time_ns: int,
    capture_time_ns: int,
) -> None:
    recording.set_time(
        SESSION_TIMELINE,
        duration=np.timedelta64(session_time_ns, "ns"),
    )
    recording.set_time(
        CAPTURE_TIMELINE,
        timestamp=np.datetime64(capture_time_ns, "ns"),
    )
    recording.disable_timeline("message_sequence")


def _log_points(
    recording: rr.RecordingStream,
    frame: DecodedPointCloudView,
    settings: RerunSceneSettings,
) -> None:
    positions = np.asarray(frame.positions_xyz, dtype=np.float32).reshape((-1, 3))
    if frame.intensities is None:
        intensities = np.full(frame.point_count, 255, dtype=np.uint8)
    else:
        intensities = np.frombuffer(frame.intensities, dtype=np.uint8)
    rgb = (
        None
        if frame.colors_rgb is None
        else np.frombuffer(frame.colors_rgb, dtype=np.uint8).reshape((-1, 3))
    )
    positions, intensities, rgb = _recorded_view_points(positions, intensities, rgb)
    recording.log(
        "/world/points",
        rr.Points3D(
            positions,
            colors=_point_colors(positions, intensities, rgb, settings),
            radii=rr.Radius.ui_points(settings.point_size),
        ),
    )


def _recorded_view_points(
    positions: np.ndarray,
    intensities: np.ndarray,
    rgb: np.ndarray | None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray | None]:
    # The native K1 capture remains the complete source of record and all AI
    # jobs read that source directly. Rerun is the interactive operator
    # projection: bound the temporal frame rate, but preserve complete normal
    # K1 scans. The AI composition intentionally uses one latest point frame
    # so dynamic cuboids do not stack; thinning a normal ~2.4k-point scan here
    # made that view visibly bald. Keep spatial decimation only as an emergency
    # guard for unusually large (>100k point) frames from future hardware.
    if len(positions) <= RECORDED_VIEW_POINT_DECIMATION_THRESHOLD:
        return positions, intensities, rgb
    return (
        positions[::RECORDED_VIEW_POINT_STRIDE],
        intensities[::RECORDED_VIEW_POINT_STRIDE],
        None if rgb is None else rgb[::RECORDED_VIEW_POINT_STRIDE],
    )


def _should_publish_recorded_point_frame(frame_number: int) -> bool:
    """Keep the first point frame and then a stable 2 Hz operator cadence."""

    if frame_number < 1:
        raise ValueError("point frame number must be positive")
    return frame_number == 1 or (frame_number - 1) % RECORDED_VIEW_POINT_FRAME_STRIDE == 0


def _log_pose(
    recording: rr.RecordingStream,
    frame: DecodedPoseView,
    position: tuple[float, float, float],
) -> None:
    recording.log(
        "/world/sensor_pose",
        rr.Transform3D(
            translation=position,
            quaternion=rr.Quaternion(xyzw=frame.orientation_xyzw),
        ),
    )


def _fsync_file(path: Path) -> None:
    with path.open("rb") as stream:
        os.fsync(stream.fileno())


def _fsync_directory(path: Path) -> None:
    descriptor = os.open(path, os.O_RDONLY)
    try:
        os.fsync(descriptor)
    finally:
        os.close(descriptor)


def _sha256_file(
    path: Path,
    *,
    cancel_event: threading.Event | None = None,
    activity_callback: Callable[[], None] | None = None,
) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
            _raise_if_cancelled(cancel_event)
            _notify_activity(activity_callback)
            digest.update(chunk)
    return digest.hexdigest()


def _raise_if_cancelled(cancel_event: threading.Event | None) -> None:
    if cancel_event is not None and cancel_event.is_set():
        raise RrdExportCancelled("RRD export was cancelled")


def _notify_activity(callback: Callable[[], None] | None) -> None:
    if callback is not None:
        callback()
