"""Lab033: one shared link queue for control, telemetry, and FEC video.

The experiment deliberately models one abstract, error-free, non-preemptive
serialization resource. Both logical directions consume the same configured
bitrate; direction-switch time and a physical duplexing scheme are outside the
scope of this laboratory.
"""

from __future__ import annotations

import csv
from dataclasses import asdict, dataclass
from pathlib import Path
import struct
import subprocess
from typing import Iterable

import cv2
import matplotlib
import numpy as np

matplotlib.use("Agg")
import matplotlib.pyplot as plt

from protocol.link_packet import (
    Direction,
    HEADER_FORMAT,
    HEADER_SIZE,
    LinkPacket,
    LinkPacketCRCError,
    TrafficClass,
    decode_link_packet,
    encode_link_packet,
)
from protocol.packet_erasure_fec import (
    decode_fec_block,
    decode_outer_symbol,
)
from protocol.priority_scheduler import (
    QueuedPacket,
    ScheduleResult,
    SchedulerMode,
    schedule_packets,
    transmission_duration_seconds,
)
from protocol.video_packet import (
    CompositeReassembler,
    decode_packet as decode_inner_packet,
)
from tests.lab028_video_packetization import (
    COMPOSITE_FPS,
    SOURCE_VIDEO_PATH,
    EncodedComposite,
    VideoMetadata,
    load_video_profile,
)
from tests.lab029_packet_channel_simulation import prepare_profiles
from tests.lab030_packet_erasure_fec import (
    FECBlockPlan,
    SourcePacket,
    TransmissionUnit,
    prepare_source_packets,
)
from tests.lab032_fec_parameter_sweep import (
    SweepMode,
    build_parameter_units,
)


OUTPUT_DIRECTORY = Path("data/processed/lab033")
SUMMARY_CSV_PATH = OUTPUT_DIRECTORY / "lab033_summary.csv"
CLASS_CSV_PATH = OUTPUT_DIRECTORY / "lab033_class_metrics.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab033_report.txt"
CONTROL_PLOT_PATH = OUTPUT_DIRECTORY / "lab033_control_delay.png"
EMERGENCY_PLOT_PATH = OUTPUT_DIRECTORY / "lab033_emergency_delay.png"
VIDEO_PLOT_PATH = OUTPUT_DIRECTORY / "lab033_video_delay.png"
QUEUE_PLOT_PATH = OUTPUT_DIRECTORY / "lab033_queue_length.png"
DEADLINE_PLOT_PATH = OUTPUT_DIRECTORY / "lab033_deadline_misses.png"
COMPARISON_PLOT_PATH = OUTPUT_DIRECTORY / "lab033_scheduler_comparison.png"
PLOT_PATHS = (
    CONTROL_PLOT_PATH,
    EMERGENCY_PLOT_PATH,
    VIDEO_PLOT_PATH,
    QUEUE_PLOT_PATH,
    DEADLINE_PLOT_PATH,
    COMPARISON_PLOT_PATH,
)

VIDEO_MODE = SweepMode("12+3", 12, 3, "12+3")
VIDEO_PAYLOAD_SIZE = 512
CHANNEL_RATES_KBPS = (300.0, 260.0, 230.0)
SCHEDULER_MODES = (
    SchedulerMode.FIFO,
    SchedulerMode.STRICT_PRIORITY,
    SchedulerMode.LATEST_STATE,
)
CLASS_NAMES = {
    TrafficClass.EMERGENCY: "аварийная команда",
    TrafficClass.CONTROL: "обычные команды",
    TrafficClass.TELEMETRY: "телеметрия",
    TrafficClass.VIDEO: "видео",
}
MODE_NAMES = {
    SchedulerMode.FIFO: "FIFO",
    SchedulerMode.STRICT_PRIORITY: "Строгий приоритет",
    SchedulerMode.LATEST_STATE: "Приоритет + замена",
}
STREAM_EMERGENCY = 1
STREAM_CONTROL = 2
STREAM_TELEMETRY = 3
STREAM_VIDEO = 4
CONTROL_PERIOD_US = 50_000
TELEMETRY_PERIOD_US = 100_000
EMERGENCY_TIME_US = 10_000_000
TIME_EPSILON_SECONDS = 1e-9


@dataclass(frozen=True)
class Workload:
    metadata: VideoMetadata
    composites: tuple[EncodedComposite, ...]
    packets: tuple[QueuedPacket, ...]
    video_units: tuple[TransmissionUnit, ...]
    video_blocks: tuple[FECBlockPlan, ...]
    total_jpeg_bytes: int


@dataclass(frozen=True)
class ClassMetrics:
    channel_kbps: float
    scheduler: str
    traffic_class: str
    offered_load_kbps: float
    created_packets: int
    transmitted_packets: int
    replaced_packets: int
    remaining_at_source_end_packets: int
    mean_start_delay_ms: float
    p95_start_delay_ms: float
    max_start_delay_ms: float
    mean_total_delay_ms: float
    p95_total_delay_ms: float
    max_total_delay_ms: float
    deadline_misses: int
    deadline_miss_fraction: float
    mean_blocking_delay_ms: float
    max_blocking_delay_ms: float


