// Copyright (C) 2026 alto555
// SPDX-License-Identifier: GPL-2.0-only

//! The multi-resolution min/max index.
//!
//! One sequential pass over the file builds, per variable, a pyramid of
//! min/max pairs: level 0 covers `base_decim` samples per bucket, each higher
//! level folds two buckets into one.  After that pass the file is never read
//! in bulk again — a query touches `O(pixels)` buckets regardless of how many
//! samples lie underneath.
//!
//! Two properties worth stating because they are easy to get wrong:
//!
//! * The pyramid is **lossless with respect to min/max**.  `min(min(a),
//!   min(b))` is exactly `min(a ∪ b)`, so an envelope drawn from level 7 is
//!   the same envelope you would get from the samples.  The only
//!   approximation is bucket *alignment*: buckets are quantised to
//!   `base_decim` boundaries, so an edge bucket may include a few samples
//!   just outside the requested span.
//! * The X pyramid is `f64`, the Y pyramids are `f32`.  Y values exist to be
//!   rasterised and `f32` halves the resident index.  X values are absolute
//!   time, where `f32`'s 24-bit mantissa is not enough: 6M points across
//!   100 µs needs ~1e-7 relative resolution and `f32` gives ~6e-8.  Storing X
//!   as `f32` produces cursors that snap and jitter, which is a bug you find
//!   late and fix expensively.

use std::fs::File;
use std::io::{Read, Write};
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};

use wave_core::{Reduced, Span};
use wave_io::WaveReader;

/// A window of raw rows, shared between the curves of one repaint.
pub struct CachedWindow {
    pub r0: usize,
    /// Rows requested (part of the cache key).
    pub req: usize,
    /// Rows actually read — short only at end of file.
    pub rows: usize,
    pub data: Vec<f64>,
}

/// Single-entry cache for the deep-zoom exact path.
///
/// Measured, not speculative: without it, a 63-curve repaint at a zoom where
/// the exact path engages re-read the same 30 MB window 63 times, because
/// point-major storage means each curve's read drags every other column along
/// with it.  One entry is enough — a repaint asks every curve for the same
/// span, so the first curve pays and the rest hit.
pub struct RowCache {
    inner: Mutex<Option<Arc<CachedWindow>>>,
    hits: AtomicUsize,
    misses: AtomicUsize,
}

impl Default for RowCache {
    fn default() -> RowCache {
        RowCache::new()
    }
}

impl RowCache {
    pub fn new() -> RowCache {
        RowCache {
            inner: Mutex::new(None),
            hits: AtomicUsize::new(0),
            misses: AtomicUsize::new(0),
        }
    }

    pub fn stats(&self) -> (usize, usize) {
        (
            self.hits.load(Ordering::Relaxed),
            self.misses.load(Ordering::Relaxed),
        )
    }

    pub fn rows(
        &self,
        reader: &dyn WaveReader,
        r0: usize,
        rows: usize,
    ) -> std::io::Result<Arc<CachedWindow>> {
        let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
        if let Some(w) = g.as_ref() {
            if w.r0 == r0 && w.req == rows {
                self.hits.fetch_add(1, Ordering::Relaxed);
                return Ok(Arc::clone(w));
            }
        }
        self.misses.fetch_add(1, Ordering::Relaxed);
        let rl = reader.row_len();
        let mut data = vec![0f64; rows * rl];
        let got = reader.read_rows(r0, rows, &mut data)?;
        let w = Arc::new(CachedWindow {
            r0,
            req: rows,
            rows: got,
            data,
        });
        *g = Some(Arc::clone(&w));
        Ok(w)
    }
}

pub type MinMaxF32 = [f32; 2];
pub type MinMaxF64 = [f64; 2];

const MAGIC: &[u8; 8] = b"WVIX0001";

/// Samples per level-0 bucket.
///
/// Trades index size against how far you can zoom before the exact path has
/// to re-read the file.  32 keeps level 0 fine enough that the pyramid alone
/// covers the whole zoom range down to a few samples per pixel, at ~6% of the
/// data — see the note on `EXACT_SAMPLES_PER_BUCKET`, which is the measurement
/// that chose this number.
pub const DEFAULT_BASE_DECIM: usize = 32;

