//! End-to-end NV-diamond simulator pipeline — Pass 5b of the implementation plan.
//!
//! `Pipeline` wires every module: scene → source synthesis → propagation →
//! NV ensemble → digitiser → MagFrame stream. One `Pipeline::run(n)` call
//! produces an n-sample deterministic frame stream from a scene + config.
//!
//! Determinism: same `(scene, config, seed)` ⇒ byte-identical frame stream
//! across runs and machines. Underwrites the proof-bundle commitment in
//! plan §5 — Pass 6 wraps this in a SHA-256 witness.

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use crate::digitiser::{adc_quantise, DigitiserConfig};
use crate::frame::{flag, MagFrame};
use crate::scene::Scene;
use crate::sensor::{NvSensor, NvSensorConfig};
use crate::source::scene_field_at;

/// Pipeline configuration.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
pub struct PipelineConfig {
    /// Sensor / digitiser sampling parameters.
    pub digitiser: DigitiserConfig,
    /// NV-ensemble physics parameters.
    pub sensor: NvSensorConfig,
    /// Per-sample integration time (s). Default 1/f_s.
    pub dt_s: Option<f64>,
}

/// Forward-only NV-diamond pipeline.
#[derive(Debug, Clone)]
pub struct Pipeline {
    scene: Scene,
    config: PipelineConfig,
    seed: u64,
}

impl Pipeline {
    /// Construct a pipeline. `seed` makes shot-noise reproducible — same
    /// `(scene, config, seed)` produces byte-identical output.
    pub fn new(scene: Scene, config: PipelineConfig, seed: u64) -> Self {
        Self {
            scene,
            config,
            seed,
        }
    }

    /// Run `n_samples` of the pipeline. Returns one [`MagFrame`] per
    /// (sensor × sample) — i.e. `n_samples · scene.sensors.len()` frames
    /// in scene-major / sample-minor order.
    pub fn run(&self, n_samples: usize) -> Vec<MagFrame> {
        let dt = self
            .config
            .dt_s
            .unwrap_or(1.0 / self.config.digitiser.f_s_hz);
        let dt_us = (dt * 1.0e6) as u64;
        let nv = NvSensor::new(self.config.sensor);

        let mut out: Vec<MagFrame> =
            Vec::with_capacity(n_samples.saturating_mul(self.scene.sensors.len()));

        for (sensor_idx, &sensor_pos) in self.scene.sensors.iter().enumerate() {
            for sample in 0..n_samples {
                let (b_synth, near_field) = scene_field_at(&self.scene, sensor_pos);
                // Per-sample seed mixes the global seed with sample/sensor
                // indices so different (sensor, sample) pairs draw from
                // independent shot-noise streams while the whole run stays
                // reproducible from the global seed.
                let per_sample_seed = self
                    .seed
                    .wrapping_mul(0x9E37_79B9_7F4A_7C15)
                    .wrapping_add((sensor_idx as u64) << 32)
                    .wrapping_add(sample as u64);
                let reading = nv.sample(b_synth, dt, per_sample_seed);

                // ADC quantise each axis independently, raising the
                // saturation flag if any axis clips.
                let mut adc_sat = false;
                let mut b_pt = [0.0_f32; 3];
                for (k, b) in b_pt.iter_mut().enumerate() {
                    let (code, sat) = adc_quantise(reading.b_recovered[k]);
                    adc_sat |= sat;
                    let recovered_t = code as f64 * crate::digitiser::ADC_LSB_T;
                    *b = (recovered_t * 1.0e12) as f32; // T → pT
                }
                let sigma_pt = [
                    (reading.sigma_per_axis[0] * 1.0e12) as f32,
                    (reading.sigma_per_axis[1] * 1.0e12) as f32,
                    (reading.sigma_per_axis[2] * 1.0e12) as f32,
                ];

                let mut frame = MagFrame::empty(sensor_idx as u16);
                frame.t_us = (sample as u64) * dt_us;
                frame.b_pt = b_pt;
                frame.sigma_pt = sigma_pt;
                frame.noise_floor_pt_sqrt_hz = (reading.noise_floor_t_sqrt_hz * 1.0e12) as f32;
                frame.temperature_k = 295.0;
                if near_field {
                    frame.set_flag(flag::SATURATION_NEAR_FIELD);
                }
                if adc_sat {
                    frame.set_flag(flag::ADC_SATURATED);
                }
                if self.config.sensor.shot_noise_disabled {
                    frame.set_flag(flag::SHOT_NOISE_DISABLED);
                }
                out.push(frame);
            }
        }
        out
    }