@dataclass(frozen=True)
class SimulationSummary:
    channel_kbps: float
    scheduler: str
    offered_load_kbps: float
    emergency_load_kbps: float
    control_load_kbps: float
    telemetry_load_kbps: float
    video_load_kbps: float
    service_share_percent: float
    offered_to_capacity_ratio: float
    transmitted_packets: int
    transmitted_bytes: int
    source_duration_seconds: float
    drain_end_seconds: float
    additional_drain_seconds: float
    queue_at_source_end_packets: int
    queue_at_source_end_bytes: float
    mean_queue_packets: float
    max_queue_packets: int
    mean_queue_bytes: float
    max_queue_bytes: int
    control_mean_age_ms: float
    control_p95_age_ms: float
    control_max_age_ms: float
    control_gaps_over_100ms: int
    control_max_receive_gap_ms: float
    control_replaced: int
    emergency_start_delay_ms: float
    emergency_total_delay_ms: float
    emergency_deadline_met: bool
    emergency_blocker_class: str
    emergency_blocker_size_bytes: int
    emergency_blocker_sequence: int
    emergency_blocking_delay_ms: float
    telemetry_mean_age_ms: float
    telemetry_p95_age_ms: float
    telemetry_max_age_ms: float
    telemetry_misses_500ms: int
    telemetry_miss_fraction: float
    telemetry_replaced: int
    video_published_at_source_end_fraction: float
    video_published_after_drain_fraction: float
    video_final_recovery_fraction: float
    video_mean_publication_delay_ms: float
    video_p95_publication_delay_ms: float
    video_max_publication_delay_ms: float
    video_mean_display_age_ms: float
    video_p95_display_age_ms: float
    video_max_display_age_ms: float
    video_age_over_1s_fraction: float
    video_late_frame_count: int
    video_max_late_run: int


@dataclass(frozen=True)
class ScenarioResult:
    summary: SimulationSummary
    classes: tuple[ClassMetrics, ...]
    schedule: ScheduleResult
    publication_times: dict[int, float]


@dataclass(frozen=True)
class FunctionalTestResult:
    name: str
    passed: bool
    detail: str


def percentile(values: Iterable[float], q: float) -> float:
    values = tuple(values)
    return float(np.percentile(values, q)) if values else 0.0


def deterministic_payload(prefix: bytes, sequence: int, size: int) -> bytes:
    """Build an exact-size reproducible educational payload."""

    head = prefix[:8].ljust(8, b"\0") + struct.pack("!I", sequence)
    tail = bytes((sequence * 37 + index * 17) & 0xFF for index in range(size))
    return (head + tail)[:size]


def _packet(
    traffic_class: TrafficClass,
    direction: Direction,
    stream_id: int,
    sequence: int,
    generation_time_us: int,
    deadline_ms: int,
    payload: bytes,
) -> LinkPacket:
    return LinkPacket(
        traffic_class=traffic_class,
        direction=direction,
        stream_id=stream_id,
        sequence_number=sequence,
        generation_time_us=generation_time_us,
        deadline_ms=deadline_ms,
        payload=payload,
    )


def build_workload() -> Workload:
    metadata, composites = load_video_profile(SOURCE_VIDEO_PATH)
    profile = prepare_profiles(composites)[VIDEO_PAYLOAD_SIZE]
    source_packets: tuple[SourcePacket, ...] = prepare_source_packets(profile)
    video_units, video_blocks = build_parameter_units(source_packets, VIDEO_MODE)
    candidates: list[LinkPacket] = []

    duration_us = int(round(metadata.duration_seconds * 1_000_000.0))
    for sequence, generation_us in enumerate(range(0, duration_us, CONTROL_PERIOD_US)):
        candidates.append(
            _packet(
                TrafficClass.CONTROL,
                Direction.GROUND_TO_ROVER,
                STREAM_CONTROL,
                sequence,
                generation_us,
                100,
                deterministic_payload(b"CONTROL", sequence, 32),
            )
        )
    for sequence, generation_us in enumerate(range(0, duration_us, TELEMETRY_PERIOD_US)):
        candidates.append(
            _packet(
                TrafficClass.TELEMETRY,
                Direction.ROVER_TO_GROUND,
                STREAM_TELEMETRY,
                sequence,
                generation_us,
                500,
                deterministic_payload(b"TELEM", sequence, 64),
            )
        )
    if EMERGENCY_TIME_US < duration_us:
        candidates.append(
            _packet(
                TrafficClass.EMERGENCY,
                Direction.GROUND_TO_ROVER,
                STREAM_EMERGENCY,
                0,
                EMERGENCY_TIME_US,
                50,
                deterministic_payload(b"E-STOP", 0, 32),
            )
        )
    for unit in video_units:
        candidates.append(
            _packet(
                TrafficClass.VIDEO,
                Direction.ROVER_TO_GROUND,
                STREAM_VIDEO,
                unit.sequence_index,
                int(round(unit.generation_time_seconds * 1_000_000.0)),
                0,
                unit.wire_packet,
            )
        )

    # FIFO coincidence rule: class priority, stream_id, then sequence number.
    candidates.sort(
        key=lambda packet: (
            packet.generation_time_us,
            int(packet.traffic_class),
            packet.stream_id,
            packet.sequence_number,
        )
    )
    packets = tuple(
        QueuedPacket(packet=packet, arrival_order=index)
        for index, packet in enumerate(candidates)
    )
    return Workload(
        metadata=metadata,
        composites=tuple(composites),
        packets=packets,
        video_units=video_units,
        video_blocks=video_blocks,
        total_jpeg_bytes=sum(
            len(frame.base_jpeg) + len(frame.roi_jpeg) for frame in composites
        ),
    )


def _class_items(workload: Workload, traffic_class: TrafficClass) -> tuple[QueuedPacket, ...]:
    return tuple(
        item for item in workload.packets
        if item.packet.traffic_class is traffic_class
    )


def _wire_bytes(item: QueuedPacket) -> int:
    return HEADER_SIZE + len(item.packet.payload)


