"""
Lab027C. Динамическое сравнение асинхронного и синхронного BASE + ROI.

Мини-лабораторная выполняет два детерминированных прохода по одному
исходному видео:

1. Первый проход измеряет размеры JPEG BASE и ROI для четырёх
   фиксированных профилей.
2. Второй проход повторяет расписание, проверяет совпадение размеров
   JPEG и создаёт preview с окончательными измеренными битрейтами.

Асинхронный профиль хранит отдельные состояния BASE и ROI. В трёх
синхронных профилях обе части формируются из одного исходного кадра и
атомарно заменяют отображаемый составной кадр.

JPEG кодируются и декодируются только в памяти. Скрипт не изменяет
существующие лабораторные и их результаты.
"""

from __future__ import annotations

import csv
from dataclasses import dataclass
from pathlib import Path

import cv2
import numpy as np


SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4")

OUTPUT_DIRECTORY = Path("data/processed/lab027c")
CSV_PATH = OUTPUT_DIRECTORY / "lab027c_preview_profiles.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab027c_preview_report.txt"

PREVIEW_DIRECTORY = Path("data/raw/lab027c_previews")
MP4_PREVIEW_PATH = (
    PREVIEW_DIRECTORY / "lab027c_synchronous_roi_preview.mp4"
)
AVI_PREVIEW_PATH = (
    PREVIEW_DIRECTORY / "lab027c_synchronous_roi_preview.avi"
)

UPDATE_MODE_ASYNCHRONOUS = "asynchronous"
UPDATE_MODE_SYNCHRONOUS = "synchronous"

OUTPUT_FPS = 30.0
PANEL_WIDTH = 640
PANEL_HEIGHT = 360
OUTPUT_WIDTH = PANEL_WIDTH * 2
OUTPUT_HEIGHT = PANEL_HEIGHT * 2

ROI_X_MIN = 0.20
ROI_X_MAX = 0.80
ROI_Y_MIN = 0.42
ROI_Y_MAX = 1.00

FRAME_TIME_EPSILON_SECONDS = 1e-9
NEW_LABEL_DURATION_SECONDS = 0.15
SYNCHRONOUS_PERIOD_SECONDS = 0.5
NOT_APPLICABLE = "N/A"

CSV_FIELD_NAMES = [
    "profile_name",
    "update_mode",
    "base_width",
    "base_height",
    "base_fps",
    "base_quality",
    "roi_width",
    "roi_height",
    "roi_fps",
    "roi_quality",
    "source_duration_s",
    "selected_base_frames",
    "selected_roi_frames",
    "synchronized_composite_frames",
    "total_base_bytes",
    "total_roi_bytes",
    "total_payload_bytes",
    "mean_base_frame_bytes",
    "mean_roi_frame_bytes",
    "mean_composite_frame_bytes",
    "p95_composite_frame_bytes",
    "max_composite_frame_bytes",
    "base_bitrate_kbps",
    "roi_bitrate_kbps",
    "total_payload_bitrate_kbps",
    "timestamp_mismatch_count",
    "frame_id_mismatch_count",
]


@dataclass(frozen=True)
class PreviewProfile:
    """
    Описывает один из четырёх фиксированных профилей Lab027C.
    """

    profile_name: str
    update_mode: str
    base_width: int
    base_height: int
    base_fps: float
    base_quality: int
    roi_width: int
    roi_height: int
    roi_fps: float
    roi_quality: int


@dataclass
class AsyncState:
    """
    Хранит независимые временные состояния асинхронных BASE и ROI.
    """

    latest_base: np.ndarray | None = None
    latest_roi: np.ndarray | None = None
    next_base_time: float = 0.0
    next_roi_time: float = 0.0
    last_base_update_time: float = 0.0
    last_roi_update_time: float = 0.0
    base_frame_id: int = -1
    roi_frame_id: int = -1
    base_source_frame_index: int = -1
    roi_source_frame_index: int = -1
    base_timestamp: float = 0.0
    roi_timestamp: float = 0.0
    base_update_count: int = 0
    roi_update_count: int = 0


@dataclass
class SyncState:
    """
    Хранит единое атомарное состояние синхронного составного кадра.
    """

    latest_base: np.ndarray | None = None
    latest_roi: np.ndarray | None = None
    next_composite_time: float = 0.0
    last_composite_update_time: float = 0.0
    composite_frame_id: int = -1
    source_frame_index: int = -1
    timestamp: float = 0.0
    base_update_count: int = 0
    roi_update_count: int = 0
    synchronized_update_count: int = 0
    timestamp_mismatch_count: int = 0
    frame_id_mismatch_count: int = 0


@dataclass(frozen=True)
class ProfileStatistics:
    """
    Хранит полные измеренные параметры одного preview-профиля.
    """

    profile_name: str
    update_mode: str
    base_width: int
    base_height: int
    base_fps: float
    base_quality: int
    roi_width: int
    roi_height: int
    roi_fps: float
    roi_quality: int
    source_duration_s: float
    selected_base_frames: int
    selected_roi_frames: int
    synchronized_composite_frames: int | None
    total_base_bytes: int
    total_roi_bytes: int
    total_payload_bytes: int
    mean_base_frame_bytes: float
    mean_roi_frame_bytes: float
    mean_composite_frame_bytes: float
    p95_composite_frame_bytes: float
    max_composite_frame_bytes: int
    base_bitrate_kbps: float
    roi_bitrate_kbps: float
    total_payload_bitrate_kbps: float
    timestamp_mismatch_count: int | None
    frame_id_mismatch_count: int | None


Measurements = dict[str, dict[str, list[int]]]
ProfileState = AsyncState | SyncState


def read_video_metadata(
    source_path: Path,
) -> tuple[int, int, float, int, float, int]:
    """
    Читает и проверяет параметры исходного видео.
    """

    if not source_path.exists():
        raise RuntimeError(
            f"Исходный видеофайл отсутствует: {source_path}"
        )

    capture = cv2.VideoCapture(str(source_path))

    if not capture.isOpened():
        raise RuntimeError(
            f"OpenCV не смог открыть видео: {source_path}"
        )

    try:
        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))
        frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
    finally:
        capture.release()

    if width <= 0 or height <= 0:
        raise RuntimeError(
            "OpenCV вернул некорректное разрешение видео."
        )

    if fps <= 0.0:
        raise RuntimeError("FPS исходного видео равен нулю.")

    if frame_count <= 0:
        raise RuntimeError("Число кадров исходного видео равно нулю.")

    duration_seconds = frame_count / fps
    file_size_bytes = source_path.stat().st_size

    if duration_seconds <= 0.0 or file_size_bytes <= 0:
        raise RuntimeError(
            "Длительность или размер исходного видео некорректны."
        )

    return (
        width,
        height,
        fps,
        frame_count,
        duration_seconds,
        file_size_bytes,
    )


