//! MATLAB-compatible `scatter` builtin.

use glam::{Vec3, Vec4};
use log::warn;
use runmat_accelerate_api::{self, GpuTensorHandle, ProviderPrecision};
#[cfg(test)]
use runmat_builtins::Tensor;
use runmat_builtins::Value;
use runmat_macros::runtime_builtin;
use runmat_plot::core::BoundingBox;
use runmat_plot::gpu::scatter2::{
    Scatter2GpuInputs, Scatter2GpuParams, ScatterAttributeBuffer, ScatterColorBuffer,
};
use runmat_plot::gpu::ScalarType;
use runmat_plot::plots::scatter::{MarkerStyle, ScatterGpuStyle};
use runmat_plot::plots::surface::ColorMap;
use runmat_plot::plots::LineStyle;
use runmat_plot::plots::ScatterPlot;

use crate::builtins::common::spec::{
    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
    ReductionNaN, ResidencyPolicy, ShapeRequirements,
};
use std::convert::TryFrom;

use super::common::{gather_tensor_from_gpu, numeric_pair};
use super::gpu_helpers::axis_bounds;
use super::op_common::line_inputs::NumericInput as ScatterInput;
use super::op_common::{apply_axes_target, split_leading_axes_handle};
use super::perf::scatter_target_points;
use super::plotting_error;
use super::point::{
    convert_rgb_color_matrix, convert_scalar_color_values, convert_size_vector,
    map_scalar_values_to_colors, validate_gpu_color_matrix, validate_gpu_vector_length, PointArgs,
    PointColorArg, PointGpuColor, PointSizeArg,
};
use super::state::{render_active_plot, PlotRenderOptions};
use super::style::{LineStyleParseOptions, MarkerColor};
use crate::builtins::plotting::type_resolvers::handle_scalar_type;
use crate::{BuiltinResult, RuntimeError};

#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::plotting::scatter")]
pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
    name: "scatter",
    op_kind: GpuOpKind::PlotRender,
    supported_precisions: &[],
    broadcast: BroadcastSemantics::None,
    provider_hooks: &[],
    constant_strategy: ConstantStrategy::InlineLiteral,
    // Plotting is a sink, but can consume gpuArray inputs zero-copy when a shared WGPU context exists.
    residency: ResidencyPolicy::InheritInputs,
    nan_mode: ReductionNaN::Include,
    two_pass_threshold: None,
    workgroup_size: None,
    accepts_nan_mode: false,
    notes: "2-D scatter rendering terminates fusion graphs; gpuArray inputs may remain on device when shared plotting context is installed.",
};

#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::plotting::scatter")]
pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
    name: "scatter",
    shape: ShapeRequirements::Any,
    constant_strategy: ConstantStrategy::InlineLiteral,
    elementwise: None,
    reduction: None,
    emits_nan: false,
    notes: "scatter performs I/O and terminates fusion graphs.",
};

const BUILTIN_NAME: &str = "scatter";