    /// Run the pipeline and return a SHA-256 of the concatenated raw frame
    /// bytes. The witness is content-addressable: same `(scene, config, seed)`
    /// produces byte-identical witnesses across runs and machines. Backbone
    /// of Pass 6's proof bundle.
    pub fn run_with_witness(&self, n_samples: usize) -> (Vec<MagFrame>, [u8; 32]) {
        let frames = self.run(n_samples);
        let mut hasher = Sha256::new();
        for f in &frames {
            hasher.update(f.to_bytes());
        }
        let digest: [u8; 32] = hasher.finalize().into();
        (frames, digest)
    }
}

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

    fn fixture_scene() -> Scene {
        let mut s = Scene::new();
        // Strong-ish dipole 50 cm above the sensor.
        s.add_dipole(DipoleSource::new([0.0, 0.0, 0.5], [0.0, 0.0, 1.0e-3]));
        s.add_sensor([0.0, 0.0, 0.0]);
        s
    }

    #[test]
    fn determinism_same_seed_byte_identical_witness() {
        // Plan §5 acceptance: (scene, seed) → byte-identical proof bundle.
        let scene = fixture_scene();
        let cfg = PipelineConfig::default();
        let p1 = Pipeline::new(scene.clone(), cfg, 42);
        let p2 = Pipeline::new(scene, cfg, 42);
        let (_, w1) = p1.run_with_witness(64);
        let (_, w2) = p2.run_with_witness(64);
        assert_eq!(w1, w2, "same seed must produce identical witnesses");
    }

    #[test]
    fn different_seeds_produce_different_witnesses() {
        // Sanity: the seed actually does something. Two different seeds
        // must produce different witnesses (overwhelmingly likely).
        let scene = fixture_scene();
        let cfg = PipelineConfig::default();
        let (_, w1) = Pipeline::new(scene.clone(), cfg, 1).run_with_witness(64);
        let (_, w2) = Pipeline::new(scene, cfg, 2).run_with_witness(64);
        assert_ne!(w1, w2);
    }

    #[test]
    fn frame_count_matches_sensor_x_sample_product() {
        let scene = fixture_scene();
        let cfg = PipelineConfig::default();
        let p = Pipeline::new(scene, cfg, 7);
        let frames = p.run(32);
        assert_eq!(frames.len(), 32);
        for (i, f) in frames.iter().enumerate() {
            assert_eq!(f.sensor_id, 0);
            assert_eq!(f.t_us, (i as u64) * (1.0e6 / 10_000.0) as u64);
        }
    }

    #[test]
    fn shot_noise_disabled_propagates_flag_and_yields_clean_signal() {
        // With shot noise off, every frame must carry SHOT_NOISE_DISABLED
        // and the recovered field must reproduce the analytical value
        // within ADC ½-LSB. Plan §5 noise-floor commitment.
        let scene = fixture_scene();
        let cfg = PipelineConfig {
            sensor: NvSensorConfig {
                shot_noise_disabled: true,
                ..NvSensorConfig::default()
            },
            ..PipelineConfig::default()
        };
        let p = Pipeline::new(scene.clone(), cfg, 0);
        let frames = p.run(8);
        let (b_analytic, _) = scene_field_at(&scene, scene.sensors[0]);
        for f in &frames {
            assert!(f.has_flag(flag::SHOT_NOISE_DISABLED));
            for (k, (&b_pt, &b_ref)) in f.b_pt.iter().zip(b_analytic.iter()).enumerate() {
                let recovered_t = b_pt as f64 * 1.0e-12;
                let lsb_t = crate::digitiser::ADC_LSB_T;
                assert!(
                    (recovered_t - b_ref).abs() <= lsb_t,
                    "noise-off recovery error > 1 LSB for axis {k}"
                );
            }
        }
    }

    #[test]
    fn adc_saturation_flag_fires_above_full_scale() {
        // Place a dipole close enough to drive the field above ±10 µT FS.
        let mut scene = Scene::new();
        scene.add_dipole(DipoleSource::new([0.0, 0.0, 0.005], [0.0, 0.0, 1.0])); // 1 A·m² at 5 mm
        scene.add_sensor([0.0, 0.0, 0.0]);
        let cfg = PipelineConfig {
            sensor: NvSensorConfig {
                shot_noise_disabled: true,
                ..NvSensorConfig::default()
            },
            ..PipelineConfig::default()
        };
        let frames = Pipeline::new(scene, cfg, 0).run(4);
        let any_sat = frames.iter().any(|f| f.has_flag(flag::ADC_SATURATED));
        assert!(
            any_sat,
            "ADC_SATURATED flag did not fire on a near-field dipole that should drive FS"
        );
    }
}