/// The crossover from real samples to the pyramid, in samples per pixel.
///
/// This must be `base_decim`, and nothing else.  Level 0 of the pyramid holds
/// `base_decim` samples per bucket, so it can only supply one bucket per pixel
/// once the view is at `base_decim` samples per pixel or coarser.  Below that
/// the pyramid is *coarser than the screen* and there is no way to draw a
/// correct trace from it.
///
/// It was hard-coded to 4 while `base_decim` was 32, which left an eight-fold
/// band of zoom where neither source could produce a clean picture: a clock
/// that drew perfectly at a 1.15 us window fell apart into blocks at 1.44 us,
/// a 1.25x step.  Deriving it removes the cliff by construction.
fn exact_samples_per_bucket(base_decim: usize) -> usize {
    base_decim
}

/// Hard ceiling regardless of the above, expressed in **bytes of file**, not
/// rows.  A point-major exact read drags every column along, so the same row
/// count costs 16x more on a 1024-variable file than on a 64-variable one.
/// Budgeting rows hid that; budgeting bytes makes the cost independent of how
/// many signals the run happened to save.
///
/// Sized from the failure it caused rather than from caution: at 8 MB, a
/// 1024-signal file zoomed to ~1100 samples needed 9.3 MB, missed the budget,
/// and fell back to an index whose buckets were 40 pixels wide — the trace
/// came out as a row of blocks.  The read this permits is a user-initiated
/// zoom, not a per-frame cost, and it is served from the page cache in tens
/// of milliseconds.
const EXACT_WINDOW_BYTES: usize = 384 << 20;

#[derive(Clone, Copy, Debug)]
pub struct IndexCfg {
    pub base_decim: usize,
    /// Target size of each thread's read buffer.
    pub io_block_bytes: usize,
    pub threads: usize,
}

impl Default for IndexCfg {
    fn default() -> IndexCfg {
        IndexCfg {
            base_decim: DEFAULT_BASE_DECIM,
            io_block_bytes: 8 << 20,
            threads: num_cpus(),
        }
    }
}

pub struct Index {
    pub n_points: usize,
    pub base_decim: usize,
    pub x_var: usize,
    /// X pyramid: `x[level][bucket] = [min, max]`.
    pub x: Vec<Vec<MinMaxF64>>,
    /// Y pyramids: `y[var][level][bucket] = [min, max]`.  The X variable's
    /// slot is left empty.
    pub y: Vec<Vec<Vec<MinMaxF32>>>,
}

impl Index {
    pub fn levels(&self) -> usize {
        self.x.len()
    }

    pub fn n_vars(&self) -> usize {
        self.y.len()
    }

    /// Resident size in bytes.
    pub fn size_bytes(&self) -> usize {
        let x: usize = self.x.iter().map(|l| l.len() * 16).sum();
        let y: usize = self
            .y
            .iter()
            .map(|v| v.iter().map(|l| l.len() * 8).sum::<usize>())
            .sum();
        x + y
    }

    pub fn x_span(&self) -> Span {
        match self.x.last() {
            Some(top) if !top.is_empty() => {
                let mut lo = f64::INFINITY;
                let mut hi = f64::NEG_INFINITY;
                for b in top {
                    if b[0] < lo {
                        lo = b[0];
                    }
                    if b[1] > hi {
                        hi = b[1];
                    }
                }
                Span::new(lo, hi)
            }
            _ => Span::new(0.0, 0.0),
        }
    }

    // ---------------------------------------------------------------- build