def build_profiles() -> list[PreviewProfile]:
    """
    Создаёт ровно четыре согласованных профиля Lab027C.
    """

    profiles = [
        PreviewProfile(
            profile_name="async_reference",
            update_mode=UPDATE_MODE_ASYNCHRONOUS,
            base_width=240,
            base_height=135,
            base_fps=1.0,
            base_quality=25,
            roi_width=320,
            roi_height=180,
            roi_fps=2.0,
            roi_quality=35,
        ),
        PreviewProfile(
            profile_name="sync_base160_q25_roi320_q35",
            update_mode=UPDATE_MODE_SYNCHRONOUS,
            base_width=160,
            base_height=90,
            base_fps=2.0,
            base_quality=25,
            roi_width=320,
            roi_height=180,
            roi_fps=2.0,
            roi_quality=35,
        ),
        PreviewProfile(
            profile_name="sync_base240_q20_roi320_q30",
            update_mode=UPDATE_MODE_SYNCHRONOUS,
            base_width=240,
            base_height=135,
            base_fps=2.0,
            base_quality=20,
            roi_width=320,
            roi_height=180,
            roi_fps=2.0,
            roi_quality=30,
        ),
        PreviewProfile(
            profile_name="sync_base240_q25_roi320_q35",
            update_mode=UPDATE_MODE_SYNCHRONOUS,
            base_width=240,
            base_height=135,
            base_fps=2.0,
            base_quality=25,
            roi_width=320,
            roi_height=180,
            roi_fps=2.0,
            roi_quality=35,
        ),
    ]

    if len(profiles) != 4:
        raise RuntimeError(
            "Lab027C должна содержать ровно четыре профиля."
        )

    if len({profile.profile_name for profile in profiles}) != 4:
        raise RuntimeError("Имена профилей Lab027C не уникальны.")

    return profiles


def normalized_roi_to_pixels(
    width: int,
    height: int,
) -> tuple[int, int, int, int]:
    """
    Переводит нормализованные координаты ROI Lab027 в пиксели.
    """

    if width <= 0 or height <= 0:
        raise ValueError("Размер кадра должен быть положительным.")

    x_min = int(round(width * ROI_X_MIN))
    x_max = int(round(width * ROI_X_MAX))
    y_min = int(round(height * ROI_Y_MIN))
    y_max = int(round(height * ROI_Y_MAX))

    x_min = min(max(x_min, 0), width - 1)
    x_max = min(max(x_max, x_min + 1), width)
    y_min = min(max(y_min, 0), height - 1)
    y_max = min(max(y_max, y_min + 1), height)

    return x_min, y_min, x_max, y_max


def should_update(
    current_time: float,
    next_update_time: float,
) -> bool:
    """
    Проверяет наступление времени очередного обновления.
    """

    return (
        current_time + FRAME_TIME_EPSILON_SECONDS
        >= next_update_time
    )


def encode_decode_jpeg(
    gray_frame: np.ndarray,
    jpeg_quality: int,
) -> tuple[int, np.ndarray]:
    """
    Кодирует grayscale JPEG в памяти и декодирует его обратно.
    """

    if gray_frame.ndim != 2:
        raise RuntimeError(
            "JPEG Lab027C должен получать grayscale-кадр."
        )

    encoding_ok, encoded = cv2.imencode(
        ".jpg",
        gray_frame,
        [cv2.IMWRITE_JPEG_QUALITY, jpeg_quality],
    )

    if not encoding_ok or encoded is None or encoded.size == 0:
        raise RuntimeError("OpenCV не смог закодировать JPEG.")

    decoded = cv2.imdecode(
        encoded,
        cv2.IMREAD_GRAYSCALE,
    )

    if decoded is None or decoded.shape != gray_frame.shape:
        raise RuntimeError(
            "Декодированный JPEG имеет некорректный размер."
        )

    return int(encoded.size), decoded


def encode_base(
    source_frame: np.ndarray,
    profile: PreviewProfile,
) -> tuple[int, np.ndarray]:
    """
    Формирует JPEG BASE из текущего исходного кадра.
    """

    source_gray = cv2.cvtColor(
        source_frame,
        cv2.COLOR_BGR2GRAY,
    )
    resized = cv2.resize(
        source_gray,
        (profile.base_width, profile.base_height),
        interpolation=cv2.INTER_AREA,
    )

    return encode_decode_jpeg(
        resized,
        profile.base_quality,
    )


def encode_roi(
    source_frame: np.ndarray,
    source_roi: tuple[int, int, int, int],
    profile: PreviewProfile,
) -> tuple[int, np.ndarray]:
    """
    Формирует JPEG ROI из того же текущего исходного кадра.
    """

    x_min, y_min, x_max, y_max = source_roi
    roi_bgr = source_frame[y_min:y_max, x_min:x_max]

    if roi_bgr.size == 0:
        raise RuntimeError("Вырезана пустая ROI.")

    roi_gray = cv2.cvtColor(
        roi_bgr,
        cv2.COLOR_BGR2GRAY,
    )
    resized = cv2.resize(
        roi_gray,
        (profile.roi_width, profile.roi_height),
        interpolation=cv2.INTER_AREA,
    )

    return encode_decode_jpeg(
        resized,
        profile.roi_quality,
    )


def create_measurements(
    profiles: list[PreviewProfile],
) -> Measurements:
    """
    Создаёт пустые списки размеров для одного прохода.
    """

    return {
        profile.profile_name: {
            "base": [],
            "roi": [],
            "composite": [],
        }
        for profile in profiles
    }


def create_states(
    profiles: list[PreviewProfile],
) -> list[ProfileState]:
    """
    Создаёт чистые временные состояния всех профилей.
    """

    states: list[ProfileState] = []

    for profile in profiles:
        if profile.update_mode == UPDATE_MODE_ASYNCHRONOUS:
            states.append(AsyncState())
        elif profile.update_mode == UPDATE_MODE_SYNCHRONOUS:
            states.append(SyncState())
        else:
            raise RuntimeError(
                f"Неизвестный режим: {profile.update_mode}"
            )

    return states