def queue_metrics(
    schedule: ScheduleResult,
    source_end: float,
) -> tuple[float, int, float, int, int, float]:
    """Time-weighted in-system queue metrics over the source interval."""

    intervals: list[tuple[float, float, int]] = []
    for sent in schedule.transmitted:
        intervals.append(
            (
                sent.queued.generation_time_seconds,
                sent.end_seconds,
                _wire_bytes(sent.queued),
            )
        )
    for replacement in schedule.replacements:
        intervals.append(
            (
                replacement.removed.generation_time_seconds,
                replacement.replacement.generation_time_seconds,
                _wire_bytes(replacement.removed),
            )
        )
    packet_area = 0.0
    byte_area = 0.0
    events: dict[float, list[int]] = {}
    for start, end, size in intervals:
        clipped_start = max(0.0, start)
        clipped_end = min(source_end, end)
        if clipped_end <= clipped_start + TIME_EPSILON_SECONDS:
            continue
        packet_area += clipped_end - clipped_start
        byte_area += (clipped_end - clipped_start) * size
        delta = events.setdefault(clipped_start, [0, 0])
        delta[0] += 1
        delta[1] += size
        delta = events.setdefault(clipped_end, [0, 0])
        delta[0] -= 1
        delta[1] -= size
    packets_now = bytes_now = max_packets = max_bytes = 0
    for time_seconds in sorted(events):
        packets_now += events[time_seconds][0]
        bytes_now += events[time_seconds][1]
        max_packets = max(max_packets, packets_now)
        max_bytes = max(max_bytes, bytes_now)

    remaining = [
        sent for sent in schedule.transmitted
        if sent.queued.generation_time_seconds <= source_end + TIME_EPSILON_SECONDS
        and sent.end_seconds > source_end + TIME_EPSILON_SECONDS
    ]
    remaining_bytes = 0.0
    for sent in remaining:
        if sent.start_seconds >= source_end:
            remaining_bytes += _wire_bytes(sent.queued)
        else:
            remaining_bytes += (
                sent.end_seconds - source_end
            ) * schedule.channel_bitrate_bps / 8.0
    return (
        packet_area / source_end,
        max_packets,
        byte_area / source_end,
        max_bytes,
        len(remaining),
        remaining_bytes,
    )


def receive_video(
    schedule: ScheduleResult,
    frame_count: int,
) -> tuple[dict[int, float], int]:
    """Unwrap both CRC layers and publish only complete BASE+ROI frames."""

    receiver = CompositeReassembler()
    publication_times: dict[int, float] = {}
    block_symbols: dict[int, list[bytes]] = {}
    decoded_blocks: set[int] = set()
    for sent in schedule.transmitted:
        link = decode_link_packet(sent.wire_packet)
        if link.traffic_class is not TrafficClass.VIDEO:
            continue
        outer = decode_outer_symbol(link.payload)
        symbols = block_symbols.setdefault(outer.block_id, [])
        symbols.append(link.payload)
        if not outer.is_parity:
            completed = receiver.ingest(outer.data)
            if completed is not None:
                publication_times[completed.composite_frame_id] = sent.end_seconds
        if outer.block_id not in decoded_blocks and len(symbols) >= outer.source_count:
            decoded = decode_fec_block(tuple(symbols))
            for inner in decoded.source_packets:
                decode_inner_packet(inner)
            decoded_blocks.add(outer.block_id)
    if len(publication_times) != frame_count:
        raise AssertionError(
            f"video receiver published {len(publication_times)} of {frame_count} frames"
        )
    return publication_times, len(decoded_blocks)


def display_age_metrics(
    publication_times: dict[int, float],
    drain_end: float,
) -> tuple[float, float, float, float]:
    events = sorted((time, frame_id) for frame_id, time in publication_times.items())
    sample_times = np.arange(0.0, drain_end + 0.005, 0.01)
    ages = []
    event_index = 0
    last_frame_id: int | None = None
    for sample_time in sample_times:
        while event_index < len(events) and events[event_index][0] <= sample_time:
            last_frame_id = events[event_index][1]
            event_index += 1
        generation = 0.0 if last_frame_id is None else last_frame_id / COMPOSITE_FPS
        ages.append(max(0.0, sample_time - generation))
    return (
        float(np.mean(ages)),
        percentile(ages, 95),
        max(ages, default=0.0),
        sum(age > 1.0 for age in ages) / len(ages) if ages else 0.0,
    )


def max_true_run(flags: Iterable[bool]) -> int:
    longest = current = 0
    for flag in flags:
        current = current + 1 if flag else 0
        longest = max(longest, current)
    return longest


def class_metrics(
    workload: Workload,
    schedule: ScheduleResult,
    channel_kbps: float,
    source_end: float,
) -> tuple[ClassMetrics, ...]:
    rows = []
    for traffic_class in TrafficClass:
        created = _class_items(workload, traffic_class)
        sent = tuple(
            record for record in schedule.transmitted
            if record.queued.packet.traffic_class is traffic_class
        )
        replaced = tuple(
            item for item in schedule.replacements
            if item.removed.packet.traffic_class is traffic_class
        )
        start_delays = [
            record.start_seconds - record.queued.generation_time_seconds
            for record in sent
        ]
        total_delays = [
            record.end_seconds - record.queued.generation_time_seconds
            for record in sent
        ]
        blocking = [record.blocking_delay_seconds for record in sent]
        deadline_misses = sum(
            delay > record.queued.packet.deadline_ms / 1000.0 + TIME_EPSILON_SECONDS
            for delay, record in zip(total_delays, sent)
            if record.queued.packet.deadline_ms > 0
        )
        deadline_population = sum(
            record.queued.packet.deadline_ms > 0 for record in sent
        )
        rows.append(
            ClassMetrics(
                channel_kbps=channel_kbps,
                scheduler=schedule.mode.value,
                traffic_class=traffic_class.name.lower(),
                offered_load_kbps=sum(_wire_bytes(item) for item in created) * 8.0 / source_end / 1000.0,
                created_packets=len(created),
                transmitted_packets=len(sent),
                replaced_packets=len(replaced),
                remaining_at_source_end_packets=sum(
                    record.end_seconds > source_end + TIME_EPSILON_SECONDS for record in sent
                ),
                mean_start_delay_ms=float(np.mean(start_delays)) * 1000.0 if start_delays else 0.0,
                p95_start_delay_ms=percentile(start_delays, 95) * 1000.0,
                max_start_delay_ms=max(start_delays, default=0.0) * 1000.0,
                mean_total_delay_ms=float(np.mean(total_delays)) * 1000.0 if total_delays else 0.0,
                p95_total_delay_ms=percentile(total_delays, 95) * 1000.0,
                max_total_delay_ms=max(total_delays, default=0.0) * 1000.0,
                deadline_misses=deadline_misses,
                deadline_miss_fraction=deadline_misses / deadline_population if deadline_population else 0.0,
                mean_blocking_delay_ms=float(np.mean(blocking)) * 1000.0 if blocking else 0.0,
                max_blocking_delay_ms=max(blocking, default=0.0) * 1000.0,
            )
        )
    return tuple(rows)