    pub fn build(reader: &Arc<dyn WaveReader>, cfg: IndexCfg) -> std::io::Result<Index> {
        let n_points = reader.n_points();
        let n_vars = reader.n_vars();
        let row_len = reader.row_len();
        let comps = reader.components();
        let d = cfg.base_decim.max(1);
        let x_var = 0;

        if n_points == 0 {
            return Ok(Index {
                n_points: 0,
                base_decim: d,
                x_var,
                x: vec![Vec::new()],
                y: vec![vec![Vec::new()]; n_vars],
            });
        }

        // Chunk boundaries land on level-0 bucket boundaries, so the partial
        // results concatenate exactly — no fixups, no overlap.
        let threads = cfg.threads.max(1);
        let chunk_rows = ceil_div(ceil_div(n_points, threads), d) * d;
        let n_chunks = ceil_div(n_points, chunk_rows);

        let block_rows = {
            let per_row = row_len * 8;
            let want = cfg.io_block_bytes / per_row.max(1);
            ceil_div(want.max(1), d) * d
        };

        let mut handles = Vec::with_capacity(n_chunks);
        for c in 0..n_chunks {
            let first = c * chunk_rows;
            let rows = std::cmp::min(chunk_rows, n_points - first);
            let rd = Arc::clone(reader);
            handles.push(std::thread::spawn(move || {
                scan_chunk(&rd, first, rows, d, block_rows, n_vars, row_len, comps, x_var)
            }));
        }

        let mut x0: Vec<MinMaxF64> = Vec::new();
        let mut y0: Vec<Vec<MinMaxF32>> = vec![Vec::new(); n_vars];
        for h in handles {
            let part = match h.join() {
                Ok(r) => r?,
                Err(_) => {
                    return Err(std::io::Error::new(
                        std::io::ErrorKind::Other,
                        "index worker panicked",
                    ))
                }
            };
            x0.extend_from_slice(&part.x);
            for v in 0..n_vars {
                y0[v].extend_from_slice(&part.y[v]);
            }
        }

        // Fold upward until a level fits comfortably in one screen width.
        let mut x_levels = vec![x0];
        while x_levels.last().map(|l| l.len()).unwrap_or(0) > 2 && x_levels.len() < 32 {
            let next = fold_f64(x_levels.last().unwrap());
            x_levels.push(next);
        }
        let n_levels = x_levels.len();

        let mut y_levels: Vec<Vec<Vec<MinMaxF32>>> = Vec::with_capacity(n_vars);
        for v in 0..n_vars {
            let mut levels = Vec::with_capacity(n_levels);
            levels.push(std::mem::take(&mut y0[v]));
            while levels.len() < n_levels {
                let next = fold_f32(levels.last().unwrap());
                levels.push(next);
            }
            y_levels.push(levels);
        }

        Ok(Index {
            n_points,
            base_decim: d,
            x_var,
            x: x_levels,
            y: y_levels,
        })
    }

    // ---------------------------------------------------------------- query

    /// Rows covered by `span`, found by binary search on the level-0 X
    /// buckets.  Assumes X is non-decreasing, which holds for `.tran`,
    /// `.ac` and `.dc` sweeps.
    pub fn row_range(&self, span: Span) -> (usize, usize) {
        let lvl0 = &self.x[0];
        if lvl0.is_empty() {
            return (0, 0);
        }
        // First bucket whose max >= x0.
        let mut lo = 0usize;
        let mut hi = lvl0.len();
        while lo < hi {
            let m = (lo + hi) / 2;
            if lvl0[m][1] < span.x0 {
                lo = m + 1;
            } else {
                hi = m;
            }
        }
        let b0 = lo;
        // First bucket whose min > x1.
        let mut lo2 = b0;
        let mut hi2 = lvl0.len();
        while lo2 < hi2 {
            let m = (lo2 + hi2) / 2;
            if lvl0[m][0] <= span.x1 {
                lo2 = m + 1;
            } else {
                hi2 = m;
            }
        }
        let b1 = lo2;
        let r0 = (b0 * self.base_decim).min(self.n_points);
        let r1 = (b1 * self.base_decim).min(self.n_points);
        (r0, r1.max(r0))
    }

    fn level_for(&self, samples_per_bucket: f64) -> usize {
        if samples_per_bucket <= self.base_decim as f64 {
            return 0;
        }
        let ratio = samples_per_bucket / self.base_decim as f64;
        let l = ratio.log2().floor() as i64;
        l.clamp(0, self.levels() as i64 - 1) as usize
    }

