"""
Lab031. Packet interleaving between packet-erasure FEC blocks.

The Lab028 512-byte-payload transport, Lab030 outer packet format and
GF(256) implementation, and Lab029B continuous-time channel are reused
unchanged.  Complete consecutive FEC blocks are buffered in groups and sent
by symbol index across the group.  Seven fixed modes compare no FEC, 8+2 at
depths 1/2/4/8, and 8+4 at depths 1/4.
"""

from __future__ import annotations

import csv
from dataclasses import asdict, dataclass, replace
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.fec_interleaver import (
    deinterleave_symbols,
    interleave_blocks,
    interleave_group,
)
from protocol.packet_erasure_fec import (
    decode_fec_block,
    decode_outer_symbol,
    encode_fec_block,
)
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,
    overlap_loss_flags,
    prepare_source_packets,
    schedule_units,
    simulate_baseline,
    simulate_fec,
)


OUTPUT_DIRECTORY = Path("data/processed/lab031")
CSV_PATH = OUTPUT_DIRECTORY / "lab031_results.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab031_report.txt"
COMPOSITE_SUCCESS_PLOT_PATH = (
    OUTPUT_DIRECTORY / "lab031_composite_success.png"
)
NO_IMAGE_PLOT_PATH = OUTPUT_DIRECTORY / "lab031_no_image_duration.png"
PUBLICATION_DELAY_PLOT_PATH = (
    OUTPUT_DIRECTORY / "lab031_publication_delay.png"
)
BLOCK_RECOVERY_PLOT_PATH = (
    OUTPUT_DIRECTORY / "lab031_block_recovery.png"
)
DEPTH_TRADEOFF_PLOT_PATH = (
    OUTPUT_DIRECTORY / "lab031_depth_tradeoff.png"
)
MODE_COMPARISON_PLOT_PATH = (
    OUTPUT_DIRECTORY / "lab031_mode_comparison.png"
)

INNER_PAYLOAD_SIZE = 512
SOURCE_BLOCK_SIZE = 8
MONTE_CARLO_REPETITIONS = 200
MASTER_SEED = 310_310
SEED_BASE = MASTER_SEED


@dataclass(frozen=True)
class InterleavingMode:
    name: str
    parity_count: int
    depth: int
    label: str


MODES = (
    InterleavingMode("none", 0, 1, "Без FEC"),
    InterleavingMode("8+2_D1", 2, 1, "8+2, D=1"),
    InterleavingMode("8+2_D2", 2, 2, "8+2, D=2"),
    InterleavingMode("8+2_D4", 2, 4, "8+2, D=4"),
    InterleavingMode("8+2_D8", 2, 8, "8+2, D=8"),
    InterleavingMode("8+4_D1", 4, 1, "8+4, D=1"),
    InterleavingMode("8+4_D4", 4, 4, "8+4, D=4"),
)


@dataclass(frozen=True)
class InterleavingGroupPlan:
    group_index: int
    block_ids: tuple[int, ...]
    ready_seconds: tuple[float, ...]
    release_seconds: float
    unit_count: int


@dataclass(frozen=True)
class InterleavedSchedule:
    mode: InterleavingMode
    fec_schedule: ModeSchedule
    groups: tuple[InterleavingGroupPlan, ...]
    group_wait_delays_seconds: tuple[float, ...]
    mean_group_wait_delay_seconds: float
    p95_group_wait_delay_seconds: float
    max_group_wait_delay_seconds: float
    mean_buffered_blocks: float
    max_buffered_blocks: int
    mean_intrablock_symbol_gap_seconds: float
    mean_intrablock_symbol_span_seconds: float
    max_intrablock_symbol_span_seconds: float


@dataclass(frozen=True)
class SimulationResult:
    mode: str
    label: str
    source_block_size: int
    nominal_parity_count: int
    interleaving_depth: int
    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
    service_and_parity_percent: float
    control_stream_bitrate_kbps: float
    schedule_duration_seconds: float
    interleaving_group_count: int
    final_group_block_count: int
    mean_group_wait_delay_seconds: float
    p95_group_wait_delay_seconds: float
    max_group_wait_delay_seconds: float
    mean_queue_length_packets: float
    max_queue_length_packets: int
    mean_buffered_blocks: float
    max_buffered_blocks: int
    mean_intrablock_symbol_gap_seconds: float
    mean_intrablock_symbol_span_seconds: float
    max_intrablock_symbol_span_seconds: float
    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
    roi_objects_completed: int
    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 _block_units(
    units: tuple[TransmissionUnit, ...],
) -> tuple[tuple[TransmissionUnit, ...], ...]:
    grouped: dict[int, list[TransmissionUnit]] = {}
    order = []
    for unit in units:
        if unit.block_id not in grouped:
            order.append(unit.block_id)
            grouped[unit.block_id] = []
        grouped[unit.block_id].append(unit)
    return tuple(tuple(grouped[block_id]) for block_id in order)


