//! Linear-film **reel** — `wai.film.reel`: a film with **cuts**.
//!
//! A [`super::Film`] is a single continuous camera move over a world. A reel
//! is the editor's layer on top: one embedded sealed world, and a sequence of
//! **shots** — each its own camera track + frame schedule — played back to
//! back. Cutting from a wide to a close-up, or showing the same beat from two
//! angles, is the essence of film grammar; a reel carries it as a few extra
//! kilobytes of camera keys, the world (the cargo) shared across every shot.
//!
//! Conformance is **reel-equivalence**, film-equivalence extended over cuts:
//! the global frame `f` belongs to a shot, samples the world at that shot's
//! tick, and is framed by that shot's camera — and its identity
//! `BLAKE3(world_state_hash(tick) || camera_pose)` is bit-identical on every
//! machine. Shots may revisit earlier world ticks (a cut back to a moment from
//! a new angle), so the world is sampled at the *sorted, de-duplicated* union
//! of every shot's frame ticks — the convergence guarantee still holds because
//! the world's replay-equivalence is order-independent.

use std::collections::BTreeMap;

use super::{interpolate_camera, pose_bytes, CamKey, CamPose, FilmReceipt, FrameHash};
use crate::score::Score;
use crate::world::{run_sampled_dispatch, run_sealed_dispatch, Fx, WorldContainer};
use ed25519_dalek::SigningKey;

const MAGIC_WREL: &[u8; 4] = b"WREL";
const SECTION_CONTRACT: u8 = 0x01;
const SECTION_WORLD: u8 = 0x02;
const SECTION_SHOTS: u8 = 0x03;
const SECTION_AUDIO: u8 = 0x04; // OPTIONAL embedded soundtrack (a WSCR score)
const CAMKEY_BYTES: usize = 72;

const DOMAIN_FRAME: &[u8] = b"wai:reel-frame\x01";
const DOMAIN_REEL: &[u8] = b"wai:reel\x01";

#[derive(Debug)]
pub enum ReelError {
    BadMagic,
    Truncated,
    MissingSection(u8),
    BadContract(String),
    BadShots(String),
    BadWorld(String),
    BadAudio(String),
    FrameTickPastSeal { tick: u64, final_tick: u64 },
}

impl std::fmt::Display for ReelError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::BadMagic => write!(f, "payload is not a WREL container"),
            Self::Truncated => write!(f, "byte stream truncated"),
            Self::MissingSection(k) => write!(f, "required section 0x{k:02x} missing"),
            Self::BadContract(why) => write!(f, "bad reel contract: {why}"),
            Self::BadShots(why) => write!(f, "bad shots: {why}"),
            Self::BadWorld(why) => write!(f, "bad embedded world: {why}"),
            Self::BadAudio(why) => write!(f, "bad embedded soundtrack: {why}"),
            Self::FrameTickPastSeal { tick, final_tick } => {
                write!(f, "a frame samples world tick {tick} past the sealed final tick {final_tick}")
            }
        }
    }
}
impl std::error::Error for ReelError {}

/// One shot: a camera track + how many frames it runs and which world ticks
/// they sample (`start_tick + local_frame * ticks_per_frame`).
#[derive(Clone, Debug, PartialEq)]
pub struct Shot {
    pub frames: u64,
    pub start_tick: u64,
    pub ticks_per_frame: u64,
    pub camera: Vec<CamKey>,
}

/// A reel: a numeric profile + an embedded sealed world + a shot list, and an
/// OPTIONAL embedded soundtrack — a `wai.audio.score` (`WSCR`) whose mixdown
/// plays synced to the frames. One envelope, two cargo classes: the moving
/// picture (reel-equivalence) and the music (mixdown-equivalence), both bound
/// by `reel_hash`.
#[derive(Clone, Debug, PartialEq)]
pub struct Reel {
    pub numeric: String,
    pub contract_bytes: Vec<u8>,
    pub world_bytes: Vec<u8>,
    pub shots: Vec<Shot>,
    pub audio_bytes: Option<Vec<u8>>,
}

fn fx(s: &[u8]) -> Fx {
    Fx::from_bits(i64::from_le_bytes(s.try_into().unwrap()))
}

