"""
Lab029B. Time-based burst loss simulation for Lab028 video packets.

The existing Lab028 packet format and reassembler are used unchanged. Real
BASE/ROI JPEGs are packetized in the existing order: all BASE fragments of a
composite frame, then all ROI fragments. Packet transmission intervals use
the actual wire length at a laboratory bit rate of 300 kbit/s.

The channel alternates between exponentially distributed Good and Bad time
states. A packet is conservatively lost if any part of its transmission
interval overlaps Bad. JPEGs and packets remain in memory.
"""

from __future__ import annotations

from bisect import bisect_right
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,
    HEADER_SIZE,
    ObjectType,
    decode_packet,
)
from tests.lab028_video_packetization import (
    COMPOSITE_FPS,
    EncodedComposite,
    SOURCE_VIDEO_PATH,
    VideoMetadata,
    load_video_profile,
)
from tests.lab029_packet_channel_simulation import (
    PAYLOAD_SIZES,
    PreparedProfile,
    prepare_profiles,
)


OUTPUT_DIRECTORY = Path("data/processed/lab029b")
CSV_PATH = OUTPUT_DIRECTORY / "lab029b_results.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab029b_report.txt"
COMPOSITE_SUCCESS_PLOT_PATH = (
    OUTPUT_DIRECTORY / "lab029b_composite_success.png"
)
NO_IMAGE_DURATION_PLOT_PATH = (
    OUTPUT_DIRECTORY / "lab029b_no_image_duration.png"
)
MISSING_FRAME_RUN_PLOT_PATH = (
    OUTPUT_DIRECTORY / "lab029b_missing_frame_runs.png"
)
PAYLOAD_COMPARISON_PLOT_PATH = (
    OUTPUT_DIRECTORY / "lab029b_payload_comparison.png"
)

CONTROL_STREAM_BITRATE_BPS = 300_000.0
CONTROL_STREAM_BITRATE_KBPS = CONTROL_STREAM_BITRATE_BPS / 1000.0
BAD_TIME_FRACTION = 0.02
MEAN_BAD_DURATIONS_SECONDS = (0.010, 0.050, 0.200, 1.000)
MONTE_CARLO_REPETITIONS = 200
MASTER_SEED = 290_290
SEED_BASE = MASTER_SEED
TIME_EPSILON_SECONDS = 1e-12

CSV_FIELDS = [
    "payload_size",
    "mean_bad_duration_ms",
    "mean_good_duration_ms",
    "target_bad_time_fraction",
    "actual_bad_time_fraction",
    "control_stream_bitrate_kbps",
    "schedule_duration_seconds",
    "monte_carlo_repetitions",
    "seed",
    "transmitted_packets",
    "received_packets",
    "lost_packets",
    "packet_delivery_rate",
    "base_objects_completed",
    "base_object_success_rate",
    "roi_objects_completed",
    "roi_object_success_rate",
    "atomic_composite_frames_completed",
    "composite_success_rate",
    "base_only_frames",
    "roi_only_frames",
    "incomplete_frames",
    "mean_lost_packet_burst",
    "p95_lost_packet_burst",
    "max_lost_packet_burst",
    "mean_consecutive_incomplete_frames",
    "p95_consecutive_incomplete_frames",
    "max_consecutive_incomplete_frames",
    "mean_no_new_image_duration_seconds",
    "p95_no_new_image_duration_seconds",
    "max_no_new_image_duration_seconds",
    "mean_interference_to_next_frame_seconds",
    "p95_interference_to_next_frame_seconds",
    "max_interference_to_next_frame_seconds",
    "effective_delivered_video_bitrate_kbps",
    "bad_interval_count",
    "actual_mean_bad_duration_ms",
    "actual_p95_bad_duration_ms",
    "actual_max_bad_duration_ms",
]


@dataclass(frozen=True)
class TimeInterval:
    start_seconds: float
    end_seconds: float

    @property
    def duration_seconds(self) -> float:
        return self.end_seconds - self.start_seconds


@dataclass(frozen=True)
class ScheduledPacket:
    composite_frame_id: int
    wire_packet: bytes
    start_seconds: float
    end_seconds: float

    @property
    def duration_seconds(self) -> float:
        return self.end_seconds - self.start_seconds


@dataclass(frozen=True)
class TransmissionSchedule:
    payload_size: int
    frames: tuple
    packets: tuple[ScheduledPacket, ...]
    duration_seconds: float


@dataclass(frozen=True)
class RepetitionResult:
    transmitted_packets: int
    received_packets: int
    lost_packets: int
    base_objects_completed: int
    roi_objects_completed: int
    atomic_composite_frames_completed: int
    base_only_frames: int
    roi_only_frames: int
    incomplete_frames: int
    delivered_jpeg_bytes: int
    lost_packet_bursts: tuple[int, ...]
    incomplete_frame_runs: tuple[int, ...]
    no_new_image_durations: tuple[float, ...]
    interference_to_next_frame_delays: tuple[float, ...]
    bad_time_seconds: float
    bad_durations: tuple[float, ...]