def _symbol_timing(
    schedule: ModeSchedule,
) -> tuple[float, float, float]:
    endings: dict[int, list[tuple[int, float]]] = {}
    for scheduled in schedule.units:
        unit = scheduled.unit
        endings.setdefault(unit.block_id, []).append(
            (unit.symbol_index, scheduled.end_seconds)
        )
    gaps = []
    spans = []
    for values in endings.values():
        ordered = [
            end for _, end in sorted(values)
        ]
        gaps.extend(
            right - left
            for left, right in zip(ordered, ordered[1:])
        )
        if len(ordered) > 1:
            spans.append(ordered[-1] - ordered[0])
    return (
        float(np.mean(gaps)) if gaps else 0.0,
        float(np.mean(spans)) if spans else 0.0,
        max(spans) if spans else 0.0,
    )


def _make_mode_schedule(
    metadata: VideoMetadata,
    composites: list[EncodedComposite],
    source_packets: tuple[SourcePacket, ...],
    mode: InterleavingMode,
) -> InterleavedSchedule:
    lab030_mode = FECMode(mode.name, mode.parity_count, mode.label)
    sequential_units, blocks = build_mode_units(
        source_packets, lab030_mode
    )

    groups = []
    waits = []
    buffer_samples = []
    if mode.parity_count == 0:
        reordered_units = sequential_units
    else:
        plans_by_id = {plan.block_id: plan for plan in blocks}
        units_by_block = _block_units(sequential_units)
        reordered = []
        sequence_index = 0
        for group_index, start in enumerate(
            range(0, len(units_by_block), mode.depth)
        ):
            group_units = units_by_block[start:start + mode.depth]
            block_ids = tuple(
                units[0].block_id for units in group_units
            )
            ready_times = tuple(
                max(
                    source_packets[index].generation_time_seconds
                    for index in plans_by_id[
                        block_id
                    ].source_global_indices
                )
                for block_id in block_ids
            )
            complete_group = len(group_units) == mode.depth
            release_time = max(ready_times)
            if mode.depth > 1 and not complete_group:
                release_time = max(
                    release_time, metadata.duration_seconds
                )
            waits.extend(
                release_time - ready for ready in ready_times
            )
            buffer_samples.extend(
                range(1, len(group_units) + 1)
            )

            wire_blocks = tuple(
                tuple(unit.wire_packet for unit in units)
                for units in group_units
            )
            interleaved = interleave_group(wire_blocks)
            lookup = {
                (unit.block_id, unit.symbol_index): unit
                for units in group_units
                for unit in units
            }
            for wire_packet in interleaved:
                parsed = decode_outer_symbol(wire_packet)
                original = lookup[
                    (parsed.block_id, parsed.symbol_index)
                ]
                generation_time = (
                    original.generation_time_seconds
                    if mode.depth == 1
                    else release_time
                )
                reordered.append(
                    replace(
                        original,
                        sequence_index=sequence_index,
                        generation_time_seconds=generation_time,
                    )
                )
                sequence_index += 1
            groups.append(
                InterleavingGroupPlan(
                    group_index=group_index,
                    block_ids=block_ids,
                    ready_seconds=ready_times,
                    release_seconds=release_time,
                    unit_count=len(interleaved),
                )
            )
        reordered_units = tuple(reordered)

    scheduled, duration, mean_queue, max_queue = schedule_units(
        reordered_units
    )
    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
    )
    fec_schedule = ModeSchedule(
        mode=lab030_mode,
        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,
    )
    if mode.parity_count:
        symbol_gap, symbol_span, max_symbol_span = _symbol_timing(
            fec_schedule
        )
    else:
        symbol_gap, symbol_span, max_symbol_span = 0.0, 0.0, 0.0
    return InterleavedSchedule(
        mode=mode,
        fec_schedule=fec_schedule,
        groups=tuple(groups),
        group_wait_delays_seconds=tuple(waits),
        mean_group_wait_delay_seconds=(
            float(np.mean(waits)) if waits else 0.0
        ),
        p95_group_wait_delay_seconds=percentile(waits, 95),
        max_group_wait_delay_seconds=max(waits) if waits else 0.0,
        mean_buffered_blocks=(
            float(np.mean(buffer_samples))
            if buffer_samples
            else 0.0
        ),
        max_buffered_blocks=(
            max(buffer_samples) if buffer_samples else 0
        ),
        mean_intrablock_symbol_gap_seconds=symbol_gap,
        mean_intrablock_symbol_span_seconds=symbol_span,
        max_intrablock_symbol_span_seconds=max_symbol_span,
    )


def build_schedules(
    metadata: VideoMetadata,
    composites: list[EncodedComposite],
    profile: PreparedProfile,
) -> dict[str, InterleavedSchedule]:
    source_packets = prepare_source_packets(profile)
    return {
        mode.name: _make_mode_schedule(
            metadata, composites, source_packets, mode
        )
        for mode in MODES
    }


