"""Lab035: predictive admission and whole-frame video service."""

from __future__ import annotations

import csv
from dataclasses import asdict, dataclass
from pathlib import Path
import subprocess
from typing import Iterable

import cv2
import matplotlib
import numpy as np

matplotlib.use("Agg")
import matplotlib.pyplot as plt

from protocol.link_packet import HEADER_SIZE, TrafficClass, decode_link_packet
from protocol.packet_erasure_fec import decode_fec_block, decode_outer_symbol
from protocol.video_frame_scheduler import (
    FramePolicy,
    FrameScheduleResult,
    VideoFrameGroup,
    predict_frame_completion,
    schedule_video_frames,
)
from protocol.video_packet import CompositeReassembler, decode_packet as decode_inner_packet
from tests.lab028_video_packetization import COMPOSITE_FPS
from tests.lab034_stale_video_drop import (
    PolicyDefinition as Lab034Policy,
    build_aligned_workload,
    build_lab033_workload,
    percentile,
    simulate_aligned,
)


OUTPUT_DIRECTORY = Path("data/processed/lab035")
SUMMARY_CSV_PATH = OUTPUT_DIRECTORY / "lab035_summary.csv"
VIDEO_CSV_PATH = OUTPUT_DIRECTORY / "lab035_video_metrics.csv"
CONTROL_CSV_PATH = OUTPUT_DIRECTORY / "lab035_control_metrics.csv"
PREDICTION_CSV_PATH = OUTPUT_DIRECTORY / "lab035_prediction_metrics.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab035_report.txt"
UPDATE_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_update_rate.png"
AGE_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_image_age.png"
PUBLICATION_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_publication_delay.png"
OUTCOME_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_frame_outcomes.png"
QUEUE_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_queue_size.png"
PREDICTION_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_prediction_accuracy.png"
CONTROL_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_control_delay.png"
COMPARISON_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_policy_comparison.png"
PLOT_PATHS = (
    UPDATE_PLOT_PATH,
    AGE_PLOT_PATH,
    PUBLICATION_PLOT_PATH,
    OUTCOME_PLOT_PATH,
    QUEUE_PLOT_PATH,
    PREDICTION_PLOT_PATH,
    CONTROL_PLOT_PATH,
    COMPARISON_PLOT_PATH,
)
LAB034_COMMIT = "b63e36abdb7031d642de8b8138b43cc29e94b759"
CHANNEL_RATES_KBPS = (300.0, 260.0, 230.0)
TIME_EPSILON_SECONDS = 1e-9


@dataclass(frozen=True)
class PolicyDefinition:
    name: str
    label: str
    scheduler_policy: FramePolicy | None
    reactive: bool = False


POLICIES = (
    PolicyDefinition("no_drop", "Без удаления", FramePolicy.NO_DROP),
    PolicyDefinition("reactive_1500ms", "Реактивная 1500 мс", None, True),
    PolicyDefinition("latest_only", "Самый свежий", FramePolicy.LATEST_ONLY),
    PolicyDefinition("two_waiting", "Два ожидающих", FramePolicy.TWO_WAITING),
    PolicyDefinition("predict_1000ms", "Прогноз 1000 мс", FramePolicy.PREDICT_1000MS),
    PolicyDefinition("predict_500ms", "Прогноз 500 мс", FramePolicy.PREDICT_500MS),
)
POLICY_BY_NAME = {policy.name: policy for policy in POLICIES}


@dataclass(frozen=True)
class TheoreticalCapacity:
    channel_kbps: float
    nonvideo_load_kbps: float
    remaining_video_kbps: float
    video_capacity_ratio: float
    minimum_skip_fraction: float
    maximum_update_fps: float


@dataclass(frozen=True)
class SummaryMetrics:
    channel_kbps: float
    policy: str
    offered_load_kbps: float
    offered_to_capacity_ratio: float
    transmitted_packets: int
    transmitted_bytes: int
    dropped_before_start_packets: int
    dropped_before_start_bytes: int
    wasted_transmitted_bytes: int
    mean_queue_packets: float
    max_queue_packets: int
    mean_queue_bytes: float
    max_queue_bytes: int
    mean_waiting_video_frames: float
    max_waiting_video_frames: int
    queue_at_source_end_packets: int
    additional_drain_seconds: float
    remaining_video_capacity_kbps: float
    theoretical_minimum_skip_fraction: float
    theoretical_maximum_update_fps: float


@dataclass(frozen=True)
class VideoMetrics:
    channel_kbps: float
    policy: str
    created_frames: int
    started_frames: int
    published_frames: int
    dropped_before_start_frames: int
    partially_transmitted_cancelled_frames: int
    published_fraction: float
    actual_update_fps: float
    mean_publication_delay_ms: float
    p95_publication_delay_ms: float
    max_publication_delay_ms: float
    mean_display_age_ms: float
    p95_display_age_ms: float
    max_display_age_ms: float
    display_age_over_500ms_fraction: float
    display_age_over_1000ms_fraction: float
    mean_no_update_duration_ms: float
    p95_no_update_duration_ms: float
    max_no_update_duration_ms: float
    mean_missing_run_frames: float
    p95_missing_run_frames: float
    max_missing_run_frames: int
    mean_publication_gap_ms: float
    max_publication_gap_ms: float
    transmitted_video_bytes: int
    dropped_before_start_video_bytes: int
    wasted_transmitted_video_bytes: int
    delivered_useful_video_kbps: float