#[runtime_builtin(
    name = "scatter",
    category = "plotting",
    summary = "Create MATLAB-compatible 2-D scatter plots.",
    keywords = "scatter,plotting,2d,markers",
    sink = true,
    suppress_auto_output = true,
    type_resolver(handle_scalar_type),
    builtin_path = "crate::builtins::plotting::scatter"
)]
pub async fn scatter_builtin(x: Value, y: Value, rest: Vec<Value>) -> crate::BuiltinResult<f64> {
    let mut args = vec![x, y];
    args.extend(rest);
    let (axes_target, mut args) = split_leading_axes_handle(args, BUILTIN_NAME)?;
    apply_axes_target(axes_target, BUILTIN_NAME)?;
    if args.len() < 2 {
        return Err(scatter_err(
            "scatter: expected X and Y data after axes handle",
        ));
    }
    let x = args.remove(0);
    let y = args.remove(0);
    let rest = args;
    let style_args = PointArgs::parse(rest, LineStyleParseOptions::scatter())?;
    let mut x_input = Some(ScatterInput::from_value(x, BUILTIN_NAME)?);
    let mut y_input = Some(ScatterInput::from_value(y, BUILTIN_NAME)?);
    let opts = PlotRenderOptions {
        title: "Scatter Plot",
        x_label: "X",
        y_label: "Y",
        ..Default::default()
    };
    let plot_index_out = std::rc::Rc::new(std::cell::RefCell::new(None));
    let plot_index_slot = std::rc::Rc::clone(&plot_index_out);
    let figure_handle = crate::builtins::plotting::current_figure_handle();
    let render_result = render_active_plot(BUILTIN_NAME, opts, move |figure, axes| {
        let style_args = style_args.clone();
        let point_count = x_input.as_ref().map(|input| input.len()).unwrap_or(0);
        let mut resolved_style = resolve_scatter_style(point_count, &style_args, "scatter")?;
        let x_arg = x_input.take().expect("scatter x consumed once");
        let y_arg = y_input.take().expect("scatter y consumed once");

        if !resolved_style.requires_cpu {
            if let (Some(x_gpu), Some(y_gpu)) = (x_arg.gpu_handle(), y_arg.gpu_handle()) {
                match build_scatter_gpu_plot(x_gpu, y_gpu, &resolved_style) {
                    Ok(plot) => {
                        let plot_index = figure.add_scatter_plot_on_axes(plot, axes);
                        *plot_index_slot.borrow_mut() = Some((axes, plot_index));
                        return Ok(());
                    }
                    Err(err) => {
                        warn!("scatter GPU path unavailable: {err}");
                    }
                }
            }
        }

        let (x_tensor, y_tensor) = (x_arg.into_tensor("scatter")?, y_arg.into_tensor("scatter")?);
        let (x_data, y_data) = numeric_pair(x_tensor, y_tensor, "scatter")?;
        let scatter = build_scatter_plot(x_data, y_data, &mut resolved_style)?;
        let plot_index = figure.add_scatter_plot_on_axes(scatter, axes);
        *plot_index_slot.borrow_mut() = Some((axes, plot_index));
        Ok(())
    });
    let Some((axes, plot_index)) = *plot_index_out.borrow() else {
        return render_result.map(|_| f64::NAN);
    };
    let handle =
        crate::builtins::plotting::state::register_scatter_handle(figure_handle, axes, plot_index);
    if let Err(err) = render_result {
        let lower = err.to_string().to_lowercase();
        if lower.contains("plotting is unavailable") || lower.contains("non-main thread") {
            return Ok(handle);
        }
        return Err(err);
    }
    Ok(handle)
}

fn build_scatter_plot(
    x: Vec<f64>,
    y: Vec<f64>,
    style: &mut ScatterResolvedStyle,
) -> BuiltinResult<ScatterPlot> {
    if x.len() != y.len() {
        return Err(scatter_err(
            "scatter: X and Y inputs must share the same length",
        ));
    }
    if x.is_empty() {
        return Err(scatter_err("scatter: inputs cannot be empty"));
    }

    ensure_host_marker_metadata(style, x.len())?;

    let mut scatter = ScatterPlot::new(x, y)
        .map_err(|err| scatter_err(format!("scatter: {err}")))?
        .with_style(style.uniform_color, style.marker_size, style.marker_style)
        .with_label(style.label.clone());
    scatter.colormap = style.colormap;
    scatter.set_edge_color(style.edge_color);
    scatter.set_edge_thickness(style.edge_thickness);
    scatter.set_filled(style.filled);
    scatter.set_edge_color_from_vertex(style.marker_edge_flat);

    if let Some(sizes) = style.per_point_sizes.take() {
        scatter.set_sizes(sizes);
    }
    if let Some(colors) = style.per_point_colors.take() {
        scatter.set_colors(colors);
    }
    if let Some(values) = style.color_values.take() {
        scatter.set_color_values(values, style.color_limits.take());
    }
    Ok(scatter)
}

const DEFAULT_MARKER_SIZE: f32 = 10.0;

fn scatter_err(message: impl Into<String>) -> RuntimeError {
    plotting_error(BUILTIN_NAME, message)
}

const DEFAULT_LINE_WIDTH: f32 = 1.0;
const DEFAULT_SCATTER_LABEL: &str = "Data";

fn default_color() -> Vec4 {
    Vec4::new(0.85, 0.2, 0.2, 0.95)
}