def _aggregate_condition(
    schedule: InterleavedSchedule,
    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
    source_packets_per_pass = len(fec_schedule.source_packets)
    parity_packets_per_pass = sum(
        int(unit.unit.is_parity) for unit in fec_schedule.units
    )
    total_fec_blocks = len(fec_schedule.blocks) * repetitions
    delivered_bytes = total("delivered_jpeg_bytes")
    source_jpeg_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
    )
    return SimulationResult(
        mode=schedule.mode.name,
        label=schedule.mode.label,
        source_block_size=SOURCE_BLOCK_SIZE,
        nominal_parity_count=schedule.mode.parity_count,
        interleaving_depth=schedule.mode.depth,
        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_jpeg_rate,
        inner_packet_stream_bitrate_kbps=inner_rate,
        outer_fec_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,
        interleaving_group_count=len(schedule.groups),
        final_group_block_count=(
            len(schedule.groups[-1].block_ids)
            if schedule.groups
            else 0
        ),
        mean_group_wait_delay_seconds=(
            schedule.mean_group_wait_delay_seconds
        ),
        p95_group_wait_delay_seconds=(
            schedule.p95_group_wait_delay_seconds
        ),
        max_group_wait_delay_seconds=(
            schedule.max_group_wait_delay_seconds
        ),
        mean_queue_length_packets=(
            fec_schedule.mean_queue_length_packets
        ),
        max_queue_length_packets=(
            fec_schedule.max_queue_length_packets
        ),
        mean_buffered_blocks=schedule.mean_buffered_blocks,
        max_buffered_blocks=schedule.max_buffered_blocks,
        mean_intrablock_symbol_gap_seconds=(
            schedule.mean_intrablock_symbol_gap_seconds
        ),
        mean_intrablock_symbol_span_seconds=(
            schedule.mean_intrablock_symbol_span_seconds
        ),
        max_intrablock_symbol_span_seconds=(
            schedule.max_intrablock_symbol_span_seconds
        ),
        transmitted_source_packets=(
            source_packets_per_pass * 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"),
        roi_objects_completed=total("roi_objects_completed"),
        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=(
            delivered_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, InterleavedSchedule],
    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(
                _aggregate_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, InterleavedSchedule],
) -> list[FunctionalTestResult]:
    tests: list[tuple[str, Callable[[], str]]] = []
    protected_modes = tuple(
        mode for mode in MODES if mode.parity_count
    )
    sequential_82, _ = build_mode_units(
        schedules["8+2_D1"].fec_schedule.source_packets,
        FECMode("lab030_8+2", 2, "8+2"),
    )

    def d1_matches_lab030_order() -> str:
        actual = tuple(
            unit.unit.wire_packet
            for unit in schedules["8+2_D1"].fec_schedule.units
        )
        expected = tuple(
            unit.wire_packet for unit in sequential_82
        )
        if actual != expected:
            raise AssertionError("D=1 changed Lab030 wire order")
        return "D=1 wire order is byte-identical to Lab030"

    def permutation_preserves_multiset() -> str:
        schedule = schedules["8+2_D8"]
        original, _ = build_mode_units(
            schedule.fec_schedule.source_packets,
            FECMode("reference", 2, "8+2"),
        )
        before = sorted(unit.wire_packet for unit in original)
        after = sorted(
            unit.unit.wire_packet
            for unit in schedule.fec_schedule.units
        )
        if before != after:
            raise AssertionError("interleaving added or removed packets")
        return f"all {len(before)} packets preserved"

    def every_packet_once() -> str:
        for mode in protected_modes:
            packets = [
                unit.unit.wire_packet
                for unit in schedules[mode.name].fec_schedule.units
            ]
            if len(packets) != len(set(packets)):
                raise AssertionError(
                    f"{mode.name} contains a duplicate packet"
                )
        return "every protected-mode packet appears exactly once"

    def identifiers_are_preserved() -> str:
        schedule = schedules["8+2_D4"].fec_schedule
        for unit in schedule.units:
            parsed = decode_outer_symbol(unit.unit.wire_packet)
            if (
                parsed.block_id != unit.unit.block_id
                or parsed.symbol_index != unit.unit.symbol_index
            ):
                raise AssertionError("outer identifiers changed")
        return "block_id and symbol_index survived permutation"

    def inverse_distribution_works() -> str:
        checked = []
        source_packets = schedules[
            "8+2_D1"
        ].fec_schedule.source_packets[:64]
        for depth in (2, 4, 8):
            wire_blocks = tuple(
                encode_fec_block(
                    tuple(
                        packet.inner_packet
                        for packet in source_packets[
                            start:start + SOURCE_BLOCK_SIZE
                        ]
                    ),
                    start // SOURCE_BLOCK_SIZE,
                    2,
                )
                for start in range(
                    0, len(source_packets), SOURCE_BLOCK_SIZE
                )
            )
            interleaved = interleave_blocks(wire_blocks, depth)
            restored = deinterleave_symbols(interleaved)
            if restored != wire_blocks:
                raise AssertionError(
                    f"inverse distribution failed for D={depth}"
                )
            checked.append(depth)
        return f"inverse grouping passed for D={checked}"

    def decoded_packets_are_exact() -> str:
        source_packets = schedules[
            "8+2_D1"
        ].fec_schedule.source_packets[:32]
        wire_blocks = tuple(
            encode_fec_block(
                tuple(
                    packet.inner_packet
                    for packet in source_packets[
                        start:start + SOURCE_BLOCK_SIZE
                    ]
                ),
                start // SOURCE_BLOCK_SIZE,
                2,
            )
            for start in range(0, 32, SOURCE_BLOCK_SIZE)
        )
        interleaved = interleave_blocks(wire_blocks, 4)
        received = tuple(
            packet
            for packet in interleaved
            if decode_outer_symbol(packet).symbol_index not in {1, 7}
        )
        restored_blocks = deinterleave_symbols(received)
        restored_packets = []
        for block in restored_blocks:
            decoded = decode_fec_block(block)
            restored_packets.extend(decoded.source_packets)
        expected = [
            packet.inner_packet for packet in source_packets
        ]
        if restored_packets != expected:
            raise AssertionError("decoded inner bytes differ")
        return "decoded inner packets are byte-exact"

    def both_crc_layers_pass() -> str:
        schedule = schedules["8+4_D4"].fec_schedule
        first_block = [
            unit.unit.wire_packet
            for unit in schedule.units
            if unit.unit.block_id == 0
            and unit.unit.symbol_index not in {0, 2, 8, 10}
        ]
        for outer in first_block:
            decode_outer_symbol(outer)
        decoded = decode_fec_block(tuple(first_block))
        for inner in decoded.source_packets:
            decode_inner_packet(inner)
        return "outer and recovered Lab028 CRC32 checks passed"

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

    def fixed_seed_is_reproducible() -> str:
        first = _aggregate_condition(
            schedules["8+2_D4"],
            len(composites),
            0.05,
            319_319,
            3,
        )
        second = _aggregate_condition(
            schedules["8+2_D4"],
            len(composites),
            0.05,
            319_319,
            3,
        )
        if first != second:
            raise AssertionError("same seed changed the result")
        return "identical seed produced identical result fields"

    def incomplete_composite_is_not_published() -> str:
        receiver = CompositeReassembler()
        first_frame = [
            packet.inner_packet
            for packet in schedules[
                "none"
            ].fec_schedule.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 depth_increases_symbol_distance() -> str:
        gaps = [
            schedules[f"8+2_D{depth}"].mean_intrablock_symbol_gap_seconds
            for depth in (1, 2, 4, 8)
        ]
        if any(
            right <= left
            for left, right in zip(gaps, gaps[1:])
        ):
            raise AssertionError(
                f"symbol gaps are not strictly increasing: {gaps}"
            )
        return "D=1/2/4/8 strictly increase intrablock symbol spacing"

    def group_wait_is_in_total_delay() -> str:
        d1 = schedules["8+2_D1"]
        d4 = schedules["8+2_D4"]
        flags1 = np.zeros(
            len(d1.fec_schedule.units), dtype=np.bool_
        )
        flags4 = np.zeros(
            len(d4.fec_schedule.units), dtype=np.bool_
        )
        result1 = simulate_fec(
            d1.fec_schedule, flags1, len(composites)
        )
        result4 = simulate_fec(
            d4.fec_schedule, flags4, len(composites)
        )
        mean1 = float(np.mean(result1.publication_delays))
        mean4 = float(np.mean(result4.publication_delays))
        if d4.mean_group_wait_delay_seconds <= 0.0:
            raise AssertionError("D=4 has no group wait")
        if mean4 <= mean1:
            raise AssertionError(
                "group wait did not increase publication delay"
            )
        return (
            f"D=4 group wait {d4.mean_group_wait_delay_seconds:.6f} s "
            f"is reflected in publication delay"
        )

    def final_partial_group_is_transmitted() -> str:
        schedule = schedules["8+2_D8"]
        if not schedule.groups:
            raise AssertionError("no interleaving groups")
        final = schedule.groups[-1]
        if len(final.block_ids) >= schedule.mode.depth:
            raise AssertionError("test stream has no partial final group")
        packets = [
            unit.unit.wire_packet
            for unit in schedule.fec_schedule.units
            if unit.unit.block_id in final.block_ids
        ]
        if len(packets) != final.unit_count:
            raise AssertionError("partial final group lost packets")
        if (
            final.release_seconds
            < schedule.fec_schedule.source_duration_seconds
        ):
            raise AssertionError("partial group was released before stream end")
        return (
            f"final {len(final.block_ids)}-block group emitted "
            f"{len(packets)} packets at end of stream"
        )

    tests.extend(
        [
            ("d1_matches_lab030", d1_matches_lab030_order),
            ("permutation_preserves_packets", permutation_preserves_multiset),
            ("each_packet_once", every_packet_once),
            ("identifiers_preserved", identifiers_are_preserved),
            ("inverse_distribution", inverse_distribution_works),
            ("decoded_inner_byte_exact", decoded_packets_are_exact),
            ("inner_outer_crc", both_crc_layers_pass),
            ("zero_bad_100_percent", zero_bad_restores_every_frame),
            ("fixed_seed_reproducibility", fixed_seed_is_reproducible),
            (
                "atomic_incomplete_composite",
                incomplete_composite_is_not_published,
            ),
            (
                "depth_increases_symbol_distance",
                depth_increases_symbol_distance,
            ),
            (
                "group_wait_in_publication_delay",
                group_wait_is_in_total_delay,
            ),
            (
                "final_partial_group",
                final_partial_group_is_transmitted,
            ),
        ]
    )
    results = []
    for name, test in tests:
        try:
            detail = test()
        except Exception as error:
            results.append(FunctionalTestResult(name, False, str(error)))
        else:
            results.append(FunctionalTestResult(name, True, detail))
    failed = [result for result in results if not result.passed]
    if failed:
        raise RuntimeError(
            "Lab031 functional checks failed: "
            + "; ".join(
                f"{result.name}: {result.detail}"
                for result in failed
            )
        )
    return 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} result rows, got {len(results)}"
        )
    keys = {
        (result.mode, result.mean_bad_duration_ms)
        for result in results
    }
    if len(keys) != expected:
        raise RuntimeError("Lab031 result rows are not unique")
    if any(
        not 0.0 <= result.composite_success_rate <= 1.0
        for result in results
    ):
        raise RuntimeError("composite success is outside 0...1")
    if any(
        not 0.0 <= result.fec_unrecoverable_block_rate <= 1.0
        for result in results
    ):
        raise RuntimeError("unrecoverable block rate is outside 0...1")