def update_async_state(
    source_frame: np.ndarray,
    source_roi: tuple[int, int, int, int],
    source_frame_index: int,
    current_time: float,
    profile: PreviewProfile,
    state: AsyncState,
    measurements: dict[str, list[int]],
) -> tuple[bool, bool]:
    """
    Независимо обновляет BASE и ROI асинхронного reference.
    """

    base_updated = False
    roi_updated = False
    event_payload_bytes = 0

    if should_update(current_time, state.next_base_time):
        base_size, decoded_base = encode_base(
            source_frame,
            profile,
        )
        state.latest_base = decoded_base
        state.base_frame_id += 1
        state.base_source_frame_index = source_frame_index
        state.base_timestamp = current_time
        state.last_base_update_time = current_time
        state.next_base_time += 1.0 / profile.base_fps
        state.base_update_count += 1
        measurements["base"].append(base_size)
        event_payload_bytes += base_size
        base_updated = True

    if should_update(current_time, state.next_roi_time):
        roi_size, decoded_roi = encode_roi(
            source_frame,
            source_roi,
            profile,
        )
        state.latest_roi = decoded_roi
        state.roi_frame_id += 1
        state.roi_source_frame_index = source_frame_index
        state.roi_timestamp = current_time
        state.last_roi_update_time = current_time
        state.next_roi_time += 1.0 / profile.roi_fps
        state.roi_update_count += 1
        measurements["roi"].append(roi_size)
        event_payload_bytes += roi_size
        roi_updated = True

    if event_payload_bytes > 0:
        measurements["composite"].append(event_payload_bytes)

    return base_updated, roi_updated


def update_sync_state(
    source_frame: np.ndarray,
    source_roi: tuple[int, int, int, int],
    source_frame_index: int,
    current_time: float,
    profile: PreviewProfile,
    state: SyncState,
    measurements: dict[str, list[int]],
) -> bool:
    """
    Атомарно обновляет обе части синхронного составного кадра.
    """

    if not should_update(
        current_time,
        state.next_composite_time,
    ):
        return False

    next_composite_id = state.composite_frame_id + 1

    # Метаданные обеих частей назначаются до кодирования из одного
    # и того же исходного кадра.
    base_source_frame_index = source_frame_index
    roi_source_frame_index = source_frame_index
    base_timestamp = current_time
    roi_timestamp = current_time
    base_composite_id = next_composite_id
    roi_composite_id = next_composite_id

    base_size, decoded_base = encode_base(
        source_frame,
        profile,
    )
    roi_size, decoded_roi = encode_roi(
        source_frame,
        source_roi,
        profile,
    )

    if base_timestamp != roi_timestamp:
        state.timestamp_mismatch_count += 1

    if (
        base_source_frame_index != roi_source_frame_index
        or base_composite_id != roi_composite_id
    ):
        state.frame_id_mismatch_count += 1

    # Отображаемое состояние заменяется только после готовности
    # обоих декодированных JPEG.
    state.latest_base = decoded_base
    state.latest_roi = decoded_roi
    state.composite_frame_id = next_composite_id
    state.source_frame_index = source_frame_index
    state.timestamp = current_time
    state.last_composite_update_time = current_time
    state.next_composite_time += SYNCHRONOUS_PERIOD_SECONDS
    state.base_update_count += 1
    state.roi_update_count += 1
    state.synchronized_update_count += 1

    measurements["base"].append(base_size)
    measurements["roi"].append(roi_size)
    measurements["composite"].append(base_size + roi_size)

    return True


def process_first_pass(
    source_path: Path,
    profiles: list[PreviewProfile],
    source_width: int,
    source_height: int,
    source_fps: float,
    expected_frame_count: int,
) -> tuple[Measurements, list[ProfileState]]:
    """
    Измеряет размеры JPEG без создания preview-видео.
    """

    measurements = create_measurements(profiles)
    states = create_states(profiles)
    source_roi = normalized_roi_to_pixels(
        source_width,
        source_height,
    )
    capture = cv2.VideoCapture(str(source_path))

    if not capture.isOpened():
        raise RuntimeError(
            f"OpenCV не смог открыть видео: {source_path}"
        )

    frame_index = 0

    try:
        while True:
            frame_read, source_frame = capture.read()

            if not frame_read:
                break

            if source_frame is None:
                raise RuntimeError(
                    f"Получен пустой кадр {frame_index}."
                )

            if (
                source_frame.shape[1] != source_width
                or source_frame.shape[0] != source_height
            ):
                raise RuntimeError(
                    "Размер кадра отличается от метаданных."
                )

            current_time = frame_index / source_fps

            for profile, state in zip(profiles, states):
                profile_measurements = measurements[
                    profile.profile_name
                ]

                if isinstance(state, AsyncState):
                    update_async_state(
                        source_frame,
                        source_roi,
                        frame_index,
                        current_time,
                        profile,
                        state,
                        profile_measurements,
                    )
                else:
                    update_sync_state(
                        source_frame,
                        source_roi,
                        frame_index,
                        current_time,
                        profile,
                        state,
                        profile_measurements,
                    )

            frame_index += 1

            if (
                frame_index % 100 == 0
                or frame_index == expected_frame_count
            ):
                print(
                    f"  First pass frames: "
                    f"{frame_index}/{expected_frame_count}"
                )
    finally:
        capture.release()

    if frame_index != expected_frame_count:
        raise RuntimeError(
            "Первый проход прочитал некорректное число кадров."
        )

    return measurements, states


