"""
Lab032. Parameter sweep for packet-erasure FEC without interleaving.

The Lab028 512-byte-payload transport, Lab030 outer packet and GF(256)
implementation, and Lab029B continuous-time impairment model are reused
unchanged.  Eight fixed modes compare block size and parity count at D=1.
Systematic symbols precede parity symbols and blocks may cross composite-frame
boundaries.  No ARQ, retransmission, or automatic mode selection is used.
"""

from __future__ import annotations

import csv
from dataclasses import asdict, dataclass
from itertools import combinations
from math import ceil
from pathlib import Path
from typing import Callable

import cv2
import matplotlib
import numpy as np

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

from protocol.packet_erasure_fec import (
    decode_fec_block,
    decode_outer_symbol,
    encode_fec_block,
    systematic_generator_matrix,
)
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 (
    PreparedProfile,
    prepare_profiles,
)
from tests.lab029b_time_based_burst_simulation import (
    BAD_TIME_FRACTION,
    CONTROL_STREAM_BITRATE_BPS,
    CONTROL_STREAM_BITRATE_KBPS,
    MEAN_BAD_DURATIONS_SECONDS,
    generate_bad_intervals,
    percentile,
)
from tests.lab030_packet_erasure_fec import (
    FECBlockPlan,
    FECMode,
    FunctionalTestResult,
    ModeSchedule,
    RepetitionResult,
    SourcePacket,
    TransmissionUnit,
    build_mode_units as build_lab030_mode_units,
    overlap_loss_flags,
    prepare_source_packets,
    schedule_units,
    simulate_baseline,
    simulate_fec,
)


OUTPUT_DIRECTORY = Path("data/processed/lab032")
CSV_PATH = OUTPUT_DIRECTORY / "lab032_results.csv"
SUMMARY_CSV_PATH = OUTPUT_DIRECTORY / "lab032_summary.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab032_report.txt"
COMPOSITE_SUCCESS_PLOT_PATH = (
    OUTPUT_DIRECTORY / "lab032_composite_success.png"
)
STREAM_OVERHEAD_PLOT_PATH = (
    OUTPUT_DIRECTORY / "lab032_stream_overhead.png"
)
PUBLICATION_DELAY_PLOT_PATH = (
    OUTPUT_DIRECTORY / "lab032_publication_delay.png"
)
NO_IMAGE_PLOT_PATH = OUTPUT_DIRECTORY / "lab032_no_image_duration.png"
BLOCK_SIZE_PLOT_PATH = OUTPUT_DIRECTORY / "lab032_block_size_effect.png"
PRACTICAL_FRONTIER_PLOT_PATH = (
    OUTPUT_DIRECTORY / "lab032_practical_frontier.png"
)

INNER_PAYLOAD_SIZE = 512
MONTE_CARLO_REPETITIONS = 200
MASTER_SEED = 300_300
SEED_BASE = MASTER_SEED


@dataclass(frozen=True)
class SweepMode:
    name: str
    source_count: int
    parity_count: int
    label: str

    @property
    def code_rate(self) -> float:
        if self.parity_count == 0:
            return 1.0
        return self.source_count / (
            self.source_count + self.parity_count
        )


MODES = (
    SweepMode("none", 0, 0, "Без FEC"),
    SweepMode("4+1", 4, 1, "4+1"),
    SweepMode("8+2", 8, 2, "8+2"),
    SweepMode("12+3", 12, 3, "12+3"),
    SweepMode("6+2", 6, 2, "6+2"),
    SweepMode("8+3", 8, 3, "8+3"),
    SweepMode("4+2", 4, 2, "4+2"),
    SweepMode("8+4", 8, 4, "8+4"),
)


@dataclass(frozen=True)
class ParameterSchedule:
    mode: SweepMode
    fec_schedule: ModeSchedule
    block_durations_seconds: tuple[float, ...]
    decode_ready_durations_seconds: tuple[float, ...]
    mean_block_duration_seconds: float
    max_block_duration_seconds: float
    mean_decode_ready_duration_seconds: float


@dataclass(frozen=True)
class SimulationResult:
    mode: str
    label: str
    source_block_size: int
    nominal_parity_count: int
    code_rate: float
    mean_bad_duration_ms: float
    mean_good_duration_ms: float
    target_bad_time_fraction: float
    actual_bad_time_fraction: float
    monte_carlo_repetitions: int
    seed: int
    source_jpeg_bitrate_kbps: float
    inner_packet_stream_bitrate_kbps: float
    outer_fec_stream_bitrate_kbps: float
    margin_to_control_bitrate_kbps: float
    service_and_parity_percent: float
    control_stream_bitrate_kbps: float
    schedule_duration_seconds: float
    fec_block_count: int
    final_block_source_count: int
    final_block_parity_count: int
    mean_block_duration_seconds: float
    max_block_duration_seconds: float
    mean_decode_ready_duration_seconds: float
    mean_queue_length_packets: float
    max_queue_length_packets: int
    transmitted_source_packets: int
    transmitted_parity_packets: int
    lost_source_packets: int
    lost_parity_packets: int
    recovered_source_packets: int
    fec_recovered_blocks: int
    fec_unrecoverable_blocks: int
    fec_affected_block_recovery_rate: float
    fec_all_block_success_rate: float
    fec_unrecoverable_block_rate: float
    base_objects_completed: int
    base_success_rate: float
    roi_objects_completed: int
    roi_success_rate: float
    atomic_composite_frames_completed: int
    composite_success_rate: float
    base_only_frames: int
    roi_only_frames: int
    incomplete_frames: int
    effective_delivered_video_bitrate_kbps: float
    mean_publication_delay_seconds: float
    p95_publication_delay_seconds: float
    max_publication_delay_seconds: float
    mean_no_new_image_duration_seconds: float
    p95_no_new_image_duration_seconds: float
    max_no_new_image_duration_seconds: float
    mean_consecutive_incomplete_frames: float
    p95_consecutive_incomplete_frames: float
    max_consecutive_incomplete_frames: int


CSV_FIELDS = [
    field.name
    for field in SimulationResult.__dataclass_fields__.values()
]


def parity_for_partial_block(
    source_count: int,
    nominal_source_count: int,
    nominal_parity_count: int,
) -> int:
    """Scale parity for a non-empty final block."""

    if not 1 <= source_count <= nominal_source_count:
        raise ValueError("partial source count is outside 1...k")
    if nominal_parity_count < 1:
        raise ValueError("nominal parity count must be positive")
    return max(
        1,
        ceil(
            source_count
            * nominal_parity_count
            / nominal_source_count
        ),
    )