def save_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 _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_metric_plot(
    results: list[SimulationResult],
    field: str,
    ylabel: str,
    title: str,
    path: Path,
) -> None:
    figure, axis = plt.subplots(figsize=(10.8, 6.4))
    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 save_plots(results: list[SimulationResult]) -> None:
    _save_metric_plot(
        results,
        "composite_success_rate",
        "Доля восстановленных составных кадров",
        "Lab031. Атомарное восстановление BASE + ROI",
        COMPOSITE_SUCCESS_PLOT_PATH,
    )
    _save_metric_plot(
        results,
        "p95_no_new_image_duration_seconds",
        "P95 отсутствия нового изображения, с",
        "Lab031. Длительность удержания последнего изображения",
        NO_IMAGE_PLOT_PATH,
    )
    _save_metric_plot(
        results,
        "p95_publication_delay_seconds",
        "P95 задержки публикации, с",
        "Lab031. Задержка публикации составного кадра",
        PUBLICATION_DELAY_PLOT_PATH,
    )

    figure, axis = plt.subplots(figsize=(10.8, 6.4))
    for mode in MODES:
        if not mode.parity_count:
            continue
        rows = _rows_for_mode(results, mode.name)
        axis.plot(
            [row.mean_bad_duration_ms for row in rows],
            [row.fec_all_block_success_rate for row in rows],
            marker="o",
            linewidth=1.8,
            label=mode.label,
        )
    axis.set_xscale("log")
    axis.set_xlabel("Средняя длительность Bad, мс")
    axis.set_ylabel("Доля восстановимых FEC-блоков")
    axis.set_title("Lab031. Восстановление FEC-блоков")
    axis.grid(True, which="both", alpha=0.28)
    axis.legend(ncol=2, fontsize=8)
    figure.tight_layout()
    figure.savefig(BLOCK_RECOVERY_PLOT_PATH, dpi=160)
    plt.close(figure)

    representative = result_lookup(results, 200.0)
    depth_rows = [
        representative[f"8+2_D{depth}"]
        for depth in (1, 2, 4, 8)
    ]
    figure, success_axis = plt.subplots(figsize=(10.2, 6.2))
    delay_axis = success_axis.twinx()
    depths = [row.interleaving_depth for row in depth_rows]
    success_axis.plot(
        depths,
        [row.composite_success_rate for row in depth_rows],
        color="#1565c0",
        marker="o",
        linewidth=2.2,
        label="Составные кадры",
    )
    delay_axis.plot(
        depths,
        [row.p95_publication_delay_seconds for row in depth_rows],
        color="#c62828",
        marker="s",
        linewidth=2.0,
        label="P95 публикации",
    )
    delay_axis.plot(
        depths,
        [row.mean_group_wait_delay_seconds for row in depth_rows],
        color="#ef6c00",
        marker="^",
        linestyle="--",
        linewidth=1.8,
        label="Среднее ожидание группы",
    )
    success_axis.set_xticks(depths)
    success_axis.set_xlabel("Глубина перемежения D")
    success_axis.set_ylabel(
        "Доля восстановленных составных кадров",
        color="#1565c0",
    )
    delay_axis.set_ylabel("Задержка, с", color="#c62828")
    success_axis.set_title(
        "Lab031. Устойчивость и задержка 8+2 при Bad 200 мс"
    )
    success_axis.grid(True, alpha=0.28)
    lines = success_axis.lines + delay_axis.lines
    success_axis.legend(
        lines,
        [line.get_label() for line in lines],
        loc="best",
    )
    figure.tight_layout()
    figure.savefig(DEPTH_TRADEOFF_PLOT_PATH, dpi=160)
    plt.close(figure)

    rows = [representative[mode.name] for mode in MODES]
    positions = np.arange(len(rows))
    figure, success_axis = plt.subplots(figsize=(12.0, 6.5))
    delay_axis = success_axis.twinx()
    success_axis.bar(
        positions - 0.2,
        [row.composite_success_rate for row in rows],
        width=0.4,
        color="#2e7d32",
        label="Составные кадры",
    )
    delay_axis.bar(
        positions + 0.2,
        [row.p95_publication_delay_seconds for row in rows],
        width=0.4,
        color="#6a1b9a",
        alpha=0.72,
        label="P95 публикации",
    )
    success_axis.set_xticks(
        positions,
        [mode.label for mode in MODES],
        rotation=24,
        ha="right",
    )
    success_axis.set_ylabel("Доля восстановленных кадров")
    delay_axis.set_ylabel("P95 задержки публикации, с")
    success_axis.set_title(
        "Lab031. Итоговое сравнение режимов при Bad 200 мс"
    )
    success_axis.grid(True, axis="y", alpha=0.25)
    lines = [
        success_axis.patches[0],
        delay_axis.patches[0],
    ]
    success_axis.legend(
        lines,
        ["Составные кадры", "P95 публикации"],
        loc="upper left",
    )
    figure.tight_layout()
    figure.savefig(MODE_COMPARISON_PLOT_PATH, dpi=160)
    plt.close(figure)


