"""
Lab029. Monte Carlo simulation of Lab028 video packets over impaired channels.

Real synchronous BASE and ROI JPEGs are formed with the Lab028 working
profile. The existing 32-byte packet format, packet CRC32, object CRC32, and
atomic CompositeReassembler are used unchanged. JPEGs and packets remain in
memory; only aggregate CSV, text, and PNG results are written.
"""

from __future__ import annotations

import csv
from dataclasses import asdict, dataclass
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.video_packet import (
    CompositeReassembler,
    ObjectType,
    PacketCRCError,
    VideoPacketError,
)
from tests.lab028_video_packetization import (
    EncodedComposite,
    SOURCE_VIDEO_PATH,
    VideoMetadata,
    load_video_profile,
    packets_for_composite,
)


OUTPUT_DIRECTORY = Path("data/processed/lab029")
CSV_PATH = OUTPUT_DIRECTORY / "lab029_results.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab029_report.txt"
INDEPENDENT_PLOT_PATH = (
    OUTPUT_DIRECTORY / "lab029_independent_loss_success.png"
)
BER_PLOT_PATH = OUTPUT_DIRECTORY / "lab029_ber_success.png"
BURST_PLOT_PATH = OUTPUT_DIRECTORY / "lab029_burst_loss_results.png"
PAYLOAD_COMPARISON_PLOT_PATH = (
    OUTPUT_DIRECTORY / "lab029_payload_comparison.png"
)

PAYLOAD_SIZES = (256, 512, 1024)
PACKET_LOSS_PROBABILITIES = (0.0, 0.001, 0.005, 0.01, 0.02, 0.05)
BER_VALUES = (0.0, 1e-7, 3e-7, 1e-6, 3e-6, 1e-5)
MONTE_CARLO_REPETITIONS = 200
MASTER_SEED = 29029
INDEPENDENT_SEED_BASE = MASTER_SEED
BER_SEED_BASE = MASTER_SEED + 1000
BURST_SEED_BASE = MASTER_SEED + 2000
BURST_STATIONARY_LOSS_PROBABILITY = 0.02

BURST_DISTRIBUTION_LABELS = (
    "1",
    "2",
    "3-4",
    "5-8",
    "9-16",
    "17-32",
    "33+",
)

CSV_FIELDS = [
    "model",
    "parameter_name",
    "parameter_value",
    "scenario",
    "payload_size",
    "monte_carlo_repetitions",
    "seed",
    "good_to_bad_probability",
    "bad_to_good_probability",
    "expected_loss_probability",
    "expected_mean_loss_burst",
    "observed_loss_probability",
    "transmitted_packets",
    "received_packets",
    "lost_packets",
    "crc_rejected_packets",
    "packet_delivery_rate",
    "base_objects_completed",
    "roi_objects_completed",
    "atomic_composite_frames_completed",
    "base_only_frames",
    "roi_only_frames",
    "incomplete_frames",
    "composite_success_rate",
    "effective_delivered_jpeg_bitrate_kbps",
    "mean_lost_fragments_per_frame",
    "p95_lost_fragments_per_frame",
    "mean_loss_burst_length",
    "max_loss_burst_length",
    "loss_burst_distribution",
]


@dataclass(frozen=True)
class BurstScenario:
    name: str
    description: str
    expected_mean_burst: float
    good_to_bad_probability: float
    bad_to_good_probability: float


@dataclass(frozen=True)
class PreparedFrame:
    composite_frame_id: int
    base_jpeg_size: int
    roi_jpeg_size: int
    packets: tuple[bytes, ...]


@dataclass(frozen=True)
class PreparedPacket:
    composite_frame_id: int
    wire_packet: bytes


@dataclass(frozen=True)
class PreparedProfile:
    payload_size: int
    frames: tuple[PreparedFrame, ...]
    packets: tuple[PreparedPacket, ...]


@dataclass(frozen=True)
class SimulationResult:
    model: str
    parameter_name: str
    parameter_value: float
    scenario: str
    payload_size: int
    monte_carlo_repetitions: int
    seed: int
    good_to_bad_probability: float
    bad_to_good_probability: float
    expected_loss_probability: float
    expected_mean_loss_burst: float
    observed_loss_probability: float
    transmitted_packets: int
    received_packets: int
    lost_packets: int
    crc_rejected_packets: int
    packet_delivery_rate: float
    base_objects_completed: int
    roi_objects_completed: int
    atomic_composite_frames_completed: int
    base_only_frames: int
    roi_only_frames: int
    incomplete_frames: int
    composite_success_rate: float
    effective_delivered_jpeg_bitrate_kbps: float
    mean_lost_fragments_per_frame: float
    p95_lost_fragments_per_frame: float
    mean_loss_burst_length: float
    max_loss_burst_length: int
    loss_burst_distribution: str


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


