//! MATLAB-compatible `hist` builtin.

use glam::{Vec3, Vec4};
use log::warn;
use runmat_accelerate_api::{self, GpuTensorHandle, ProviderPrecision};
use runmat_builtins::{NumericDType, Tensor, Value};
use runmat_macros::runtime_builtin;
use runmat_plot::core::BoundingBox;
use runmat_plot::gpu::bar::{BarGpuInputs, BarGpuParams, BarLayoutMode, BarOrientation};
use runmat_plot::gpu::histogram::{
    HistogramGpuInputs, HistogramGpuOutput, HistogramGpuParams, HistogramGpuWeights,
    HistogramNormalizationMode,
};
use runmat_plot::gpu::ScalarType;
use runmat_plot::plots::BarChart;

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

use super::bar::apply_bar_style;
use super::common::{numeric_vector, value_as_f64};
use super::plotting_error;
use super::state::{render_active_plot, PlotRenderOptions};
use super::style::{parse_bar_style_args, BarStyle, BarStyleDefaults};
use crate::builtins::plotting::gpu_helpers::{axis_bounds_async, gather_tensor_from_gpu_async};
use crate::builtins::plotting::type_resolvers::hist_type;
use crate::{BuiltinResult, RuntimeError};

#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::plotting::hist")]
pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
    name: "hist",
    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: "Histogram 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::hist")]
pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
    name: "hist",
    shape: ShapeRequirements::Any,
    constant_strategy: ConstantStrategy::InlineLiteral,
    elementwise: None,
    reduction: None,
    emits_nan: false,
    notes: "hist terminates fusion graphs and produces I/O.",
};

const BUILTIN_NAME: &str = "hist";
const HIST_BAR_WIDTH: f32 = 0.95;
const HIST_DEFAULT_COLOR: Vec4 = Vec4::new(0.15, 0.5, 0.8, 0.95);
const HIST_DEFAULT_LABEL: &str = "Frequency";

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

struct HistComputation {
    counts: Vec<f64>,
    centers: Vec<f64>,
    chart: BarChart,
}

/// Captures the evaluated histogram so both the renderer and MATLAB outputs share the same data.
pub struct HistEvaluation {
    counts: Tensor,
    #[allow(dead_code)]
    centers: Tensor,
    chart: BarChart,
    normalization: HistNormalization,
}

impl HistEvaluation {
    fn new(
        counts: Vec<f64>,
        centers: Vec<f64>,
        chart: BarChart,
        normalization: HistNormalization,
    ) -> BuiltinResult<Self> {
        if counts.len() != centers.len() {
            return Err(hist_err("hist: mismatch between counts and bin centers"));
        }
        let cols = counts.len();
        let shape = vec![1, cols];
        let counts_tensor = Tensor::new(counts, shape.clone())?;
        let centers_tensor = Tensor::new(centers, shape)?;
        Ok(Self {
            counts: counts_tensor,
            centers: centers_tensor,
            chart,
            normalization,
        })
    }

    pub fn counts_value(&self) -> Value {
        Value::Tensor(self.counts.clone())
    }

    #[allow(dead_code)]
    pub fn centers_value(&self) -> Value {
        Value::Tensor(self.centers.clone())
    }

    pub fn render_plot(&self) -> BuiltinResult<()> {
        let y_label = match self.normalization {
            HistNormalization::Count => "Count",
            HistNormalization::Probability => "Probability",
            HistNormalization::Pdf => "PDF",
        };
        let mut chart_opt = Some(self.chart.clone());
        let opts = PlotRenderOptions {
            title: "Histogram",
            x_label: "Bin",
            y_label,
            ..Default::default()
        };
        render_active_plot(BUILTIN_NAME, opts, move |figure, axes| {
            let chart = chart_opt
                .take()
                .expect("hist chart consumed exactly once at render time");
            figure.add_bar_chart_on_axes(chart, axes);
            Ok(())
        })?;
        Ok(())
    }
}

impl HistComputation {
    fn into_evaluation(self, normalization: HistNormalization) -> BuiltinResult<HistEvaluation> {
        HistEvaluation::new(self.counts, self.centers, self.chart, normalization)
    }
}

#[derive(Clone)]
enum HistBinSpec {
    Auto,
    Count(usize),
    Centers(Vec<f64>),
    Edges(Vec<f64>),
}

#[derive(Clone)]
struct HistBinOptions {
    spec: HistBinSpec,
    bin_width: Option<f64>,
    bin_limits: Option<(f64, f64)>,
    bin_method: Option<HistBinMethod>,
}

impl HistBinOptions {
    fn new(spec: HistBinSpec) -> Self {
        Self {
            spec,
            bin_width: None,
            bin_limits: None,
            bin_method: None,
        }
    }

    fn is_uniform(&self) -> bool {
        match &self.spec {
            HistBinSpec::Edges(edges) => uniform_edge_width(edges).is_some(),
            _ => true,
        }
    }
}

#[derive(Clone, Copy)]
enum HistBinMethod {
    Sqrt,
    Sturges,
    Integers,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
enum HistNormalization {
    #[default]
    Count,
    Probability,
    Pdf,
}

#[derive(Clone)]
enum HistWeightsInput {
    None,
    Host(Tensor),
    Gpu(GpuTensorHandle),
}

impl HistWeightsInput {
    fn from_value(value: Value, expected_len: usize) -> BuiltinResult<Self> {
        match value {
            Value::GpuTensor(handle) => {
                let len: usize = handle.shape.iter().product();
                if len != expected_len {
                    return Err(hist_err(format!(
                        "hist: Weights must contain {expected_len} elements (got {len})"
                    )));
                }
                Ok(HistWeightsInput::Gpu(handle))
            }
            other => {
                let tensor =
                    Tensor::try_from(&other).map_err(|e| hist_err(format!("hist: Weights {e}")))?;
                if tensor.data.len() != expected_len {
                    return Err(hist_err(format!(
                        "hist: Weights must contain {expected_len} elements (got {})",
                        tensor.data.len()
                    )));
                }
                Ok(HistWeightsInput::Host(tensor))
            }
        }
    }