@dataclass(frozen=True)
class ControlMetrics:
    channel_kbps: float
    policy: str
    control_p95_delay_ms: float
    control_max_delay_ms: float
    control_deadline_misses: int
    control_max_receive_gap_ms: float
    emergency_delay_ms: float
    emergency_deadline_met: bool
    emergency_blocker_class: str
    emergency_blocking_delay_ms: float
    telemetry_deadline_misses: int


@dataclass(frozen=True)
class PredictionMetrics:
    channel_kbps: float
    policy: str
    admitted_frames: int
    prediction_rejected_frames: int
    mean_absolute_error_ms: float
    p95_absolute_error_ms: float
    max_absolute_error_ms: float
    published_after_deadline_frames: int
    false_rejections: int


@dataclass(frozen=True)
class ScenarioResult:
    summary: SummaryMetrics
    video: VideoMetrics
    control: ControlMetrics
    prediction: PredictionMetrics
    publication_times: dict[int, float]
    schedule: FrameScheduleResult | None


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


def build_frame_groups(aligned) -> tuple[VideoFrameGroup, ...]:
    grouped: dict[int, list] = {}
    for item in aligned.packets:
        if item.composite_frame_id is not None:
            grouped.setdefault(item.composite_frame_id, []).append(item)
    return tuple(
        VideoFrameGroup(
            composite_frame_id=frame_id,
            generation_time_us=packets[0].packet.generation_time_us,
            packets=tuple(sorted(packets, key=lambda item: item.packet.sequence_number)),
        )
        for frame_id, packets in sorted(grouped.items())
    )


def high_priority_packets(aligned) -> tuple:
    return tuple(
        item for item in aligned.packets
        if item.packet.traffic_class is not TrafficClass.VIDEO
    )


def theoretical_capacity(aligned, rate: float) -> TheoreticalCapacity:
    duration = aligned.lab033.metadata.duration_seconds
    nonvideo_bytes = sum(
        item.wire_size_bytes for item in aligned.packets
        if item.packet.traffic_class is not TrafficClass.VIDEO
    )
    nonvideo_kbps = nonvideo_bytes * 8.0 / duration / 1000.0
    video_kbps = aligned.layout_metrics.after_link_kbps
    remaining = max(0.0, rate - nonvideo_kbps)
    ratio = remaining / video_kbps
    skip = max(0.0, 1.0 - ratio)
    return TheoreticalCapacity(
        rate,
        nonvideo_kbps,
        remaining,
        ratio,
        skip,
        COMPOSITE_FPS * min(1.0, ratio),
    )


def receive_whole_frames(schedule: FrameScheduleResult) -> dict[int, float]:
    dropped = {item.frame.composite_frame_id for item in schedule.dropped_frames}
    receiver = CompositeReassembler()
    publication_times: dict[int, float] = {}
    symbols_by_block: dict[int, list[bytes]] = {}
    decoded_blocks: set[int] = set()
    for sent in schedule.transmitted:
        link = decode_link_packet(sent.wire_packet)
        if link.traffic_class is not TrafficClass.VIDEO:
            continue
        frame_id = sent.item.composite_frame_id
        assert frame_id is not None and frame_id not in dropped
        outer = decode_outer_symbol(link.payload)
        symbols = symbols_by_block.setdefault(outer.block_id, [])
        symbols.append(link.payload)
        if not outer.is_parity:
            completed = receiver.ingest(outer.data)
            if completed is not None:
                publication_times[completed.composite_frame_id] = sent.end_seconds
        if outer.block_id not in decoded_blocks and len(symbols) >= outer.source_count:
            decoded = decode_fec_block(tuple(symbols))
            for inner in decoded.source_packets:
                decode_inner_packet(inner)
            decoded_blocks.add(outer.block_id)
    if set(publication_times) != set(schedule.completed_frame_ids):
        raise AssertionError("published frames differ from completed whole frames")
    return publication_times


def display_and_gap_metrics(publications: dict[int, float], source_end: float):
    events = sorted((time, frame) for frame, time in publications.items() if time <= source_end + TIME_EPSILON_SECONDS)
    samples = np.arange(0.0, source_end + 0.005, 0.01)
    ages = []
    index = 0
    last_frame = None
    for time in samples:
        while index < len(events) and events[index][0] <= time:
            last_frame = events[index][1]; index += 1
        generation = 0.0 if last_frame is None else last_frame / COMPOSITE_FPS
        ages.append(max(0.0, time - generation))
    update_times = [0.0] + [time for time, _ in events] + [source_end]
    no_update = tuple(max(0.0, right - left) for left, right in zip(update_times, update_times[1:]))
    publication_gaps = tuple(right[0] - left[0] for left, right in zip(events, events[1:]))
    return ages, no_update, publication_gaps


