use super::*;
use crate::data::{Observable, StreamingXY, signal};
use crate::prelude::Plot;
use crate::render::{Color, MarkerStyle};
use std::sync::{Arc, atomic::Ordering};

fn render_target() -> SurfaceTarget {
    SurfaceTarget {
        size_px: (320, 240),
        scale_factor: 1.0,
        time_seconds: 0.0,
    }
}

fn derived_y_ticks(session: &InteractivePlotSession) -> Vec<f64> {
    let geometry = session
        .geometry_snapshot()
        .expect("geometry should be available after render");
    let plot = session.prepared_plot().plot();
    crate::axes::generate_ticks_for_scale(
        geometry.y_bounds.0,
        geometry.y_bounds.1,
        plot.layout.tick_config.major_ticks_y,
        &plot.layout.y_scale,
    )
}

fn color_centroid<F>(image: &Image, predicate: F) -> Option<ViewportPoint>
where
    F: Fn(&[u8]) -> bool,
{
    let mut x_sum = 0.0;
    let mut y_sum = 0.0;
    let mut count = 0.0;

    for (index, pixel) in image.pixels.chunks_exact(4).enumerate() {
        if !predicate(pixel) {
            continue;
        }

        let x = (index as u32 % image.width) as f64;
        let y = (index as u32 / image.width) as f64;
        x_sum += x;
        y_sum += y;
        count += 1.0;
    }

    (count > 0.0).then(|| ViewportPoint::new(x_sum / count, y_sum / count))
}

fn count_matching_pixels_near<F>(
    image: &Image,
    center: ViewportPoint,
    radius: u32,
    predicate: F,
) -> usize
where
    F: Fn(&[u8]) -> bool,
{
    let min_x = center.x.round().max(0.0) as i32 - radius as i32;
    let max_x = center.x.round().min(image.width as f64) as i32 + radius as i32;
    let min_y = center.y.round().max(0.0) as i32 - radius as i32;
    let max_y = center.y.round().min(image.height as f64) as i32 + radius as i32;

    let mut count = 0usize;
    for y in min_y.max(0)..max_y.min(image.height as i32) {
        for x in min_x.max(0)..max_x.min(image.width as i32) {
            let index = ((y as u32 * image.width + x as u32) * 4) as usize;
            if predicate(&image.pixels[index..index + 4]) {
                count += 1;
            }
        }
    }

    count
}

fn count_matching_pixels_outside_rect<F>(
    image: &Image,
    rect: tiny_skia::Rect,
    predicate: F,
) -> usize
where
    F: Fn(&[u8]) -> bool,
{
    let left = rect.left().floor() as i32;
    let right = rect.right().ceil() as i32;
    let top = rect.top().floor() as i32;
    let bottom = rect.bottom().ceil() as i32;

    let mut count = 0usize;
    for y in 0..image.height as i32 {
        for x in 0..image.width as i32 {
            if x >= left && x < right && y >= top && y < bottom {
                continue;
            }

            let index = ((y as u32 * image.width + x as u32) * 4) as usize;
            if predicate(&image.pixels[index..index + 4]) {
                count += 1;
            }
        }
    }

    count
}

fn matching_pixel_bounds<F>(image: &Image, predicate: F) -> Option<(u32, u32, u32, u32)>
where
    F: Fn(&[u8]) -> bool,
{
    let mut min_x = u32::MAX;
    let mut min_y = u32::MAX;
    let mut max_x = 0u32;
    let mut max_y = 0u32;
    let mut found = false;

    for y in 0..image.height {
        for x in 0..image.width {
            let index = ((y * image.width + x) * 4) as usize;
            if !predicate(&image.pixels[index..index + 4]) {
                continue;
            }
            min_x = min_x.min(x);
            min_y = min_y.min(y);
            max_x = max_x.max(x);
            max_y = max_y.max(y);
            found = true;
        }
    }

    found.then_some((min_x, min_y, max_x, max_y))
}

#[test]
fn test_dirty_domains_mark_and_clear() {
    let mut dirty = DirtyDomains::default();
    dirty.mark(DirtyDomain::Layout);
    dirty.mark(DirtyDomain::Overlay);
    dirty.mark(DirtyDomain::Temporal);

    assert!(dirty.layout);
    assert!(dirty.overlay);
    assert!(dirty.temporal);
    assert!(dirty.needs_base_render());
    assert!(dirty.needs_overlay_render());

    dirty.clear_base();
    assert!(!dirty.layout);
    assert!(!dirty.data);
    assert!(!dirty.temporal);
    assert!(!dirty.interaction);
    assert!(dirty.overlay);

    dirty.clear_overlay();
    assert!(!dirty.overlay);
}

#[test]
fn test_resize_updates_size_and_scale_factor_together() {
    let plot: Plot = Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).into();
    let session = plot.prepare_interactive();

    session.resize((640, 480), 2.0);

    let state = session
        .inner
        .state
        .lock()
        .expect("InteractivePlotSession state lock poisoned")
        .clone();
    assert_eq!(state.size_px, (640, 480));
    assert_eq!(state.scale_factor, 2.0);
    assert!(session.dirty_domains().layout);
}

#[test]
fn test_resize_event_updates_size_and_scale_factor_together() {
    let plot: Plot = Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).into();
    let session = plot.prepare_interactive();

    session.apply_input(PlotInputEvent::Resize {
        size_px: (640, 480),
        scale_factor: 2.0,
    });

    let state = session
        .inner
        .state
        .lock()
        .expect("InteractivePlotSession state lock poisoned")
        .clone();
    assert_eq!(state.size_px, (640, 480));
    assert_eq!(state.scale_factor, 2.0);
    assert!(session.dirty_domains().layout);
}

