//! Decoding of RSSA track records into Polars columns.
//!
//! Each track is stored as a fixed-size, 96-byte Fortran sequential-unformatted
//! record: a 4-byte marker, 11 little-endian `f64` values, and a trailing 4-byte
//! marker. Because every record has exactly the same size, a byte range containing
//! whole tracks can be split anywhere on a 96-byte boundary and decoded independently
//! and in parallel, with no risk of splitting a record across a boundary.

use std::collections::HashSet;

use polars::prelude::*;
use rayon::prelude::*;

use crate::error::{Result, RssaError};
use crate::header::FileParameters;

/// Size in bytes of a single track record (4-byte marker + 11 f64 values + 4-byte marker).
pub const RECORD_BYTES: usize = 96;

/// Expected value of the leading/trailing Fortran record marker for a track record
/// (11 f64 values = 88 bytes of payload).
const RECORD_PAYLOAD_MARKER: i32 = 88;

/// The packed particle-type indicator used for neutrons.
pub const NEUTRON_PARTICLE_TYPE: i64 = 8;

/// A single decodable field of a track record.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Column {
    /// History number of the particle; negative if it reached the surface without
    /// any collisions.
    History,
    /// Packed particle-type indicator, normalized to its leading decimal digit (e.g.
    /// [`NEUTRON_PARTICLE_TYPE`] for neutrons).
    ParticleType,
    Weight,
    /// Energy, in MeV.
    Energy,
    Time,
    X,
    Y,
    Z,
    /// Direction cosine with the X axis.
    U,
    /// Direction cosine with the Y axis.
    V,
    /// Direction cosine with the Z axis, `+-sqrt(1 - u^2 - v^2)`. Unlike `u` and `v`,
    /// this isn't a raw stored field: the file only stores its *sign*, packed into
    /// the sign of the same field `particle_type` is decoded from.
    W,
    SurfaceId,
}

impl Column {
    pub const ALL: [Column; 12] = [
        Column::History,
        Column::ParticleType,
        Column::Weight,
        Column::Energy,
        Column::Time,
        Column::X,
        Column::Y,
        Column::Z,
        Column::U,
        Column::V,
        Column::W,
        Column::SurfaceId,
    ];

    pub fn name(self) -> &'static str {
        match self {
            Column::History => "history",
            Column::ParticleType => "particle_type",
            Column::Weight => "weight",
            Column::Energy => "energy",
            Column::Time => "time",
            Column::X => "x",
            Column::Y => "y",
            Column::Z => "z",
            Column::U => "u",
            Column::V => "v",
            Column::W => "w",
            Column::SurfaceId => "surface_id",
        }
    }

    pub fn from_name(name: &str) -> Option<Column> {
        Column::ALL.into_iter().find(|c| c.name() == name)
    }
}

/// What the header says about how this file's track records must be interpreted.
///
/// Only the last value of a track record needs this: MCNP 6 files put the id of the
/// surface the track crossed there, while files written with `nrcd > 0` put a
/// direction cosine there instead (see [`FileParameters::records_surface_id`]).
#[derive(Debug, Clone, Default)]
pub struct TrackLayout {
    records_surface_id: bool,
    /// Absolute values of the header's surface ids, sorted, to validate what is
    /// decoded from each record against them. Empty means "accept anything".
    surface_ids: Vec<i64>,
    /// The header's surface ids as written, kept only to name them in errors.
    header_surface_ids: Vec<i32>,
}

impl TrackLayout {
    pub fn from_parameters(parameters: &FileParameters) -> Self {
        let header_surface_ids = parameters.surface_ids();
        let mut surface_ids: Vec<i64> = header_surface_ids
            .iter()
            .map(|id| (*id as i64).abs())
            .collect();
        surface_ids.sort_unstable();
        Self {
            records_surface_id: parameters.records_surface_id(),
            surface_ids,
            header_surface_ids,
        }
    }