def build_parameter_units(
    source_packets: tuple[SourcePacket, ...],
    mode: SweepMode,
) -> tuple[
    tuple[TransmissionUnit, ...],
    tuple[FECBlockPlan, ...],
]:
    """Build non-interleaved systematic-then-parity block units."""

    if mode.parity_count == 0:
        units = tuple(
            TransmissionUnit(
                sequence_index=index,
                generation_time_seconds=packet.generation_time_seconds,
                wire_packet=packet.inner_packet,
                is_parity=False,
                block_id=index,
                symbol_index=0,
                source_global_index=index,
            )
            for index, packet in enumerate(source_packets)
        )
        return units, ()

    units = []
    blocks = []
    sequence_index = 0
    for block_id, start in enumerate(
        range(0, len(source_packets), mode.source_count)
    ):
        block_sources = source_packets[
            start:start + mode.source_count
        ]
        source_count = len(block_sources)
        parity_count = (
            mode.parity_count
            if source_count == mode.source_count
            else parity_for_partial_block(
                source_count,
                mode.source_count,
                mode.parity_count,
            )
        )
        outer_packets = encode_fec_block(
            tuple(
                packet.inner_packet for packet in block_sources
            ),
            block_id,
            parity_count,
        )
        parsed = tuple(
            decode_outer_symbol(packet)
            for packet in outer_packets
        )
        generation_complete = max(
            packet.generation_time_seconds
            for packet in block_sources
        )
        blocks.append(
            FECBlockPlan(
                block_id=block_id,
                source_global_indices=tuple(
                    packet.global_index
                    for packet in block_sources
                ),
                source_count=source_count,
                parity_count=parity_count,
                symbol_size=parsed[0].symbol_size,
            )
        )
        for local_index, (wire_packet, symbol) in enumerate(
            zip(outer_packets, parsed)
        ):
            is_parity = symbol.is_parity
            units.append(
                TransmissionUnit(
                    sequence_index=sequence_index,
                    generation_time_seconds=(
                        generation_complete
                        if is_parity
                        else block_sources[
                            local_index
                        ].generation_time_seconds
                    ),
                    wire_packet=wire_packet,
                    is_parity=is_parity,
                    block_id=block_id,
                    symbol_index=symbol.symbol_index,
                    source_global_index=(
                        None
                        if is_parity
                        else block_sources[
                            local_index
                        ].global_index
                    ),
                )
            )
            sequence_index += 1
    return tuple(units), tuple(blocks)


def block_timing_metrics(
    schedule: ModeSchedule,
) -> tuple[tuple[float, ...], tuple[float, ...]]:
    if not schedule.blocks:
        return (), ()

    scheduled_by_block = {
        plan.block_id: [] for plan in schedule.blocks
    }
    plans = {plan.block_id: plan for plan in schedule.blocks}
    for unit in schedule.units:
        scheduled_by_block[unit.unit.block_id].append(unit)

    block_durations = []
    decode_ready = []
    for block_id, units in scheduled_by_block.items():
        ordered = sorted(
            units, key=lambda item: item.unit.symbol_index
        )
        first_start = ordered[0].start_seconds
        block_durations.append(
            ordered[-1].end_seconds - first_start
        )
        source_count = plans[block_id].source_count
        kth_source = next(
            unit
            for unit in ordered
            if unit.unit.symbol_index == source_count - 1
        )
        decode_ready.append(
            kth_source.end_seconds - first_start
        )
    return tuple(block_durations), tuple(decode_ready)


def build_schedules(
    metadata: VideoMetadata,
    composites: list[EncodedComposite],
    profile: PreparedProfile,
) -> dict[str, ParameterSchedule]:
    source_packets = prepare_source_packets(profile)
    total_jpeg_bytes = sum(
        len(composite.base_jpeg) + len(composite.roi_jpeg)
        for composite in composites
    )
    total_inner_bytes = sum(
        len(packet.inner_packet) for packet in source_packets
    )
    schedules = {}
    for mode in MODES:
        units, blocks = build_parameter_units(
            source_packets, mode
        )
        scheduled, duration, mean_queue, max_queue = schedule_units(
            units
        )
        fec_schedule = ModeSchedule(
            mode=FECMode(
                mode.name, mode.parity_count, mode.label
            ),
            source_packets=source_packets,
            blocks=blocks,
            units=scheduled,
            source_duration_seconds=metadata.duration_seconds,
            duration_seconds=duration,
            total_jpeg_bytes=total_jpeg_bytes,
            total_inner_bytes=total_inner_bytes,
            total_transmitted_bytes=sum(
                len(unit.unit.wire_packet)
                for unit in scheduled
            ),
            mean_queue_length_packets=mean_queue,
            max_queue_length_packets=max_queue,
        )
        durations, decode_ready = block_timing_metrics(
            fec_schedule
        )
        schedules[mode.name] = ParameterSchedule(
            mode=mode,
            fec_schedule=fec_schedule,
            block_durations_seconds=durations,
            decode_ready_durations_seconds=decode_ready,
            mean_block_duration_seconds=(
                float(np.mean(durations))
                if durations
                else 0.0
            ),
            max_block_duration_seconds=(
                max(durations) if durations else 0.0
            ),
            mean_decode_ready_duration_seconds=(
                float(np.mean(decode_ready))
                if decode_ready
                else 0.0
            ),
        )
    return schedules