    async fn resolve_for_cpu_async(
        &self,
        context: &'static str,
        sample_len: usize,
    ) -> BuiltinResult<(Option<Vec<f64>>, f64)> {
        match self {
            HistWeightsInput::None => Ok((None, sample_len as f64)),
            HistWeightsInput::Host(tensor) => {
                let values = numeric_vector(tensor.clone());
                let total = values.iter().copied().sum::<f64>();
                Ok((Some(values), total))
            }
            HistWeightsInput::Gpu(handle) => {
                let tensor = gather_tensor_from_gpu_async(handle.clone(), context).await?;
                let values = numeric_vector(tensor);
                let total = values.iter().copied().sum::<f64>();
                Ok((Some(values), total))
            }
        }
    }

    fn total_weight_hint(&self, sample_len: usize) -> Option<f64> {
        match self {
            HistWeightsInput::None => Some(sample_len as f64),
            HistWeightsInput::Host(tensor) => {
                let values = numeric_vector(tensor.clone());
                Some(values.iter().copied().sum::<f64>())
            }
            HistWeightsInput::Gpu(_) => None,
        }
    }

    fn to_gpu_weights(&self, sample_len: usize) -> BuiltinResult<HistogramGpuWeights> {
        match self {
            HistWeightsInput::None => Ok(HistogramGpuWeights::Uniform {
                total_weight: sample_len as f32,
            }),
            HistWeightsInput::Host(tensor) => {
                let values = numeric_vector(tensor.clone());
                let total = values.iter().copied().sum::<f64>() as f32;
                match tensor.dtype {
                    NumericDType::F32 => {
                        let data: Vec<f32> = values.iter().map(|v| *v as f32).collect();
                        Ok(HistogramGpuWeights::HostF32 {
                            data,
                            total_weight: total,
                        })
                    }
                    NumericDType::F64 => Ok(HistogramGpuWeights::HostF64 {
                        data: values,
                        total_weight: total,
                    }),
                    NumericDType::U8 | NumericDType::U16 => Ok(HistogramGpuWeights::HostF64 {
                        data: values,
                        total_weight: total,
                    }),
                }
            }
            HistWeightsInput::Gpu(handle) => {
                let exported = runmat_accelerate_api::export_wgpu_buffer(handle)
                    .ok_or_else(|| hist_err("hist: unable to export GPU weights"))?;
                match exported.precision {
                    ProviderPrecision::F32 => Ok(HistogramGpuWeights::GpuF32 {
                        buffer: exported.buffer.clone(),
                    }),
                    ProviderPrecision::F64 => Ok(HistogramGpuWeights::GpuF64 {
                        buffer: exported.buffer.clone(),
                    }),
                }
            }
        }
    }
}

#[runtime_builtin(
    name = "hist",
    category = "plotting",
    summary = "Plot a histogram with MATLAB-compatible defaults.",
    keywords = "hist,histogram,frequency",
    sink = true,
    suppress_auto_output = true,
    type_resolver(hist_type),
    builtin_path = "crate::builtins::plotting::hist"
)]
pub async fn hist_builtin(data: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
    let evaluation = evaluate_async(data, &rest).await?;
    evaluation.render_plot()?;
    Ok(evaluation.counts_value())
}

/// Evaluate the histogram inputs once so renderers and MATLAB outputs share the same data.
pub async fn evaluate_async(data: Value, rest: &[Value]) -> BuiltinResult<HistEvaluation> {
    let mut input = Some(HistInput::from_value(data)?);
    let sample_len = input.as_ref().map(|value| value.len()).unwrap_or(0);
    let (bin_options, normalization, style_args, weights_value) =
        parse_hist_arguments(sample_len, rest)?;
    let defaults = BarStyleDefaults::new(HIST_DEFAULT_COLOR, HIST_BAR_WIDTH);
    let bar_style = parse_bar_style_args("hist", &style_args, defaults)?;
    let weights_input = if let Some(value) = weights_value {
        HistWeightsInput::from_value(value, sample_len)?
    } else {
        HistWeightsInput::None
    };

    let computation = if !bar_style.requires_cpu_path() {
        if let Some(handle) = input.as_ref().and_then(|value| value.gpu_handle()) {
            if bin_options.is_uniform() {
                match build_histogram_gpu_chart_async(
                    handle,
                    &bin_options,
                    sample_len,
                    normalization,
                    &bar_style,
                    &weights_input,
                )
                .await
                {
                    Ok(chart) => Some(chart),
                    Err(err) => {
                        warn!("hist GPU path unavailable: {err}");
                        None
                    }
                }
            } else {
                None
            }
        } else {
            None
        }
    } else {
        None
    };

    let computation = match computation {
        Some(chart) => chart,
        None => {
            let data_arg = input.take().expect("hist input consumed once");
            let tensor = match data_arg {
                HistInput::Host(tensor) => tensor,
                HistInput::Gpu(handle) => gather_tensor_from_gpu_async(handle, "hist").await?,
            };
            let samples = numeric_vector(tensor);
            let (weight_values, total_weight) = weights_input
                .resolve_for_cpu_async("hist weights", sample_len)
                .await?;
            build_histogram_chart(
                samples,
                &bin_options,
                normalization,
                weight_values.as_deref(),
                total_weight,
            )?
        }
    };

    let mut evaluation = computation.into_evaluation(normalization)?;
    apply_bar_style(&mut evaluation.chart, &bar_style, HIST_DEFAULT_LABEL);
    Ok(evaluation)
}