def build_burst_scenarios() -> tuple[BurstScenario, ...]:
    """
    Build three loss-only Gilbert-Elliott scenarios.

    Good packets are delivered and Bad packets are lost. All scenarios have
    the same stationary Bad probability of 2%, while mean Bad-state duration
    changes from 2 to 20 packets.
    """

    scenarios = []
    definitions = (
        ("short", "короткие серии потерь", 2.0),
        ("medium", "средние серии потерь", 5.0),
        ("long", "длинные серии потерь", 20.0),
    )
    for name, description, mean_burst in definitions:
        bad_to_good = 1.0 / mean_burst
        good_to_bad = (
            BURST_STATIONARY_LOSS_PROBABILITY
            * bad_to_good
            / (1.0 - BURST_STATIONARY_LOSS_PROBABILITY)
        )
        scenarios.append(
            BurstScenario(
                name=name,
                description=description,
                expected_mean_burst=mean_burst,
                good_to_bad_probability=good_to_bad,
                bad_to_good_probability=bad_to_good,
            )
        )
    return tuple(scenarios)


BURST_SCENARIOS = build_burst_scenarios()


def prepare_profiles(
    composites: list[EncodedComposite],
) -> dict[int, PreparedProfile]:
    """Packetize every real JPEG once for each investigated payload size."""

    profiles = {}
    for payload_size in PAYLOAD_SIZES:
        frames = []
        flattened_packets = []
        for composite in composites:
            base_packets, roi_packets = packets_for_composite(
                composite, payload_size
            )
            packets = tuple(base_packets + roi_packets)
            frame = PreparedFrame(
                composite_frame_id=composite.composite_frame_id,
                base_jpeg_size=len(composite.base_jpeg),
                roi_jpeg_size=len(composite.roi_jpeg),
                packets=packets,
            )
            frames.append(frame)
            flattened_packets.extend(
                PreparedPacket(
                    composite_frame_id=composite.composite_frame_id,
                    wire_packet=packet,
                )
                for packet in packets
            )
        profiles[payload_size] = PreparedProfile(
            payload_size=payload_size,
            frames=tuple(frames),
            packets=tuple(flattened_packets),
        )
    return profiles


def burst_distribution_bin(length: int) -> str:
    if length == 1:
        return "1"
    if length == 2:
        return "2"
    if length <= 4:
        return "3-4"
    if length <= 8:
        return "5-8"
    if length <= 16:
        return "9-16"
    if length <= 32:
        return "17-32"
    return "33+"


def format_burst_distribution(lengths: list[int]) -> str:
    counts = {label: 0 for label in BURST_DISTRIBUTION_LABELS}
    for length in lengths:
        counts[burst_distribution_bin(length)] += 1
    return ";".join(
        f"{label}:{counts[label]}"
        for label in BURST_DISTRIBUTION_LABELS
    )


def corrupt_packet_bits(
    wire_packet: bytes,
    error_count: int,
    rng: np.random.Generator,
) -> bytes:
    """Flip independently selected bit positions in a packet copy."""

    if error_count <= 0:
        return wire_packet
    bit_count = len(wire_packet) * 8
    error_count = min(error_count, bit_count)
    positions = rng.choice(
        bit_count,
        size=error_count,
        replace=False,
    )
    corrupted = bytearray(wire_packet)
    for position in np.atleast_1d(positions):
        bit_position = int(position)
        byte_index, bit_index = divmod(bit_position, 8)
        corrupted[byte_index] ^= 1 << bit_index
    return bytes(corrupted)


def independent_loss_flags(
    packet_count: int,
    loss_probability: float,
    rng: np.random.Generator,
) -> np.ndarray:
    return rng.random(packet_count) < loss_probability


def ber_error_counts(
    profile: PreparedProfile,
    ber: float,
    rng: np.random.Generator,
) -> np.ndarray:
    bit_lengths = np.fromiter(
        (
            len(packet.wire_packet) * 8
            for packet in profile.packets
        ),
        dtype=np.int64,
        count=len(profile.packets),
    )
    return rng.binomial(bit_lengths, ber)


def burst_loss_flags(
    packet_count: int,
    scenario: BurstScenario,
    rng: np.random.Generator,
) -> np.ndarray:
    """Generate one stationary two-state Good/Bad packet sequence."""

    flags = np.zeros(packet_count, dtype=np.bool_)
    bad_state = (
        rng.random() < BURST_STATIONARY_LOSS_PROBABILITY
    )
    for index in range(packet_count):
        flags[index] = bad_state
        transition_sample = rng.random()
        if bad_state:
            if transition_sample < scenario.bad_to_good_probability:
                bad_state = False
        elif transition_sample < scenario.good_to_bad_probability:
            bad_state = True
    return flags