def simulate_condition(
    schedule: ParameterSchedule,
    frame_count: int,
    mean_bad_duration_seconds: float,
    seed: int,
    repetitions: int,
) -> SimulationResult:
    fec_schedule = schedule.fec_schedule
    rng = np.random.default_rng(seed)
    repetition_results: list[RepetitionResult] = []
    bad_time_seconds = 0.0
    for _ in range(repetitions):
        intervals = generate_bad_intervals(
            fec_schedule.duration_seconds,
            mean_bad_duration_seconds,
            rng,
        )
        bad_time_seconds += sum(
            interval.duration_seconds for interval in intervals
        )
        loss_flags = overlap_loss_flags(fec_schedule, intervals)
        repetition_results.append(
            simulate_baseline(
                fec_schedule, loss_flags, frame_count
            )
            if schedule.mode.parity_count == 0
            else simulate_fec(
                fec_schedule, loss_flags, frame_count
            )
        )

    def total(field: str) -> int:
        return sum(
            int(getattr(result, field))
            for result in repetition_results
        )

    def flattened(field: str) -> list[float]:
        return [
            float(value)
            for result in repetition_results
            for value in getattr(result, field)
        ]

    publication_delays = flattened("publication_delays")
    no_image = flattened("no_new_image_durations")
    incomplete_runs = flattened("incomplete_frame_runs")
    recovered_blocks = total("fec_recovered_blocks")
    failed_blocks = total("fec_unrecoverable_blocks")
    affected_blocks = recovered_blocks + failed_blocks
    total_frames = frame_count * repetitions
    total_fec_blocks = len(fec_schedule.blocks) * repetitions
    parity_packets_per_pass = sum(
        int(unit.unit.is_parity) for unit in fec_schedule.units
    )
    source_rate = (
        fec_schedule.total_jpeg_bytes
        * 8.0
        / fec_schedule.source_duration_seconds
        / 1000.0
    )
    inner_rate = (
        fec_schedule.total_inner_bytes
        * 8.0
        / fec_schedule.source_duration_seconds
        / 1000.0
    )
    outer_rate = (
        fec_schedule.total_transmitted_bytes
        * 8.0
        / fec_schedule.source_duration_seconds
        / 1000.0
    )
    final_plan = (
        fec_schedule.blocks[-1]
        if fec_schedule.blocks
        else None
    )
    return SimulationResult(
        mode=schedule.mode.name,
        label=schedule.mode.label,
        source_block_size=schedule.mode.source_count,
        nominal_parity_count=schedule.mode.parity_count,
        code_rate=schedule.mode.code_rate,
        mean_bad_duration_ms=mean_bad_duration_seconds * 1000.0,
        mean_good_duration_ms=(
            mean_bad_duration_seconds
            * (1.0 - BAD_TIME_FRACTION)
            / BAD_TIME_FRACTION
            * 1000.0
        ),
        target_bad_time_fraction=BAD_TIME_FRACTION,
        actual_bad_time_fraction=(
            bad_time_seconds
            / (fec_schedule.duration_seconds * repetitions)
        ),
        monte_carlo_repetitions=repetitions,
        seed=seed,
        source_jpeg_bitrate_kbps=source_rate,
        inner_packet_stream_bitrate_kbps=inner_rate,
        outer_fec_stream_bitrate_kbps=outer_rate,
        margin_to_control_bitrate_kbps=(
            CONTROL_STREAM_BITRATE_KBPS - outer_rate
        ),
        service_and_parity_percent=(
            (
                fec_schedule.total_transmitted_bytes
                - fec_schedule.total_jpeg_bytes
            )
            / fec_schedule.total_transmitted_bytes
            * 100.0
        ),
        control_stream_bitrate_kbps=CONTROL_STREAM_BITRATE_KBPS,
        schedule_duration_seconds=fec_schedule.duration_seconds,
        fec_block_count=len(fec_schedule.blocks),
        final_block_source_count=(
            final_plan.source_count if final_plan else 0
        ),
        final_block_parity_count=(
            final_plan.parity_count if final_plan else 0
        ),
        mean_block_duration_seconds=(
            schedule.mean_block_duration_seconds
        ),
        max_block_duration_seconds=(
            schedule.max_block_duration_seconds
        ),
        mean_decode_ready_duration_seconds=(
            schedule.mean_decode_ready_duration_seconds
        ),
        mean_queue_length_packets=(
            fec_schedule.mean_queue_length_packets
        ),
        max_queue_length_packets=(
            fec_schedule.max_queue_length_packets
        ),
        transmitted_source_packets=(
            len(fec_schedule.source_packets) * repetitions
        ),
        transmitted_parity_packets=(
            parity_packets_per_pass * repetitions
        ),
        lost_source_packets=total("lost_source_packets"),
        lost_parity_packets=total("lost_parity_packets"),
        recovered_source_packets=total("recovered_source_packets"),
        fec_recovered_blocks=recovered_blocks,
        fec_unrecoverable_blocks=failed_blocks,
        fec_affected_block_recovery_rate=(
            recovered_blocks / affected_blocks
            if affected_blocks
            else 0.0
        ),
        fec_all_block_success_rate=(
            (total_fec_blocks - failed_blocks) / total_fec_blocks
            if total_fec_blocks
            else 0.0
        ),
        fec_unrecoverable_block_rate=(
            failed_blocks / total_fec_blocks
            if total_fec_blocks
            else 0.0
        ),
        base_objects_completed=total("base_objects_completed"),
        base_success_rate=(
            total("base_objects_completed") / total_frames
        ),
        roi_objects_completed=total("roi_objects_completed"),
        roi_success_rate=(
            total("roi_objects_completed") / total_frames
        ),
        atomic_composite_frames_completed=total(
            "atomic_composite_frames_completed"
        ),
        composite_success_rate=(
            total("atomic_composite_frames_completed")
            / total_frames
        ),
        base_only_frames=total("base_only_frames"),
        roi_only_frames=total("roi_only_frames"),
        incomplete_frames=total("incomplete_frames"),
        effective_delivered_video_bitrate_kbps=(
            total("delivered_jpeg_bytes")
            * 8.0
            / (fec_schedule.duration_seconds * repetitions)
            / 1000.0
        ),
        mean_publication_delay_seconds=(
            float(np.mean(publication_delays))
            if publication_delays
            else 0.0
        ),
        p95_publication_delay_seconds=percentile(
            publication_delays, 95
        ),
        max_publication_delay_seconds=(
            max(publication_delays)
            if publication_delays
            else 0.0
        ),
        mean_no_new_image_duration_seconds=(
            float(np.mean(no_image)) if no_image else 0.0
        ),
        p95_no_new_image_duration_seconds=percentile(
            no_image, 95
        ),
        max_no_new_image_duration_seconds=(
            max(no_image) if no_image else 0.0
        ),
        mean_consecutive_incomplete_frames=(
            float(np.mean(incomplete_runs))
            if incomplete_runs
            else 0.0
        ),
        p95_consecutive_incomplete_frames=percentile(
            incomplete_runs, 95
        ),
        max_consecutive_incomplete_frames=(
            int(max(incomplete_runs))
            if incomplete_runs
            else 0
        ),
    )


def run_monte_carlo(
    schedules: dict[str, ParameterSchedule],
    frame_count: int,
) -> list[SimulationResult]:
    results = []
    for duration_index, duration in enumerate(
        MEAN_BAD_DURATIONS_SECONDS
    ):
        seed = SEED_BASE + duration_index
        for mode in MODES:
            results.append(
                simulate_condition(
                    schedules[mode.name],
                    frame_count,
                    duration,
                    seed,
                    MONTE_CARLO_REPETITIONS,
                )
            )
    return results


def result_lookup(
    results: list[SimulationResult],
    mean_bad_duration_ms: float,
) -> dict[str, SimulationResult]:
    return {
        result.mode: result
        for result in results
        if result.mean_bad_duration_ms == mean_bad_duration_ms
    }


