//! Object-based spatial audio — `extensions/spatial-audio.md` (Phase 1).
//!
//! The audio analog of interactive-worlds: a `wai.audio.scene` ships
//! sound **objects** (each a content-addressed `wai.audio.*` source plus
//! a position trajectory and gain) and the sink renders them to its own
//! speaker layout. Conformance is **mixdown-equivalence** — same scene +
//! same declared layout → the identical canonical PCM, hence the
//! identical BLAKE3 — the audio counterpart of replay-equivalence, with
//! the tick replaced by the audio sample.
//!
//! Everything that must be reproducible runs in the `wai.det.fixed64`
//! floor ([`crate::world::Fx`]): position interpolation, distance
//! attenuation, the constant-power pan, the accumulate, and the i16
//! quantization. No floats touch the mix. The pan/distance laws are
//! chosen to be exactly computable in the floor (sqrt only — no
//! transcendental tables): `wai.audio.pan.sqrt_constpower` and
//! `wai.audio.dist.inverse_clamped`.
//!
//! How a source's mono PCM is obtained (decode the `wai.audio.*`
//! envelope) is the sink's job, out of scope here — exactly as decoding
//! a `wai.mesh.glb`/`wai.splat.spz` asset is.

use crate::world::Fx;
use std::collections::HashMap;

mod container;
pub mod receipt;
pub use container::{AudioObject, PosKey, SceneContract, SceneSeal, SpatialScene, MAGIC_WSCN};
pub use receipt::{BlockHash, SceneReceipt};

pub const CAP_AUDIO_SCENE: &str = "wai.audio.scene";
pub const PAN_SQRT_CONSTPOWER: &str = "wai.audio.pan.sqrt_constpower";
pub const DIST_INVERSE_CLAMPED: &str = "wai.audio.dist.inverse_clamped";
pub const DOMAIN_MIXDOWN: &[u8] = b"wai:audio-mixdown\x01";
pub const DOMAIN_SCENE: &[u8] = b"wai:audio-scene\x01";

#[derive(Debug)]
pub enum AudioError {
    BadMagic,
    Truncated,
    MissingSection(u8),
    BadContract(String),
    MissingSource([u8; 32]),
    UnsupportedLayout(String),
    BadSignature,
}

impl std::fmt::Display for AudioError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::BadMagic => write!(f, "payload is not a WSCN container"),
            Self::Truncated => write!(f, "byte stream truncated"),
            Self::MissingSection(k) => write!(f, "required section 0x{k:02x} missing"),
            Self::BadContract(w) => write!(f, "bad scene contract: {w}"),
            Self::MissingSource(h) => write!(f, "no PCM provided for source {}", hex8(h)),
            Self::UnsupportedLayout(l) => write!(f, "unsupported layout `{l}`"),
            Self::BadSignature => write!(f, "seal signature failed"),
        }
    }
}
impl std::error::Error for AudioError {}

fn hex8(b: &[u8; 32]) -> String {
    b[..4].iter().map(|x| format!("{x:02x}")).collect()
}

#[inline]
fn fx_abs(v: Fx) -> Fx {
    if v.to_bits() < 0 { Fx::ZERO - v } else { v }
}
#[inline]
fn fx_clamp(v: Fx, lo: Fx, hi: Fx) -> Fx {
    if v.to_bits() < lo.to_bits() { lo } else if v.to_bits() > hi.to_bits() { hi } else { v }
}

/// Source PCM: mono, i16, already at the scene's sample rate, keyed by
/// the source's content hash (the sink decoded the `wai.audio.*` asset).
pub type Sources = HashMap<[u8; 32], Vec<i16>>;