def missing_runs(frame_count: int, published: set[int]) -> tuple[int, ...]:
    runs = []
    current = 0
    for frame_id in range(frame_count):
        if frame_id not in published:
            current += 1
        elif current:
            runs.append(current); current = 0
    if current:
        runs.append(current)
    return tuple(runs)


def queue_metrics(schedule: FrameScheduleResult, frames, source_end: float):
    intervals = []
    first_start: dict[int, float] = {}
    for sent in schedule.transmitted:
        intervals.append((sent.item.available_time_seconds, sent.end_seconds, sent.item.wire_size_bytes))
        if sent.item.composite_frame_id is not None:
            first_start.setdefault(sent.item.composite_frame_id, sent.start_seconds)
    drop_time = {item.frame.composite_frame_id: item.drop_time_seconds for item in schedule.dropped_frames}
    for item in schedule.dropped_frames:
        for packet in item.frame.packets:
            intervals.append((packet.available_time_seconds, item.drop_time_seconds, packet.wire_size_bytes))
    for item in schedule.replacements:
        intervals.append((item.removed.available_time_seconds, item.time_seconds, item.removed.wire_size_bytes))
    packet_area = byte_area = 0.0
    events: dict[float, list[int]] = {}
    remaining = 0
    for start, end, size in intervals:
        if start <= source_end + TIME_EPSILON_SECONDS < end - TIME_EPSILON_SECONDS:
            remaining += 1
        left, right = max(0.0, start), min(source_end, end)
        if right <= left + TIME_EPSILON_SECONDS:
            continue
        packet_area += right - left; byte_area += (right - left) * size
        events.setdefault(left, [0, 0])[0] += 1; events[left][1] += size
        events.setdefault(right, [0, 0])[0] -= 1; events[right][1] -= size
    count = size_now = max_count = max_size = 0
    for time in sorted(events):
        count += events[time][0]; size_now += events[time][1]
        max_count = max(max_count, count); max_size = max(max_size, size_now)
    frame_intervals = []
    for frame in frames:
        end = first_start.get(frame.composite_frame_id, drop_time.get(frame.composite_frame_id, source_end))
        frame_intervals.append((frame.generation_time_seconds, end))
    frame_area = 0.0; frame_events: dict[float, int] = {}
    for start, end in frame_intervals:
        left, right = max(0.0, start), min(source_end, end)
        if right <= left + TIME_EPSILON_SECONDS: continue
        frame_area += right - left
        frame_events[left] = frame_events.get(left, 0) + 1
        frame_events[right] = frame_events.get(right, 0) - 1
    waiting = max_waiting = 0
    for time in sorted(frame_events):
        waiting += frame_events[time]; max_waiting = max(max_waiting, waiting)
    finish = max([source_end] + [item.end_seconds for item in schedule.transmitted] + [item.drop_time_seconds for item in schedule.dropped_frames])
    return (
        packet_area / source_end, max_count, byte_area / source_end, max_size,
        frame_area / source_end, max_waiting, remaining, max(0.0, finish - source_end),
    )


def control_metrics(schedule: FrameScheduleResult, rate: float, policy: str) -> ControlMetrics:
    by_class = {
        traffic: [item for item in schedule.transmitted if item.item.packet.traffic_class is traffic]
        for traffic in (TrafficClass.CONTROL, TrafficClass.EMERGENCY, TrafficClass.TELEMETRY)
    }
    control = by_class[TrafficClass.CONTROL]
    control_delays = [item.end_seconds - item.item.packet.generation_time_us / 1_000_000.0 for item in control]
    gaps = [right.end_seconds - left.end_seconds for left, right in zip(control, control[1:])]
    telemetry_delays = [item.end_seconds - item.item.packet.generation_time_us / 1_000_000.0 for item in by_class[TrafficClass.TELEMETRY]]
    emergency = by_class[TrafficClass.EMERGENCY]
    if len(emergency) != 1: raise AssertionError("exactly one emergency command is required")
    urgent = emergency[0]
    urgent_delay = urgent.end_seconds - urgent.item.packet.generation_time_us / 1_000_000.0
    return ControlMetrics(
        rate, policy,
        percentile(control_delays, 95) * 1000.0,
        max(control_delays) * 1000.0,
        sum(delay > 0.1 + TIME_EPSILON_SECONDS for delay in control_delays),
        max(gaps, default=0.0) * 1000.0,
        urgent_delay * 1000.0,
        urgent_delay <= 0.05 + TIME_EPSILON_SECONDS,
        urgent.blocked_by.packet.traffic_class.name.lower() if urgent.blocked_by else "none",
        urgent.blocking_delay_seconds * 1000.0,
        sum(delay > 0.5 + TIME_EPSILON_SECONDS for delay in telemetry_delays),
    )