def run_functional_tests(
    composites: list[EncodedComposite],
    schedules: dict[str, ParameterSchedule],
    results: list[SimulationResult],
) -> list[FunctionalTestResult]:
    tests: list[tuple[str, Callable[[], str]]] = []
    protected_modes = tuple(
        mode for mode in MODES if mode.parity_count
    )
    source_packets = schedules[
        "8+2"
    ].fec_schedule.source_packets

    def all_k_r_supported() -> str:
        for mode in protected_modes:
            matrix = systematic_generator_matrix(
                mode.source_count,
                mode.parity_count,
            )
            if len(matrix) != (
                mode.source_count + mode.parity_count
            ):
                raise AssertionError(
                    f"bad generator shape for {mode.name}"
                )
        return "GF(256) generated every requested k+r matrix"

    def recover_any_r_losses() -> str:
        checked = 0
        for mode in protected_modes:
            sample = tuple(
                packet.inner_packet
                for packet in source_packets[
                    :mode.source_count
                ]
            )
            outer = encode_fec_block(
                sample, mode.source_count, mode.parity_count
            )
            for missing in combinations(
                range(len(outer)), mode.parity_count
            ):
                available = tuple(
                    packet
                    for index, packet in enumerate(outer)
                    if index not in missing
                )
                decoded = decode_fec_block(available)
                if decoded.source_packets != sample:
                    raise AssertionError(
                        f"failed {mode.name}, missing={missing}"
                    )
                checked += 1
        return f"recovered all {checked} exact-r erasure patterns"

    def restored_inner_is_exact() -> str:
        sample = tuple(
            packet.inner_packet for packet in source_packets[:12]
        )
        outer = encode_fec_block(sample, 77, 3)
        available = tuple(
            packet
            for index, packet in enumerate(outer)
            if index not in {0, 6, 14}
        )
        decoded = decode_fec_block(available)
        if decoded.source_packets != sample:
            raise AssertionError("restored inner bytes differ")
        return "restored Lab028 packets are byte-exact"

    def crc_layers_pass() -> str:
        sample = tuple(
            packet.inner_packet for packet in source_packets[:8]
        )
        outer = encode_fec_block(sample, 78, 4)
        available = tuple(
            packet
            for index, packet in enumerate(outer)
            if index not in {1, 4, 8, 11}
        )
        for packet in available:
            decode_outer_symbol(packet)
        decoded = decode_fec_block(available)
        for packet in decoded.source_packets:
            decode_inner_packet(packet)
        return "outer and recovered Lab028 CRC32 checks passed"

    def final_partial_blocks_are_scaled() -> str:
        checked = []
        for mode in protected_modes:
            final = schedules[mode.name].fec_schedule.blocks[-1]
            expected = parity_for_partial_block(
                final.source_count,
                mode.source_count,
                mode.parity_count,
            )
            if final.parity_count != expected:
                raise AssertionError(
                    f"{mode.name}: got {final.parity_count}, "
                    f"expected {expected}"
                )
            checked.append(
                f"{mode.name}:{final.source_count}+"
                f"{final.parity_count}"
            )
        return "final blocks: " + ", ".join(checked)

    def zero_bad_restores_all_frames() -> str:
        for mode in MODES:
            schedule = schedules[mode.name].fec_schedule
            flags = np.zeros(len(schedule.units), dtype=np.bool_)
            repetition = (
                simulate_baseline(
                    schedule, flags, len(composites)
                )
                if mode.parity_count == 0
                else simulate_fec(
                    schedule, flags, len(composites)
                )
            )
            if (
                repetition.atomic_composite_frames_completed
                != len(composites)
            ):
                raise AssertionError(
                    f"zero-Bad failed for {mode.name}"
                )
        return "all eight modes restored 100% of frames without Bad"

    def fixed_seed_is_reproducible() -> str:
        first = simulate_condition(
            schedules["8+3"],
            len(composites),
            0.05,
            329_329,
            3,
        )
        second = simulate_condition(
            schedules["8+3"],
            len(composites),
            0.05,
            329_329,
            3,
        )
        if first != second:
            raise AssertionError("same seed changed result fields")
        return "identical seed produced identical result fields"

    def queue_counts_all_parity() -> str:
        baseline = schedules["none"].fec_schedule
        for mode in protected_modes:
            schedule = schedules[mode.name].fec_schedule
            parity = sum(
                int(unit.unit.is_parity)
                for unit in schedule.units
            )
            if len(schedule.units) != len(
                baseline.source_packets
            ) + parity:
                raise AssertionError(
                    f"{mode.name}: parity absent from FIFO"
                )
            if (
                schedule.total_transmitted_bytes
                <= baseline.total_transmitted_bytes
            ):
                raise AssertionError(
                    f"{mode.name}: parity did not add bytes"
                )
        return "every parity packet is included in FIFO timing"

    def overload_grows_queue() -> str:
        source = schedules["8+4"].fec_schedule
        overloaded = tuple(
            TransmissionUnit(
                sequence_index=unit.unit.sequence_index,
                generation_time_seconds=(
                    unit.unit.generation_time_seconds * 0.5
                ),
                wire_packet=unit.unit.wire_packet,
                is_parity=unit.unit.is_parity,
                block_id=unit.unit.block_id,
                symbol_index=unit.unit.symbol_index,
                source_global_index=(
                    unit.unit.source_global_index
                ),
            )
            for unit in source.units
        )
        _, duration, mean_queue, max_queue = schedule_units(
            overloaded
        )
        offered_horizon = (
            source.source_duration_seconds * 0.5
        )
        offered_rate = (
            source.total_transmitted_bytes
            * 8.0
            / offered_horizon
            / 1000.0
        )
        if offered_rate <= CONTROL_STREAM_BITRATE_KBPS:
            raise AssertionError("synthetic stream is not overloaded")
        if duration <= offered_horizon or max_queue <= mean_queue:
            raise AssertionError("overload did not grow the queue")
        return (
            f"{offered_rate:.3f} kbit/s produced queue "
            f"mean/max {mean_queue:.3f}/{max_queue}"
        )

    def incomplete_composite_is_not_published() -> str:
        receiver = CompositeReassembler()
        first_frame = [
            packet.inner_packet
            for packet in source_packets
            if packet.composite_frame_id == 0
        ]
        for packet in first_frame[:-1]:
            if receiver.ingest(packet) is not None:
                raise AssertionError(
                    "incomplete composite was published"
                )
        return "missing final ROI fragment prevented publication"

    def lab030_8_2_is_reproduced() -> str:
        reference_units, reference_blocks = (
            build_lab030_mode_units(
                source_packets,
                FECMode("8+2", 2, "8+2"),
            )
        )
        schedule = schedules["8+2"].fec_schedule
        if tuple(
            unit.wire_packet for unit in reference_units
        ) != tuple(
            unit.unit.wire_packet for unit in schedule.units
        ):
            raise AssertionError("8+2 wire order differs from Lab030")
        if reference_blocks != schedule.blocks:
            raise AssertionError("8+2 block plans differ from Lab030")

        with Path(
            "data/processed/lab030/lab030_results.csv"
        ).open(newline="", encoding="utf-8") as file:
            reference_rows = {
                float(row["mean_bad_duration_ms"]): row
                for row in csv.DictReader(file)
                if row["mode"] == "8+2"
            }
        current_rows = {
            row.mean_bad_duration_ms: row
            for row in results
            if row.mode == "8+2"
        }
        for duration, current in current_rows.items():
            reference = reference_rows[duration]
            if abs(
                current.composite_success_rate
                - float(reference["composite_success_rate"])
            ) > 1e-12:
                raise AssertionError(
                    f"8+2 composite differs at {duration} ms"
                )
            if abs(
                current.p95_publication_delay_seconds
                - float(
                    reference[
                        "p95_publication_delay_seconds"
                    ]
                )
            ) > 1e-12:
                raise AssertionError(
                    f"8+2 delay differs at {duration} ms"
                )
        return "8+2 exactly reproduced Lab030 order and four rows"

    tests.extend(
        [
            ("all_k_r_supported", all_k_r_supported),
            ("recover_any_r_erasures", recover_any_r_losses),
            ("restored_inner_byte_exact", restored_inner_is_exact),
            ("inner_outer_crc", crc_layers_pass),
            (
                "final_partial_block_scaling",
                final_partial_blocks_are_scaled,
            ),
            ("zero_bad_100_percent", zero_bad_restores_all_frames),
            ("fixed_seed_reproducibility", fixed_seed_is_reproducible),
            ("queue_counts_parity", queue_counts_all_parity),
            ("overload_grows_queue", overload_grows_queue),
            (
                "atomic_incomplete_composite",
                incomplete_composite_is_not_published,
            ),
            ("lab030_8_2_reproduction", lab030_8_2_is_reproduced),
        ]
    )
    test_results = []
    for name, test in tests:
        try:
            detail = test()
        except Exception as error:
            test_results.append(
                FunctionalTestResult(name, False, str(error))
            )
        else:
            test_results.append(
                FunctionalTestResult(name, True, detail)
            )
    failed = [
        result for result in test_results if not result.passed
    ]
    if failed:
        raise RuntimeError(
            "Lab032 functional checks failed: "
            + "; ".join(
                f"{result.name}: {result.detail}"
                for result in failed
            )
        )
    return test_results