def _mode_configuration_table(
    schedules: dict[str, InterleavedSchedule],
) -> list[str]:
    lines = [
        (
            "mode | k+r | D | groups | final group | source/parity "
            "packets | outer kbit/s | overhead | wait mean/max s | "
            "buffer mean/max | queue mean/max | symbol gap/span ms"
        ),
        (
            "----:|----:|--:|-------:|------------:|----------------------:"
            "|-------------:|---------:|----------------:|----------------:"
            "|---------------:|-------------------:"
        ),
    ]
    for mode in MODES:
        schedule = schedules[mode.name]
        fec = schedule.fec_schedule
        source_count = len(fec.source_packets)
        parity_count = sum(
            int(unit.unit.is_parity) for unit in fec.units
        )
        outer_rate = (
            fec.total_transmitted_bytes
            * 8.0
            / fec.source_duration_seconds
            / 1000.0
        )
        overhead = (
            (
                fec.total_transmitted_bytes - fec.total_jpeg_bytes
            )
            / fec.total_transmitted_bytes
            * 100.0
        )
        block_code = (
            "none"
            if not mode.parity_count
            else f"8+{mode.parity_count}"
        )
        lines.append(
            f"{mode.label} | {block_code} | {mode.depth} | "
            f"{len(schedule.groups)} | "
            f"{len(schedule.groups[-1].block_ids) if schedule.groups else 0} "
            f"| {source_count}/{parity_count} | {outer_rate:.3f} | "
            f"{overhead:.3f}% | "
            f"{schedule.mean_group_wait_delay_seconds:.6f}/"
            f"{schedule.max_group_wait_delay_seconds:.6f} | "
            f"{schedule.mean_buffered_blocks:.3f}/"
            f"{schedule.max_buffered_blocks} | "
            f"{fec.mean_queue_length_packets:.3f}/"
            f"{fec.max_queue_length_packets} | "
            f"{schedule.mean_intrablock_symbol_gap_seconds * 1000:.3f}/"
            f"{schedule.mean_intrablock_symbol_span_seconds * 1000:.3f}"
        )
    return lines


