use crate::core::config::{
    AppConfig, BrightnessAdjustConfig, ChromaKeyConfig, DisplayConfig, ScaleMode, TransitionType,
    VideoItem,
};
use crate::core::message::PlayerStatusData;
use crate::plugins::video::state_machine::StateMachine;
use anyhow::{anyhow, bail, Context, Result};
use opencv::{
    core::{self, Mat, Point2f, Scalar, Size, Vector},
    highgui, imgproc,
    prelude::*,
    videoio::{self, VideoCapture},
};
use rand::Rng;
use std::collections::HashSet;
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use std::time::Instant;

type WindowRectKey = (String, i32, i32, i32, i32);
type LoggedSizes = ((i32, i32), (i32, i32), (i32, i32));

fn log_mat_size(label: &str, frame: &Mat) {
    static SEEN: OnceLock<Mutex<HashSet<(String, i32, i32)>>> = OnceLock::new();
    let key = (label.to_string(), frame.cols(), frame.rows());
    let mut guard = SEEN
        .get_or_init(|| Mutex::new(HashSet::new()))
        .lock()
        .unwrap();
    if guard.insert(key) {
        println!("{}: cols={} rows={}", label, frame.cols(), frame.rows());
    }
}

fn log_window_image_rect(window_name: &str) {
    static SEEN: OnceLock<Mutex<HashSet<WindowRectKey>>> = OnceLock::new();
    match highgui::get_window_image_rect(window_name) {
        Ok(rect) => {
            let key = (
                window_name.to_string(),
                rect.x,
                rect.y,
                rect.width,
                rect.height,
            );
            let mut guard = SEEN
                .get_or_init(|| Mutex::new(HashSet::new()))
                .lock()
                .unwrap();
            if guard.insert(key) {
                println!(
                    "window.image_rect {}: x={} y={} width={} height={}",
                    window_name, rect.x, rect.y, rect.width, rect.height,
                );
            }
        }
        Err(error) => eprintln!("window.image_rect {} unavailable: {}", window_name, error),
    }
}

pub struct StepOutcome {
    pub window_name: String,
    pub frame: Option<Mat>,
    pub delay: i32,
    pub keep_running: bool,
}

impl StepOutcome {
    fn new(window_name: &str, frame: Option<Mat>, delay: i32, keep_running: bool) -> Self {
        Self {
            window_name: window_name.to_string(),
            frame,
            delay,
            keep_running,
        }
    }

    fn idle(window_name: &str) -> Self {
        Self::new(window_name, None, 1, true)
    }

    fn stop(window_name: &str) -> Self {
        Self::new(window_name, None, 1, false)
    }
}

#[derive(Debug, Clone)]
pub struct VideoTransformer {
    rotation: i32,
    flip_horizontal: bool,
    flip_vertical: bool,
    render_size: Size,
    scale_mode: ScaleMode,
    allow_upscale: bool,
    perspective_enabled: bool,
    perspective_points: Option<Vec<Point2f>>,
    chroma_key: ChromaKeyConfig,
    brightness_adjust: BrightnessAdjustConfig,
}

impl VideoTransformer {
    pub fn new(config: &DisplayConfig) -> Self {
        let perspective_points = if config.perspective_correction.enabled
            && config.perspective_correction.points.len() == 4
        {
            Some(
                config
                    .perspective_correction
                    .points
                    .iter()
                    .map(|point| Point2f::new(point[0] as f32, point[1] as f32))
                    .collect(),
            )
        } else {
            None
        };

        Self {
            rotation: config.rotation,
            flip_horizontal: config.flip_horizontal,
            flip_vertical: config.flip_vertical,
            render_size: Size::new(config.render_width, config.render_height),
            scale_mode: config.scale_mode,
            allow_upscale: config.allow_upscale,
            perspective_enabled: config.perspective_correction.enabled,
            perspective_points,
            chroma_key: config.chroma_key.clone(),
            brightness_adjust: config.brightness_adjust.clone(),
        }
    }

    pub fn transform(&self, frame: &Mat) -> Result<Mat> {
        if cfg!(debug_assertions) {
            log_mat_size("transform.input", frame);
        }
        let scaled = self.scale_to_render_resolution(frame)?;
        if cfg!(debug_assertions) {
            log_mat_size("transform.after_scale_to_render_resolution", &scaled);
        }
        let mut transformed = self.rotate(&scaled)?;
        if cfg!(debug_assertions) {
            log_mat_size("transform.after_rotate", &transformed);
        }

        if self.flip_horizontal {
            let mut flipped = Mat::default();
            core::flip(&transformed, &mut flipped, 1)?;
            transformed = flipped;
            if cfg!(debug_assertions) {
                log_mat_size("transform.after_flip_horizontal", &transformed);
            }
        }

        if self.flip_vertical {
            let mut flipped = Mat::default();
            core::flip(&transformed, &mut flipped, 0)?;
            transformed = flipped;
            if cfg!(debug_assertions) {
                log_mat_size("transform.after_flip_vertical", &transformed);
            }
        }

        if self.chroma_key.enabled {
            transformed = self.apply_chroma_key(&transformed)?;
            if cfg!(debug_assertions) {
                log_mat_size("transform.after_chroma_key", &transformed);
            }
        }

        if self.brightness_adjust.enabled {
            transformed = self.apply_brightness_adjust(&transformed)?;
            if cfg!(debug_assertions) {
                log_mat_size("transform.after_brightness_adjust", &transformed);
            }
        }

        if self.perspective_enabled {
            transformed = self.apply_perspective(&transformed)?;
            if cfg!(debug_assertions) {
                log_mat_size("transform.after_perspective", &transformed);
            }
        }

        if cfg!(debug_assertions) {
            log_mat_size("transform.output", &transformed);
        }
        Ok(transformed)
    }

    pub fn render_size(&self) -> Size {
        self.render_size
    }

    fn rotate(&self, frame: &Mat) -> Result<Mat> {
        let mut rotated = Mat::default();
        match self.rotation {
            90 => core::rotate(frame, &mut rotated, core::ROTATE_90_CLOCKWISE)?,
            180 => core::rotate(frame, &mut rotated, core::ROTATE_180)?,
            270 => core::rotate(frame, &mut rotated, core::ROTATE_90_COUNTERCLOCKWISE)?,
            _ => return Ok(frame.try_clone()?),
        }

        Ok(rotated)
    }