def simulate(workload: Workload, channel_kbps: float, mode: SchedulerMode) -> ScenarioResult:
    bitrate = channel_kbps * 1000.0
    source_end = workload.metadata.duration_seconds
    schedule = schedule_packets(workload.packets, mode, bitrate)
    publication_times, decoded_blocks = receive_video(
        schedule, len(workload.composites)
    )
    if decoded_blocks != len(workload.video_blocks):
        raise AssertionError("not every FEC block passed Lab030/Lab028 decoding")
    classes = class_metrics(workload, schedule, channel_kbps, source_end)
    by_class = {
        TrafficClass[row.traffic_class.upper()]: row for row in classes
    }
    sent_by_class = {
        traffic_class: tuple(
            record for record in schedule.transmitted
            if record.queued.packet.traffic_class is traffic_class
        )
        for traffic_class in TrafficClass
    }
    load_by_class = {
        traffic_class: sum(_wire_bytes(item) for item in _class_items(workload, traffic_class))
        * 8.0 / source_end / 1000.0
        for traffic_class in TrafficClass
    }
    offered_load = sum(load_by_class.values())
    useful_bytes = workload.total_jpeg_bytes + sum(
        len(item.packet.payload)
        for item in workload.packets
        if item.packet.traffic_class is not TrafficClass.VIDEO
    )
    proposed_wire_bytes = sum(_wire_bytes(item) for item in workload.packets)
    service_share = 100.0 * (proposed_wire_bytes - useful_bytes) / proposed_wire_bytes
    drain_end = max(record.end_seconds for record in schedule.transmitted)
    mean_qp, max_qp, mean_qb, max_qb, queue_end_packets, queue_end_bytes = queue_metrics(
        schedule, source_end
    )

    control = sent_by_class[TrafficClass.CONTROL]
    control_ages = [record.end_seconds - record.queued.generation_time_seconds for record in control]
    control_receive_times = [record.end_seconds for record in control]
    control_gaps = [right - left for left, right in zip(control_receive_times, control_receive_times[1:])]
    telemetry = sent_by_class[TrafficClass.TELEMETRY]
    telemetry_ages = [record.end_seconds - record.queued.generation_time_seconds for record in telemetry]
    emergency = sent_by_class[TrafficClass.EMERGENCY]
    if len(emergency) != 1:
        raise AssertionError("exactly one emergency command must be transmitted")
    emergency_record = emergency[0]
    blocker = emergency_record.blocked_by

    publication_delays = [
        publication_times[index] - index / COMPOSITE_FPS
        for index in range(len(workload.composites))
    ]
    late_flags = [delay > 1.0 for delay in publication_delays]
    mean_age, p95_age, max_age, age_over_one = display_age_metrics(
        publication_times, drain_end
    )
    summary = SimulationSummary(
        channel_kbps=channel_kbps,
        scheduler=mode.value,
        offered_load_kbps=offered_load,
        emergency_load_kbps=load_by_class[TrafficClass.EMERGENCY],
        control_load_kbps=load_by_class[TrafficClass.CONTROL],
        telemetry_load_kbps=load_by_class[TrafficClass.TELEMETRY],
        video_load_kbps=load_by_class[TrafficClass.VIDEO],
        service_share_percent=service_share,
        offered_to_capacity_ratio=offered_load / channel_kbps,
        transmitted_packets=len(schedule.transmitted),
        transmitted_bytes=sum(len(record.wire_packet) for record in schedule.transmitted),
        source_duration_seconds=source_end,
        drain_end_seconds=drain_end,
        additional_drain_seconds=max(0.0, drain_end - source_end),
        queue_at_source_end_packets=queue_end_packets,
        queue_at_source_end_bytes=queue_end_bytes,
        mean_queue_packets=mean_qp,
        max_queue_packets=max_qp,
        mean_queue_bytes=mean_qb,
        max_queue_bytes=max_qb,
        control_mean_age_ms=float(np.mean(control_ages)) * 1000.0,
        control_p95_age_ms=percentile(control_ages, 95) * 1000.0,
        control_max_age_ms=max(control_ages) * 1000.0,
        control_gaps_over_100ms=sum(gap > 0.1 + TIME_EPSILON_SECONDS for gap in control_gaps),
        control_max_receive_gap_ms=max(control_gaps, default=0.0) * 1000.0,
        control_replaced=by_class[TrafficClass.CONTROL].replaced_packets,
        emergency_start_delay_ms=(emergency_record.start_seconds - emergency_record.queued.generation_time_seconds) * 1000.0,
        emergency_total_delay_ms=(emergency_record.end_seconds - emergency_record.queued.generation_time_seconds) * 1000.0,
        emergency_deadline_met=(emergency_record.end_seconds - emergency_record.queued.generation_time_seconds) <= 0.05 + TIME_EPSILON_SECONDS,
        emergency_blocker_class=blocker.packet.traffic_class.name.lower() if blocker else "none",
        emergency_blocker_size_bytes=_wire_bytes(blocker) if blocker else 0,
        emergency_blocker_sequence=blocker.packet.sequence_number if blocker else -1,
        emergency_blocking_delay_ms=emergency_record.blocking_delay_seconds * 1000.0,
        telemetry_mean_age_ms=float(np.mean(telemetry_ages)) * 1000.0,
        telemetry_p95_age_ms=percentile(telemetry_ages, 95) * 1000.0,
        telemetry_max_age_ms=max(telemetry_ages) * 1000.0,
        telemetry_misses_500ms=sum(age > 0.5 + TIME_EPSILON_SECONDS for age in telemetry_ages),
        telemetry_miss_fraction=sum(age > 0.5 + TIME_EPSILON_SECONDS for age in telemetry_ages) / len(telemetry_ages),
        telemetry_replaced=by_class[TrafficClass.TELEMETRY].replaced_packets,
        video_published_at_source_end_fraction=sum(time <= source_end + TIME_EPSILON_SECONDS for time in publication_times.values()) / len(workload.composites),
        video_published_after_drain_fraction=sum(time <= drain_end + TIME_EPSILON_SECONDS for time in publication_times.values()) / len(workload.composites),
        video_final_recovery_fraction=len(publication_times) / len(workload.composites),
        video_mean_publication_delay_ms=float(np.mean(publication_delays)) * 1000.0,
        video_p95_publication_delay_ms=percentile(publication_delays, 95) * 1000.0,
        video_max_publication_delay_ms=max(publication_delays) * 1000.0,
        video_mean_display_age_ms=mean_age * 1000.0,
        video_p95_display_age_ms=p95_age * 1000.0,
        video_max_display_age_ms=max_age * 1000.0,
        video_age_over_1s_fraction=age_over_one,
        video_late_frame_count=sum(late_flags),
        video_max_late_run=max_true_run(late_flags),
    )
    return ScenarioResult(summary, classes, schedule, publication_times)