fn camkeys_bytes(keys: &[CamKey]) -> Vec<u8> {
    let mut o = Vec::with_capacity(4 + keys.len() * CAMKEY_BYTES);
    o.extend_from_slice(&(keys.len() as u32).to_le_bytes());
    for k in keys {
        o.extend_from_slice(&k.tick.to_le_bytes());
        for c in k.pos {
            o.extend_from_slice(&c.to_bits().to_le_bytes());
        }
        for c in k.rot {
            o.extend_from_slice(&c.to_bits().to_le_bytes());
        }
        o.extend_from_slice(&k.fov.to_bits().to_le_bytes());
    }
    o
}

/// Shots section: `n_shots u32`, then per shot `frames u64 | start_tick u64 |
/// ticks_per_frame u64 | camkeys(n u32, then 72B each)`.
fn shots_bytes(shots: &[Shot]) -> Vec<u8> {
    let mut o = Vec::new();
    o.extend_from_slice(&(shots.len() as u32).to_le_bytes());
    for s in shots {
        o.extend_from_slice(&s.frames.to_le_bytes());
        o.extend_from_slice(&s.start_tick.to_le_bytes());
        o.extend_from_slice(&s.ticks_per_frame.to_le_bytes());
        o.extend_from_slice(&camkeys_bytes(&s.camera));
    }
    o
}

fn parse_camkeys(b: &[u8], p: &mut usize) -> Result<Vec<CamKey>, ReelError> {
    let rd = |q: usize, n: usize| -> Result<&[u8], ReelError> {
        if q + n > b.len() { Err(ReelError::Truncated) } else { Ok(&b[q..q + n]) }
    };
    let n = u32::from_le_bytes(rd(*p, 4)?.try_into().unwrap()) as usize;
    *p += 4;
    // Reserve no more entries than the bytes left could hold: the count is the input's,
    // and a reservation sized by it alone let a few bytes ask for gigabytes.
    let mut keys = Vec::with_capacity(n.min(b.len().saturating_sub(*p) / CAMKEY_BYTES));
    let mut last: Option<u64> = None;
    for _ in 0..n {
        let tick = u64::from_le_bytes(rd(*p, 8)?.try_into().unwrap());
        if let Some(prev) = last
            && tick <= prev
        {
            return Err(ReelError::BadShots("camera keys must be strictly ascending by tick".into()));
        }
        last = Some(tick);
        let pos = [fx(rd(*p + 8, 8)?), fx(rd(*p + 16, 8)?), fx(rd(*p + 24, 8)?)];
        let rot = [fx(rd(*p + 32, 8)?), fx(rd(*p + 40, 8)?), fx(rd(*p + 48, 8)?), fx(rd(*p + 56, 8)?)];
        let fov = fx(rd(*p + 64, 8)?);
        keys.push(CamKey { tick, pos, rot, fov });
        *p += CAMKEY_BYTES;
    }
    if keys.is_empty() {
        return Err(ReelError::BadShots("a shot must carry at least one camera key".into()));
    }
    Ok(keys)
}

fn parse_shots(b: &[u8]) -> Result<Vec<Shot>, ReelError> {
    if b.len() < 4 {
        return Err(ReelError::Truncated);
    }
    let n = u32::from_le_bytes(b[0..4].try_into().unwrap()) as usize;
    let mut p = 4usize;
    let rd = |q: usize| -> Result<u64, ReelError> {
        if q + 8 > b.len() { Err(ReelError::Truncated) } else { Ok(u64::from_le_bytes(b[q..q + 8].try_into().unwrap())) }
    };
    // Reserve no more entries than the bytes left could hold: the count is the input's,
    // and a reservation sized by it alone let a few bytes ask for gigabytes.
    let mut shots = Vec::with_capacity(n.min(b.len() / 24));
    let mut total = 0u64;
    for _ in 0..n {
        let frames = rd(p)?;
        let start_tick = rd(p + 8)?;
        let ticks_per_frame = rd(p + 16)?;
        if frames == 0 || ticks_per_frame == 0 {
            return Err(ReelError::BadShots("shot frames and ticks_per_frame must be >= 1".into()));
        }
        // The reel renders every shot's frames: their sum is held to the frame limit, and
        // each shot's last tick to what a u64 holds.
        total = total.saturating_add(frames);
        if total > crate::limits::RENDER_MAX_FRAMES {
            return Err(ReelError::BadShots(crate::limits::duration_too_long(
                "frames across its shots", total, crate::limits::RENDER_MAX_FRAMES)));
        }
        if (frames - 1).checked_mul(ticks_per_frame).and_then(|t| t.checked_add(start_tick)).is_none() {
            return Err(ReelError::BadShots("a shot's last tick passes 2^64 - 1".into()));
        }
        p += 24;
        let camera = parse_camkeys(b, &mut p)?;
        shots.push(Shot { frames, start_tick, ticks_per_frame, camera });
    }
    if shots.is_empty() {
        return Err(ReelError::BadShots("a reel must carry at least one shot".into()));
    }
    Ok(shots)
}