    fn apply_perspective(&self, frame: &Mat) -> Result<Mat> {
        let Some(points) = &self.perspective_points else {
            return Ok(frame.try_clone()?);
        };

        if cfg!(debug_assertions) {
            log_mat_size("apply_perspective.input", frame);
        }

        let width = frame.cols() as f32;
        let height = frame.rows() as f32;
        let src = Vector::<Point2f>::from_iter(vec![
            Point2f::new(0.0, 0.0),
            Point2f::new(width, 0.0),
            Point2f::new(width, height),
            Point2f::new(0.0, height),
        ]);

        let dst = Vector::<Point2f>::from_iter(points.iter().map(|point| {
            Point2f::new(
                point.x * width / self.render_size.width as f32,
                point.y * height / self.render_size.height as f32,
            )
        }));

        let matrix = imgproc::get_perspective_transform(&src, &dst, core::DECOMP_LU)?;
        let mut warped = Mat::default();
        imgproc::warp_perspective(
            frame,
            &mut warped,
            &matrix,
            Size::new(frame.cols(), frame.rows()),
            imgproc::INTER_LINEAR,
            core::BORDER_CONSTANT,
            Scalar::default(),
        )?;

        if cfg!(debug_assertions) {
            log_mat_size("apply_perspective.output", &warped);
        }

        Ok(warped)
    }

    fn apply_chroma_key(&self, frame: &Mat) -> Result<Mat> {
        let ck = &self.chroma_key;

        let mut hsv = Mat::default();
        imgproc::cvt_color(frame, &mut hsv, imgproc::COLOR_BGR2HSV, 0)?;

        let lower = Scalar::new(
            ck.hsv_min[0] as f64,
            ck.hsv_min[1] as f64,
            ck.hsv_min[2] as f64,
            0.0,
        );
        let upper = Scalar::new(
            ck.hsv_max[0] as f64,
            ck.hsv_max[1] as f64,
            ck.hsv_max[2] as f64,
            0.0,
        );
        let mut mask = Mat::default();
        core::in_range(&hsv, &lower, &upper, &mut mask)?;

        if ck.invert {
            let mut inverted = Mat::default();
            core::bitwise_not(&mask, &mut inverted, &core::no_array())?;
            mask = inverted;
        }

        if ck.feather > 0 {
            let ksize = ck.feather * 2 + 1;
            let mut blurred = Mat::default();
            imgproc::gaussian_blur(
                &mask,
                &mut blurred,
                Size::new(ksize, ksize),
                0.0,
                0.0,
                core::BORDER_DEFAULT,
            )?;
            mask = blurred;
        }

        let mut result = frame.try_clone()?;
        result.set_to(&Scalar::all(0.0), &mask)?;
        Ok(result)
    }

    fn apply_brightness_adjust(&self, frame: &Mat) -> Result<Mat> {
        let cfg = &self.brightness_adjust;

        let mut gray = Mat::default();
        imgproc::cvt_color(frame, &mut gray, imgproc::COLOR_BGR2GRAY, 0)?;

        let mut subject_mask = Mat::default();
        imgproc::threshold(
            &gray,
            &mut subject_mask,
            cfg.threshold as f64,
            255.0,
            imgproc::THRESH_BINARY,
        )?;

        let mut bg_mask = Mat::default();
        core::bitwise_not(&subject_mask, &mut bg_mask, &core::no_array())?;

        let mut boosted = Mat::default();
        frame.convert_to(&mut boosted, -1, cfg.subject_boost, 0.0)?;

        let mut suppressed = Mat::default();
        frame.convert_to(&mut suppressed, -1, cfg.background_suppress, 0.0)?;

        let mut result = Mat::zeros(frame.rows(), frame.cols(), frame.typ())?.to_mat()?;
        boosted.copy_to_masked(&mut result, &subject_mask)?;
        suppressed.copy_to_masked(&mut result, &bg_mask)?;

        Ok(result)
    }

    fn scale_to_render_resolution(&self, frame: &Mat) -> Result<Mat> {
        if frame.cols() <= 0 || frame.rows() <= 0 {
            return Ok(frame.try_clone()?);
        }

        match self.scale_mode {
            ScaleMode::Fit => self.scale_fit(frame, self.render_size),
            ScaleMode::Stretch => self.scale_stretch(frame, self.render_size),
        }
    }

    fn scale_fit(&self, frame: &Mat, target: Size) -> Result<Mat> {
        let src_width = frame.cols() as f64;
        let src_height = frame.rows() as f64;
        let target_width = target.width as f64;
        let target_height = target.height as f64;

        let mut scale = (target_width / src_width).min(target_height / src_height);
        if !self.allow_upscale {
            scale = scale.min(1.0);
        }

        let scaled_width = ((src_width * scale).round() as i32).clamp(1, target.width.max(1));
        let scaled_height = ((src_height * scale).round() as i32).clamp(1, target.height.max(1));
        let scaled_size = Size::new(scaled_width, scaled_height);

        let scaled = if scaled_size == frame.size()? {
            frame.try_clone()?
        } else {
            self.resize_frame(frame, scaled_size, scale)?
        };

        let vertical_padding = (target.height - scaled_height).max(0);
        let horizontal_padding = (target.width - scaled_width).max(0);
        let top = vertical_padding / 2;
        let bottom = vertical_padding - top;
        let left = horizontal_padding / 2;
        let right = horizontal_padding - left;

        if top == 0 && bottom == 0 && left == 0 && right == 0 {
            return Ok(scaled);
        }

        let mut padded = Mat::default();
        core::copy_make_border(
            &scaled,
            &mut padded,
            top,
            bottom,
            left,
            right,
            core::BORDER_CONSTANT,
            Scalar::all(0.0),
        )?;
        Ok(padded)
    }

    fn scale_stretch(&self, frame: &Mat, target: Size) -> Result<Mat> {
        if frame.size()? == target {
            return Ok(frame.try_clone()?);
        }

        let mut resized = Mat::default();
        imgproc::resize(frame, &mut resized, target, 0.0, 0.0, imgproc::INTER_LINEAR)?;
        Ok(resized)
    }

    fn resize_frame(&self, frame: &Mat, target: Size, scale: f64) -> Result<Mat> {
        let interpolation = if scale < 1.0 {
            imgproc::INTER_AREA
        } else if scale > 1.0 {
            imgproc::INTER_CUBIC
        } else {
            imgproc::INTER_LINEAR
        };

        let mut resized = Mat::default();
        imgproc::resize(frame, &mut resized, target, 0.0, 0.0, interpolation)?;
        Ok(resized)
    }
}

#[derive(Debug, Clone)]
pub struct TransitionEffect {
    duration: f64,
    effect_type: TransitionType,
}

impl TransitionEffect {
    pub fn new(duration: f64, effect_type: TransitionType) -> Self {
        Self {
            duration: duration.max(0.001),
            effect_type,
        }
    }

    pub fn duration(&self) -> f64 {
        self.duration
    }