def _representative_table(
    results: list[SimulationResult],
) -> list[str]:
    rows = result_lookup(results, 200.0)
    lines = [
        (
            "mode | composite | no-image mean/p95/max s | publication "
            "mean/p95/max s | affected blocks | all blocks | "
            "unrecoverable | delivered kbit/s | incomplete run "
            "mean/p95/max"
        ),
        (
            "----:|----------:|------------------------:|-------------------------:"
            "|----------------:|-----------:|--------------:|----------------:"
            "|------------------------:"
        ),
    ]
    for mode in MODES:
        row = rows[mode.name]
        lines.append(
            f"{mode.label} | {row.composite_success_rate:.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_publication_delay_seconds:.6f}/"
            f"{row.p95_publication_delay_seconds:.6f}/"
            f"{row.max_publication_delay_seconds:.6f} | "
            f"{row.fec_affected_block_recovery_rate:.6f} | "
            f"{row.fec_all_block_success_rate:.6f} | "
            f"{row.fec_unrecoverable_block_rate:.6f} | "
            f"{row.effective_delivered_video_bitrate_kbps:.3f} | "
            f"{row.mean_consecutive_incomplete_frames:.3f}/"
            f"{row.p95_consecutive_incomplete_frames:.3f}/"
            f"{row.max_consecutive_incomplete_frames}"
        )
    return lines


