"""
Lab028. Packetization of synchronous BASE + ROI JPEG video objects.

The laboratory forms the selected Lab027E profile directly from the source
video, keeps every JPEG and packet in memory, verifies the transport layer,
and writes only aggregate CSV/report/plot artifacts.
"""

from __future__ import annotations

import csv
from dataclasses import dataclass
from pathlib import Path
import random
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 (
    CompositeFrame,
    CompositeReassembler,
    HEADER_FORMAT,
    HEADER_SIZE,
    ObjectCRCError,
    ObjectType,
    PacketCRCError,
    VideoPacket,
    decode_packet,
    encode_packet,
    packetize_jpeg,
)


SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4")
OUTPUT_DIRECTORY = Path("data/processed/lab028")
CSV_PATH = OUTPUT_DIRECTORY / "lab028_packet_payload_results.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab028_report.txt"
OVERHEAD_PLOT_PATH = (
    OUTPUT_DIRECTORY / "lab028_overhead_efficiency.png"
)
TRAFFIC_PLOT_PATH = (
    OUTPUT_DIRECTORY / "lab028_packets_wire_bitrate.png"
)

COMPOSITE_FPS = 3.0
BASE_WIDTH = 240
BASE_HEIGHT = 135
BASE_QUALITY = 23
ROI_WIDTH = 320
ROI_HEIGHT = 180
ROI_QUALITY = 33
ROI_X_MIN = 0.20
ROI_X_MAX = 0.80
ROI_Y_MIN = 0.42
ROI_Y_MAX = 1.00
PAYLOAD_LENGTHS = (64, 128, 256, 512, 1024)
FRAME_TIME_EPSILON_SECONDS = 1e-9

CSV_FIELDS = [
    "max_payload_bytes",
    "composite_frames",
    "mean_base_packets_per_frame",
    "mean_roi_packets_per_frame",
    "mean_total_packets_per_frame",
    "max_total_packets_per_frame",
    "packets_per_second",
    "jpeg_payload_bytes",
    "jpeg_payload_bitrate_kbps",
    "header_bytes_per_second",
    "header_bitrate_kbps",
    "wire_bytes",
    "wire_bitrate_kbps",
    "service_data_percent",
    "efficiency_percent",
    "mean_wire_packet_bytes",
    "p95_wire_packet_bytes",
    "max_wire_packet_bytes",
]


@dataclass(frozen=True)
class VideoMetadata:
    width: int
    height: int
    fps: float
    frame_count: int
    duration_seconds: float


@dataclass(frozen=True)
class EncodedComposite:
    composite_frame_id: int
    source_frame_index: int
    base_jpeg: bytes
    roi_jpeg: bytes


@dataclass(frozen=True)
class PayloadMetrics:
    max_payload_bytes: int
    composite_frames: int
    mean_base_packets_per_frame: float
    mean_roi_packets_per_frame: float
    mean_total_packets_per_frame: float
    max_total_packets_per_frame: int
    packets_per_second: float
    jpeg_payload_bytes: int
    jpeg_payload_bitrate_kbps: float
    header_bytes_per_second: float
    header_bitrate_kbps: float
    wire_bytes: int
    wire_bitrate_kbps: float
    service_data_percent: float
    efficiency_percent: float
    mean_wire_packet_bytes: float
    p95_wire_packet_bytes: float
    max_wire_packet_bytes: int


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


def normalized_roi_to_pixels(
    width: int,
    height: int,
) -> tuple[int, int, int, int]:
    """Use the normalized ROI coordinates from Lab027 through Lab027E."""

    coordinates = (
        int(round(width * ROI_X_MIN)),
        int(round(height * ROI_Y_MIN)),
        int(round(width * ROI_X_MAX)),
        int(round(height * ROI_Y_MAX)),
    )
    x_min, y_min, x_max, y_max = coordinates
    if not (
        0 <= x_min < x_max <= width
        and 0 <= y_min < y_max <= height
    ):
        raise RuntimeError("calculated ROI is outside the source frame")
    return coordinates