    pub fn apply(&self, previous: Option<&Mat>, current: &Mat, progress: f64) -> Result<Mat> {
        let progress = progress.clamp(0.0, 1.0);
        let Some(previous) = previous else {
            return Ok(current.try_clone()?);
        };

        if progress >= 1.0 || self.effect_type != TransitionType::Fade {
            return Ok(current.try_clone()?);
        }

        let previous_frame = if previous.size()? != current.size()? {
            let mut resized = Mat::default();
            imgproc::resize(
                previous,
                &mut resized,
                current.size()?,
                0.0,
                0.0,
                imgproc::INTER_LINEAR,
            )?;
            resized
        } else {
            previous.try_clone()?
        };

        let mut blended = Mat::default();
        core::add_weighted(
            &previous_frame,
            1.0 - progress,
            current,
            progress,
            0.0,
            &mut blended,
            -1,
        )?;

        Ok(blended)
    }
}

pub struct VideoProcessor {
    config: AppConfig,
    transformer: VideoTransformer,
    output_size: Size,
    last_logged_sizes: Option<LoggedSizes>,
    transition_enabled: bool,
    transition: TransitionEffect,
    playlist: Vec<VideoItem>,
    current_index: usize,
    current_loop: i32,
    max_loops: i32,
    loop_playlist: bool,
    cap: Option<VideoCapture>,
    window_name: String,
    running: bool,
    paused: bool,
    in_transition: bool,
    transition_start: Option<Instant>,
    last_frame: Option<Mat>,
    reload_requested: bool,
    state_machine: Option<StateMachine>,
    cached_screen_size: Option<Size>,
}

impl VideoProcessor {
    pub fn new(config: AppConfig) -> Result<Self> {
        let transformer = VideoTransformer::new(&config.display);
        let transition = TransitionEffect::new(
            config.transition.duration,
            config.transition.transition_type.clone(),
        );

        let state_machine = if let Some(sm_config) = &config.scenes.state_machine {
            let mut sm = StateMachine::new(sm_config.clone());
            sm.start()?;
            Some(sm)
        } else {
            None
        };

        Ok(Self {
            playlist: config.playlist.clone(),
            current_index: 0,
            current_loop: 0,
            max_loops: 1,
            loop_playlist: config.playback.loop_playlist,
            window_name: config.display.window_title.clone(),
            transition_enabled: config.transition.enabled,
            config,
            transformer,
            output_size: Size::new(0, 0),
            last_logged_sizes: None,
            transition,
            cap: None,
            running: false,
            paused: false,
            in_transition: false,
            transition_start: None,
            last_frame: None,
            reload_requested: false,
            state_machine,
            cached_screen_size: None,
        })
    }

    pub fn run(&mut self) -> Result<()> {
        self.start()?;

        while self.running {
            let outcome = self.step()?;
            if let Some(frame) = outcome.frame {
                self.display_frame(&outcome.window_name, &frame)?;
            }

            if !outcome.keep_running {
                break;
            }

            let key = highgui::wait_key(outcome.delay)?;
            if !self.handle_key_code_internal(key)? {
                break;
            }
        }

        self.stop()?;
        Ok(())
    }

    pub fn run_shared(shared: Arc<Mutex<Self>>) -> Result<()> {
        {
            let mut processor = lock_processor(&shared)?;
            processor.start()?;
        }

        loop {
            let outcome = {
                let mut processor = lock_processor(&shared)?;
                processor.step()?
            };

            if let Some(frame) = outcome.frame {
                let processor = lock_processor(&shared)?;
                processor.display_frame(&outcome.window_name, &frame)?;
            }

            if !outcome.keep_running {
                break;
            }

            let key = highgui::wait_key(outcome.delay)?;
            let should_continue = {
                let mut processor = lock_processor(&shared)?;
                processor.handle_key_code_internal(key)?
            };

            if !should_continue {
                break;
            }
        }

        let mut processor = lock_processor(&shared)?;
        processor.stop()?;
        Ok(())
    }

    pub fn start(&mut self) -> Result<()> {
        if self.playlist.is_empty() {
            bail!("playlist is empty");
        }

        self.running = true;
        self.cached_screen_size = Some(self.detect_screen_size());
        self.ensure_window()?;
        self.reload_current_video()?;
        Ok(())
    }

    pub fn stop(&mut self) -> Result<()> {
        self.running = false;
        if let Some(mut cap) = self.cap.take() {
            cap.release()?;
        }
        highgui::destroy_all_windows()?;
        Ok(())
    }

    pub fn play(&mut self) -> Result<()> {
        if !self.running {
            self.start()?;
        } else {
            self.resume();
        }
        Ok(())
    }

    pub fn pause(&mut self) {
        self.paused = true;
    }

    pub fn resume(&mut self) {
        self.paused = false;
    }

    pub fn next_video(&mut self) -> Result<()> {
        if self.playlist.is_empty() {
            return Ok(());
        }

        if self.current_index + 1 >= self.playlist.len() {
            if self.loop_playlist {
                self.current_index = 0;
            } else {
                self.running = false;
                return Ok(());
            }
        } else {
            self.current_index += 1;
        }

        self.begin_transition();
        self.reload_requested = true;
        self.current_loop = 0;
        Ok(())
    }

    pub fn previous(&mut self) -> Result<()> {
        if self.playlist.is_empty() || self.current_index == 0 {
            return Ok(());
        }

        self.current_index -= 1;
        self.begin_transition();
        self.reload_requested = true;
        self.current_loop = 0;
        Ok(())
    }

    pub fn goto(&mut self, index: usize) -> Result<()> {
        if index >= self.playlist.len() {
            return Ok(());
        }

        self.current_index = index;
        self.begin_transition();
        self.reload_requested = true;
        self.current_loop = 0;
        Ok(())
    }

    pub fn trigger(&mut self, trigger_name: &str, trigger_value: &str) -> Result<bool> {
        if let Some(sm) = &mut self.state_machine {
            let triggered = sm.handle_trigger(trigger_name, trigger_value)?;
            if triggered {
                if let Some(video_id) = sm.current_video_id() {
                    if let Some(index) = self.playlist.iter().position(|v| v.id == video_id) {
                        self.current_index = index;
                        self.begin_transition();
                        self.reload_requested = true;
                        self.current_loop = 0;
                        return Ok(true);
                    }

                    bail!("Video ID '{}' not found in playlist", video_id);
                }
            }
            Ok(triggered)
        } else {
            Ok(false)
        }
    }

    pub fn change_scene(&mut self, scene_name: &str) -> Result<bool> {
        if self.state_machine.is_some() {
            println!(
                "change_scene('{}') ignored: state machine mode is active, use trigger() instead",
                scene_name,
            );
            return Ok(false);
        }

        let Some(scene_playlist) = self.scene_playlist(scene_name) else {
            return Ok(false);
        };

        if scene_playlist.is_empty() {
            return Ok(false);
        }

        self.playlist = scene_playlist.to_vec();
        self.current_index = 0;
        self.current_loop = 0;
        self.begin_transition();
        self.reload_requested = true;
        Ok(true)
    }