fn parse_hist_arguments(
    sample_len: usize,
    args: &[Value],
) -> BuiltinResult<(HistBinOptions, HistNormalization, Vec<Value>, Option<Value>)> {
    let mut idx = 0usize;
    let mut bin_options = HistBinOptions::new(HistBinSpec::Auto);
    let mut bin_set = false;
    let mut normalization = HistNormalization::Count;
    let mut norm_set = false;
    let mut style_args = Vec::new();
    let mut weights_value: Option<Value> = None;

    while idx < args.len() {
        let arg = &args[idx];
        if !bin_set && is_bin_candidate(arg) {
            let spec = parse_hist_bins(Some(arg.clone()), sample_len)?;
            ensure_spec_compatible(&spec, &bin_options, "bin argument")?;
            bin_options.spec = spec;
            bin_set = true;
            idx += 1;
            continue;
        }

        if !norm_set {
            if let Some(result) = try_parse_norm_literal(arg) {
                normalization = result?;
                norm_set = true;
                idx += 1;
                continue;
            }
        }

        let Some(key) = value_as_string(arg) else {
            style_args.extend_from_slice(&args[idx..]);
            break;
        };
        if idx + 1 >= args.len() {
            return Err(hist_err(format!("hist: missing value for '{key}' option")));
        }
        let value = args[idx + 1].clone();
        let lower = key.trim().to_ascii_lowercase();
        match lower.as_str() {
            "normalization" => {
                normalization = parse_hist_normalization(Some(value))?;
                norm_set = true;
            }
            "binedges" => {
                if bin_set {
                    return Err(hist_err(
                        "hist: specify either bins argument or 'BinEdges', not both",
                    ));
                }
                let edges = parse_bin_edges_value(value)?;
                ensure_spec_compatible(
                    &HistBinSpec::Edges(edges.clone()),
                    &bin_options,
                    "BinEdges",
                )?;
                bin_options.spec = HistBinSpec::Edges(edges);
                bin_set = true;
            }
            "numbins" => {
                if bin_set {
                    return Err(hist_err(
                        "hist: NumBins cannot be combined with explicit bins",
                    ));
                }
                let count = parse_num_bins_value(&value)?;
                ensure_spec_compatible(&HistBinSpec::Count(count), &bin_options, "NumBins")?;
                bin_options.spec = HistBinSpec::Count(count);
                bin_set = true;
            }
            "binwidth" => {
                if bin_set {
                    return Err(hist_err(
                        "hist: BinWidth cannot be combined with explicit bins",
                    ));
                }
                ensure_no_explicit_bins(&bin_options, "BinWidth")?;
                if bin_options.bin_width.is_some() {
                    return Err(hist_err("hist: BinWidth specified more than once"));
                }
                let width = parse_positive_scalar(
                    &value,
                    "hist: BinWidth must be a positive finite scalar",
                )?;
                bin_options.bin_width = Some(width);
            }
            "binlimits" => {
                ensure_no_explicit_bins(&bin_options, "BinLimits")?;
                if bin_options.bin_limits.is_some() {
                    return Err(hist_err("hist: BinLimits specified more than once"));
                }
                let limits = parse_bin_limits_value(value)?;
                bin_options.bin_limits = Some(limits);
            }
            "binmethod" => {
                if bin_options.bin_width.is_some() {
                    return Err(hist_err("hist: BinMethod cannot be combined with BinWidth"));
                }
                ensure_no_explicit_bins(&bin_options, "BinMethod")?;
                if bin_options.bin_method.is_some() {
                    return Err(hist_err("hist: BinMethod specified more than once"));
                }
                let method = parse_hist_bin_method(&value)?;
                bin_options.bin_method = Some(method);
            }
            "weights" => {
                if weights_value.is_some() {
                    return Err(hist_err("hist: Weights specified more than once"));
                }
                weights_value = Some(value);
            }
            _ => {
                style_args.push(arg.clone());
                style_args.push(value);
            }
        }
        idx += 2;
    }

    Ok((bin_options, normalization, style_args, weights_value))
}

fn parse_hist_bins(arg: Option<Value>, sample_len: usize) -> BuiltinResult<HistBinSpec> {
    let spec = match arg {
        None => HistBinSpec::Auto,
        Some(Value::Tensor(tensor)) => parse_center_vector(tensor)?,
        Some(Value::GpuTensor(_)) => {
            return Err(hist_err("hist: bin definitions must reside on the host"))
        }
        Some(other) => {
            if let Some(numeric) = value_as_f64(&other) {
                parse_bin_count_value(numeric)?
            } else {
                return Err(hist_err(
                    "hist: bin argument must be a scalar count or a vector of centers",
                ));
            }
        }
    };
    Ok(match spec {
        HistBinSpec::Count(0) => HistBinSpec::Count(default_bin_count(sample_len)),
        other => other,
    })
}

#[derive(Clone, Copy)]
struct HistDataStats {
    min: Option<f64>,
    max: Option<f64>,
}

impl HistDataStats {
    fn from_samples(samples: &[f64]) -> Self {
        let mut min: Option<f64> = None;
        let mut max: Option<f64> = None;
        for &value in samples {
            if value.is_nan() {
                continue;
            }
            min = Some(match min {
                Some(current) => current.min(value),
                None => value,
            });
            max = Some(match max {
                Some(current) => current.max(value),
                None => value,
            });
        }
        Self { min, max }
    }
}

struct RealizedBins {
    edges: Vec<f64>,
    widths: Vec<f64>,
    labels: Vec<String>,
    centers: Vec<f64>,
    uniform_width: Option<f64>,
}

impl RealizedBins {
    fn from_edges(edges: Vec<f64>) -> BuiltinResult<Self> {
        if edges.len() < 2 {
            return Err(hist_err(
                "hist: bin definitions must contain at least two edges",
            ));
        }
        let widths = widths_from_edges(&edges);
        let labels = histogram_labels_from_edges(&edges);
        let centers = centers_from_edges(&edges);
        let uniform_width = if widths.iter().all(|w| approx_equal(*w, widths[0])) {
            Some(widths[0])
        } else {
            None
        };
        Ok(Self {
            edges,
            widths,
            labels,
            centers,
            uniform_width,
        })
    }

    fn bin_count(&self) -> usize {
        self.widths.len()
    }
}

