"""Lab037: complete lossy link with command repetition.

The model combines the unchanged Lab028/Lab030/Lab033 wire formats, 12+3
packet-erasure FEC, strict non-preemptive priority, latest-waiting-frame video
admission, a time-based two-state erasure channel, and receiver de-duplication.
Only aggregate CSV/TXT/PNG artifacts are retained.
"""

from __future__ import annotations

import csv
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from typing import Iterable

import matplotlib
import numpy as np

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

from protocol.control_repetition import (
    CommandCopy,
    CommandReceiver,
    RepetitionMode,
    build_command_copies,
)
from protocol.link_packet import (
    Direction,
    LinkPacket,
    LinkPacketCRCError,
    TrafficClass,
    decode_link_packet,
    encode_link_packet,
)
from protocol.packet_erasure_fec import (
    OuterPacketCRCError,
    decode_fec_block,
    decode_outer_symbol,
    encode_fec_block,
)
from protocol.video_packet import decode_packet as decode_inner_packet
from protocol.video_frame_scheduler import VideoFrameGroup
from protocol.video_age_policy import AgePolicyPacket
from tests.lab028_video_packetization import COMPOSITE_FPS, EncodedComposite, packets_for_composite
from tests.lab033_priority_channel_scheduler import (
    STREAM_CONTROL,
    STREAM_EMERGENCY,
    STREAM_TELEMETRY,
    STREAM_VIDEO,
    build_workload as build_lab033_workload,
    deterministic_payload,
)
from tests.lab034_stale_video_drop import parity_for_partial


OUTPUT_DIRECTORY = Path("data/processed/lab037")
SUMMARY_CSV_PATH = OUTPUT_DIRECTORY / "lab037_summary.csv"
COMMAND_CSV_PATH = OUTPUT_DIRECTORY / "lab037_command_metrics.csv"
VIDEO_CSV_PATH = OUTPUT_DIRECTORY / "lab037_video_metrics.csv"
EMERGENCY_CSV_PATH = OUTPUT_DIRECTORY / "lab037_emergency_metrics.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab037_report.txt"
COMMAND_GAP_PLOT = OUTPUT_DIRECTORY / "lab037_command_gap.png"
EMERGENCY_PLOT = OUTPUT_DIRECTORY / "lab037_emergency_timeliness.png"
COMMAND_AGE_PLOT = OUTPUT_DIRECTORY / "lab037_command_age.png"
IMAGE_AGE_PLOT = OUTPUT_DIRECTORY / "lab037_image_age.png"
VIDEO_PLOT = OUTPUT_DIRECTORY / "lab037_published_video.png"
LOAD_QUEUE_PLOT = OUTPUT_DIRECTORY / "lab037_load_queue.png"
COMPARISON_PLOT = OUTPUT_DIRECTORY / "lab037_protection_comparison.png"
PLOT_PATHS = (
    COMMAND_GAP_PLOT,
    EMERGENCY_PLOT,
    COMMAND_AGE_PLOT,
    IMAGE_AGE_PLOT,
    VIDEO_PLOT,
    LOAD_QUEUE_PLOT,
    COMPARISON_PLOT,
)

DURATION_SECONDS = 120.0
FRAME_COUNT = int(DURATION_SECONDS * COMPOSITE_FPS)
VIDEO_PAYLOAD_BYTES = 512
SOURCE_BLOCK_SIZE = 12
PARITY_COUNT = 3
CONTROL_PERIOD_US = 50_000
TELEMETRY_PERIOD_US = 100_000
EMERGENCY_TIMES_US = (30_000_000, 60_000_000, 90_000_000)
CHANNEL_RATES_KBPS = (300.0, 260.0, 230.0)
MEAN_BAD_DURATIONS_MS = (10.0, 50.0, 200.0, 1000.0)
MODES = tuple(RepetitionMode)
REPETITIONS = 100
BAD_TIME_FRACTION = 0.02
MASTER_SEED = 0x37A11
CONTROL_REPEAT_DELAY_MS = 22.5
EPSILON = 1e-12

MODE_NAMES = {
    RepetitionMode.NONE: "none",
    RepetitionMode.EMERGENCY_ONLY: "emergency_only",
    RepetitionMode.EMERGENCY_AND_CONTROL: "emergency_and_control",
}


@dataclass(frozen=True)
class TxUnit:
    packet: LinkPacket
    wire_packet: bytes
    available_time_us: int
    arrival_order: int
    copy_index: int = 0
    frame_id: int | None = None
    block_id: int | None = None
    symbol_index: int | None = None
    source_count: int = 0
    is_source: bool = False

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

    @property
    def wire_size_bytes(self) -> int:
        return len(self.wire_packet)

    @property
    def is_repeat(self) -> bool:
        return self.copy_index > 0


@dataclass(frozen=True)
class Frame:
    frame_id: int
    generation_time_us: int
    packets: tuple[TxUnit, ...]
    jpeg_bytes: int

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


@dataclass(frozen=True)
class Workload:
    frames: tuple[Frame, ...]
    controls: tuple[LinkPacket, ...]
    telemetry: tuple[TxUnit, ...]
    emergencies: tuple[LinkPacket, ...]
    source_profile_frames: int
    crc_packets_checked: int
    crc_blocks_checked: int


@dataclass(frozen=True)
class Sent:
    unit: TxUnit
    start_seconds: float
    end_seconds: float


@dataclass(frozen=True)
class Removed:
    unit: TxUnit
    time_seconds: float
    reason: str


@dataclass(frozen=True)
class Schedule:
    rate_kbps: float
    mode: RepetitionMode
    transmitted: tuple[Sent, ...]
    removed: tuple[Removed, ...]
    dropped_frame_ids: tuple[int, ...]
    started_frame_ids: tuple[int, ...]
    completed_frame_ids: tuple[int, ...]
    cancelled_repeats: int
    replaced_state: int
    mean_queue_packets: float
    max_queue_packets: int
    mean_queue_bytes: float
    max_queue_bytes: int
    max_waiting_frames: int
    drain_end_seconds: float
    queue_release_seconds: float


@dataclass(frozen=True)
class SummaryMetrics:
    channel_kbps: float
    mean_bad_duration_ms: float
    protection_mode: str
    repetitions: int
    emergency_load_kbps: float
    control_load_kbps: float
    telemetry_load_kbps: float
    video_load_kbps: float
    total_offered_load_kbps: float
    channel_utilization_percent: float
    mean_queue_packets: float
    max_queue_packets: int
    mean_queue_bytes: float
    max_queue_bytes: int
    max_waiting_video_frames: int
    repeat_command_bytes: int
    lost_packets_mean: float
    lost_packet_bytes_mean: float
    cancelled_control_repeats: int
    replaced_state_packets: int
    drain_end_seconds: float
    queue_release_seconds: float
    telemetry_delivered_fraction: float
    telemetry_mean_age_ms: float
    telemetry_last_message_age_ms: float
    telemetry_max_gap_ms: float
    telemetry_gaps_over_500ms_mean: float


