//! Per-material magnetic-field attenuation along sensor–source line-of-sight
//! segments — Pass 3 of the implementation plan.
//!
//! Free-space `1/r³` falloff lives in [`crate::source`] (it's part of the
//! dipole formula). This layer applies *additional* attenuation when the LoS
//! crosses material slabs of known thickness. Default — for air / vacuum —
//! is the identity transform.
//!
//! # Primary sources
//!
//! - Jackson, *Classical Electrodynamics* 3e (1999) §5.8, §8.1 — skin depth.
//! - Cullity & Graham, *Introduction to Magnetic Materials* 2e (2009) Ch. 2.
//! - Ulrich, *NDT&E Int.* 35 (2002) — concrete-attenuation proxy (cited as
//!   *proxy*; the real research gap is plan §6.3).
//!
//! # Honest scope
//!
//! Plan §2.2 explicitly marks drywall / brick / dry-concrete loss values as
//! **conjectural** with defensible defaults. We re-state that here in code:
//! the table is the best public-domain estimate at DC–10 kHz, but no
//! systematic measurement of residential-wall magnetic-field penetration
//! loss at RuView geometry has been published. Reinforced concrete carries
//! a warning flag so consumers know to escalate.

use crate::scene::Vec3;

/// Material categories the simulator knows about. Extend by adding to this
/// enum + the per-material entry in [`material_loss_db_per_m`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Material {
    /// Vacuum / air. Identity attenuation.
    Air,
    /// Gypsum drywall, dry. Conjectural 0 dB/m.
    Drywall,
    /// Dry brick. Conjectural 0 dB/m.
    Brick,
    /// Dry concrete, no rebar. Conjectural 0.5 dB/m (Ulrich 2002 proxy).
    ConcreteDry,
    /// Reinforced concrete. 20 dB/m + raises the heavy-attenuation flag.
    ReinforcedConcrete,
    /// Sheet steel (low-carbon). Frequency-dependent skin-depth attenuation
    /// per Jackson §8.1; the simulator passes a representative DC value.
    SheetSteel,
}

/// One slab of material along a line-of-sight segment.
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LosSegment {
    /// Material in this slab.
    pub material: Material,
    /// Path length through the slab (m). Must be `>= 0` and finite; `0`
    /// is the documented no-op input.
    pub path_m: f64,
}

/// Per-meter loss in decibels at DC–10 kHz. See plan §2.2 for primary
/// sources and conjecture markers.
pub fn material_loss_db_per_m(m: Material) -> f64 {
    match m {
        Material::Air => 0.0,
        Material::Drywall => 0.0, // conjecture: gypsum non-ferromagnetic
        Material::Brick => 0.0,   // conjecture: same logic as drywall
        Material::ConcreteDry => 0.5, // conjecture: Ulrich 2002 proxy
        Material::ReinforcedConcrete => 20.0, // proxy + warning flag (plan §2.2)
        Material::SheetSteel => 100.0, // frequency-dependent in reality;
                                   // representative DC bulk loss
    }
}

/// True iff this material warrants the `HEAVY_ATTENUATION` frame flag
/// (i.e. the simulator's confidence in the per-meter loss is poor and the
/// downstream consumer should know to interpret the reading with caution).
pub fn material_is_heavy(m: Material) -> bool {
    matches!(m, Material::ReinforcedConcrete | Material::SheetSteel)
}

/// Apply per-segment attenuation to an incoming 3-vector field. Returns
/// `(B_out, heavy_flag)` where `heavy_flag` is `true` if any segment was
/// flagged as heavy / low-confidence.
///
/// Total loss is the sum of `path_m × loss_db_per_m` across segments,
/// converted to a linear scale factor. NaN-safe — segments with non-finite
/// `path_m` are skipped (no contribution, no panic).
pub fn attenuate(b_in: Vec3, segments: &[LosSegment]) -> (Vec3, bool) {
    let mut total_db = 0.0_f64;
    let mut heavy = false;
    for seg in segments {
        if !seg.path_m.is_finite() || seg.path_m <= 0.0 {
            continue;
        }
        total_db += seg.path_m * material_loss_db_per_m(seg.material);
        heavy |= material_is_heavy(seg.material);
    }
    let scale = 10.0_f64.powf(-total_db / 20.0);
    ([b_in[0] * scale, b_in[1] * scale, b_in[2] * scale], heavy)
}

/// Aggregate "propagator" type — currently a stateless wrapper over
/// [`attenuate`] but a struct to keep room for future per-frequency or
/// per-thickness parameters without breaking the call-site shape.
#[derive(Debug, Clone, Copy, Default)]
pub struct Propagator;