def video_metrics(aligned, schedule, publications, rate, policy):
    source_end = aligned.lab033.metadata.duration_seconds
    published = set(publications)
    dropped = {item.frame.composite_frame_id for item in schedule.dropped_frames}
    delays = [publications[frame] - frame / COMPOSITE_FPS for frame in sorted(published)]
    ages, no_update, publication_gaps = display_and_gap_metrics(publications, source_end)
    runs = missing_runs(len(aligned.lab033.composites), published)
    transmitted_video_bytes = sum(
        len(item.wire_packet) for item in schedule.transmitted
        if item.item.packet.traffic_class is TrafficClass.VIDEO
    )
    dropped_bytes = sum(item.frame.wire_size_bytes for item in schedule.dropped_frames)
    useful = sum(
        len(aligned.lab033.composites[frame].base_jpeg) + len(aligned.lab033.composites[frame].roi_jpeg)
        for frame in published
    )
    return VideoMetrics(
        rate, policy, len(aligned.lab033.composites), len(schedule.started_frame_ids),
        len(published), len(dropped), 0, len(published) / len(aligned.lab033.composites),
        sum(time <= source_end + TIME_EPSILON_SECONDS for time in publications.values()) / source_end,
        float(np.mean(delays)) * 1000.0 if delays else 0.0,
        percentile(delays, 95) * 1000.0, max(delays, default=0.0) * 1000.0,
        float(np.mean(ages)) * 1000.0, percentile(ages, 95) * 1000.0,
        max(ages, default=0.0) * 1000.0,
        sum(age > 0.5 for age in ages) / len(ages),
        sum(age > 1.0 for age in ages) / len(ages),
        float(np.mean(no_update)) * 1000.0, percentile(no_update, 95) * 1000.0,
        max(no_update, default=0.0) * 1000.0,
        float(np.mean(runs)) if runs else 0.0, percentile(runs, 95), max(runs, default=0),
        float(np.mean(publication_gaps)) * 1000.0 if publication_gaps else 0.0,
        max(publication_gaps, default=0.0) * 1000.0,
        transmitted_video_bytes, dropped_bytes, 0,
        useful * 8.0 / source_end / 1000.0,
    )


def prediction_metrics(schedule, rate, policy):
    errors = [abs(item.prediction_error_seconds) for item in schedule.admissions]
    deadline = schedule.policy.deadline_seconds
    rejected = [item for item in schedule.dropped_frames if item.reason == "prediction_reject"]
    late = sum(
        item.actual_completion_seconds - item.composite_frame_id / COMPOSITE_FPS
        > deadline + TIME_EPSILON_SECONDS
        for item in schedule.admissions
    ) if deadline is not None else 0
    return PredictionMetrics(
        rate, policy, len(schedule.admissions), len(rejected),
        float(np.mean(errors)) * 1000.0 if errors else 0.0,
        percentile(errors, 95) * 1000.0, max(errors, default=0.0) * 1000.0,
        late, 0,
    )


def whole_frame_result(aligned, frames, high, rate, policy_def):
    schedule = schedule_video_frames(frames, high, policy_def.scheduler_policy, rate * 1000.0)
    publications = receive_whole_frames(schedule)
    video = video_metrics(aligned, schedule, publications, rate, policy_def.name)
    control = control_metrics(schedule, rate, policy_def.name)
    prediction = prediction_metrics(schedule, rate, policy_def.name)
    source_end = aligned.lab033.metadata.duration_seconds
    qp, qmax, qb, qbmax, fq, fqmax, remaining, drain = queue_metrics(schedule, frames, source_end)
    capacity = theoretical_capacity(aligned, rate)
    offered_bytes = sum(item.wire_size_bytes for item in aligned.packets)
    summary = SummaryMetrics(
        rate, policy_def.name,
        offered_bytes * 8.0 / source_end / 1000.0,
        offered_bytes * 8.0 / source_end / (rate * 1000.0),
        len(schedule.transmitted), sum(len(item.wire_packet) for item in schedule.transmitted),
        sum(len(item.frame.packets) for item in schedule.dropped_frames),
        sum(item.frame.wire_size_bytes for item in schedule.dropped_frames), 0,
        qp, qmax, qb, qbmax, fq, fqmax, remaining, drain,
        capacity.remaining_video_kbps, capacity.minimum_skip_fraction,
        capacity.maximum_update_fps,
    )
    return ScenarioResult(summary, video, control, prediction, publications, schedule)