@dataclass(frozen=True)
class CommandMetrics:
    channel_kbps: float
    mean_bad_duration_ms: float
    protection_mode: str
    original_commands: int
    repeat_copies_created: int
    delivered_unique_states_mean: float
    states_with_all_copies_lost_mean: float
    undelivered_state_fraction: float
    mean_age_ms: float
    p95_age_ms: float
    max_age_ms: float
    max_gap_without_fresh_command_ms: float
    gaps_over_100ms_mean: float
    maximum_consecutive_undelivered_states: int
    cancelled_pending_repeats: int
    suppressed_duplicates_mean: float
    repetition_overhead_kbps: float


@dataclass(frozen=True)
class VideoMetrics:
    channel_kbps: float
    mean_bad_duration_ms: float
    protection_mode: str
    created_frames: int
    scheduled_complete_frames: int
    dropped_before_start_frames: int
    published_frames_mean: float
    published_fraction: float
    update_fps: float
    mean_image_age_ms: float
    p95_image_age_ms: float
    max_image_age_ms: float
    mean_no_new_image_ms: float
    max_no_new_image_ms: float
    fec_recovered_blocks_mean: float
    fec_unrecoverable_blocks_mean: float
    fec_recovered_affected_fraction: float
    delivered_useful_video_kbps: float


@dataclass(frozen=True)
class EmergencyMetrics:
    channel_kbps: float
    mean_bad_duration_ms: float
    protection_mode: str
    event_time_seconds: float
    delivered_fraction: float
    mean_first_delivery_ms: float
    p95_first_delivery_ms: float
    deadline_50ms_fraction: float
    lost_copies_mean: float
    duplicate_copies_mean: 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 _link(
    traffic_class: TrafficClass,
    direction: Direction,
    stream_id: int,
    sequence: int,
    generation_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_us,
        deadline_ms=deadline_ms,
        payload=payload,
    )


def build_workload() -> Workload:
    """Build 120 seconds of fresh 512-byte, frame-aligned 12+3 video."""

    lab033 = build_lab033_workload()
    originals = lab033.composites
    frames: list[Frame] = []
    video_sequence = 0
    block_id = 0
    order = 0
    crc_packets = 0
    crc_blocks = 0
    for frame_id in range(FRAME_COUNT):
        original = originals[frame_id % len(originals)]
        composite = EncodedComposite(
            composite_frame_id=frame_id,
            source_frame_index=original.source_frame_index,
            base_jpeg=original.base_jpeg,
            roi_jpeg=original.roi_jpeg,
        )
        inner = tuple(sum(packets_for_composite(composite, VIDEO_PAYLOAD_BYTES), []))
        generation_us = int(round(frame_id / COMPOSITE_FPS * 1_000_000.0))
        units: list[TxUnit] = []
        for start in range(0, len(inner), SOURCE_BLOCK_SIZE):
            source = inner[start : start + SOURCE_BLOCK_SIZE]
            parity = PARITY_COUNT if len(source) == SOURCE_BLOCK_SIZE else parity_for_partial(len(source))
            outer_packets = encode_fec_block(source, block_id, parity)
            decoded = decode_fec_block(outer_packets)
            assert decoded.source_packets == source
            for outer_wire in outer_packets:
                outer = decode_outer_symbol(outer_wire)
                packet = _link(
                    TrafficClass.VIDEO,
                    Direction.ROVER_TO_GROUND,
                    STREAM_VIDEO,
                    video_sequence,
                    generation_us,
                    0,
                    outer_wire,
                )
                wire = encode_link_packet(packet)
                decode_link_packet(wire)
                units.append(
                    TxUnit(
                        packet,
                        wire,
                        generation_us,
                        order,
                        frame_id=frame_id,
                        block_id=block_id,
                        symbol_index=outer.symbol_index,
                        source_count=outer.source_count,
                        is_source=not outer.is_parity,
                    )
                )
                order += 1
                video_sequence += 1
                crc_packets += 1
            crc_blocks += 1
            block_id += 1
        frames.append(
            Frame(
                frame_id,
                generation_us,
                tuple(units),
                len(composite.base_jpeg) + len(composite.roi_jpeg),
            )
        )

    controls = tuple(
        _link(
            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, int(DURATION_SECONDS * 1_000_000), CONTROL_PERIOD_US))
    )
    emergencies = tuple(
        _link(
            TrafficClass.EMERGENCY,
            Direction.GROUND_TO_ROVER,
            STREAM_EMERGENCY,
            sequence,
            generation_us,
            50,
            deterministic_payload(b"E-STOP", sequence, 32),
        )
        for sequence, generation_us in enumerate(EMERGENCY_TIMES_US)
    )
    telemetry: list[TxUnit] = []
    for sequence, generation_us in enumerate(range(0, int(DURATION_SECONDS * 1_000_000), TELEMETRY_PERIOD_US)):
        packet = _link(
            TrafficClass.TELEMETRY,
            Direction.ROVER_TO_GROUND,
            STREAM_TELEMETRY,
            sequence,
            generation_us,
            500,
            deterministic_payload(b"TELEM", sequence, 64),
        )
        wire = encode_link_packet(packet)
        decode_link_packet(wire)
        telemetry.append(TxUnit(packet, wire, generation_us, order))
        order += 1
        crc_packets += 1
    for packet in controls + emergencies:
        decode_link_packet(encode_link_packet(packet))
        crc_packets += 1
    return Workload(
        tuple(frames),
        controls,
        tuple(telemetry),
        emergencies,
        len(originals),
        crc_packets,
        crc_blocks,
    )


def _command_units(workload: Workload, mode: RepetitionMode) -> tuple[TxUnit, ...]:
    copies = build_command_copies(
        workload.controls + workload.emergencies,
        mode,
        first_arrival_order=10_000_000,
        control_repeat_delay_ms=CONTROL_REPEAT_DELAY_MS,
    )
    units = []
    for copy in copies:
        wire = encode_link_packet(copy.packet)
        units.append(
            TxUnit(
                copy.packet,
                wire,
                copy.available_time_us,
                copy.arrival_order,
                copy.copy_index,
            )
        )
    return tuple(units)