#[test]
fn test_inflight_dirty_marks_survive_render_clear() {
    let plot: Plot = Plot::new().line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0]).into();
    let session = plot.prepare_interactive();

    session.mark_dirty(DirtyDomain::Data);
    let render_epoch = session.inner.dirty_epoch.load(Ordering::Acquire);
    session.mark_dirty(DirtyDomain::Overlay);
    session.clear_dirty_after_render(render_epoch);

    let dirty = session.dirty_domains();
    assert!(dirty.data);
    assert!(dirty.overlay);
}

#[test]
fn test_session_invalidate_forces_base_rerender() {
    let plot: Plot = Plot::new()
        .line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
        .title("Invalidate")
        .into();
    let session = plot.prepare_interactive();

    session
        .render_to_surface(render_target())
        .expect("initial surface frame should render");
    assert!(!session.dirty_domains().needs_base_render());

    session.invalidate();
    assert!(session.dirty_domains().needs_base_render());

    let rerendered = session
        .render_to_surface(render_target())
        .expect("invalidated surface frame should rerender");
    assert!(rerendered.layer_state.base_dirty);
}

#[test]
fn test_empty_plot_surface_uses_default_cartesian_bounds() {
    let plot: Plot = Plot::new().title("Empty Plot").into();
    let session = plot.prepare_interactive();

    let frame = session
        .render_to_surface(render_target())
        .expect("empty surface frame should render");
    let geometry = session
        .geometry_snapshot()
        .expect("geometry should be available after empty render");

    assert!(!frame.layers.base.pixels.is_empty());
    assert_eq!(geometry.x_bounds, (0.0, 1.0));
    assert_eq!(geometry.y_bounds, (0.0, 1.0));
}

#[test]
fn test_compose_images_alpha_blends_overlay() {
    let base = Image::new(1, 1, vec![0, 0, 255, 255]);
    let overlay = Image::new(1, 1, vec![255, 0, 0, 128]);
    let composed = compose_images(&base, &overlay);

    assert!(composed.pixels[0] > 0);
    assert!(composed.pixels[2] > 0);
    assert_eq!(composed.pixels[3], 255);
}

#[test]
fn test_overlay_only_updates_reuse_cached_base_layer() {
    let plot: Plot = Plot::new()
        .line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
        .title("Layer Reuse")
        .into();
    let session = plot.prepare_interactive();

    let first = session
        .render_to_surface(SurfaceTarget {
            size_px: (320, 240),
            scale_factor: 1.0,
            time_seconds: 0.0,
        })
        .expect("surface frame should render");
    assert!(first.layer_state.base_dirty);

    let geometry = session
        .geometry_snapshot()
        .expect("geometry should be available after first frame");
    let (hover_x, hover_y) = map_data_to_pixels(
        1.0,
        1.0,
        geometry.x_bounds.0,
        geometry.x_bounds.1,
        geometry.y_bounds.0,
        geometry.y_bounds.1,
        geometry.plot_area,
    );

    session.apply_input(PlotInputEvent::Hover {
        position_px: ViewportPoint::new(hover_x as f64, hover_y as f64),
    });
    let second = session
        .render_to_surface(SurfaceTarget {
            size_px: (320, 240),
            scale_factor: 1.0,
            time_seconds: 0.0,
        })
        .expect("surface frame should render after hover");

    assert!(!second.layer_state.base_dirty);
    assert!(second.layer_state.overlay_dirty);
    assert!(Arc::ptr_eq(&first.layers.base, &second.layers.base));
}

#[test]
fn test_tooltip_overlay_renders_text_pixels() {
    let plot: Plot = Plot::new()
        .line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
        .title("Tooltip")
        .into();
    let session = plot.prepare_interactive();

    session.apply_input(PlotInputEvent::ShowTooltip {
        content: "x=1.234, y=5.678".to_string(),
        position_px: ViewportPoint::new(180.0, 120.0),
    });

    let frame = session
        .render_to_surface(SurfaceTarget {
            size_px: (320, 240),
            scale_factor: 1.0,
            time_seconds: 0.0,
        })
        .expect("surface frame should render with tooltip");

    let overlay = frame
        .layers
        .overlay
        .expect("surface frame should include overlay pixels");
    let dark_text_pixels = overlay
        .pixels
        .chunks_exact(4)
        .filter(|pixel| pixel[3] > 0 && (pixel[0] < 220 || pixel[1] < 220 || pixel[2] < 180))
        .count();

    assert!(
        dark_text_pixels > 0,
        "tooltip overlay should contain dark text pixels in addition to the background box"
    );
}

#[test]
fn test_brush_overlay_renders_visible_outline() {
    let plot: Plot = Plot::new()
        .line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
        .title("Brush")
        .into();
    let session = plot.prepare_interactive();

    session.apply_input(PlotInputEvent::BrushStart {
        position_px: ViewportPoint::new(96.0, 72.0),
    });
    session.apply_input(PlotInputEvent::BrushMove {
        position_px: ViewportPoint::new(160.0, 136.0),
    });

    let frame = session
        .render_to_surface(SurfaceTarget {
            size_px: (320, 240),
            scale_factor: 1.0,
            time_seconds: 0.0,
        })
        .expect("surface frame should render with brush overlay");

    let overlay = frame
        .layers
        .overlay
        .expect("surface frame should include brush overlay");
    let width = frame.layers.base.width as usize;

    let border_index = ((72usize * width + 96usize) * 4) as usize;
    let interior_index = ((104usize * width + 128usize) * 4) as usize;

    assert!(
        overlay.pixels[border_index + 3] > overlay.pixels[interior_index + 3],
        "brush outline should be more visible than the fill interior"
    );
}

#[test]
fn test_draw_rect_outline_clamps_to_buffer_bounds() {
    let mut pixels = vec![0u8; 4 * 4 * 4];

    draw_rect_outline(
        &mut pixels,
        (4, 4),
        ViewportRect::from_points(ViewportPoint::new(-1.0, -1.0), ViewportPoint::new(3.0, 3.0)),
        Color::new_rgba(255, 128, 64, 255),
        2,
    );

    assert!(
        pixels.chunks_exact(4).any(|pixel| pixel[3] > 0),
        "outline should still draw visible pixels when clamped to the frame"
    );
}