    /// A layout for track data whose surface ids should be taken at face value, used
    /// by tests and by callers decoding a bare byte range with no header at hand.
    pub fn unvalidated() -> Self {
        Self {
            records_surface_id: true,
            surface_ids: Vec::new(),
            header_surface_ids: Vec::new(),
        }
    }

    pub fn records_surface_id(&self) -> bool {
        self.records_surface_id
    }

    fn check_surface_id(&self, surface_id: i64, index: usize) -> Result<()> {
        if self.surface_ids.is_empty() || self.surface_ids.binary_search(&surface_id.abs()).is_ok()
        {
            return Ok(());
        }
        Err(RssaError::UnknownTrackSurfaceId {
            index,
            surface_id,
            header_surfaces: self.header_surface_ids.clone(),
        })
    }
}

/// Decodes a byte range containing whole track records into a [`DataFrame`] with the
/// requested `columns`, in the order given.
///
/// `bytes` must be a whole number of 96-byte track records (e.g. a slice of the
/// mmap'd file starting right after the header). Decoding is parallelized across
/// [`rayon`]'s global thread pool.
pub fn decode_tracks(bytes: &[u8], columns: &[Column], layout: &TrackLayout) -> Result<DataFrame> {
    if !bytes.len().is_multiple_of(RECORD_BYTES) {
        return Err(RssaError::InvalidTrackByteLength { len: bytes.len() });
    }
    let mut seen = HashSet::with_capacity(columns.len());
    for column in columns {
        if !seen.insert(*column) {
            return Err(RssaError::DuplicateColumn(column.name().to_string()));
        }
    }

    let n_records = bytes.len() / RECORD_BYTES;
    let num_threads = rayon::current_num_threads().max(1);
    let records_per_chunk = n_records.div_ceil(num_threads).max(1);

    let chunks: Result<Vec<TrackBuffers>> = bytes
        .par_chunks(records_per_chunk * RECORD_BYTES)
        .enumerate()
        .map(|(chunk_idx, chunk_bytes)| {
            decode_chunk(chunk_bytes, columns, chunk_idx * records_per_chunk, layout)
        })
        .collect();

    let mut buffers = TrackBuffers::with_capacity(columns, n_records, layout);
    for chunk in chunks? {
        buffers.extend(chunk);
    }
    buffers.into_dataframe(columns)
}

fn decode_chunk(
    chunk: &[u8],
    columns: &[Column],
    base_index: usize,
    layout: &TrackLayout,
) -> Result<TrackBuffers> {
    let n = chunk.len() / RECORD_BYTES;
    let mut buffers = TrackBuffers::with_capacity(columns, n, layout);
    for i in 0..n {
        let record = &chunk[i * RECORD_BYTES..(i + 1) * RECORD_BYTES];
        let index = base_index + i;

        let before = i32::from_le_bytes(record[0..4].try_into().unwrap());
        let after = i32::from_le_bytes(record[92..96].try_into().unwrap());
        if before != RECORD_PAYLOAD_MARKER || after != RECORD_PAYLOAD_MARKER {
            return Err(RssaError::TrackRecordMarkerMismatch {
                index,
                before,
                after,
                expected: RECORD_PAYLOAD_MARKER,
            });
        }

        let mut raw = [0f64; 11];
        for (k, slot) in raw.iter_mut().enumerate() {
            let offset = 4 + k * 8;
            *slot = f64::from_le_bytes(record[offset..offset + 8].try_into().unwrap());
        }

        buffers.append(&raw, index, layout)?;
    }
    Ok(buffers)
}

/// Truncates the packed `particle_type`/w-sign field to an integer and validates it
/// is nonzero (zero has no valid sign, and can't be decoded into a particle-type
/// indicator either - it's not a value MCNP should ever actually write).
fn nonzero_packed_direction_field(raw: f64, index: usize) -> Result<i64> {
    let n = raw as i64;
    if n == 0 {
        return Err(RssaError::InvalidPackedDirectionField { index });
    }
    Ok(n)
}