def reactive_result(aligned, rate):
    old_policy = Lab034Policy("aligned_1500ms", "По кадрам, 1500 мс", "aligned", 1500)
    old = simulate_aligned(aligned, rate, old_policy)
    capacity = theoretical_capacity(aligned, rate)
    s, v, c = old.summary, old.video, old.control
    source_end = aligned.lab033.metadata.duration_seconds
    dropped_frames = set(old.dropped_frames)
    partial_frames = {
        frame_id for frame_id in dropped_frames
        if old.transmitted_video_by_frame.get(frame_id, 0) > 0
    }
    before_start_frames = dropped_frames - partial_frames
    dropped_before_packets = [
        item for item in old.schedule.dropped_video
        if item.item.composite_frame_id in before_start_frames
    ]
    first_start = {}
    for item in old.schedule.transmitted:
        if item.item.composite_frame_id is not None:
            first_start.setdefault(item.item.composite_frame_id, item.start_seconds)
    frame_drop_time = {}
    for item in old.schedule.dropped_video:
        assert item.item.composite_frame_id is not None
        frame_drop_time.setdefault(item.item.composite_frame_id, item.drop_time_seconds)
    frame_events = {}
    frame_area = 0.0
    for frame_id in range(len(aligned.lab033.composites)):
        start = frame_id / COMPOSITE_FPS
        end = first_start.get(frame_id, frame_drop_time.get(frame_id, start))
        left, right = max(0.0, start), min(source_end, end)
        if right <= left + TIME_EPSILON_SECONDS:
            continue
        frame_area += right - left
        frame_events[left] = frame_events.get(left, 0) + 1
        frame_events[right] = frame_events.get(right, 0) - 1
    waiting = max_waiting = 0
    for time in sorted(frame_events):
        waiting += frame_events[time]
        max_waiting = max(max_waiting, waiting)
    publication_events = sorted(old.publication_times.values())
    publication_gaps = [
        right - left for left, right in zip(publication_events, publication_events[1:])
    ]
    summary = SummaryMetrics(
        rate, "reactive_1500ms", s.offered_load_kbps, s.offered_to_capacity_ratio,
        s.transmitted_packets, s.transmitted_bytes,
        len(dropped_before_packets),
        sum(item.item.wire_size_bytes for item in dropped_before_packets),
        s.wasted_transmitted_bytes, s.mean_queue_packets, s.max_queue_packets,
        s.mean_queue_bytes, s.max_queue_bytes,
        frame_area / source_end, max_waiting,
        s.queue_at_source_end_packets, s.additional_drain_seconds,
        capacity.remaining_video_kbps, capacity.minimum_skip_fraction,
        capacity.maximum_update_fps,
    )
    video = VideoMetrics(
        rate, "reactive_1500ms", v.created_frames,
        v.published_frames + v.partially_transmitted_cancelled_frames,
        v.published_frames,
        v.intentionally_dropped_frames - v.partially_transmitted_cancelled_frames,
        v.partially_transmitted_cancelled_frames,
        v.published_fraction, v.actual_update_fps,
        v.mean_publication_delay_ms, v.p95_publication_delay_ms, v.max_publication_delay_ms,
        v.mean_display_age_ms, v.p95_display_age_ms, v.max_display_age_ms,
        v.display_age_over_500ms_fraction, v.display_age_over_1000ms_fraction,
        v.mean_no_update_duration_ms, v.p95_no_update_duration_ms, v.max_no_update_duration_ms,
        v.mean_missing_run_frames, v.p95_missing_run_frames, v.max_missing_run_frames,
        float(np.mean(publication_gaps)) * 1000.0 if publication_gaps else 0.0,
        max(publication_gaps, default=0.0) * 1000.0,
        sum(len(item.wire_packet) for item in old.schedule.transmitted if item.item.packet.traffic_class is TrafficClass.VIDEO),
        0, v.wasted_transmitted_video_bytes, v.delivered_useful_video_kbps,
    )
    control = ControlMetrics(
        rate, "reactive_1500ms", c.control_p95_age_ms, c.control_max_age_ms,
        c.control_deadline_misses, c.control_max_receive_gap_ms,
        c.emergency_total_delay_ms, c.emergency_deadline_met,
        c.emergency_blocker_class, c.emergency_blocking_delay_ms,
        c.telemetry_deadline_misses,
    )
    prediction = PredictionMetrics(rate, "reactive_1500ms", 0, 0, 0.0, 0.0, 0.0, 0, 0)
    return ScenarioResult(summary, video, control, prediction, old.publication_times, None)


def run_experiment(aligned, frames, high):
    results = []
    for rate in CHANNEL_RATES_KBPS:
        for policy in POLICIES:
            results.append(
                reactive_result(aligned, rate)
                if policy.reactive
                else whole_frame_result(aligned, frames, high, rate, policy)
            )
    return tuple(results)