    pub fn current_state(&self) -> Option<&str> {
        self.state_machine
            .as_ref()
            .map(|machine| machine.current_state.as_str())
    }

    pub fn status(&self) -> PlayerStatusData {
        PlayerStatusData {
            running: self.running,
            paused: self.paused,
            in_transition: self.in_transition,
            current_index: self.current_index,
            playlist_length: self.playlist.len(),
            current_video: self.current_video_id(),
        }
    }

    pub fn step(&mut self) -> Result<StepOutcome> {
        if !self.running {
            return Ok(StepOutcome::stop(&self.window_name));
        }

        if self.paused {
            return Ok(StepOutcome::new(
                &self.window_name,
                self.last_frame.as_ref().map(Mat::try_clone).transpose()?,
                100,
                true,
            ));
        }

        if self.reload_requested {
            self.reload_current_video()?;
            return Ok(StepOutcome::idle(&self.window_name));
        }

        let mut frame = Mat::default();
        let read_ok = self.capture_mut()?.read(&mut frame)?;
        if !read_ok || frame.empty() {
            self.current_loop += 1;
            if self.current_loop < self.max_loops {
                self.capture_mut()?.set(videoio::CAP_PROP_POS_FRAMES, 0.0)?;
                return Ok(StepOutcome::idle(&self.window_name));
            }

            self.current_loop = 0;
            let old_video_id = self.current_video_id();
            self.advance_playlist();
            if !self.running {
                return Ok(StepOutcome::stop(&self.window_name));
            }

            let new_video_id = self.current_video_id();
            if self.state_machine.is_some() && old_video_id == new_video_id {
                self.capture_mut()?.set(videoio::CAP_PROP_POS_FRAMES, 0.0)?;
            } else {
                self.reload_current_video()?;
            }

            if self.in_transition && self.last_frame.is_some() {
                return Ok(StepOutcome::new(
                    &self.window_name,
                    self.last_frame.as_ref().map(Mat::try_clone).transpose()?,
                    1,
                    true,
                ));
            }

            return Ok(StepOutcome::idle(&self.window_name));
        }

        log_mat_size("cap.read.output", &frame);

        let processed = self.transformer.transform(&frame)?;
        log_mat_size("step.after_transform", &processed);

        let output_source = if self.in_transition {
            let transitioned = self.apply_transition(&processed)?;
            log_mat_size("step.after_transition", &transitioned);
            transitioned
        } else {
            processed
        };

        let output = self.prepare_output_frame(output_source)?;
        log_mat_size("step.output_frame", &output);
        self.last_frame = Some(output.try_clone()?);

        let delay = self.frame_delay()?;
        let window_name = self.window_name.clone();
        Ok(StepOutcome::new(&window_name, Some(output), delay, true))
    }

    pub(crate) fn display_frame(&self, window_name: &str, frame: &Mat) -> Result<()> {
        log_mat_size("imshow.input", frame);

        if self.config.display.fullscreen {
            let frame_size = frame.size()?;
            let screen_size = self.detect_screen_size();

            if frame_size == screen_size {
                highgui::imshow(window_name, frame)?;
            } else {
                let mut resized = Mat::default();
                imgproc::resize(
                    frame,
                    &mut resized,
                    screen_size,
                    0.0,
                    0.0,
                    imgproc::INTER_LINEAR,
                )?;
                highgui::imshow(window_name, &resized)?;
            }
        } else {
            highgui::imshow(window_name, frame)?;
        }

        log_window_image_rect(window_name);
        Ok(())
    }

    pub(crate) fn handle_key_code(&mut self, key: i32) -> Result<()> {
        self.handle_key_code_internal(key)?;
        Ok(())
    }

    fn ensure_window(&mut self) -> Result<()> {
        let output_size = self
            .configured_output_size()
            .unwrap_or_else(|| self.transformer.render_size());

        println!(
            "Creating window '{}' with output_size={}x{} fullscreen={}",
            self.window_name, output_size.width, output_size.height, self.config.display.fullscreen,
        );

        highgui::named_window(&self.window_name, highgui::WINDOW_NORMAL)?;

        if self.config.display.fullscreen {
            let screen_size = self.detect_screen_size();

            highgui::resize_window(&self.window_name, screen_size.width, screen_size.height)?;
            highgui::move_window(&self.window_name, 0, 0)?;
            highgui::set_window_property(
                &self.window_name,
                highgui::WND_PROP_FULLSCREEN,
                highgui::WINDOW_FULLSCREEN as f64,
            )?;

            let black_frame =
                Mat::zeros(screen_size.height, screen_size.width, core::CV_8UC3)?.to_mat()?;
            highgui::imshow(&self.window_name, &black_frame)?;
            highgui::wait_key(1)?;

            println!(
                "Fullscreen window initialized: screen={}x{}",
                screen_size.width, screen_size.height,
            );
        }

        Self::hide_cursor();
        self.output_size = output_size;

        Ok(())
    }

    fn hide_cursor() {
        let _ = std::process::Command::new("pkill")
            .args(["-f", "unclutter"])
            .status();
        match std::process::Command::new("unclutter")
            .args(["-idle", "0", "-root"])
            .spawn()
        {
            Ok(_) => println!("Cursor hidden via unclutter"),
            Err(error) => eprintln!("Failed to start unclutter: {}", error),
        }
    }

    fn show_cursor() {
        let _ = std::process::Command::new("pkill")
            .args(["-f", "unclutter"])
            .status();
    }

    fn configured_output_size(&self) -> Option<Size> {
        match (
            self.config.display.output_width,
            self.config.display.output_height,
        ) {
            (Some(width), Some(height)) => Some(Size::new(width, height)),
            _ => None,
        }
    }