def simulate_condition(
    profile: PreparedProfile,
    metadata: VideoMetadata,
    model: str,
    parameter_name: str,
    parameter_value: float,
    seed: int,
    repetitions: int,
    scenario: BurstScenario | None = None,
) -> SimulationResult:
    """Simulate one channel condition and aggregate all requested metrics."""

    if repetitions <= 0:
        raise ValueError("repetitions must be positive")
    if model == "burst" and scenario is None:
        raise ValueError("burst model requires a scenario")
    if model not in {"independent_loss", "ber", "burst"}:
        raise ValueError(f"unsupported model: {model}")

    rng = np.random.default_rng(seed)
    transmitted_packets = 0
    received_packets = 0
    lost_packets = 0
    crc_rejected_packets = 0
    base_objects_completed = 0
    roi_objects_completed = 0
    atomic_composite_frames_completed = 0
    base_only_frames = 0
    roi_only_frames = 0
    incomplete_frames = 0
    delivered_atomic_jpeg_bytes = 0
    lost_fragments_per_frame: list[int] = []
    loss_burst_lengths: list[int] = []

    packet_count = len(profile.packets)
    frame_count = len(profile.frames)
    jpeg_sizes_by_id = {
        frame.composite_frame_id: (
            frame.base_jpeg_size + frame.roi_jpeg_size
        )
        for frame in profile.frames
    }

    for _ in range(repetitions):
        receiver = CompositeReassembler()
        completed_frame_ids: set[int] = set()
        unavailable_by_frame = [0] * frame_count
        current_loss_burst = 0

        if model == "independent_loss":
            loss_flags = independent_loss_flags(
                packet_count, parameter_value, rng
            )
            error_counts = None
        elif model == "ber":
            loss_flags = np.zeros(packet_count, dtype=np.bool_)
            error_counts = ber_error_counts(profile, parameter_value, rng)
        else:
            assert scenario is not None
            loss_flags = burst_loss_flags(packet_count, scenario, rng)
            error_counts = None

        for packet_index, prepared_packet in enumerate(profile.packets):
            transmitted_packets += 1
            frame_id = prepared_packet.composite_frame_id

            if bool(loss_flags[packet_index]):
                lost_packets += 1
                unavailable_by_frame[frame_id] += 1
                current_loss_burst += 1
                continue

            received_packets += 1
            wire_packet = prepared_packet.wire_packet
            if error_counts is not None:
                error_count = int(error_counts[packet_index])
                if error_count > 0:
                    wire_packet = corrupt_packet_bits(
                        wire_packet, error_count, rng
                    )

            try:
                completed = receiver.ingest(wire_packet)
            except VideoPacketError:
                crc_rejected_packets += 1
                unavailable_by_frame[frame_id] += 1
                current_loss_burst += 1
                continue

            if current_loss_burst:
                loss_burst_lengths.append(current_loss_burst)
                current_loss_burst = 0

            if completed is not None:
                completed_frame_ids.add(completed.composite_frame_id)
                atomic_composite_frames_completed += 1
                delivered_atomic_jpeg_bytes += (
                    len(completed.base_jpeg) + len(completed.roi_jpeg)
                )

        if current_loss_burst:
            loss_burst_lengths.append(current_loss_burst)

        for frame in profile.frames:
            frame_id = frame.composite_frame_id
            atomic_complete = frame_id in completed_frame_ids
            base_complete = (
                atomic_complete
                or receiver.object_is_complete(frame_id, ObjectType.BASE)
            )
            roi_complete = (
                atomic_complete
                or receiver.object_is_complete(frame_id, ObjectType.ROI)
            )
            base_objects_completed += int(base_complete)
            roi_objects_completed += int(roi_complete)
            base_only_frames += int(base_complete and not roi_complete)
            roi_only_frames += int(roi_complete and not base_complete)
            incomplete_frames += int(not atomic_complete)

        lost_fragments_per_frame.extend(unavailable_by_frame)

    expected_frames = frame_count * repetitions
    if (
        atomic_composite_frames_completed
        + incomplete_frames
        != expected_frames
    ):
        raise RuntimeError("atomic and incomplete frame counts disagree")
    if base_objects_completed != (
        atomic_composite_frames_completed + base_only_frames
    ):
        raise RuntimeError("BASE completion accounting disagrees")
    if roi_objects_completed != (
        atomic_composite_frames_completed + roi_only_frames
    ):
        raise RuntimeError("ROI completion accounting disagrees")

    unavailable_packets = lost_packets + crc_rejected_packets
    accepted_packets = received_packets - crc_rejected_packets
    observed_loss_probability = (
        unavailable_packets / transmitted_packets
    )
    if model == "burst":
        assert scenario is not None
        scenario_name = scenario.name
        good_to_bad = scenario.good_to_bad_probability
        bad_to_good = scenario.bad_to_good_probability
        expected_loss = BURST_STATIONARY_LOSS_PROBABILITY
        expected_mean_burst = scenario.expected_mean_burst
    elif model == "independent_loss":
        scenario_name = ""
        good_to_bad = 0.0
        bad_to_good = 0.0
        expected_loss = parameter_value
        expected_mean_burst = (
            0.0 if parameter_value == 0.0
            else 1.0 / (1.0 - parameter_value)
        )
    else:
        scenario_name = ""
        good_to_bad = 0.0
        bad_to_good = 0.0
        expected_loss = 0.0
        expected_mean_burst = 0.0

    total_simulated_duration = (
        metadata.duration_seconds * repetitions
    )
    return SimulationResult(
        model=model,
        parameter_name=parameter_name,
        parameter_value=parameter_value,
        scenario=scenario_name,
        payload_size=profile.payload_size,
        monte_carlo_repetitions=repetitions,
        seed=seed,
        good_to_bad_probability=good_to_bad,
        bad_to_good_probability=bad_to_good,
        expected_loss_probability=expected_loss,
        expected_mean_loss_burst=expected_mean_burst,
        observed_loss_probability=observed_loss_probability,
        transmitted_packets=transmitted_packets,
        received_packets=received_packets,
        lost_packets=lost_packets,
        crc_rejected_packets=crc_rejected_packets,
        packet_delivery_rate=accepted_packets / transmitted_packets,
        base_objects_completed=base_objects_completed,
        roi_objects_completed=roi_objects_completed,
        atomic_composite_frames_completed=(
            atomic_composite_frames_completed
        ),
        base_only_frames=base_only_frames,
        roi_only_frames=roi_only_frames,
        incomplete_frames=incomplete_frames,
        composite_success_rate=(
            atomic_composite_frames_completed / expected_frames
        ),
        effective_delivered_jpeg_bitrate_kbps=(
            delivered_atomic_jpeg_bytes
            * 8.0
            / total_simulated_duration
            / 1000.0
        ),
        mean_lost_fragments_per_frame=float(
            np.mean(lost_fragments_per_frame)
        ),
        p95_lost_fragments_per_frame=float(
            np.percentile(lost_fragments_per_frame, 95)
        ),
        mean_loss_burst_length=(
            float(np.mean(loss_burst_lengths))
            if loss_burst_lengths
            else 0.0
        ),
        max_loss_burst_length=(
            max(loss_burst_lengths) if loss_burst_lengths else 0
        ),
        loss_burst_distribution=format_burst_distribution(
            loss_burst_lengths
        ),
    )