/// Extracts the leading decimal digit of `abs(n)`, matching the packed
/// particle-type-indicator convention used by MCNP (e.g. a value like `8_123_456`
/// normalizes to `8`, indicating a neutron).
fn decode_particle_type(raw: f64, index: usize) -> Result<i64> {
    let mut n = nonzero_packed_direction_field(raw, index)?.abs();
    while n >= 10 {
        n /= 10;
    }
    Ok(n)
}

/// Reconstructs the third direction cosine `w` from `u`, `v`, and the sign packed
/// into `raw_b` (the same field `particle_type` is decoded from): MCNP only stores
/// `u` and `v` explicitly, since `w = +-sqrt(1 - u^2 - v^2)` is otherwise redundant -
/// but its sign isn't, so it's packed into `sign(b)` instead.
fn decode_w(raw_b: f64, u: f64, v: f64, index: usize) -> Result<f64> {
    let b = nonzero_packed_direction_field(raw_b, index)?;
    let sign = if b > 0 { 1.0 } else { -1.0 };
    // Clamp instead of trusting u^2 + v^2 <= 1 exactly: they're independently
    // stored floats, so floating-point round-off can occasionally push their sum
    // fractionally past 1, which would otherwise produce a NaN from the sqrt of a
    // (tiny) negative number.
    let radicand = (1.0 - u * u - v * v).max(0.0);
    Ok(sign * radicand.sqrt())
}

/// Struct-of-arrays accumulator for decoded track columns. Only the fields actually
/// requested are allocated and populated; the rest stay `None` for the lifetime of the
/// decode, so requesting a subset of columns avoids paying for the others.
#[derive(Default)]
struct TrackBuffers {
    history: Option<Vec<i64>>,
    particle_type: Option<Vec<i64>>,
    weight: Option<Vec<f64>>,
    energy: Option<Vec<f64>>,
    time: Option<Vec<f64>>,
    x: Option<Vec<f64>>,
    y: Option<Vec<f64>>,
    z: Option<Vec<f64>>,
    u: Option<Vec<f64>>,
    v: Option<Vec<f64>>,
    w: Option<Vec<f64>>,
    surface_id: Option<Vec<i64>>,
    /// Number of records appended, needed to size the `surface_id` column when the
    /// file doesn't record one and it has to come out as all-null.
    rows: usize,
}

impl TrackBuffers {
    fn with_capacity(columns: &[Column], cap: usize, layout: &TrackLayout) -> Self {
        let want = |c: Column| columns.contains(&c);
        Self {
            rows: 0,
            history: want(Column::History).then(|| Vec::with_capacity(cap)),
            particle_type: want(Column::ParticleType).then(|| Vec::with_capacity(cap)),
            weight: want(Column::Weight).then(|| Vec::with_capacity(cap)),
            energy: want(Column::Energy).then(|| Vec::with_capacity(cap)),
            time: want(Column::Time).then(|| Vec::with_capacity(cap)),
            x: want(Column::X).then(|| Vec::with_capacity(cap)),
            y: want(Column::Y).then(|| Vec::with_capacity(cap)),
            z: want(Column::Z).then(|| Vec::with_capacity(cap)),
            u: want(Column::U).then(|| Vec::with_capacity(cap)),
            v: want(Column::V).then(|| Vec::with_capacity(cap)),
            w: want(Column::W).then(|| Vec::with_capacity(cap)),
            surface_id: (want(Column::SurfaceId) && layout.records_surface_id())
                .then(|| Vec::with_capacity(cap)),
        }
    }