def run_experiment(workload: Workload) -> tuple[ScenarioResult, ...]:
    return tuple(
        simulate(workload, rate, mode)
        for rate in CHANNEL_RATES_KBPS
        for mode in SCHEDULER_MODES
    )


def _synthetic_item(
    traffic_class: TrafficClass,
    sequence: int,
    generation_us: int,
    arrival_order: int,
    payload_size: int = 32,
    stream_id: int | None = None,
) -> QueuedPacket:
    direction = (
        Direction.GROUND_TO_ROVER
        if traffic_class in (TrafficClass.EMERGENCY, TrafficClass.CONTROL)
        else Direction.ROVER_TO_GROUND
    )
    return QueuedPacket(
        _packet(
            traffic_class,
            direction,
            int(traffic_class) if stream_id is None else stream_id,
            sequence,
            generation_us,
            100,
            deterministic_payload(b"TEST", sequence, payload_size),
        ),
        arrival_order,
    )


def run_functional_tests(
    workload: Workload,
    results: tuple[ScenarioResult, ...],
) -> tuple[FunctionalTestResult, ...]:
    checks: list[tuple[str, callable]] = []

    def add(name: str):
        def decorator(function):
            checks.append((name, function))
            return function
        return decorator

    sample = next(item for item in workload.packets if item.packet.traffic_class is TrafficClass.VIDEO)

    @add("01_header_roundtrip")
    def _():
        decoded = decode_link_packet(encode_link_packet(sample.packet))
        assert decoded == LinkPacket(**{**asdict(sample.packet), "packet_crc32": decoded.packet_crc32})

    @add("02_header_crc_detection")
    def _():
        wire = bytearray(encode_link_packet(sample.packet)); wire[11] ^= 1
        try: decode_link_packet(wire)
        except LinkPacketCRCError: return
        raise AssertionError("header corruption was accepted")

    @add("03_payload_crc_detection")
    def _():
        wire = bytearray(encode_link_packet(sample.packet)); wire[-1] ^= 1
        try: decode_link_packet(wire)
        except LinkPacketCRCError: return
        raise AssertionError("payload corruption was accepted")

    @add("04_video_wrapper_byte_exact")
    def _():
        assert decode_link_packet(encode_link_packet(sample.packet)).payload == sample.packet.payload

    @add("05_lab030_crc")
    def _():
        decode_outer_symbol(decode_link_packet(encode_link_packet(sample.packet)).payload)

    @add("06_lab028_crc")
    def _():
        systematic = next(unit for unit in workload.video_units if not unit.is_parity)
        decode_inner_packet(decode_outer_symbol(systematic.wire_packet).data)

    @add("07_fifo_global_order")
    def _():
        items = (_synthetic_item(TrafficClass.VIDEO, 0, 0, 0, 1000), _synthetic_item(TrafficClass.EMERGENCY, 0, 1, 1))
        order = schedule_packets(items, SchedulerMode.FIFO, 100_000).transmitted
        assert [item.queued.arrival_order for item in order] == [0, 1]

    @add("08_strict_priority")
    def _():
        items = tuple(_synthetic_item(cls, 0, 0, index) for index, cls in enumerate((TrafficClass.VIDEO, TrafficClass.TELEMETRY, TrafficClass.CONTROL, TrafficClass.EMERGENCY)))
        order = schedule_packets(items, SchedulerMode.STRICT_PRIORITY, 100_000).transmitted
        assert [item.queued.packet.traffic_class for item in order] == list(TrafficClass)

    @add("09_non_preemptive")
    def _():
        items = (_synthetic_item(TrafficClass.VIDEO, 0, 0, 0, 1000), _synthetic_item(TrafficClass.EMERGENCY, 0, 1, 1))
        order = schedule_packets(items, SchedulerMode.STRICT_PRIORITY, 100_000).transmitted
        assert order[0].queued.packet.traffic_class is TrafficClass.VIDEO
        assert order[1].start_seconds >= order[0].end_seconds - TIME_EPSILON_SECONDS

    @add("10_intra_class_sequence")
    def _():
        items = tuple(_synthetic_item(TrafficClass.CONTROL, index, 0, index) for index in range(4))
        order = schedule_packets(items, SchedulerMode.STRICT_PRIORITY, 100_000).transmitted
        assert [item.queued.packet.sequence_number for item in order] == list(range(4))

    @add("11_latest_state_scope")
    def _():
        items = (
            _synthetic_item(TrafficClass.VIDEO, 0, 0, 0, 1000),
            _synthetic_item(TrafficClass.CONTROL, 0, 1, 1, stream_id=20),
            _synthetic_item(TrafficClass.CONTROL, 1, 2, 2, stream_id=20),
            _synthetic_item(TrafficClass.CONTROL, 2, 3, 3, stream_id=21),
            _synthetic_item(TrafficClass.TELEMETRY, 0, 4, 4, stream_id=30),
            _synthetic_item(TrafficClass.TELEMETRY, 1, 5, 5, stream_id=30),
        )
        result = schedule_packets(items, SchedulerMode.LATEST_STATE, 100_000)
        removed = {(x.removed.packet.traffic_class, x.removed.packet.stream_id, x.removed.packet.sequence_number) for x in result.replacements}
        assert removed == {(TrafficClass.CONTROL, 20, 0), (TrafficClass.TELEMETRY, 30, 0)}

    @add("12_active_state_not_removed")
    def _():
        items = (_synthetic_item(TrafficClass.CONTROL, 0, 0, 0, 1000), _synthetic_item(TrafficClass.CONTROL, 1, 1, 1))
        result = schedule_packets(items, SchedulerMode.LATEST_STATE, 100_000)
        assert len(result.transmitted) == 2 and not result.replacements

    @add("13_emergency_never_replaced")
    def _():
        items = (_synthetic_item(TrafficClass.VIDEO, 0, 0, 0, 1000), _synthetic_item(TrafficClass.EMERGENCY, 0, 1, 1), _synthetic_item(TrafficClass.EMERGENCY, 1, 2, 2))
        result = schedule_packets(items, SchedulerMode.LATEST_STATE, 100_000)
        assert sum(x.queued.packet.traffic_class is TrafficClass.EMERGENCY for x in result.transmitted) == 2

    @add("14_300kbps_all_video")
    def _():
        assert all(r.summary.video_final_recovery_fraction == 1.0 for r in results if r.summary.channel_kbps == 300.0)

    @add("15_overload_visible")
    def _():
        low = [r.summary for r in results if r.summary.channel_kbps == 230.0]
        assert any(r.queue_at_source_end_packets > 0 and r.additional_drain_seconds > 0 for r in low)

    @add("16_exact_transmission_duration")
    def _():
        assert abs(transmission_duration_seconds(123, 230_000) - 984 / 230_000) < 1e-15

    @add("17_total_bits_accounting")
    def _():
        for result in results:
            assert result.summary.transmitted_bytes * 8 == sum(len(x.wire_packet) * 8 for x in result.schedule.transmitted)

    @add("18_reproducible_schedule")
    def _():
        original = results[0].schedule
        repeated = schedule_packets(workload.packets, original.mode, original.channel_bitrate_bps)
        assert [(x.queued.arrival_order, x.start_seconds, x.end_seconds) for x in original.transmitted] == [(x.queued.arrival_order, x.start_seconds, x.end_seconds) for x in repeated.transmitted]

    @add("19_no_video_control_isolated")
    def _():
        items = tuple(_synthetic_item(TrafficClass.CONTROL, index, index * CONTROL_PERIOD_US, index) for index in range(20))
        result = schedule_packets(items, SchedulerMode.STRICT_PRIORITY, 300_000)
        assert max(x.end_seconds - x.queued.generation_time_seconds for x in result.transmitted) < 0.1

    @add("20_latest_state_reaches_receiver")
    def _():
        latest = next(r for r in results if r.summary.channel_kbps == 230.0 and r.summary.scheduler == SchedulerMode.LATEST_STATE.value)
        for cls in (TrafficClass.CONTROL, TrafficClass.TELEMETRY):
            created_last = max(item.packet.sequence_number for item in _class_items(workload, cls))
            sent_last = max(item.queued.packet.sequence_number for item in latest.schedule.transmitted if item.queued.packet.traffic_class is cls)
            assert sent_last == created_last

    output = []
    for name, function in checks:
        try:
            function()
            output.append(FunctionalTestResult(name, True, "PASS"))
        except Exception as error:
            output.append(FunctionalTestResult(name, False, f"{type(error).__name__}: {error}"))
    if not all(item.passed for item in output):
        failed = ", ".join(item.name for item in output if not item.passed)
        raise AssertionError(f"functional checks failed: {failed}")
    return tuple(output)