def run_monte_carlo(
    profiles: dict[int, PreparedProfile],
    metadata: VideoMetadata,
) -> list[SimulationResult]:
    """Run all 45 channel/payload combinations."""

    results = []
    for parameter_index, loss_probability in enumerate(
        PACKET_LOSS_PROBABILITIES
    ):
        seed = INDEPENDENT_SEED_BASE + parameter_index
        for payload_size in PAYLOAD_SIZES:
            results.append(
                simulate_condition(
                    profiles[payload_size],
                    metadata,
                    model="independent_loss",
                    parameter_name="packet_loss_probability",
                    parameter_value=loss_probability,
                    seed=seed,
                    repetitions=MONTE_CARLO_REPETITIONS,
                )
            )

    for parameter_index, ber in enumerate(BER_VALUES):
        seed = BER_SEED_BASE + parameter_index
        for payload_size in PAYLOAD_SIZES:
            results.append(
                simulate_condition(
                    profiles[payload_size],
                    metadata,
                    model="ber",
                    parameter_name="bit_error_rate",
                    parameter_value=ber,
                    seed=seed,
                    repetitions=MONTE_CARLO_REPETITIONS,
                )
            )

    for scenario_index, scenario in enumerate(BURST_SCENARIOS):
        seed = BURST_SEED_BASE + scenario_index
        for payload_size in PAYLOAD_SIZES:
            results.append(
                simulate_condition(
                    profiles[payload_size],
                    metadata,
                    model="burst",
                    parameter_name="gilbert_elliott_scenario",
                    parameter_value=float(scenario_index),
                    seed=seed,
                    repetitions=MONTE_CARLO_REPETITIONS,
                    scenario=scenario,
                )
            )
    return results


def run_functional_tests(
    composites: list[EncodedComposite],
    profiles: dict[int, PreparedProfile],
    metadata: VideoMetadata,
    results: list[SimulationResult],
) -> list[FunctionalTestResult]:
    """Verify zero impairment, CRC, isolation, atomicity, and repeatability."""

    tests: list[tuple[str, Callable[[], str]]] = []

    def zero_impairment_is_perfect() -> str:
        zero_rows = [
            result
            for result in results
            if (
                result.model in {"independent_loss", "ber"}
                and result.parameter_value == 0.0
            )
        ]
        if len(zero_rows) != len(PAYLOAD_SIZES) * 2:
            raise AssertionError("zero-impairment rows are missing")
        for row in zero_rows:
            if (
                row.packet_delivery_rate != 1.0
                or row.composite_success_rate != 1.0
                or row.incomplete_frames != 0
            ):
                raise AssertionError(
                    f"zero impairment failed for {row.model}/"
                    f"{row.payload_size}"
                )
        return "all payloads delivered 100% packets and composite frames"

    def crc_rejects_corruption() -> str:
        packet = profiles[512].packets[0].wire_packet
        corrupted = bytearray(packet)
        corrupted[-1] ^= 0x01
        try:
            CompositeReassembler().ingest(bytes(corrupted))
        except PacketCRCError:
            return "payload bit flip was rejected by packet CRC32"
        raise AssertionError("corrupted packet was not rejected by CRC")

    def adjacent_frames_do_not_mix() -> str:
        first_profile_frames = profiles[512].frames[:2]
        interleaved = []
        first_packets = first_profile_frames[0].packets
        second_packets = first_profile_frames[1].packets
        for index in range(max(len(first_packets), len(second_packets))):
            if index < len(second_packets):
                interleaved.append(second_packets[index])
            if index < len(first_packets):
                interleaved.append(first_packets[index])
        receiver = CompositeReassembler()
        completed = {}
        for packet in interleaved:
            frame = receiver.ingest(packet)
            if frame is not None:
                completed[frame.composite_frame_id] = frame
        expected = {composites[0].composite_frame_id, composites[1].composite_frame_id}
        if set(completed) != expected:
            raise AssertionError("adjacent frame IDs were mixed or lost")
        for composite in composites[:2]:
            frame = completed[composite.composite_frame_id]
            if (
                frame.base_jpeg != composite.base_jpeg
                or frame.roi_jpeg != composite.roi_jpeg
            ):
                raise AssertionError("neighboring JPEG objects were mixed")
        return "two interleaved neighboring frames remained independent"

    def incomplete_frame_is_not_published() -> str:
        frame = profiles[512].frames[0]
        receiver = CompositeReassembler()
        completed_count = 0
        for packet in frame.packets[:-1]:
            if receiver.ingest(packet) is not None:
                completed_count += 1
        if completed_count != 0:
            raise AssertionError("incomplete composite frame was published")
        if not receiver.object_is_complete(
            frame.composite_frame_id, ObjectType.BASE
        ):
            raise AssertionError("complete BASE object was not retained")
        return "missing ROI fragment prevented atomic publication"

    def identical_seed_is_reproducible() -> str:
        short_profile = PreparedProfile(
            payload_size=512,
            frames=profiles[512].frames[:2],
            packets=tuple(
                packet
                for packet in profiles[512].packets
                if packet.composite_frame_id < 2
            ),
        )
        first = simulate_condition(
            short_profile,
            metadata,
            model="independent_loss",
            parameter_name="packet_loss_probability",
            parameter_value=0.02,
            seed=MASTER_SEED + 9999,
            repetitions=5,
        )
        second = simulate_condition(
            short_profile,
            metadata,
            model="independent_loss",
            parameter_name="packet_loss_probability",
            parameter_value=0.02,
            seed=MASTER_SEED + 9999,
            repetitions=5,
        )
        if first != second:
            raise AssertionError("same seed produced different aggregates")
        return "identical seed produced byte-for-byte equal result fields"

    tests.extend(
        [
            ("zero_impairment_100_percent", zero_impairment_is_perfect),
            ("packet_crc_rejects_bit_error", crc_rejects_corruption),
            ("adjacent_frame_isolation", adjacent_frames_do_not_mix),
            ("atomic_incomplete_frame", incomplete_frame_is_not_published),
            ("fixed_seed_reproducibility", identical_seed_is_reproducible),
        ]
    )

    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:
        details = "; ".join(
            f"{result.name}: {result.detail}" for result in failed
        )
        raise RuntimeError(f"Lab029 functional checks failed: {details}")
    return test_results