#[derive(Clone, Debug)]
struct ScatterResolvedStyle {
    uniform_color: Vec4,
    edge_color: Vec4,
    edge_thickness: f32,
    marker_style: MarkerStyle,
    marker_size: f32,
    filled: bool,
    per_point_sizes: Option<Vec<f32>>,
    per_point_colors: Option<Vec<Vec4>>,
    color_values: Option<Vec<f64>>,
    color_limits: Option<(f64, f64)>,
    gpu_sizes: Option<GpuTensorHandle>,
    gpu_colors: Option<PointGpuColor>,
    colormap: ColorMap,
    marker_face_flat: bool,
    marker_edge_flat: bool,
    requires_cpu: bool,
    label: String,
}

fn resolve_scatter_style(
    point_count: usize,
    args: &PointArgs,
    context: &'static str,
) -> BuiltinResult<ScatterResolvedStyle> {
    let mut style = ScatterResolvedStyle {
        uniform_color: default_color(),
        edge_color: default_color(),
        edge_thickness: DEFAULT_LINE_WIDTH,
        marker_style: MarkerStyle::Circle,
        marker_size: DEFAULT_MARKER_SIZE,
        filled: args.filled,
        per_point_sizes: None,
        per_point_colors: None,
        color_values: None,
        color_limits: None,
        gpu_sizes: None,
        gpu_colors: None,
        colormap: ColorMap::Parula,
        marker_face_flat: false,
        marker_edge_flat: false,
        requires_cpu: false,
        label: DEFAULT_SCATTER_LABEL.to_string(),
    };

    if let Some(label) = args.style.label.clone() {
        style.label = label;
    }

    let appearance = &args.style.appearance;
    style.uniform_color = appearance.color;
    style.edge_color = appearance.color;
    style.edge_thickness = appearance.line_width.max(0.1);

    if let PointColorArg::Uniform(color) = &args.color {
        style.uniform_color = *color;
    }

    if appearance.marker.is_none() {
        style.edge_color = style.uniform_color;
    }

    if let Some(marker) = appearance.marker.as_ref() {
        style.marker_style = marker.kind.to_plot_marker();
        if let Some(size) = marker.size {
            style.marker_size = size.max(0.1);
        }
        if matches!(marker.edge_color, MarkerColor::Flat) {
            style.marker_edge_flat = true;
        } else {
            style.edge_color =
                resolve_marker_color(&marker.edge_color, style.edge_color, style.uniform_color);
        }
        match &marker.face_color {
            MarkerColor::Flat => {
                style.marker_face_flat = true;
                style.filled = true;
            }
            MarkerColor::None => {
                style.filled = false;
            }
            _ => {
                let face_color = resolve_marker_color(
                    &marker.face_color,
                    style.uniform_color,
                    style.uniform_color,
                );
                if matches!(marker.face_color, MarkerColor::Color(_) | MarkerColor::Auto) {
                    style.uniform_color = face_color;
                }
            }
        }
    }

    if let PointSizeArg::Scalar(size) = &args.size {
        style.marker_size = (*size).max(0.1);
    }

    if let Some(value) = args.size.value() {
        match value {
            Value::GpuTensor(handle) => {
                validate_gpu_vector_length(handle, point_count, context)?;
                style.gpu_sizes = Some(handle.clone());
            }
            _ => {
                style.per_point_sizes = Some(convert_size_vector(value, point_count, context)?);
            }
        }
    }

    match &args.color {
        PointColorArg::ScalarValues(value) => {
            let scalars = convert_scalar_color_values(value, point_count, context)?;
            let (colors, limits) = map_scalar_values_to_colors(&scalars, style.colormap);
            style.color_values = Some(scalars);
            style.per_point_colors = Some(colors);
            style.color_limits = Some(limits);
        }
        PointColorArg::RgbMatrix(value) => match value {
            Value::GpuTensor(handle) => {
                let components = validate_gpu_color_matrix(handle, point_count, context)?;
                style.gpu_colors = Some(PointGpuColor {
                    handle: handle.clone(),
                    components,
                });
            }
            _ => {
                style.per_point_colors =
                    Some(convert_rgb_color_matrix(value, point_count, context)?);
            }
        },
        _ => {}
    }

    if style.per_point_colors.is_some() || style.color_values.is_some() {
        style.filled = true;
    }

    if style.marker_face_flat {
        if style.per_point_colors.is_none() && style.gpu_colors.is_none() {
            return Err(scatter_err(format!(
                "{context}: MarkerFaceColor 'flat' requires per-point color data (C argument)"
            )));
        }
        style.filled = true;
    }

    if style.marker_edge_flat && style.per_point_colors.is_none() && style.gpu_colors.is_none() {
        return Err(scatter_err(format!(
            "{context}: MarkerEdgeColor 'flat' requires per-point color data (C argument)"
        )));
    }

    if args.style.appearance.line_style != LineStyle::Solid && args.style.line_style_explicit {
        style.requires_cpu = true;
    }
    style.requires_cpu |= args.style.requires_cpu_fallback;
    if args.style.line_style_order.is_some() {
        style.requires_cpu = true;
    }

    Ok(style)
}