#[test]
fn test_supported_surface_series_use_fast_path_on_full_rerender() {
    let plot: Plot = Plot::new()
        .line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
        .title("Fast Path")
        .into();
    let session = plot.prepare_interactive();

    let frame = session
        .render_to_surface(SurfaceTarget {
            size_px: (320, 240),
            scale_factor: 1.0,
            time_seconds: 0.0,
        })
        .expect("supported surface frame should render");

    assert_eq!(frame.surface_capability, SurfaceCapability::FastPath);
    assert!(!frame.layer_state.used_incremental_data);
}

#[test]
fn test_unsupported_surface_series_fall_back_to_image_capability() {
    let plot: Plot = Plot::new()
        .histogram(&[0.0, 1.0, 1.5, 2.0, 2.5], None)
        .into();
    let session = plot.prepare_interactive();

    let frame = session
        .render_to_surface(SurfaceTarget {
            size_px: (320, 240),
            scale_factor: 1.0,
            time_seconds: 0.0,
        })
        .expect("fallback surface frame should render");

    assert_eq!(frame.surface_capability, SurfaceCapability::FallbackImage);
}

#[test]
fn test_streaming_surface_render_uses_incremental_fast_path() {
    let stream = StreamingXY::new(256);
    stream.push_many(vec![(0.0, 0.0), (1.0, 0.5), (2.0, 1.0)]);

    let plot: Plot = Plot::new()
        .line_streaming(&stream)
        .xlim(0.0, 10.0)
        .ylim(-2.0, 2.0)
        .into();
    let session = plot.prepare_interactive();

    session
        .render_to_surface(SurfaceTarget {
            size_px: (320, 240),
            scale_factor: 1.0,
            time_seconds: 0.0,
        })
        .expect("initial surface frame should render");

    stream.push(3.0, 0.75);
    let incremental = session
        .render_to_surface(SurfaceTarget {
            size_px: (320, 240),
            scale_factor: 1.0,
            time_seconds: 0.0,
        })
        .expect("incremental surface frame should render");

    assert!(incremental.layer_state.used_incremental_data);
    assert_eq!(incremental.surface_capability, SurfaceCapability::FastPath);
    assert_eq!(stream.appended_count(), 0);
}

#[test]
fn test_streaming_surface_render_falls_back_after_wraparound() {
    let stream = StreamingXY::new(3);
    stream.push_many(vec![(0.0, 0.0), (1.0, 0.5), (2.0, 1.0)]);

    let plot: Plot = Plot::new()
        .line_streaming(&stream)
        .xlim(0.0, 3.0)
        .ylim(-1.0, 2.0)
        .into();
    let session = plot.prepare_interactive();

    session
        .render_to_surface(render_target())
        .expect("initial wrapped-stream frame should render");

    stream.push(3.0, 1.25);
    let rerendered = session
        .render_to_surface(render_target())
        .expect("wrapped-stream surface frame should render");

    assert!(!rerendered.layer_state.used_incremental_data);
    assert_eq!(stream.appended_count(), 0);
}

#[test]
fn test_hover_and_selection_refresh_after_view_change() {
    let plot: Plot = Plot::new()
        .line(&[0.0, 5.0, 10.0], &[0.0, 5.0, 10.0])
        .title("Overlay Refresh")
        .xlabel("X Label")
        .ylabel("Y Label")
        .xlim(0.0, 10.0)
        .ylim(0.0, 10.0)
        .into();
    let session = plot.prepare_interactive();

    session
        .render_to_surface(render_target())
        .expect("initial surface frame should render");

    let before_geometry = session
        .geometry_snapshot()
        .expect("geometry should be available before hover");
    let (hover_x, hover_y) = map_data_to_pixels(
        5.0,
        5.0,
        before_geometry.x_bounds.0,
        before_geometry.x_bounds.1,
        before_geometry.y_bounds.0,
        before_geometry.y_bounds.1,
        before_geometry.plot_area,
    );
    let hover_px = ViewportPoint::new(hover_x as f64, hover_y as f64);

    session.apply_input(PlotInputEvent::Hover {
        position_px: hover_px,
    });
    session.apply_input(PlotInputEvent::SelectAt {
        position_px: hover_px,
    });
    session.apply_input(PlotInputEvent::Pan {
        delta_px: ViewportPoint::new(36.0, 18.0),
    });
    session
        .render_to_surface(render_target())
        .expect("surface frame should rerender after pan");

    let after_geometry = session
        .geometry_snapshot()
        .expect("geometry should be available after pan");
    let (expected_x, expected_y) = map_data_to_pixels(
        5.0,
        5.0,
        after_geometry.x_bounds.0,
        after_geometry.x_bounds.1,
        after_geometry.y_bounds.0,
        after_geometry.y_bounds.1,
        after_geometry.plot_area,
    );
    let state = session
        .inner
        .state
        .lock()
        .expect("InteractivePlotSession state lock poisoned")
        .clone();
    let hovered = state.hovered.expect("hovered hit should be refreshed");
    let selected = state
        .selected
        .first()
        .cloned()
        .expect("selected hit should be refreshed");
    let tooltip = state.tooltip.expect("hover tooltip should be refreshed");

    match hovered {
        HitResult::SeriesPoint {
            screen_position,
            data_position,
            ..
        } => {
            assert!((screen_position.x - expected_x as f64).abs() < 1e-6);
            assert!((screen_position.y - expected_y as f64).abs() < 1e-6);
            assert_eq!(data_position, ViewportPoint::new(5.0, 5.0));
            assert_eq!(tooltip.position_px, screen_position);
        }
        other => panic!("expected series-point hover hit, got {other:?}"),
    }

    match selected {
        HitResult::SeriesPoint {
            screen_position,
            data_position,
            ..
        } => {
            assert!((screen_position.x - expected_x as f64).abs() < 1e-6);
            assert!((screen_position.y - expected_y as f64).abs() < 1e-6);
            assert_eq!(data_position, ViewportPoint::new(5.0, 5.0));
        }
        other => panic!("expected series-point selection hit, got {other:?}"),
    }
}