def result_lookup(
    results: list[SimulationResult],
    model: str,
    parameter_value: float | None = None,
    scenario: str | None = None,
) -> dict[int, SimulationResult]:
    matches = [
        result
        for result in results
        if (
            result.model == model
            and (
                parameter_value is None
                or result.parameter_value == parameter_value
            )
            and (scenario is None or result.scenario == scenario)
        )
    ]
    return {result.payload_size: result for result in matches}


def save_csv(results: list[SimulationResult]) -> None:
    OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
    with CSV_PATH.open("w", encoding="utf-8", newline="") as csv_file:
        writer = csv.DictWriter(csv_file, fieldnames=CSV_FIELDS)
        writer.writeheader()
        for result in results:
            raw_row = asdict(result)
            row = {}
            for field_name in CSV_FIELDS:
                value = raw_row[field_name]
                row[field_name] = (
                    f"{value:.12g}"
                    if isinstance(value, float)
                    else value
                )
            writer.writerow(row)


def save_independent_plot(results: list[SimulationResult]) -> None:
    figure, axis = plt.subplots(figsize=(9, 5.5))
    loss_percentages = [
        probability * 100.0
        for probability in PACKET_LOSS_PROBABILITIES
    ]
    for payload_size in PAYLOAD_SIZES:
        success = [
            result_lookup(
                results, "independent_loss", probability
            )[payload_size].composite_success_rate
            * 100.0
            for probability in PACKET_LOSS_PROBABILITIES
        ]
        axis.plot(
            loss_percentages,
            success,
            marker="o",
            linewidth=2,
            label=f"payload {payload_size} B",
        )
    axis.set_xlabel("Independent packet loss probability, %")
    axis.set_ylabel("Atomic composite success rate, %")
    axis.set_title("Lab029 independent packet loss")
    axis.grid(True, alpha=0.3)
    axis.legend()
    figure.tight_layout()
    figure.savefig(INDEPENDENT_PLOT_PATH, dpi=160)
    plt.close(figure)


def save_ber_plot(results: list[SimulationResult]) -> None:
    figure, axis = plt.subplots(figsize=(9, 5.5))
    x_values = [1e-8 if ber == 0.0 else ber for ber in BER_VALUES]
    for payload_size in PAYLOAD_SIZES:
        success = [
            result_lookup(results, "ber", ber)[
                payload_size
            ].composite_success_rate
            * 100.0
            for ber in BER_VALUES
        ]
        axis.plot(
            x_values,
            success,
            marker="o",
            linewidth=2,
            label=f"payload {payload_size} B",
        )
    axis.set_xscale("log")
    axis.set_xticks(x_values)
    axis.set_xticklabels(
        ["0", "1e-7", "3e-7", "1e-6", "3e-6", "1e-5"]
    )
    axis.set_xlabel("Bit error rate (zero shown at 1e-8 position)")
    axis.set_ylabel("Atomic composite success rate, %")
    axis.set_title("Lab029 independent bit errors")
    axis.grid(True, which="both", alpha=0.3)
    axis.legend()
    figure.tight_layout()
    figure.savefig(BER_PLOT_PATH, dpi=160)
    plt.close(figure)