fn resolve_marker_color(marker_color: &MarkerColor, fallback: Vec4, default_base: Vec4) -> Vec4 {
    match marker_color {
        MarkerColor::Auto => fallback,
        MarkerColor::None => Vec4::new(default_base.x, default_base.y, default_base.z, 0.0),
        MarkerColor::Flat => fallback,
        MarkerColor::Color(color) => *color,
    }
}

fn build_scatter_gpu_plot(
    x: &GpuTensorHandle,
    y: &GpuTensorHandle,
    style: &ScatterResolvedStyle,
) -> BuiltinResult<ScatterPlot> {
    let context = super::gpu_helpers::ensure_shared_wgpu_context(BUILTIN_NAME)?;

    let x_ref = runmat_accelerate_api::export_wgpu_buffer(x)
        .ok_or_else(|| scatter_err("scatter: unable to export GPU X data"))?;
    let y_ref = runmat_accelerate_api::export_wgpu_buffer(y)
        .ok_or_else(|| scatter_err("scatter: unable to export GPU Y data"))?;

    if x_ref.len == 0 {
        return Err(scatter_err("scatter: empty input tensor"));
    }
    if x_ref.len != y_ref.len {
        return Err(scatter_err(
            "scatter: X and Y inputs must have identical lengths",
        ));
    }
    if x_ref.precision != y_ref.precision {
        return Err(scatter_err(
            "scatter: X and Y gpuArrays must have matching precision",
        ));
    }
    let point_count = x_ref.len;
    let len_u32 = u32::try_from(point_count)
        .map_err(|_| scatter_err("scatter: point count exceeds supported range"))?;
    let scalar = ScalarType::from_is_f64(x_ref.precision == ProviderPrecision::F64);
    let lod_stride = scatter_lod_stride(len_u32);

    let size_buffer = build_size_buffer(style, point_count)?;
    let has_sizes = size_buffer.has_data();
    let color_buffer = build_color_buffer(style, point_count)?;
    let has_colors = color_buffer.has_data();

    let inputs = Scatter2GpuInputs {
        x_buffer: x_ref.buffer.clone(),
        y_buffer: y_ref.buffer.clone(),
        len: len_u32,
        scalar,
    };
    let params = Scatter2GpuParams {
        color: style.uniform_color,
        point_size: style.marker_size,
        sizes: size_buffer,
        colors: color_buffer,
        lod_stride,
    };

    let gpu_vertices = runmat_plot::gpu::scatter2::pack_vertices_from_xy(
        &context.device,
        &context.queue,
        &inputs,
        &params,
    )
    .map_err(|e| scatter_err(format!("scatter: failed to build GPU vertices: {e}")))?;
    let drawn_points = gpu_vertices.vertex_count;

    let bounds = build_gpu_bounds(x, y)?;
    let gpu_style = ScatterGpuStyle {
        color: style.uniform_color,
        edge_color: style.edge_color,
        edge_thickness: style.edge_thickness,
        marker_size: style.marker_size,
        marker_style: style.marker_style,
        filled: style.filled,
        has_per_point_sizes: has_sizes,
        has_per_point_colors: has_colors,
        edge_from_vertex_colors: style.marker_edge_flat,
    };

    let mut scatter = ScatterPlot::from_gpu_buffer(gpu_vertices, drawn_points, bounds, gpu_style)
        .with_label(style.label.clone());
    scatter.colormap = style.colormap;
    scatter.set_edge_color_from_vertex(style.marker_edge_flat);
    if lod_stride == 1 {
        if let Some(values) = style.color_values.as_ref() {
            scatter.color_values = Some(values.clone());
        }
    }
    scatter.color_limits = style.color_limits;
    Ok(scatter)
}