fn realize_bins(
    options: &HistBinOptions,
    sample_len: usize,
    stats: Option<&HistDataStats>,
    fallback_value: Option<f64>,
) -> BuiltinResult<RealizedBins> {
    match &options.spec {
        HistBinSpec::Centers(centers) => {
            let edges = edges_from_centers(centers)?;
            RealizedBins::from_edges(edges)
        }
        HistBinSpec::Edges(edges) => RealizedBins::from_edges(edges.clone()),
        _ => {
            if matches!(options.bin_method, Some(HistBinMethod::Integers)) {
                let edges = integer_edges(options, stats, fallback_value)?;
                return RealizedBins::from_edges(edges);
            }
            let edges = uniform_edges_from_options(options, sample_len, stats, fallback_value)?;
            RealizedBins::from_edges(edges)
        }
    }
}

fn integer_edges(
    options: &HistBinOptions,
    stats: Option<&HistDataStats>,
    fallback_value: Option<f64>,
) -> BuiltinResult<Vec<f64>> {
    let (lower, upper) = determine_limits(options, stats, fallback_value)?;
    let start = lower.floor();
    let mut end = upper.ceil();
    if approx_equal(start, end) {
        end = start + 1.0;
    }
    if end <= start {
        end = start + 1.0;
    }
    let mut edges = Vec::new();
    let mut current = start;
    while current <= end {
        edges.push(current);
        current += 1.0;
    }
    if edges.len() < 2 {
        edges.push(edges[0] + 1.0);
    }
    Ok(edges)
}

fn uniform_edges_from_options(
    options: &HistBinOptions,
    sample_len: usize,
    stats: Option<&HistDataStats>,
    fallback_value: Option<f64>,
) -> BuiltinResult<Vec<f64>> {
    let (mut lower, mut upper) = determine_limits(options, stats, fallback_value)?;
    if !lower.is_finite() || !upper.is_finite() {
        lower = -0.5;
        upper = 0.5;
    }
    if approx_equal(lower, upper) {
        upper = lower + 1.0;
    }
    if let Some(width) = options.bin_width {
        let bins = ((upper - lower) / width).ceil().max(1.0) as usize;
        let mut edges = Vec::with_capacity(bins + 1);
        for i in 0..=bins {
            edges.push(lower + width * i as f64);
        }
        if let Some(last) = edges.last_mut() {
            *last = upper;
        }
        return Ok(edges);
    }
    let span = (upper - lower).abs();
    let bin_count = determine_bin_count(options, sample_len)?;
    let mut edges = Vec::with_capacity(bin_count + 1);
    let step = if bin_count == 0 {
        1.0
    } else {
        span / bin_count as f64
    };
    for i in 0..=bin_count {
        edges.push(lower + step * i as f64);
    }
    if let Some(last) = edges.last_mut() {
        *last = upper;
    }
    Ok(edges)
}

fn widths_from_edges(edges: &[f64]) -> Vec<f64> {
    edges
        .windows(2)
        .map(|pair| (pair[1] - pair[0]).max(f64::MIN_POSITIVE))
        .collect()
}

fn determine_limits(
    options: &HistBinOptions,
    stats: Option<&HistDataStats>,
    fallback_value: Option<f64>,
) -> BuiltinResult<(f64, f64)> {
    if let Some((lo, hi)) = options.bin_limits {
        if hi <= lo {
            return Err(hist_err("hist: BinLimits must be increasing"));
        }
        return Ok((lo, hi));
    }
    if let Some(stats) = stats {
        if let (Some(min), Some(max)) = (stats.min, stats.max) {
            if approx_equal(min, max) {
                let span = options.bin_width.unwrap_or(1.0);
                return Ok((min - span * 0.5, min + span * 0.5));
            }
            return Ok((min, max));
        }
    }
    let center = fallback_value.unwrap_or(0.0);
    let span = options.bin_width.unwrap_or(1.0);
    Ok((center - span * 0.5, center + span * 0.5))
}

fn determine_bin_count(options: &HistBinOptions, sample_len: usize) -> BuiltinResult<usize> {
    if let HistBinSpec::Count(count) = options.spec {
        return Ok(count.max(1));
    }
    if let Some(method) = options.bin_method {
        return Ok(match method {
            HistBinMethod::Sqrt => sqrt_bin_count(sample_len),
            HistBinMethod::Sturges => sturges_bin_count(sample_len),
            HistBinMethod::Integers => {
                return Err(hist_err("hist: internal integer bin method misuse"))
            }
        });
    }
    Ok(default_bin_count(sample_len))
}

fn sqrt_bin_count(sample_len: usize) -> usize {
    ((sample_len as f64).sqrt().ceil() as usize).max(1)
}

fn sturges_bin_count(sample_len: usize) -> usize {
    let n = sample_len.max(1) as f64;
    ((n.log2().ceil() + 1.0) as usize).max(1)
}

fn approx_equal(a: f64, b: f64) -> bool {
    (a - b).abs() <= 1e-9
}

fn ensure_spec_compatible(
    new_spec: &HistBinSpec,
    options: &HistBinOptions,
    source: &str,
) -> BuiltinResult<()> {
    if matches!(new_spec, HistBinSpec::Centers(_) | HistBinSpec::Edges(_))
        && (options.bin_width.is_some()
            || options.bin_method.is_some()
            || options.bin_limits.is_some())
    {
        return Err(hist_err(format!(
            "hist: {source} cannot be combined with BinWidth, BinLimits, or BinMethod"
        )));
    }
    Ok(())
}

fn ensure_no_explicit_bins(options: &HistBinOptions, source: &str) -> BuiltinResult<()> {
    if matches!(
        options.spec,
        HistBinSpec::Centers(_) | HistBinSpec::Edges(_)
    ) {
        return Err(hist_err(format!(
            "hist: {source} cannot be combined with explicit bin centers or edges"
        )));
    }
    Ok(())
}

fn parse_num_bins_value(value: &Value) -> BuiltinResult<usize> {
    let Some(scalar) = value_as_f64(value) else {
        return Err(hist_err("hist: NumBins must be a numeric scalar"));
    };
    if !scalar.is_finite() || scalar <= 0.0 {
        return Err(hist_err("hist: NumBins must be a positive finite scalar"));
    }
    let rounded = scalar.round();
    if (scalar - rounded).abs() > 1e-9 {
        return Err(hist_err("hist: NumBins must be an integer"));
    }
    Ok(rounded as usize)
}