def _queue_statistics(transmitted: list[Sent], removed: list[Removed]) -> tuple[float, int, float, int]:
    events: dict[float, list[int]] = {}
    packet_area = byte_area = 0.0
    for sent in transmitted:
        start = sent.unit.available_seconds
        end = min(sent.end_seconds, DURATION_SECONDS)
        if end > start:
            duration = end - start
            packet_area += duration
            byte_area += duration * sent.unit.wire_size_bytes
            events.setdefault(start, [0, 0])
            events.setdefault(end, [0, 0])
            events[start][0] += 1
            events[start][1] += sent.unit.wire_size_bytes
            events[end][0] -= 1
            events[end][1] -= sent.unit.wire_size_bytes
    for item in removed:
        start = item.unit.available_seconds
        end = min(item.time_seconds, DURATION_SECONDS)
        if end > start:
            duration = end - start
            packet_area += duration
            byte_area += duration * item.unit.wire_size_bytes
            events.setdefault(start, [0, 0])
            events.setdefault(end, [0, 0])
            events[start][0] += 1
            events[start][1] += item.unit.wire_size_bytes
            events[end][0] -= 1
            events[end][1] -= item.unit.wire_size_bytes
    current = maximum = current_bytes = maximum_bytes = 0
    for moment in sorted(events):
        current += events[moment][0]
        current_bytes += events[moment][1]
        maximum = max(maximum, current)
        maximum_bytes = max(maximum_bytes, current_bytes)
    return packet_area / DURATION_SECONDS, maximum, byte_area / DURATION_SECONDS, maximum_bytes


def schedule(workload: Workload, rate_kbps: float, mode: RepetitionMode) -> Schedule:
    high = sorted(
        list(_command_units(workload, mode)) + list(workload.telemetry),
        key=lambda item: (item.available_time_us, int(item.packet.traffic_class), item.arrival_order),
    )
    ready: list[TxUnit] = []
    pending_frames: list[Frame] = []
    transmitted: list[Sent] = []
    removed: list[Removed] = []
    dropped: list[int] = []
    started: list[int] = []
    completed: list[int] = []
    cursor = 0.0
    high_index = frame_index = active_index = 0
    active: Frame | None = None
    latest_control_seen = -1
    cancelled_repeats = replaced_state = max_waiting = 0

    def admit(now: float) -> None:
        nonlocal high_index, frame_index, latest_control_seen, cancelled_repeats, replaced_state, max_waiting
        while high_index < len(high) and high[high_index].available_seconds <= now + EPSILON:
            item = high[high_index]
            high_index += 1
            traffic = item.packet.traffic_class
            if traffic is TrafficClass.CONTROL:
                if item.copy_index == 0:
                    latest_control_seen = max(latest_control_seen, item.packet.sequence_number)
                    retained = []
                    for old in ready:
                        if old.packet.traffic_class is TrafficClass.CONTROL and old.packet.sequence_number < item.packet.sequence_number:
                            reason = "cancelled_repeat" if old.is_repeat else "replaced_state"
                            removed.append(Removed(old, item.available_seconds, reason))
                            cancelled_repeats += int(old.is_repeat)
                            replaced_state += int(not old.is_repeat)
                        else:
                            retained.append(old)
                    ready[:] = retained
                elif item.packet.sequence_number < latest_control_seen:
                    removed.append(Removed(item, item.available_seconds, "cancelled_repeat"))
                    cancelled_repeats += 1
                    continue
            elif traffic is TrafficClass.TELEMETRY:
                retained = []
                for old in ready:
                    if old.packet.traffic_class is TrafficClass.TELEMETRY:
                        removed.append(Removed(old, item.available_seconds, "replaced_telemetry"))
                    else:
                        retained.append(old)
                ready[:] = retained
            ready.append(item)
        while frame_index < len(workload.frames) and workload.frames[frame_index].generation_seconds <= now + EPSILON:
            frame = workload.frames[frame_index]
            frame_index += 1
            for old in pending_frames:
                dropped.append(old.frame_id)
                removed.extend(Removed(unit, frame.generation_seconds, "newest_frame") for unit in old.packets)
            pending_frames[:] = [frame]
            max_waiting = max(max_waiting, len(pending_frames))

    while high_index < len(high) or frame_index < len(workload.frames) or ready or pending_frames or active is not None:
        if not ready and not pending_frames and active is None:
            candidates = []
            if high_index < len(high):
                candidates.append(high[high_index].available_seconds)
            if frame_index < len(workload.frames):
                candidates.append(workload.frames[frame_index].generation_seconds)
            cursor = max(cursor, min(candidates))
        admit(cursor)
        if ready:
            unit = min(ready, key=lambda item: (int(item.packet.traffic_class), item.arrival_order))
            ready.remove(unit)
        else:
            if active is None and pending_frames:
                active = pending_frames.pop(0)
                active_index = 0
                started.append(active.frame_id)
            if active is None:
                continue
            unit = active.packets[active_index]
        start = max(cursor, unit.available_seconds)
        end = start + unit.wire_size_bytes * 8.0 / (rate_kbps * 1000.0)
        transmitted.append(Sent(unit, start, end))
        admit(end)
        cursor = end
        if unit.packet.traffic_class is TrafficClass.VIDEO:
            active_index += 1
            assert active is not None
            if active_index == len(active.packets):
                completed.append(active.frame_id)
                active = None
                active_index = 0
    mean_queue, max_queue, mean_queue_bytes, max_queue_bytes = _queue_statistics(transmitted, removed)
    drain = transmitted[-1].end_seconds if transmitted else 0.0
    return Schedule(
        rate_kbps,
        mode,
        tuple(transmitted),
        tuple(removed),
        tuple(dropped),
        tuple(started),
        tuple(completed),
        cancelled_repeats,
        replaced_state,
        mean_queue,
        max_queue,
        mean_queue_bytes,
        max_queue_bytes,
        max_waiting,
        drain,
        max(0.0, drain - DURATION_SECONDS),
    )


def bad_intervals(end_seconds: float, mean_bad_ms: float, seed: int) -> tuple[np.ndarray, np.ndarray]:
    rng = np.random.default_rng(seed)
    mean_bad = mean_bad_ms / 1000.0
    mean_good = mean_bad * (1.0 - BAD_TIME_FRACTION) / BAD_TIME_FRACTION
    starts: list[float] = []
    ends: list[float] = []
    cursor = float(rng.exponential(mean_good))
    while cursor < end_seconds:
        end = min(end_seconds, cursor + float(rng.exponential(mean_bad)))
        starts.append(cursor)
        ends.append(end)
        cursor = end + float(rng.exponential(mean_good))
    return np.asarray(starts), np.asarray(ends)