impl SpatialScene {
    /// Deterministic mixdown to the declared layout's interleaved i16
    /// PCM. Only the mandatory `stereo` floor is implemented here;
    /// richer layouts register their own (binaural is perceptual, not
    /// bit-exact, per the draft). Bit-identical on every machine.
    pub fn mixdown(&self, sources: &Sources, layout: &str) -> Result<Vec<i16>, AudioError> {
        if layout != "stereo" {
            return Err(AudioError::UnsupportedLayout(layout.into()));
        }
        let n = self.contract.duration as usize;
        // Fx accumulators, 2 channels interleaved.
        let mut acc = vec![Fx::ZERO; n * 2];
        let norm = Fx::from_int(32768);
        let half = Fx::ONE / Fx::from_int(2);

        for obj in &self.objects {
            let src = sources.get(&obj.source_hash).ok_or(AudioError::MissingSource(obj.source_hash))?;
            for t in 0..n {
                let s_i16 = src.get(t).copied().unwrap_or(0);
                if s_i16 == 0 && obj.keys.len() == 1 {
                    // cheap skip only when silent AND static contributes 0
                }
                let amp = Fx::from_int(s_i16 as i32) / norm; // [-1,1)
                let g = amp * obj.gain;
                let (x, y, z) = interp_pos(&obj.keys, t as u64);
                // distance attenuation: 1/(1+d), clamped [0,1]
                let d = (x * x + y * y + z * z).sqrt();
                let atten = fx_clamp(Fx::ONE / (Fx::ONE + d), Fx::ZERO, Fx::ONE);
                // constant-power pan from x/(|x|+|z|)
                let denom = fx_abs(x) + fx_abs(z);
                let pan = if denom.to_bits() != 0 {
                    fx_clamp(x / denom, Fx::ZERO - Fx::ONE, Fx::ONE)
                } else {
                    Fx::ZERO
                };
                let gl = ((Fx::ONE - pan) * half).sqrt();
                let gr = ((Fx::ONE + pan) * half).sqrt();
                let contrib = g * atten;
                acc[t * 2] = acc[t * 2] + contrib * gl;
                acc[t * 2 + 1] = acc[t * 2 + 1] + contrib * gr;
            }
        }

        // Quantize Fx → i16, round half away from zero, in integer space.
        let mut out = vec![0i16; n * 2];
        for (o, a) in out.iter_mut().zip(acc) {
            let bias = if a.to_bits() >= 0 { half } else { Fx::ZERO - half };
            let scaled = a * norm + bias;
            let i = scaled.to_bits() / (1i64 << 32); // truncate toward zero
            *o = i.clamp(-32768, 32767) as i16;
        }
        Ok(out)
    }

    /// `BLAKE3(domain || layout || i16le pcm)` — the quantity
    /// mixdown-equivalence is defined over.
    pub fn mixdown_hash(&self, sources: &Sources, layout: &str) -> Result<[u8; 32], AudioError> {
        let pcm = self.mixdown(sources, layout)?;
        let mut h = blake3::Hasher::new();
        h.update(DOMAIN_MIXDOWN);
        h.update(layout.as_bytes());
        for s in &pcm {
            h.update(&s.to_le_bytes());
        }
        Ok(*h.finalize().as_bytes())
    }

    /// Object positions at sample `t` (for a renderer/visualizer):
    /// `[x, y, z]` per object, in listener-relative metres. Display
    /// floats — never used in the mix (the mix stays exact `Fx`).
    pub fn object_positions(&self, t: u64) -> Vec<[f32; 3]> {
        self.objects
            .iter()
            .map(|o| {
                let (x, y, z) = interp_pos(&o.keys, t);
                [x.approx_f64() as f32, y.approx_f64() as f32, z.approx_f64() as f32]
            })
            .collect()
    }

    /// Scene identity: `BLAKE3(domain || contract_bytes || object_table)`.
    pub fn scene_hash(&self) -> [u8; 32] {
        let mut h = blake3::Hasher::new();
        h.update(DOMAIN_SCENE);
        h.update(&self.contract_bytes);
        h.update(&container::object_table_bytes(&self.objects));
        *h.finalize().as_bytes()
    }
}