fn parse_positive_scalar(value: &Value, err: &str) -> BuiltinResult<f64> {
    let Some(scalar) = value_as_f64(value) else {
        return Err(hist_err(err));
    };
    if !scalar.is_finite() || scalar <= 0.0 {
        return Err(hist_err(err));
    }

    Ok(scalar)
}

fn parse_bin_limits_value(value: Value) -> BuiltinResult<(f64, f64)> {
    let tensor = Tensor::try_from(&value)
        .map_err(|_| hist_err("hist: BinLimits must be provided as a numeric vector"))?;
    let values = numeric_vector(tensor);
    if values.len() != 2 {
        return Err(hist_err(
            "hist: BinLimits must contain exactly two elements",
        ));
    }
    let lo = values[0];
    let hi = values[1];
    if !lo.is_finite() || !hi.is_finite() {
        return Err(hist_err("hist: BinLimits must be finite"));
    }
    if hi <= lo {
        return Err(hist_err("hist: BinLimits must be increasing"));
    }
    Ok((lo, hi))
}

fn parse_hist_bin_method(value: &Value) -> BuiltinResult<HistBinMethod> {
    let Some(text) = value_as_string(value) else {
        return Err(hist_err("hist: BinMethod must be a string"));
    };
    match text.trim().to_ascii_lowercase().as_str() {
        "sqrt" => Ok(HistBinMethod::Sqrt),
        "sturges" => Ok(HistBinMethod::Sturges),
        "integers" => Ok(HistBinMethod::Integers),
        other => Err(hist_err(format!(
            "hist: BinMethod '{other}' is not supported yet (supported: 'sqrt', 'sturges', 'integers')"
        ))),
    }
}

fn parse_center_vector(tensor: Tensor) -> BuiltinResult<HistBinSpec> {
    let values = numeric_vector(tensor);
    if values.is_empty() {
        return Err(hist_err("hist: bin center array cannot be empty"));
    }
    if values.len() == 1 {
        return parse_bin_count_value(values[0]);
    }
    validate_monotonic(&values)?;
    ensure_uniform_spacing(&values)?;
    Ok(HistBinSpec::Centers(values))
}

fn parse_bin_count_value(value: f64) -> BuiltinResult<HistBinSpec> {
    if value.is_finite() && value > 0.0 {
        Ok(HistBinSpec::Count(value.round() as usize))
    } else {
        Err(hist_err("hist: bin count must be positive"))
    }
}

fn is_bin_candidate(value: &Value) -> bool {
    matches!(
        value,
        Value::Tensor(_) | Value::Num(_) | Value::Int(_) | Value::Bool(_)
    )
}

fn try_parse_norm_literal(value: &Value) -> Option<BuiltinResult<HistNormalization>> {
    match value {
        Value::String(_) | Value::CharArray(_) => {
            let cloned = value.clone();
            match parse_hist_normalization(Some(cloned)) {
                Ok(norm) => Some(Ok(norm)),
                Err(_) => None,
            }
        }
        _ => None,
    }
}

fn parse_bin_edges_value(value: Value) -> BuiltinResult<Vec<f64>> {
    match value {
        Value::Tensor(tensor) => {
            let edges = numeric_vector(tensor);
            if edges.len() < 2 {
                return Err(hist_err(
                    "hist: 'BinEdges' must contain at least two elements",
                ));
            }
            validate_monotonic(&edges)?;
            Ok(edges)
        }
        Value::GpuTensor(_) => Err(hist_err("hist: 'BinEdges' must be provided on the host")),
        _ => Err(hist_err("hist: 'BinEdges' expects a numeric vector")),
    }
}

fn ensure_uniform_spacing(values: &[f64]) -> BuiltinResult<()> {
    if values.len() <= 2 {
        return Ok(());
    }
    let mut diffs = values.windows(2).map(|pair| pair[1] - pair[0]);
    let first = diffs.next().unwrap();
    if first <= 0.0 || !first.is_finite() {
        return Err(hist_err("hist: bin centers must be strictly increasing"));
    }
    let tol = first.abs().max(1.0) * 1e-6;
    for diff in diffs {
        if (diff - first).abs() > tol {
            return Err(hist_err("hist: bin centers must be evenly spaced"));
        }
    }
    Ok(())
}

fn uniform_edge_width(edges: &[f64]) -> Option<f64> {
    if edges.len() < 2 {
        return None;
    }
    let mut diffs = edges.windows(2).map(|pair| pair[1] - pair[0]);
    let first = diffs.next().unwrap();
    if first <= 0.0 || !first.is_finite() {
        return None;
    }
    let tol = first.abs().max(1.0) * 1e-5;
    for diff in diffs {
        if diff <= 0.0 || !diff.is_finite() {
            return None;
        }
        if (diff - first).abs() > tol {
            return None;
        }
    }
    Some(first)
}

fn parse_hist_normalization(arg: Option<Value>) -> BuiltinResult<HistNormalization> {
    match arg {
        None => Ok(HistNormalization::Count),
        Some(Value::String(s)) => parse_norm_string(&s),
        Some(Value::CharArray(chars)) => {
            let text: String = chars.data.iter().collect();
            parse_norm_string(&text)
        }
        Some(value) => {
            if let Some(text) = value_as_string(&value) {
                parse_norm_string(&text)
            } else {
                Err(hist_err(
                    "hist: normalization must be 'count', 'probability', or 'pdf'",
                ))
            }
        }
    }
}

fn parse_norm_string(text: &str) -> BuiltinResult<HistNormalization> {
    match text.trim().to_ascii_lowercase().as_str() {
        "count" | "counts" => Ok(HistNormalization::Count),
        "probability" | "prob" => Ok(HistNormalization::Probability),
        "pdf" => Ok(HistNormalization::Pdf),
        other => Err(hist_err(format!(
            "hist: unsupported normalization '{other}' (expected 'count', 'probability', or 'pdf')"
        ))),
    }
}