def encode_grayscale_jpeg(image: np.ndarray, quality: int) -> bytes:
    """Encode one grayscale image to an in-memory JPEG."""

    encoded, buffer = cv2.imencode(
        ".jpg",
        image,
        [int(cv2.IMWRITE_JPEG_QUALITY), int(quality)],
    )
    if not encoded:
        raise RuntimeError("OpenCV could not encode JPEG")
    jpeg = buffer.tobytes()
    if not jpeg:
        raise RuntimeError("OpenCV produced an empty JPEG")
    return jpeg


def encode_composite(
    source_frame: np.ndarray,
    source_roi: tuple[int, int, int, int],
    composite_frame_id: int,
    source_frame_index: int,
) -> EncodedComposite:
    """Form synchronous BASE and ROI JPEGs from exactly one source frame."""

    grayscale = cv2.cvtColor(source_frame, cv2.COLOR_BGR2GRAY)
    base = cv2.resize(
        grayscale,
        (BASE_WIDTH, BASE_HEIGHT),
        interpolation=cv2.INTER_AREA,
    )
    x_min, y_min, x_max, y_max = source_roi
    roi = grayscale[y_min:y_max, x_min:x_max]
    if roi.size == 0:
        raise RuntimeError("source ROI is empty")
    roi = cv2.resize(
        roi,
        (ROI_WIDTH, ROI_HEIGHT),
        interpolation=cv2.INTER_AREA,
    )
    return EncodedComposite(
        composite_frame_id=composite_frame_id,
        source_frame_index=source_frame_index,
        base_jpeg=encode_grayscale_jpeg(base, BASE_QUALITY),
        roi_jpeg=encode_grayscale_jpeg(roi, ROI_QUALITY),
    )


def load_video_profile(
    source_path: Path,
) -> tuple[VideoMetadata, list[EncodedComposite]]:
    """Read the video sequentially and select synchronous updates at 3 fps."""

    if not source_path.exists():
        raise FileNotFoundError(f"source video is missing: {source_path}")
    capture = cv2.VideoCapture(str(source_path))
    if not capture.isOpened():
        raise RuntimeError(f"OpenCV could not open {source_path}")

    width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
    height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
    fps = float(capture.get(cv2.CAP_PROP_FPS))
    declared_frame_count = int(
        capture.get(cv2.CAP_PROP_FRAME_COUNT)
    )
    if width <= 0 or height <= 0 or fps <= 0.0:
        capture.release()
        raise RuntimeError("invalid source video metadata")

    source_roi = normalized_roi_to_pixels(width, height)
    selected: list[EncodedComposite] = []
    source_frame_index = 0
    next_composite_time = 0.0
    try:
        while True:
            frame_read, source_frame = capture.read()
            if not frame_read or source_frame is None:
                break
            source_time = source_frame_index / fps
            if (
                source_time + FRAME_TIME_EPSILON_SECONDS
                >= next_composite_time
            ):
                selected.append(
                    encode_composite(
                        source_frame,
                        source_roi,
                        len(selected),
                        source_frame_index,
                    )
                )
                next_composite_time += 1.0 / COMPOSITE_FPS
            source_frame_index += 1
    finally:
        capture.release()

    if source_frame_index <= 0 or not selected:
        raise RuntimeError("source video did not yield frames")
    if (
        declared_frame_count > 0
        and source_frame_index != declared_frame_count
    ):
        raise RuntimeError(
            "decoded frame count differs from video metadata: "
            f"{source_frame_index} != {declared_frame_count}"
        )
    metadata = VideoMetadata(
        width=width,
        height=height,
        fps=fps,
        frame_count=source_frame_index,
        duration_seconds=source_frame_index / fps,
    )
    return metadata, selected


def packets_for_composite(
    composite: EncodedComposite,
    max_payload_bytes: int,
) -> tuple[list[bytes], list[bytes]]:
    """Packetize BASE and ROI separately with one composite frame ID."""

    base_packets = packetize_jpeg(
        composite.base_jpeg,
        composite.composite_frame_id,
        ObjectType.BASE,
        max_payload_bytes,
    )
    roi_packets = packetize_jpeg(
        composite.roi_jpeg,
        composite.composite_frame_id,
        ObjectType.ROI,
        max_payload_bytes,
    )
    return base_packets, roi_packets


