"""Frame-aware stale-video policy for the Lab034 shared link queue.

The scheduler remains strict-priority, state-replacing, and non-preemptive.
Unlike Lab033, packet availability is separate from the timestamp stored in
the common header.  Every aligned video packet therefore carries the source
composite-frame generation time while FEC construction metadata remains local
to the scheduler.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Iterable

from protocol.link_packet import HEADER_SIZE, LinkPacket, TrafficClass, encode_link_packet
from protocol.priority_scheduler import transmission_duration_seconds


TIME_EPSILON_SECONDS = 1e-12


@dataclass(frozen=True)
class AgePolicyPacket:
    """A common packet plus non-serialized availability/frame metadata."""

    packet: LinkPacket
    arrival_order: int
    available_time_us: int
    composite_frame_id: int | None = None

    @property
    def available_time_seconds(self) -> float:
        return self.available_time_us / 1_000_000.0

    @property
    def frame_generation_seconds(self) -> float:
        return self.packet.generation_time_us / 1_000_000.0

    @property
    def wire_size_bytes(self) -> int:
        return HEADER_SIZE + len(self.packet.payload)


@dataclass(frozen=True)
class StateReplacement:
    removed: AgePolicyPacket
    replacement: AgePolicyPacket
    time_seconds: float


@dataclass(frozen=True)
class DroppedVideoPacket:
    item: AgePolicyPacket
    drop_time_seconds: float


@dataclass(frozen=True)
class AgeScheduledPacket:
    item: AgePolicyPacket
    start_seconds: float
    end_seconds: float
    blocked_by: AgePolicyPacket | None
    blocking_delay_seconds: float
    wire_packet: bytes


@dataclass(frozen=True)
class AgeScheduleResult:
    channel_bitrate_bps: float
    max_video_age_seconds: float | None
    transmitted: tuple[AgeScheduledPacket, ...]
    dropped_video: tuple[DroppedVideoPacket, ...]
    replacements: tuple[StateReplacement, ...]


def _enqueue(
    ready: list[AgePolicyPacket],
    item: AgePolicyPacket,
    replacements: list[StateReplacement],
) -> None:
    if item.packet.traffic_class in (TrafficClass.CONTROL, TrafficClass.TELEMETRY):
        retained = []
        for old in ready:
            if (
                old.packet.traffic_class == item.packet.traffic_class
                and old.packet.stream_id == item.packet.stream_id
            ):
                replacements.append(
                    StateReplacement(old, item, item.available_time_seconds)
                )
            else:
                retained.append(old)
        ready[:] = retained
    ready.append(item)


def schedule_with_video_age(
    packets: Iterable[AgePolicyPacket],
    channel_bitrate_bps: float,
    max_video_age_seconds: float | None,
) -> AgeScheduleResult:
    """Run a strict-priority non-preemptive scheduler with frame drops.

    At every packet-selection instant all queued packets belonging to a video
    frame older than ``max_video_age_seconds`` are removed together.  An active
    packet has already left the queue and is never interrupted.
    """

    if channel_bitrate_bps <= 0.0:
        raise ValueError("channel bitrate must be positive")
    if max_video_age_seconds is not None and max_video_age_seconds <= 0.0:
        raise ValueError("maximum video age must be positive")
    arrivals = sorted(
        tuple(packets),
        key=lambda item: (item.available_time_seconds, item.arrival_order),
    )
    if len({item.arrival_order for item in arrivals}) != len(arrivals):
        raise ValueError("arrival_order values must be unique")
    for item in arrivals:
        if item.packet.traffic_class is TrafficClass.VIDEO:
            if item.composite_frame_id is None:
                raise ValueError("video packet lacks composite_frame_id")
        elif item.composite_frame_id is not None:
            raise ValueError("non-video packet has composite_frame_id")

    ready: list[AgePolicyPacket] = []
    transmitted: list[AgeScheduledPacket] = []
    dropped: list[DroppedVideoPacket] = []
    replacements: list[StateReplacement] = []
    blocker_by_order: dict[int, tuple[AgePolicyPacket, float]] = {}
    dropped_frames: set[int] = set()
    arrival_index = 0
    cursor = 0.0

    def admit_until(limit: float, active: AgePolicyPacket | None = None, active_end: float = 0.0) -> None:
        nonlocal arrival_index
        while (
            arrival_index < len(arrivals)
            and arrivals[arrival_index].available_time_seconds
            <= limit + TIME_EPSILON_SECONDS
        ):
            item = arrivals[arrival_index]
            arrival_index += 1
            if (
                active is not None
                and item.available_time_seconds > cursor + TIME_EPSILON_SECONDS
            ):
                blocker_by_order[item.arrival_order] = (
                    active,
                    max(0.0, active_end - item.available_time_seconds),
                )
            if item.composite_frame_id in dropped_frames:
                dropped.append(DroppedVideoPacket(item, limit))
            else:
                _enqueue(ready, item, replacements)

    def expire_video(now: float) -> None:
        if max_video_age_seconds is None:
            return
        expired_frames = {
            item.composite_frame_id
            for item in ready
            if (
                item.packet.traffic_class is TrafficClass.VIDEO
                and now - item.frame_generation_seconds
                > max_video_age_seconds + TIME_EPSILON_SECONDS
            )
        }
        if not expired_frames:
            return
        retained = []
        for item in ready:
            if item.composite_frame_id in expired_frames:
                dropped.append(DroppedVideoPacket(item, now))
                assert item.composite_frame_id is not None
                dropped_frames.add(item.composite_frame_id)
            else:
                retained.append(item)
        ready[:] = retained

    while arrival_index < len(arrivals) or ready:
        if not ready:
            cursor = max(cursor, arrivals[arrival_index].available_time_seconds)
        admit_until(cursor)
        expire_video(cursor)
        if not ready:
            continue
        selected_index = min(
            range(len(ready)),
            key=lambda index: (
                int(ready[index].packet.traffic_class),
                ready[index].arrival_order,
            ),
        )
        selected = ready.pop(selected_index)
        start = max(cursor, selected.available_time_seconds)
        wire_packet = encode_link_packet(selected.packet)
        end = start + transmission_duration_seconds(
            len(wire_packet), channel_bitrate_bps
        )
        cursor = start
        admit_until(end, selected, end)
        blocker, blocking_delay = blocker_by_order.get(
            selected.arrival_order, (None, 0.0)
        )
        transmitted.append(
            AgeScheduledPacket(
                item=selected,
                start_seconds=start,
                end_seconds=end,
                blocked_by=blocker,
                blocking_delay_seconds=blocking_delay,
                wire_packet=wire_packet,
            )
        )
        cursor = end

    return AgeScheduleResult(
        channel_bitrate_bps=channel_bitrate_bps,
        max_video_age_seconds=max_video_age_seconds,
        transmitted=tuple(transmitted),
        dropped_video=tuple(dropped),
        replacements=tuple(replacements),
    )