    /// Serve a reduction for one variable.
    ///
    /// `reader` is only touched on the deep-zoom exact path, and then through
    /// `cache`: a point-major file makes an exact read cost `n_vars * 8`
    /// bytes per row *per curve*, so a repaint of N curves over the same span
    /// would otherwise read the same window N times.
    pub fn reduce_var(
        &self,
        var: usize,
        span: Span,
        n_buckets: usize,
        reader: &dyn WaveReader,
        cache: &RowCache,
    ) -> Reduced {
        let n_buckets = n_buckets.max(1);
        let (r0, r1) = self.row_range(span);
        let samples = r1.saturating_sub(r0);
        if samples == 0 || var >= self.y.len() {
            return Reduced::default();
        }

        if want_exact(samples, n_buckets, reader.row_len() * 8, self.base_decim) {
            if let Ok(red) = self.exact(var, r0, r1, n_buckets, reader, cache) {
                return red;
            }
        }

        let spb = samples as f64 / n_buckets as f64;
        let level = self.level_for(spb);
        let bucket_rows = self.base_decim << level;
        let xl = &self.x[level];
        let yl = &self.y[var][level];
        let b0 = (r0 / bucket_rows).min(xl.len());
        let b1 = ceil_div(r1, bucket_rows).min(xl.len());
        let n_src = b1.saturating_sub(b0);
        if n_src == 0 {
            return Reduced::default();
        }
        let n_out = n_buckets.min(n_src);

        let mut out = Reduced {
            x: Vec::with_capacity(n_out),
            min: Vec::with_capacity(n_out),
            max: Vec::with_capacity(n_out),
            exact: false,
        };
        for i in 0..n_out {
            let s = b0 + n_src * i / n_out;
            let mut e = b0 + n_src * (i + 1) / n_out;
            if e <= s {
                e = s + 1;
            }
            let mut mn = f32::INFINITY;
            let mut mx = f32::NEG_INFINITY;
            for b in s..e.min(yl.len()) {
                if yl[b][0] < mn {
                    mn = yl[b][0];
                }
                if yl[b][1] > mx {
                    mx = yl[b][1];
                }
            }
            out.x.push(xl[s.min(xl.len() - 1)][0]);
            out.min.push(mn);
            out.max.push(mx);
        }
        out
    }

    /// Reduce several variables over one span — what a repaint actually asks
    /// for.
    ///
    /// On the exact path this is not a convenience wrapper: a row is
    /// contiguous in a point-major file, so visiting every variable of a row
    /// while it is in cache turns N strided passes over the window into one
    /// sequential pass.  Per-curve `reduce` stays on the `Curve` trait for
    /// the general case; a pane with many traces uses this.
    pub fn reduce_many(
        &self,
        vars: &[usize],
        span: Span,
        n_buckets: usize,
        reader: &dyn WaveReader,
        cache: &RowCache,
    ) -> Vec<Reduced> {
        let n_buckets = n_buckets.max(1);
        let (r0, r1) = self.row_range(span);
        let samples = r1.saturating_sub(r0);
        if samples == 0 {
            return vec![Reduced::default(); vars.len()];
        }
        if !want_exact(samples, n_buckets, reader.row_len() * 8, self.base_decim) {
            // Pyramid path is already index-resident; nothing to share.
            return vars
                .iter()
                .map(|v| self.reduce_var(*v, span, n_buckets, reader, cache))
                .collect();
        }

        let win = match cache.rows(reader, r0, samples) {
            Ok(w) => w,
            Err(_) => return vec![Reduced::default(); vars.len()],
        };
        let got = win.rows;
        let rl = reader.row_len();
        let comps = reader.components();
        let n_out = n_buckets.min(got).max(1);
        let nv = vars.len();

        let mut outs: Vec<Reduced> = vars
            .iter()
            .map(|_| Reduced {
                x: Vec::with_capacity(n_out),
                min: Vec::with_capacity(n_out),
                max: Vec::with_capacity(n_out),
                exact: got <= n_buckets,
            })
            .collect();
        let mut acc_min = vec![f32::INFINITY; nv];
        let mut acc_max = vec![f32::NEG_INFINITY; nv];

        for i in 0..n_out {
            let s = got * i / n_out;
            let mut e = got * (i + 1) / n_out;
            if e <= s {
                e = s + 1;
            }
            for a in acc_min.iter_mut() {
                *a = f32::INFINITY;
            }
            for a in acc_max.iter_mut() {
                *a = f32::NEG_INFINITY;
            }
            for r in s..e.min(got) {
                let base = r * rl;
                // One row, all requested variables: sequential in memory.
                for (k, v) in vars.iter().enumerate() {
                    let val = if comps == 2 {
                        win.data[base + v * 2].hypot(win.data[base + v * 2 + 1]) as f32
                    } else {
                        win.data[base + v] as f32
                    };
                    if val < acc_min[k] {
                        acc_min[k] = val;
                    }
                    if val > acc_max[k] {
                        acc_max[k] = val;
                    }
                }
            }
            let xs = value_at_row(&win.data, s, self.x_var, rl, comps);
            for k in 0..nv {
                outs[k].x.push(xs);
                outs[k].min.push(acc_min[k]);
                outs[k].max.push(acc_max[k]);
            }
        }
        outs
    }