@dataclass(frozen=True)
class TimeSimulationResult:
    payload_size: int
    mean_bad_duration_ms: float
    mean_good_duration_ms: float
    target_bad_time_fraction: float
    actual_bad_time_fraction: float
    control_stream_bitrate_kbps: float
    schedule_duration_seconds: float
    monte_carlo_repetitions: int
    seed: int
    transmitted_packets: int
    received_packets: int
    lost_packets: int
    packet_delivery_rate: float
    base_objects_completed: int
    base_object_success_rate: float
    roi_objects_completed: int
    roi_object_success_rate: float
    atomic_composite_frames_completed: int
    composite_success_rate: float
    base_only_frames: int
    roi_only_frames: int
    incomplete_frames: int
    mean_lost_packet_burst: float
    p95_lost_packet_burst: float
    max_lost_packet_burst: int
    mean_consecutive_incomplete_frames: float
    p95_consecutive_incomplete_frames: float
    max_consecutive_incomplete_frames: int
    mean_no_new_image_duration_seconds: float
    p95_no_new_image_duration_seconds: float
    max_no_new_image_duration_seconds: float
    mean_interference_to_next_frame_seconds: float
    p95_interference_to_next_frame_seconds: float
    max_interference_to_next_frame_seconds: float
    effective_delivered_video_bitrate_kbps: float
    bad_interval_count: int
    actual_mean_bad_duration_ms: float
    actual_p95_bad_duration_ms: float
    actual_max_bad_duration_ms: float


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


def mean_good_duration(mean_bad_duration: float) -> float:
    """Keep the stationary Bad time fraction at two percent."""

    return (
        mean_bad_duration
        * (1.0 - BAD_TIME_FRACTION)
        / BAD_TIME_FRACTION
    )


def percentile(values: list[float] | tuple[float, ...], q: float) -> float:
    if not values:
        return 0.0
    return float(np.percentile(values, q))


def build_transmission_schedule(
    profile: PreparedProfile,
) -> TransmissionSchedule:
    """
    Schedule real packets at 3 composite fps and 300 kbit/s.

    A frame becomes available at frame_id / 3. BASE packets precede ROI
    packets exactly as returned by the Lab028 packets_for_composite helper.
    Packets inside one frame are contiguous. If transmission finishes before
    the next frame is generated, the remaining frame period is naturally
    idle; no artificial inter-packet interval is added.
    """

    scheduled_packets = []
    cursor_seconds = 0.0
    for frame in profile.frames:
        nominal_frame_time = (
            frame.composite_frame_id / COMPOSITE_FPS
        )
        cursor_seconds = max(cursor_seconds, nominal_frame_time)
        object_types = [
            decode_packet(packet).object_type
            for packet in frame.packets
        ]
        first_roi = next(
            (
                index
                for index, object_type in enumerate(object_types)
                if object_type == ObjectType.ROI
            ),
            len(object_types),
        )
        if any(
            object_type != ObjectType.BASE
            for object_type in object_types[:first_roi]
        ) or any(
            object_type != ObjectType.ROI
            for object_type in object_types[first_roi:]
        ):
            raise RuntimeError("Lab028 packet order is not BASE then ROI")

        for wire_packet in frame.packets:
            duration_seconds = (
                len(wire_packet) * 8.0 / CONTROL_STREAM_BITRATE_BPS
            )
            end_seconds = cursor_seconds + duration_seconds
            scheduled_packets.append(
                ScheduledPacket(
                    composite_frame_id=frame.composite_frame_id,
                    wire_packet=wire_packet,
                    start_seconds=cursor_seconds,
                    end_seconds=end_seconds,
                )
            )
            cursor_seconds = end_seconds

    if not scheduled_packets:
        raise RuntimeError("transmission schedule is empty")
    return TransmissionSchedule(
        payload_size=profile.payload_size,
        frames=profile.frames,
        packets=tuple(scheduled_packets),
        duration_seconds=scheduled_packets[-1].end_seconds,
    )


def build_schedules(
    profiles: dict[int, PreparedProfile],
) -> dict[int, TransmissionSchedule]:
    return {
        payload_size: build_transmission_schedule(profiles[payload_size])
        for payload_size in PAYLOAD_SIZES
    }


def generate_bad_intervals(
    horizon_seconds: float,
    mean_bad_duration_seconds: float,
    rng: np.random.Generator,
) -> tuple[TimeInterval, ...]:
    """Generate a stationary alternating exponential Good/Bad timeline."""

    if horizon_seconds <= 0.0:
        raise ValueError("horizon must be positive")
    if mean_bad_duration_seconds <= 0.0:
        raise ValueError("mean Bad duration must be positive")

    mean_good_seconds = mean_good_duration(
        mean_bad_duration_seconds
    )
    bad_state = rng.random() < BAD_TIME_FRACTION
    current_time = 0.0
    intervals = []
    while current_time < horizon_seconds:
        state_mean = (
            mean_bad_duration_seconds
            if bad_state
            else mean_good_seconds
        )
        state_duration = float(rng.exponential(state_mean))
        state_end = min(
            current_time + state_duration,
            horizon_seconds,
        )
        if bad_state and state_end > current_time:
            intervals.append(
                TimeInterval(current_time, state_end)
            )
        current_time += state_duration
        bad_state = not bad_state
    return tuple(intervals)