#[test]
fn test_hover_refreshes_after_time_change() {
    let temporal_y = signal::of(|time| vec![time, 1.0 + time, 2.0 + time]);
    let plot: Plot = Plot::new()
        .line_source(vec![0.0, 1.0, 2.0], temporal_y)
        .xlim(0.0, 2.0)
        .ylim(0.0, 4.0)
        .into();
    let session = plot.prepare_interactive();

    session
        .render_to_surface(render_target())
        .expect("initial temporal surface frame should render");

    let before_geometry = session
        .geometry_snapshot()
        .expect("geometry should be available before temporal update");
    let (hover_x, hover_y) = map_data_to_pixels(
        1.0,
        1.0,
        before_geometry.x_bounds.0,
        before_geometry.x_bounds.1,
        before_geometry.y_bounds.0,
        before_geometry.y_bounds.1,
        before_geometry.plot_area,
    );
    session.apply_input(PlotInputEvent::Hover {
        position_px: ViewportPoint::new(hover_x as f64, hover_y as f64),
    });

    session
        .render_to_surface(SurfaceTarget {
            time_seconds: 1.0,
            ..render_target()
        })
        .expect("temporal surface frame should render at updated time");

    let after_geometry = session
        .geometry_snapshot()
        .expect("geometry should be available after temporal update");
    let (expected_x, expected_y) = map_data_to_pixels(
        1.0,
        2.0,
        after_geometry.x_bounds.0,
        after_geometry.x_bounds.1,
        after_geometry.y_bounds.0,
        after_geometry.y_bounds.1,
        after_geometry.plot_area,
    );
    let state = session
        .inner
        .state
        .lock()
        .expect("InteractivePlotSession state lock poisoned")
        .clone();
    let hovered = state
        .hovered
        .expect("hovered hit should survive temporal update");
    let tooltip = state
        .tooltip
        .expect("hover tooltip should survive temporal update");

    match hovered {
        HitResult::SeriesPoint {
            screen_position,
            data_position,
            ..
        } => {
            assert_eq!(data_position, ViewportPoint::new(1.0, 2.0));
            assert!((screen_position.x - expected_x as f64).abs() < 1e-6);
            assert!((screen_position.y - expected_y as f64).abs() < 1e-6);
        }
        other => panic!("expected series-point hover hit, got {other:?}"),
    }
    assert_eq!(tooltip.content, "x=1.000, y=2.000");
    assert!((tooltip.position_px.x - expected_x as f64).abs() < 1e-6);
    assert!((tooltip.position_px.y - expected_y as f64).abs() < 1e-6);
}

#[test]
fn test_heatmap_hover_skips_masked_log_cells() {
    let values = vec![vec![0.0, 1.0, 10.0]];
    let plot: Plot = Plot::new()
        .heatmap(
            &values,
            Some(
                crate::plots::heatmap::HeatmapConfig::new()
                    .value_scale(crate::axes::AxisScale::Log)
                    .colorbar(false),
            ),
        )
        .into();
    let session = plot.prepare_interactive();

    session
        .render_to_surface(render_target())
        .expect("log heatmap surface frame should render");

    let geometry = session
        .geometry_snapshot()
        .expect("geometry should be available after log heatmap render");
    let (masked_x, masked_y) = map_data_to_pixels(
        0.5,
        0.5,
        geometry.x_bounds.0,
        geometry.x_bounds.1,
        geometry.y_bounds.0,
        geometry.y_bounds.1,
        geometry.plot_area,
    );
    session.apply_input(PlotInputEvent::Hover {
        position_px: ViewportPoint::new(masked_x as f64, masked_y as f64),
    });

    let state = session
        .inner
        .state
        .lock()
        .expect("InteractivePlotSession state lock poisoned")
        .clone();
    assert!(state.hovered.is_none());
    assert!(state.tooltip.is_none());

    let (valid_x, valid_y) = map_data_to_pixels(
        1.5,
        0.5,
        geometry.x_bounds.0,
        geometry.x_bounds.1,
        geometry.y_bounds.0,
        geometry.y_bounds.1,
        geometry.plot_area,
    );
    session.apply_input(PlotInputEvent::Hover {
        position_px: ViewportPoint::new(valid_x as f64, valid_y as f64),
    });

    let state = session
        .inner
        .state
        .lock()
        .expect("InteractivePlotSession state lock poisoned")
        .clone();
    match state.hovered {
        Some(HitResult::HeatmapCell {
            row, col, value, ..
        }) => {
            assert_eq!((row, col), (0, 1));
            assert_eq!(value, 1.0);
        }
        other => panic!("expected unmasked heatmap hover hit, got {other:?}"),
    }
}