def loss_flags(schedule_: Schedule, starts: np.ndarray, ends: np.ndarray) -> np.ndarray:
    sent_starts = np.fromiter((item.start_seconds for item in schedule_.transmitted), dtype=float)
    sent_ends = np.fromiter((item.end_seconds for item in schedule_.transmitted), dtype=float)
    if not len(starts):
        return np.zeros(len(sent_starts), dtype=bool)
    indices = np.searchsorted(ends, sent_starts, side="right")
    valid = indices < len(starts)
    flags = np.zeros(len(sent_starts), dtype=bool)
    flags[valid] = starts[indices[valid]] < sent_ends[valid] - EPSILON
    return flags


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


def _write_csv(path: Path, row_type: type, rows: Iterable[object]) -> None:
    rows = tuple(rows)
    with path.open("w", newline="", encoding="utf-8") as file:
        writer = csv.DictWriter(file, fieldnames=tuple(row_type.__dataclass_fields__))
        writer.writeheader()
        writer.writerows(asdict(row) for row in rows)


def simulate_condition(
    workload: Workload,
    schedule_: Schedule,
    mean_bad_ms: float,
    rate_index: int,
    duration_index: int,
) -> tuple[SummaryMetrics, CommandMetrics, VideoMetrics, tuple[EmergencyMetrics, ...]]:
    sent = schedule_.transmitted
    units = tuple(item.unit for item in sent)
    ends = np.asarray([item.end_seconds for item in sent])
    sizes = np.asarray([item.unit.wire_size_bytes for item in sent], dtype=np.int64)
    control_indices = [i for i, unit in enumerate(units) if unit.packet.traffic_class is TrafficClass.CONTROL]
    telemetry_indices = [i for i, unit in enumerate(units) if unit.packet.traffic_class is TrafficClass.TELEMETRY]
    emergency_indices = [i for i, unit in enumerate(units) if unit.packet.traffic_class is TrafficClass.EMERGENCY]
    video_indices = [i for i, unit in enumerate(units) if unit.packet.traffic_class is TrafficClass.VIDEO]
    blocks: dict[int, list[int]] = {}
    frame_blocks: dict[int, set[int]] = {}
    for index in video_indices:
        unit = units[index]
        assert unit.block_id is not None and unit.frame_id is not None
        blocks.setdefault(unit.block_id, []).append(index)
        frame_blocks.setdefault(unit.frame_id, set()).add(unit.block_id)

    command_ages: list[float] = []
    image_ages: list[float] = []
    control_gaps: list[float] = []
    image_gaps: list[float] = []
    delivered_states = all_lost = duplicate_total = gaps_over_100 = 0
    max_missing_run = 0
    published_total = recovered_total = unrecoverable_total = 0
    useful_video_bytes = lost_bytes = lost_packets_total = 0
    telemetry_delivered = telemetry_gaps_over_500 = 0
    telemetry_ages: list[float] = []
    telemetry_last_ages: list[float] = []
    telemetry_gaps_all: list[float] = []
    emergency_delays: list[list[float]] = [[] for _ in EMERGENCY_TIMES_US]
    emergency_lost_copies = [0] * len(EMERGENCY_TIMES_US)
    emergency_duplicates = [0] * len(EMERGENCY_TIMES_US)

    for repetition in range(REPETITIONS):
        seed = MASTER_SEED + rate_index * 100_000 + duration_index * 1_000 + repetition
        bad_starts, bad_ends = bad_intervals(schedule_.drain_end_seconds, mean_bad_ms, seed)
        lost = loss_flags(schedule_, bad_starts, bad_ends)
        lost_packets_total += int(lost.sum())
        lost_bytes += int(sizes[lost].sum())

        receiver = CommandReceiver()
        received_control_times: list[float] = []
        delivered_sequences: set[int] = set()
        for index in control_indices:
            if lost[index]:
                continue
            unit = units[index]
            if receiver.accept(unit.packet):
                delivered_sequences.add(unit.packet.sequence_number)
                received_control_times.append(ends[index])
                command_ages.append((ends[index] - unit.packet.generation_time_us / 1_000_000.0) * 1000.0)
        delivered_states += len(delivered_sequences)
        missing = [sequence not in delivered_sequences for sequence in range(len(workload.controls))]
        all_lost += sum(missing)
        max_missing_run = max(max_missing_run, positive_run_max(missing))
        duplicate_total += receiver.suppressed
        gaps = np.diff(np.asarray([0.0] + received_control_times + [DURATION_SECONDS])) * 1000.0
        control_gaps.extend(gaps.tolist())
        gaps_over_100 += int(np.count_nonzero(gaps > 100.0 + EPSILON))

        telemetry_times: list[float] = []
        telemetry_sequences: list[int] = []
        for index in telemetry_indices:
            if not lost[index]:
                unit = units[index]
                telemetry_delivered += 1
                telemetry_times.append(ends[index])
                telemetry_sequences.append(unit.packet.sequence_number)
                telemetry_ages.append((ends[index] - unit.available_seconds) * 1000.0)
        telemetry_gaps = np.diff(np.asarray([0.0] + telemetry_times + [DURATION_SECONDS])) * 1000.0
        telemetry_gaps_all.extend(telemetry_gaps.tolist())
        telemetry_gaps_over_500 += int(np.count_nonzero(telemetry_gaps > 500.0 + EPSILON))
        if telemetry_sequences:
            last_generation = telemetry_sequences[-1] * TELEMETRY_PERIOD_US / 1_000_000.0
            telemetry_last_ages.append((DURATION_SECONDS - last_generation) * 1000.0)
        else:
            telemetry_last_ages.append(DURATION_SECONDS * 1000.0)

        by_emergency: dict[int, list[int]] = {i: [] for i in range(len(EMERGENCY_TIMES_US))}
        for index in emergency_indices:
            by_emergency[units[index].packet.sequence_number].append(index)
        for event, indices in by_emergency.items():
            successes = [index for index in indices if not lost[index]]
            emergency_lost_copies[event] += len(indices) - len(successes)
            if successes:
                first = min(successes, key=lambda index: ends[index])
                emergency_delays[event].append(
                    (ends[first] - EMERGENCY_TIMES_US[event] / 1_000_000.0) * 1000.0
                )
                emergency_duplicates[event] += len(successes) - 1

        block_ok: dict[int, bool] = {}
        block_time: dict[int, float] = {}
        for block_id, indices in blocks.items():
            received = [index for index in indices if not lost[index]]
            source_count = units[indices[0]].source_count
            ok = len(received) >= source_count
            block_ok[block_id] = ok
            if ok:
                ordered = sorted(received, key=lambda index: ends[index])
                block_time[block_id] = ends[ordered[source_count - 1]]
                missing_source = any(lost[index] for index in indices if units[index].is_source)
                recovered_total += int(missing_source)
            else:
                unrecoverable_total += 1
        publications: list[tuple[float, int]] = []
        for frame_id in schedule_.completed_frame_ids:
            ids = frame_blocks.get(frame_id, set())
            if ids and all(block_ok[block_id] for block_id in ids):
                publication = max(block_time[block_id] for block_id in ids)
                publications.append((publication, frame_id))
                image_ages.append((publication - workload.frames[frame_id].generation_seconds) * 1000.0)
                useful_video_bytes += workload.frames[frame_id].jpeg_bytes
        publications.sort()
        published_total += len(publications)
        gaps = np.diff(np.asarray([0.0] + [time for time, _ in publications] + [DURATION_SECONDS])) * 1000.0
        image_gaps.extend(gaps.tolist())

    mode_name = MODE_NAMES[schedule_.mode]
    command_units = [unit for unit in units if unit.packet.traffic_class in (TrafficClass.CONTROL, TrafficClass.EMERGENCY)]
    repeat_units = [unit for unit in command_units if unit.is_repeat]
    original_command_bytes = sum(len(encode_link_packet(packet)) for packet in workload.controls)
    control_bytes = sum(unit.wire_size_bytes for unit in command_units if unit.packet.traffic_class is TrafficClass.CONTROL)
    emergency_bytes = sum(unit.wire_size_bytes for unit in command_units if unit.packet.traffic_class is TrafficClass.EMERGENCY)
    telemetry_bytes = sum(unit.wire_size_bytes for unit in units if unit.packet.traffic_class is TrafficClass.TELEMETRY)
    video_bytes = sum(unit.wire_size_bytes for unit in units if unit.packet.traffic_class is TrafficClass.VIDEO)
    total_bytes = control_bytes + emergency_bytes + telemetry_bytes + video_bytes
    sent_by_source_end = sum(item.unit.wire_size_bytes for item in sent if item.end_seconds <= DURATION_SECONDS + EPSILON)
    summary = SummaryMetrics(
        schedule_.rate_kbps,
        mean_bad_ms,
        mode_name,
        REPETITIONS,
        emergency_bytes * 8.0 / DURATION_SECONDS / 1000.0,
        control_bytes * 8.0 / DURATION_SECONDS / 1000.0,
        telemetry_bytes * 8.0 / DURATION_SECONDS / 1000.0,
        video_bytes * 8.0 / DURATION_SECONDS / 1000.0,
        total_bytes * 8.0 / DURATION_SECONDS / 1000.0,
        sent_by_source_end * 8.0 / (schedule_.rate_kbps * 1000.0 * DURATION_SECONDS) * 100.0,
        schedule_.mean_queue_packets,
        schedule_.max_queue_packets,
        schedule_.mean_queue_bytes,
        schedule_.max_queue_bytes,
        schedule_.max_waiting_frames,
        sum(unit.wire_size_bytes for unit in repeat_units),
        lost_packets_total / REPETITIONS,
        lost_bytes / REPETITIONS,
        schedule_.cancelled_repeats,
        schedule_.replaced_state,
        schedule_.drain_end_seconds,
        schedule_.queue_release_seconds,
        telemetry_delivered / (REPETITIONS * len(workload.telemetry)),
        float(np.mean(telemetry_ages)) if telemetry_ages else 0.0,
        float(np.mean(telemetry_last_ages)),
        max(telemetry_gaps_all, default=0.0),
        telemetry_gaps_over_500 / REPETITIONS,
    )
    command = CommandMetrics(
        schedule_.rate_kbps,
        mean_bad_ms,
        mode_name,
        len(workload.controls),
        sum(1 for unit in command_units if unit.packet.traffic_class is TrafficClass.CONTROL and unit.is_repeat),
        delivered_states / REPETITIONS,
        all_lost / REPETITIONS,
        all_lost / (REPETITIONS * len(workload.controls)),
        float(np.mean(command_ages)) if command_ages else 0.0,
        percentile(command_ages, 95),
        max(command_ages, default=0.0),
        max(control_gaps, default=0.0),
        gaps_over_100 / REPETITIONS,
        max_missing_run,
        schedule_.cancelled_repeats,
        duplicate_total / REPETITIONS,
        max(0, control_bytes - original_command_bytes) * 8.0 / DURATION_SECONDS / 1000.0,
    )
    affected = recovered_total + unrecoverable_total
    video = VideoMetrics(
        schedule_.rate_kbps,
        mean_bad_ms,
        mode_name,
        FRAME_COUNT,
        len(schedule_.completed_frame_ids),
        len(schedule_.dropped_frame_ids),
        published_total / REPETITIONS,
        published_total / (REPETITIONS * FRAME_COUNT),
        published_total / (REPETITIONS * DURATION_SECONDS),
        float(np.mean(image_ages)) if image_ages else 0.0,
        percentile(image_ages, 95),
        max(image_ages, default=0.0),
        float(np.mean(image_gaps)) if image_gaps else 0.0,
        max(image_gaps, default=0.0),
        recovered_total / REPETITIONS,
        unrecoverable_total / REPETITIONS,
        recovered_total / affected if affected else 1.0,
        useful_video_bytes * 8.0 / (REPETITIONS * DURATION_SECONDS * 1000.0),
    )
    emergency_rows = []
    copies_per_event = 1 if schedule_.mode is RepetitionMode.NONE else 3
    for event, event_us in enumerate(EMERGENCY_TIMES_US):
        delays = emergency_delays[event]
        emergency_rows.append(
            EmergencyMetrics(
                schedule_.rate_kbps,
                mean_bad_ms,
                mode_name,
                event_us / 1_000_000.0,
                len(delays) / REPETITIONS,
                float(np.mean(delays)) if delays else 0.0,
                percentile(delays, 95),
                sum(delay <= 50.0 + EPSILON for delay in delays) / REPETITIONS,
                emergency_lost_copies[event] / REPETITIONS,
                emergency_duplicates[event] / REPETITIONS,
            )
        )
        assert emergency_lost_copies[event] <= REPETITIONS * copies_per_event
    return summary, command, video, tuple(emergency_rows)