def _outperformance_lines(
    results: list[SimulationResult],
) -> list[str]:
    lines = []
    for duration in (
        value * 1000.0 for value in MEAN_BAD_DURATIONS_SECONDS
    ):
        rows = result_lookup(results, duration)
        control = rows["8+4_D1"]
        for name in ("8+2_D2", "8+2_D4", "8+2_D8"):
            candidate = rows[name]
            if (
                candidate.composite_success_rate
                > control.composite_success_rate
            ):
                lines.append(
                    f"- Bad {duration:.0f} мс, {candidate.label}: "
                    f"composite {candidate.composite_success_rate:.6f} "
                    f"> {control.composite_success_rate:.6f}; "
                    f"P95 no-image "
                    f"{candidate.p95_no_new_image_duration_seconds:.6f} "
                    f"с против "
                    f"{control.p95_no_new_image_duration_seconds:.6f} с; "
                    f"P95 публикации "
                    f"{candidate.p95_publication_delay_seconds:.6f} "
                    f"с против "
                    f"{control.p95_publication_delay_seconds:.6f} с."
                )
    if not lines:
        lines.append(
            "- В исследованных условиях 8+2 с перемежением не превзошёл "
            "8+4, D=1 по доле составных кадров."
        )
    return lines


def write_report(
    metadata: VideoMetadata,
    composites: list[EncodedComposite],
    schedules: dict[str, InterleavedSchedule],
    results: list[SimulationResult],
    tests: list[FunctionalTestResult],
) -> None:
    lines = [
        "Lab031. Перемежение пакетов между FEC-блоками",
        "",
        "Исходные данные и неизменные слои",
        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: payload 512 байт, "
            "packet CRC32, object CRC32, атомарная публикация BASE+ROI."
        ),
        (
            "- Внешний формат и GF(256) Lab030 используются без "
            "изменений; k=8, parity r=2 или r=4."
        ),
        "",
        "Точное правило перестановки",
        (
            "- Последовательные готовые FEC-блоки делятся на группы "
            "не более D блоков."
        ),
        (
            "- Для symbol_index=0,1,2,... передаётся символ с этим "
            "индексом блока 0, затем блока 1, ... блока D-1."
        ),
        (
            "- Если блок короче, отсутствующий symbol_index пропускается. "
            "Внутри блока сохраняется порядок systematic, затем parity."
        ),
        (
            "- Перестановка не изменяет байты, block_id или symbol_index "
            "и не добавляет пакеты."
        ),
        "",
        "Формирование и буферизация групп",
        (
            "- Полная группа D>1 выпускается после готовности D-го "
            "последовательного блока; все её пакеты получают это время "
            "доступности перед FIFO-передатчиком."
        ),
        (
            "- Неполная последняя группа выпускается в момент окончания "
            "видеопотока; синтетические блоки и символы не добавляются."
        ),
        (
            "- D=1 сохраняет порядок и времена доступности Lab030 и "
            "имеет нулевую добавочную задержку ожидания группы."
        ),
        (
            "- Размер группы формируется в порядке возрастания block_id; "
            "полные группы имеют D блоков, последняя — остаток."
        ),
        (
            "- Средний буфер — среднее заполнение после каждого прихода "
            "готового блока (1..размер группы); максимум включает момент "
            "непосредственно перед выпуском группы."
        ),
        "",
        "Семь фиксированных режимов и статические метрики",
        *_mode_configuration_table(schedules),
        "",
        "Временная модель и статистика",
        (
            "- Непрерывные чередующиеся экспоненциальные Good/Bad "
            "интервалы Lab029B, средняя доля Bad около 2%."
        ),
        (
            "- Средние Bad: 10, 50, 200, 1000 мс; 200 повторов на "
            f"условие; seeds {SEED_BASE}...{SEED_BASE + 3}."
        ),
        (
            "- Пакет теряется при любом пересечении интервала его "
            "передачи с Bad; скорость FIFO 300 кбит/с."
        ),
        (
            "- Учитываются фактическая длина каждого внутреннего и "
            "внешнего пакета, ожидание группы и накопление очереди."
        ),
        (
            "- Приёмник группирует внешние символы по block_id, "
            "декодирует после любых k принятых символов и передаёт "
            "восстановленные внутренние пакеты в CompositeReassembler."
        ),
        (
            "- Соседние composite_frame_id не смешиваются; неполный "
            "BASE+ROI кадр не публикуется."
        ),
        "",
        "Результаты при средней Bad 200 мс",
        *_representative_table(results),
        "",
        "Когда 8+2 с перемежением превосходит 8+4 без него",
        (
            "- Критерий ниже: строго более высокая доля атомарно "
            "восстановленных составных кадров; задержки приведены рядом "
            "как цена перемежения."
        ),
        *_outperformance_lines(results),
        "",
        "Функциональные проверки",
        *[
            f"- {'PASS' if test.passed else 'FAIL'} "
            f"{test.name}: {test.detail}"
            for test in tests
        ],
        "",
        "Допущения модели",
        (
            "- Видеопрофиль конечный и детерминированный; JPEG "
            "кодируются в памяти один раз перед Monte Carlo."
        ),
        (
            "- Устройство знает конец потока и только тогда выпускает "
            "неполную последнюю группу D>1."
        ),
        (
            "- Время готовности FEC-блока равно максимальному времени "
            "генерации его исходных пакетов; вычислительная задержка "
            "GF(256) считается нулевой."
        ),
        (
            "- D=1 определён как точный режим Lab030; поэтому он не "
            "ждёт завершения блока перед отправкой уже готовых "
            "systematic symbols."
        ),
        (
            "- Передатчик и канал общие FIFO; параллельные радиоканалы, "
            "ARQ, повторные передачи, команды, телеметрия, модуляция и "
            "реальный SDR не моделируются."
        ),
        (
            "- Потеря является стиранием полного внешнего пакета; "
            "отдельные битовые ошибки не добавляются."
        ),
        (
            "- Publication delay считается от frame_id/3 до конца "
            "пакета, завершившего атомарную сборку; no-image включает "
            "время до следующего опубликованного кадра или конца "
            "расписания."
        ),
        (
            "- Intrablock symbol gap измеряется между временами конца "
            "последовательных symbol_index; span — от первого до "
            "последнего символа блока."
        ),
        "",
        "Артефакты",
        f"- CSV: {CSV_PATH}",
        f"- Отчёт: {REPORT_PATH}",
        f"- Доля восстановленных кадров: {COMPOSITE_SUCCESS_PLOT_PATH}",
        f"- Отсутствие нового изображения: {NO_IMAGE_PLOT_PATH}",
        f"- Задержка публикации: {PUBLICATION_DELAY_PLOT_PATH}",
        f"- Доля восстановленных блоков: {BLOCK_RECOVERY_PLOT_PATH}",
        f"- Устойчивость/задержка от D: {DEPTH_TRADEOFF_PLOT_PATH}",
        f"- Итоговое сравнение: {MODE_COMPARISON_PLOT_PATH}",
        "- JPEG, внешние пакеты и бинарные дампы на диск не сохранялись.",
        "",
    ]
    REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")