def save_burst_plot(results: list[SimulationResult]) -> None:
    figure, axis = plt.subplots(figsize=(9, 5.5))
    x_positions = np.arange(len(BURST_SCENARIOS))
    for payload_size in PAYLOAD_SIZES:
        success = [
            result_lookup(
                results, "burst", scenario=scenario.name
            )[payload_size].composite_success_rate
            * 100.0
            for scenario in BURST_SCENARIOS
        ]
        axis.plot(
            x_positions,
            success,
            marker="o",
            linewidth=2,
            label=f"payload {payload_size} B",
        )
    axis.set_xticks(x_positions)
    axis.set_xticklabels(
        [
            f"{scenario.name}\nmean {scenario.expected_mean_burst:g}"
            for scenario in BURST_SCENARIOS
        ]
    )
    axis.set_xlabel("Gilbert-Elliott loss scenario")
    axis.set_ylabel("Atomic composite success rate, %")
    axis.set_title(
        "Lab029 burst loss at 2% stationary packet loss"
    )
    axis.grid(True, alpha=0.3)
    axis.legend()
    figure.tight_layout()
    figure.savefig(BURST_PLOT_PATH, dpi=160)
    plt.close(figure)


def save_payload_comparison_plot(
    results: list[SimulationResult],
) -> None:
    conditions = [
        (
            "Loss 1%",
            result_lookup(results, "independent_loss", 0.01),
        ),
        ("BER 1e-6", result_lookup(results, "ber", 1e-6)),
        (
            "Burst medium",
            result_lookup(results, "burst", scenario="medium"),
        ),
    ]
    x_positions = np.arange(len(conditions))
    bar_width = 0.24
    figure, axis = plt.subplots(figsize=(9, 5.5))
    for payload_index, payload_size in enumerate(PAYLOAD_SIZES):
        values = [
            condition_results[payload_size].composite_success_rate
            * 100.0
            for _, condition_results in conditions
        ]
        axis.bar(
            x_positions
            + (payload_index - 1) * bar_width,
            values,
            width=bar_width,
            label=f"payload {payload_size} B",
        )
    axis.set_xticks(x_positions)
    axis.set_xticklabels([name for name, _ in conditions])
    axis.set_ylabel("Atomic composite success rate, %")
    axis.set_title("Lab029 payload comparison")
    axis.grid(True, axis="y", alpha=0.3)
    axis.legend()
    figure.tight_layout()
    figure.savefig(PAYLOAD_COMPARISON_PLOT_PATH, dpi=160)
    plt.close(figure)


def save_plots(results: list[SimulationResult]) -> None:
    save_independent_plot(results)
    save_ber_plot(results)
    save_burst_plot(results)
    save_payload_comparison_plot(results)


def success_matrix_lines(
    results: list[SimulationResult],
    model: str,
    parameters: tuple[float, ...],
    parameter_formatter: Callable[[float], str],
) -> list[str]:
    lines = [
        "parameter | payload 256 | payload 512 | payload 1024",
        "---------:|------------:|------------:|-------------:",
    ]
    for parameter in parameters:
        by_payload = result_lookup(results, model, parameter)
        lines.append(
            f"{parameter_formatter(parameter)} | "
            f"{by_payload[256].composite_success_rate * 100.0:.3f}% | "
            f"{by_payload[512].composite_success_rate * 100.0:.3f}% | "
            f"{by_payload[1024].composite_success_rate * 100.0:.3f}%"
        )
    return lines


def burst_table_lines(
    results: list[SimulationResult],
) -> list[str]:
    lines = [
        (
            "scenario | payload | expected loss | observed loss | "
            "mean/max burst | composite success | burst distribution"
        ),
        (
            "--------:|--------:|--------------:|--------------:|"
            "---------------:|------------------:|:------------------"
        ),
    ]
    for scenario in BURST_SCENARIOS:
        by_payload = result_lookup(
            results, "burst", scenario=scenario.name
        )
        for payload_size in PAYLOAD_SIZES:
            result = by_payload[payload_size]
            lines.append(
                f"{scenario.name} | {payload_size} | "
                f"{result.expected_loss_probability * 100.0:.3f}% | "
                f"{result.observed_loss_probability * 100.0:.3f}% | "
                f"{result.mean_loss_burst_length:.3f}/"
                f"{result.max_loss_burst_length} | "
                f"{result.composite_success_rate * 100.0:.3f}% | "
                f"{result.loss_burst_distribution}"
            )
    return lines


def comparison_table_lines(
    results: list[SimulationResult],
) -> list[str]:
    loss = result_lookup(results, "independent_loss", 0.01)
    ber = result_lookup(results, "ber", 1e-6)
    burst = result_lookup(results, "burst", scenario="medium")
    lines = [
        (
            "payload | loss 1% success | BER 1e-6 success | "
            "medium burst success | Lab028 wire kbit/s"
        ),
        (
            "-------:|----------------:|-----------------:|"
            "---------------------:|--------------------:"
        ),
    ]
    lab028_wire_bitrate = {
        256: 178.233323,
        512: 168.396019,
        1024: 163.662279,
    }
    for payload_size in PAYLOAD_SIZES:
        lines.append(
            f"{payload_size} | "
            f"{loss[payload_size].composite_success_rate * 100.0:.3f}% | "
            f"{ber[payload_size].composite_success_rate * 100.0:.3f}% | "
            f"{burst[payload_size].composite_success_rate * 100.0:.3f}% | "
            f"{lab028_wire_bitrate[payload_size]:.3f}"
        )
    return lines