def packet_loss_flags(
    schedule: TransmissionSchedule,
    bad_intervals: tuple[TimeInterval, ...],
) -> np.ndarray:
    """
    Mark a packet lost when any part of its transmission overlaps Bad.
    """

    flags = np.zeros(len(schedule.packets), dtype=np.bool_)
    interval_index = 0
    for packet_index, packet in enumerate(schedule.packets):
        while (
            interval_index < len(bad_intervals)
            and bad_intervals[interval_index].end_seconds
            <= packet.start_seconds + TIME_EPSILON_SECONDS
        ):
            interval_index += 1
        if interval_index >= len(bad_intervals):
            break
        interval = bad_intervals[interval_index]
        if (
            interval.start_seconds
            < packet.end_seconds - TIME_EPSILON_SECONDS
            and interval.end_seconds
            > packet.start_seconds + TIME_EPSILON_SECONDS
        ):
            flags[packet_index] = True
    return flags


def positive_runs(flags: list[bool] | np.ndarray) -> tuple[int, ...]:
    runs = []
    current = 0
    for flag in flags:
        if bool(flag):
            current += 1
        elif current:
            runs.append(current)
            current = 0
    if current:
        runs.append(current)
    return tuple(runs)


def frame_outage_metrics(
    schedule: TransmissionSchedule,
    completion_times: dict[int, float],
    bad_intervals: tuple[TimeInterval, ...],
) -> tuple[
    tuple[int, ...],
    tuple[float, ...],
    tuple[float, ...],
]:
    """
    Measure failed-frame runs, held-image durations, and recovery delays.

    For each run of incomplete frames, no-new-image duration is measured
    from publication of the preceding complete frame to publication of the
    next complete frame. At the boundaries, zero or schedule end is used.
    Only runs containing at least one incomplete frame are included.
    """

    frame_ids = [
        frame.composite_frame_id for frame in schedule.frames
    ]
    complete_flags = [
        frame_id in completion_times for frame_id in frame_ids
    ]
    incomplete_runs = positive_runs(
        [not flag for flag in complete_flags]
    )

    no_image_durations = []
    index = 0
    while index < len(frame_ids):
        if complete_flags[index]:
            index += 1
            continue
        run_start = index
        while index < len(frame_ids) and not complete_flags[index]:
            index += 1
        next_complete_index = index
        if run_start > 0:
            previous_id = frame_ids[run_start - 1]
            start_time = completion_times.get(previous_id, 0.0)
        else:
            start_time = 0.0
        if next_complete_index < len(frame_ids):
            next_id = frame_ids[next_complete_index]
            end_time = completion_times[next_id]
        else:
            end_time = schedule.duration_seconds
        no_image_durations.append(max(0.0, end_time - start_time))

    sorted_completion_times = sorted(completion_times.values())
    recovery_delays = []
    for interval in bad_intervals:
        completion_index = bisect_right(
            sorted_completion_times,
            interval.start_seconds,
        )
        if completion_index < len(sorted_completion_times):
            next_completion = sorted_completion_times[completion_index]
        else:
            next_completion = schedule.duration_seconds
        recovery_delays.append(
            max(0.0, next_completion - interval.start_seconds)
        )
    return (
        incomplete_runs,
        tuple(no_image_durations),
        tuple(recovery_delays),
    )


def simulate_repetition(
    schedule: TransmissionSchedule,
    bad_intervals: tuple[TimeInterval, ...],
) -> RepetitionResult:
    """Run one already generated time-channel realization."""

    loss_flags = packet_loss_flags(schedule, bad_intervals)
    receiver = CompositeReassembler()
    completion_times: dict[int, float] = {}
    received_packets = 0
    delivered_jpeg_bytes = 0

    for lost, packet in zip(loss_flags, schedule.packets):
        if bool(lost):
            continue
        received_packets += 1
        completed = receiver.ingest(packet.wire_packet)
        if completed is not None:
            completion_times[completed.composite_frame_id] = (
                packet.end_seconds
            )
            delivered_jpeg_bytes += (
                len(completed.base_jpeg) + len(completed.roi_jpeg)
            )

    base_completed = 0
    roi_completed = 0
    base_only = 0
    roi_only = 0
    for frame in schedule.frames:
        frame_id = frame.composite_frame_id
        atomic = frame_id in completion_times
        base = (
            atomic
            or receiver.object_is_complete(frame_id, ObjectType.BASE)
        )
        roi = (
            atomic
            or receiver.object_is_complete(frame_id, ObjectType.ROI)
        )
        base_completed += int(base)
        roi_completed += int(roi)
        base_only += int(base and not roi)
        roi_only += int(roi and not base)

    incomplete_runs, no_image_durations, recovery_delays = (
        frame_outage_metrics(
            schedule, completion_times, bad_intervals
        )
    )
    bad_durations = tuple(
        interval.duration_seconds for interval in bad_intervals
    )
    transmitted = len(schedule.packets)
    completed_count = len(completion_times)
    return RepetitionResult(
        transmitted_packets=transmitted,
        received_packets=received_packets,
        lost_packets=transmitted - received_packets,
        base_objects_completed=base_completed,
        roi_objects_completed=roi_completed,
        atomic_composite_frames_completed=completed_count,
        base_only_frames=base_only,
        roi_only_frames=roi_only,
        incomplete_frames=len(schedule.frames) - completed_count,
        delivered_jpeg_bytes=delivered_jpeg_bytes,
        lost_packet_bursts=positive_runs(loss_flags),
        incomplete_frame_runs=incomplete_runs,
        no_new_image_durations=no_image_durations,
        interference_to_next_frame_delays=recovery_delays,
        bad_time_seconds=sum(bad_durations),
        bad_durations=bad_durations,
    )


