from __future__ import annotations

import math
from dataclasses import dataclass


class NormalizationError(ValueError):
    """A transport message matched a known channel but could not be normalized."""


@dataclass(frozen=True, slots=True)
class ConsumerFrameContext:
    """Transport-neutral provenance shared by decoded consumer views.

    Raw channel names and payloads deliberately do not cross this boundary. The
    byte count is retained for operational metrics, while opaque device/session
    aliases are optional because older streams do not carry them. They are
    deliberately named as source aliases: a vendor header must never become a
    Mission Core device identity merely by crossing the decode boundary.
    """

    sequence: int
    captured_at_epoch_ns: int
    received_monotonic_ns: int | None
    processing_started_monotonic_ns: int
    encoded_size_bytes: int
    live: bool
    source_device_alias: str | None = None
    source_session_alias: str | None = None

    def __post_init__(self) -> None:
        if self.sequence < 1:
            raise ValueError("frame sequence must be positive")
        if self.captured_at_epoch_ns < 0:
            raise ValueError("capture time must be non-negative")
        if self.received_monotonic_ns is not None and self.received_monotonic_ns < 0:
            raise ValueError("receive monotonic time must be non-negative")
        if self.processing_started_monotonic_ns < 0:
            raise ValueError("processing start time must be non-negative")
        if self.encoded_size_bytes < 0:
            raise ValueError("encoded size must be non-negative")


@dataclass(frozen=True, slots=True)
class DecodedPointCloudView:
    """In-process point-cloud projection in a named Cartesian frame.

    This is a consumer view, not the portable SDK ``PointCloudFrame`` contract.
    """

    context: ConsumerFrameContext
    frame_id: str
    positions_xyz: tuple[tuple[float, float, float], ...]
    intensities: bytes | None = None
    colors_rgb: bytes | None = None

    def __post_init__(self) -> None:
        if not self.frame_id:
            raise ValueError("point-cloud frame_id must not be empty")
        point_count = len(self.positions_xyz)
        if self.intensities is not None and len(self.intensities) != point_count:
            raise ValueError("intensity count must equal point count")
        if self.colors_rgb is not None and len(self.colors_rgb) != point_count * 3:
            raise ValueError("RGB byte count must equal point count * 3")
        if not all(
            len(position) == 3 and all(math.isfinite(value) for value in position)
            for position in self.positions_xyz
        ):
            raise ValueError("point positions must contain finite xyz triples")

    @property
    def point_count(self) -> int:
        return len(self.positions_xyz)


@dataclass(frozen=True, slots=True)
class DecodedPoseView:
    """In-process pose projection in a named Cartesian coordinate frame."""

    context: ConsumerFrameContext
    frame_id: str
    child_frame_id: str
    position_xyz: tuple[float, float, float]
    orientation_xyzw: tuple[float, float, float, float]

    def __post_init__(self) -> None:
        if not self.frame_id or not self.child_frame_id:
            raise ValueError("pose frame identifiers must not be empty")
        values = (*self.position_xyz, *self.orientation_xyzw)
        if not all(math.isfinite(value) for value in values):
            raise ValueError("pose values must be finite")


@dataclass(frozen=True, slots=True)
class StatusAttribute:
    """One stable, normalized status attribute."""

    name: str
    value: bool | int | float | str

    def __post_init__(self) -> None:
        if not self.name:
            raise ValueError("status attribute name must not be empty")
        if isinstance(self.value, float) and not math.isfinite(self.value):
            raise ValueError("status attribute float must be finite")


@dataclass(frozen=True, slots=True)
class DecodedDeviceStatusView:
    """An in-process device-status projection.

    The current verified K1 profile does not yet decode its status topic. The
    view exists so future verified status codecs do not alter consumers.
    """

    context: ConsumerFrameContext
    state: str
    attributes: tuple[StatusAttribute, ...] = ()

    def __post_init__(self) -> None:
        if not self.state:
            raise ValueError("device status state must not be empty")
        names = [attribute.name for attribute in self.attributes]
        if len(names) != len(set(names)):
            raise ValueError("device status attribute names must be unique")


DecodedDataPlaneView = DecodedPointCloudView | DecodedPoseView | DecodedDeviceStatusView