def calculate_payload_metrics(
    composites: list[EncodedComposite],
    duration_seconds: float,
    max_payload_bytes: int,
) -> PayloadMetrics:
    """Calculate actual packet and bitrate statistics for one payload limit."""

    base_counts: list[int] = []
    roi_counts: list[int] = []
    total_counts: list[int] = []
    wire_packet_sizes: list[int] = []
    for composite in composites:
        base_packets, roi_packets = packets_for_composite(
            composite, max_payload_bytes
        )
        base_counts.append(len(base_packets))
        roi_counts.append(len(roi_packets))
        total_counts.append(len(base_packets) + len(roi_packets))
        wire_packet_sizes.extend(
            len(packet) for packet in base_packets + roi_packets
        )

    jpeg_payload_bytes = sum(
        len(composite.base_jpeg) + len(composite.roi_jpeg)
        for composite in composites
    )
    packet_count = len(wire_packet_sizes)
    header_bytes = packet_count * HEADER_SIZE
    wire_bytes = jpeg_payload_bytes + header_bytes
    return PayloadMetrics(
        max_payload_bytes=max_payload_bytes,
        composite_frames=len(composites),
        mean_base_packets_per_frame=float(np.mean(base_counts)),
        mean_roi_packets_per_frame=float(np.mean(roi_counts)),
        mean_total_packets_per_frame=float(np.mean(total_counts)),
        max_total_packets_per_frame=max(total_counts),
        packets_per_second=packet_count / duration_seconds,
        jpeg_payload_bytes=jpeg_payload_bytes,
        jpeg_payload_bitrate_kbps=(
            jpeg_payload_bytes * 8.0 / duration_seconds / 1000.0
        ),
        header_bytes_per_second=header_bytes / duration_seconds,
        header_bitrate_kbps=(
            header_bytes * 8.0 / duration_seconds / 1000.0
        ),
        wire_bytes=wire_bytes,
        wire_bitrate_kbps=(
            wire_bytes * 8.0 / duration_seconds / 1000.0
        ),
        service_data_percent=header_bytes / wire_bytes * 100.0,
        efficiency_percent=jpeg_payload_bytes / wire_bytes * 100.0,
        mean_wire_packet_bytes=float(np.mean(wire_packet_sizes)),
        p95_wire_packet_bytes=float(
            np.percentile(wire_packet_sizes, 95)
        ),
        max_wire_packet_bytes=max(wire_packet_sizes),
    )


def feed_packets(
    packets: list[bytes],
    reassembler: CompositeReassembler | None = None,
) -> tuple[list[CompositeFrame], CompositeReassembler]:
    """Feed packets and collect every atomically published frame."""

    receiver = reassembler or CompositeReassembler()
    completed = []
    for packet in packets:
        frame = receiver.ingest(packet)
        if frame is not None:
            completed.append(frame)
    return completed, receiver


def assert_frame_matches(
    frame: CompositeFrame,
    expected: EncodedComposite,
) -> None:
    if frame.composite_frame_id != expected.composite_frame_id:
        raise AssertionError("composite frame ID differs")
    if frame.base_jpeg != expected.base_jpeg:
        raise AssertionError("BASE JPEG differs byte-for-byte")
    if frame.roi_jpeg != expected.roi_jpeg:
        raise AssertionError("ROI JPEG differs byte-for-byte")