fn scatter_lod_stride(point_count: u32) -> u32 {
    let target = scatter_target_points().max(1);
    if point_count <= target {
        1
    } else {
        point_count.div_ceil(target)
    }
}

fn build_gpu_bounds(x: &GpuTensorHandle, y: &GpuTensorHandle) -> BuiltinResult<BoundingBox> {
    let (min_x, max_x) = axis_bounds(x, BUILTIN_NAME)?;
    let (min_y, max_y) = axis_bounds(y, BUILTIN_NAME)?;
    Ok(BoundingBox::new(
        Vec3::new(min_x, min_y, 0.0),
        Vec3::new(max_x, max_y, 0.0),
    ))
}

fn build_size_buffer(
    style: &ScatterResolvedStyle,
    point_count: usize,
) -> BuiltinResult<ScatterAttributeBuffer> {
    if let Some(handle) = style.gpu_sizes.as_ref() {
        let exported = runmat_accelerate_api::export_wgpu_buffer(handle)
            .ok_or_else(|| scatter_err("scatter: unable to export GPU marker sizes"))?;
        if exported.len != point_count {
            return Err(scatter_err(format!(
                "scatter: marker size array must have {point_count} elements (got {})",
                exported.len
            )));
        }
        if exported.precision != ProviderPrecision::F32 {
            return Err(scatter_err(
                "scatter: GPU marker sizes must be single-precision (cast before plotting)",
            ));
        }
        return Ok(ScatterAttributeBuffer::Gpu(exported.buffer));
    }

    if let Some(sizes) = style.per_point_sizes.as_ref() {
        if sizes.is_empty() {
            Ok(ScatterAttributeBuffer::None)
        } else {
            Ok(ScatterAttributeBuffer::Host(sizes.clone()))
        }
    } else {
        Ok(ScatterAttributeBuffer::None)
    }
}

fn build_color_buffer(
    style: &ScatterResolvedStyle,
    point_count: usize,
) -> BuiltinResult<ScatterColorBuffer> {
    if let Some(gpu_color) = style.gpu_colors.as_ref() {
        let exported = runmat_accelerate_api::export_wgpu_buffer(&gpu_color.handle)
            .ok_or_else(|| scatter_err("scatter: unable to export GPU color data"))?;
        let expected = point_count * gpu_color.components.stride() as usize;
        if exported.len != expected {
            return Err(scatter_err(format!(
                "scatter: color array must contain {} elements (got {})",
                expected, exported.len
            )));
        }
        if exported.precision != ProviderPrecision::F32 {
            return Err(scatter_err(
                "scatter: GPU color arrays must be single-precision (cast before plotting)",
            ));
        }
        return Ok(ScatterColorBuffer::Gpu {
            buffer: exported.buffer,
            components: gpu_color.components.stride(),
        });
    }

    if let Some(colors) = style.per_point_colors.as_ref() {
        if colors.is_empty() {
            Ok(ScatterColorBuffer::None)
        } else {
            let data = colors.iter().map(|c| c.to_array()).collect();
            Ok(ScatterColorBuffer::Host(data))
        }
    } else {
        Ok(ScatterColorBuffer::None)
    }
}

fn ensure_host_marker_metadata(
    style: &mut ScatterResolvedStyle,
    point_count: usize,
) -> BuiltinResult<()> {
    if style.per_point_sizes.is_none() {
        if let Some(handle) = style.gpu_sizes.clone() {
            let tensor = gather_tensor_from_gpu(handle, "scatter")?;
            let value = Value::Tensor(tensor);
            style.per_point_sizes = Some(convert_size_vector(&value, point_count, "scatter")?);
        }
    }
    if style.per_point_colors.is_none() {
        if let Some(gpu_color) = style.gpu_colors.as_ref() {
            let tensor = gather_tensor_from_gpu(gpu_color.handle.clone(), "scatter")?;
            let value = Value::Tensor(tensor);
            style.per_point_colors =
                Some(convert_rgb_color_matrix(&value, point_count, "scatter")?);
        }
    }
    Ok(())
}