def simulate_condition(
    schedule: TransmissionSchedule,
    mean_bad_duration_seconds: float,
    seed: int,
    repetitions: int,
) -> TimeSimulationResult:
    """Aggregate one payload/time-based interference condition."""

    rng = np.random.default_rng(seed)
    repetitions_results = []
    for _ in range(repetitions):
        bad_intervals = generate_bad_intervals(
            schedule.duration_seconds,
            mean_bad_duration_seconds,
            rng,
        )
        repetitions_results.append(
            simulate_repetition(schedule, bad_intervals)
        )

    transmitted = sum(
        result.transmitted_packets for result in repetitions_results
    )
    received = sum(
        result.received_packets for result in repetitions_results
    )
    lost = sum(
        result.lost_packets for result in repetitions_results
    )
    base_completed = sum(
        result.base_objects_completed for result in repetitions_results
    )
    roi_completed = sum(
        result.roi_objects_completed for result in repetitions_results
    )
    composite_completed = sum(
        result.atomic_composite_frames_completed
        for result in repetitions_results
    )
    base_only = sum(
        result.base_only_frames for result in repetitions_results
    )
    roi_only = sum(
        result.roi_only_frames for result in repetitions_results
    )
    incomplete = sum(
        result.incomplete_frames for result in repetitions_results
    )
    delivered_bytes = sum(
        result.delivered_jpeg_bytes for result in repetitions_results
    )
    lost_bursts = [
        value
        for result in repetitions_results
        for value in result.lost_packet_bursts
    ]
    incomplete_runs = [
        value
        for result in repetitions_results
        for value in result.incomplete_frame_runs
    ]
    no_image_durations = [
        value
        for result in repetitions_results
        for value in result.no_new_image_durations
    ]
    recovery_delays = [
        value
        for result in repetitions_results
        for value in result.interference_to_next_frame_delays
    ]
    bad_durations = [
        value
        for result in repetitions_results
        for value in result.bad_durations
    ]
    bad_time = sum(
        result.bad_time_seconds for result in repetitions_results
    )

    total_frames = len(schedule.frames) * repetitions
    total_time = schedule.duration_seconds * repetitions
    if received + lost != transmitted:
        raise RuntimeError("packet accounting disagrees")
    if composite_completed + incomplete != total_frames:
        raise RuntimeError("frame accounting disagrees")
    if base_completed != composite_completed + base_only:
        raise RuntimeError("BASE object accounting disagrees")
    if roi_completed != composite_completed + roi_only:
        raise RuntimeError("ROI object accounting disagrees")

    return TimeSimulationResult(
        payload_size=schedule.payload_size,
        mean_bad_duration_ms=mean_bad_duration_seconds * 1000.0,
        mean_good_duration_ms=(
            mean_good_duration(mean_bad_duration_seconds) * 1000.0
        ),
        target_bad_time_fraction=BAD_TIME_FRACTION,
        actual_bad_time_fraction=bad_time / total_time,
        control_stream_bitrate_kbps=CONTROL_STREAM_BITRATE_KBPS,
        schedule_duration_seconds=schedule.duration_seconds,
        monte_carlo_repetitions=repetitions,
        seed=seed,
        transmitted_packets=transmitted,
        received_packets=received,
        lost_packets=lost,
        packet_delivery_rate=received / transmitted,
        base_objects_completed=base_completed,
        base_object_success_rate=base_completed / total_frames,
        roi_objects_completed=roi_completed,
        roi_object_success_rate=roi_completed / total_frames,
        atomic_composite_frames_completed=composite_completed,
        composite_success_rate=composite_completed / total_frames,
        base_only_frames=base_only,
        roi_only_frames=roi_only,
        incomplete_frames=incomplete,
        mean_lost_packet_burst=(
            float(np.mean(lost_bursts)) if lost_bursts else 0.0
        ),
        p95_lost_packet_burst=percentile(lost_bursts, 95),
        max_lost_packet_burst=max(lost_bursts) if lost_bursts else 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=(
            max(incomplete_runs) if incomplete_runs else 0
        ),
        mean_no_new_image_duration_seconds=(
            float(np.mean(no_image_durations))
            if no_image_durations
            else 0.0
        ),
        p95_no_new_image_duration_seconds=percentile(
            no_image_durations, 95
        ),
        max_no_new_image_duration_seconds=(
            max(no_image_durations) if no_image_durations else 0.0
        ),
        mean_interference_to_next_frame_seconds=(
            float(np.mean(recovery_delays))
            if recovery_delays
            else 0.0
        ),
        p95_interference_to_next_frame_seconds=percentile(
            recovery_delays, 95
        ),
        max_interference_to_next_frame_seconds=(
            max(recovery_delays) if recovery_delays else 0.0
        ),
        effective_delivered_video_bitrate_kbps=(
            delivered_bytes * 8.0 / total_time / 1000.0
        ),
        bad_interval_count=len(bad_durations),
        actual_mean_bad_duration_ms=(
            float(np.mean(bad_durations)) * 1000.0
            if bad_durations
            else 0.0
        ),
        actual_p95_bad_duration_ms=(
            percentile(bad_durations, 95) * 1000.0
        ),
        actual_max_bad_duration_ms=(
            max(bad_durations) * 1000.0
            if bad_durations
            else 0.0
        ),
    )