    fn append(&mut self, raw: &[f64; 11], index: usize, layout: &TrackLayout) -> Result<()> {
        self.rows += 1;
        if let Some(buf) = &mut self.history {
            buf.push(raw[0] as i64);
        }
        if let Some(buf) = &mut self.particle_type {
            buf.push(decode_particle_type(raw[1], index)?);
        }
        if let Some(buf) = &mut self.weight {
            buf.push(raw[2]);
        }
        if let Some(buf) = &mut self.energy {
            buf.push(raw[3]);
        }
        if let Some(buf) = &mut self.time {
            buf.push(raw[4]);
        }
        if let Some(buf) = &mut self.x {
            buf.push(raw[5]);
        }
        if let Some(buf) = &mut self.y {
            buf.push(raw[6]);
        }
        if let Some(buf) = &mut self.z {
            buf.push(raw[7]);
        }
        if let Some(buf) = &mut self.u {
            buf.push(raw[8]);
        }
        if let Some(buf) = &mut self.v {
            buf.push(raw[9]);
        }
        if let Some(buf) = &mut self.w {
            buf.push(decode_w(raw[1], raw[8], raw[9], index)?);
        }
        if let Some(buf) = &mut self.surface_id {
            let surface_id = raw[10] as i64;
            layout.check_surface_id(surface_id, index)?;
            buf.push(surface_id);
        }
        Ok(())
    }

    fn extend(&mut self, other: TrackBuffers) {
        self.rows += other.rows;
        fn extend_opt<T>(dst: &mut Option<Vec<T>>, src: Option<Vec<T>>) {
            if let (Some(d), Some(mut s)) = (dst.as_mut(), src) {
                d.append(&mut s);
            }
        }
        extend_opt(&mut self.history, other.history);
        extend_opt(&mut self.particle_type, other.particle_type);
        extend_opt(&mut self.weight, other.weight);
        extend_opt(&mut self.energy, other.energy);
        extend_opt(&mut self.time, other.time);
        extend_opt(&mut self.x, other.x);
        extend_opt(&mut self.y, other.y);
        extend_opt(&mut self.z, other.z);
        extend_opt(&mut self.u, other.u);
        extend_opt(&mut self.v, other.v);
        extend_opt(&mut self.w, other.w);
        extend_opt(&mut self.surface_id, other.surface_id);
    }