def calculate_statistics(
    profiles: list[PreviewProfile],
    measurements: Measurements,
    states: list[ProfileState],
    source_duration_seconds: float,
) -> list[ProfileStatistics]:
    """
    Рассчитывает итоговые фактические битрейты и размеры обновлений.
    """

    statistics: list[ProfileStatistics] = []

    for profile, state in zip(profiles, states):
        profile_measurements = measurements[
            profile.profile_name
        ]
        base_sizes = profile_measurements["base"]
        roi_sizes = profile_measurements["roi"]
        composite_sizes = profile_measurements["composite"]

        if not base_sizes or not roi_sizes or not composite_sizes:
            raise RuntimeError(
                f"Пустые измерения: {profile.profile_name}"
            )

        total_base_bytes = int(sum(base_sizes))
        total_roi_bytes = int(sum(roi_sizes))
        total_payload_bytes = (
            total_base_bytes + total_roi_bytes
        )
        base_bitrate_kbps = (
            total_base_bytes
            * 8.0
            / source_duration_seconds
            / 1000.0
        )
        roi_bitrate_kbps = (
            total_roi_bytes
            * 8.0
            / source_duration_seconds
            / 1000.0
        )
        total_payload_bitrate_kbps = (
            total_payload_bytes
            * 8.0
            / source_duration_seconds
            / 1000.0
        )

        if not np.isclose(
            total_payload_bitrate_kbps,
            base_bitrate_kbps + roi_bitrate_kbps,
            rtol=0.0,
            atol=1e-12,
        ):
            raise RuntimeError(
                "Суммарный битрейт не равен BASE + ROI."
            )

        if isinstance(state, AsyncState):
            synchronized_frames = None
            timestamp_mismatch_count = None
            frame_id_mismatch_count = None
        else:
            synchronized_frames = (
                state.synchronized_update_count
            )
            timestamp_mismatch_count = (
                state.timestamp_mismatch_count
            )
            frame_id_mismatch_count = (
                state.frame_id_mismatch_count
            )

            if (
                len(base_sizes) != len(roi_sizes)
                or len(base_sizes) != synchronized_frames
            ):
                raise RuntimeError(
                    "Число синхронных обновлений не совпало."
                )

            if (
                timestamp_mismatch_count != 0
                or frame_id_mismatch_count != 0
            ):
                raise RuntimeError(
                    "Обнаружено рассогласование sync-профиля."
                )

        composite_array = np.asarray(
            composite_sizes,
            dtype=np.float64,
        )

        statistics.append(
            ProfileStatistics(
                profile_name=profile.profile_name,
                update_mode=profile.update_mode,
                base_width=profile.base_width,
                base_height=profile.base_height,
                base_fps=profile.base_fps,
                base_quality=profile.base_quality,
                roi_width=profile.roi_width,
                roi_height=profile.roi_height,
                roi_fps=profile.roi_fps,
                roi_quality=profile.roi_quality,
                source_duration_s=source_duration_seconds,
                selected_base_frames=len(base_sizes),
                selected_roi_frames=len(roi_sizes),
                synchronized_composite_frames=(
                    synchronized_frames
                ),
                total_base_bytes=total_base_bytes,
                total_roi_bytes=total_roi_bytes,
                total_payload_bytes=total_payload_bytes,
                mean_base_frame_bytes=float(
                    np.mean(
                        np.asarray(
                            base_sizes,
                            dtype=np.float64,
                        )
                    )
                ),
                mean_roi_frame_bytes=float(
                    np.mean(
                        np.asarray(
                            roi_sizes,
                            dtype=np.float64,
                        )
                    )
                ),
                mean_composite_frame_bytes=float(
                    np.mean(composite_array)
                ),
                p95_composite_frame_bytes=float(
                    np.percentile(composite_array, 95)
                ),
                max_composite_frame_bytes=int(
                    np.max(composite_array)
                ),
                base_bitrate_kbps=base_bitrate_kbps,
                roi_bitrate_kbps=roi_bitrate_kbps,
                total_payload_bitrate_kbps=(
                    total_payload_bitrate_kbps
                ),
                timestamp_mismatch_count=(
                    timestamp_mismatch_count
                ),
                frame_id_mismatch_count=(
                    frame_id_mismatch_count
                ),
            )
        )

    if len(statistics) != 4:
        raise RuntimeError(
            "Создано не четыре результата Lab027C."
        )

    return statistics


def reconstruct_panel(
    latest_base: np.ndarray | None,
    latest_roi: np.ndarray | None,
    panel_roi: tuple[int, int, int, int],
) -> np.ndarray:
    """
    Восстанавливает составную grayscale-панель размером 640x360.
    """

    if latest_base is None or latest_roi is None:
        raise RuntimeError(
            "BASE или ROI ещё не инициализированы."
        )

    reconstructed = cv2.resize(
        latest_base,
        (PANEL_WIDTH, PANEL_HEIGHT),
        interpolation=cv2.INTER_LINEAR,
    )
    x_min, y_min, x_max, y_max = panel_roi
    resized_roi = cv2.resize(
        latest_roi,
        (x_max - x_min, y_max - y_min),
        interpolation=cv2.INTER_LINEAR,
    )
    reconstructed[y_min:y_max, x_min:x_max] = resized_roi

    return reconstructed


def draw_text_line(
    image: np.ndarray,
    text: str,
    y_position: int,
    color: tuple[int, int, int] = (255, 255, 255),
) -> None:
    """
    Рисует одну ASCII-строку служебной информации.
    """

    cv2.putText(
        image,
        text,
        (9, y_position),
        cv2.FONT_HERSHEY_SIMPLEX,
        0.41,
        color,
        1,
        cv2.LINE_AA,
    )


def prepare_panel_background(
    reconstructed_gray: np.ndarray,
    panel_roi: tuple[int, int, int, int],
) -> np.ndarray:
    """
    Преобразует панель в BGR, рисует ROI и фон подписей.
    """

    panel = cv2.cvtColor(
        reconstructed_gray,
        cv2.COLOR_GRAY2BGR,
    )
    cv2.rectangle(
        panel,
        (panel_roi[0], panel_roi[1]),
        (panel_roi[2] - 1, panel_roi[3] - 1),
        (0, 255, 255),
        2,
    )

    overlay = panel.copy()
    cv2.rectangle(
        overlay,
        (0, 0),
        (PANEL_WIDTH - 1, 158),
        (0, 0, 0),
        thickness=-1,
    )
    cv2.addWeighted(
        overlay,
        0.74,
        panel,
        0.26,
        0.0,
        panel,
    )

    return panel