    fn exact(
        &self,
        var: usize,
        r0: usize,
        r1: usize,
        n_buckets: usize,
        reader: &dyn WaveReader,
        cache: &RowCache,
    ) -> std::io::Result<Reduced> {
        let rows = r1 - r0;
        let rl = reader.row_len();
        let comps = reader.components();
        let win = cache.rows(reader, r0, rows)?;
        let buf: &[f64] = &win.data;
        let got = win.rows;
        let n_out = n_buckets.min(got);
        let mut out = Reduced {
            x: Vec::with_capacity(n_out),
            min: Vec::with_capacity(n_out),
            max: Vec::with_capacity(n_out),
            exact: got <= n_buckets,
        };
        for i in 0..n_out {
            let s = got * i / n_out;
            let mut e = got * (i + 1) / n_out;
            if e <= s {
                e = s + 1;
            }
            let mut mn = f32::INFINITY;
            let mut mx = f32::NEG_INFINITY;
            for r in s..e.min(got) {
                let v = value_at_row(&buf, r, var, rl, comps) as f32;
                if v < mn {
                    mn = v;
                }
                if v > mx {
                    mx = v;
                }
            }
            out.x
                .push(value_at_row(&buf, s, self.x_var, rl, comps));
            out.min.push(mn);
            out.max.push(mx);
        }
        Ok(out)
    }

    /// Sample-accurate value nearest `x` — what a cursor readout needs.
    ///
    /// Reads exactly one level-0 bucket, so this is a fixed ~`base_decim *
    /// n_vars * 8` byte read no matter how large the file is.
    pub fn value_at(
        &self,
        var: usize,
        x: f64,
        reader: &dyn WaveReader,
    ) -> std::io::Result<Option<f64>> {
        let lvl0 = &self.x[0];
        if lvl0.is_empty() {
            return Ok(None);
        }
        let mut lo = 0usize;
        let mut hi = lvl0.len();
        while lo < hi {
            let m = (lo + hi) / 2;
            if lvl0[m][1] < x {
                lo = m + 1;
            } else {
                hi = m;
            }
        }
        let b = lo.min(lvl0.len() - 1);
        let r0 = b * self.base_decim;
        let rows = self.base_decim.min(self.n_points.saturating_sub(r0));
        if rows == 0 {
            return Ok(None);
        }
        let rl = reader.row_len();
        let comps = reader.components();
        let mut buf = vec![0f64; rows * rl];
        let got = reader.read_rows(r0, rows, &mut buf)?;
        let mut best = None;
        let mut best_d = f64::INFINITY;
        for r in 0..got {
            let xr = value_at_row(&buf, r, self.x_var, rl, comps);
            let d = (xr - x).abs();
            if d < best_d {
                best_d = d;
                best = Some(value_at_row(&buf, r, var, rl, comps));
            }
        }
        Ok(best)
    }

    // ------------------------------------------------------------ sidecar