def write_report(
    metadata: VideoMetadata,
    composites: list[EncodedComposite],
    profiles: dict[int, PreparedProfile],
    results: list[SimulationResult],
    functional_tests: list[FunctionalTestResult],
) -> None:
    lines = [
        "Lab029. Прохождение видеопакетов через канал с потерями и ошибками",
        "",
        "Исходные данные и неизменный транспорт Lab028",
        f"- Видео: {SOURCE_VIDEO_PATH}",
        (
            f"- Источник: {metadata.width}x{metadata.height}, "
            f"{metadata.fps:.6f} fps, {metadata.frame_count} кадров, "
            f"{metadata.duration_seconds:.6f} с."
        ),
        (
            f"- Реальных синхронных JPEG-пар: {len(composites)}; "
            "BASE 240x135 grayscale Q23, ROI 320x180 grayscale Q33, "
            "3 composite fps."
        ),
        (
            "- Использован protocol/video_packet.py без изменения: "
            "32-byte header, packet CRC32, object CRC32, отдельные BASE/ROI "
            "и атомарная выдача CompositeReassembler."
        ),
        (
            "- Payload: "
            + ", ".join(str(value) for value in PAYLOAD_SIZES)
            + " байт."
        ),
        (
            "- Пакетов в одном проходе: "
            + ", ".join(
                f"{payload} B={len(profiles[payload].packets)}"
                for payload in PAYLOAD_SIZES
            )
            + "."
        ),
        "",
        "Методика Monte Carlo",
        f"- Повторов на каждую строку: {MONTE_CARLO_REPETITIONS}.",
        (
            f"- Master seed: {MASTER_SEED}; independent seeds "
            f"{INDEPENDENT_SEED_BASE}...{INDEPENDENT_SEED_BASE + 5}; "
            f"BER seeds {BER_SEED_BASE}...{BER_SEED_BASE + 5}; "
            f"burst seeds {BURST_SEED_BASE}...{BURST_SEED_BASE + 2}."
        ),
        (
            "- Для одинакового параметра один seed повторно используется "
            "для payload 256/512/1024, чтобы сравнение было коррелированным "
            "и воспроизводимым."
        ),
        (
            "- packet_delivery_rate = (received - CRC rejected) / "
            "transmitted."
        ),
        (
            "- effective delivered JPEG bitrate учитывает BASE+ROI bytes "
            "только атомарно выданных составных кадров и полное "
            "симулированное время."
        ),
        (
            "- Потерянный фрагмент — физически потерянный пакет или пакет, "
            "отклонённый проверкой транспорта."
        ),
        (
            "- FEC, ARQ, повторные передачи и интерливинг не применяются."
        ),
        "",
        "Модели канала",
        (
            "- Independent loss: каждый пакет теряется независимо с "
            "заданной вероятностью."
        ),
        (
            "- BER: число независимых битовых ошибок выбирается binomial "
            "по полной длине пакета в битах, включая 32-byte header; "
            "выбранные биты физически инвертируются перед reassembler."
        ),
        (
            "- Gilbert-Elliott: Good доставляет все пакеты, Bad теряет все. "
            "Начальное состояние выбирается из стационарного распределения."
        ),
        (
            "- В Lab029 длительность состояния Bad и серии потерь задаётся "
            "количеством пакетов, а не физическим временем."
        ),
        (
            "- Серия из одинакового количества пакетов имеет разную "
            "физическую длительность для payload 256, 512 и 1024 байта, "
            "поскольку различается время передачи пакета."
        ),
        (
            "- Доля успешно восстановленных кадров не показывает "
            "длительность непрерывного отсутствия нового изображения."
        ),
        (
            "- Поэтому burst-loss результаты Lab029 не являются "
            "окончательным сравнением размеров payload; для временного "
            "сравнения предназначена Lab029B."
        ),
    ]
    for scenario in BURST_SCENARIOS:
        lines.append(
            f"  - {scenario.name}: {scenario.description}; "
            f"P(G->B)={scenario.good_to_bad_probability:.9f}, "
            f"P(B->G)={scenario.bad_to_good_probability:.9f}, "
            f"stationary loss={BURST_STATIONARY_LOSS_PROBABILITY:.3%}, "
            f"expected mean burst={scenario.expected_mean_burst:.1f}."
        )

    lines.extend(["", "Функциональные проверки"])
    lines.extend(
        f"- {'PASS' if test.passed else 'FAIL'} {test.name}: {test.detail}"
        for test in functional_tests
    )
    lines.extend(
        [
            "",
            "1. Independent packet loss — atomic composite success",
            *success_matrix_lines(
                results,
                "independent_loss",
                PACKET_LOSS_PROBABILITIES,
                lambda value: f"{value * 100.0:.1f}%",
            ),
            "",
            "2. BER — atomic composite success",
            *success_matrix_lines(
                results,
                "ber",
                BER_VALUES,
                lambda value: "0" if value == 0.0 else f"{value:.0e}",
            ),
            "",
            "3. Burst-loss",
            *burst_table_lines(results),
            "",
            "4. Сравнение payload 256/512/1024",
            *comparison_table_lines(results),
            "",
            "Интерпретация",
            (
                "- При фиксированной вероятности потери пакета крупный "
                "payload уменьшает число обязательных фрагментов. Поскольку "
                "потеря любого фрагмента делает объект неполным, меньшее "
                "число пакетов обычно повышает шанс полного кадра."
            ),
            (
                "- При фиксированном BER длинный отдельный пакет чаще "
                "повреждается: P(error)=1-(1-BER)^(packet_bits). Одновременно "
                "крупный payload уменьшает число 32-byte headers и общую "
                "длину wire-потока, поэтому итоговый composite success "
                "определяется обоими эффектами."
            ),
            (
                "- Атомарная сборка требует одновременной готовности BASE и "
                "ROI. Даже полностью собранный BASE не даёт полезного "
                "составного кадра без ROI, поэтому composite success ниже "
                "успеха каждого объекта отдельно."
            ),
            (
                "- Burst-loss особенно опасен без интерливинга: соседние "
                "фрагменты и иногда соседние кадры теряются одной серией, "
                "а CRC только обнаруживает ошибку и не восстанавливает "
                "данные."
            ),
            (
                "- Payload 256 B даёт наименьшую длину отдельного пакета и "
                "наименьшую вероятность BER-повреждения одного пакета, но "
                "требует больше всего фрагментов и headers."
            ),
            (
                "- Payload 512 B остаётся умеренным рабочим кандидатом между "
                "packet count и длиной отдельного пакета."
            ),
            (
                "- Payload 1024 B даёт минимум пакетов и header overhead в "
                "этих моделях, но каждый пакет длиннее и при его потере "
                "пропадает более крупный фрагмент JPEG."
            ),
            (
                "- Окончательный packet payload автоматически не выбирается; "
                "для решения нужны Lab029 и последующие данные реального "
                "радиоканала, FEC/ARQ и задержек."
            ),
            "",
            "Артефакты",
            f"- CSV: {CSV_PATH}",
            f"- Independent loss plot: {INDEPENDENT_PLOT_PATH}",
            f"- BER plot: {BER_PLOT_PATH}",
            f"- Burst plot: {BURST_PLOT_PATH}",
            f"- Payload comparison: {PAYLOAD_COMPARISON_PLOT_PATH}",
            (
                "- JPEG, пакеты и бинарные дампы на диск не сохранялись."
            ),
            "",
        ]
    )
    REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")