def draw_async_information(
    reconstructed_gray: np.ndarray,
    profile: PreviewProfile,
    statistics: ProfileStatistics,
    state: AsyncState,
    current_time: float,
    panel_roi: tuple[int, int, int, int],
) -> np.ndarray:
    """
    Добавляет отдельные ID, source frame и возраст BASE/ROI.
    """

    panel = prepare_panel_background(
        reconstructed_gray,
        panel_roi,
    )
    base_age = max(
        0.0,
        current_time - state.last_base_update_time,
    )
    roi_age = max(
        0.0,
        current_time - state.last_roi_update_time,
    )

    draw_text_line(
        panel,
        "ASYNCHRONOUS | async_reference",
        18,
    )
    draw_text_line(
        panel,
        (
            f"BASE {profile.base_width}x{profile.base_height} "
            f"{profile.base_fps:g}fps Q{profile.base_quality}"
        ),
        37,
    )
    draw_text_line(
        panel,
        (
            f"ROI {profile.roi_width}x{profile.roi_height} "
            f"{profile.roi_fps:g}fps Q{profile.roi_quality}"
        ),
        56,
    )
    draw_text_line(
        panel,
        (
            f"payload={statistics.total_payload_bitrate_kbps:.3f} "
            f"kbps | video t={current_time:.3f}s"
        ),
        75,
    )
    draw_text_line(
        panel,
        (
            f"BASE ID={state.base_frame_id} "
            f"src={state.base_source_frame_index} "
            f"age={base_age:.3f}s"
        ),
        94,
    )
    draw_text_line(
        panel,
        (
            f"ROI ID={state.roi_frame_id} "
            f"src={state.roi_source_frame_index} "
            f"age={roi_age:.3f}s"
        ),
        113,
    )

    update_labels: list[str] = []

    if base_age <= NEW_LABEL_DURATION_SECONDS:
        update_labels.append("NEW BASE")

    if roi_age <= NEW_LABEL_DURATION_SECONDS:
        update_labels.append("NEW ROI")

    if update_labels:
        draw_text_line(
            panel,
            " | ".join(update_labels),
            137,
            color=(0, 255, 0),
        )

    return panel


def draw_sync_information(
    reconstructed_gray: np.ndarray,
    profile: PreviewProfile,
    statistics: ProfileStatistics,
    state: SyncState,
    current_time: float,
    panel_roi: tuple[int, int, int, int],
) -> np.ndarray:
    """
    Добавляет единые ID, source frame, timestamp и возраст composite.
    """

    panel = prepare_panel_background(
        reconstructed_gray,
        panel_roi,
    )
    composite_age = max(
        0.0,
        current_time - state.last_composite_update_time,
    )

    draw_text_line(
        panel,
        f"SYNCHRONOUS | {profile.profile_name}",
        18,
    )
    draw_text_line(
        panel,
        (
            f"BASE {profile.base_width}x{profile.base_height} "
            f"2fps Q{profile.base_quality}"
        ),
        37,
    )
    draw_text_line(
        panel,
        (
            f"ROI {profile.roi_width}x{profile.roi_height} "
            f"2fps Q{profile.roi_quality}"
        ),
        56,
    )
    draw_text_line(
        panel,
        (
            f"payload={statistics.total_payload_bitrate_kbps:.3f} "
            f"kbps | video t={current_time:.3f}s"
        ),
        75,
    )
    draw_text_line(
        panel,
        (
            f"COMPOSITE ID={state.composite_frame_id} "
            f"source frame={state.source_frame_index}"
        ),
        94,
    )
    draw_text_line(
        panel,
        (
            f"timestamp={state.timestamp:.3f}s "
            f"age={composite_age:.3f}s"
        ),
        113,
    )

    if composite_age <= NEW_LABEL_DURATION_SECONDS:
        draw_text_line(
            panel,
            "NEW COMPOSITE",
            137,
            color=(0, 255, 0),
        )

    return panel


def compose_grid(panels: list[np.ndarray]) -> np.ndarray:
    """
    Объединяет четыре панели в сетку 2x2 размером 1280x720.
    """

    if len(panels) != 4:
        raise RuntimeError(
            "Для preview требуется ровно четыре панели."
        )

    for panel in panels:
        if panel.shape != (PANEL_HEIGHT, PANEL_WIDTH, 3):
            raise RuntimeError(
                f"Некорректный размер панели: {panel.shape}"
            )

    grid = np.vstack(
        (
            np.hstack((panels[0], panels[1])),
            np.hstack((panels[2], panels[3])),
        )
    )

    if grid.shape != (OUTPUT_HEIGHT, OUTPUT_WIDTH, 3):
        raise RuntimeError(
            f"Некорректный размер сетки: {grid.shape}"
        )

    return grid


def open_preview_writer() -> tuple[cv2.VideoWriter, Path, bool]:
    """
    Открывает MP4/mp4v writer либо разрешённый fallback AVI/MJPG.
    """

    mp4_writer = cv2.VideoWriter(
        str(MP4_PREVIEW_PATH),
        cv2.VideoWriter_fourcc(*"mp4v"),
        OUTPUT_FPS,
        (OUTPUT_WIDTH, OUTPUT_HEIGHT),
        True,
    )

    if mp4_writer.isOpened():
        return mp4_writer, MP4_PREVIEW_PATH, False

    mp4_writer.release()

    if MP4_PREVIEW_PATH.exists():
        MP4_PREVIEW_PATH.unlink()

    avi_writer = cv2.VideoWriter(
        str(AVI_PREVIEW_PATH),
        cv2.VideoWriter_fourcc(*"MJPG"),
        OUTPUT_FPS,
        (OUTPUT_WIDTH, OUTPUT_HEIGHT),
        True,
    )

    if not avi_writer.isOpened():
        avi_writer.release()

        if AVI_PREVIEW_PATH.exists():
            AVI_PREVIEW_PATH.unlink()

        raise RuntimeError(
            "OpenCV не смог открыть MP4 или AVI writer."
        )

    return avi_writer, AVI_PREVIEW_PATH, True


