"""
Lab027. Динамическое превью временного обновления BASE и ROI.

Скрипт не повторяет расчёт benchmark Lab027 и не изменяет его
результаты. Он создаёт одно игнорируемое Git preview-видео с четырьмя
фиксированными профилями. Для каждого профиля независимо удерживаются
последние декодированные JPEG-состояния BASE и ROI.

Отдельные JPEG-файлы не сохраняются: кодирование и декодирование
выполняются только в памяти средствами OpenCV.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

import cv2
import numpy as np


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

PREVIEW_DIRECTORY = Path("data/raw/lab027_previews")
MP4_PREVIEW_PATH = (
    PREVIEW_DIRECTORY / "lab027_roi_temporal_preview.mp4"
)
AVI_PREVIEW_PATH = (
    PREVIEW_DIRECTORY / "lab027_roi_temporal_preview.avi"
)

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_UPDATE_LABEL_SECONDS = 0.15


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

    name: str
    base_width: int
    base_height: int
    base_fps: float
    base_quality: int
    roi_enabled: bool
    roi_width: int
    roi_height: int
    roi_fps: float
    roi_quality: int
    measured_payload_kbps: float


@dataclass
class ProfileState:
    """
    Хранит независимое временное состояние 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_update_count: int = 0
    roi_update_count: int = 0


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

    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

    return width, height, fps, frame_count, duration_seconds


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

    profiles = [
        PreviewProfile(
            name="baseline",
            base_width=320,
            base_height=180,
            base_fps=2.0,
            base_quality=40,
            roi_enabled=False,
            roi_width=0,
            roi_height=0,
            roi_fps=0.0,
            roi_quality=0,
            measured_payload_kbps=80.27,
        ),
        PreviewProfile(
            name="ROI normal",
            base_width=240,
            base_height=135,
            base_fps=1.0,
            base_quality=25,
            roi_enabled=True,
            roi_width=320,
            roi_height=180,
            roi_fps=2.0,
            roi_quality=35,
            measured_payload_kbps=93.28,
        ),
        PreviewProfile(
            name="ROI economy",
            base_width=160,
            base_height=90,
            base_fps=1.0,
            base_quality=25,
            roi_enabled=True,
            roi_width=320,
            roi_height=180,
            roi_fps=2.0,
            roi_quality=35,
            measured_payload_kbps=85.19,
        ),
        PreviewProfile(
            name="ROI degraded channel",
            base_width=240,
            base_height=135,
            base_fps=1.0,
            base_quality=20,
            roi_enabled=True,
            roi_width=320,
            roi_height=180,
            roi_fps=1.0,
            roi_quality=30,
            measured_payload_kbps=50.49,
        ),
    ]

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

    return profiles


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

    Результат задаёт полуоткрытый прямоугольник:
    x_min, y_min, x_max, y_max.
    """

    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,
    epsilon_seconds: float = FRAME_TIME_EPSILON_SECONDS,
) -> bool:
    """
    Проверяет наступление времени очередного обновления потока.
    """

    return (
        current_time + epsilon_seconds
        >= next_update_time
    )


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

    if gray_frame.ndim != 2:
        raise RuntimeError(
            "JPEG preview должен получать 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 decoded


def encode_decode_base(
    source_frame: np.ndarray,
    profile: PreviewProfile,
) -> np.ndarray:
    """
    Формирует очередное декодированное состояние BASE.
    """

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

    return encode_decode_grayscale_jpeg(
        resized_base,
        profile.base_quality,
    )


def encode_decode_roi(
    source_frame: np.ndarray,
    source_roi: tuple[int, int, int, int],
    profile: PreviewProfile,
) -> np.ndarray:
    """
    Вырезает ROI исходного BGR-кадра и формирует JPEG-состояние.
    """

    if not profile.roi_enabled:
        raise RuntimeError(
            "Нельзя кодировать ROI для отключённого 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_roi = cv2.resize(
        roi_gray,
        (profile.roi_width, profile.roi_height),
        interpolation=cv2.INTER_AREA,
    )

    return encode_decode_grayscale_jpeg(
        resized_roi,
        profile.roi_quality,
    )


def reconstruct_panel(
    profile: PreviewProfile,
    state: ProfileState,
    panel_roi: tuple[int, int, int, int],
) -> np.ndarray:
    """
    Восстанавливает одну grayscale-панель размером 640x360.
    """

    if state.latest_base is None:
        raise RuntimeError("BASE-состояние ещё не создано.")

    reconstructed = cv2.resize(
        state.latest_base,
        (PANEL_WIDTH, PANEL_HEIGHT),
        interpolation=cv2.INTER_LINEAR,
    )

    if profile.roi_enabled:
        if state.latest_roi is None:
            raise RuntimeError("ROI-состояние ещё не создано.")

        x_min, y_min, x_max, y_max = panel_roi
        resized_roi = cv2.resize(
            state.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,
    text_color: tuple[int, int, int] = (255, 255, 255),
) -> None:
    """
    Рисует одну ASCII-строку служебной информации OpenCV.
    """

    cv2.putText(
        image,
        text,
        (10, y_position),
        cv2.FONT_HERSHEY_SIMPLEX,
        0.46,
        text_color,
        1,
        cv2.LINE_AA,
    )


def draw_panel_information(
    reconstructed_gray: np.ndarray,
    profile: PreviewProfile,
    state: ProfileState,
    current_time: float,
    panel_roi: tuple[int, int, int, int],
) -> np.ndarray:
    """
    Добавляет рамку ROI, параметры и индикаторы обновления панели.
    """

    panel = cv2.cvtColor(
        reconstructed_gray,
        cv2.COLOR_GRAY2BGR,
    )

    if profile.roi_enabled:
        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, 142),
        (0, 0, 0),
        thickness=-1,
    )
    cv2.addWeighted(
        overlay,
        0.72,
        panel,
        0.28,
        0.0,
        panel,
    )

    base_age = max(
        0.0,
        current_time - state.last_base_update_time,
    )
    roi_age = max(
        0.0,
        current_time - state.last_roi_update_time,
    )
    base_is_new = (
        base_age <= NEW_UPDATE_LABEL_SECONDS
    )
    roi_is_new = (
        profile.roi_enabled
        and roi_age <= NEW_UPDATE_LABEL_SECONDS
    )

    draw_text_line(panel, profile.name, 20)
    draw_text_line(
        panel,
        (
            f"BASE: {profile.base_width}x{profile.base_height}, "
            f"{profile.base_fps:g} fps, Q{profile.base_quality}"
        ),
        41,
    )

    if profile.roi_enabled:
        roi_text = (
            f"ROI: {profile.roi_width}x{profile.roi_height}, "
            f"{profile.roi_fps:g} fps, Q{profile.roi_quality}"
        )
        roi_age_text = f"{roi_age:.3f} s"
    else:
        roi_text = "ROI: disabled"
        roi_age_text = "disabled"

    draw_text_line(panel, roi_text, 62)
    draw_text_line(
        panel,
        (
            f"payload={profile.measured_payload_kbps:.2f} kbps, "
            f"source time={current_time:.3f} s"
        ),
        83,
    )
    draw_text_line(
        panel,
        (
            f"since BASE={base_age:.3f} s, "
            f"since ROI={roi_age_text}"
        ),
        104,
    )

    update_labels: list[str] = []

    if base_is_new:
        update_labels.append("NEW BASE")

    if roi_is_new:
        update_labels.append("NEW ROI")

    if update_labels:
        draw_text_line(
            panel,
            " | ".join(update_labels),
            130,
            text_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}"
            )

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

    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 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 create_preview(
    source_path: Path,
    profiles: list[PreviewProfile],
    source_width: int,
    source_height: int,
    source_fps: float,
    expected_frame_count: int,
) -> tuple[Path, bool, int, list[ProfileState]]:
    """
    Создаёт preview-видео с одним выходным кадром на исходный кадр.
    """

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

    PREVIEW_DIRECTORY.mkdir(
        parents=True,
        exist_ok=True,
    )

    capture = cv2.VideoCapture(str(source_path))

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

    writer, preview_path, fallback_used = open_preview_writer()
    source_roi = normalized_roi_to_pixels(
        source_width,
        source_height,
    )
    panel_roi = normalized_roi_to_pixels(
        PANEL_WIDTH,
        PANEL_HEIGHT,
    )
    states = [
        ProfileState()
        for _ in profiles
    ]
    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
            panels: list[np.ndarray] = []

            for profile, state in zip(profiles, states):
                if should_update(
                    current_time,
                    state.next_base_time,
                ):
                    state.latest_base = encode_decode_base(
                        source_frame,
                        profile,
                    )
                    state.last_base_update_time = current_time
                    state.next_base_time += 1.0 / profile.base_fps
                    state.base_update_count += 1

                if (
                    profile.roi_enabled
                    and should_update(
                        current_time,
                        state.next_roi_time,
                    )
                ):
                    state.latest_roi = encode_decode_roi(
                        source_frame,
                        source_roi,
                        profile,
                    )
                    state.last_roi_update_time = current_time
                    state.next_roi_time += 1.0 / profile.roi_fps
                    state.roi_update_count += 1

                reconstructed = reconstruct_panel(
                    profile,
                    state,
                    panel_roi,
                )
                panels.append(
                    draw_panel_information(
                        reconstructed,
                        profile,
                        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"  Written 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(
            "Число записанных кадров не совпало с исходным: "
            f"{frame_index} != {expected_frame_count}."
        )

    return (
        preview_path,
        fallback_used,
        frame_index,
        states,
    )


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],
]:
    """
    Проверяет контейнер, геометрию, FPS, длительность и три кадра.
    """

    if not preview_path.exists():
        raise RuntimeError(
            f"Preview отсутствует: {preview_path}"
        )

    if 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 != OUTPUT_WIDTH or height != OUTPUT_HEIGHT:
        raise RuntimeError(
            f"Некорректное разрешение preview: {width}x{height}."
        )

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

    allowed_duration_difference = (
        1.0 / fps + FRAME_TIME_EPSILON_SECONDS
    )

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

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

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


def main() -> None:
    """
    Создаёт и проверяет динамическое preview Lab027.
    """

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

    print(f"  Source: {SOURCE_VIDEO_PATH}")
    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()
    print("Creating dynamic preview...")
    (
        preview_path,
        fallback_used,
        written_frame_count,
        states,
    ) = create_preview(
        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,
    )

    print("Verifying preview...")
    (
        preview_width,
        preview_height,
        preview_fps,
        preview_frame_count,
        preview_duration_seconds,
        first_frame,
        middle_frame,
        last_frame,
    ) = verify_preview(
        preview_path,
        source_frame_count,
        source_duration_seconds,
    )

    print("")
    print(f"Preview path: {preview_path}")
    print(f"Fallback used: {fallback_used}")
    print(f"File size: {preview_path.stat().st_size} bytes")
    print(
        f"Preview resolution: "
        f"{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: "
        f"{preview_duration_seconds:.6f} s"
    )
    print(f"First frame: {first_frame}")
    print(f"Middle frame: {middle_frame}")
    print(f"Last frame: {last_frame}")
    print("")
    print("Profile update counts:")

    for profile, state in zip(profiles, states):
        print(
            f"  {profile.name}: "
            f"BASE={state.base_update_count}, "
            f"ROI={state.roi_update_count}"
        )

    print("")
    print("Lab027 temporal preview completed successfully.")


if __name__ == "__main__":
    main()