    fn detect_screen_size(&self) -> Size {
        if let Some(cached) = self.cached_screen_size {
            return cached;
        }

        if std::env::var("DISPLAY").is_ok() {
            if let Ok(output) = std::process::Command::new("xrandr").output() {
                if let Ok(text) = String::from_utf8(output.stdout) {
                    for line in text.lines() {
                        if line.contains('*') {
                            let parts: Vec<&str> = line.split_whitespace().collect();
                            if let Some(resolution) = parts.first() {
                                let dims: Vec<&str> = resolution.split('x').collect();
                                if dims.len() == 2 {
                                    let w_str =
                                        dims[0].trim_end_matches(|c: char| !c.is_ascii_digit());
                                    let h_str =
                                        dims[1].trim_end_matches(|c: char| !c.is_ascii_digit());
                                    if let (Ok(w), Ok(h)) =
                                        (w_str.parse::<i32>(), h_str.parse::<i32>())
                                    {
                                        println!(
                                            "Detected screen size from xrandr (X11 active): {}x{}",
                                            w, h,
                                        );
                                        return Size::new(w, h);
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }

        if let Ok(content) = std::fs::read_to_string("/sys/class/graphics/fb0/modes") {
            let trimmed = content.trim();
            if let Some(x_pos) = trimmed.find('x') {
                let start = trimmed[..x_pos]
                    .rfind(|c: char| !c.is_ascii_digit())
                    .map(|p| p + 1)
                    .unwrap_or(0);
                let end = trimmed[x_pos + 1..]
                    .find(|c: char| !c.is_ascii_digit())
                    .map(|p| x_pos + 1 + p)
                    .unwrap_or(trimmed.len());
                let w_str = &trimmed[start..x_pos];
                let h_str = &trimmed[x_pos + 1..end];
                if let (Ok(w), Ok(h)) = (w_str.parse::<i32>(), h_str.parse::<i32>()) {
                    println!("Detected screen size from fb0/modes: {}x{}", w, h);
                    return Size::new(w, h);
                }
            }
        }

        if let Ok(content) = std::fs::read_to_string("/sys/class/graphics/fb0/virtual_size") {
            let parts: Vec<&str> = content.trim().split(',').collect();
            if parts.len() == 2 {
                if let (Ok(w), Ok(h)) = (parts[0].parse::<i32>(), parts[1].parse::<i32>()) {
                    println!("Detected screen size from fb0/virtual_size: {}x{}", w, h);
                    return Size::new(w, h);
                }
            }
        }

        let render = self.transformer.render_size();
        println!(
            "Could not detect screen size, using render_size fallback: {}x{}",
            render.width, render.height,
        );
        render
    }

    fn prepare_output_frame(&mut self, frame: Mat) -> Result<Mat> {
        if self.config.display.fullscreen {
            let offset_x = self.config.display.offset_x;
            let offset_y = self.config.display.offset_y;

            if offset_x != 0 || offset_y != 0 {
                let screen_size = self.detect_screen_size();
                let frame_size = frame.size()?;
                let output =
                    Mat::zeros(screen_size.height, screen_size.width, frame.typ())?.to_mat()?;

                let dst_x = offset_x.max(0).min(screen_size.width - 1);
                let dst_y = offset_y.max(0).min(screen_size.height - 1);
                let copy_width = frame_size.width.min(screen_size.width - dst_x).max(0);
                let copy_height = frame_size.height.min(screen_size.height - dst_y).max(0);

                if copy_width > 0 && copy_height > 0 {
                    let src_roi = core::Rect::new(0, 0, copy_width, copy_height);
                    let dst_roi = core::Rect::new(dst_x, dst_y, copy_width, copy_height);
                    let src_region = Mat::roi(&frame, src_roi)?;
                    let mut dst_region = Mat::roi(&output, dst_roi)?;
                    src_region.copy_to(&mut dst_region)?;
                }

                log_mat_size("prepare_output_frame.fullscreen+offset", &output);
                return Ok(output);
            }

            log_mat_size("prepare_output_frame.fullscreen_bypass", &frame);
            return Ok(frame);
        }

        let target = self.output_size;
        let frame_size = frame.size()?;
        log_mat_size("prepare_output_frame.input", &frame);
        self.log_output_sizes(frame_size);

        if target.width <= 0 || target.height <= 0 {
            log_mat_size("prepare_output_frame.bypass_invalid_target", &frame);
            return Ok(frame);
        }

        if frame_size == target {
            log_mat_size("prepare_output_frame.bypass_matches_target", &frame);
            return Ok(frame);
        }

        let output = match self.config.display.scale_mode {
            ScaleMode::Fit => self.scale_fit_to_output(&frame, target),
            ScaleMode::Stretch => self.scale_stretch_to_output(&frame, target),
        }?;

        log_mat_size("prepare_output_frame.output", &output);
        Ok(output)
    }

    fn log_output_sizes(&mut self, frame_size: Size) {
        let render_size = self.transformer.render_size();
        let current_sizes = (
            (render_size.width, render_size.height),
            (self.output_size.width, self.output_size.height),
            (frame_size.width, frame_size.height),
        );

        if self.last_logged_sizes == Some(current_sizes) {
            return;
        }

        self.last_logged_sizes = Some(current_sizes);
        println!(
            "Frame sizing render_size={}x{} output_size={}x{} frame_size={}x{}",
            render_size.width,
            render_size.height,
            self.output_size.width,
            self.output_size.height,
            frame_size.width,
            frame_size.height,
        );
    }

    fn scale_fit_to_output(&self, frame: &Mat, target: Size) -> Result<Mat> {
        let src_width = frame.cols() as f64;
        let src_height = frame.rows() as f64;
        let target_width = target.width as f64;
        let target_height = target.height as f64;

        let mut scale = (target_width / src_width).min(target_height / src_height);
        if !self.config.display.allow_upscale {
            scale = scale.min(1.0);
        }

        let scaled_width = ((src_width * scale).round() as i32).clamp(1, target.width.max(1));
        let scaled_height = ((src_height * scale).round() as i32).clamp(1, target.height.max(1));
        let scaled_size = Size::new(scaled_width, scaled_height);

        let scaled = if scaled_size == frame.size()? {
            frame.try_clone()?
        } else {
            self.resize_frame_for_output(frame, scaled_size, scale)?
        };

        let vertical_padding = (target.height - scaled_height).max(0);
        let horizontal_padding = (target.width - scaled_width).max(0);
        let offset_x = self.config.display.offset_x;
        let offset_y = self.config.display.offset_y;

        let mut top = vertical_padding / 2;
        let mut left = horizontal_padding / 2;

        top = (top + offset_y).max(0).min(vertical_padding);
        let bottom = vertical_padding - top;

        left = (left + offset_x).max(0).min(horizontal_padding);
        let right = horizontal_padding - left;

        if top == 0 && bottom == 0 && left == 0 && right == 0 {
            log_mat_size("scale_fit_to_output.no_padding", &scaled);
            return Ok(scaled);
        }

        let border_color = if scaled.channels() == 4 {
            Scalar::new(0.0, 0.0, 0.0, 255.0)
        } else {
            Scalar::new(0.0, 0.0, 0.0, 0.0)
        };

        let mut padded = Mat::default();
        core::copy_make_border(
            &scaled,
            &mut padded,
            top,
            bottom,
            left,
            right,
            core::BORDER_CONSTANT,
            border_color,
        )?;
        log_mat_size("scale_fit_to_output.padded", &padded);
        Ok(padded)
    }

    fn scale_stretch_to_output(&self, frame: &Mat, target: Size) -> Result<Mat> {
        if frame.size()? == target {
            return Ok(frame.try_clone()?);
        }

        let mut output = Mat::default();
        imgproc::resize(frame, &mut output, target, 0.0, 0.0, imgproc::INTER_LINEAR)?;
        Ok(output)
    }

    fn resize_frame_for_output(&self, frame: &Mat, target: Size, scale: f64) -> Result<Mat> {
        let interpolation = if scale < 1.0 {
            imgproc::INTER_AREA
        } else if scale > 1.0 {
            imgproc::INTER_CUBIC
        } else {
            imgproc::INTER_LINEAR
        };

        let mut resized = Mat::default();
        imgproc::resize(frame, &mut resized, target, 0.0, 0.0, interpolation)?;
        Ok(resized)
    }

    fn reload_current_video(&mut self) -> Result<()> {
        self.reload_requested = false;
        self.open_video(self.current_video()?.clone())?;
        Ok(())
    }

    fn open_video(&mut self, video_info: VideoItem) -> Result<()> {
        if let Some(mut cap) = self.cap.take() {
            cap.release()?;
        }

        let resolved_path = self.config.resolve_media_path(&video_info);
        let video_path = resolved_path.to_string_lossy().into_owned();
        let capture = VideoCapture::from_file(&video_path, videoio::CAP_ANY)
            .with_context(|| format!("failed to create capture for {}", resolved_path.display()))?;
        if !capture.is_opened()? {
            bail!("failed to open video: {}", resolved_path.display());
        }

        if self.state_machine.is_some() {
            self.max_loops = 1;
        } else {
            self.max_loops = resolve_video_loop_count(&video_info);
        }
        self.current_loop = 0;
        self.cap = Some(capture);
        println!("Playing: {} - {}", video_info.id, resolved_path.display());
        Ok(())
    }

    fn capture_mut(&mut self) -> Result<&mut VideoCapture> {
        self.cap
            .as_mut()
            .context("video capture is not initialized")
    }

    fn current_video(&self) -> Result<&VideoItem> {
        self.playlist
            .get(self.current_index)
            .context("current playlist index is out of bounds")
    }

    fn current_video_id(&self) -> Option<String> {
        self.playlist
            .get(self.current_index)
            .map(|video| video.id.clone())
    }

    fn frame_delay(&mut self) -> Result<i32> {
        let fps = self.capture_mut()?.get(videoio::CAP_PROP_FPS)?;
        let fps = if fps.is_finite() && fps > 1.0 {
            fps
        } else {
            30.0
        };
        Ok((1000.0 / fps).round() as i32)
    }

    fn apply_transition(&mut self, frame: &Mat) -> Result<Mat> {
        if !self.in_transition {
            return Ok(frame.try_clone()?);
        }

        let progress = self.transition_progress();
        let transitioned = self
            .transition
            .apply(self.last_frame.as_ref(), frame, progress)?;
        if progress >= 1.0 {
            self.in_transition = false;
            self.transition_start = None;
        }

        Ok(transitioned)
    }

    fn transition_progress(&self) -> f64 {
        let Some(started_at) = self.transition_start else {
            return 1.0;
        };

        (started_at.elapsed().as_secs_f64() / self.transition.duration()).clamp(0.0, 1.0)
    }

    fn handle_key_code_internal(&mut self, key: i32) -> Result<bool> {
        match key {
            27 => {
                self.running = false;
                Ok(false)
            }
            32 => {
                self.paused = !self.paused;
                Ok(true)
            }
            110 | 78 => {
                self.next_video()?;
                Ok(self.running)
            }
            112 | 80 => {
                self.previous()?;
                Ok(true)
            }
            _ => Ok(true),
        }
    }

    fn advance_playlist(&mut self) {
        if let Some(sm) = &mut self.state_machine {
            let old_video_id = sm.current_video_id();

            let state_changed = match sm.on_video_completed() {
                Ok(changed) => changed,
                Err(error) => {
                    eprintln!("State machine error: {}", error);
                    self.running = false;
                    return;
                }
            };

            if !state_changed {
                if let Err(error) = sm.check_random_triggers() {
                    eprintln!("State machine random trigger error: {}", error);
                }
            }

            let new_video_id = sm.current_video_id();
            if let Some(video_id) = new_video_id.clone() {
                if let Some(index) = self.playlist.iter().position(|v| v.id == video_id) {
                    self.current_index = index;

                    if old_video_id.as_deref() != new_video_id.as_deref() {
                        self.begin_transition();
                    }
                    return;
                }

                eprintln!("Video ID '{}' not found in playlist", video_id);
                self.running = false;
                return;
            }

            self.running = false;
            return;
        }

        if self.playlist.is_empty() {
            self.running = false;
            return;
        }

        self.current_index += 1;
        if self.current_index >= self.playlist.len() {
            if self.loop_playlist {
                self.current_index = 0;
            } else {
                self.running = false;
                return;
            }
        }

        self.begin_transition();
    }

    fn begin_transition(&mut self) {
        if self.transition_enabled && self.last_frame.is_some() {
            self.in_transition = true;
            self.transition_start = Some(Instant::now());
        }
    }

    fn scene_playlist(&self, scene_name: &str) -> Option<&[VideoItem]> {
        match scene_name {
            "rest" => Some(&self.config.scenes.rest),
            "active" => Some(&self.config.scenes.active),
            "sleep" => Some(&self.config.scenes.sleep),
            "interact" => Some(&self.config.scenes.interact),
            _ => None,
        }
    }
}

fn resolve_video_loop_count(item: &VideoItem) -> i32 {
    if let Some([start, end]) = item.random_loop_range {
        let min = start.min(end);
        let max = start.max(end);
        return rand::thread_rng().gen_range(min..=max).max(1);
    }

    item.loop_count.max(1)
}

fn lock_processor(shared: &Arc<Mutex<VideoProcessor>>) -> Result<MutexGuard<'_, VideoProcessor>> {
    shared
        .lock()
        .map_err(|_| anyhow!("video processor lock poisoned"))
}

impl Drop for VideoProcessor {
    fn drop(&mut self) {
        Self::show_cursor();
    }
}

#[cfg(test)]
mod tests {
    use super::{resolve_video_loop_count, TransitionEffect, VideoProcessor, VideoTransformer};
    use crate::core::config::{
        AnimationStep, AppConfig, BleConfig, DisplayConfig, PerspectiveCorrectionConfig,
        PlaybackConfig, RemoteControlConfig, ScaleMode, ScenesConfig, StateConfig,
        StateMachineConfig, StateMode, StateTransition, TransitionConfig, TransitionType,
        TriggerType, VideoItem,
    };
    use opencv::{
        core::{Scalar, Size, Vec3b, CV_8UC3},
        prelude::*,
    };
    use std::collections::HashMap;
    use std::path::PathBuf;

    #[test]
    fn transformer_applies_rotation_flip_and_fit_padding() {
        let mut display = sample_display();
        display.render_width = 4;
        display.render_height = 4;
        display.rotation = 90;
        display.flip_horizontal = true;

        let transformer = VideoTransformer::new(&display);
        let mut frame = Mat::new_rows_cols_with_default(2, 1, CV_8UC3, Scalar::all(0.0))
            .expect("frame should build");
        *frame
            .at_2d_mut::<Vec3b>(0, 0)
            .expect("top pixel should exist") = Vec3b::from([10, 0, 0]);
        *frame
            .at_2d_mut::<Vec3b>(1, 0)
            .expect("bottom pixel should exist") = Vec3b::from([20, 0, 0]);

        let output = transformer
            .transform(&frame)
            .expect("transform should succeed");

        assert_eq!(output.size().expect("size should exist"), Size::new(4, 4));
        assert_eq!(
            *output
                .at_2d::<Vec3b>(0, 0)
                .expect("top-left pixel should exist"),
            Vec3b::from([0, 0, 0])
        );
        // Allow ±1 tolerance for OpenCV interpolation rounding
        let pixel_2_0 = *output
            .at_2d::<Vec3b>(2, 0)
            .expect("bottom-left pixel should exist");
        assert!(
            (pixel_2_0[0] as i16 - 10).abs() <= 1,
            "expected ~10, got {}",
            pixel_2_0[0]
        );
        let pixel_2_3 = *output
            .at_2d::<Vec3b>(2, 3)
            .expect("bottom-right pixel should exist");
        assert!(
            (pixel_2_3[0] as i16 - 20).abs() <= 1,
            "expected ~20, got {}",
            pixel_2_3[0]
        );
    }

    #[test]
    fn transition_effect_fades_between_frames_and_resizes_previous_frame() {
        let effect = TransitionEffect::new(0.5, TransitionType::Fade);
        let previous = Mat::new_rows_cols_with_default(1, 1, CV_8UC3, Scalar::all(0.0))
            .expect("previous frame should build");
        let current = Mat::new_rows_cols_with_default(2, 2, CV_8UC3, Scalar::all(200.0))
            .expect("current frame should build");

        let output = effect
            .apply(Some(&previous), &current, 0.25)
            .expect("fade should succeed");

        assert_eq!(output.size().expect("size should exist"), Size::new(2, 2));
        assert_eq!(
            *output.at_2d::<Vec3b>(0, 0).expect("pixel should exist"),
            Vec3b::from([50, 50, 50])
        );
    }

    #[test]
    fn transition_effect_cut_returns_current_frame() {
        let effect = TransitionEffect::new(0.5, TransitionType::Cut);
        let previous = Mat::new_rows_cols_with_default(1, 1, CV_8UC3, Scalar::all(0.0))
            .expect("previous frame should build");
        let current = Mat::new_rows_cols_with_default(1, 1, CV_8UC3, Scalar::all(123.0))
            .expect("current frame should build");

        let output = effect
            .apply(Some(&previous), &current, 0.5)
            .expect("cut should succeed");

        assert_eq!(
            *output.at_2d::<Vec3b>(0, 0).expect("pixel should exist"),
            Vec3b::from([123, 123, 123])
        );
    }

    #[test]
    fn configured_output_size_requires_both_dimensions() {
        let mut processor = sample_processor();
        processor.config.display.output_width = Some(1920);
        processor.config.display.output_height = Some(1080);
        assert_eq!(
            processor.configured_output_size(),
            Some(Size::new(1920, 1080))
        );

        processor.config.display.output_height = None;
        assert_eq!(processor.configured_output_size(), None);
    }

    #[test]
    fn prepare_output_frame_fit_preserves_aspect_ratio_with_padding() {
        let mut processor = sample_processor();
        processor.output_size = Size::new(1920, 1080);
        processor.config.display.scale_mode = ScaleMode::Fit;

        let frame = Mat::new_rows_cols_with_default(1024, 1024, CV_8UC3, Scalar::all(255.0))
            .expect("frame should build");
        let output = processor
            .prepare_output_frame(frame)
            .expect("fit output should build");

        assert_eq!(
            output.size().expect("output size should exist"),
            Size::new(1920, 1080)
        );
        assert_eq!(
            *output
                .at_2d::<Vec3b>(540, 100)
                .expect("left padding pixel should exist"),
            Vec3b::from([0, 0, 0])
        );
        assert_eq!(
            *output
                .at_2d::<Vec3b>(540, 960)
                .expect("center pixel should exist"),
            Vec3b::from([255, 255, 255])
        );
    }

    #[test]
    fn prepare_output_frame_stretch_fills_target_without_padding() {
        let mut processor = sample_processor();
        processor.output_size = Size::new(1920, 1080);
        processor.config.display.scale_mode = ScaleMode::Stretch;

        let frame = Mat::new_rows_cols_with_default(1024, 1024, CV_8UC3, Scalar::all(255.0))
            .expect("frame should build");
        let output = processor
            .prepare_output_frame(frame)
            .expect("stretch output should build");

        assert_eq!(
            output.size().expect("output size should exist"),
            Size::new(1920, 1080)
        );
        assert_eq!(
            *output
                .at_2d::<Vec3b>(540, 100)
                .expect("edge pixel should exist"),
            Vec3b::from([255, 255, 255])
        );
    }

    #[test]
    fn prepare_output_frame_fit_applies_horizontal_offset_within_bounds() {
        let mut processor = sample_processor();
        processor.output_size = Size::new(1920, 1080);
        processor.config.display.scale_mode = ScaleMode::Fit;
        processor.config.display.offset_x = 50;

        let frame = Mat::new_rows_cols_with_default(1024, 1024, CV_8UC3, Scalar::all(255.0))
            .expect("frame should build");
        let output = processor
            .prepare_output_frame(frame)
            .expect("fit output should build");

        assert_eq!(
            *output
                .at_2d::<Vec3b>(540, 450)
                .expect("shifted padding pixel should exist"),
            Vec3b::from([0, 0, 0])
        );
        assert_eq!(
            *output
                .at_2d::<Vec3b>(540, 530)
                .expect("shifted content pixel should exist"),
            Vec3b::from([255, 255, 255])
        );
    }

    #[test]
    fn prepare_output_frame_fit_clamps_offset_to_available_padding() {
        let mut processor = sample_processor();
        processor.output_size = Size::new(1920, 1080);
        processor.config.display.scale_mode = ScaleMode::Fit;
        processor.config.display.offset_x = 1000;

        let frame = Mat::new_rows_cols_with_default(1024, 1024, CV_8UC3, Scalar::all(255.0))
            .expect("frame should build");
        let output = processor
            .prepare_output_frame(frame)
            .expect("fit output should build");

        assert_eq!(
            *output
                .at_2d::<Vec3b>(540, 0)
                .expect("left edge pixel should exist"),
            Vec3b::from([0, 0, 0])
        );
        assert_eq!(
            *output
                .at_2d::<Vec3b>(540, 1919)
                .expect("right edge pixel should exist"),
            Vec3b::from([255, 255, 255])
        );
    }

    #[test]
    fn resolve_video_loop_count_prefers_random_range() {
        let item = VideoItem {
            id: "clip".to_string(),
            path: "clip.mp4".to_string(),
            duration: None,
            loop_count: 99,
            random_loop_range: Some([2, 2]),
        };

        assert_eq!(resolve_video_loop_count(&item), 2);
    }

    #[test]
    fn advance_playlist_runs_random_trigger_only_when_state_does_not_change() {
        let mut processor = sample_processor_with_state_machine(StateMachineConfig {
            initial_state: "idle".to_string(),
            states: HashMap::from([
                (
                    "idle".to_string(),
                    StateConfig {
                        name: "idle".to_string(),
                        mode: StateMode::FreeMode,
                        sequence: vec![animation_step("idle")],
                        next_state: Some("next".to_string()),
                        next_states: None,
                        transitions: vec![StateTransition {
                            trigger: TriggerType::Random { probability: 1.0 },
                            target_state: "random".to_string(),
                            priority: 10,
                        }],
                        weight: 1.0,
                        defer_triggers: false,
                        ignore_triggers: false,
                    },
                ),
                (
                    "next".to_string(),
                    basic_state("next", vec![animation_step("next")]),
                ),
                (
                    "random".to_string(),
                    basic_state("random", vec![animation_step("random")]),
                ),
            ]),
        });

        processor.advance_playlist();

        assert_eq!(processor.current_state(), Some("next"));
        assert_eq!(processor.current_video_id().as_deref(), Some("next"));
        assert_eq!(processor.current_index, 1);
    }

    #[test]
    fn advance_playlist_allows_random_trigger_after_same_state_completion() {
        let mut processor = sample_processor_with_state_machine(StateMachineConfig {
            initial_state: "idle".to_string(),
            states: HashMap::from([
                (
                    "idle".to_string(),
                    StateConfig {
                        name: "idle".to_string(),
                        mode: StateMode::FreeMode,
                        sequence: vec![animation_step("idle")],
                        next_state: Some("idle".to_string()),
                        next_states: None,
                        transitions: vec![StateTransition {
                            trigger: TriggerType::Random { probability: 1.0 },
                            target_state: "random".to_string(),
                            priority: 10,
                        }],
                        weight: 1.0,
                        defer_triggers: false,
                        ignore_triggers: false,
                    },
                ),
                (
                    "random".to_string(),
                    basic_state("random", vec![animation_step("random")]),
                ),
            ]),
        });

        processor.advance_playlist();

        assert_eq!(processor.current_state(), Some("random"));
        assert_eq!(processor.current_video_id().as_deref(), Some("random"));
        assert_eq!(processor.current_index, 2);
    }

    #[test]
    fn advance_playlist_skips_transition_when_video_id_is_unchanged() {
        let mut processor = sample_processor_with_state_machine(StateMachineConfig {
            initial_state: "idle".to_string(),
            states: HashMap::from([(
                "idle".to_string(),
                StateConfig {
                    name: "idle".to_string(),
                    mode: StateMode::FreeMode,
                    sequence: vec![animation_step("idle")],
                    next_state: Some("idle".to_string()),
                    next_states: None,
                    transitions: vec![],
                    weight: 1.0,
                    defer_triggers: false,
                    ignore_triggers: false,
                },
            )]),
        });
        processor.transition_enabled = true;
        processor.last_frame = Some(
            Mat::new_rows_cols_with_default(1, 1, CV_8UC3, Scalar::all(0.0))
                .expect("frame should build"),
        );

        processor.advance_playlist();

        assert_eq!(processor.current_state(), Some("idle"));
        assert_eq!(processor.current_video_id().as_deref(), Some("idle"));
        assert_eq!(processor.current_index, 0);
        assert!(!processor.in_transition);
        assert!(processor.transition_start.is_none());
    }

    fn sample_processor() -> VideoProcessor {
        VideoProcessor::new(sample_config()).expect("sample processor should build")
    }

    fn sample_processor_with_state_machine(state_machine: StateMachineConfig) -> VideoProcessor {
        let mut config = sample_config();
        config.playlist = vec![
            sample_video("idle"),
            sample_video("next"),
            sample_video("random"),
        ];
        config.scenes.state_machine = Some(state_machine);
        VideoProcessor::new(config).expect("state machine processor should build")
    }

    fn sample_config() -> AppConfig {
        AppConfig {
            display: sample_display(),
            playlist: vec![VideoItem {
                id: "sample".to_string(),
                path: "sample.mp4".to_string(),
                duration: None,
                loop_count: 1,
                random_loop_range: None,
            }],
            transition: TransitionConfig {
                enabled: false,
                transition_type: TransitionType::None,
                duration: 0.0,
            },
            playback: PlaybackConfig {
                loop_playlist: false,
                auto_start: false,
            },
            scenes: ScenesConfig {
                rest: vec![],
                active: vec![],
                sleep: vec![],
                interact: vec![],
                state_machine: None,
            },
            remote_control: RemoteControlConfig {
                enabled: false,
                host: "127.0.0.1".to_string(),
                port: 8080,
            },
            ble: BleConfig::default(),
            character: Default::default(),
            ai: Default::default(),
            source_path: PathBuf::from("/tmp/config.json"),
            source_dir: PathBuf::from("/tmp"),
        }
    }

    fn sample_display() -> DisplayConfig {
        DisplayConfig {
            fullscreen: false,
            window_title: "test".to_string(),
            rotation: 0,
            flip_horizontal: false,
            flip_vertical: false,
            offset_x: 0,
            offset_y: 0,
            prevent_screen_lock: false,
            render_width: 1024,
            render_height: 1024,
            output_width: None,
            output_height: None,
            scale_mode: ScaleMode::Fit,
            allow_upscale: true,
            perspective_correction: PerspectiveCorrectionConfig {
                enabled: false,
                points: vec![],
            },
            chroma_key: Default::default(),
            brightness_adjust: Default::default(),
        }
    }

    fn sample_video(id: &str) -> VideoItem {
        VideoItem {
            id: id.to_string(),
            path: format!("{id}.mp4"),
            duration: None,
            loop_count: 1,
            random_loop_range: None,
        }
    }

    fn animation_step(video_id: &str) -> AnimationStep {
        AnimationStep {
            video_id: video_id.to_string(),
            loop_count: Some(1),
            random_loop_range: None,
        }
    }

    fn basic_state(name: &str, sequence: Vec<AnimationStep>) -> StateConfig {
        StateConfig {
            name: name.to_string(),
            mode: StateMode::FreeMode,
            sequence,
            next_state: None,
            next_states: None,
            transitions: vec![],
            weight: 1.0,
            defer_triggers: false,
            ignore_triggers: false,
        }
    }
}