impl Reel {
    /// Total frames across all shots.
    pub fn total_frames(&self) -> u64 {
        self.shots.iter().map(|s| s.frames).sum()
    }

    /// Map a global frame to `(shot_index, local_frame)`.
    fn locate(&self, f: u64) -> Option<(usize, u64)> {
        let mut base = 0u64;
        for (i, s) in self.shots.iter().enumerate() {
            if f < base + s.frames {
                return Some((i, f - base));
            }
            base += s.frames;
        }
        None
    }

    /// The world tick global frame `f` samples.
    pub fn frame_tick(&self, f: u64) -> Option<u64> {
        let (i, local) = self.locate(f)?;
        let s = &self.shots[i];
        local.checked_mul(s.ticks_per_frame).and_then(|t| t.checked_add(s.start_tick))
    }

    /// The camera pose at global frame `f` (its shot's track, at `f`'s tick).
    pub fn camera_at_frame(&self, f: u64) -> Option<CamPose> {
        let (i, _) = self.locate(f)?;
        let tick = self.frame_tick(f)?;
        Some(interpolate_camera(&self.shots[i].camera, tick))
    }

    pub fn world(&self) -> Result<WorldContainer, ReelError> {
        WorldContainer::from_bytes(&self.world_bytes).map_err(|e| ReelError::BadWorld(e.to_string()))
    }

    /// Validate the embedded world as a conformant sealed replay; return its
    /// sealed final tick.
    pub fn validate_world(&self) -> Result<u64, ReelError> {
        let world = self.world()?;
        run_sealed_dispatch(&world).map_err(|e| ReelError::BadWorld(e.to_string()))?;
        world.seal.map(|s| s.final_tick).ok_or_else(|| ReelError::BadWorld("embedded world is not sealed".into()))
    }

    /// Per-frame reconstruction-block hashes — the reel's objects. Each is
    /// `BLAKE3(DOMAIN_FRAME || frame_index || world_state_hash(tick) ||
    /// camera_pose)`. The world is sampled at the sorted, de-duplicated union
    /// of all shots' frame ticks (so cuts may revisit ticks).
    pub fn frame_hashes(&self) -> Result<Vec<FrameHash>, ReelError> {
        let final_tick = self.validate_world()?;
        let total = self.total_frames();
        let ticks: Vec<u64> = (0..total).map(|f| self.frame_tick(f).unwrap()).collect();
        if let Some(&max) = ticks.iter().max()
            && max > final_tick
        {
            return Err(ReelError::FrameTickPastSeal { tick: max, final_tick });
        }
        let mut sorted = ticks.clone();
        sorted.sort_unstable();
        sorted.dedup();
        let world = self.world()?;
        let checkpoints =
            run_sampled_dispatch(&world, &sorted).map_err(|e| ReelError::BadWorld(e.to_string()))?;
        let map: BTreeMap<u64, [u8; 32]> = checkpoints.iter().map(|c| (c.tick, c.hash)).collect();
        Ok((0..total)
            .map(|f| {
                let tick = ticks[f as usize];
                let pose = self.camera_at_frame(f).unwrap();
                let mut h = blake3::Hasher::new();
                h.update(DOMAIN_FRAME);
                h.update(&f.to_le_bytes());
                h.update(&map[&tick]);
                h.update(&pose_bytes(&pose));
                FrameHash { index: f, hash: *h.finalize().as_bytes() }
            })
            .collect())
    }