    /// Persist the index next to the data file.  Native-endian: this is a
    /// regenerable machine-local cache, not an interchange format.
    pub fn save<P: AsRef<Path>>(&self, path: P) -> std::io::Result<()> {
        let mut f = std::io::BufWriter::new(File::create(path)?);
        f.write_all(MAGIC)?;
        f.write_all(&(self.n_points as u64).to_le_bytes())?;
        f.write_all(&(self.base_decim as u64).to_le_bytes())?;
        f.write_all(&(self.x_var as u64).to_le_bytes())?;
        f.write_all(&(self.x.len() as u64).to_le_bytes())?;
        f.write_all(&(self.y.len() as u64).to_le_bytes())?;
        for lvl in &self.x {
            f.write_all(&(lvl.len() as u64).to_le_bytes())?;
            f.write_all(as_bytes_f64(lvl))?;
        }
        for var in &self.y {
            f.write_all(&(var.len() as u64).to_le_bytes())?;
            for lvl in var {
                f.write_all(&(lvl.len() as u64).to_le_bytes())?;
                f.write_all(as_bytes_f32(lvl))?;
            }
        }
        f.flush()
    }

    pub fn load<P: AsRef<Path>>(path: P) -> std::io::Result<Index> {
        let mut f = std::io::BufReader::new(File::open(path)?);
        let mut magic = [0u8; 8];
        f.read_exact(&mut magic)?;
        if &magic != MAGIC {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                "not a wave index sidecar",
            ));
        }
        let n_points = read_u64(&mut f)? as usize;
        let base_decim = read_u64(&mut f)? as usize;
        let x_var = read_u64(&mut f)? as usize;
        let n_levels = read_u64(&mut f)? as usize;
        let n_vars = read_u64(&mut f)? as usize;

        let mut x = Vec::with_capacity(n_levels);
        for _ in 0..n_levels {
            let len = read_u64(&mut f)? as usize;
            let mut lvl = vec![[0f64; 2]; len];
            f.read_exact(as_bytes_mut_f64(&mut lvl))?;
            x.push(lvl);
        }
        let mut y = Vec::with_capacity(n_vars);
        for _ in 0..n_vars {
            let nl = read_u64(&mut f)? as usize;
            let mut levels = Vec::with_capacity(nl);
            for _ in 0..nl {
                let len = read_u64(&mut f)? as usize;
                let mut lvl = vec![[0f32; 2]; len];
                f.read_exact(as_bytes_mut_f32(&mut lvl))?;
                levels.push(lvl);
            }
            y.push(levels);
        }
        Ok(Index {
            n_points,
            base_decim,
            x_var,
            x,
            y,
        })
    }
}

// ------------------------------------------------------------------ worker

struct Partial {
    x: Vec<MinMaxF64>,
    y: Vec<Vec<MinMaxF32>>,
}

#[allow(clippy::too_many_arguments)]
fn scan_chunk(
    reader: &Arc<dyn WaveReader>,
    first_row: usize,
    n_rows: usize,
    d: usize,
    block_rows: usize,
    n_vars: usize,
    row_len: usize,
    comps: usize,
    x_var: usize,
) -> std::io::Result<Partial> {
    let n_buckets = ceil_div(n_rows, d);
    let mut part = Partial {
        x: Vec::with_capacity(n_buckets),
        y: (0..n_vars).map(|_| Vec::with_capacity(n_buckets)).collect(),
    };

    let mut buf = vec![0f64; block_rows * row_len];
    let mut acc_min = vec![f32::INFINITY; n_vars];
    let mut acc_max = vec![f32::NEG_INFINITY; n_vars];
    let mut acc_x = [f64::INFINITY, f64::NEG_INFINITY];
    let mut in_bucket = 0usize;

    let mut done = 0usize;
    while done < n_rows {
        let want = std::cmp::min(block_rows, n_rows - done);
        let got = reader.read_rows(first_row + done, want, &mut buf)?;
        if got == 0 {
            break;
        }
        for r in 0..got {
            let base = r * row_len;
            let xv = if comps == 2 {
                buf[base + x_var * 2]
            } else {
                buf[base + x_var]
            };
            if xv < acc_x[0] {
                acc_x[0] = xv;
            }
            if xv > acc_x[1] {
                acc_x[1] = xv;
            }
            for v in 0..n_vars {
                let val = if comps == 2 {
                    let re = buf[base + v * 2];
                    let im = buf[base + v * 2 + 1];
                    re.hypot(im)
                } else {
                    buf[base + v]
                } as f32;
                // NaN-tolerant: comparisons against NaN are false, so a NaN
                // sample neither widens nor corrupts the envelope.
                if val < acc_min[v] {
                    acc_min[v] = val;
                }
                if val > acc_max[v] {
                    acc_max[v] = val;
                }
            }
            in_bucket += 1;
            if in_bucket == d {
                part.x.push(acc_x);
                for v in 0..n_vars {
                    part.y[v].push([acc_min[v], acc_max[v]]);
                    acc_min[v] = f32::INFINITY;
                    acc_max[v] = f32::NEG_INFINITY;
                }
                acc_x = [f64::INFINITY, f64::NEG_INFINITY];
                in_bucket = 0;
            }
        }
        done += got;
    }
    if in_bucket > 0 {
        part.x.push(acc_x);
        for v in 0..n_vars {
            part.y[v].push([acc_min[v], acc_max[v]]);
        }
    }
    Ok(part)
}