def run_functional_tests(aligned, frames, high, results):
    lookup = {(r.summary.channel_kbps, r.summary.policy): r for r in results}
    checks = []
    def check(name):
        def decorator(function): checks.append((name, function)); return function
        return decorator

    whole = [result for result in results if result.schedule is not None]

    @check("01_video_frames_do_not_interleave")
    def _():
        for result in whole:
            sequence = [item.item.composite_frame_id for item in result.schedule.transmitted if item.item.composite_frame_id is not None]
            compressed = [frame for index, frame in enumerate(sequence) if index == 0 or frame != sequence[index - 1]]
            assert len(compressed) == len(set(compressed))

    @check("02_started_frame_never_dropped")
    def _():
        for result in whole:
            assert not (set(result.schedule.started_frame_ids) & {item.frame.composite_frame_id for item in result.schedule.dropped_frames})

    @check("03_high_priority_between_frame_packets")
    def _():
        assert any(
            any(item.item.packet.traffic_class is not TrafficClass.VIDEO for item in result.schedule.transmitted[left + 1:right])
            for result in whole
            for left, right in zip(
                [i for i, item in enumerate(result.schedule.transmitted) if item.item.composite_frame_id is not None][:-1],
                [i for i, item in enumerate(result.schedule.transmitted) if item.item.composite_frame_id is not None][1:],
            )
            if result.schedule.transmitted[left].item.composite_frame_id == result.schedule.transmitted[right].item.composite_frame_id
        )

    @check("04_latest_drops_only_unstarted")
    def _():
        for rate in CHANNEL_RATES_KBPS:
            result = lookup[(rate, "latest_only")]
            assert not (set(result.schedule.started_frame_ids) & {item.frame.composite_frame_id for item in result.schedule.dropped_frames})

    @check("05_two_waiting_limit")
    def _(): assert all(lookup[(rate, "two_waiting")].summary.max_waiting_video_frames <= 2 for rate in CHANNEL_RATES_KBPS)

    @check("06_prediction_is_pure")
    def _():
        ready = list(high[:2]); future = list(high[2:20]); ready_before=list(ready); future_before=list(future)
        predict_frame_completion(0.0, frames[0], 230_000.0, ready, future)
        assert ready == ready_before and future == future_before

    @check("07_prediction_uses_actual_sizes")
    def _():
        small = VideoFrameGroup(999, 0, (frames[0].packets[0],))
        full = predict_frame_completion(0.0, frames[0], 300_000.0, (), ())
        one = predict_frame_completion(0.0, small, 300_000.0, (), ())
        assert full > one and abs(one - small.wire_size_bytes * 8.0 / 300_000.0) < 1e-12

    @check("08_prestart_drop_has_no_waste")
    def _(): assert all(result.video.wasted_transmitted_video_bytes == 0 for result in whole)

    @check("09_partial_only_reactive")
    def _():
        assert all(result.video.partially_transmitted_cancelled_frames == 0 for result in whole)
        assert lookup[(230.0, "reactive_1500ms")].video.partially_transmitted_cancelled_frames > 0

    @check("10_incomplete_not_published")
    def _():
        for result in whole:
            dropped = {item.frame.composite_frame_id for item in result.schedule.dropped_frames}
            assert not (dropped & set(result.publication_times))

    @check("11_crc_layers_pass")
    def _(): assert all(result.video.published_frames == len(result.publication_times) for result in results)

    @check("12_emergency_never_deleted")
    def _(): assert all(result.control.emergency_deadline_met for result in results)

    @check("13_priority_above_video")
    def _(): assert all(result.control.control_deadline_misses == 0 and result.control.telemetry_deadline_misses == 0 for result in results)

    @check("14_300kbps_no_unnecessary_loss")
    def _(): assert all(lookup[(300.0, policy.name)].video.published_frames == 63 for policy in POLICIES)

    @check("15_230kbps_bounded_queue")
    def _():
        baseline = lookup[(230.0, "no_drop")].summary.max_queue_packets
        assert all(lookup[(230.0, name)].summary.max_queue_packets < baseline for name in ("latest_only", "two_waiting", "predict_1000ms", "predict_500ms"))

    @check("16_frame_accounting")
    def _():
        for result in results:
            assert result.video.published_frames + result.video.dropped_before_start_frames + result.video.partially_transmitted_cancelled_frames == 63

    @check("17_byte_accounting")
    def _():
        for result in whole:
            assert result.summary.transmitted_bytes == sum(len(item.wire_packet) for item in result.schedule.transmitted)
            assert result.summary.dropped_before_start_bytes == sum(item.frame.wire_size_bytes for item in result.schedule.dropped_frames)

    @check("18_reproducible")
    def _():
        original = lookup[(230.0, "predict_1000ms")].schedule
        repeated = schedule_video_frames(frames, high, FramePolicy.PREDICT_1000MS, 230_000.0)
        assert [(x.item.arrival_order,x.start_seconds,x.end_seconds) for x in original.transmitted] == [(x.item.arrival_order,x.start_seconds,x.end_seconds) for x in repeated.transmitted]

    @check("19_predict_1000_never_known_late")
    def _(): assert all(lookup[(rate, "predict_1000ms")].prediction.published_after_deadline_frames == 0 for rate in CHANNEL_RATES_KBPS)

    @check("20_command_delay_bound")
    def _():
        lab033 = {}
        with Path("data/processed/lab033/lab033_summary.csv").open(encoding="utf-8") as file:
            for row in csv.DictReader(file):
                if row["scheduler"] == "latest_state": lab033[float(row["channel_kbps"])] = float(row["control_max_age_ms"])
        max_video_bytes = max(packet.wire_size_bytes for frame in frames for packet in frame.packets)
        for result in results:
            bound = lab033[result.summary.channel_kbps] + max_video_bytes * 8.0 / (result.summary.channel_kbps * 1000.0) * 1000.0
            assert result.control.control_max_delay_ms <= bound + 1e-9

    output=[]
    for name,function in checks:
        try: function(); output.append(FunctionalTestResult(name,True,"PASS"))
        except Exception as error: output.append(FunctionalTestResult(name,False,f"{type(error).__name__}: {error}"))
    if not all(item.passed for item in output): raise AssertionError("functional checks failed: "+", ".join(item.name for item in output if not item.passed))
    return tuple(output)