    /// Reel identity: `BLAKE3(DOMAIN_REEL || contract || world || shots ||
    /// audio?)` — a presence byte then the soundtrack bytes when present, so a
    /// silent reel and an empty-soundtrack reel never collide.
    pub fn reel_hash(&self) -> [u8; 32] {
        let mut h = blake3::Hasher::new();
        h.update(DOMAIN_REEL);
        h.update(&self.contract_bytes);
        h.update(&self.world_bytes);
        h.update(&shots_bytes(&self.shots));
        match &self.audio_bytes {
            Some(a) => {
                h.update(&[1]);
                h.update(a);
            }
            None => {
                h.update(&[0]);
            }
        }
        *h.finalize().as_bytes()
    }

    /// The embedded soundtrack as a [`Score`], if present. The audio is
    /// verified by its own mixdown-equivalence (`mixdown_hash`), bound into the
    /// reel by `reel_hash`.
    pub fn audio_score(&self) -> Option<Result<Score, ReelError>> {
        self.audio_bytes
            .as_ref()
            .map(|b| Score::from_bytes(b).map_err(|e| ReelError::BadAudio(e.to_string())))
    }

    /// Audio samples per frame for playback sync: the soundtrack's total
    /// samples spread over the reel's frames (presentation timing — the
    /// verified quantities are the per-frame hashes and the mixdown hash).
    pub fn samples_per_frame(&self) -> Option<u64> {
        let s = self.audio_score()?.ok()?;
        Some(s.contract.duration.checked_div(self.total_frames()).unwrap_or(0))
    }

    /// Build a receipt over the reel's per-frame blocks (the [`FilmReceipt`]
    /// profile, identity = `reel_hash`). `joules_micro` is the meter's.
    pub fn receipt(
        &self,
        signer: &SigningKey,
        signer_id: impl Into<String>,
        group_id: u64,
        joules_micro: u64,
        parent_receipt_hash: Option<[u8; 32]>,
    ) -> Result<FilmReceipt, ReelError> {
        let frames = self.frame_hashes()?;
        let work = frames.len() as u64;
        Ok(FilmReceipt::build(signer, signer_id, self.reel_hash(), group_id, frames, work, joules_micro, parent_receipt_hash))
    }

    pub fn to_bytes(&self) -> Vec<u8> {
        let shots = shots_bytes(&self.shots);
        let mut sections: Vec<(u8, &[u8])> = vec![
            (SECTION_CONTRACT, &self.contract_bytes[..]),
            (SECTION_WORLD, &self.world_bytes[..]),
            (SECTION_SHOTS, &shots[..]),
        ];
        if let Some(a) = &self.audio_bytes {
            sections.push((SECTION_AUDIO, &a[..]));
        }
        let mut out = Vec::new();
        out.extend_from_slice(MAGIC_WREL);
        out.extend_from_slice(&(sections.len() as u16).to_le_bytes());
        let mut off = 0u32;
        let mut offs = Vec::new();
        for (_, b) in &sections {
            offs.push(off);
            off += b.len().div_ceil(8) as u32 * 8;
        }
        for ((kind, b), o) in sections.iter().zip(&offs) {
            out.push(*kind);
            out.extend_from_slice(&o.to_le_bytes());
            out.extend_from_slice(&(b.len() as u32).to_le_bytes());
        }
        for (_, b) in &sections {
            out.extend_from_slice(b);
            out.extend_from_slice(&[0u8; 8][..b.len().div_ceil(8) * 8 - b.len()]);
        }
        out
    }