def validate_results(results: list[SimulationResult]) -> None:
    expected = len(MODES) * len(MEAN_BAD_DURATIONS_SECONDS)
    if len(results) != expected:
        raise RuntimeError(
            f"expected {expected} rows, got {len(results)}"
        )
    keys = {
        (result.mode, result.mean_bad_duration_ms)
        for result in results
    }
    if len(keys) != expected:
        raise RuntimeError("Lab032 result rows are not unique")
    for result in results:
        if not 0.0 <= result.composite_success_rate <= 1.0:
            raise RuntimeError("composite success outside 0...1")
        if not 0.0 <= result.base_success_rate <= 1.0:
            raise RuntimeError("BASE success outside 0...1")
        if not 0.0 <= result.roi_success_rate <= 1.0:
            raise RuntimeError("ROI success outside 0...1")


def save_results_csv(results: list[SimulationResult]) -> None:
    with CSV_PATH.open("w", newline="", encoding="utf-8") as file:
        writer = csv.DictWriter(file, fieldnames=CSV_FIELDS)
        writer.writeheader()
        for result in results:
            writer.writerow(asdict(result))


def summary_rows(
    results: list[SimulationResult],
) -> list[dict[str, object]]:
    lookups = {
        duration: result_lookup(results, duration)
        for duration in (10.0, 50.0, 200.0, 1000.0)
    }
    rows = []
    for mode in MODES:
        representative = lookups[200.0][mode.name]
        row: dict[str, object] = {
            "mode": mode.name,
            "label": mode.label,
            "source_block_size": mode.source_count,
            "nominal_parity_count": mode.parity_count,
            "code_rate": mode.code_rate,
            "outer_fec_stream_bitrate_kbps": (
                representative.outer_fec_stream_bitrate_kbps
            ),
            "margin_to_control_bitrate_kbps": (
                representative.margin_to_control_bitrate_kbps
            ),
            "service_and_parity_percent": (
                representative.service_and_parity_percent
            ),
            "fec_block_count": representative.fec_block_count,
            "mean_block_duration_seconds": (
                representative.mean_block_duration_seconds
            ),
            "max_block_duration_seconds": (
                representative.max_block_duration_seconds
            ),
            "mean_decode_ready_duration_seconds": (
                representative.mean_decode_ready_duration_seconds
            ),
            "mean_queue_length_packets": (
                representative.mean_queue_length_packets
            ),
            "max_queue_length_packets": (
                representative.max_queue_length_packets
            ),
        }
        for duration in (10, 50, 200, 1000):
            result = lookups[float(duration)][mode.name]
            row[f"composite_success_{duration}ms"] = (
                result.composite_success_rate
            )
            row[f"p95_publication_delay_{duration}ms_seconds"] = (
                result.p95_publication_delay_seconds
            )
            row[f"p95_no_new_image_{duration}ms_seconds"] = (
                result.p95_no_new_image_duration_seconds
            )
        rows.append(row)
    return rows


def save_summary_csv(results: list[SimulationResult]) -> None:
    rows = summary_rows(results)
    with SUMMARY_CSV_PATH.open(
        "w", newline="", encoding="utf-8"
    ) as file:
        writer = csv.DictWriter(
            file, fieldnames=list(rows[0])
        )
        writer.writeheader()
        writer.writerows(rows)


def _rows_for_mode(
    results: list[SimulationResult],
    mode_name: str,
) -> list[SimulationResult]:
    return sorted(
        (
            result
            for result in results
            if result.mode == mode_name
        ),
        key=lambda result: result.mean_bad_duration_ms,
    )


def _save_line_plot(
    results: list[SimulationResult],
    field: str,
    ylabel: str,
    title: str,
    path: Path,
) -> None:
    figure, axis = plt.subplots(figsize=(11.0, 6.5))
    for mode in MODES:
        rows = _rows_for_mode(results, mode.name)
        axis.plot(
            [row.mean_bad_duration_ms for row in rows],
            [getattr(row, field) for row in rows],
            marker="o",
            linewidth=1.8,
            label=mode.label,
        )
    axis.set_xscale("log")
    axis.set_xlabel("Средняя длительность Bad, мс")
    axis.set_ylabel(ylabel)
    axis.set_title(title)
    axis.grid(True, which="both", alpha=0.28)
    axis.legend(ncol=2, fontsize=8)
    figure.tight_layout()
    figure.savefig(path, dpi=160)
    plt.close(figure)


def practical_frontier(
    results: list[SimulationResult],
    mean_bad_duration_ms: float = 200.0,
) -> list[SimulationResult]:
    rows = list(
        result_lookup(results, mean_bad_duration_ms).values()
    )
    frontier = []
    for candidate in rows:
        dominated = any(
            other.mode != candidate.mode
            and (
                other.outer_fec_stream_bitrate_kbps
                <= candidate.outer_fec_stream_bitrate_kbps
            )
            and (
                other.p95_publication_delay_seconds
                <= candidate.p95_publication_delay_seconds
            )
            and (
                other.composite_success_rate
                >= candidate.composite_success_rate
            )
            and (
                other.outer_fec_stream_bitrate_kbps
                < candidate.outer_fec_stream_bitrate_kbps
                or other.p95_publication_delay_seconds
                < candidate.p95_publication_delay_seconds
                or other.composite_success_rate
                > candidate.composite_success_rate
            )
            for other in rows
        )
        if not dominated:
            frontier.append(candidate)
    return sorted(
        frontier,
        key=lambda result: result.outer_fec_stream_bitrate_kbps,
    )