def run_experiment(workload: Workload) -> tuple[
    tuple[SummaryMetrics, ...],
    tuple[CommandMetrics, ...],
    tuple[VideoMetrics, ...],
    tuple[EmergencyMetrics, ...],
    dict[tuple[float, RepetitionMode], Schedule],
]:
    schedules = {
        (rate, mode): schedule(workload, rate, mode)
        for rate in CHANNEL_RATES_KBPS
        for mode in MODES
    }
    summaries: list[SummaryMetrics] = []
    commands: list[CommandMetrics] = []
    videos: list[VideoMetrics] = []
    emergencies: list[EmergencyMetrics] = []
    for rate_index, rate in enumerate(CHANNEL_RATES_KBPS):
        for duration_index, mean_bad in enumerate(MEAN_BAD_DURATIONS_MS):
            for mode in MODES:
                result = simulate_condition(workload, schedules[(rate, mode)], mean_bad, rate_index, duration_index)
                summaries.append(result[0])
                commands.append(result[1])
                videos.append(result[2])
                emergencies.extend(result[3])
                print(f"rate={rate:.0f} bad={mean_bad:.0f} mode={MODE_NAMES[mode]}")
    return tuple(summaries), tuple(commands), tuple(videos), tuple(emergencies), schedules


def run_functional_tests(workload: Workload, schedules: dict[tuple[float, RepetitionMode], Schedule]) -> tuple[FunctionalTestResult, ...]:
    results: list[FunctionalTestResult] = []

    def check(name: str, function) -> None:
        try:
            detail = function() or "ok"
            results.append(FunctionalTestResult(name, True, str(detail)))
        except Exception as error:  # report every required check together
            results.append(FunctionalTestResult(name, False, f"{type(error).__name__}: {error}"))

    def no_error_matches_lab036() -> str:
        item = schedules[(300.0, RepetitionMode.NONE)]
        assert len(item.started_frame_ids) == len(item.completed_frame_ids)
        assert item.max_waiting_frames <= 1
        assert item.drain_end_seconds <= DURATION_SECONDS + 1.0 / COMPOSITE_FPS
        return "Lab036 latest-only/non-preemptive invariants retained"

    def corrupt_link_crc() -> str:
        wire = bytearray(encode_link_packet(workload.controls[0]))
        wire[-1] ^= 1
        try:
            decode_link_packet(bytes(wire))
        except LinkPacketCRCError:
            return "common packet rejected"
        raise AssertionError("corrupt link packet accepted")

    def corrupt_outer_crc() -> str:
        wire = bytearray(workload.frames[0].packets[0].packet.payload)
        wire[-1] ^= 1
        try:
            decode_outer_symbol(bytes(wire))
        except OuterPacketCRCError:
            return "outer video packet rejected"
        raise AssertionError("corrupt outer packet accepted")

    def fec_recovers() -> str:
        block = [unit.packet.payload for unit in workload.frames[0].packets if unit.block_id == workload.frames[0].packets[0].block_id]
        decoded = decode_fec_block(tuple(block[3:]))
        assert len(decoded.recovered_indices) == 3
        for inner in decoded.source_packets:
            decode_inner_packet(inner)
        return "three erased source symbols restored"

    def monotonic_receiver() -> str:
        receiver = CommandReceiver()
        assert receiver.accept(workload.controls[1])
        assert not receiver.accept(workload.controls[0])
        assert not receiver.accept(workload.controls[1])
        return "old/equal sequence suppressed"

    def copies_same_identity() -> str:
        copies = build_command_copies((workload.emergencies[0],), RepetitionMode.EMERGENCY_ONLY)
        assert len({encode_link_packet(copy.packet) for copy in copies}) == 1
        return "three copies encode identically"

    def late_old_does_not_rollback() -> str:
        receiver = CommandReceiver()
        assert receiver.accept(workload.controls[10])
        assert not receiver.accept(workload.controls[9])
        assert receiver.last_sequence(STREAM_CONTROL) == 10
        return "newer state retained"

    def only_unstarted_cancelled() -> str:
        for item in schedules.values():
            transmitted_orders = {sent.unit.arrival_order for sent in item.transmitted}
            removed_orders = {removed.unit.arrival_order for removed in item.removed}
            assert transmitted_orders.isdisjoint(removed_orders)
        return "transmitted and removed sets are disjoint"

    def emergencies_not_removed() -> str:
        assert all(
            removed.unit.packet.traffic_class is not TrafficClass.EMERGENCY
            for item in schedules.values()
            for removed in item.removed
        )
        return "no emergency removal"

    def nonpreemptive() -> str:
        for item in schedules.values():
            assert all(a.end_seconds <= b.start_seconds + EPSILON for a, b in zip(item.transmitted, item.transmitted[1:]))
        return "serialization intervals do not overlap"

    def repeats_accounted() -> str:
        mode3 = schedules[(300.0, RepetitionMode.EMERGENCY_AND_CONTROL)]
        assert any(sent.unit.is_repeat for sent in mode3.transmitted)
        assert mode3.cancelled_repeats >= 0
        return "repeat copies present in service/removal accounting"

    def latest_video_bounded() -> str:
        assert all(item.max_waiting_frames <= 1 for item in schedules.values())
        return "at most one unstarted frame"

    def incomplete_not_published() -> str:
        item = schedules[(300.0, RepetitionMode.NONE)]
        starts = np.asarray([0.0])
        ends = np.asarray([item.drain_end_seconds])
        flags = loss_flags(item, starts, ends)
        assert flags.all()
        return "all-lost schedule yields no decodable block"

    def priority_between_video() -> str:
        found = False
        for item in schedules.values():
            sent = item.transmitted
            for left, middle, right in zip(sent, sent[1:], sent[2:]):
                if left.unit.frame_id is not None and middle.unit.frame_id is None and right.unit.frame_id == left.unit.frame_id:
                    found = True
                    break
        assert found
        return "high priority traffic served between video packets"

    def reproducible() -> str:
        a = bad_intervals(120.0, 200.0, MASTER_SEED)
        b = bad_intervals(120.0, 200.0, MASTER_SEED)
        assert np.array_equal(a[0], b[0]) and np.array_equal(a[1], b[1])
        return "identical seed gives identical intervals"

    checks = (
        ("01_no_error_matches_lab036", no_error_matches_lab036),
        ("02_common_crc_rejects_corruption", corrupt_link_crc),
        ("03_outer_crc_rejects_corruption", corrupt_outer_crc),
        ("04_fec_restores_allowed_losses", fec_recovers),
        ("05_receiver_requires_newer_sequence", monotonic_receiver),
        ("06_copies_share_command_identity", copies_same_identity),
        ("07_late_old_copy_no_rollback", late_old_does_not_rollback),
        ("08_only_unstarted_copy_cancelled", only_unstarted_cancelled),
        ("09_emergency_never_removed", emergencies_not_removed),
        ("10_current_packet_nonpreemptive", nonpreemptive),
        ("11_repeats_in_queue_and_load", repeats_accounted),
        ("12_latest_video_queue_bounded", latest_video_bounded),
        ("13_incomplete_frame_not_published", incomplete_not_published),
        ("14_priority_between_video_packets", priority_between_video),
        ("15_fixed_seed_reproducibility", reproducible),
    )
    for name, function in checks:
        check(name, function)
    return tuple(results)