#[test]
fn test_refresh_hit_result_drops_masked_log_heatmap_cells() {
    let values = vec![vec![0.0, 1.0, 10.0]];
    let plot: Plot = Plot::new()
        .heatmap(
            &values,
            Some(
                crate::plots::heatmap::HeatmapConfig::new()
                    .value_scale(crate::axes::AxisScale::Log)
                    .colorbar(false),
            ),
        )
        .into();
    let session = plot.prepare_interactive();

    session
        .render_to_surface(render_target())
        .expect("log heatmap surface frame should render");

    let geometry = session
        .geometry_snapshot()
        .expect("geometry should be available after log heatmap render");
    let snapshot = session.prepared_plot().plot().snapshot_series(0.0);

    let masked = HitResult::HeatmapCell {
        series_index: 0,
        row: 0,
        col: 0,
        value: 0.0,
        screen_rect: ViewportRect::from_points(
            ViewportPoint::new(0.0, 0.0),
            ViewportPoint::new(1.0, 1.0),
        ),
    };
    assert!(refresh_hit_result(&masked, &snapshot, &geometry).is_none());

    let valid = HitResult::HeatmapCell {
        series_index: 0,
        row: 0,
        col: 1,
        value: 1.0,
        screen_rect: ViewportRect::from_points(
            ViewportPoint::new(0.0, 0.0),
            ViewportPoint::new(1.0, 1.0),
        ),
    };
    match refresh_hit_result(&valid, &snapshot, &geometry) {
        Some(HitResult::HeatmapCell {
            row, col, value, ..
        }) => {
            assert_eq!((row, col), (0, 1));
            assert_eq!(value, 1.0);
        }
        other => panic!("expected refresh to preserve unmasked heatmap cell, got {other:?}"),
    }
}

#[test]
fn test_zoom_keeps_cursor_anchor_stable() {
    let plot: Plot = Plot::new()
        .line(&[0.0, 10.0], &[0.0, 10.0])
        .title("Zoom Anchor")
        .xlabel("Time")
        .ylabel("Value")
        .xlim(0.0, 10.0)
        .ylim(0.0, 10.0)
        .into();
    let session = plot.prepare_interactive();

    session
        .render_to_surface(render_target())
        .expect("initial surface frame should render");

    let before_geometry = session
        .geometry_snapshot()
        .expect("geometry should be available before zoom");
    let anchor_px = ViewportPoint::new(
        0.0,
        before_geometry.plot_area.top() as f64
            + f64::from(before_geometry.plot_area.height()) * 0.5,
    );
    let before_state = session
        .inner
        .state
        .lock()
        .expect("InteractivePlotSession state lock poisoned")
        .clone();
    let before_visible = visible_bounds(&before_state);
    let anchor_before = screen_to_data(before_visible, before_geometry.plot_area, anchor_px);

    session.apply_input(PlotInputEvent::Zoom {
        factor: 2.0,
        center_px: anchor_px,
    });

    let after_state = session
        .inner
        .state
        .lock()
        .expect("InteractivePlotSession state lock poisoned")
        .clone();
    let after_visible = visible_bounds(&after_state);
    let after_geometry = session
        .geometry_snapshot()
        .expect("geometry should be available after zoom");
    let anchor_after = screen_to_data(after_visible, after_geometry.plot_area, anchor_px);

    assert!((anchor_before.x - anchor_after.x).abs() < 1e-9);
    assert!((anchor_before.y - anchor_after.y).abs() < 1e-9);
}

#[test]
fn test_zoom_rect_maps_screen_region_to_visible_bounds() {
    let plot: Plot = Plot::new()
        .line(&[0.0, 10.0], &[0.0, 10.0])
        .title("Zoom Rect")
        .xlabel("X Axis")
        .ylabel("Y Axis")
        .xlim(0.0, 10.0)
        .ylim(0.0, 10.0)
        .into();
    let session = plot.prepare_interactive();

    session
        .render_to_surface(render_target())
        .expect("initial surface frame should render");

    let before_state = session
        .inner
        .state
        .lock()
        .expect("InteractivePlotSession state lock poisoned")
        .clone();
    let before_visible = visible_bounds(&before_state);
    let geometry = session
        .geometry_snapshot()
        .expect("geometry should be available before zoom rect");
    let start_px = ViewportPoint::new(
        geometry.plot_area.left() as f64 + 48.0,
        geometry.plot_area.top() as f64 + 36.0,
    );
    let end_px = ViewportPoint::new(
        geometry.plot_area.left() as f64 + 212.0,
        geometry.plot_area.top() as f64 + 168.0,
    );
    let start_data = screen_to_data(before_visible, geometry.plot_area, start_px);
    let end_data = screen_to_data(before_visible, geometry.plot_area, end_px);

    session.apply_input(PlotInputEvent::ZoomRect {
        region_px: ViewportRect::from_points(start_px, end_px),
    });

    let after_state = session
        .inner
        .state
        .lock()
        .expect("InteractivePlotSession state lock poisoned")
        .clone();
    let after_visible = visible_bounds(&after_state);

    assert!((after_visible.x_min - start_data.x.min(end_data.x)).abs() < 1e-9);
    assert!((after_visible.x_max - start_data.x.max(end_data.x)).abs() < 1e-9);
    assert!((after_visible.y_min - start_data.y.min(end_data.y)).abs() < 1e-9);
    assert!((after_visible.y_max - start_data.y.max(end_data.y)).abs() < 1e-9);
}