// ------------------------------------------------------------------ helpers

#[inline]
fn value_at_row(buf: &[f64], row: usize, var: usize, row_len: usize, comps: usize) -> f64 {
    let base = row * row_len;
    if comps == 2 {
        buf[base + var * 2].hypot(buf[base + var * 2 + 1])
    } else {
        buf[base + var]
    }
}

/// Should this query read real samples, or is the pyramid visually adequate?
#[inline]
fn want_exact(samples: usize, n_buckets: usize, row_bytes: usize, base_decim: usize) -> bool {
    samples <= n_buckets.saturating_mul(exact_samples_per_bucket(base_decim))
        && samples.saturating_mul(row_bytes.max(1)) <= EXACT_WINDOW_BYTES
}

pub fn ceil_div(a: usize, b: usize) -> usize {
    if b == 0 {
        0
    } else {
        (a + b - 1) / b
    }
}

fn fold_f32(prev: &[MinMaxF32]) -> Vec<MinMaxF32> {
    let mut out = Vec::with_capacity((prev.len() + 1) / 2);
    let mut i = 0;
    while i < prev.len() {
        let a = prev[i];
        let b = if i + 1 < prev.len() { prev[i + 1] } else { a };
        out.push([a[0].min(b[0]), a[1].max(b[1])]);
        i += 2;
    }
    out
}

fn fold_f64(prev: &[MinMaxF64]) -> Vec<MinMaxF64> {
    let mut out = Vec::with_capacity((prev.len() + 1) / 2);
    let mut i = 0;
    while i < prev.len() {
        let a = prev[i];
        let b = if i + 1 < prev.len() { prev[i + 1] } else { a };
        out.push([a[0].min(b[0]), a[1].max(b[1])]);
        i += 2;
    }
    out
}

fn read_u64<R: Read>(r: &mut R) -> std::io::Result<u64> {
    let mut b = [0u8; 8];
    r.read_exact(&mut b)?;
    Ok(u64::from_le_bytes(b))
}

fn as_bytes_f64(v: &[MinMaxF64]) -> &[u8] {
    unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, v.len() * 16) }
}
fn as_bytes_f32(v: &[MinMaxF32]) -> &[u8] {
    unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, v.len() * 8) }
}
fn as_bytes_mut_f64(v: &mut [MinMaxF64]) -> &mut [u8] {
    unsafe { std::slice::from_raw_parts_mut(v.as_mut_ptr() as *mut u8, v.len() * 16) }
}
fn as_bytes_mut_f32(v: &mut [MinMaxF32]) -> &mut [u8] {
    unsafe { std::slice::from_raw_parts_mut(v.as_mut_ptr() as *mut u8, v.len() * 8) }
}

pub fn num_cpus() -> usize {
    if let Ok(s) = std::fs::read_to_string("/proc/cpuinfo") {
        let n = s.lines().filter(|l| l.starts_with("processor")).count();
        if n > 0 {
            return n;
        }
    }
    4
}