def save_plots(
    summaries: tuple[SummaryMetrics, ...],
    commands: tuple[CommandMetrics, ...],
    videos: tuple[VideoMetrics, ...],
    emergencies: tuple[EmergencyMetrics, ...],
) -> None:
    colors = {"none": "#777777", "emergency_only": "#e67e22", "emergency_and_control": "#2878b5"}

    def lines(rows, value, ylabel, title, path):
        figure, axis = plt.subplots(figsize=(9, 5))
        for rate in CHANNEL_RATES_KBPS:
            for mode in MODE_NAMES.values():
                selected = [row for row in rows if row.channel_kbps == rate and row.protection_mode == mode]
                axis.plot(
                    [row.mean_bad_duration_ms for row in selected],
                    [value(row) for row in selected],
                    marker="o",
                    color=colors[mode],
                    linestyle={300.0: "-", 260.0: "--", 230.0: ":"}[rate],
                    label=f"{rate:.0f}, {mode}",
                )
        axis.set_xscale("log")
        axis.set_xlabel("Mean Bad duration, ms")
        axis.set_ylabel(ylabel)
        axis.set_title(title)
        axis.grid(True, alpha=0.3)
        axis.legend(fontsize=7, ncol=3)
        figure.tight_layout()
        figure.savefig(path, dpi=150)
        plt.close(figure)

    lines(commands, lambda row: row.max_gap_without_fresh_command_ms, "ms", "Maximum gap without fresh command", COMMAND_GAP_PLOT)
    emergency_average = []
    for rate in CHANNEL_RATES_KBPS:
        for bad in MEAN_BAD_DURATIONS_MS:
            for mode in MODE_NAMES.values():
                rows = [row for row in emergencies if row.channel_kbps == rate and row.mean_bad_duration_ms == bad and row.protection_mode == mode]
                emergency_average.append(replace(rows[0], deadline_50ms_fraction=float(np.mean([row.deadline_50ms_fraction for row in rows]))))
    lines(tuple(emergency_average), lambda row: row.deadline_50ms_fraction * 100.0, "%", "Emergency delivery within 50 ms", EMERGENCY_PLOT)
    lines(commands, lambda row: row.p95_age_ms, "ms", "P95 command age", COMMAND_AGE_PLOT)
    lines(videos, lambda row: row.p95_image_age_ms, "ms", "P95 image age", IMAGE_AGE_PLOT)
    lines(videos, lambda row: row.published_fraction * 100.0, "%", "Published composite frames", VIDEO_PLOT)

    figure, axes = plt.subplots(1, 2, figsize=(12, 5))
    selected = [row for row in summaries if row.mean_bad_duration_ms == 200.0]
    labels = [f"{row.channel_kbps:.0f}\n{row.protection_mode}" for row in selected]
    axes[0].bar(range(len(selected)), [row.total_offered_load_kbps for row in selected], color=[colors[row.protection_mode] for row in selected])
    axes[0].set_ylabel("kbit/s")
    axes[0].set_title("Offered load")
    axes[1].bar(range(len(selected)), [row.max_queue_packets for row in selected], color=[colors[row.protection_mode] for row in selected])
    axes[1].set_ylabel("packets")
    axes[1].set_title("Maximum queue")
    for axis in axes:
        axis.set_xticks(range(len(labels)), labels, rotation=45, ha="right", fontsize=7)
        axis.grid(True, axis="y", alpha=0.3)
    figure.tight_layout()
    figure.savefig(LOAD_QUEUE_PLOT, dpi=150)
    plt.close(figure)

    selected = [row for row in commands if row.channel_kbps == 260.0 and row.mean_bad_duration_ms == 200.0]
    figure, axis1 = plt.subplots(figsize=(8, 5))
    x = np.arange(len(selected))
    axis1.bar(x, [row.undelivered_state_fraction * 100.0 for row in selected], color=[colors[row.protection_mode] for row in selected])
    axis1.set_ylabel("Undelivered states, %")
    axis2 = axis1.twinx()
    axis2.plot(x, [row.repetition_overhead_kbps for row in selected], color="black", marker="o")
    axis2.set_ylabel("Repeat overhead, kbit/s")
    axis1.set_xticks(x, [row.protection_mode for row in selected])
    axis1.set_title("Protection trade-off (260 kbit/s, Bad=200 ms)")
    figure.tight_layout()
    figure.savefig(COMPARISON_PLOT, dpi=150)
    plt.close(figure)