def save_plots(results: list[SimulationResult]) -> None:
    _save_line_plot(
        results,
        "composite_success_rate",
        "Доля восстановленных составных кадров",
        "Lab032. Атомарное восстановление BASE + ROI",
        COMPOSITE_SUCCESS_PLOT_PATH,
    )
    _save_line_plot(
        results,
        "p95_publication_delay_seconds",
        "P95 задержки публикации, с",
        "Lab032. Задержка публикации составного кадра",
        PUBLICATION_DELAY_PLOT_PATH,
    )
    _save_line_plot(
        results,
        "p95_no_new_image_duration_seconds",
        "P95 отсутствия нового изображения, с",
        "Lab032. Длительность удержания изображения",
        NO_IMAGE_PLOT_PATH,
    )

    representative = result_lookup(results, 200.0)
    rows = [representative[mode.name] for mode in MODES]
    positions = np.arange(len(rows))
    figure, rate_axis = plt.subplots(figsize=(11.4, 6.5))
    overhead_axis = rate_axis.twinx()
    rate_bars = rate_axis.bar(
        positions - 0.2,
        [row.outer_fec_stream_bitrate_kbps for row in rows],
        width=0.4,
        color="#1565c0",
        label="Внешний поток",
    )
    overhead_bars = overhead_axis.bar(
        positions + 0.2,
        [row.service_and_parity_percent for row in rows],
        width=0.4,
        color="#ef6c00",
        alpha=0.72,
        label="Служебная доля",
    )
    limit_line = rate_axis.axhline(
        CONTROL_STREAM_BITRATE_KBPS,
        color="#b71c1c",
        linestyle="--",
        linewidth=1.5,
        label="300 кбит/с",
    )
    rate_axis.set_xticks(
        positions,
        [mode.label for mode in MODES],
        rotation=24,
        ha="right",
    )
    rate_axis.set_ylabel("Внешний поток, кбит/с")
    overhead_axis.set_ylabel("Служебные и parity, %")
    rate_axis.set_title("Lab032. Скорость потока и избыточность")
    rate_axis.grid(True, axis="y", alpha=0.25)
    rate_axis.legend(
        [rate_bars, overhead_bars, limit_line],
        ["Внешний поток", "Служебная доля", "300 кбит/с"],
        loc="upper left",
    )
    figure.tight_layout()
    figure.savefig(STREAM_OVERHEAD_PLOT_PATH, dpi=160)
    plt.close(figure)

    equal_rate_modes = ("4+1", "8+2", "12+3")
    figure, success_axis = plt.subplots(figsize=(10.4, 6.3))
    decode_axis = success_axis.twinx()
    block_sizes = [
        representative[name].source_block_size
        for name in equal_rate_modes
    ]
    for duration in (10.0, 50.0, 200.0, 1000.0):
        lookup = result_lookup(results, duration)
        success_axis.plot(
            block_sizes,
            [
                lookup[name].composite_success_rate
                for name in equal_rate_modes
            ],
            marker="o",
            linewidth=1.8,
            label=f"Bad {duration:.0f} мс",
        )
    decode_axis.plot(
        block_sizes,
        [
            representative[
                name
            ].mean_decode_ready_duration_seconds
            for name in equal_rate_modes
        ],
        color="#c62828",
        marker="s",
        linestyle="--",
        linewidth=2.0,
        label="До декодирования",
    )
    success_axis.set_xticks(block_sizes)
    success_axis.set_xlabel("Число исходных пакетов k")
    success_axis.set_ylabel("Доля восстановленных кадров")
    decode_axis.set_ylabel("Среднее время до декодирования, с")
    success_axis.set_title(
        "Lab032. Влияние размера блока при кодовой скорости 0.8"
    )
    success_axis.grid(True, alpha=0.28)
    lines = success_axis.lines + decode_axis.lines
    success_axis.legend(
        lines,
        [line.get_label() for line in lines],
        loc="best",
        fontsize=8,
    )
    figure.tight_layout()
    figure.savefig(BLOCK_SIZE_PLOT_PATH, dpi=160)
    plt.close(figure)

    figure, axis = plt.subplots(figsize=(10.7, 6.5))
    delay_values = np.array(
        [row.p95_publication_delay_seconds for row in rows]
    )
    scatter = axis.scatter(
        [row.outer_fec_stream_bitrate_kbps for row in rows],
        [row.composite_success_rate for row in rows],
        c=delay_values,
        s=100,
        cmap="viridis_r",
        edgecolors="black",
        linewidths=0.6,
    )
    for row in rows:
        axis.annotate(
            row.label,
            (
                row.outer_fec_stream_bitrate_kbps,
                row.composite_success_rate,
            ),
            xytext=(5, 5),
            textcoords="offset points",
            fontsize=8,
        )
    frontier = practical_frontier(results)
    axis.plot(
        [
            row.outer_fec_stream_bitrate_kbps
            for row in frontier
        ],
        [row.composite_success_rate for row in frontier],
        color="#d32f2f",
        linestyle="--",
        linewidth=1.4,
        label="Недоминируемая граница",
    )
    axis.axvline(
        CONTROL_STREAM_BITRATE_KBPS,
        color="#555555",
        linestyle=":",
        linewidth=1.3,
    )
    axis.set_xlabel("Внешний поток, кбит/с")
    axis.set_ylabel("Доля восстановленных кадров")
    axis.set_title(
        "Lab032. Практическая граница при Bad 200 мс"
    )
    axis.grid(True, alpha=0.25)
    axis.legend(loc="lower right")
    colorbar = figure.colorbar(scatter, ax=axis)
    colorbar.set_label("P95 задержки публикации, с")
    figure.tight_layout()
    figure.savefig(PRACTICAL_FRONTIER_PLOT_PATH, dpi=160)
    plt.close(figure)


def _summary_table(
    results: list[SimulationResult],
) -> list[str]:
    lookups = {
        duration: result_lookup(results, duration)
        for duration in (10.0, 50.0, 200.0, 1000.0)
    }
    lines = [
        (
            "mode | rate | outer/margin kbit/s | composite "
            "10/50/200/1000 ms | pub p95 200 ms s | no-image "
            "p95 200 ms s | block mean/max ms | decode mean ms | "
            "queue mean/max"
        ),
        (
            "----:|-----:|----------------------:|"
            "----------------------------:|-------------------:"
            "|------------------------:|------------------:"
            "|---------------:|---------------:"
        ),
    ]
    for mode in MODES:
        row = lookups[200.0][mode.name]
        composites = "/".join(
            f"{lookups[duration][mode.name].composite_success_rate:.6f}"
            for duration in (10.0, 50.0, 200.0, 1000.0)
        )
        lines.append(
            f"{mode.label} | {mode.code_rate:.6f} | "
            f"{row.outer_fec_stream_bitrate_kbps:.3f}/"
            f"{row.margin_to_control_bitrate_kbps:.3f} | "
            f"{composites} | "
            f"{row.p95_publication_delay_seconds:.6f} | "
            f"{row.p95_no_new_image_duration_seconds:.6f} | "
            f"{row.mean_block_duration_seconds * 1000:.3f}/"
            f"{row.max_block_duration_seconds * 1000:.3f} | "
            f"{row.mean_decode_ready_duration_seconds * 1000:.3f} | "
            f"{row.mean_queue_length_packets:.3f}/"
            f"{row.max_queue_length_packets}"
        )
    return lines