fn value_as_string(value: &Value) -> Option<String> {
    match value {
        Value::String(s) => Some(s.clone()),
        Value::CharArray(chars) => Some(chars.data.iter().collect()),
        _ => None,
    }
}

fn default_bin_count(sample_len: usize) -> usize {
    ((sample_len as f64).sqrt().floor() as usize).max(1)
}

fn build_histogram_chart(
    data: Vec<f64>,
    bin_options: &HistBinOptions,
    normalization: HistNormalization,
    weights: Option<&[f64]>,
    total_weight: f64,
) -> BuiltinResult<HistComputation> {
    let sample_len = data.len();
    if sample_len == 0 {
        return build_empty_histogram_chart(bin_options, normalization, 0, total_weight);
    }
    let stats = HistDataStats::from_samples(&data);
    let fallback = data.first().copied();
    let bins = realize_bins(bin_options, sample_len, Some(&stats), fallback)?;
    let weight_for_sample = |sample_idx: usize| -> f64 {
        weights
            .and_then(|slice| slice.get(sample_idx).copied())
            .unwrap_or(1.0)
    };
    let mut counts = vec![0f64; bins.bin_count()];
    for (sample_idx, value) in data.iter().enumerate() {
        let bin_idx = find_bin_index(&bins.edges, *value);
        counts[bin_idx] += weight_for_sample(sample_idx);
    }
    apply_normalization(&mut counts, &bins.widths, normalization, total_weight);
    build_hist_cpu_result(&bins, counts)
}

fn build_empty_histogram_chart(
    bin_options: &HistBinOptions,
    _normalization: HistNormalization,
    sample_len: usize,
    _total_weight: f64,
) -> BuiltinResult<HistComputation> {
    let bins = realize_bins(bin_options, sample_len, None, None)?;
    let counts = vec![0.0; bins.bin_count()];
    build_hist_cpu_result(&bins, counts)
}

fn build_hist_cpu_result(bins: &RealizedBins, counts: Vec<f64>) -> BuiltinResult<HistComputation> {
    let mut bar = BarChart::new(bins.labels.clone(), counts.clone())
        .map_err(|err| hist_err(format!("hist: {err}")))?;
    bar.label = Some(HIST_DEFAULT_LABEL.to_string());
    Ok(HistComputation {
        counts,
        centers: bins.centers.clone(),
        chart: bar,
    })
}

fn validate_monotonic(values: &[f64]) -> BuiltinResult<()> {
    if values.windows(2).all(|w| w[0] < w[1]) {
        Ok(())
    } else {
        Err(hist_err("hist: values must be strictly increasing"))
    }
}

fn find_bin_index(edges: &[f64], value: f64) -> usize {
    if value <= edges[0] {
        return 0;
    }
    let last = edges.len() - 2;
    for i in 0..=last {
        if value < edges[i + 1] || i == last {
            return i;
        }
    }
    last
}

fn edges_from_centers(centers: &[f64]) -> BuiltinResult<Vec<f64>> {
    if centers.is_empty() {
        return Err(hist_err(
            "hist: bin centers must contain at least one element",
        ));
    }
    if centers.len() == 1 {
        let half = 0.5;
        return Ok(vec![centers[0] - half, centers[0] + half]);
    }
    validate_monotonic(centers)?;
    let mut edges = Vec::with_capacity(centers.len() + 1);
    edges.push(centers[0] - (centers[1] - centers[0]) * 0.5);
    for pair in centers.windows(2) {
        edges.push((pair[0] + pair[1]) * 0.5);
    }
    edges.push(
        centers[centers.len() - 1]
            + (centers[centers.len() - 1] - centers[centers.len() - 2]) * 0.5,
    );
    Ok(edges)
}

fn histogram_labels_from_edges(edges: &[f64]) -> Vec<String> {
    edges
        .windows(2)
        .map(|pair| {
            let start = pair[0];
            let end = pair[1];
            format!("[{start:.3}, {end:.3})")
        })
        .collect()
}

fn centers_from_edges(edges: &[f64]) -> Vec<f64> {
    edges
        .windows(2)
        .map(|pair| (pair[0] + pair[1]) * 0.5)
        .collect()
}

fn apply_normalization(
    counts: &mut [f64],
    widths: &[f64],
    normalization: HistNormalization,
    total_weight: f64,
) {
    match normalization {
        HistNormalization::Count => {}
        HistNormalization::Probability => {
            let total = total_weight.max(f64::EPSILON);
            for count in counts {
                *count /= total;
            }
        }
        HistNormalization::Pdf => {
            let total = total_weight.max(f64::EPSILON);
            for (count, width) in counts.iter_mut().zip(widths.iter()) {
                let w = width.max(f64::MIN_POSITIVE);
                *count /= total * w;
            }
        }
    }
}