#[test]
fn test_reversed_manual_limits_survive_zoom_and_zoom_rect() {
    let plot: Plot = Plot::new()
        .line(&[0.0, 4.0], &[0.0, 4.0])
        .xlim(4.0, 0.0)
        .ylim(4.0, 0.0)
        .into();
    let session = plot.prepare_interactive();

    session
        .render_to_surface(render_target())
        .expect("initial reversed surface frame should render");

    let initial_geometry = session
        .geometry_snapshot()
        .expect("geometry should be available for reversed limits");
    assert_eq!(initial_geometry.x_bounds, (4.0, 0.0));
    assert_eq!(initial_geometry.y_bounds, (4.0, 0.0));

    let anchor_px = ViewportPoint::new(
        initial_geometry.plot_area.left() as f64
            + f64::from(initial_geometry.plot_area.width()) * 0.5,
        initial_geometry.plot_area.top() as f64
            + f64::from(initial_geometry.plot_area.height()) * 0.5,
    );
    session.apply_input(PlotInputEvent::Zoom {
        factor: 2.0,
        center_px: anchor_px,
    });

    let after_zoom_state = session
        .inner
        .state
        .lock()
        .expect("InteractivePlotSession state lock poisoned")
        .clone();
    let after_zoom_visible = visible_bounds(&after_zoom_state);
    assert!(after_zoom_visible.width() < 0.0);
    assert!(after_zoom_visible.height() < 0.0);

    let zoom_geometry = session
        .geometry_snapshot()
        .expect("geometry should be available after reversed zoom");
    let start_px = ViewportPoint::new(
        zoom_geometry.plot_area.left() as f64 + 32.0,
        zoom_geometry.plot_area.top() as f64 + 28.0,
    );
    let end_px = ViewportPoint::new(
        zoom_geometry.plot_area.left() as f64 + 180.0,
        zoom_geometry.plot_area.top() as f64 + 144.0,
    );
    let start_data = screen_to_data(after_zoom_visible, zoom_geometry.plot_area, start_px);
    let end_data = screen_to_data(after_zoom_visible, zoom_geometry.plot_area, end_px);

    session.apply_input(PlotInputEvent::ZoomRect {
        region_px: ViewportRect::from_points(start_px, end_px),
    });

    let after_zoom_rect_state = session
        .inner
        .state
        .lock()
        .expect("InteractivePlotSession state lock poisoned")
        .clone();
    let after_zoom_rect_visible = visible_bounds(&after_zoom_rect_state);

    assert!((after_zoom_rect_visible.x_min - start_data.x).abs() < 1e-9);
    assert!((after_zoom_rect_visible.x_max - end_data.x).abs() < 1e-9);
    assert!((after_zoom_rect_visible.y_min - end_data.y).abs() < 1e-9);
    assert!((after_zoom_rect_visible.y_max - start_data.y).abs() < 1e-9);
    assert!(after_zoom_rect_visible.width() < 0.0);
    assert!(after_zoom_rect_visible.height() < 0.0);
}

#[test]
fn test_pan_uses_plot_area_dimensions() {
    let plot: Plot = Plot::new()
        .line(&[0.0, 10.0], &[0.0, 10.0])
        .title("Pan Scale")
        .xlabel("X Axis")
        .ylabel("Y Axis")
        .xlim(0.0, 10.0)
        .ylim(0.0, 10.0)
        .into();
    let session = plot.prepare_interactive();

    session
        .render_to_surface(render_target())
        .expect("initial surface frame should render");

    let before_state = session
        .inner
        .state
        .lock()
        .expect("InteractivePlotSession state lock poisoned")
        .clone();
    let before_visible = visible_bounds(&before_state);
    let geometry = session
        .geometry_snapshot()
        .expect("geometry should be available before pan");
    let delta_px = ViewportPoint::new(40.0, 24.0);

    session.apply_input(PlotInputEvent::Pan { delta_px });

    let after_state = session
        .inner
        .state
        .lock()
        .expect("InteractivePlotSession state lock poisoned")
        .clone();
    let after_visible = visible_bounds(&after_state);
    let expected_dx =
        -(delta_px.x / f64::from(geometry.plot_area.width())) * before_visible.width();
    let expected_dy =
        (delta_px.y / f64::from(geometry.plot_area.height())) * before_visible.height();

    assert!(((after_visible.x_min - before_visible.x_min) - expected_dx).abs() < 1e-9);
    assert!(((after_visible.x_max - before_visible.x_max) - expected_dx).abs() < 1e-9);
    assert!(((after_visible.y_min - before_visible.y_min) - expected_dy).abs() < 1e-9);
    assert!(((after_visible.y_max - before_visible.y_max) - expected_dy).abs() < 1e-9);
}

#[test]
fn test_temporal_layout_content_uses_current_frame_labels() {
    let xlabel = signal::of(|time| {
        if time < 1.0 {
            "baseline".to_string()
        } else {
            "updated temporal label".to_string()
        }
    });
    let plot: Plot = Plot::new()
        .line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
        .xlabel(xlabel)
        .into();
    let visible = DataBounds::from_limits(0.0, 2.0, 0.0, 4.0);

    let baseline_content = plot.create_plot_content(visible.y_min, visible.y_max);
    let current_content = plot.create_plot_content_at_time(visible.y_min, visible.y_max, 1.0);
    let prepared_content = plot
        .prepared_frame_plot(render_target().size_px, render_target().scale_factor, 1.0)
        .create_plot_content(visible.y_min, visible.y_max);

    assert_eq!(baseline_content.xlabel.as_deref(), Some("baseline"));
    assert_eq!(
        current_content.xlabel.as_deref(),
        Some("updated temporal label")
    );
    assert_eq!(prepared_content.xlabel, current_content.xlabel);

    compute_plot_layout(
        &plot,
        render_target().size_px,
        render_target().scale_factor,
        1.0,
        visible,
    )
    .expect("interactive layout should compute using current-frame labels");
}

#[test]
fn test_incremental_line_render_preserves_markers() {
    let stream = StreamingXY::new(32);
    stream.push_many(vec![(0.5, 0.5), (1.0, 1.2)]);

    let plot: Plot = Plot::new()
        .line_streaming(&stream)
        .color(Color::new(220, 20, 20))
        .width(1.0)
        .marker(MarkerStyle::Square)
        .marker_size(18.0)
        .into();
    let plot = plot.ticks(false).grid(false).xlim(0.0, 3.0).ylim(0.0, 3.0);
    let session = plot.prepare_interactive();

    session
        .render_to_surface(render_target())
        .expect("initial line+marker surface frame should render");

    stream.push(2.0, 2.3);
    let incremental = session
        .render_to_surface(render_target())
        .expect("incremental line+marker surface frame should render");
    assert!(incremental.layer_state.used_incremental_data);

    let geometry = session
        .geometry_snapshot()
        .expect("geometry should be available after incremental render");
    let (px, py) = map_data_to_pixels(
        2.0,
        2.3,
        geometry.x_bounds.0,
        geometry.x_bounds.1,
        geometry.y_bounds.0,
        geometry.y_bounds.1,
        geometry.plot_area,
    );
    let point = ViewportPoint::new(px as f64, py as f64);
    let incremental_pixels =
        count_matching_pixels_near(incremental.layers.base.as_ref(), point, 12, |pixel| {
            pixel[3] > 0 && pixel[0] > 150 && pixel[1] < 100 && pixel[2] < 100
        });

    let full = session
        .prepared_plot()
        .plot()
        .prepared_frame_plot(render_target().size_px, render_target().scale_factor, 0.0)
        .xlim(0.0, 3.0)
        .ylim(0.0, 3.0)
        .render()
        .expect("full line+marker render should succeed");
    let full_pixels = count_matching_pixels_near(&full, point, 12, |pixel| {
        pixel[3] > 0 && pixel[0] > 150 && pixel[1] < 100 && pixel[2] < 100
    });

    assert!(incremental_pixels > 0);
    assert!((incremental_pixels as i32 - full_pixels as i32).abs() <= 12);
}