def save_csv(results):
    OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
    for path, cls, rows in (
        (SUMMARY_CSV_PATH, SummaryMetrics, (r.summary for r in results)),
        (VIDEO_CSV_PATH, VideoMetrics, (r.video for r in results)),
        (CONTROL_CSV_PATH, ControlMetrics, (r.control for r in results)),
        (PREDICTION_CSV_PATH, PredictionMetrics, (r.prediction for r in results)),
    ):
        with path.open("w",encoding="utf-8",newline="") as file:
            writer=csv.DictWriter(file,fieldnames=list(cls.__dataclass_fields__)); writer.writeheader(); writer.writerows(asdict(row) for row in rows)


def grouped_plot(results,value,ylabel,title,path):
    x=np.arange(len(CHANNEL_RATES_KBPS)); width=.13
    fig,axis=plt.subplots(figsize=(12,5.5))
    for index,policy in enumerate(POLICIES):
        rows=[r for r in results if r.summary.policy==policy.name]
        axis.bar(x+(index-2.5)*width,[value(r) for r in rows],width,label=policy.label)
    axis.set_xticks(x,[f"{rate:.0f}" for rate in CHANNEL_RATES_KBPS]); axis.set_xlabel("Скорость, кбит/с"); axis.set_ylabel(ylabel); axis.set_title(title); axis.grid(axis="y",alpha=.3); axis.legend(fontsize=8); fig.tight_layout(); fig.savefig(path,dpi=150); plt.close(fig)


def save_plots(results):
    grouped_plot(results,lambda r:r.video.actual_update_fps,"Обновлений/с","Фактическая частота обновления",UPDATE_PLOT_PATH)
    grouped_plot(results,lambda r:r.video.p95_display_age_ms,"P95 возраста, мс","Возраст отображаемого изображения",AGE_PLOT_PATH)
    grouped_plot(results,lambda r:r.video.p95_publication_delay_ms,"P95 задержки, мс","Задержка публикации",PUBLICATION_PLOT_PATH)
    grouped_plot(results,lambda r:r.video.published_frames,"Кадров","Опубликованные кадры",OUTCOME_PLOT_PATH)
    grouped_plot(results,lambda r:r.summary.max_queue_packets,"Пакетов","Максимальный размер очереди",QUEUE_PLOT_PATH)
    grouped_plot(results,lambda r:r.prediction.p95_absolute_error_ms,"P95 ошибки, мс","Точность прогноза",PREDICTION_PLOT_PATH)
    grouped_plot(results,lambda r:r.control.control_p95_delay_ms,"P95, мс","Задержка команд",CONTROL_PLOT_PATH)
    grouped_plot(results,lambda r:r.video.dropped_before_start_frames,"Кадров","Сравнение политик упреждающего удаления",COMPARISON_PLOT_PATH)