    pub fn from_bytes(bytes: &[u8]) -> Result<Reel, ReelError> {
        if bytes.len() < 6 {
            return Err(ReelError::Truncated);
        }
        if &bytes[..4] != MAGIC_WREL {
            return Err(ReelError::BadMagic);
        }
        let n = u16::from_le_bytes(bytes[4..6].try_into().unwrap()) as usize;
        let table_end = 6 + n * 9;
        if bytes.len() < table_end {
            return Err(ReelError::Truncated);
        }
        let pay = &bytes[table_end..];
        let (mut contract_bytes, mut world_bytes, mut shots_b, mut audio_bytes) = (None, None, None, None);
        for i in 0..n {
            let e = &bytes[6 + i * 9..6 + (i + 1) * 9];
            let off = u32::from_le_bytes(e[1..5].try_into().unwrap()) as usize;
            let len = u32::from_le_bytes(e[5..9].try_into().unwrap()) as usize;
            let end = off.checked_add(len).ok_or(ReelError::Truncated)?;
            if end > pay.len() {
                return Err(ReelError::Truncated);
            }
            match e[0] {
                SECTION_CONTRACT => contract_bytes = Some(pay[off..end].to_vec()),
                SECTION_WORLD => world_bytes = Some(pay[off..end].to_vec()),
                SECTION_SHOTS => shots_b = Some(&pay[off..end]),
                SECTION_AUDIO => audio_bytes = Some(pay[off..end].to_vec()),
                _ => {}
            }
        }
        let contract_bytes = contract_bytes.ok_or(ReelError::MissingSection(SECTION_CONTRACT))?;
        let world_bytes = world_bytes.ok_or(ReelError::MissingSection(SECTION_WORLD))?;
        let shots_b = shots_b.ok_or(ReelError::MissingSection(SECTION_SHOTS))?;
        let v: serde_json::Value =
            serde_json::from_slice(&contract_bytes).map_err(|e| ReelError::BadContract(e.to_string()))?;
        let numeric = v
            .get("numeric")
            .and_then(|x| x.as_str())
            .map(str::to_owned)
            .ok_or_else(|| ReelError::BadContract("missing `numeric`".into()))?;
        let shots = parse_shots(shots_b)?;
        Ok(Reel { numeric, contract_bytes, world_bytes, shots, audio_bytes })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::world::sim::{kin_mutation, pack_fx, KinNode, CAP_SIM_KINEMATIC0};
    use crate::world::{
        mint_node_id, CausalOp, Kinematic0, LiveWorld, SimContract, WorldContainer, WorldState,
        NUMERIC_FIXED64, RESOLVE_LWW,
    };

    fn key(s: u8) -> SigningKey {
        SigningKey::from_bytes(&[s; 32])
    }

    fn sealed_world() -> Vec<u8> {
        let c = SimContract {
            tick_rate: 60,
            numeric: NUMERIC_FIXED64.into(),
            rng_alg: "xoshiro256++".into(),
            rng_seed: [0x42; 32],
            sim: CAP_SIM_KINEMATIC0.into(),
            resolve_default: RESOLVE_LWW.into(),
            checkpoint_every: 100,
        };
        let mut container = WorldContainer {
            contract_bytes: c.to_canonical_json(),
            contract: c,
            graph_bytes: WorldState::<KinNode>::new().canonical_bytes(&Kinematic0),
            assets: vec![],
            ops: vec![],
            seal: None,
        };
        let a = key(1);
        let create = CausalOp::sign(
            &a, 1, 1, vec![], mint_node_id(&a.verifying_key().to_bytes(), 1), kin_mutation::CREATE,
            pack_fx(&[Fx::ZERO, Fx::ZERO, Fx::ZERO, Fx::ONE, Fx::ZERO, Fx::ZERO, Fx::ZERO]),
        );
        let mut live = LiveWorld::open(Kinematic0, container.clone()).unwrap();
        live.ingest(create).unwrap();
        let final_hash = live.advance_to(24).unwrap();
        container.ops = live.canonical_ops();
        container.seal(&key(3), 24, final_hash);
        container.to_bytes()
    }

    fn ck(tick: u64, x: i32) -> CamKey {
        CamKey { tick, pos: [Fx::from_int(x), Fx::from_int(2), Fx::ZERO], rot: [Fx::ZERO, Fx::ZERO, Fx::ZERO, Fx::ONE], fov: Fx::from_int(1) / Fx::from_int(2) }
    }

    fn reel() -> Reel {
        // two shots over one world: a wide (ticks 0..12) then a cut to a closer
        // angle revisiting ticks 6..18.
        let contract = b"{\"numeric\":\"wai.det.fixed64\",\"shots\":2}".to_vec();
        Reel {
            numeric: NUMERIC_FIXED64.into(),
            contract_bytes: contract,
            world_bytes: sealed_world(),
            shots: vec![
                Shot { frames: 4, start_tick: 0, ticks_per_frame: 3, camera: vec![ck(0, -6), ck(12, -2)] },
                Shot { frames: 4, start_tick: 6, ticks_per_frame: 3, camera: vec![ck(6, 4), ck(18, 1)] },
            ],
            audio_bytes: None,
        }
    }

    fn score_bytes() -> Vec<u8> {
        use crate::score::{waveform, Note, Score, ScoreContract};
        let sr = 22050u32;
        let inc = |f: f64| Fx::from_bits(((f / sr as f64) * 4294967296.0).round() as i64);
        let note = |start: u64, freq: f64| Note {
            start, dur: 4000, phase_inc: inc(freq), gain: Fx::from_int(1) / Fx::from_int(3),
            waveform: waveform::TRIANGLE, attack: 200, decay: 400,
            sustain: Fx::from_int(6) / Fx::from_int(10), release: 800,
        };
        let c = ScoreContract { sample_rate: sr, numeric: NUMERIC_FIXED64.into(), channels: 1, duration: 16000 };
        Score { contract_bytes: c.to_canonical_json(), contract: c, notes: vec![note(0, 440.0), note(6000, 660.0)] }.to_bytes()
    }

    #[test]
    fn embedded_soundtrack_binds_and_decodes() {
        let mut r = reel();
        r.audio_bytes = Some(score_bytes());
        // the audio decodes as a score and the per-frame visual hashes are
        // unchanged by adding sound (audio is verified separately)
        let s = r.audio_score().unwrap().unwrap();
        assert_eq!(s.contract.sample_rate, 22050);
        assert!(!s.mixdown().is_empty());
        assert_eq!(r.samples_per_frame(), Some(16000 / 8));
        // reel_hash binds the soundtrack: changing it changes the identity
        let silent = reel();
        assert_ne!(r.reel_hash(), silent.reel_hash());
        // round-trips with the audio section
        let back = Reel::from_bytes(&r.to_bytes()).unwrap();
        assert_eq!(back, r);
        assert_eq!(back.audio_score().unwrap().unwrap().mixdown_hash(), s.mixdown_hash());
    }

    #[test]
    fn frames_and_ticks_map_across_cuts() {
        let r = reel();
        assert_eq!(r.total_frames(), 8);
        // shot 0: frames 0..3 → ticks 0,3,6,9
        assert_eq!(r.frame_tick(0), Some(0));
        assert_eq!(r.frame_tick(3), Some(9));
        // shot 1: frames 4..7 → ticks 6,9,12,15 (revisits earlier ticks)
        assert_eq!(r.frame_tick(4), Some(6));
        assert_eq!(r.frame_tick(7), Some(15));
        assert_eq!(r.frame_tick(8), None);
    }

    #[test]
    fn frames_reconstruct_and_cut_changes_the_hash() {
        let r = reel();
        let fh = r.frame_hashes().expect("frame hashes");
        assert_eq!(fh.len(), 8);
        assert_eq!(r.frame_hashes().unwrap(), fh); // deterministic
        // frame 1 (shot 0, tick 3) and frame 5 (shot 1, tick 9) differ
        assert_ne!(fh[1].hash, fh[5].hash);
    }

    #[test]
    fn reel_hash_binds_shots_and_receipt_verifies() {
        let a = reel();
        let mut b = reel();
        b.shots[1].camera[1] = ck(18, 2); // change the second shot's last key
        assert_ne!(a.reel_hash(), b.reel_hash());
        let r = a.receipt(&key(9), "did:web:wai#reel", 0, 1234, None).unwrap();
        assert!(r.verify());
    }

    #[test]
    fn frame_past_seal_is_rejected() {
        let mut r = reel();
        r.shots[1].frames = 8; // shot1 frame 7 → tick 6+7*3 = 27 > sealed 24
        assert!(matches!(r.frame_hashes(), Err(ReelError::FrameTickPastSeal { .. })));
    }

    #[test]
    fn container_round_trips() {
        let r = reel();
        let bytes = r.to_bytes();
        let back = Reel::from_bytes(&bytes).unwrap();
        assert_eq!(back, r);
        assert_eq!(back.reel_hash(), r.reel_hash());
        assert_eq!(back.frame_hashes().unwrap(), r.frame_hashes().unwrap());
    }
}