def save_csv(results: tuple[ScenarioResult, ...]) -> None:
    OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
    with SUMMARY_CSV_PATH.open("w", newline="", encoding="utf-8") as file:
        writer = csv.DictWriter(file, fieldnames=list(SimulationSummary.__dataclass_fields__))
        writer.writeheader()
        writer.writerows(asdict(result.summary) for result in results)
    with CLASS_CSV_PATH.open("w", newline="", encoding="utf-8") as file:
        writer = csv.DictWriter(file, fieldnames=list(ClassMetrics.__dataclass_fields__))
        writer.writeheader()
        writer.writerows(asdict(row) for result in results for row in result.classes)


def _grouped_plot(
    results: tuple[ScenarioResult, ...],
    values,
    ylabel: str,
    title: str,
    path: Path,
) -> None:
    x = np.arange(len(CHANNEL_RATES_KBPS)); width = 0.24
    fig, axis = plt.subplots(figsize=(10, 5.5))
    for index, mode in enumerate(SCHEDULER_MODES):
        subset = [r for r in results if r.summary.scheduler == mode.value]
        axis.bar(x + (index - 1) * width, [values(r.summary) for r in subset], width, label=MODE_NAMES[mode])
    axis.set_xticks(x, [f"{rate:.0f}" for rate in CHANNEL_RATES_KBPS])
    axis.set_xlabel("Скорость общего канала, кбит/с")
    axis.set_ylabel(ylabel); axis.set_title(title); axis.grid(axis="y", alpha=0.3); axis.legend()
    fig.tight_layout(); fig.savefig(path, dpi=150); plt.close(fig)