def write_report(aligned,results,tests):
    git_status=subprocess.run(("git","status","--short","--branch"),check=True,capture_output=True,text=True,encoding="utf-8").stdout.rstrip()
    capacities={rate:theoretical_capacity(aligned,rate) for rate in CHANNEL_RATES_KBPS}
    lines=[
        "Lab035. Упреждающий допуск видеокадров и обслуживание видео целыми кадрами","",
        "1. Исходное состояние",f"- Commit Lab034: {LAB034_COMMIT}.","- Перед Lab035 рабочее дерево было чистым; main опережала origin/main на два commit.","",
        "2. Правило обслуживания","- После первого видеопакета кадр становится активным и не удаляется.","- При повторном выборе видео передаётся следующий пакет активного кадра; команды и телеметрия могут передаваться между пакетами.","- Новый видеокадр начинается только после полного завершения активного; видеопакеты разных кадров не чередуются; отдельный пакет не прерывается.","- Только неактивные кадры могут быть удалены до передачи первого пакета.","",
        "3. Теоретическая пропускная способность","speed | nonvideo kbps | remaining video kbps | remaining/aligned | minimum skip | maximum fps",
    ]
    for rate in CHANNEL_RATES_KBPS:
        c=capacities[rate]; lines.append(f"{rate:.0f} | {c.nonvideo_load_kbps:.3f} | {c.remaining_video_kbps:.3f} | {c.video_capacity_ratio:.6f} | {c.minimum_skip_fraction:.6f} | {c.maximum_update_fps:.3f}")
    lines.extend(["","4. Восемнадцать сочетаний","speed | policy | published/drop/partial | fps | age P95 ms | no-update max ms | queue max/waiting frames | waste bytes | prediction MAE/P95/max ms | control P95/max ms | emergency ms"])
    for r in results:
        s,v,c,p=r.summary,r.video,r.control,r.prediction
        lines.append(f"{s.channel_kbps:.0f} | {POLICY_BY_NAME[s.policy].label} | {v.published_frames}/{v.dropped_before_start_frames}/{v.partially_transmitted_cancelled_frames} | {v.actual_update_fps:.3f} | {v.p95_display_age_ms:.3f} | {v.max_no_update_duration_ms:.3f} | {s.max_queue_packets}/{s.max_waiting_video_frames} | {v.wasted_transmitted_video_bytes} | {p.mean_absolute_error_ms:.6f}/{p.p95_absolute_error_ms:.6f}/{p.max_absolute_error_ms:.6f} | {c.control_p95_delay_ms:.3f}/{c.control_max_delay_ms:.3f} | {c.emergency_delay_ms:.3f}")
    lines.extend(["","5. Интерпретация","- Реактивная Lab034 начинает кадр без гарантии завершения, затем удаляет остаток: уже переданные байты становятся бесполезными, а обновление не публикуется.","- Удаление до первого пакета исключает бесполезную передачу; обслуживание целыми кадрами гарантирует, что начатый кадр будет опубликован.","- Политика самого свежего уменьшает задержку ожидающих данных, но удаляет больше промежуточных кадров; очередь из двух кадров сохраняет больше последовательных обновлений ценой возраста.","- Прогноз полного завершения учитывает весь размер кадра и будущую периодическую высокоприоритетную нагрузку, поэтому полезнее проверки только текущего возраста.","- В модели точно известны команды 20 Гц, телеметрия 10 Гц и аварийная команда 10,0 с; неизвестные будущие дискретные события не моделируются и в реальной системе потребовали бы запаса.","- При устойчивой перегрузке невозможно одновременно сохранить все кадры, исходное JPEG-качество и малую задержку; требуется уменьшить частоту, качество или заранее пропускать кадры.","- Частота обновления, возраст изображения и длительность отсутствия нового изображения оцениваются одновременно: оптимизация одного показателя может ухудшить остальные.","","6. Допущения","- Ошибки и помехи отсутствуют; один общий абстрактный ресурс, форматы Lab028-Lab034 неизменны, активный пакет не прерывается.","- Прогноз не изменяет настоящую очередь; для допущенных кадров сохраняются только агрегированные ошибки, без подробного журнала.","- Политика автоматически не выбирается.","","7. Функциональные проверки"])
    lines.extend(f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}" for item in tests)
    lines.extend(["","8. Созданные файлы"])
    lines.extend(f"- {path.as_posix()}" for path in (Path("protocol/video_frame_scheduler.py"),Path("tests/lab035_video_frame_admission.py"),SUMMARY_CSV_PATH,VIDEO_CSV_PATH,CONTROL_CSV_PATH,PREDICTION_CSV_PATH,REPORT_PATH,*PLOT_PATHS))
    lines.extend(["","9. Итоговый Git status","- Lab035 не добавлена в индекс и не закоммичена.","",git_status])
    REPORT_PATH.write_text("\n".join(lines)+"\n",encoding="utf-8")


def validate_outputs():
    for path in (SUMMARY_CSV_PATH,VIDEO_CSV_PATH,CONTROL_CSV_PATH,PREDICTION_CSV_PATH):
        with path.open(encoding="utf-8",newline="") as file: rows=list(csv.DictReader(file))
        if len(rows)!=18: raise AssertionError(f"{path} must contain 18 rows")
    if "Lab035" not in REPORT_PATH.read_text(encoding="utf-8"): raise AssertionError("invalid report")
    for path in PLOT_PATHS:
        image=cv2.imread(str(path),cv2.IMREAD_UNCHANGED)
        if image is None or image.size==0: raise AssertionError(f"OpenCV could not read {path}")


def main():
    lab033=build_lab033_workload(); aligned=build_aligned_workload(lab033)
    frames=build_frame_groups(aligned); high=high_priority_packets(aligned)
    results=run_experiment(aligned,frames,high)
    tests=run_functional_tests(aligned,frames,high,results)
    save_csv(results); save_plots(results); write_report(aligned,results,tests); validate_outputs()
    print(f"Lab035 complete: {len(results)} scenarios, {len(tests)} checks")


if __name__=="__main__": main()