#[cfg(test)]
pub(crate) mod tests {
    use super::super::point::{PointArgs, PointColorArg, PointSizeArg};
    use super::super::style::{
        LineAppearance, LineStyleParseOptions, MarkerAppearance, MarkerColor, MarkerKind,
        ParsedLineStyle,
    };
    use super::*;
    use crate::builtins::plotting::state::current_axes_handle_for_figure;
    use crate::builtins::plotting::tests::{ensure_plot_test_env, lock_plot_registry};
    use crate::builtins::plotting::{
        clear_figure, clone_figure, configure_subplot, current_figure_handle,
        reset_hold_state_for_run,
    };
    use crate::RuntimeError;
    use futures::executor::block_on;
    use runmat_builtins::Value;
    use runmat_builtins::{ResolveContext, Type};
    use runmat_plot::plots::PlotElement;

    fn setup_plot_tests() {
        ensure_plot_test_env();
        reset_hold_state_for_run();
        let _ = clear_figure(None);
    }

    fn scatter_builtin(x: Value, y: Value, rest: Vec<Value>) -> BuiltinResult<f64> {
        block_on(super::scatter_builtin(x, y, rest))
    }

    fn test_style() -> ScatterResolvedStyle {
        ScatterResolvedStyle {
            uniform_color: default_color(),
            edge_color: default_color(),
            edge_thickness: DEFAULT_LINE_WIDTH,
            marker_style: MarkerStyle::Circle,
            marker_size: DEFAULT_MARKER_SIZE,
            filled: false,
            per_point_sizes: None,
            per_point_colors: None,
            color_values: None,
            color_limits: None,
            gpu_sizes: None,
            gpu_colors: None,
            colormap: ColorMap::Parula,
            marker_face_flat: false,
            marker_edge_flat: false,
            requires_cpu: false,
            label: DEFAULT_SCATTER_LABEL.to_string(),
        }
    }

    fn tensor_from(data: &[f64]) -> Tensor {
        Tensor {
            data: data.to_vec(),
            shape: vec![data.len()],
            rows: data.len(),
            cols: 1,
            dtype: runmat_builtins::NumericDType::F64,
        }
    }