def write_preview(
    source_path: Path,
    profiles: list[PreviewProfile],
    statistics: list[ProfileStatistics],
    source_width: int,
    source_height: int,
    source_fps: float,
    expected_frame_count: int,
) -> tuple[
    Path,
    bool,
    int,
    Measurements,
    list[ProfileState],
]:
    """
    Выполняет второй проход и записывает preview с итоговым bitrate.
    """

    PREVIEW_DIRECTORY.mkdir(
        parents=True,
        exist_ok=True,
    )
    statistics_by_name = {
        item.profile_name: item
        for item in statistics
    }
    measurements = create_measurements(profiles)
    states = create_states(profiles)
    source_roi = normalized_roi_to_pixels(
        source_width,
        source_height,
    )
    panel_roi = normalized_roi_to_pixels(
        PANEL_WIDTH,
        PANEL_HEIGHT,
    )
    capture = cv2.VideoCapture(str(source_path))

    if not capture.isOpened():
        raise RuntimeError(
            f"OpenCV не смог открыть видео: {source_path}"
        )

    writer, preview_path, fallback_used = open_preview_writer()
    frame_index = 0

    try:
        while True:
            frame_read, source_frame = capture.read()

            if not frame_read:
                break

            if source_frame is None:
                raise RuntimeError(
                    f"Получен пустой кадр {frame_index}."
                )

            current_time = frame_index / source_fps
            panels: list[np.ndarray] = []

            for profile, state in zip(profiles, states):
                profile_measurements = measurements[
                    profile.profile_name
                ]
                profile_statistics = statistics_by_name[
                    profile.profile_name
                ]

                if isinstance(state, AsyncState):
                    update_async_state(
                        source_frame,
                        source_roi,
                        frame_index,
                        current_time,
                        profile,
                        state,
                        profile_measurements,
                    )
                    reconstructed = reconstruct_panel(
                        state.latest_base,
                        state.latest_roi,
                        panel_roi,
                    )
                    panels.append(
                        draw_async_information(
                            reconstructed,
                            profile,
                            profile_statistics,
                            state,
                            current_time,
                            panel_roi,
                        )
                    )
                else:
                    update_sync_state(
                        source_frame,
                        source_roi,
                        frame_index,
                        current_time,
                        profile,
                        state,
                        profile_measurements,
                    )
                    reconstructed = reconstruct_panel(
                        state.latest_base,
                        state.latest_roi,
                        panel_roi,
                    )
                    panels.append(
                        draw_sync_information(
                            reconstructed,
                            profile,
                            profile_statistics,
                            state,
                            current_time,
                            panel_roi,
                        )
                    )

            writer.write(compose_grid(panels))
            frame_index += 1

            if (
                frame_index % 100 == 0
                or frame_index == expected_frame_count
            ):
                print(
                    f"  Second pass frames: "
                    f"{frame_index}/{expected_frame_count}"
                )
    except Exception:
        capture.release()
        writer.release()

        if preview_path.exists():
            preview_path.unlink()

        raise
    finally:
        capture.release()
        writer.release()

    if frame_index != expected_frame_count:
        if preview_path.exists():
            preview_path.unlink()

        raise RuntimeError(
            "Второй проход записал некорректное число кадров."
        )

    return (
        preview_path,
        fallback_used,
        frame_index,
        measurements,
        states,
    )


def compare_passes(
    profiles: list[PreviewProfile],
    first_measurements: Measurements,
    second_measurements: Measurements,
    first_states: list[ProfileState],
    second_states: list[ProfileState],
) -> None:
    """
    Проверяет точное совпадение размеров JPEG и числа обновлений.
    """

    for profile, first_state, second_state in zip(
        profiles,
        first_states,
        second_states,
    ):
        profile_name = profile.profile_name

        for stream_name in ["base", "roi", "composite"]:
            if (
                first_measurements[profile_name][stream_name]
                != second_measurements[profile_name][stream_name]
            ):
                raise RuntimeError(
                    "Размеры JPEG двух проходов не совпали: "
                    f"{profile_name}, {stream_name}."
                )

        if isinstance(first_state, AsyncState):
            if not isinstance(second_state, AsyncState):
                raise RuntimeError(
                    "Тип состояния async изменился между проходами."
                )

            if (
                first_state.base_update_count
                != second_state.base_update_count
                or first_state.roi_update_count
                != second_state.roi_update_count
            ):
                raise RuntimeError(
                    "Число async-обновлений между проходами различно."
                )
        else:
            if not isinstance(second_state, SyncState):
                raise RuntimeError(
                    "Тип состояния sync изменился между проходами."
                )

            if (
                first_state.synchronized_update_count
                != second_state.synchronized_update_count
                or first_state.timestamp_mismatch_count
                != second_state.timestamp_mismatch_count
                or first_state.frame_id_mismatch_count
                != second_state.frame_id_mismatch_count
            ):
                raise RuntimeError(
                    "Sync-проверка двух проходов не совпала."
                )


def optional_integer_text(value: int | None) -> str:
    """
    Представляет целое значение либо N/A для CSV и отчёта.
    """

    return NOT_APPLICABLE if value is None else str(value)


def save_csv(statistics: list[ProfileStatistics]) -> None:
    """
    Сохраняет четыре полные строки измерений в UTF-8 CSV.
    """

    with CSV_PATH.open(
        "w",
        encoding="utf-8",
        newline="",
    ) as csv_file:
        writer = csv.DictWriter(
            csv_file,
            fieldnames=CSV_FIELD_NAMES,
        )
        writer.writeheader()

        for item in statistics:
            writer.writerow(
                {
                    "profile_name": item.profile_name,
                    "update_mode": item.update_mode,
                    "base_width": item.base_width,
                    "base_height": item.base_height,
                    "base_fps": f"{item.base_fps:.6f}",
                    "base_quality": item.base_quality,
                    "roi_width": item.roi_width,
                    "roi_height": item.roi_height,
                    "roi_fps": f"{item.roi_fps:.6f}",
                    "roi_quality": item.roi_quality,
                    "source_duration_s": (
                        f"{item.source_duration_s:.6f}"
                    ),
                    "selected_base_frames": (
                        item.selected_base_frames
                    ),
                    "selected_roi_frames": (
                        item.selected_roi_frames
                    ),
                    "synchronized_composite_frames": (
                        optional_integer_text(
                            item.synchronized_composite_frames
                        )
                    ),
                    "total_base_bytes": item.total_base_bytes,
                    "total_roi_bytes": item.total_roi_bytes,
                    "total_payload_bytes": (
                        item.total_payload_bytes
                    ),
                    "mean_base_frame_bytes": (
                        f"{item.mean_base_frame_bytes:.3f}"
                    ),
                    "mean_roi_frame_bytes": (
                        f"{item.mean_roi_frame_bytes:.3f}"
                    ),
                    "mean_composite_frame_bytes": (
                        f"{item.mean_composite_frame_bytes:.3f}"
                    ),
                    "p95_composite_frame_bytes": (
                        f"{item.p95_composite_frame_bytes:.3f}"
                    ),
                    "max_composite_frame_bytes": (
                        item.max_composite_frame_bytes
                    ),
                    "base_bitrate_kbps": (
                        f"{item.base_bitrate_kbps:.6f}"
                    ),
                    "roi_bitrate_kbps": (
                        f"{item.roi_bitrate_kbps:.6f}"
                    ),
                    "total_payload_bitrate_kbps": (
                        f"{item.total_payload_bitrate_kbps:.6f}"
                    ),
                    "timestamp_mismatch_count": (
                        optional_integer_text(
                            item.timestamp_mismatch_count
                        )
                    ),
                    "frame_id_mismatch_count": (
                        optional_integer_text(
                            item.frame_id_mismatch_count
                        )
                    ),
                }
            )