def validate_outputs() -> None:
    expected = (
        CSV_PATH,
        REPORT_PATH,
        COMPOSITE_SUCCESS_PLOT_PATH,
        NO_IMAGE_PLOT_PATH,
        PUBLICATION_DELAY_PLOT_PATH,
        BLOCK_RECOVERY_PLOT_PATH,
        DEPTH_TRADEOFF_PLOT_PATH,
        MODE_COMPARISON_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) != 28:
        raise RuntimeError(
            f"Lab031 CSV must contain 28 rows, got {len(rows)}"
        )
    if len(rows[0]) != len(CSV_FIELDS):
        raise RuntimeError("Lab031 CSV column count changed")
    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("Lab031: 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
        print(
            f"  {mode.label}: units={len(fec.units)}, "
            f"groups={len(schedule.groups)}, "
            f"wait={schedule.mean_group_wait_delay_seconds:.6f} s, "
            f"gap={schedule.mean_intrablock_symbol_gap_seconds:.6f} s, "
            f"timeline={fec.duration_seconds:.6f} s"
        )

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

    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)

    OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
    save_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}: composite="
            f"{result.composite_success_rate:.6f}, "
            f"block={result.fec_all_block_success_rate:.6f}, "
            f"delay_p95={result.p95_publication_delay_seconds:.6f} s"
        )
    print(f"CSV: {CSV_PATH}")
    print(f"Report: {REPORT_PATH}")
    print("Lab031 completed successfully.")


if __name__ == "__main__":
    main()
