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

//! ngspice / Berkeley SPICE raw-file reader (binary and ASCII).
//!
//! Layout, verified against real files produced by ngspice-46:
//!
//! ```text
//! Title: ...
//! Date: ...
//! Plotname: Transient Analysis
//! Flags: real
//! No. Variables: 15
//! No. Points: 35684
//! Variables:
//! <tab>0<tab>time<tab>time
//! <tab>1<tab>v(an_sub)<tab>voltage
//! ...
//! Binary:                       <- or "Values:" for the ASCII variant
//! <nvars * npoints f64, native endian, point-major>
//! ```
//!
//! Point-major is the whole reason the indexer looks the way it does: reading
//! one signal touches every byte of the file, so the file is read exactly
//! once, sequentially, and everything after that is served from the index.

use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::os::unix::fs::FileExt;
use std::path::{Path, PathBuf};

use wave_core::{Unit, ValueKind};

use crate::{invalid, Variable, WaveReader};

/// How much of the file may be header before we give up looking.
const MAX_HEADER: usize = 4 << 20;

enum Data {
    Binary { file: File, offset: u64 },
    /// ASCII raws are a debugging convenience, never gigabytes.  Parsed once
    /// into memory rather than growing a line index nobody would exercise.
    Ascii { values: Vec<f64> },
}

pub struct RawReader {
    path: PathBuf,
    plot_name: String,
    vars: Vec<Variable>,
    n_points: usize,
    header_points: usize,
    kind: ValueKind,
    data: Data,
}

impl RawReader {
    pub fn open<P: AsRef<Path>>(path: P) -> std::io::Result<RawReader> {
        let path = path.as_ref().to_path_buf();
        let mut file = File::open(&path)?;
        let file_len = file.metadata()?.len();

        // Slurp a bounded prefix and locate the data marker.
        let probe_len = std::cmp::min(file_len as usize, MAX_HEADER);
        let mut probe = vec![0u8; probe_len];
        let got = read_upto(&mut file, &mut probe)?;
        probe.truncate(got);

        let (marker_at, marker_len, binary) = match find_marker(&probe) {
            Some(v) => v,
            None => return invalid("no 'Binary:' or 'Values:' marker in header"),
        };
        let header = String::from_utf8_lossy(&probe[..marker_at]).into_owned();
        let data_offset = (marker_at + marker_len) as u64;

        let mut plot_name = String::new();
        let mut n_vars = 0usize;
        let mut header_points = 0usize;
        let mut kind = ValueKind::Real;
        let mut vars: Vec<Variable> = Vec::new();
        let mut in_vars = false;

        for line in header.lines() {
            let lower = line.to_ascii_lowercase();
            if in_vars {
                let tok: Vec<&str> = line.split_whitespace().collect();
                // "<idx> <name> <type> [extra...]"
                if tok.len() >= 3 {
                    if let Ok(idx) = tok[0].parse::<usize>() {
                        // Trailing annotations: "grid=3", "dims=...", etc.
                        let grid = tok[3..].iter().find_map(|t| {
                            t.strip_prefix("grid=").and_then(|g| g.parse::<i32>().ok())
                        });
                        vars.push(Variable {
                            index: idx,
                            name: tok[1].to_string(),
                            unit: Unit::from_raw_type(tok[2]),
                            grid,
                        });
                        continue;
                    }
                }
                // Anything that is not a variable row ends the table.
                in_vars = false;
            }
            if lower.starts_with("plotname:") {
                plot_name = line[9..].trim().to_string();
            } else if lower.starts_with("flags:") {
                if lower.contains("complex") {
                    kind = ValueKind::Complex;
                }
            } else if lower.starts_with("no. variables:") {
                n_vars = parse_usize(&line[14..]);
            } else if lower.starts_with("no. points:") {
                header_points = parse_usize(&line[11..]);
            } else if lower.starts_with("variables:") {
                in_vars = true;
            }
        }

        if vars.is_empty() {
            return invalid("no variables declared in raw header");
        }
        if n_vars != 0 && n_vars != vars.len() {
            return invalid(format!(
                "header says {} variables but {} rows follow",
                n_vars,
                vars.len()
            ));
        }
        let comps = if kind == ValueKind::Complex { 2 } else { 1 };
        let row_vals = vars.len() * comps;

        let (data, n_points) = if binary {
            let bytes_per_row = (row_vals * 8) as u64;
            let avail = file_len.saturating_sub(data_offset);
            // A simulation killed mid-write leaves a short final row.  Serve
            // what is actually there instead of refusing the file — that case
            // is common enough to be worth handling in the reader.
            let rows_present = (avail / bytes_per_row) as usize;
            let n = if header_points == 0 {
                rows_present
            } else {
                std::cmp::min(header_points, rows_present)
            };
            (
                Data::Binary {
                    file,
                    offset: data_offset,
                },
                n,
            )
        } else {
            let values = parse_ascii_values(&mut file, data_offset, vars.len(), comps)?;
            let n = values.len() / row_vals;
            (Data::Ascii { values }, n)
        };

        Ok(RawReader {
            path,
            plot_name,
            vars,
            n_points,
            header_points,
            kind,
            data,
        })
    }