/// Linear-interpolate the object position at sample `t` (Fx, exact).
fn interp_pos(keys: &[PosKey], t: u64) -> (Fx, Fx, Fx) {
    match keys {
        [] => (Fx::ZERO, Fx::ZERO, Fx::ZERO),
        [k] => (k.x, k.y, k.z),
        _ => {
            if t <= keys[0].pos_sample {
                return (keys[0].x, keys[0].y, keys[0].z);
            }
            let last = keys.last().unwrap();
            if t >= last.pos_sample {
                return (last.x, last.y, last.z);
            }
            // find the segment [a, b] with a.pos <= t < b.pos
            let mut a = &keys[0];
            for k in &keys[1..] {
                if t < k.pos_sample {
                    let span = (k.pos_sample - a.pos_sample) as i32;
                    let into = (t - a.pos_sample) as i32;
                    let frac = Fx::from_int(into) / Fx::from_int(span);
                    let lerp = |p: Fx, q: Fx| p + (q - p) * frac;
                    return (lerp(a.x, k.x), lerp(a.y, k.y), lerp(a.z, k.z));
                }
                a = k;
            }
            (last.x, last.y, last.z)
        }
    }
}

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

    fn tone(n: usize, period: usize, amp: i16) -> Vec<i16> {
        // deterministic square-ish tone (no float): +amp/-amp per half period
        (0..n).map(|i| if (i / period) % 2 == 0 { amp } else { -amp }).collect()
    }

    fn scene(objs: Vec<AudioObject>, dur: u64) -> SpatialScene {
        let contract = container::SceneContract {
            sample_rate: 48000,
            numeric: crate::world::NUMERIC_FIXED64.into(),
            pan_law: PAN_SQRT_CONSTPOWER.into(),
            distance: DIST_INVERSE_CLAMPED.into(),
            duration: dur,
            floor_layout: "stereo".into(),
        };
        SpatialScene {
            contract_bytes: contract.to_canonical_json(),
            contract,
            objects: objs,
            assets: vec![],
            seal: None,
        }
    }

    fn obj(hash: u8, gain: i32, x: i32, z: i32) -> AudioObject {
        AudioObject {
            source_hash: [hash; 32],
            gain: Fx::from_int(gain),
            keys: vec![PosKey { pos_sample: 0, x: Fx::from_int(x), y: Fx::ZERO, z: Fx::from_int(z) }],
        }
    }

    #[test]
    fn centered_source_is_equal_left_right() {
        let mut src = Sources::new();
        src.insert([1u8; 32], tone(64, 8, 10000));
        let s = scene(vec![obj(1, 1, 0, 1)], 64); // x=0 → centered
        let pcm = s.mixdown(&src, "stereo").unwrap();
        for t in 0..64 {
            assert_eq!(pcm[t * 2], pcm[t * 2 + 1], "centered object must pan equally");
        }
    }

    #[test]
    fn hard_left_source_is_silent_on_the_right() {
        let mut src = Sources::new();
        src.insert([2u8; 32], tone(64, 8, 10000));
        // x negative, z=0 → pan = -1 → all left
        let s = scene(vec![obj(2, 1, -5, 0)], 64);
        let pcm = s.mixdown(&src, "stereo").unwrap();
        let right_energy: i64 = (0..64).map(|t| (pcm[t * 2 + 1] as i64).abs()).sum();
        let left_energy: i64 = (0..64).map(|t| (pcm[t * 2] as i64).abs()).sum();
        assert_eq!(right_energy, 0, "hard-left object must be silent on the right");
        assert!(left_energy > 0, "and present on the left");
    }

    #[test]
    fn mixdown_is_deterministic_and_layout_bound() {
        let mut src = Sources::new();
        src.insert([3u8; 32], tone(128, 5, 9000));
        src.insert([4u8; 32], tone(128, 7, 6000));
        let s = scene(vec![obj(3, 1, -3, 2), obj(4, 1, 4, 1)], 128);
        let h1 = s.mixdown_hash(&src, "stereo").unwrap();
        let h2 = s.mixdown_hash(&src, "stereo").unwrap();
        assert_eq!(h1, h2, "mixdown-equivalence: same scene → identical hash");
        // a changed gain → different mix
        let s2 = scene(vec![obj(3, 2, -3, 2), obj(4, 1, 4, 1)], 128);
        assert_ne!(h1, s2.mixdown_hash(&src, "stereo").unwrap());
    }

    #[test]
    fn distance_attenuates() {
        let mut src = Sources::new();
        src.insert([5u8; 32], tone(64, 8, 10000));
        let near: i64 = energy(&scene(vec![obj(5, 1, 0, 1)], 64).mixdown(&src, "stereo").unwrap());
        let far: i64 = energy(&scene(vec![obj(5, 1, 0, 9)], 64).mixdown(&src, "stereo").unwrap());
        assert!(far < near, "a more distant source must be quieter ({far} < {near})");
    }

    #[test]
    fn moving_source_interpolates_and_round_trips_container() {
        let mut src = Sources::new();
        src.insert([6u8; 32], tone(200, 6, 8000));
        let moving = AudioObject {
            source_hash: [6u8; 32],
            gain: Fx::ONE,
            keys: vec![
                PosKey { pos_sample: 0, x: Fx::from_int(-4), y: Fx::ZERO, z: Fx::ONE },
                PosKey { pos_sample: 200, x: Fx::from_int(4), y: Fx::ZERO, z: Fx::ONE },
            ],
        };
        let mut s = scene(vec![moving], 200);
        s.assets.push(AssetEntry { hash: [6u8; 32], role: 0, name: "tone.opus".into() });
        // pan should sweep left→right: early frames louder on left, late on right
        let pcm = s.mixdown(&src, "stereo").unwrap();
        let early_l: i64 = (0..20).map(|t| (pcm[t*2] as i64).abs()).sum();
        let early_r: i64 = (0..20).map(|t| (pcm[t*2+1] as i64).abs()).sum();
        let late_l: i64 = (180..200).map(|t| (pcm[t*2] as i64).abs()).sum();
        let late_r: i64 = (180..200).map(|t| (pcm[t*2+1] as i64).abs()).sum();
        assert!(early_l > early_r, "starts left-weighted");
        assert!(late_r > late_l, "ends right-weighted");
        // container round-trips
        let back = SpatialScene::from_bytes(&s.to_bytes()).unwrap();
        assert_eq!(back.objects, s.objects);
        assert_eq!(back.contract, s.contract);
        assert_eq!(back.scene_hash(), s.scene_hash());
    }

    fn energy(pcm: &[i16]) -> i64 {
        pcm.iter().map(|s| (*s as i64).abs()).sum()
    }
}