def save_plots(results: tuple[ScenarioResult, ...]) -> None:
    _grouped_plot(results, lambda x: x.control_p95_age_ms, "P95 полной задержки, мс", "Задержка обычных команд", CONTROL_PLOT_PATH)
    _grouped_plot(results, lambda x: x.emergency_total_delay_ms, "Полная задержка, мс", "Задержка аварийной команды", EMERGENCY_PLOT_PATH)
    _grouped_plot(results, lambda x: x.video_p95_publication_delay_ms, "P95 задержки, мс", "Задержка публикации видео", VIDEO_PLOT_PATH)
    _grouped_plot(results, lambda x: x.max_queue_packets, "Максимум, пакетов", "Длина общей очереди", QUEUE_PLOT_PATH)
    _grouped_plot(results, lambda x: sum(row.deadline_misses for row in next(r.classes for r in results if r.summary is x)), "Число превышений", "Превышения допустимых сроков", DEADLINE_PLOT_PATH)

    fig, axes = plt.subplots(1, 2, figsize=(12, 5))
    for mode in SCHEDULER_MODES:
        subset = [r.summary for r in results if r.summary.scheduler == mode.value]
        axes[0].plot(CHANNEL_RATES_KBPS, [r.control_p95_age_ms for r in subset], marker="o", label=MODE_NAMES[mode])
        axes[1].plot(CHANNEL_RATES_KBPS, [r.additional_drain_seconds for r in subset], marker="o", label=MODE_NAMES[mode])
    axes[0].set_ylabel("P95 команд, мс"); axes[1].set_ylabel("Освобождение после видео, с")
    for axis in axes:
        axis.set_xlabel("Скорость, кбит/с"); axis.grid(alpha=0.3); axis.invert_xaxis()
    axes[0].legend(); fig.suptitle("Сравнение планировщиков"); fig.tight_layout(); fig.savefig(COMPARISON_PLOT_PATH, dpi=150); plt.close(fig)