#[test]
fn test_reactive_manual_ylim_stays_pinned_across_updates() {
    let y = Observable::new(vec![0.0, 0.5, 1.0, -0.25]);
    let plot: Plot = Plot::new()
        .line_source(vec![0.0, 1.0, 2.0, 3.0], y.clone())
        .ylim(-2.0, 2.0)
        .into();
    let session = plot.prepare_interactive();

    session
        .render_to_surface(render_target())
        .expect("initial surface frame should render");
    let first_geometry = session
        .geometry_snapshot()
        .expect("geometry should be available after initial render");
    let first_ticks = derived_y_ticks(&session);

    y.set(vec![12.0, -15.0, 8.0, -11.0]);
    session
        .render_to_surface(render_target())
        .expect("surface frame after first reactive update should render");
    let second_geometry = session
        .geometry_snapshot()
        .expect("geometry should be available after first reactive update");
    let second_ticks = derived_y_ticks(&session);

    y.set(vec![30.0, 5.0, -22.0, 18.0]);
    session
        .render_to_surface(render_target())
        .expect("surface frame after second reactive update should render");
    let third_geometry = session
        .geometry_snapshot()
        .expect("geometry should be available after second reactive update");
    let third_ticks = derived_y_ticks(&session);

    assert_eq!(first_geometry.y_bounds, (-2.0, 2.0));
    assert_eq!(second_geometry.y_bounds, (-2.0, 2.0));
    assert_eq!(third_geometry.y_bounds, (-2.0, 2.0));
    assert_eq!(first_ticks, second_ticks);
    assert_eq!(second_ticks, third_ticks);
}

#[test]
fn test_dashboard_like_reactive_updates_do_not_drift_manual_ylim() {
    let x: Vec<f64> = (0..120).map(|index| index as f64 * 12.0 / 119.0).collect();
    let primary = Observable::new(
        x.iter()
            .map(|value| 0.85 * value.sin() + 0.2 * (value * 3.0).cos())
            .collect::<Vec<_>>(),
    );
    let baseline = Observable::new(
        x.iter()
            .map(|value| 0.4 * (value * 0.75).sin())
            .collect::<Vec<_>>(),
    );
    let event_x = Observable::new(vec![2.0, 6.0, 9.5]);
    let event_y = Observable::new(vec![1.1, -1.3, 1.4]);
    let accent = Observable::new(Color::new(42, 157, 143));

    let plot: Plot = Plot::new()
        .line(&x, &vec![1.2; x.len()])
        .color(Color::LIGHT_GRAY)
        .into();
    let plot: Plot = plot
        .line(&x, &vec![-1.2; x.len()])
        .color(Color::LIGHT_GRAY)
        .into();
    let plot: Plot = plot
        .line_source(x.clone(), primary.clone())
        .color_source(accent.clone())
        .line_width(2.4)
        .into();
    let plot: Plot = plot
        .line_source(x.clone(), baseline.clone())
        .color(Color::new(38, 70, 83))
        .line_width(1.6)
        .into();
    let plot: Plot = plot
        .scatter_source(event_x.clone(), event_y.clone())
        .color(Color::new(231, 111, 81))
        .marker(MarkerStyle::Diamond)
        .marker_size(9.0)
        .xlim(0.0, 12.0)
        .ylim(-2.0, 2.0)
        .into();
    let session = plot.prepare_interactive();

    session
        .render_to_surface(render_target())
        .expect("initial dashboard-like surface frame should render");
    let first_geometry = session
        .geometry_snapshot()
        .expect("geometry should be available after initial dashboard render");
    let first_ticks = derived_y_ticks(&session);

    primary.set(
        x.iter()
            .map(|value| 1.6 * (value * 1.3).sin() + 0.5 * (value * 2.8).cos())
            .collect::<Vec<_>>(),
    );
    baseline.set(
        x.iter()
            .map(|value| 0.55 * (value * 0.65).sin() - 0.2 * (value * 2.1).cos())
            .collect::<Vec<_>>(),
    );
    event_x.set(vec![1.0, 4.5, 10.5]);
    event_y.set(vec![1.5, -1.4, 1.7]);
    accent.set(Color::new(231, 111, 81));

    session
        .render_to_surface(render_target())
        .expect("dashboard-like surface frame after reactive updates should render");
    let second_geometry = session
        .geometry_snapshot()
        .expect("geometry should be available after dashboard reactive updates");
    let second_ticks = derived_y_ticks(&session);

    assert_eq!(first_geometry.y_bounds, (-2.0, 2.0));
    assert_eq!(second_geometry.y_bounds, (-2.0, 2.0));
    assert_eq!(first_ticks, second_ticks);
}