def run_monte_carlo(
    schedules: dict[int, TransmissionSchedule],
) -> list[TimeSimulationResult]:
    results = []
    for duration_index, mean_bad_duration_seconds in enumerate(
        MEAN_BAD_DURATIONS_SECONDS
    ):
        seed = SEED_BASE + duration_index
        for payload_size in PAYLOAD_SIZES:
            results.append(
                simulate_condition(
                    schedules[payload_size],
                    mean_bad_duration_seconds,
                    seed,
                    MONTE_CARLO_REPETITIONS,
                )
            )
    return results


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


def run_functional_tests(
    composites: list[EncodedComposite],
    schedules: dict[int, TransmissionSchedule],
    results: list[TimeSimulationResult],
) -> list[FunctionalTestResult]:
    tests: list[tuple[str, Callable[[], str]]] = []

    def no_bad_state_is_perfect() -> str:
        for payload_size in PAYLOAD_SIZES:
            repetition = simulate_repetition(
                schedules[payload_size], ()
            )
            if (
                repetition.lost_packets != 0
                or repetition.atomic_composite_frames_completed
                != len(schedules[payload_size].frames)
            ):
                raise AssertionError(
                    f"zero-Bad failed for payload {payload_size}"
                )
        return "all 63 composite frames restored for all payloads"

    def same_seed_is_reproducible() -> str:
        first = simulate_condition(
            schedules[512], 0.050, MASTER_SEED + 999, 5
        )
        second = simulate_condition(
            schedules[512], 0.050, MASTER_SEED + 999, 5
        )
        if first != second:
            raise AssertionError("identical seed changed the result")
        return "identical seed produced identical aggregate fields"

    def adjacent_frames_do_not_mix() -> str:
        schedule = schedules[512]
        receiver = CompositeReassembler()
        completed = {}
        selected = [
            packet
            for packet in schedule.packets
            if packet.composite_frame_id in {0, 1}
        ]
        first = [p for p in selected if p.composite_frame_id == 0]
        second = [p for p in selected if p.composite_frame_id == 1]
        interleaved = []
        for index in range(max(len(first), len(second))):
            if index < len(second):
                interleaved.append(second[index])
            if index < len(first):
                interleaved.append(first[index])
        for packet in interleaved:
            frame = receiver.ingest(packet.wire_packet)
            if frame is not None:
                completed[frame.composite_frame_id] = frame
        if set(completed) != {0, 1}:
            raise AssertionError("neighboring frame IDs were mixed")
        for composite in composites[:2]:
            restored = completed[composite.composite_frame_id]
            if (
                restored.base_jpeg != composite.base_jpeg
                or restored.roi_jpeg != composite.roi_jpeg
            ):
                raise AssertionError("neighboring JPEGs were mixed")
        return "two interleaved neighboring frames remained independent"

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

    def bad_fraction_is_close() -> str:
        worst_error = max(
            abs(
                result.actual_bad_time_fraction
                - result.target_bad_time_fraction
            )
            for result in results
        )
        if worst_error > 0.01:
            raise AssertionError(
                f"Bad fraction error is too large: {worst_error:.6f}"
            )
        return (
            "all observed Bad fractions are within "
            f"{worst_error * 100.0:.3f} percentage points of 2%"
        )

    def longer_bad_means_longer_outage() -> str:
        short_mean = float(
            np.mean(
                [
                    result.mean_no_new_image_duration_seconds
                    for result in results
                    if result.mean_bad_duration_ms == 10.0
                ]
            )
        )
        long_mean = float(
            np.mean(
                [
                    result.mean_no_new_image_duration_seconds
                    for result in results
                    if result.mean_bad_duration_ms == 1000.0
                ]
            )
        )
        if long_mean <= short_mean:
            raise AssertionError(
                f"long outage {long_mean} <= short outage {short_mean}"
            )
        return (
            f"mean held-image interval increased from "
            f"{short_mean:.6f} to {long_mean:.6f} s"
        )

    def packet_time_uses_actual_size() -> str:
        schedule = schedules[512]
        durations = [
            packet.duration_seconds for packet in schedule.packets
        ]
        sizes = [len(packet.wire_packet) for packet in schedule.packets]
        if len(set(sizes)) < 2 or len(set(durations)) < 2:
            raise AssertionError("last packet duration did not differ")
        for packet in schedule.packets:
            expected = (
                len(packet.wire_packet)
                * 8.0
                / CONTROL_STREAM_BITRATE_BPS
            )
            if abs(packet.duration_seconds - expected) > 1e-12:
                raise AssertionError("packet duration formula disagrees")
        if (
            max(
                packet.duration_seconds
                for packet in schedules[1024].packets
            )
            <= max(
                packet.duration_seconds
                for packet in schedules[256].packets
            )
        ):
            raise AssertionError("larger payload did not take longer")
        return (
            "duration equals actual wire bits / 300000 for full and "
            "short final packets"
        )

    tests.extend(
        [
            ("zero_bad_state_100_percent", no_bad_state_is_perfect),
            ("fixed_seed_reproducibility", same_seed_is_reproducible),
            ("adjacent_frame_isolation", adjacent_frames_do_not_mix),
            ("atomic_incomplete_frame", incomplete_frame_is_not_published),
            ("stationary_bad_fraction", bad_fraction_is_close),
            ("longer_bad_longer_outage", longer_bad_means_longer_outage),
            ("actual_packet_transmission_time", packet_time_uses_actual_size),
        ]
    )

    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"Lab029B functional checks failed: {details}")
    return test_results