def run_functional_tests(
    composites: list[EncodedComposite],
) -> list[TestResult]:
    """Run header and all mandatory Lab028 transport checks."""

    if len(composites) < 2:
        raise RuntimeError("functional checks need two video frames")
    first = composites[0]
    second = composites[1]
    base_packets, roi_packets = packets_for_composite(first, 256)
    all_packets = base_packets + roi_packets
    tests: list[tuple[str, Callable[[], str]]] = []

    def header_round_trip() -> str:
        parsed = decode_packet(all_packets[0])
        rebuilt = encode_packet(
            VideoPacket(
                composite_frame_id=parsed.composite_frame_id,
                object_type=parsed.object_type,
                fragment_index=parsed.fragment_index,
                fragment_count=parsed.fragment_count,
                jpeg_size=parsed.jpeg_size,
                object_crc32=parsed.object_crc32,
                payload=parsed.payload,
            )
        )
        if rebuilt != all_packets[0]:
            raise AssertionError("serialized bytes changed after round trip")
        return f"fixed {HEADER_SIZE}-byte header round trip is exact"

    def ordered_lossless() -> str:
        frames, _ = feed_packets(all_packets)
        if len(frames) != 1:
            raise AssertionError("ordered transfer did not emit one frame")
        assert_frame_matches(frames[0], first)
        return "BASE and ROI match original JPEG bytes"

    def shuffled_packets() -> str:
        shuffled = list(all_packets)
        random.Random(28001).shuffle(shuffled)
        frames, _ = feed_packets(shuffled)
        if len(frames) != 1:
            raise AssertionError("shuffled transfer did not emit one frame")
        assert_frame_matches(frames[0], first)
        return "arbitrary packet order reconstructed correctly"

    def duplicate_packets() -> str:
        duplicated = list(all_packets)
        duplicated.extend(
            [all_packets[0], all_packets[len(base_packets)]]
        )
        random.Random(28002).shuffle(duplicated)
        frames, receiver = feed_packets(duplicated)
        if len(frames) != 1:
            raise AssertionError("duplicates changed publication count")
        assert_frame_matches(frames[0], first)
        if receiver.duplicate_packets < 2:
            raise AssertionError("exact duplicates were not counted")
        return f"{receiver.duplicate_packets} exact duplicates ignored"

    def packet_crc_corruption() -> str:
        corrupted = bytearray(all_packets[0])
        corrupted[-1] ^= 0x01
        receiver = CompositeReassembler()
        try:
            receiver.ingest(bytes(corrupted))
        except PacketCRCError:
            pass
        else:
            raise AssertionError("corrupted packet passed packet CRC")
        frames, _ = feed_packets(all_packets[1:], receiver)
        if frames:
            raise AssertionError("frame emitted despite rejected packet")
        return "payload bit flip rejected; composite not emitted"

    def missing_fragment() -> str:
        missing_packet = roi_packets[len(roi_packets) // 2]
        missing_index = decode_packet(missing_packet).fragment_index
        remaining = [
            packet
            for packet in all_packets
            if packet is not missing_packet
        ]
        frames, receiver = feed_packets(remaining)
        if frames:
            raise AssertionError("frame emitted with a missing fragment")
        missing = receiver.missing_fragments(
            first.composite_frame_id, ObjectType.ROI
        )
        if missing is None or missing_index not in missing:
            raise AssertionError("missing fragment was not reported")
        return f"ROI fragment {missing_index} reported missing"

    def object_crc_corruption() -> str:
        target_position = len(base_packets)
        parsed = decode_packet(all_packets[target_position])
        changed_payload = bytearray(parsed.payload)
        changed_payload[0] ^= 0x01
        altered = encode_packet(
            VideoPacket(
                composite_frame_id=parsed.composite_frame_id,
                object_type=parsed.object_type,
                fragment_index=parsed.fragment_index,
                fragment_count=parsed.fragment_count,
                jpeg_size=parsed.jpeg_size,
                object_crc32=parsed.object_crc32,
                payload=bytes(changed_payload),
            )
        )
        formally_valid = list(all_packets)
        formally_valid[target_position] = altered
        receiver = CompositeReassembler()
        emitted = []
        object_error_seen = False
        for packet in formally_valid:
            try:
                frame = receiver.ingest(packet)
            except ObjectCRCError:
                object_error_seen = True
                continue
            if frame is not None:
                emitted.append(frame)
        if not object_error_seen:
            raise AssertionError("object CRC did not detect changed JPEG")
        if emitted:
            raise AssertionError("frame emitted after object CRC failure")
        return "valid packet CRCs still failed full-object CRC"

    def adjacent_frames_do_not_mix() -> str:
        first_packets = sum(packets_for_composite(first, 256), [])
        second_packets = sum(packets_for_composite(second, 256), [])
        interleaved = first_packets + second_packets
        random.Random(28003).shuffle(interleaved)
        frames, _ = feed_packets(interleaved)
        by_id = {frame.composite_frame_id: frame for frame in frames}
        if set(by_id) != {
            first.composite_frame_id,
            second.composite_frame_id,
        }:
            raise AssertionError("adjacent frames were lost or mixed")
        assert_frame_matches(by_id[first.composite_frame_id], first)
        assert_frame_matches(by_id[second.composite_frame_id], second)
        return "two interleaved frame IDs remained independent"

    def base_only_is_not_atomic() -> str:
        frames, receiver = feed_packets(base_packets)
        if frames:
            raise AssertionError("BASE-only input emitted a composite")
        if not receiver.object_is_complete(
            first.composite_frame_id, ObjectType.BASE
        ):
            raise AssertionError("complete BASE object was not retained")
        return "complete BASE retained while composite stayed unpublished"

    tests.extend(
        [
            ("header_serialization_round_trip", header_round_trip),
            ("ordered_lossless_transfer", ordered_lossless),
            ("random_packet_order", shuffled_packets),
            ("exact_duplicate_packets", duplicate_packets),
            ("packet_crc_corruption", packet_crc_corruption),
            ("missing_fragment", missing_fragment),
            ("object_crc_corruption", object_crc_corruption),
            ("adjacent_frame_isolation", adjacent_frames_do_not_mix),
            ("base_without_roi_atomicity", base_only_is_not_atomic),
        ]
    )

    results = []
    for name, test in tests:
        try:
            detail = test()
        except Exception as error:
            results.append(TestResult(name, False, str(error)))
        else:
            results.append(TestResult(name, True, detail))
    failed = [result for result in results if not result.passed]
    if failed:
        details = "; ".join(
            f"{result.name}: {result.detail}" for result in failed
        )
        raise RuntimeError(f"functional transport tests failed: {details}")
    return results


def save_csv(metrics: list[PayloadMetrics]) -> 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 item in metrics:
            row = {}
            for field_name in CSV_FIELDS:
                value = getattr(item, field_name)
                row[field_name] = (
                    f"{value:.6f}"
                    if isinstance(value, float)
                    else value
                )
            writer.writerow(row)


def save_plots(metrics: list[PayloadMetrics]) -> None:
    payloads = [item.max_payload_bytes for item in metrics]

    figure, axis = plt.subplots(figsize=(9, 5.5))
    axis.plot(
        payloads,
        [item.service_data_percent for item in metrics],
        marker="o",
        linewidth=2,
        label="Service data (header / wire)",
    )
    axis.plot(
        payloads,
        [item.efficiency_percent for item in metrics],
        marker="s",
        linewidth=2,
        label="Efficiency (JPEG / wire)",
    )
    axis.set_xscale("log", base=2)
    axis.set_xticks(payloads)
    axis.set_xticklabels([str(value) for value in payloads])
    axis.set_xlabel("Maximum packet payload, bytes")
    axis.set_ylabel("Share, %")
    axis.set_title("Lab028 packet overhead and efficiency")
    axis.grid(True, alpha=0.3)
    axis.legend()
    figure.tight_layout()
    figure.savefig(OVERHEAD_PLOT_PATH, dpi=160)
    plt.close(figure)

    figure, packet_axis = plt.subplots(figsize=(9, 5.5))
    bitrate_axis = packet_axis.twinx()
    packet_line = packet_axis.plot(
        payloads,
        [item.packets_per_second for item in metrics],
        color="tab:blue",
        marker="o",
        linewidth=2,
        label="Packets/s",
    )
    bitrate_lines = bitrate_axis.plot(
        payloads,
        [item.wire_bitrate_kbps for item in metrics],
        color="tab:red",
        marker="s",
        linewidth=2,
        label="Wire bitrate",
    )
    bitrate_axis.plot(
        payloads,
        [item.jpeg_payload_bitrate_kbps for item in metrics],
        color="tab:green",
        linestyle="--",
        linewidth=2,
        label="JPEG payload bitrate",
    )
    packet_axis.set_xscale("log", base=2)
    packet_axis.set_xticks(payloads)
    packet_axis.set_xticklabels([str(value) for value in payloads])
    packet_axis.set_xlabel("Maximum packet payload, bytes")
    packet_axis.set_ylabel("Packets per second", color="tab:blue")
    bitrate_axis.set_ylabel("Bitrate, kbit/s", color="tab:red")
    packet_axis.set_title("Lab028 packet rate and wire bitrate")
    packet_axis.grid(True, alpha=0.3)
    lines = packet_line + bitrate_lines + bitrate_axis.lines[1:]
    packet_axis.legend(
        lines,
        [line.get_label() for line in lines],
        loc="best",
    )
    figure.tight_layout()
    figure.savefig(TRAFFIC_PLOT_PATH, dpi=160)
    plt.close(figure)


def metrics_table(metrics: list[PayloadMetrics]) -> list[str]:
    lines = [
        (
            "payload | BASE pkt/frame | ROI pkt/frame | all pkt/frame | "
            "max pkt/frame | pkt/s | JPEG kbit/s | headers B/s | "
            "wire kbit/s | service % | efficiency % | packet mean/p95/max"
        ),
        (
            "-------:|---------------:|--------------:|--------------:|"
            "--------------:|------:|------------:|------------:|"
            "------------:|----------:|-------------:|--------------------:"
        ),
    ]
    for item in metrics:
        lines.append(
            f"{item.max_payload_bytes} | "
            f"{item.mean_base_packets_per_frame:.3f} | "
            f"{item.mean_roi_packets_per_frame:.3f} | "
            f"{item.mean_total_packets_per_frame:.3f} | "
            f"{item.max_total_packets_per_frame} | "
            f"{item.packets_per_second:.3f} | "
            f"{item.jpeg_payload_bitrate_kbps:.3f} | "
            f"{item.header_bytes_per_second:.3f} | "
            f"{item.wire_bitrate_kbps:.3f} | "
            f"{item.service_data_percent:.3f} | "
            f"{item.efficiency_percent:.3f} | "
            f"{item.mean_wire_packet_bytes:.1f}/"
            f"{item.p95_wire_packet_bytes:.1f}/"
            f"{item.max_wire_packet_bytes}"
        )
    return lines


def write_report(
    metadata: VideoMetadata,
    composites: list[EncodedComposite],
    metrics: list[PayloadMetrics],
    test_results: list[TestResult],
) -> None:
    source_roi = normalized_roi_to_pixels(
        metadata.width, metadata.height
    )
    base_sizes = [len(item.base_jpeg) for item in composites]
    roi_sizes = [len(item.roi_jpeg) for item in composites]
    lines = [
        "Lab028. Пакетирование синхронного BASE + ROI",
        "",
        "Профиль и исходные данные",
        f"- Видео: {SOURCE_VIDEO_PATH}",
        (
            f"- Источник: {metadata.width}x{metadata.height}, "
            f"{metadata.fps:.6f} fps, {metadata.frame_count} кадров, "
            f"{metadata.duration_seconds:.6f} с."
        ),
        (
            f"- ROI: x={ROI_X_MIN:.2f}...{ROI_X_MAX:.2f}, "
            f"y={ROI_Y_MIN:.2f}...{ROI_Y_MAX:.2f}; "
            f"пиксели x={source_roi[0]}...{source_roi[2]}, "
            f"y={source_roi[1]}...{source_roi[3]}."
        ),
        (
            f"- Синхронные составные кадры: {len(composites)} "
            f"при {COMPOSITE_FPS:.3f} fps."
        ),
        (
            f"- BASE: {BASE_WIDTH}x{BASE_HEIGHT}, grayscale JPEG Q"
            f"{BASE_QUALITY}; средний размер {np.mean(base_sizes):.3f} B, "
            f"min/max {min(base_sizes)}/{max(base_sizes)} B."
        ),
        (
            f"- ROI: {ROI_WIDTH}x{ROI_HEIGHT}, grayscale JPEG Q"
            f"{ROI_QUALITY}; средний размер {np.mean(roi_sizes):.3f} B, "
            f"min/max {min(roi_sizes)}/{max(roi_sizes)} B."
        ),
        (
            "- BASE и ROI формируются из одного source frame и имеют "
            "общий composite_frame_id."
        ),
        "",
        "Бинарный заголовок",
        f"- struct format: {HEADER_FORMAT}",
        f"- Размер: {HEADER_SIZE} байта; network byte order; padding нет.",
        (
            "- Layout: magic[4] @0, version:u8 @4, object_type:u8 @5, "
            "header_size:u16 @6, composite_frame_id:u32 @8, "
            "fragment_index:u16 @12, fragment_count:u16 @14, "
            "payload_length:u16 @16, flags:u16 @18, jpeg_size:u32 @20, "
            "object_crc32:u32 @24, packet_crc32:u32 @28."
        ),
        "- object_type: 1=BASE, 2=ROI; flags зарезервирован и равен 0.",
        "",
        "CRC32",
        (
            "- object_crc32 = zlib.crc32(полный JPEG) & 0xFFFFFFFF; "
            "значение помещается во все фрагменты объекта и проверяется "
            "после полной сборки."
        ),
        (
            "- packet_crc32: сначала поле packet_crc32 заголовка "
            "обнуляется, затем CRC считается как "
            "zlib.crc32(header_with_zero_crc + payload) & 0xFFFFFFFF."
        ),
        "- Таким образом packet CRC защищает все поля заголовка и payload.",
        "",
        "Функциональные проверки",
    ]
    lines.extend(
        f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}"
        for item in test_results
    )
    lines.extend(
        [
            "",
            "Результаты размеров packet payload",
            *metrics_table(metrics),
            "",
            (
                "JPEG payload bitrate одинаков для всех строк, потому что "
                "исходные JPEG не меняются при выборе размера фрагмента."
            ),
            (
                "Wire bitrate включает только JPEG payload и 32-байтные "
                "заголовки каждого пакета."
            ),
            (
                "Wire bitrate пока НЕ включает FEC, преамбулу, "
                "синхронизацию, модуляцию, интервалы, повторные передачи, "
                "команды управления и телеметрию."
            ),
            (
                "Лучший размер packet payload автоматически не выбирается: "
                "таблица показывает только транспортный компромисс."
            ),
            "",
            "Артефакты",
            f"- CSV: {CSV_PATH}",
            f"- Overhead/efficiency: {OVERHEAD_PLOT_PATH}",
            f"- Packet count/wire bitrate: {TRAFFIC_PLOT_PATH}",
            (
                "- Промежуточные JPEG и пакеты сохранялись только в памяти; "
                "бинарные дампы не создавались."
            ),
            "",
        ]
    )
    REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")