def _detailed_200ms_table(
    results: list[SimulationResult],
) -> list[str]:
    rows = result_lookup(results, 200.0)
    lines = [
        (
            "mode | BASE/ROI/composite | blocks ok/fail | restored "
            "packets | delivered kbit/s | publication mean/p95/max s "
            "| no-image mean/p95/max s | incomplete mean/p95/max"
        ),
        (
            "----:|-------------------:|---------------:|"
            "-----------------:|-----------------:|"
            "--------------------------:|-----------------------:"
            "|------------------------:"
        ),
    ]
    for mode in MODES:
        row = rows[mode.name]
        lines.append(
            f"{mode.label} | {row.base_success_rate:.6f}/"
            f"{row.roi_success_rate:.6f}/"
            f"{row.composite_success_rate:.6f} | "
            f"{row.fec_all_block_success_rate:.6f}/"
            f"{row.fec_unrecoverable_block_rate:.6f} | "
            f"{row.recovered_source_packets} | "
            f"{row.effective_delivered_video_bitrate_kbps:.3f} | "
            f"{row.mean_publication_delay_seconds:.6f}/"
            f"{row.p95_publication_delay_seconds:.6f}/"
            f"{row.max_publication_delay_seconds:.6f} | "
            f"{row.mean_no_new_image_duration_seconds:.6f}/"
            f"{row.p95_no_new_image_duration_seconds:.6f}/"
            f"{row.max_no_new_image_duration_seconds:.6f} | "
            f"{row.mean_consecutive_incomplete_frames:.3f}/"
            f"{row.p95_consecutive_incomplete_frames:.3f}/"
            f"{row.max_consecutive_incomplete_frames}"
        )
    return lines


def write_report(
    metadata: VideoMetadata,
    composites: list[EncodedComposite],
    schedules: dict[str, ParameterSchedule],
    results: list[SimulationResult],
    tests: list[FunctionalTestResult],
) -> None:
    representative = result_lookup(results, 200.0)
    equal_rate = [
        representative[name]
        for name in ("4+1", "8+2", "12+3")
    ]
    strong = [
        representative[name] for name in ("4+2", "8+4")
    ]
    intermediate = representative["8+3"]
    lower = representative["8+2"]
    upper = representative["8+4"]
    frontier = practical_frontier(results)
    best_resilience = max(
        representative.values(),
        key=lambda result: result.composite_success_rate,
    )
    minimum_rate = min(
        representative.values(),
        key=lambda result: result.outer_fec_stream_bitrate_kbps,
    )
    protected = [
        row
        for row in representative.values()
        if row.nominal_parity_count
    ]
    minimum_protected_delay = min(
        protected,
        key=lambda result: result.p95_publication_delay_seconds,
    )

    lines = [
        "Lab032. Подбор параметров блочного исправления потерь",
        "",
        "Исходный профиль и неизменные слои",
        f"- Видео: {SOURCE_VIDEO_PATH}",
        (
            f"- {len(composites)} реальных пар BASE/ROI, "
            f"{COMPOSITE_FPS:.0f} fps; длительность "
            f"{metadata.duration_seconds:.6f} с."
        ),
        (
            "- BASE 240x135 grayscale JPEG Q23; ROI 320x180 "
            "grayscale JPEG Q33."
        ),
        (
            "- Внутренний Lab028 packet: payload 512 байт; внешний "
            "формат и GF(256) Lab030 не изменены."
        ),
        (
            "- Глубина D=1: systematic symbols блока передаются "
            "первыми, затем parity; перемежение отсутствует."
        ),
        (
            "- Блоки формируются из непрерывного потока внутренних "
            "пакетов и могут пересекать composite_frame_id."
        ),
        "",
        "Последний неполный блок",
        "- Используется единое правило:",
        "  r_last = max(1, ceil(k_last × r / k)).",
        (
            "- Нулевое дополнение существует только внутри математики "
            "GF(256); внешние пакеты синтетически не добавляются."
        ),
        "",
        "Сравнительная таблица восьми режимов",
        *_summary_table(results),
        "",
        "Подробные результаты при средней Bad 200 мс",
        *_detailed_200ms_table(results),
        "",
        "Временная модель и очередь",
        (
            "- Непрерывные чередующиеся экспоненциальные Good/Bad "
            "интервалы Lab029B, средняя доля Bad около 2%."
        ),
        (
            "- Средние Bad 10, 50, 200 и 1000 мс; 200 повторов; "
            f"fixed seeds {SEED_BASE}...{SEED_BASE + 3}."
        ),
        (
            "- Пакет теряется при любом пересечении передачи с Bad. "
            "FIFO учитывает фактическую длину каждого внешнего пакета "
            "и скорость 300 кбит/с."
        ),
        (
            "- Все исследованные режимы остаются ниже 300 кбит/с; "
            "положительный запас показан в таблице. Отдельная "
            "функциональная проверка ускоряет поток 8+4 вдвое и "
            "подтверждает реальное накопление очереди при перегрузке."
        ),
        (
            "- Block duration измеряется от начала первого до конца "
            "последнего символа. Decode-ready без потерь — от начала "
            "первого до конца k-го systematic symbol."
        ),
        "",
        "1. Одинаковая кодовая скорость 0.8: 4+1, 8+2 и 12+3",
        *[
            (
                f"- {row.label}: outer "
                f"{row.outer_fec_stream_bitrate_kbps:.3f} кбит/с, "
                f"block mean "
                f"{row.mean_block_duration_seconds * 1000:.3f} мс, "
                f"decode-ready "
                f"{row.mean_decode_ready_duration_seconds * 1000:.3f} "
                f"мс, composite@200 "
                f"{row.composite_success_rate:.6f}."
            )
            for row in equal_rate
        ],
        (
            "- При одинаковой номинальной code rate длина блока "
            "меняет временное окно, в котором одна помеха может собрать "
            "несколько стираний. Короткий блок раньше получает k "
            "systematic symbols и быстрее допускает декодирование."
        ),
        (
            "- Длинный блок дольше остаётся открытым и может включить "
            "больше потерянных пакетов одной длительной помехи; при этом "
            "его больший абсолютный r иногда полезен для рассеянных "
            "стираний."
        ),
        "",
        "2. Одинаковая code rate 2/3: 4+2 и 8+4",
        *[
            (
                f"- {row.label}: outer "
                f"{row.outer_fec_stream_bitrate_kbps:.3f} кбит/с, "
                f"composite@200 "
                f"{row.composite_success_rate:.6f}, "
                f"publication P95 "
                f"{row.p95_publication_delay_seconds:.6f} с, "
                f"no-image P95 "
                f"{row.p95_no_new_image_duration_seconds:.6f} с."
            )
            for row in strong
        ],
        (
            "- Сравнение изолирует влияние длины блока при одинаковой "
            "сильной избыточности: короткий 4+2 быстрее закрывается, "
            "длинный 8+4 имеет больше абсолютных parity symbols, но "
            "дольше накапливает стирания."
        ),
        "",
        "3. Влияние увеличения r",
        (
            "- Большее r позволяет исправить больше стираний, но "
            "увеличивает внешний поток, служебную долю, время передачи "
            "блока и публикационную задержку."
        ),
        (
            f"- 8+3: outer "
            f"{intermediate.outer_fec_stream_bitrate_kbps:.3f} кбит/с "
            f"(8+2 {lower.outer_fec_stream_bitrate_kbps:.3f}, "
            f"8+4 {upper.outer_fec_stream_bitrate_kbps:.3f}); "
            f"composite@200 {intermediate.composite_success_rate:.6f} "
            f"(8+2 {lower.composite_success_rate:.6f}, "
            f"8+4 {upper.composite_success_rate:.6f}); "
            f"P95 delay {intermediate.p95_publication_delay_seconds:.6f} "
            f"с (8+2 {lower.p95_publication_delay_seconds:.6f}, "
            f"8+4 {upper.p95_publication_delay_seconds:.6f})."
        ),
        (
            "- Поэтому 8+3 оценивается по фактическому положению между "
            "8+2 и 8+4 сразу по скорости, устойчивости и задержке, без "
            "автоматического назначения рабочим режимом."
        ),
        "",
        "4. Практическая граница без автоматического выбора",
        (
            f"- Минимальный поток: {minimum_rate.label}, "
            f"{minimum_rate.outer_fec_stream_bitrate_kbps:.3f} кбит/с."
        ),
        (
            f"- Максимальная composite-устойчивость при Bad 200 мс: "
            f"{best_resilience.label}, "
            f"{best_resilience.composite_success_rate:.6f}."
        ),
        (
            f"- Минимальная P95-задержка среди защищённых режимов: "
            f"{minimum_protected_delay.label}, "
            f"{minimum_protected_delay.p95_publication_delay_seconds:.6f} "
            "с."
        ),
        (
            "- Недоминируемые по outer bitrate↓, publication P95↓ и "
            "composite success↑ режимы при Bad 200 мс: "
            + ", ".join(row.label for row in frontier)
            + "."
        ),
        (
            "- Это набор компромиссов, а не автоматический выбор "
            "единственного режима."
        ),
        "",
        "Функциональные проверки",
        *[
            f"- {'PASS' if test.passed else 'FAIL'} "
            f"{test.name}: {test.detail}"
            for test in tests
        ],
        "",
        "Допущения модели",
        (
            "- JPEG кодируются один раз в памяти; вычислительная "
            "задержка GF(256) считается нулевой."
        ),
        (
            "- Стирается весь внешний пакет; отдельные битовые ошибки "
            "не моделируются, но оба уровня CRC проверяются."
        ),
        (
            "- Publication delay считается от frame_id/3 до конца "
            "пакета, завершившего атомарную сборку BASE+ROI."
        ),
        (
            "- No-image заканчивается публикацией следующего полного "
            "кадра или концом расписания; неполный кадр не публикуется."
        ),
        (
            "- ARQ, повторы, глубокое перемежение, команды, телеметрия, "
            "модуляция и реальный SDR отсутствуют."
        ),
        "",
        "Артефакты",
        f"- Полный CSV: {CSV_PATH}",
        f"- Сводный CSV: {SUMMARY_CSV_PATH}",
        f"- Отчёт: {REPORT_PATH}",
        f"- Доля восстановленных кадров: {COMPOSITE_SUCCESS_PLOT_PATH}",
        f"- Скорость и избыточность: {STREAM_OVERHEAD_PLOT_PATH}",
        f"- Задержка публикации: {PUBLICATION_DELAY_PLOT_PATH}",
        f"- Отсутствие изображения: {NO_IMAGE_PLOT_PATH}",
        f"- Влияние размера блока: {BLOCK_SIZE_PLOT_PATH}",
        f"- Практическая граница: {PRACTICAL_FRONTIER_PLOT_PATH}",
        "- JPEG, пакеты и бинарные дампы не сохранялись.",
        "",
    ]
    REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")