def validate_results(results: list[TimeSimulationResult]) -> None:
    if len(results) != 12:
        raise RuntimeError(f"expected 12 rows, got {len(results)}")
    keys = {
        (result.payload_size, result.mean_bad_duration_ms)
        for result in results
    }
    if len(keys) != 12:
        raise RuntimeError("result conditions are not unique")
    for result in results:
        if (
            result.received_packets + result.lost_packets
            != result.transmitted_packets
        ):
            raise RuntimeError("packet counts disagree")
        if not 0.0 <= result.composite_success_rate <= 1.0:
            raise RuntimeError("composite success is outside 0...1")


def save_csv(results: list[TimeSimulationResult]) -> 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 = asdict(result)
            row = {
                field: (
                    f"{raw[field]:.12g}"
                    if isinstance(raw[field], float)
                    else raw[field]
                )
                for field in CSV_FIELDS
            }
            writer.writerow(row)


def save_line_plot(
    results: list[TimeSimulationResult],
    value_getter: Callable[[TimeSimulationResult], float],
    ylabel: str,
    title: str,
    output_path: Path,
) -> None:
    x_values = [
        duration * 1000.0
        for duration in MEAN_BAD_DURATIONS_SECONDS
    ]
    figure, axis = plt.subplots(figsize=(9, 5.5))
    for payload_size in PAYLOAD_SIZES:
        values = [
            value_getter(
                result_lookup(results, duration_ms)[payload_size]
            )
            for duration_ms in x_values
        ]
        axis.plot(
            x_values,
            values,
            marker="o",
            linewidth=2,
            label=f"payload {payload_size} B",
        )
    axis.set_xscale("log")
    axis.set_xticks(x_values)
    axis.set_xticklabels(
        [f"{value:g}" for value in x_values]
    )
    axis.set_xlabel("Средняя длительность Bad, мс")
    axis.set_ylabel(ylabel)
    axis.set_title(title)
    axis.grid(True, which="both", alpha=0.3)
    axis.legend()
    figure.tight_layout()
    figure.savefig(output_path, dpi=160)
    plt.close(figure)


def save_plots(results: list[TimeSimulationResult]) -> None:
    save_line_plot(
        results,
        lambda result: result.composite_success_rate * 100.0,
        "Полностью восстановленные составные кадры, %",
        "Lab029B. Успешная атомарная сборка",
        COMPOSITE_SUCCESS_PLOT_PATH,
    )
    save_line_plot(
        results,
        lambda result: result.p95_no_new_image_duration_seconds,
        "P95 отсутствия нового изображения, с",
        "Lab029B. Длительность удержания последнего изображения",
        NO_IMAGE_DURATION_PLOT_PATH,
    )
    save_line_plot(
        results,
        lambda result: result.p95_consecutive_incomplete_frames,
        "P95 последовательных невосстановленных кадров",
        "Lab029B. Серии невосстановленных составных кадров",
        MISSING_FRAME_RUN_PLOT_PATH,
    )

    comparison = result_lookup(results, 200.0)
    x_positions = np.arange(len(PAYLOAD_SIZES))
    success = [
        comparison[payload].composite_success_rate * 100.0
        for payload in PAYLOAD_SIZES
    ]
    outage = [
        comparison[
            payload
        ].p95_no_new_image_duration_seconds
        for payload in PAYLOAD_SIZES
    ]
    figure, success_axis = plt.subplots(figsize=(9, 5.5))
    outage_axis = success_axis.twinx()
    bars = success_axis.bar(
        x_positions - 0.18,
        success,
        width=0.36,
        color="tab:blue",
        label="Composite success",
    )
    outage_bars = outage_axis.bar(
        x_positions + 0.18,
        outage,
        width=0.36,
        color="tab:orange",
        label="P95 no-new-image",
    )
    success_axis.set_xticks(x_positions)
    success_axis.set_xticklabels(
        [str(payload) for payload in PAYLOAD_SIZES]
    )
    success_axis.set_xlabel("Payload, байт")
    success_axis.set_ylabel(
        "Восстановленные составные кадры, %",
        color="tab:blue",
    )
    outage_axis.set_ylabel(
        "P95 отсутствия нового изображения, с",
        color="tab:orange",
    )
    success_axis.set_title(
        "Lab029B. Сравнение payload при среднем Bad 200 мс"
    )
    success_axis.grid(True, axis="y", alpha=0.3)
    success_axis.legend(
        [bars, outage_bars],
        ["Composite success", "P95 no-new-image"],
        loc="best",
    )
    figure.tight_layout()
    figure.savefig(PAYLOAD_COMPARISON_PLOT_PATH, dpi=160)
    plt.close(figure)