def format_statistics_line(item: ProfileStatistics) -> str:
    """
    Формирует полную строку профиля для текстового отчёта.
    """

    return (
        f"{item.profile_name}: mode={item.update_mode}, "
        f"BASE={item.base_width}x{item.base_height}, "
        f"{item.base_fps:.3f} fps, Q{item.base_quality}, "
        f"base_frames={item.selected_base_frames}, "
        f"base_bytes={item.total_base_bytes}, "
        f"base_bitrate={item.base_bitrate_kbps:.6f} kbit/s; "
        f"ROI={item.roi_width}x{item.roi_height}, "
        f"{item.roi_fps:.3f} fps, Q{item.roi_quality}, "
        f"roi_frames={item.selected_roi_frames}, "
        f"roi_bytes={item.total_roi_bytes}, "
        f"roi_bitrate={item.roi_bitrate_kbps:.6f} kbit/s; "
        f"total_bytes={item.total_payload_bytes}, "
        f"total_bitrate="
        f"{item.total_payload_bitrate_kbps:.6f} kbit/s, "
        f"sync_frames="
        f"{optional_integer_text(item.synchronized_composite_frames)}, "
        f"timestamp_mismatch="
        f"{optional_integer_text(item.timestamp_mismatch_count)}, "
        f"frame_id_mismatch="
        f"{optional_integer_text(item.frame_id_mismatch_count)}"
    )


def write_report(
    statistics: list[ProfileStatistics],
    source_width: int,
    source_height: int,
    source_fps: float,
    source_frame_count: int,
    source_duration_seconds: float,
    preview_path: Path,
    fallback_used: bool,
) -> None:
    """
    Сохраняет краткий UTF-8 отчёт без автоматического выбора профиля.
    """

    source_roi = normalized_roi_to_pixels(
        source_width,
        source_height,
    )
    lines = [
        "Lab027C. Динамическое сравнение асинхронного "
        "и синхронного BASE + ROI",
        "",
        "Цель: визуально сравнить ранее выбранный асинхронный "
        "профиль BASE 1 fps + ROI 2 fps с тремя синхронными "
        "профилями BASE 2 fps + ROI 2 fps.",
        "",
        f"Исходное видео: {SOURCE_VIDEO_PATH}",
        f"Исходное разрешение: {source_width}x{source_height}",
        f"Исходный FPS: {source_fps:.6f}",
        f"Число кадров: {source_frame_count}",
        f"Длительность: {source_duration_seconds:.6f} с",
        "ROI: "
        f"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]}.",
        "",
        "Результат ручной оценки предыдущей Lab027:",
        "- пользователь выбрал вариант B;",
        "- граница ROI не мешает;",
        "- обновление BASE один раз в секунду мешает;",
        "- рассинхронное движение BASE и ROI мешает;",
        "- принято решение сравнить с синхронным обновлением 2 fps.",
        "",
        "async_reference: BASE и ROI обновляются независимо. "
        "Единого logical_frame_id нет; используются отдельные "
        "BASE ID и ROI ID. Mismatch для этого режима неприменим.",
        "Синхронные профили: BASE и ROI формируются из одного "
        "source frame, получают единые composite ID и timestamp "
        "и атомарно заменяют отображаемый составной кадр.",
        "",
        "Фактические результаты четырёх профилей:",
    ]
    lines.extend(
        format_statistics_line(item)
        for item in statistics
    )
    lines.extend(
        [
            "",
            "Количество обновлений:",
        ]
    )

    for item in statistics:
        lines.append(
            f"{item.profile_name}: "
            f"BASE={item.selected_base_frames}, "
            f"ROI={item.selected_roi_frames}, "
            "synchronized="
            f"{optional_integer_text(item.synchronized_composite_frames)}."
        )

    lines.extend(
        [
            "",
            "Mismatch-проверка:",
        ]
    )

    for item in statistics:
        lines.append(
            f"{item.profile_name}: timestamp="
            f"{optional_integer_text(item.timestamp_mismatch_count)}, "
            "frame_id="
            f"{optional_integer_text(item.frame_id_mismatch_count)}."
        )

    lines.extend(
        [
            "",
            f"Preview: {preview_path}",
            "Формат: "
            + ("AVI/MJPG fallback." if fallback_used else "MP4/mp4v."),
            "Радиопротокол, CRC, FEC, фрагментация и служебный "
            "трафик пока не учитываются.",
            "Программа не выбирает лучший профиль и не объявляет "
            "режим безопасным автоматически.",
            "Следующий шаг: ручной выбор пользователем после "
            "просмотра preview-видео.",
            "",
        ]
    )

    REPORT_PATH.write_text(
        "\n".join(lines),
        encoding="utf-8",
    )


def read_frame_at(
    capture: cv2.VideoCapture,
    frame_index: int,
) -> tuple[bool, tuple[int, ...] | None]:
    """
    Читает один контрольный кадр preview.
    """

    capture.set(
        cv2.CAP_PROP_POS_FRAMES,
        frame_index,
    )
    frame_read, frame = capture.read()

    if not frame_read or frame is None:
        return False, None

    return True, frame.shape