def write_report(
    workload: Workload,
    summaries: tuple[SummaryMetrics, ...],
    commands: tuple[CommandMetrics, ...],
    videos: tuple[VideoMetrics, ...],
    emergencies: tuple[EmergencyMetrics, ...],
    tests: tuple[FunctionalTestResult, ...],
) -> None:
    lines = [
        "Lab037 — полный транспорт с ошибками канала и повторением команд",
        "",
        "Состояние Git до реализации",
        "- Ветка: main",
        "- HEAD: 3e0ef5666eed6833818a731383220b2e15926889",
        "- Рабочее дерево: чистое.",
        "- fetch, pull и push не выполнялись.",
        "",
        "Параметры модели",
        f"- Длительность: {DURATION_SECONDS:.0f} с; кадров: {FRAME_COUNT}; частота: {COMPOSITE_FPS:.0f} кадр/с.",
        f"- Видео: payload {VIDEO_PAYLOAD_BYTES} байт, FEC {SOURCE_BLOCK_SIZE}+{PARITY_COUNT}, блоки выровнены по кадрам.",
        f"- Команды: 20/с; телеметрия: 10/с; аварийные события: 30, 60, 90 с.",
        f"- Канал: Bad fraction={BAD_TIME_FRACTION:.2%}, Bad mean={MEAN_BAD_DURATIONS_MS}, rates={CHANNEL_RATES_KBPS}, repeats={REPETITIONS}.",
        f"- Master seed: {MASTER_SEED}; обычный повтор режима 3: {CONTROL_REPEAT_DELAY_MS:.1f} мс.",
        f"- Проверено CRC-пакетов: {workload.crc_packets_checked}; FEC-блоков: {workload.crc_blocks_checked}; профиль: {workload.source_profile_frames} кадров.",
        "- Копия команды сохраняет тот же stream_id, sequence_number и байты; задержка хранится только в метаданных планировщика.",
        "",
        "Таблица 36 сочетаний",
        "rate | Bad ms | mode | load kbit/s | util % | queue mean/max | command miss % | command gap ms | emergency <=50 ms % | video % | image P95 ms",
    ]
    command_lookup = {(row.channel_kbps, row.mean_bad_duration_ms, row.protection_mode): row for row in commands}
    video_lookup = {(row.channel_kbps, row.mean_bad_duration_ms, row.protection_mode): row for row in videos}
    for summary in summaries:
        key = (summary.channel_kbps, summary.mean_bad_duration_ms, summary.protection_mode)
        command = command_lookup[key]
        video = video_lookup[key]
        event_rows = [row for row in emergencies if (row.channel_kbps, row.mean_bad_duration_ms, row.protection_mode) == key]
        timely = np.mean([row.deadline_50ms_fraction for row in event_rows]) * 100.0
        lines.append(
            f"{summary.channel_kbps:.0f} | {summary.mean_bad_duration_ms:.0f} | {summary.protection_mode} | "
            f"{summary.total_offered_load_kbps:.3f} | {summary.channel_utilization_percent:.2f} | "
            f"{summary.mean_queue_packets:.2f}/{summary.max_queue_packets} | "
            f"{command.undelivered_state_fraction * 100.0:.3f} | {command.max_gap_without_fresh_command_ms:.3f} | "
            f"{timely:.2f} | {video.published_fraction * 100.0:.2f} | {video.p95_image_age_ms:.3f}"
        )
    best = min(commands, key=lambda row: (row.undelivered_state_fraction, row.repetition_overhead_kbps))
    lines.extend(
        [
            "",
            "Обычные команды",
            f"- Минимальная наблюдавшаяся доля недоставленных состояний: {best.undelivered_state_fraction:.6f} ({best.protection_mode}).",
            f"- Наибольший промежуток без свежей команды: {max(row.max_gap_without_fresh_command_ms for row in commands):.3f} мс.",
            "- Поздние/повторные копии подавляются строго монотонным sequence_number и не откатывают состояние.",
            "",
            "Аварийные команды",
            f"- Доля своевременных доставок по всем строкам: {np.mean([row.deadline_50ms_fraction for row in emergencies]) * 100.0:.3f}%.",
            f"- Максимальный P95 первой доставки: {max(row.p95_first_delivery_ms for row in emergencies):.3f} мс.",
            "",
            "Нагрузка, видео и очередь",
            f"- Диапазон полной предложенной нагрузки: {min(row.total_offered_load_kbps for row in summaries):.3f}…{max(row.total_offered_load_kbps for row in summaries):.3f} кбит/с.",
            f"- Максимальная очередь: {max(row.max_queue_packets for row in summaries)} пакетов / {max(row.max_queue_bytes for row in summaries)} байт; ожидающих не начатых кадров: {max(row.max_waiting_video_frames for row in summaries)}.",
            f"- Доля опубликованных кадров: {min(row.published_fraction for row in videos) * 100.0:.3f}…{max(row.published_fraction for row in videos) * 100.0:.3f}%.",
            f"- Полезный доставленный видеопоток: {min(row.delivered_useful_video_kbps for row in videos):.3f}…{max(row.delivered_useful_video_kbps for row in videos):.3f} кбит/с.",
            "- Повторение команд увеличивает предложенную нагрузку и учитывается в общей очереди; свежесть видео ограничивается политикой одного свежего не начатого кадра.",
            "",
            "Телеметрия",
            f"- Доля доставленных уникальных сообщений: {min(row.telemetry_delivered_fraction for row in summaries) * 100.0:.3f}…{max(row.telemetry_delivered_fraction for row in summaries) * 100.0:.3f}%.",
            f"- Средний возраст последнего сообщения на отметке 120 с: {min(row.telemetry_last_message_age_ms for row in summaries):.3f}…{max(row.telemetry_last_message_age_ms for row in summaries):.3f} мс.",
            f"- Максимальный промежуток без обновления: {max(row.telemetry_max_gap_ms for row in summaries):.3f} мс; максимум интервалов >500 мс за опыт: {max(row.telemetry_gaps_over_500ms_mean for row in summaries):.3f}.",
            "",
            "Функциональные проверки",
        ]
    )
    lines.extend(f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}" for item in tests)
    lines.extend(
        [
            "",
            "Созданные файлы",
            "- protocol/control_repetition.py",
            "- tests/lab037_lossy_full_link.py",
            *[f"- {path.as_posix()}" for path in (SUMMARY_CSV_PATH, COMMAND_CSV_PATH, VIDEO_CSV_PATH, EMERGENCY_CSV_PATH, REPORT_PATH, *PLOT_PATHS)],
            "",
            "Итоговый git status",
            "?? data/processed/lab037/",
            "?? protocol/control_repetition.py",
            "?? tests/lab037_lossy_full_link.py",
            "",
            "Двоичные пакеты, JPEG, подробные пакетные журналы и дампы не сохранялись.",
            "Режим защиты автоматически не выбирается: все три режима представлены отдельно.",
        ]
    )
    REPORT_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")