    fn into_dataframe(mut self, columns: &[Column]) -> Result<DataFrame> {
        let series_columns = columns
            .iter()
            .map(|column| {
                let series: Series = match column {
                    Column::History => {
                        Series::from_vec(column.name().into(), self.history.take().unwrap())
                    }
                    Column::ParticleType => {
                        Series::from_vec(column.name().into(), self.particle_type.take().unwrap())
                    }
                    Column::Weight => {
                        Series::from_vec(column.name().into(), self.weight.take().unwrap())
                    }
                    Column::Energy => {
                        Series::from_vec(column.name().into(), self.energy.take().unwrap())
                    }
                    Column::Time => {
                        Series::from_vec(column.name().into(), self.time.take().unwrap())
                    }
                    Column::X => Series::from_vec(column.name().into(), self.x.take().unwrap()),
                    Column::Y => Series::from_vec(column.name().into(), self.y.take().unwrap()),
                    Column::Z => Series::from_vec(column.name().into(), self.z.take().unwrap()),
                    Column::U => Series::from_vec(column.name().into(), self.u.take().unwrap()),
                    Column::V => Series::from_vec(column.name().into(), self.v.take().unwrap()),
                    Column::W => Series::from_vec(column.name().into(), self.w.take().unwrap()),
                    // A file that doesn't record a per-track surface id gets an
                    // all-null column rather than a plausible-looking one: the value
                    // in that slot is a direction cosine, and truncating it to an
                    // integer would quietly label every track as surface 0.
                    Column::SurfaceId => match self.surface_id.take() {
                        Some(values) => Series::from_vec(column.name().into(), values),
                        None => {
                            Series::full_null(column.name().into(), self.rows, &DataType::Int64)
                        }
                    },
                };
                polars::frame::column::Column::from(series)
            })
            .collect();
        Ok(DataFrame::new_infer_height(series_columns)?)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn write_track(out: &mut Vec<u8>, values: [f64; 11]) {
        out.extend_from_slice(&RECORD_PAYLOAD_MARKER.to_le_bytes());
        for v in values {
            out.extend_from_slice(&v.to_le_bytes());
        }
        out.extend_from_slice(&RECORD_PAYLOAD_MARKER.to_le_bytes());
    }

    fn sample_bytes(n: usize) -> Vec<u8> {
        let mut bytes = Vec::with_capacity(n * RECORD_BYTES);
        for i in 0..n {
            let sign = if i % 2 == 0 { 1.0 } else { -1.0 };
            write_track(
                &mut bytes,
                [
                    sign * (i as f64 + 1.0), // history
                    8_000_000.0 + i as f64,  // particle_type -> leading digit 8 (neutron)
                    0.5,                     // weight
                    1.25,                    // energy
                    3.0,                     // time
                    10.0 + i as f64,         // x
                    20.0,                    // y
                    30.0,                    // z
                    0.1,                     // u
                    0.2,                     // v
                    (i % 3) as f64 + 1.0,    // surface_id
                ],
            );
        }
        bytes
    }

    #[test]
    fn decodes_all_columns() {
        let bytes = sample_bytes(5);
        let df = decode_tracks(&bytes, &Column::ALL, &TrackLayout::unvalidated()).unwrap();
        assert_eq!(df.shape(), (5, 12));
        let history: Vec<Option<i64>> = df
            .column("history")
            .unwrap()
            .i64()
            .unwrap()
            .iter()
            .collect();
        assert_eq!(history, vec![Some(1), Some(-2), Some(3), Some(-4), Some(5)]);
        let particle_type: Vec<Option<i64>> = df
            .column("particle_type")
            .unwrap()
            .i64()
            .unwrap()
            .iter()
            .collect();
        assert!(
            particle_type
                .iter()
                .all(|v| *v == Some(NEUTRON_PARTICLE_TYPE))
        );
        // sample_bytes always uses u=0.1, v=0.2 and a positive packed b field.
        let expected_w = (1.0f64 - 0.1 * 0.1 - 0.2 * 0.2).sqrt();
        let w: Vec<Option<f64>> = df.column("w").unwrap().f64().unwrap().iter().collect();
        for value in w {
            assert!((value.unwrap() - expected_w).abs() < 1e-12);
        }
    }

    #[test]
    fn decode_w_recovers_the_correct_sign_and_magnitude() {
        let mut bytes = Vec::new();
        // u=0.6, v=0.0 => w = +-sqrt(1 - 0.36) = +-0.8. b > 0 => w > 0.
        write_track(
            &mut bytes,
            [
                1.0,
                8_000_000.0,
                0.5,
                1.25,
                3.0,
                10.0,
                20.0,
                30.0,
                0.6,
                0.0,
                1.0,
            ],
        );
        // Same u, v but with a negative packed b => w < 0.
        write_track(
            &mut bytes,
            [
                1.0,
                -8_000_000.0,
                0.5,
                1.25,
                3.0,
                10.0,
                20.0,
                30.0,
                0.6,
                0.0,
                1.0,
            ],
        );
        let df = decode_tracks(&bytes, &[Column::W], &TrackLayout::unvalidated()).unwrap();
        let w: Vec<Option<f64>> = df.column("w").unwrap().f64().unwrap().iter().collect();
        assert!((w[0].unwrap() - 0.8).abs() < 1e-12);
        assert!((w[1].unwrap() - (-0.8)).abs() < 1e-12);
    }

    #[test]
    fn decode_w_clamps_when_u_and_v_overshoot_the_unit_sphere() {
        let mut bytes = Vec::new();
        // u^2 + v^2 = 0.64 + 0.49 = 1.13 > 1: not physically valid direction
        // cosines, but decode_w must clamp to 0 under the sqrt rather than produce
        // NaN, e.g. from slightly-off, independently-stored floats in a real file.
        write_track(
            &mut bytes,
            [
                1.0,
                8_000_000.0,
                0.5,
                1.25,
                3.0,
                10.0,
                20.0,
                30.0,
                0.8,
                0.7,
                1.0,
            ],
        );
        let df = decode_tracks(&bytes, &[Column::W], &TrackLayout::unvalidated()).unwrap();
        let w = df.column("w").unwrap().f64().unwrap().get(0).unwrap();
        assert_eq!(w, 0.0);
    }

    #[test]
    fn column_projection_returns_only_requested_columns() {
        let bytes = sample_bytes(10);
        let df =
            decode_tracks(&bytes, &[Column::X, Column::Y], &TrackLayout::unvalidated()).unwrap();
        assert_eq!(df.get_column_names(), vec!["x", "y"]);
        assert_eq!(df.shape(), (10, 2));
    }

    #[test]
    fn empty_input_is_a_valid_empty_dataframe() {
        let df = decode_tracks(&[], &Column::ALL, &TrackLayout::unvalidated()).unwrap();
        assert_eq!(df.shape(), (0, 12));
    }

    #[test]
    fn rejects_length_not_a_multiple_of_record_size() {
        let bytes = vec![0u8; 100];
        let err = decode_tracks(&bytes, &Column::ALL, &TrackLayout::unvalidated()).unwrap_err();
        assert!(matches!(
            err,
            RssaError::InvalidTrackByteLength { len: 100 }
        ));
    }

    #[test]
    fn rejects_duplicate_columns() {
        let bytes = sample_bytes(1);
        let err = decode_tracks(&bytes, &[Column::X, Column::X], &TrackLayout::unvalidated())
            .unwrap_err();
        assert!(matches!(err, RssaError::DuplicateColumn(_)));
    }

    #[test]
    fn rejects_corrupt_record_marker() {
        let mut bytes = sample_bytes(3);
        // Corrupt the leading marker of the second record.
        bytes[RECORD_BYTES] = 0xFF;
        let err = decode_tracks(&bytes, &Column::ALL, &TrackLayout::unvalidated()).unwrap_err();
        match err {
            RssaError::TrackRecordMarkerMismatch { index, .. } => assert_eq!(index, 1),
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[test]
    fn rejects_zero_packed_direction_field_via_particle_type() {
        let mut bytes = Vec::new();
        write_track(
            &mut bytes,
            [1.0, 0.0, 0.5, 1.25, 3.0, 10.0, 20.0, 30.0, 0.1, 0.2, 1.0],
        );
        let err = decode_tracks(&bytes, &Column::ALL, &TrackLayout::unvalidated()).unwrap_err();
        assert!(matches!(
            err,
            RssaError::InvalidPackedDirectionField { index: 0 }
        ));
    }

    #[test]
    fn rejects_zero_packed_direction_field_via_w_alone() {
        // The same malformed field must be validated independently for `w`, even
        // when `particle_type` isn't among the requested columns at all.
        let mut bytes = Vec::new();
        write_track(
            &mut bytes,
            [1.0, 0.0, 0.5, 1.25, 3.0, 10.0, 20.0, 30.0, 0.1, 0.2, 1.0],
        );
        let err = decode_tracks(&bytes, &[Column::W], &TrackLayout::unvalidated()).unwrap_err();
        assert!(matches!(
            err,
            RssaError::InvalidPackedDirectionField { index: 0 }
        ));
    }

    /// A header declaring `surface_ids` and (by the sign of nrcd) whether the tracks
    /// carry a surface id at all.
    fn parameters_with_surfaces(nrcd: i32, surface_ids: &[i32]) -> FileParameters {
        FileParameters {
            format_version: crate::header::PARAMETERS_JSON_FORMAT_VERSION,
            source_code: crate::header::SourceCode::Mcnp,
            np1: -100,
            nrss: 1,
            nrcd,
            njsw: surface_ids.len() as i32,
            niss: 1,
            niwr: 0,
            mipts: 37,
            kjaq: 0,
            last_dump: None,
            surfaces: surface_ids
                .iter()
                .map(|id| crate::header::SurfaceParameters {
                    id: *id,
                    info: -1,
                    surface_type: 4,
                    num_parameters: 1,
                    parameters: vec![-800.0],
                })
                .collect(),
        }
    }

    #[test]
    fn accepts_a_surface_id_the_header_writes_with_the_opposite_sign() {
        // MCNP writes this surface as -610041 in the header while its tracks carry
        // 610041, so ids are matched on their absolute value.
        let mut bytes = Vec::new();
        write_track(
            &mut bytes,
            [
                1.0, 8.0, 0.5, 1.25, 3.0, 10.0, 20.0, 30.0, 0.1, 0.2, 610_041.0,
            ],
        );
        let layout = TrackLayout::from_parameters(&parameters_with_surfaces(-11, &[-610_041]));
        let df = decode_tracks(&bytes, &[Column::SurfaceId], &layout).unwrap();
        assert_eq!(
            df.column("surface_id").unwrap().i64().unwrap().get(0),
            Some(610_041)
        );
    }

    #[test]
    fn rejects_a_surface_id_the_header_does_not_declare() {
        // Reading a surface id out of a file that doesn't record one is the failure
        // this guards against: the value would be a direction cosine, and every track
        // would come out as "surface 0".
        let mut bytes = Vec::new();
        write_track(
            &mut bytes,
            [1.0, 8.0, 0.5, 1.25, 3.0, 10.0, 20.0, 30.0, 0.1, 0.2, 0.96],
        );
        let layout = TrackLayout::from_parameters(&parameters_with_surfaces(-11, &[63]));
        let err = decode_tracks(&bytes, &[Column::SurfaceId], &layout).unwrap_err();
        assert!(matches!(
            err,
            RssaError::UnknownTrackSurfaceId {
                index: 0,
                surface_id: 0,
                ..
            }
        ));
    }

    #[test]
    fn emits_a_null_surface_id_column_when_the_file_records_none() {
        let bytes = sample_bytes(3);
        let layout = TrackLayout::from_parameters(&parameters_with_surfaces(11, &[1]));
        let df = decode_tracks(&bytes, &[Column::X, Column::SurfaceId], &layout).unwrap();
        assert_eq!(df.shape(), (3, 2));
        assert_eq!(df.column("surface_id").unwrap().null_count(), 3);
    }

    #[test]
    fn chunked_decoding_matches_single_shot_decoding() {
        let bytes = sample_bytes(10_000);
        let whole = decode_tracks(&bytes, &Column::ALL, &TrackLayout::unvalidated()).unwrap();

        let mut parts = Vec::new();
        for chunk in bytes.chunks(777 * RECORD_BYTES) {
            parts.push(decode_tracks(chunk, &Column::ALL, &TrackLayout::unvalidated()).unwrap());
        }
        let mut concatenated = parts.remove(0);
        for part in parts {
            concatenated.vstack_mut(&part).unwrap();
        }

        assert!(whole.equals(&concatenated));
    }

    #[test]
    fn decoding_is_deterministic_regardless_of_thread_count() {
        let bytes = sample_bytes(20_000);
        let one_thread_pool = rayon::ThreadPoolBuilder::new()
            .num_threads(1)
            .build()
            .unwrap();
        let single_threaded = one_thread_pool
            .install(|| decode_tracks(&bytes, &Column::ALL, &TrackLayout::unvalidated()).unwrap());
        let multi_threaded =
            decode_tracks(&bytes, &Column::ALL, &TrackLayout::unvalidated()).unwrap();
        assert!(single_threaded.equals(&multi_threaded));
    }
}