def validate_results(results: list[SimulationResult]) -> None:
    if len(results) != 45:
        raise RuntimeError(f"expected 45 result rows, got {len(results)}")
    keys = {
        (
            result.model,
            result.parameter_value,
            result.scenario,
            result.payload_size,
        )
        for result in results
    }
    if len(keys) != len(results):
        raise RuntimeError("result rows are not unique")
    for result in results:
        if not 0.0 <= result.packet_delivery_rate <= 1.0:
            raise RuntimeError("packet delivery rate is outside 0...1")
        if not 0.0 <= result.composite_success_rate <= 1.0:
            raise RuntimeError("composite success rate is outside 0...1")
        if (
            result.received_packets + result.lost_packets
            != result.transmitted_packets
        ):
            raise RuntimeError("packet receive/loss accounting disagrees")


def validate_output_files() -> None:
    for path in (
        CSV_PATH,
        REPORT_PATH,
        INDEPENDENT_PLOT_PATH,
        BER_PLOT_PATH,
        BURST_PLOT_PATH,
        PAYLOAD_COMPARISON_PLOT_PATH,
    ):
        if not path.exists() or path.stat().st_size <= 0:
            raise RuntimeError(f"missing or empty output: {path}")


def main() -> None:
    print("Lab029: loading real Lab028 BASE/ROI JPEG profile...")
    metadata, composites = load_video_profile(SOURCE_VIDEO_PATH)
    profiles = prepare_profiles(composites)
    print(
        f"  source={metadata.width}x{metadata.height}, "
        f"frames={metadata.frame_count}, composites={len(composites)}"
    )
    for payload_size in PAYLOAD_SIZES:
        print(
            f"  payload={payload_size}: "
            f"{len(profiles[payload_size].packets)} packets/pass"
        )

    print(
        f"Running 45 Monte Carlo conditions, "
        f"{MONTE_CARLO_REPETITIONS} repetitions each..."
    )
    results = run_monte_carlo(profiles, metadata)
    validate_results(results)

    print("Running Lab029 functional checks...")
    functional_tests = run_functional_tests(
        composites, profiles, metadata, results
    )
    for test in functional_tests:
        print(f"  PASS {test.name}: {test.detail}")

    OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
    save_csv(results)
    save_plots(results)
    write_report(
        metadata,
        composites,
        profiles,
        results,
        functional_tests,
    )
    validate_output_files()

    print("Representative composite success rates:")
    for payload_size in PAYLOAD_SIZES:
        loss_result = result_lookup(
            results, "independent_loss", 0.01
        )[payload_size]
        ber_result = result_lookup(
            results, "ber", 1e-6
        )[payload_size]
        burst_result = result_lookup(
            results, "burst", scenario="medium"
        )[payload_size]
        print(
            f"  payload={payload_size}: "
            f"loss1%={loss_result.composite_success_rate:.6f}, "
            f"BER1e-6={ber_result.composite_success_rate:.6f}, "
            f"burst-medium={burst_result.composite_success_rate:.6f}"
        )
    print(f"CSV: {CSV_PATH}")
    print(f"Report: {REPORT_PATH}")
    print("Lab029 completed successfully.")


if __name__ == "__main__":
    main()