async fn build_histogram_gpu_chart_async(
    values: &GpuTensorHandle,
    bin_options: &HistBinOptions,
    sample_len: usize,
    normalization: HistNormalization,
    style: &BarStyle,
    weights: &HistWeightsInput,
) -> BuiltinResult<HistComputation> {
    let context = crate::builtins::plotting::gpu_helpers::ensure_shared_wgpu_context(BUILTIN_NAME)?;
    let exported = runmat_accelerate_api::export_wgpu_buffer(values)
        .ok_or_else(|| hist_err("hist: unable to export GPU data"))?;
    if exported.len == 0 {
        let total_hint = weights
            .total_weight_hint(sample_len)
            .unwrap_or(sample_len as f64);
        return build_empty_histogram_chart(bin_options, normalization, sample_len, total_hint);
    }

    let sample_count_u32 = u32::try_from(exported.len)
        .map_err(|_| hist_err("hist: sample count exceeds supported range"))?;
    let gpu_weights = weights.to_gpu_weights(sample_len)?;
    let (min_value_f32, max_value_f32) = axis_bounds_async(values, "hist").await?;
    let stats = HistDataStats {
        min: Some(min_value_f32 as f64),
        max: Some(max_value_f32 as f64),
    };
    let bins = realize_bins(
        bin_options,
        sample_len,
        Some(&stats),
        Some(min_value_f32 as f64),
    )?;
    let Some(uniform_width_f64) = bins.uniform_width else {
        return Err(hist_err(
            "hist: GPU rendering currently requires uniform bin edges",
        ));
    };
    let uniform_width = uniform_width_f64 as f32;
    let bin_count_u32 = u32::try_from(bins.bin_count())
        .map_err(|_| hist_err("hist: bin count exceeds supported range for GPU execution"))?;

    let histogram_inputs = HistogramGpuInputs {
        samples: exported.buffer.clone(),
        sample_count: sample_count_u32,
        scalar: ScalarType::from_is_f64(exported.precision == ProviderPrecision::F64),
        weights: gpu_weights,
    };
    let histogram_params = HistogramGpuParams {
        min_value: bins.edges[0] as f32,
        inv_bin_width: 1.0 / uniform_width,
        bin_count: bin_count_u32,
    };
    let normalization_mode = match normalization {
        HistNormalization::Count => HistogramNormalizationMode::Count,
        HistNormalization::Probability => HistogramNormalizationMode::Probability,
        HistNormalization::Pdf => HistogramNormalizationMode::Pdf {
            bin_width: uniform_width.max(f32::MIN_POSITIVE),
        },
    };

    let histogram_output = runmat_plot::gpu::histogram::histogram_values_buffer(
        &context.device,
        &context.queue,
        histogram_inputs,
        &histogram_params,
        normalization_mode,
    )
    .await
    .map_err(|e| hist_err(format!("hist: failed to build GPU histogram counts: {e}")))?;

    let HistogramGpuOutput {
        values_buffer,
        total_weight,
    } = histogram_output;

    let bar_inputs = BarGpuInputs {
        values_buffer,
        row_count: bin_count_u32,
        scalar: ScalarType::F32,
    };
    let bar_params = BarGpuParams {
        color: style.face_rgba(),
        bar_width: style.bar_width,
        series_index: 0,
        series_count: 1,
        group_index: 0,
        group_count: 1,
        orientation: BarOrientation::Vertical,
        layout: BarLayoutMode::Grouped,
    };

    let gpu_vertices = runmat_plot::gpu::bar::pack_vertices_from_values(
        &context.device,
        &context.queue,
        &bar_inputs,
        &bar_params,
    )
    .map_err(|e| hist_err(format!("hist: failed to build GPU vertices: {e}")))?;

    let bin_count = bins.bin_count();
    let normalization_scale = match normalization {
        HistNormalization::Count => 1.0,
        HistNormalization::Probability => {
            if total_weight <= f32::EPSILON {
                0.0
            } else {
                1.0 / total_weight
            }
        }
        HistNormalization::Pdf => {
            if total_weight <= f32::EPSILON {
                0.0
            } else {
                1.0 / (total_weight * uniform_width)
            }
        }
    };
    let bounds = histogram_bar_bounds(
        bin_count,
        total_weight,
        normalization_scale,
        style.bar_width,
    );
    let vertex_count = gpu_vertices.vertex_count;
    let mut bar = BarChart::from_gpu_buffer(
        bins.labels.clone(),
        bin_count,
        gpu_vertices,
        vertex_count,
        bounds,
        style.face_rgba(),
        style.bar_width,
    );
    bar.label = Some(HIST_DEFAULT_LABEL.to_string());
    let counts_f32 = runmat_plot::gpu::util::readback_f32_buffer(
        &context.device,
        bar_inputs.values_buffer.as_ref(),
        bin_count,
    )
    .await
    .map_err(|e| hist_err(format!("hist: failed to read GPU histogram counts: {e}")))?;
    let counts: Vec<f64> = counts_f32.iter().map(|v| *v as f64).collect();

    Ok(HistComputation {
        counts,
        centers: bins.centers.clone(),
        chart: bar,
    })
}

fn histogram_bar_bounds(
    bins: usize,
    total_weight: f32,
    normalization_scale: f32,
    bar_width: f32,
) -> BoundingBox {
    let min_x = 1.0 - bar_width * 0.5;
    let max_x = bins as f32 + bar_width * 0.5;
    let max_y = total_weight * normalization_scale;
    let max_y = if max_y.is_finite() && max_y > 0.0 {
        max_y
    } else {
        1.0
    };
    BoundingBox::new(Vec3::new(min_x, 0.0, 0.0), Vec3::new(max_x, max_y, 0.0))
}

enum HistInput {
    Host(Tensor),
    Gpu(GpuTensorHandle),
}

impl HistInput {
    fn from_value(value: Value) -> BuiltinResult<Self> {
        match value {
            Value::GpuTensor(handle) => Ok(Self::Gpu(handle)),
            other => {
                let tensor =
                    Tensor::try_from(&other).map_err(|e| hist_err(format!("hist: {e}")))?;
                Ok(Self::Host(tensor))
            }
        }
    }

    fn gpu_handle(&self) -> Option<&GpuTensorHandle> {
        match self {
            Self::Gpu(handle) => Some(handle),
            Self::Host(_) => None,
        }
    }

    fn len(&self) -> usize {
        match self {
            Self::Host(tensor) => tensor.data.len(),
            Self::Gpu(handle) => handle.shape.iter().product(),
        }
    }
}

#[cfg(test)]
pub(crate) mod tests {
    use super::*;
    use crate::builtins::array::type_resolvers::row_vector_type;
    use crate::builtins::plotting::tests::ensure_plot_test_env;
    use crate::RuntimeError;
    use futures::executor::block_on;
    use runmat_builtins::{ResolveContext, Type};