def verify_preview(
    preview_path: Path,
    source_frame_count: int,
    source_duration_seconds: float,
) -> tuple[
    int,
    int,
    float,
    int,
    float,
    tuple[bool, tuple[int, ...] | None],
    tuple[bool, tuple[int, ...] | None],
    tuple[bool, tuple[int, ...] | None],
]:
    """
    Проверяет preview и читает первый, средний и последний кадры.
    """

    if not preview_path.exists() or preview_path.stat().st_size <= 0:
        raise RuntimeError("Preview отсутствует или имеет нулевой размер.")

    capture = cv2.VideoCapture(str(preview_path))

    if not capture.isOpened():
        raise RuntimeError(
            f"OpenCV не смог открыть preview: {preview_path}"
        )

    try:
        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))
        frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))

        if fps <= 0.0:
            raise RuntimeError("FPS preview равен нулю.")

        duration_seconds = frame_count / fps
        first_frame = read_frame_at(capture, 0)
        middle_frame = read_frame_at(
            capture,
            frame_count // 2,
        )
        last_frame = read_frame_at(
            capture,
            frame_count - 1,
        )
    finally:
        capture.release()

    if (width, height) != (OUTPUT_WIDTH, OUTPUT_HEIGHT):
        raise RuntimeError(
            f"Некорректное разрешение preview: {width}x{height}."
        )

    if frame_count != source_frame_count:
        raise RuntimeError(
            "Число кадров preview не совпало с исходным."
        )

    if (
        abs(duration_seconds - source_duration_seconds)
        > 1.0 / fps + FRAME_TIME_EPSILON_SECONDS
    ):
        raise RuntimeError(
            "Длительность preview отличается более чем на один кадр."
        )

    if not all(
        frame_result[0]
        for frame_result in [
            first_frame,
            middle_frame,
            last_frame,
        ]
    ):
        raise RuntimeError(
            "Не удалось прочитать один из контрольных кадров."
        )

    return (
        width,
        height,
        fps,
        frame_count,
        duration_seconds,
        first_frame,
        middle_frame,
        last_frame,
    )


def validate_sync_statistics(
    statistics: list[ProfileStatistics],
) -> None:
    """
    Проверяет N/A async и нулевые mismatch всех sync-профилей.
    """

    for item in statistics:
        if item.update_mode == UPDATE_MODE_ASYNCHRONOUS:
            if (
                item.synchronized_composite_frames is not None
                or item.timestamp_mismatch_count is not None
                or item.frame_id_mismatch_count is not None
            ):
                raise RuntimeError(
                    "Async mismatch должен быть N/A."
                )
        else:
            if (
                item.selected_base_frames
                != item.selected_roi_frames
                or item.selected_base_frames
                != item.synchronized_composite_frames
                or item.timestamp_mismatch_count != 0
                or item.frame_id_mismatch_count != 0
            ):
                raise RuntimeError(
                    f"Некорректный sync: {item.profile_name}"
                )


def main() -> None:
    """
    Выполняет оба прохода, сохраняет CSV/TXT и проверяет preview.
    """

    print("Reading source video metadata...")
    (
        source_width,
        source_height,
        source_fps,
        source_frame_count,
        source_duration_seconds,
        source_file_size_bytes,
    ) = read_video_metadata(SOURCE_VIDEO_PATH)

    print(f"  Source: {SOURCE_VIDEO_PATH}")
    print(f"  File size: {source_file_size_bytes} bytes")
    print(f"  Resolution: {source_width}x{source_height}")
    print(f"  FPS: {source_fps:.6f}")
    print(f"  Frames: {source_frame_count}")
    print(f"  Duration: {source_duration_seconds:.6f} s")

    profiles = build_profiles()
    OUTPUT_DIRECTORY.mkdir(
        parents=True,
        exist_ok=True,
    )

    print("First pass: measuring JPEG payload...")
    first_measurements, first_states = process_first_pass(
        source_path=SOURCE_VIDEO_PATH,
        profiles=profiles,
        source_width=source_width,
        source_height=source_height,
        source_fps=source_fps,
        expected_frame_count=source_frame_count,
    )
    statistics = calculate_statistics(
        profiles,
        first_measurements,
        first_states,
        source_duration_seconds,
    )
    validate_sync_statistics(statistics)

    print("Second pass: writing preview...")
    (
        preview_path,
        fallback_used,
        written_frame_count,
        second_measurements,
        second_states,
    ) = write_preview(
        source_path=SOURCE_VIDEO_PATH,
        profiles=profiles,
        statistics=statistics,
        source_width=source_width,
        source_height=source_height,
        source_fps=source_fps,
        expected_frame_count=source_frame_count,
    )
    compare_passes(
        profiles,
        first_measurements,
        second_measurements,
        first_states,
        second_states,
    )

    print("Saving CSV and report...")
    save_csv(statistics)
    write_report(
        statistics=statistics,
        source_width=source_width,
        source_height=source_height,
        source_fps=source_fps,
        source_frame_count=source_frame_count,
        source_duration_seconds=source_duration_seconds,
        preview_path=preview_path,
        fallback_used=fallback_used,
    )

    (
        preview_width,
        preview_height,
        preview_fps,
        preview_frame_count,
        preview_duration,
        first_frame,
        middle_frame,
        last_frame,
    ) = verify_preview(
        preview_path,
        source_frame_count,
        source_duration_seconds,
    )

    if not CSV_PATH.exists() or CSV_PATH.stat().st_size <= 0:
        raise RuntimeError("CSV не создан или пуст.")

    if not REPORT_PATH.exists() or REPORT_PATH.stat().st_size <= 0:
        raise RuntimeError("Отчёт не создан или пуст.")

    print("")
    print("Measured profiles:")
    for item in statistics:
        print(
            f"  {item.profile_name}: "
            f"BASE={item.base_bitrate_kbps:.6f}, "
            f"ROI={item.roi_bitrate_kbps:.6f}, "
            f"total={item.total_payload_bitrate_kbps:.6f} "
            "kbit/s, "
            "sync="
            f"{optional_integer_text(item.synchronized_composite_frames)}, "
            "timestamp mismatch="
            f"{optional_integer_text(item.timestamp_mismatch_count)}, "
            "frame ID mismatch="
            f"{optional_integer_text(item.frame_id_mismatch_count)}"
        )

    print("")
    print("Two-pass JPEG sizes: identical")
    print(f"Preview: {preview_path}")
    print(f"Fallback used: {fallback_used}")
    print(f"Preview size: {preview_path.stat().st_size} bytes")
    print(
        f"Preview resolution: {preview_width}x{preview_height}"
    )
    print(f"Preview FPS: {preview_fps:.6f}")
    print(f"Written frames: {written_frame_count}")
    print(f"Verified frames: {preview_frame_count}")
    print(f"Preview duration: {preview_duration:.6f} s")
    print(f"First frame: {first_frame}")
    print(f"Middle frame: {middle_frame}")
    print(f"Last frame: {last_frame}")
    print("")
    print("Lab027C completed successfully.")


if __name__ == "__main__":
    main()