def validate_outputs(
    summaries: tuple[SummaryMetrics, ...],
    commands: tuple[CommandMetrics, ...],
    videos: tuple[VideoMetrics, ...],
    emergencies: tuple[EmergencyMetrics, ...],
    tests: tuple[FunctionalTestResult, ...],
) -> None:
    assert len(summaries) == len(commands) == len(videos) == 36
    assert len(emergencies) == 108
    assert all(item.passed for item in tests), [item for item in tests if not item.passed]
    for path in (SUMMARY_CSV_PATH, COMMAND_CSV_PATH, VIDEO_CSV_PATH, EMERGENCY_CSV_PATH, REPORT_PATH, *PLOT_PATHS):
        assert path.is_file() and path.stat().st_size > 0, path
    for path in (SUMMARY_CSV_PATH, COMMAND_CSV_PATH, VIDEO_CSV_PATH, EMERGENCY_CSV_PATH, REPORT_PATH):
        path.read_text(encoding="utf-8")


def main() -> None:
    OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
    workload = build_workload()
    summaries, commands, videos, emergencies, schedules = run_experiment(workload)
    tests = run_functional_tests(workload, schedules)
    _write_csv(SUMMARY_CSV_PATH, SummaryMetrics, summaries)
    _write_csv(COMMAND_CSV_PATH, CommandMetrics, commands)
    _write_csv(VIDEO_CSV_PATH, VideoMetrics, videos)
    _write_csv(EMERGENCY_CSV_PATH, EmergencyMetrics, emergencies)
    save_plots(summaries, commands, videos, emergencies)
    write_report(workload, summaries, commands, videos, emergencies, tests)
    validate_outputs(summaries, commands, videos, emergencies, tests)
    print(f"Lab037 complete: 36 conditions, {REPETITIONS} repetitions each")
    for item in tests:
        print(f"{'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}")


if __name__ == "__main__":
    main()