    fn setup_plot_tests() {
        ensure_plot_test_env();
    }

    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 hist_respects_bin_argument() {
        setup_plot_tests();
        let data = Value::Tensor(tensor_from(&[1.0, 2.0, 3.0, 4.0]));
        let bins = vec![Value::from(2.0)];
        let result = block_on(hist_builtin(data, bins));
        if let Err(flow) = result {
            assert_plotting_unavailable(&flow);
        }
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn hist_accepts_bin_centers_vector() {
        setup_plot_tests();
        let data = Value::Tensor(tensor_from(&[0.0, 0.5, 1.0, 1.5]));
        let centers = Value::Tensor(tensor_from(&[0.0, 1.0, 2.0]));
        let result = block_on(hist_builtin(data, vec![centers]));
        if let Err(flow) = result {
            assert_plotting_unavailable(&flow);
        }
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn hist_accepts_probability_normalization() {
        setup_plot_tests();
        let data = Value::Tensor(tensor_from(&[0.0, 0.5, 1.0]));
        let result = block_on(hist_builtin(
            data,
            vec![Value::from(3.0), Value::String("probability".into())],
        ));
        if let Err(flow) = result {
            assert_plotting_unavailable(&flow);
        }
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn hist_accepts_string_only_normalization() {
        setup_plot_tests();
        let data = Value::Tensor(tensor_from(&[0.0, 0.5, 1.0]));
        let result = block_on(hist_builtin(data, vec![Value::String("pdf".into())]));
        if let Err(flow) = result {
            assert_plotting_unavailable(&flow);
        }
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn hist_accepts_normalization_name_value_pair() {
        setup_plot_tests();
        let data = Value::Tensor(tensor_from(&[0.0, 0.5, 1.0]));
        let result = block_on(hist_builtin(
            data,
            vec![
                Value::String("Normalization".into()),
                Value::String("probability".into()),
            ],
        ));
        if let Err(flow) = result {
            assert_plotting_unavailable(&flow);
        }
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn hist_accepts_bin_edges_option() {
        setup_plot_tests();
        let data = Value::Tensor(tensor_from(&[0.1, 0.4, 0.7]));
        let edges = Value::Tensor(tensor_from(&[0.0, 0.5, 1.0]));
        let result = block_on(hist_builtin(
            data,
            vec![Value::String("BinEdges".into()), edges],
        ));
        if let Err(flow) = result {
            assert_plotting_unavailable(&flow);
        }
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn hist_evaluate_returns_counts_and_centers() {
        setup_plot_tests();
        let data = Value::Tensor(tensor_from(&[0.0, 0.2, 0.8, 1.0]));
        let eval = block_on(evaluate_async(data, &[])).expect("hist evaluate");
        let counts = match eval.counts_value() {
            Value::Tensor(tensor) => tensor.data,
            other => panic!("unexpected value: {other:?}"),
        };
        assert_eq!(counts.len(), 2);
        let centers = match eval.centers_value() {
            Value::Tensor(tensor) => tensor.data,
            other => panic!("unexpected centers: {other:?}"),
        };
        assert_eq!(centers.len(), 2);
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn hist_supports_numbins_option() {
        setup_plot_tests();
        let data = Value::Tensor(tensor_from(&[0.0, 0.5, 1.0, 1.5]));
        let args = vec![Value::String("NumBins".into()), Value::Num(4.0)];
        let eval = block_on(evaluate_async(data, &args)).expect("hist evaluate");
        let centers = match eval.centers_value() {
            Value::Tensor(tensor) => tensor.data,
            other => panic!("unexpected centers: {other:?}"),
        };
        assert_eq!(centers.len(), 4);
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn hist_supports_binwidth_and_limits() {
        setup_plot_tests();
        let data = Value::Tensor(tensor_from(&[0.1, 0.2, 0.6, 0.8]));
        let args = vec![
            Value::String("BinWidth".into()),
            Value::Num(0.5),
            Value::String("BinLimits".into()),
            Value::Tensor(tensor_from(&[0.0, 1.0])),
        ];
        let eval = block_on(evaluate_async(data, &args)).expect("hist evaluate");
        let centers = match eval.centers_value() {
            Value::Tensor(tensor) => tensor.data,
            other => panic!("unexpected centers: {other:?}"),
        };
        assert_eq!(centers.len(), 2);
        assert!((centers[0] - 0.25).abs() < 1e-9);
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn hist_supports_sqrt_binmethod() {
        setup_plot_tests();
        let data = Value::Tensor(tensor_from(&[0.0, 0.2, 0.4, 0.6, 0.8]));
        let args = vec![
            Value::String("BinMethod".into()),
            Value::String("sqrt".into()),
        ];
        let eval = block_on(evaluate_async(data, &args)).expect("hist evaluate");
        let centers = match eval.centers_value() {
            Value::Tensor(tensor) => tensor.data,
            other => panic!("unexpected centers: {other:?}"),
        };
        assert!(centers.len() >= 2);
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn apply_normalization_handles_weighted_probability() {
        setup_plot_tests();
        let mut counts = vec![2.0, 4.0];
        let widths = vec![1.0, 1.0];
        apply_normalization(&mut counts, &widths, HistNormalization::Probability, 6.0);
        assert!((counts[0] - 2.0 / 6.0).abs() < 1e-12);
        assert!((counts[1] - 4.0 / 6.0).abs() < 1e-12);
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn apply_normalization_handles_weighted_pdf() {
        setup_plot_tests();
        let mut counts = vec![5.0];
        let widths = vec![0.5];
        apply_normalization(&mut counts, &widths, HistNormalization::Pdf, 10.0);
        // PDF height = weight / (total_weight * bin_width) = 5 / (10 * 0.5) = 1
        assert!((counts[0] - 1.0).abs() < 1e-12);
    }

    #[test]
    fn hist_type_defaults_to_row_vector() {
        let ctx = ResolveContext::new(Vec::new());
        assert_eq!(hist_type(&[Type::tensor()], &ctx), row_vector_type(&ctx));
    }

    #[test]
    fn hist_type_uses_bin_centers_length() {
        let ctx = ResolveContext::new(Vec::new());
        let out = hist_type(
            &[
                Type::tensor(),
                Type::Tensor {
                    shape: Some(vec![Some(1), Some(5)]),
                },
            ],
            &ctx,
        );
        assert_eq!(
            out,
            Type::Tensor {
                shape: Some(vec![Some(1), Some(5)])
            }
        );
    }
}