#[test]
fn test_surface_frame_pixels_honor_manual_ylim() {
    let plot: Plot = Plot::new()
        .scatter(&[0.5], &[1.0])
        .color(Color::new(220, 20, 20))
        .marker(MarkerStyle::Square)
        .marker_size(18.0)
        .ticks(false)
        .grid(false)
        .into();
    let plot: Plot = plot
        .scatter(&[0.5], &[-1.0])
        .color(Color::new(20, 20, 220))
        .marker(MarkerStyle::Square)
        .marker_size(18.0)
        .ticks(false)
        .grid(false)
        .xlim(0.0, 1.0)
        .ylim(-2.0, 2.0)
        .into();
    let session = plot.prepare_interactive();
    let plain_plot = plot.clone().set_output_pixels(320, 240);
    let render_plot = session
        .prepared_plot()
        .plot()
        .prepared_frame_plot(render_target().size_px, render_target().scale_factor, 0.0)
        .xlim(0.0, 1.0)
        .ylim(-2.0, 2.0);

    assert_eq!(plain_plot.layout.y_limits, Some((-2.0, 2.0)));
    assert_eq!(render_plot.layout.y_limits, Some((-2.0, 2.0)));
    assert_eq!(render_plot.layout.x_limits, Some((0.0, 1.0)));

    let plain = plain_plot.render().expect("plain plot should render");
    let plain_red_center = color_centroid(&plain, |pixel| {
        pixel[3] > 0 && pixel[0] > 160 && pixel[1] < 80 && pixel[2] < 80
    })
    .expect("plain red marker pixels should be present");
    let direct = render_plot
        .render()
        .expect("direct prepared frame should render");
    let direct_red_center = color_centroid(&direct, |pixel| {
        pixel[3] > 0 && pixel[0] > 160 && pixel[1] < 80 && pixel[2] < 80
    })
    .expect("direct red marker pixels should be present");

    let visible = DataBounds::from_limits(0.0, 1.0, -2.0, 2.0);
    let layout = compute_plot_layout(
        &plain_plot,
        render_target().size_px,
        render_target().scale_factor,
        0.0,
        visible,
    )
    .expect("plot layout should compute for manual bounds");

    let frame = session
        .render_to_surface(render_target())
        .expect("surface frame should render");
    let base = frame.layers.base.as_ref();

    let red_center = color_centroid(base, |pixel| {
        pixel[3] > 0 && pixel[0] > 160 && pixel[1] < 80 && pixel[2] < 80
    })
    .expect("red marker pixels should be present");
    let blue_center = color_centroid(base, |pixel| {
        pixel[3] > 0 && pixel[0] < 80 && pixel[1] < 80 && pixel[2] > 160
    })
    .expect("blue marker pixels should be present");

    let expected_red_y = map_data_to_pixels(
        0.5,
        1.0,
        visible.x_min,
        visible.x_max,
        visible.y_min,
        visible.y_max,
        layout.plot_area_rect,
    )
    .1 as f64;

    assert!(
        (plain_red_center.y - expected_red_y).abs() <= 12.0,
        "plain render red marker y={} should be close to expected {}",
        plain_red_center.y,
        expected_red_y
    );
    assert!(
        (direct_red_center.y - expected_red_y).abs() <= 12.0,
        "direct render red marker y={} should be close to expected {}",
        direct_red_center.y,
        expected_red_y
    );
    let expected_blue_y = map_data_to_pixels(
        0.5,
        -1.0,
        visible.x_min,
        visible.x_max,
        visible.y_min,
        visible.y_max,
        layout.plot_area_rect,
    )
    .1 as f64;

    assert!(
        (red_center.y - expected_red_y).abs() <= 12.0,
        "red marker y={} should be close to expected {}",
        red_center.y,
        expected_red_y
    );
    assert!(
        (blue_center.y - expected_blue_y).abs() <= 12.0,
        "blue marker y={} should be close to expected {}",
        blue_center.y,
        expected_blue_y
    );
}

#[test]
fn test_surface_frame_clips_series_pixels_to_plot_area_after_zoom() {
    let plot: Plot = Plot::new()
        .line(&[0.0, 5.0, 10.0], &[0.0, 5.0, 10.0])
        .color(Color::new(220, 20, 20))
        .line_width(18.0)
        .ticks(false)
        .grid(false)
        .xlim(0.0, 10.0)
        .ylim(0.0, 10.0)
        .into();
    let session = plot.prepare_interactive();

    session
        .render_to_surface(render_target())
        .expect("initial surface frame should render");

    let geometry = session
        .geometry_snapshot()
        .expect("geometry should be available before zoom");
    let zoom_region = ViewportRect::from_points(
        ViewportPoint::new(
            geometry.plot_area.left() as f64 + 64.0,
            geometry.plot_area.top() as f64 + 40.0,
        ),
        ViewportPoint::new(
            geometry.plot_area.right() as f64 - 64.0,
            geometry.plot_area.bottom() as f64 - 40.0,
        ),
    );
    session.apply_input(PlotInputEvent::ZoomRect {
        region_px: zoom_region,
    });

    let frame = session
        .render_to_surface(render_target())
        .expect("zoomed surface frame should render");
    let geometry = session
        .geometry_snapshot()
        .expect("geometry should be available after zoom");
    let base = frame.layers.base.as_ref();

    let red_pixels = color_centroid(base, |pixel| {
        pixel[3] > 0 && pixel[0] > 160 && pixel[1] < 80 && pixel[2] < 80
    });
    let leaked_red_pixels = count_matching_pixels_outside_rect(base, geometry.plot_area, |pixel| {
        pixel[3] > 0 && pixel[0] > 160 && pixel[1] < 80 && pixel[2] < 80
    });
    let red_bounds = matching_pixel_bounds(base, |pixel| {
        pixel[3] > 0 && pixel[0] > 160 && pixel[1] < 80 && pixel[2] < 80
    });

    assert!(red_pixels.is_some(), "expected red line pixels after zoom");
    assert_eq!(
        leaked_red_pixels, 0,
        "expected no strong red series pixels outside plot area after zoom; plot_area={:?}; red_bounds={red_bounds:?}",
        geometry.plot_area
    );
}