def comparison_table_lines(
    results: list[TimeSimulationResult],
) -> list[str]:
    lines = [
        (
            "Bad ms | payload | Bad actual | tx/rx/lost packets | "
            "packet delivery | BASE | ROI | composite | BASE-only | "
            "ROI-only | incomplete | "
            "lost burst mean/p95/max | incomplete run mean/p95/max | "
            "no image mean/p95/max s | recovery mean/p95/max s | "
            "video kbit/s"
        ),
        (
            "------:|--------:|-----------:|-------------------:|"
            "----------------:|-----:|----:|----------:|----------:|"
            "---------:|-----------:|"
            "-----------------------:|----------------------------:|"
            "-------------------------:|------------------------:|"
            "------------:"
        ),
    ]
    for duration_seconds in MEAN_BAD_DURATIONS_SECONDS:
        duration_ms = duration_seconds * 1000.0
        by_payload = result_lookup(results, duration_ms)
        for payload in PAYLOAD_SIZES:
            result = by_payload[payload]
            lines.append(
                f"{duration_ms:.0f} | {payload} | "
                f"{result.actual_bad_time_fraction * 100.0:.3f}% | "
                f"{result.transmitted_packets}/"
                f"{result.received_packets}/"
                f"{result.lost_packets} | "
                f"{result.packet_delivery_rate * 100.0:.3f}% | "
                f"{result.base_object_success_rate * 100.0:.3f}% | "
                f"{result.roi_object_success_rate * 100.0:.3f}% | "
                f"{result.composite_success_rate * 100.0:.3f}% | "
                f"{result.base_only_frames} | {result.roi_only_frames} | "
                f"{result.incomplete_frames} | "
                f"{result.mean_lost_packet_burst:.2f}/"
                f"{result.p95_lost_packet_burst:.1f}/"
                f"{result.max_lost_packet_burst} | "
                f"{result.mean_consecutive_incomplete_frames:.2f}/"
                f"{result.p95_consecutive_incomplete_frames:.1f}/"
                f"{result.max_consecutive_incomplete_frames} | "
                f"{result.mean_no_new_image_duration_seconds:.3f}/"
                f"{result.p95_no_new_image_duration_seconds:.3f}/"
                f"{result.max_no_new_image_duration_seconds:.3f} | "
                f"{result.mean_interference_to_next_frame_seconds:.3f}/"
                f"{result.p95_interference_to_next_frame_seconds:.3f}/"
                f"{result.max_interference_to_next_frame_seconds:.3f} | "
                f"{result.effective_delivered_video_bitrate_kbps:.3f}"
            )
    return lines