def validate_outputs() -> None:
    expected = (
        CSV_PATH,
        SUMMARY_CSV_PATH,
        REPORT_PATH,
        COMPOSITE_SUCCESS_PLOT_PATH,
        STREAM_OVERHEAD_PLOT_PATH,
        PUBLICATION_DELAY_PLOT_PATH,
        NO_IMAGE_PLOT_PATH,
        BLOCK_SIZE_PLOT_PATH,
        PRACTICAL_FRONTIER_PLOT_PATH,
    )
    for path in expected:
        if not path.exists() or path.stat().st_size <= 0:
            raise RuntimeError(f"missing or empty output: {path}")
    with CSV_PATH.open(newline="", encoding="utf-8") as file:
        rows = list(csv.DictReader(file))
    if len(rows) != 32 or len(rows[0]) != len(CSV_FIELDS):
        raise RuntimeError("Lab032 full CSV shape is invalid")
    with SUMMARY_CSV_PATH.open(
        newline="", encoding="utf-8"
    ) as file:
        summary = list(csv.DictReader(file))
    if len(summary) != 8:
        raise RuntimeError("Lab032 summary CSV must have eight rows")
    for path in expected:
        if path.suffix == ".png":
            image = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
            if image is None:
                raise RuntimeError(f"OpenCV cannot read {path}")


def main() -> None:
    print("Lab032: loading real 512-byte-payload Lab028 stream...")
    metadata, composites = load_video_profile(SOURCE_VIDEO_PATH)
    profile = prepare_profiles(composites)[INNER_PAYLOAD_SIZE]
    schedules = build_schedules(metadata, composites, profile)
    for mode in MODES:
        schedule = schedules[mode.name]
        fec = schedule.fec_schedule
        rate = (
            fec.total_transmitted_bytes
            * 8.0
            / metadata.duration_seconds
            / 1000.0
        )
        print(
            f"  {mode.label}: blocks={len(fec.blocks)}, "
            f"units={len(fec.units)}, offered={rate:.3f} kbit/s, "
            f"block={schedule.mean_block_duration_seconds:.6f} s, "
            f"queue={fec.mean_queue_length_packets:.3f}/"
            f"{fec.max_queue_length_packets}"
        )

    print(
        f"Running {len(MODES) * len(MEAN_BAD_DURATIONS_SECONDS)} "
        f"conditions, {MONTE_CARLO_REPETITIONS} repetitions each..."
    )
    results = run_monte_carlo(schedules, len(composites))
    validate_results(results)

    print("Running Lab032 functional checks...")
    tests = run_functional_tests(
        composites, schedules, results
    )
    for test in tests:
        print(f"  PASS {test.name}: {test.detail}")

    OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
    save_results_csv(results)
    save_summary_csv(results)
    save_plots(results)
    write_report(
        metadata, composites, schedules, results, tests
    )
    validate_outputs()

    representative = result_lookup(results, 200.0)
    print("Representative Bad=200 ms results:")
    for mode in MODES:
        result = representative[mode.name]
        print(
            f"  {mode.label}: outer="
            f"{result.outer_fec_stream_bitrate_kbps:.3f} kbit/s, "
            f"composite={result.composite_success_rate:.6f}, "
            f"delay_p95={result.p95_publication_delay_seconds:.6f} s"
        )
    print(f"Full CSV: {CSV_PATH}")
    print(f"Summary CSV: {SUMMARY_CSV_PATH}")
    print(f"Report: {REPORT_PATH}")
    print("Lab032 completed successfully.")


if __name__ == "__main__":
    main()