    pub fn path(&self) -> &Path {
        &self.path
    }

    pub fn is_binary(&self) -> bool {
        matches!(self.data, Data::Binary { .. })
    }

    /// True when the file holds fewer points than its header claims — an
    /// interrupted simulation.
    pub fn truncated(&self) -> bool {
        self.header_points != 0 && self.n_points < self.header_points
    }

    pub fn header_points(&self) -> usize {
        self.header_points
    }
}

impl WaveReader for RawReader {
    fn plot_name(&self) -> &str {
        &self.plot_name
    }
    fn variables(&self) -> &[Variable] {
        &self.vars
    }
    fn n_points(&self) -> usize {
        self.n_points
    }
    fn kind(&self) -> ValueKind {
        self.kind
    }

    fn read_rows(&self, first_row: usize, n_rows: usize, out: &mut [f64]) -> std::io::Result<usize> {
        let row_vals = self.row_len();
        if first_row >= self.n_points {
            return Ok(0);
        }
        let rows = std::cmp::min(n_rows, self.n_points - first_row);
        let want = rows * row_vals;
        if out.len() < want {
            return invalid("read_rows: output slice too small");
        }
        match &self.data {
            Data::Ascii { values } => {
                let start = first_row * row_vals;
                out[..want].copy_from_slice(&values[start..start + want]);
                Ok(rows)
            }
            Data::Binary { file, offset } => {
                let pos = offset + (first_row * row_vals * 8) as u64;
                // The file holds native-endian f64 exactly as they sit in
                // `out`, so read straight into it.  Every bit pattern is a
                // valid f64 (possibly NaN), and f64 alignment exceeds u8's,
                // so the reinterpretation is sound.  This avoids a second
                // full-size copy of the data on every pass, which at 3 GB is
                // not a micro-optimisation.
                let bytes = unsafe {
                    std::slice::from_raw_parts_mut(out.as_mut_ptr() as *mut u8, want * 8)
                };
                file.read_exact_at(bytes, pos)?;
                Ok(rows)
            }
        }
    }
}

fn read_upto(file: &mut File, buf: &mut [u8]) -> std::io::Result<usize> {
    let mut done = 0;
    while done < buf.len() {
        let n = file.read(&mut buf[done..])?;
        if n == 0 {
            break;
        }
        done += n;
    }
    Ok(done)
}

/// Returns (offset of marker, marker length including newline, is_binary).
fn find_marker(buf: &[u8]) -> Option<(usize, usize, bool)> {
    let mut line_start = 0usize;
    for i in 0..buf.len() {
        if buf[i] != b'\n' {
            continue;
        }
        let mut end = i;
        if end > line_start && buf[end - 1] == b'\r' {
            end -= 1;
        }
        let line = &buf[line_start..end];
        if line.eq_ignore_ascii_case(b"binary:") {
            return Some((line_start, i + 1 - line_start, true));
        }
        if line.eq_ignore_ascii_case(b"values:") {
            return Some((line_start, i + 1 - line_start, false));
        }
        line_start = i + 1;
    }
    None
}

fn parse_usize(s: &str) -> usize {
    s.trim().parse::<usize>().unwrap_or(0)
}

fn parse_ascii_values(
    file: &mut File,
    offset: u64,
    n_vars: usize,
    comps: usize,
) -> std::io::Result<Vec<f64>> {
    file.seek(SeekFrom::Start(offset))?;
    let mut text = String::new();
    file.read_to_string(&mut text)?;

    let mut out: Vec<f64> = Vec::new();
    let mut tok = text.split_ascii_whitespace();
    'points: loop {
        // Each point begins with its index, which carries no information we
        // do not already have.
        if tok.next().is_none() {
            break;
        }
        for _ in 0..n_vars {
            let t = match tok.next() {
                Some(t) => t,
                None => break 'points,
            };
            if comps == 2 {
                let mut it = t.split(',');
                let re = it.next().unwrap_or("0").parse::<f64>().unwrap_or(f64::NAN);
                let im = it.next().unwrap_or("0").parse::<f64>().unwrap_or(f64::NAN);
                out.push(re);
                out.push(im);
            } else {
                out.push(t.parse::<f64>().unwrap_or(f64::NAN));
            }
        }
    }
    Ok(out)
}