def write_report(
    metadata: VideoMetadata,
    composites: list[EncodedComposite],
    schedules: dict[int, TransmissionSchedule],
    results: list[TimeSimulationResult],
    tests: list[FunctionalTestResult],
) -> None:
    lines = [
        "Lab029B. Временная модель серийных потерь видеопакетов",
        "",
        "Исходные данные и транспорт",
        f"- Видео: {SOURCE_VIDEO_PATH}",
        (
            f"- Источник: {metadata.width}x{metadata.height}, "
            f"{metadata.fps:.6f} fps, {metadata.frame_count} кадров, "
            f"{metadata.duration_seconds:.6f} с."
        ),
        (
            f"- Использованы все {len(composites)} реальные пары: "
            "BASE 240x135 grayscale JPEG Q23, ROI 320x180 grayscale "
            "JPEG Q33, 3 составных кадра/с."
        ),
        (
            f"- Формат Lab028 не изменён: заголовок {HEADER_SIZE} байта, "
            "packet CRC32, object CRC32, отдельные BASE/ROI, атомарный "
            "CompositeReassembler."
        ),
        (
            "- Порядок передачи внутри кадра: сначала все BASE-фрагменты, "
            "затем все ROI-фрагменты. Поэтому длительная помеха может "
            "затрагивать объекты несимметрично."
        ),
        "",
        "Временная модель передачи",
        (
            f"- Контрольная скорость: {CONTROL_STREAM_BITRATE_KBPS:.0f} "
            "кбит/с. Это параметр лабораторной, а не выбранная скорость "
            "радиоканала."
        ),
        (
            "- Длительность пакета = фактическая длина "
            "(32-byte header + фактический payload) × 8 / 300000."
        ),
        (
            "- Кадр доступен в момент frame_id / 3. Пакеты BASE и ROI "
            "передаются подряд; искусственных межпакетных интервалов нет. "
            "Остаток периода до следующего кадра является естественным idle."
        ),
    ]
    for payload in PAYLOAD_SIZES:
        schedule = schedules[payload]
        packet_durations_ms = [
            packet.duration_seconds * 1000.0
            for packet in schedule.packets
        ]
        lines.append(
            f"- Payload {payload}: packets={len(schedule.packets)}, "
            f"timeline={schedule.duration_seconds:.6f} с, "
            f"packet duration min/max="
            f"{min(packet_durations_ms):.6f}/"
            f"{max(packet_durations_ms):.6f} мс."
        )

    lines.extend(
        [
            "",
            "Временная модель канала",
            (
                "- Good пропускает пакеты, Bad теряет пакеты. Длительности "
                "состояний независимы и экспоненциально распределены."
            ),
            (
                f"- Целевая стационарная доля Bad: "
                f"{BAD_TIME_FRACTION * 100.0:.1f}%."
            ),
            (
                "- mean Good = mean Bad × (1-0.02)/0.02 = "
                "49 × mean Bad."
            ),
            (
                "- Консервативное допущение: пакет считается потерянным, "
                "если хотя бы часть его передачи пересекается с Bad."
            ),
            (
                f"- Monte Carlo: {MONTE_CARLO_REPETITIONS} повторов на "
                f"условие; master seed={MASTER_SEED}; seeds "
                f"{SEED_BASE}...{SEED_BASE + 3}."
            ),
            (
                "- Один seed для одинаковой длительности повторно "
                "используется для payload 256/512/1024."
            ),
            "",
            "Параметры состояний",
            "mean Bad | mean Good | target Bad",
            "--------:|----------:|----------:",
        ]
    )
    for duration in MEAN_BAD_DURATIONS_SECONDS:
        lines.append(
            f"{duration * 1000.0:.0f} мс | "
            f"{mean_good_duration(duration) * 1000.0:.0f} мс | "
            f"{BAD_TIME_FRACTION * 100.0:.1f}%"
        )

    lines.extend(["", "Функциональные проверки"])
    lines.extend(
        f"- {'PASS' if test.passed else 'FAIL'} {test.name}: {test.detail}"
        for test in tests
    )
    lines.extend(
        [
            "",
            "Полная сравнительная таблица",
            *comparison_table_lines(results),
            "",
            "Допущения и ограничения",
            (
                "- Не моделируются FEC, интерливинг, ARQ, повторные "
                "передачи, искусственные межпакетные интервалы, преамбула "
                "физического уровня, управление и телеметрия."
            ),
            (
                "- Bad-интервалы не зависят от границ пакетов; любое "
                "частичное пересечение уничтожает пакет целиком."
            ),
            (
                "- no-new-image duration для серии невосстановленных кадров "
                "измеряется от публикации предыдущего полного кадра до "
                "публикации следующего; при краевых сериях используются "
                "начало и конец временной шкалы."
            ),
            (
                "- interference-to-next-frame измеряется от начала каждого "
                "Bad-интервала до первого следующего опубликованного "
                "составного кадра или до конца шкалы."
            ),
            (
                "- Эффективный видеопоток учитывает JPEG bytes только "
                "атомарно восстановленных BASE+ROI и полную длительность "
                "расписания."
            ),
            (
                "- Результаты зависят от порядка BASE затем ROI и от "
                "контрольной скорости 300 кбит/с."
            ),
            (
                "- Окончательный размер payload автоматически не "
                "выбирается."
            ),
            "",
            "Артефакты",
            f"- CSV: {CSV_PATH}",
            f"- Composite success: {COMPOSITE_SUCCESS_PLOT_PATH}",
            f"- No-new-image duration: {NO_IMAGE_DURATION_PLOT_PATH}",
            f"- Missing frame runs: {MISSING_FRAME_RUN_PLOT_PATH}",
            f"- Payload comparison: {PAYLOAD_COMPARISON_PLOT_PATH}",
            "- JPEG, пакеты и бинарные дампы не сохранялись.",
            "",
        ]
    )
    REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")


def validate_output_files() -> None:
    for path in (
        CSV_PATH,
        REPORT_PATH,
        COMPOSITE_SUCCESS_PLOT_PATH,
        NO_IMAGE_DURATION_PLOT_PATH,
        MISSING_FRAME_RUN_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("Lab029B: loading real BASE/ROI JPEGs...")
    metadata, composites = load_video_profile(SOURCE_VIDEO_PATH)
    profiles = prepare_profiles(composites)
    schedules = build_schedules(profiles)
    for payload in PAYLOAD_SIZES:
        schedule = schedules[payload]
        print(
            f"  payload={payload}: packets={len(schedule.packets)}, "
            f"timeline={schedule.duration_seconds:.6f} s"
        )

    print(
        f"Running 12 time-channel conditions, "
        f"{MONTE_CARLO_REPETITIONS} repetitions each..."
    )
    results = run_monte_carlo(schedules)
    validate_results(results)

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

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

    print("Composite success and P95 no-new-image duration:")
    for duration in MEAN_BAD_DURATIONS_SECONDS:
        duration_ms = duration * 1000.0
        by_payload = result_lookup(results, duration_ms)
        for payload in PAYLOAD_SIZES:
            result = by_payload[payload]
            print(
                f"  Bad={duration_ms:.0f} ms, payload={payload}: "
                f"success={result.composite_success_rate:.6f}, "
                f"no-image-p95="
                f"{result.p95_no_new_image_duration_seconds:.6f} s"
            )
    print(f"CSV: {CSV_PATH}")
    print(f"Report: {REPORT_PATH}")
    print("Lab029B completed successfully.")


if __name__ == "__main__":
    main()