def write_report(
    workload: Workload,
    results: tuple[ScenarioResult, ...],
    tests: tuple[FunctionalTestResult, ...],
) -> None:
    first = results[0].summary
    git_root = subprocess.run(
        ("git", "rev-parse", "--show-toplevel"),
        check=True,
        capture_output=True,
        text=True,
        encoding="utf-8",
    ).stdout.strip()
    git_branch = subprocess.run(
        ("git", "branch", "--show-current"),
        check=True,
        capture_output=True,
        text=True,
        encoding="utf-8",
    ).stdout.strip()
    git_head = subprocess.run(
        ("git", "rev-parse", "HEAD"),
        check=True,
        capture_output=True,
        text=True,
        encoding="utf-8",
    ).stdout.strip()
    git_status = subprocess.run(
        ("git", "status", "--short", "--branch"),
        check=True,
        capture_output=True,
        text=True,
        encoding="utf-8",
    ).stdout.rstrip()
    external_video_kbps = (
        sum(len(unit.wire_packet) for unit in workload.video_units)
        * 8.0
        / workload.metadata.duration_seconds
        / 1000.0
    )
    lines = [
        "Lab033. Общий пакет канала и приоритетное обслуживание",
        "",
        "1. Git и конфигурация",
        f"- Git-корень: {git_root}.",
        f"- Ветка: {git_branch}; HEAD: {git_head}.",
        f"- Исходное видео: {workload.metadata.duration_seconds:.6f} с, {len(workload.composites)} составных кадров, {COMPOSITE_FPS:.0f} кадра/с.",
        "- BASE 240x135 grayscale JPEG Q23; ROI 320x180 grayscale JPEG Q33.",
        "- Lab028: payload 512 байт; Lab030: FEC 12+3; Lab031: D=1.",
        f"- Фактический внешний видеопоток до Lab033: {external_video_kbps:.3f} кбит/с; с общим заголовком: {first.video_load_kbps:.3f} кбит/с.",
        f"- Аварийная команда: {first.emergency_load_kbps:.6f}; обычные команды: {first.control_load_kbps:.3f}; телеметрия: {first.telemetry_load_kbps:.3f} кбит/с.",
        f"- Общая предложенная нагрузка: {first.offered_load_kbps:.3f} кбит/с; суммарная служебная доля: {first.service_share_percent:.3f}%.",
        "",
        "2. Формат общего заголовка",
        f"- struct: {HEADER_FORMAT}; размер: {HEADER_SIZE} байта; network byte order, неявного padding нет.",
        "- Смещения: magic[4]@0, version:u8@4, traffic_class:u8@5, direction:u8@6, flags:u8@7, stream_id:u16@8, sequence_number:u32@10, generation_time_us:u64@14, deadline_ms:u16@22, payload_length:u32@24, packet_crc32:u32@28.",
        "- traffic_class: 1=EMERGENCY, 2=CONTROL, 3=TELEMETRY, 4=VIDEO; direction: 1=GROUND_TO_ROVER, 2=ROVER_TO_GROUND; flags в версии 1 равен нулю.",
        "- CRC32 защищает заголовок с нулевым packet_crc32 и всю полезную нагрузку.",
        "",
        "3. Девять сочетаний скорости и планировщика",
        "speed | scheduler | offered/capacity | control P95 ms | emergency ms | telemetry P95 ms | video P95 ms | queue@end packets | drain extra s | final video",
    ]
    for result in results:
        row = result.summary
        lines.append(
            f"{row.channel_kbps:.0f} | {MODE_NAMES[SchedulerMode(row.scheduler)]} | {row.offered_to_capacity_ratio:.4f} | {row.control_p95_age_ms:.3f} | {row.emergency_total_delay_ms:.3f} | {row.telemetry_p95_age_ms:.3f} | {row.video_p95_publication_delay_ms:.3f} | {row.queue_at_source_end_packets} | {row.additional_drain_seconds:.6f} | {row.video_final_recovery_fraction:.6f}"
        )
    lines.extend([
        "",
        "4. Подробные показатели",
    ])
    for result in results:
        row = result.summary
        class_rows = {item.traffic_class: item for item in result.classes}
        lines.extend([
            f"[{row.channel_kbps:.0f} кбит/с; {MODE_NAMES[SchedulerMode(row.scheduler)]}]",
            f"- Передано {row.transmitted_packets} пакетов, {row.transmitted_bytes} байт; средняя/максимальная очередь {row.mean_queue_packets:.3f}/{row.max_queue_packets} пакетов и {row.mean_queue_bytes:.1f}/{row.max_queue_bytes} байт.",
            f"- На конце видео: {row.queue_at_source_end_packets} пакетов, {row.queue_at_source_end_bytes:.1f} байт; полное освобождение {row.drain_end_seconds:.6f} с, дополнительно {row.additional_drain_seconds:.6f} с.",
            f"- Команды: mean/P95/max {row.control_mean_age_ms:.3f}/{row.control_p95_age_ms:.3f}/{row.control_max_age_ms:.3f} мс; превышений 100 мс {class_rows['control'].deadline_misses}; промежутков >100 мс {row.control_gaps_over_100ms}; max gap {row.control_max_receive_gap_ms:.3f} мс; заменено {row.control_replaced}.",
            f"- Аварийная команда: start/total {row.emergency_start_delay_ms:.3f}/{row.emergency_total_delay_ms:.3f} мс; deadline 50 мс {'соблюдён' if row.emergency_deadline_met else 'НАРУШЕН'}; блокировал {row.emergency_blocker_class}, {row.emergency_blocker_size_bytes} байт, seq={row.emergency_blocker_sequence}, ожидание {row.emergency_blocking_delay_ms:.3f} мс.",
            f"- Телеметрия: mean/P95/max {row.telemetry_mean_age_ms:.3f}/{row.telemetry_p95_age_ms:.3f}/{row.telemetry_max_age_ms:.3f} мс; превышений 500 мс {row.telemetry_misses_500ms} ({row.telemetry_miss_fraction:.6f}); заменено {row.telemetry_replaced}.",
            f"- Видео: опубликовано к концу источника {row.video_published_at_source_end_fraction:.6f}, после drain {row.video_published_after_drain_fraction:.6f}, итог {row.video_final_recovery_fraction:.6f}; delay mean/P95/max {row.video_mean_publication_delay_ms:.3f}/{row.video_p95_publication_delay_ms:.3f}/{row.video_max_publication_delay_ms:.3f} мс.",
            f"- Возраст изображения mean/P95/max {row.video_mean_display_age_ms:.3f}/{row.video_p95_display_age_ms:.3f}/{row.video_max_display_age_ms:.3f} мс; доля >1 с {row.video_age_over_1s_fraction:.6f}; поздних кадров {row.video_late_frame_count}, max серия {row.video_max_late_run}.",
        ])
    lines.extend([
        "",
        "5. Интерпретация планировщиков",
        "- FIFO задерживает команды, потому что все ранее поступившие внешние видеопакеты остаются перед ними независимо от срочности.",
        "- Строгий приоритет после каждого окончания пакета выбирает управление раньше телеметрии и видео, поэтому сокращает очередь команд.",
        "- Уже начатая передача не прерывается: модель сериализует целый пакет как атомарную единицу и не задаёт фрагментацию общего пакета или возобновление передачи.",
        "- Поэтому нижняя граница задержки срочной команды включает остаток времени самого длинного пакета, уже занявшего ресурс.",
        "- Замена старого состояния новым не тратит канал на запоздалую команду, которая к моменту приёма уже не отражает актуальное управление.",
        "- Строгий приоритет перераспределяет порядок, но не уменьшает объём видео; при нагрузке выше пропускной способности видеопакеты продолжают накапливаться.",
        "- Длинная очередь видео увеличивает возраст изображения даже без потерь: полные, но старые кадры публикуются позже времени формирования.",
        "- Восстановление 100% кадров после drain подтверждает целостность, но не пригодность задержек канала для управления в реальном времени.",
        "",
        "6. Допущения модели",
        "- Один общий абстрактный ресурс и одна расчётная скорость для обоих направлений; переключение направления не задерживает передачу.",
        "- Не выбран физический полудуплекс/дуплекс, TDD/FDD, модуляция, ретранслятор или физическое разделение восходящего и нисходящего каналов.",
        "- Ошибки и помехи отсутствуют; полностью переданный пакет доставляется без повреждений; видеопакеты не отбрасываются по возрасту.",
        "- После конца исходного видео новые данные не создаются, а очередь полностью освобождается.",
        "- При совпадении времени FIFO использует traffic_class, stream_id и sequence_number как устойчивое правило разрешения совпадений.",
        "- Средняя очередь рассчитана по времени на основном интервале и включает пакет, находящийся в передатчике; остаток байтов активного пакета учитывается пропорционально недопереданному времени.",
        "",
        "7. Функциональные проверки",
    ])
    lines.extend(f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}" for item in tests)
    lines.extend([
        "",
        "8. Созданные файлы и итоговый Git status",
        "- protocol/link_packet.py",
        "- protocol/priority_scheduler.py",
        "- tests/lab033_priority_channel_scheduler.py",
        "- data/processed/lab033/lab033_summary.csv",
        "- data/processed/lab033/lab033_class_metrics.csv",
        "- data/processed/lab033/lab033_report.txt",
    ])
    lines.extend(f"- data/processed/lab033/{path.name}" for path in PLOT_PATHS)
    lines.extend([
        "- Lab028-Lab032 и их сохранённые результаты не изменены.",
        "- Lab033 не добавлена в индекс и не закоммичена.",
        "",
        git_status,
    ])
    REPORT_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")


def validate_outputs() -> None:
    for path, expected_rows in ((SUMMARY_CSV_PATH, 9), (CLASS_CSV_PATH, 36)):
        with path.open("r", newline="", encoding="utf-8") as file:
            rows = list(csv.DictReader(file))
        if len(rows) != expected_rows or not rows or any(set(rows[0]) != set(row) for row in rows):
            raise AssertionError(f"invalid CSV output: {path}")
    text = REPORT_PATH.read_text(encoding="utf-8")
    if "Lab033" not in text or "Функциональные проверки" not in text:
        raise AssertionError("report validation failed")
    for path in PLOT_PATHS:
        image = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
        if image is None or image.size == 0:
            raise AssertionError(f"OpenCV could not read {path}")


def main() -> None:
    workload = build_workload()
    results = run_experiment(workload)
    tests = run_functional_tests(workload, results)
    save_csv(results)
    save_plots(results)
    write_report(workload, results, tests)
    validate_outputs()
    print(
        f"Lab033 complete: {len(results)} scenarios, "
        f"{len(tests)} functional checks, "
        f"{len(workload.composites)} video frames"
    )


if __name__ == "__main__":
    main()