def validate_outputs(metrics: list[PayloadMetrics]) -> None:
    if len(metrics) != len(PAYLOAD_LENGTHS):
        raise RuntimeError("not all payload sizes were measured")
    if [item.max_payload_bytes for item in metrics] != list(
        PAYLOAD_LENGTHS
    ):
        raise RuntimeError("payload result order differs")
    if any(
        abs(
            item.service_data_percent
            + item.efficiency_percent
            - 100.0
        ) > 1e-9
        for item in metrics
    ):
        raise RuntimeError("overhead and efficiency do not sum to 100%")
    for path in (
        CSV_PATH,
        REPORT_PATH,
        OVERHEAD_PLOT_PATH,
        TRAFFIC_PLOT_PATH,
    ):
        if not path.exists() or path.stat().st_size <= 0:
            raise RuntimeError(f"missing or empty output: {path}")


def main() -> None:
    print("Lab028: loading and JPEG-encoding the Lab027E profile...")
    metadata, composites = load_video_profile(SOURCE_VIDEO_PATH)
    print(
        f"  source={metadata.width}x{metadata.height}, "
        f"{metadata.fps:.3f} fps, frames={metadata.frame_count}"
    )
    print(f"  synchronous composite frames={len(composites)}")

    print("Running functional transport checks...")
    test_results = run_functional_tests(composites)
    for result in test_results:
        print(f"  PASS {result.name}: {result.detail}")

    print("Measuring packet payload sizes...")
    metrics = [
        calculate_payload_metrics(
            composites,
            metadata.duration_seconds,
            payload_length,
        )
        for payload_length in PAYLOAD_LENGTHS
    ]
    for item in metrics:
        print(
            f"  payload={item.max_payload_bytes:4d} B: "
            f"{item.packets_per_second:.3f} packet/s, "
            f"wire={item.wire_bitrate_kbps:.3f} kbit/s, "
            f"service={item.service_data_percent:.3f}%"
        )

    OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
    save_csv(metrics)
    save_plots(metrics)
    write_report(metadata, composites, metrics, test_results)
    validate_outputs(metrics)
    print(f"CSV: {CSV_PATH}")
    print(f"Report: {REPORT_PATH}")
    print(f"Plots: {OVERHEAD_PLOT_PATH}, {TRAFFIC_PLOT_PATH}")
    print("Lab028 completed successfully.")


if __name__ == "__main__":
    main()