impl Propagator {
    /// Identity-attenuation propagator (air/free-space).
    pub fn new() -> Self {
        Self
    }

    /// Run [`attenuate`] across a slice of LoS segments.
    pub fn attenuate(self, b_in: Vec3, segments: &[LosSegment]) -> (Vec3, bool) {
        attenuate(b_in, segments)
    }
}

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

    #[test]
    fn free_space_is_identity_transform() {
        // Air with any path length: B_out == B_in, no heavy flag.
        let b_in = [1.0e-9, 2.0e-9, 3.0e-9];
        let segs = [LosSegment {
            material: Material::Air,
            path_m: 5.0,
        }];
        let (b_out, heavy) = attenuate(b_in, &segs);
        assert_relative_eq!(b_out[0], b_in[0], max_relative = 1e-12);
        assert_relative_eq!(b_out[1], b_in[1], max_relative = 1e-12);
        assert_relative_eq!(b_out[2], b_in[2], max_relative = 1e-12);
        assert!(!heavy);
    }

    #[test]
    fn drywall_is_approximately_zero_db() {
        // Plan §2.2 marks drywall as conjectural 0 dB/m. The simulator
        // commits to identity for now; if a primary source is ever cited
        // this test is the regression boundary.
        let b_in = [1.0e-9, 0.0, 0.0];
        let segs = [LosSegment {
            material: Material::Drywall,
            path_m: 0.1,
        }];
        let (b_out, heavy) = attenuate(b_in, &segs);
        assert_relative_eq!(b_out[0], b_in[0], max_relative = 1e-12);
        assert!(!heavy, "drywall is not flagged as heavy");
    }

    #[test]
    fn dry_concrete_attenuates_at_half_db_per_meter() {
        // 0.5 dB/m × 2 m = 1 dB total. Linear scale = 10^(-1/20) ≈ 0.8913.
        let b_in = [1.0_f64, 0.0, 0.0];
        let segs = [LosSegment {
            material: Material::ConcreteDry,
            path_m: 2.0,
        }];
        let (b_out, heavy) = attenuate(b_in, &segs);
        let expected = 10.0_f64.powf(-1.0 / 20.0);
        assert_relative_eq!(b_out[0], expected, max_relative = 1e-12);
        assert!(!heavy, "dry concrete is not flagged heavy");
    }

    #[test]
    fn reinforced_concrete_attenuates_and_raises_heavy_flag() {
        // 20 dB/m × 0.2 m = 4 dB. Linear scale = 10^(-0.2) ≈ 0.6310.
        let b_in = [1.0_f64; 3];
        let segs = [LosSegment {
            material: Material::ReinforcedConcrete,
            path_m: 0.2,
        }];
        let (b_out, heavy) = attenuate(b_in, &segs);
        let expected = 10.0_f64.powf(-4.0 / 20.0);
        for &val in &b_out {
            assert_relative_eq!(val, expected, max_relative = 1e-12);
        }
        assert!(heavy, "reinforced concrete must raise heavy_flag");
    }

    #[test]
    fn nan_or_negative_path_is_skipped_without_nan_in_output() {
        // A degenerate or hostile input must not propagate NaN/Inf to the
        // pipeline (the digitiser would otherwise produce a poisoned frame).
        let b_in = [1.0_f64, 2.0, 3.0];
        let segs = [
            LosSegment {
                material: Material::ConcreteDry,
                path_m: f64::NAN,
            },
            LosSegment {
                material: Material::Drywall,
                path_m: -1.0, // negative paths are skipped, not negated
            },
            LosSegment {
                material: Material::Air,
                path_m: 5.0,
            },
        ];
        let (b_out, heavy) = attenuate(b_in, &segs);
        for k in 0..3 {
            assert!(
                b_out[k].is_finite(),
                "B[{k}] = {} is non-finite — pass-3 NaN guard failed",
                b_out[k]
            );
            // Air alone -> identity; the malformed segments contributed nothing.
            assert_relative_eq!(b_out[k], b_in[k], max_relative = 1e-12);
        }
        assert!(!heavy);
    }

    #[test]
    fn empty_los_returns_input_unchanged() {
        let b_in = [1.0_f64, 2.0, 3.0];
        let (b_out, heavy) = attenuate(b_in, &[]);
        assert_eq!(b_out, b_in);
        assert!(!heavy);
    }

    #[test]
    fn propagator_struct_dispatches_to_free_function() {
        let b_in = [1.0_f64, 2.0, 3.0];
        let segs = [LosSegment {
            material: Material::Air,
            path_m: 1.0,
        }];
        let p = Propagator::new();
        let (b_out, _) = p.attenuate(b_in, &segs);
        assert_eq!(b_out, b_in);
    }
}