    fn assert_plotting_unavailable(err: &RuntimeError) {
        let lower = err.to_string().to_lowercase();
        assert!(
            lower.contains("plotting is unavailable") || lower.contains("non-main thread"),
            "unexpected error: {err}"
        );
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn scatter_requires_equal_lengths() {
        setup_plot_tests();
        assert!(build_scatter_plot(vec![1.0], vec![], &mut test_style()).is_err());
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn scatter_builtin_returns_or_reports_backend_status() {
        setup_plot_tests();
        let out = scatter_builtin(
            Value::Tensor(tensor_from(&[0.0, 1.0])),
            Value::Tensor(tensor_from(&[0.0, 1.0])),
            Vec::new(),
        );
        if let Err(flow) = out {
            assert_plotting_unavailable(&flow);
        }
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn scatter_resolves_marker_style_from_arguments() {
        setup_plot_tests();
        let rest = vec![
            Value::Num(12.0),
            Value::String("r".into()),
            Value::String("filled".into()),
            Value::String("Marker".into()),
            Value::String("s".into()),
        ];
        let args = PointArgs::parse(rest, LineStyleParseOptions::scatter()).unwrap();
        let style = resolve_scatter_style(3, &args, "scatter").expect("style");
        assert!(style.filled);
        assert_eq!(style.marker_style, MarkerStyle::Square);
        assert_eq!(style.marker_size as i32, 12);
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn scatter_supports_flat_marker_face_color() {
        setup_plot_tests();
        let rest = vec![
            Value::Tensor(tensor_from(&[5.0, 5.0])),
            Value::Tensor(tensor_from(&[0.0, 1.0])),
            Value::String("Marker".into()),
            Value::String("o".into()),
            Value::String("MarkerFaceColor".into()),
            Value::String("flat".into()),
        ];
        let args = PointArgs::parse(rest, LineStyleParseOptions::scatter()).unwrap();
        let style = resolve_scatter_style(2, &args, "scatter").expect("style");
        assert!(style.marker_face_flat);
        assert!(style.per_point_colors.is_some() || style.gpu_colors.is_some());
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn scatter_applies_display_name() {
        setup_plot_tests();
        let rest = vec![
            Value::String("DisplayName".into()),
            Value::String("Series A".into()),
        ];
        let args = PointArgs::parse(rest, LineStyleParseOptions::scatter()).unwrap();
        let mut style = resolve_scatter_style(2, &args, "scatter").expect("style");
        let plot = build_scatter_plot(vec![0.0, 1.0], vec![0.0, 1.0], &mut style).expect("plot");
        assert_eq!(plot.label.as_deref(), Some("Series A"));
    }

    #[test]
    fn scatter_accepts_leading_axes_handle() {
        let _guard = crate::builtins::plotting::tests::lock_plot_registry();
        setup_plot_tests();
        configure_subplot(1, 2, 1).unwrap();
        let fig_handle = current_figure_handle();
        let ax = current_axes_handle_for_figure(fig_handle).unwrap();
        let _ = scatter_builtin(
            Value::Num(ax),
            Value::Tensor(tensor_from(&[0.0, 1.0])),
            vec![Value::Tensor(tensor_from(&[1.0, 2.0]))],
        );
        let fig = clone_figure(fig_handle).unwrap();
        assert_eq!(fig.plot_axes_indices(), &[1]);
    }

    #[test]
    fn scatter_accepts_scalar_point() {
        let _guard = lock_plot_registry();
        setup_plot_tests();
        let _ = scatter_builtin(Value::Num(1.0), Value::Num(2.0), Vec::new()).unwrap();
        let fig = clone_figure(current_figure_handle()).unwrap();
        let PlotElement::Scatter(plot) = fig.plots().next().unwrap() else {
            panic!("expected scatter")
        };
        assert_eq!(plot.x_data, vec![1.0]);
        assert_eq!(plot.y_data, vec![2.0]);
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn scatter_rejects_flat_marker_edge_color_without_color_data() {
        setup_plot_tests();
        let rest = vec![
            Value::Tensor(tensor_from(&[5.0, 5.0])),
            Value::String("Marker".into()),
            Value::String("o".into()),
            Value::String("MarkerEdgeColor".into()),
            Value::String("flat".into()),
        ];
        let args = PointArgs::parse(rest, LineStyleParseOptions::scatter()).unwrap();
        let err = resolve_scatter_style(2, &args, "scatter").unwrap_err();
        assert!(err.to_string().contains("requires per-point color data"));
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn scatter_accepts_flat_marker_edge_color_when_colors_supplied() {
        setup_plot_tests();
        let appearance = LineAppearance {
            marker: Some(MarkerAppearance {
                kind: MarkerKind::Circle,
                size: None,
                edge_color: MarkerColor::Flat,
                face_color: MarkerColor::Auto,
            }),
            ..Default::default()
        };
        let args = PointArgs {
            size: PointSizeArg::Default,
            color: PointColorArg::ScalarValues(Value::Tensor(tensor_from(&[0.1, 0.5]))),
            filled: false,
            style: ParsedLineStyle {
                appearance,
                requires_cpu_fallback: false,
                line_style_explicit: false,
                line_style_order: None,
                label: None,
            },
        };
        let mut style = resolve_scatter_style(2, &args, "scatter").expect("style");
        assert!(style.marker_edge_flat);
        let plot = build_scatter_plot(vec![0.0, 1.0], vec![0.0, 1.0], &mut style).expect("plot");
        assert!(plot.edge_color_from_vertex_colors);
    }

    #[test]
    fn scatter_type_is_numeric_handle() {
        assert_eq!(
            handle_scalar_type(
                &[Type::tensor(), Type::tensor()],
                &ResolveContext::new(Vec::new())
            ),
            Type::Num
        );
    }
}
