//! Scene carriage (`extensions/scene-carriage.md`): WAI worlds, audio scenes, their assets and
//! timed media mapped onto ISO/IEC 23090-14 scene description — a glTF 2.0 document with the
//! MPEG extensions — and onto a USD layer in the text format of the USD Core Specification
//! v1.0.1, in both directions.
//!
//! - A [`Bundle`] is the WAI side (§2): at most one world (`WWLD`), at most one audio scene
//!   (`WSCN`), and a list of timed media items, each a WAI object named by its content hash.
//! - [`gltf::export`] and [`usda::export`] write a bundle as a document, byte for byte (§4, §6):
//!   what a player of that format presents, and the bundle's own bytes carried exactly.
//! - [`gltf::import`] and [`usda::import`] read a document back: one WAI wrote, to the same
//!   bundle, refused unless re-exporting it gives the document's bytes; one WAI did not
//!   write, to a new bundle under the rules of §5 and §7, refused where a value would not
//!   carry exactly.
//!
//! The corpus is `live-conformance/scene/` ([`corpus`]); its second implementation is
//! `live-conformance/scene_verify.py`.

pub mod corpus;
pub mod gltf;
pub mod num;
pub mod usda;

use crate::world::fixed::Fx;

/// The mapping's profile identifier, carried in every document it writes (§2.3).
pub const PROFILE: &str = "wai-scene/1";
/// The URI scheme prefix that names a WAI object by its content hash (§2.2).
pub const ASSET_URN: &str = "urn:wai:asset:";
/// The media type of a WAI envelope (http-delivery §3).
pub const MEDIA_TYPE: &str = "application/wai";

/// Every refusal code of §8, in its table's order.
pub const CODES: &[&str] = &[
    "world_malformed",
    "audio_malformed",
    "media_malformed",
    "not_presentable",
    "document_malformed",
    "carried_malformed",
    "presentation_mismatch",
    "usd_not_read",
    "transform_not_carried",
    "position_not_exact",
    "animation_not_carried",
    "time_not_exact",
    "gain_not_exact",
    "audio_not_object",
    "media_not_wai",
    "asset_not_wai",
    "contract_absent",
    "sim_not_importable",
];

/// Why a bundle or a document was refused: a code of §8 and the particulars.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Refusal {
    pub code: &'static str,
    pub why: String,
}

impl Refusal {
    pub fn new(code: &'static str, why: impl Into<String>) -> Self {
        debug_assert!(CODES.contains(&code), "{code} is not a §8 code");
        Refusal { code, why: why.into() }
    }
}

impl std::fmt::Display for Refusal {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}: {}", self.code, self.why)
    }
}

impl std::error::Error for Refusal {}

pub(crate) fn refuse<T>(code: &'static str, why: impl Into<String>) -> Result<T, Refusal> {
    Err(Refusal::new(code, why))
}

/// A timed media item (§2.2): a WAI object by its content hash, its capability, a name, and,
/// for a picture or a video, its width and height.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MediaItem {
    pub name: String,
    pub asset: [u8; 32],
    pub capability: String,
    pub size: Option<(u32, u32)>,
}

/// The WAI side of the mapping (§2.1).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Bundle {
    /// A `WWLD` payload.
    pub world: Option<Vec<u8>>,
    /// A `WSCN` payload.
    pub audio: Option<Vec<u8>>,
    pub media: Vec<MediaItem>,
}

/// What an import of a document WAI did not write reports beside the bundle: the
/// presentation it did not carry (§5.3, §7.2).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Imported {
    pub bundle: Bundle,
    pub dropped: Vec<String>,
}

/// What a document WAI did not write needs from its reader to become WAI bytes (§5.3): the
/// key that mints its node ids and the contracts of the world and the audio scene it yields.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ImportParams {
    pub actor: [u8; 32],
    pub world_contract: Option<Vec<u8>>,
    pub audio_contract: Option<Vec<u8>>,
}

pub(crate) fn hex(b: &[u8]) -> String {
    b.iter().map(|x| format!("{x:02x}")).collect()
}

pub(crate) fn unhex(s: &str) -> Option<Vec<u8>> {
    if !s.len().is_multiple_of(2) || !s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) {
        return None;
    }
    (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect()
}

/// `urn:wai:asset:<64 lowercase hexadecimal digits>` as a content hash.
pub(crate) fn parse_urn(s: &str) -> Option<[u8; 32]> {
    let h = s.strip_prefix(ASSET_URN)?;
    if h.len() != 64 {
        return None;
    }
    unhex(h)?.try_into().ok()
}

/// A capability a media item may name (§2.2): `wai.video.`, `wai.audio.` or `wai.image.`, then
/// the capability grammar of http-delivery §4.1.
pub(crate) fn media_capability_ok(c: &str) -> bool {
    let Some(rest) = c.strip_prefix("wai.") else { return false };
    let kind_ok = rest.starts_with("video.") || rest.starts_with("audio.") || rest.starts_with("image.");
    let mut segs = rest.split('.');
    let first = segs.next().unwrap_or("");
    kind_ok
        && !first.is_empty()
        && first.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
        && rest.contains('.')
        && segs.all(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_'))
}

/// Whether a capability names pictures (a width and a height are due).
pub(crate) fn is_picture(c: &str) -> bool {
    c.starts_with("wai.video.") || c.starts_with("wai.image.")
}

/// §2.2: a media item's rules.
pub(crate) fn check_media(m: &MediaItem) -> Result<(), Refusal> {
    if !media_capability_ok(&m.capability) {
        return refuse("media_malformed", format!("{:?}: capability {:?}", m.name, m.capability));
    }
    match (is_picture(&m.capability), m.size) {
        (true, Some((w, h))) if (1..=65535).contains(&w) && (1..=65535).contains(&h) => Ok(()),
        (false, None) => Ok(()),
        _ => refuse("media_malformed", format!("{:?}: a width and height of 1 to 65535 for pictures and video, none for audio", m.name)),
    }
}

// ---------------------------------------------------------------------------------------
// The parts of WWLD and WSCN the mapping reads (§2.1)
// ---------------------------------------------------------------------------------------

/// A section-table container (`WWLD`, `WSCN`): its sections by kind. Unknown kinds are kept
/// and ignored; a section out of bounds or a kind twice is refused.
pub(crate) fn sections(b: &[u8], magic: &[u8; 4], code: &'static str) -> Result<std::collections::BTreeMap<u8, Vec<u8>>, Refusal> {
    if b.len() < 6 || &b[..4] != magic {
        return refuse(code, "no magic");
    }
    let n = u16::from_le_bytes([b[4], b[5]]) as usize;
    let base = 6 + 9 * n;
    if base > b.len() {
        return refuse(code, "the section table runs past the end");
    }
    let mut out = std::collections::BTreeMap::new();
    for i in 0..n {
        let e = &b[6 + 9 * i..6 + 9 * (i + 1)];
        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 = base.checked_add(off).and_then(|s| s.checked_add(len)).filter(|&x| x <= b.len());
        let Some(end) = end else { return refuse(code, format!("section {} runs past the end", e[0])) };
        if out.insert(e[0], b[base + off..end].to_vec()).is_some() {
            return refuse(code, format!("section {} twice", e[0]));
        }
    }
    Ok(out)
}

/// One node of a world's tick-0 graph: its id and its state bytes.
pub(crate) struct GraphNode {
    pub id: [u8; 16],
    pub state: Vec<u8>,
}

/// The world as the mapping reads it (§2.1).
pub(crate) struct WorldView {
    pub sim: String,
    pub nodes: Vec<GraphNode>,
    pub assets: Vec<AssetEntryV>,
}

pub(crate) fn read_world(b: &[u8]) -> Result<WorldView, Refusal> {
    let s = sections(b, b"WWLD", "world_malformed")?;
    let (Some(contract), Some(graph)) = (s.get(&1), s.get(&2)) else {
        return refuse("world_malformed", "no contract or graph section");
    };
    let c = crate::strict_json::read(contract).map_err(|e| Refusal::new("world_malformed", format!("contract: {e}")))?;
    let Some(sim) = c.get("sim").and_then(|v| v.as_str()).map(str::to_string) else {
        return refuse("world_malformed", "the contract names no sim");
    };
    if graph.len() < 4 {
        return refuse("world_malformed", "the graph is truncated");
    }
    let n = u32::from_le_bytes(graph[..4].try_into().unwrap()) as usize;
    let mut at = 4usize;
    let mut nodes: Vec<GraphNode> = Vec::new();
    for _ in 0..n {
        if at + 20 > graph.len() {
            return refuse("world_malformed", "the graph is truncated");
        }
        let id: [u8; 16] = graph[at..at + 16].try_into().unwrap();
        let len = u32::from_le_bytes(graph[at + 16..at + 20].try_into().unwrap()) as usize;
        at += 20;
        if at + len > graph.len() {
            return refuse("world_malformed", "the graph is truncated");
        }
        if nodes.last().is_some_and(|p| p.id >= id) {
            return refuse("world_malformed", "the graph's nodes are not in ascending id order");
        }
        nodes.push(GraphNode { id, state: graph[at..at + len].to_vec() });
        at += len;
    }
    if at != graph.len() {
        return refuse("world_malformed", "bytes follow the graph's last node");
    }
    let assets = match s.get(&3) {
        Some(a) => read_assets(a, "world_malformed")?,
        None => Vec::new(),
    };
    for nd in &nodes {
        position(&sim, &nd.state)?;
    }
    Ok(WorldView { sim, nodes, assets })
}

/// The asset table of interactive-worlds §4.1.
pub(crate) fn read_assets(a: &[u8], code: &'static str) -> Result<Vec<AssetEntryV>, Refusal> {
    if a.len() < 4 {
        return refuse(code, "the asset table is truncated");
    }
    let n = u32::from_le_bytes(a[..4].try_into().unwrap()) as usize;
    let mut at = 4usize;
    let mut out = Vec::new();
    for _ in 0..n {
        if at + 37 > a.len() {
            return refuse(code, "the asset table is truncated");
        }
        let hash = a[at..at + 32].to_vec();
        let role = a[at + 32];
        let nl = u32::from_le_bytes(a[at + 33..at + 37].try_into().unwrap()) as usize;
        at += 37;
        if at + nl > a.len() {
            return refuse(code, "the asset table is truncated");
        }
        let name = String::from_utf8(a[at..at + nl].to_vec()).map_err(|_| Refusal::new(code, "an asset name that is not UTF-8"))?;
        at += nl;
        out.push((hash, role, name));
    }
    if at != a.len() {
        return refuse(code, "bytes follow the asset table");
    }
    Ok(out)
}

/// A node's position (§3.4), for the sims whose state begins with one; `None` for the others.
pub(crate) fn position(sim: &str, state: &[u8]) -> Result<Option<[i64; 3]>, Refusal> {
    let take = |b: &[u8]| -> Result<[i64; 3], Refusal> {
        if b.len() < 24 {
            return refuse("world_malformed", "a node state shorter than its position");
        }
        Ok([0, 8, 16].map(|o| i64::from_le_bytes(b[o..o + 8].try_into().unwrap())))
    };
    match sim {
        "wai.world.sim.kinematic0" | "wai.world.sim.ballistic0" => take(state).map(Some),
        "wai.world.sim.truss0" => match state.first() {
            Some(0) => take(&state[1..]).map(Some),
            Some(_) => Ok(None),
            None => refuse("world_malformed", "an empty truss node"),
        },
        _ => Ok(None),
    }
}

/// One key of an audio object: its sample and position.
pub(crate) struct Key {
    pub sample: u64,
    pub pos: [i64; 3],
}

/// An audio object as the mapping reads it.
pub(crate) struct ObjectView {
    pub source: [u8; 32],
    pub keys: Vec<Key>,
}

/// The audio scene as the mapping reads it (§2.1).
pub(crate) struct AudioView {
    pub sample_rate: u64,
    pub duration: u64,
    pub objects: Vec<ObjectView>,
}

pub(crate) fn read_audio(b: &[u8]) -> Result<AudioView, Refusal> {
    let s = sections(b, b"WSCN", "audio_malformed")?;
    let (Some(contract), Some(objs)) = (s.get(&1), s.get(&2)) else {
        return refuse("audio_malformed", "no contract or object table");
    };
    let c = crate::strict_json::read(contract).map_err(|e| Refusal::new("audio_malformed", format!("contract: {e}")))?;
    let rate = c.get("sample_rate").and_then(|v| v.as_u64()).filter(|&r| (1..=u64::from(u32::MAX)).contains(&r));
    let dur = c.get("duration").and_then(|v| v.as_u64());
    let (Some(sample_rate), Some(duration)) = (rate, dur) else {
        return refuse("audio_malformed", "the contract lacks a sample_rate of 1 to 2^32 − 1 or a duration");
    };
    let rd = |at: usize, n: usize| -> Result<&[u8], Refusal> {
        objs.get(at..at + n).ok_or_else(|| Refusal::new("audio_malformed", "the object table is truncated"))
    };
    let n = u32::from_le_bytes(rd(0, 4)?.try_into().unwrap()) as usize;
    let mut at = 4usize;
    let mut objects = Vec::new();
    for _ in 0..n {
        let source: [u8; 32] = rd(at, 32)?.try_into().unwrap();
        let nk = u16::from_le_bytes(rd(at + 40, 2)?.try_into().unwrap()) as usize;
        at += 42;
        let mut keys = Vec::with_capacity(nk);
        for _ in 0..nk {
            let k = rd(at, 32)?;
            let sample = u64::from_le_bytes(k[..8].try_into().unwrap());
            let pos = [8, 16, 24].map(|o| i64::from_le_bytes(k[o..o + 8].try_into().unwrap()));
            keys.push(Key { sample, pos });
            at += 32;
        }
        objects.push(ObjectView { source, keys });
    }
    if at != objs.len() {
        return refuse("audio_malformed", "bytes follow the object table");
    }
    if let Some(a) = s.get(&3) {
        read_assets(a, "audio_malformed")?;
    }
    if let Some(seal) = s.get(&4)
        && seal.len() != 104
    {
        return refuse("audio_malformed", "a seal that is not 104 bytes");
    }
    Ok(AudioView { sample_rate, duration, objects })
}

/// The canonical JSON text of a bundle's media list (§2.3): each item an object of `name`,
/// `asset`, `capability`, and for pictures `width` and `height`, in that order.
pub(crate) fn media_json(media: &[MediaItem]) -> String {
    let items: Vec<String> = media
        .iter()
        .map(|m| {
            let mut s = format!(
                "{{\"name\":{},\"asset\":\"{}\",\"capability\":{}",
                crate::strict_json::quote(&m.name),
                hex(&m.asset),
                crate::strict_json::quote(&m.capability)
            );
            if let Some((w, h)) = m.size {
                s += &format!(",\"width\":{w},\"height\":{h}");
            }
            s + "}"
        })
        .collect();
    format!("[{}]", items.join(","))
}

/// Read a media list written as [`media_json`] writes it, in the JSON value `v`.
pub(crate) fn media_from_json(v: &crate::strict_json::Json) -> Option<Vec<MediaItem>> {
    let mut out = Vec::new();
    for it in v.as_arr()? {
        let m = it.as_obj()?;
        let keys: Vec<&str> = m.iter().map(|(k, _)| k.as_str()).collect();
        let pic = keys.len() == 5;
        if keys != ["name", "asset", "capability"] && keys != ["name", "asset", "capability", "width", "height"] {
            return None;
        }
        let name = it.get("name")?.as_str()?.to_string();
        let asset: [u8; 32] = unhex(it.get("asset")?.as_str()?)?.try_into().ok()?;
        let capability = it.get("capability")?.as_str()?.to_string();
        let size = if pic {
            Some((u32::try_from(it.get("width")?.as_u64()?).ok()?, u32::try_from(it.get("height")?.as_u64()?).ok()?))
        } else {
            None
        };
        out.push(MediaItem { name, asset, capability, size });
    }
    Some(out)
}

/// §2.1 over a whole bundle, before anything is written.
pub(crate) fn check_bundle(b: &Bundle) -> Result<(Option<WorldView>, Option<AudioView>), Refusal> {
    let w = b.world.as_deref().map(read_world).transpose()?;
    let a = b.audio.as_deref().map(read_audio).transpose()?;
    for m in &b.media {
        check_media(m)?;
    }
    Ok((w, a))
}

/// An audio object as an import builds it: its source and its keys `(sample, position)`.
pub(crate) type ObjectKeys = ([u8; 32], Vec<(u64, [i64; 3])>);
/// An asset table entry: hash, role, name.
pub(crate) type AssetEntryV = (Vec<u8>, u8, String);

/// The Q32.32 sum of two positions, or `None` past the range.
pub(crate) fn add(a: [i64; 3], b: [i64; 3]) -> Option<[i64; 3]> {
    Some([a[0].checked_add(b[0])?, a[1].checked_add(b[1])?, a[2].checked_add(b[2])?])
}

/// The bytes of a kinematic0 node at `pos`, at rest, scalar 0 (§5.3).
pub(crate) fn kinematic_state(pos: [i64; 3]) -> Vec<u8> {
    let mut v = Vec::with_capacity(56);
    for x in pos.into_iter().chain([0, 0, 0, 0]) {
        v.extend_from_slice(&x.to_le_bytes());
    }
    v
}

/// A `WWLD` payload of a contract and tick-0 nodes, no assets, an empty op log (§5.3).
pub(crate) fn world_bytes(contract: &[u8], nodes: &mut [([u8; 16], Vec<u8>)], assets: &[AssetEntryV]) -> Vec<u8> {
    nodes.sort_by_key(|a| a.0);
    let mut graph = (nodes.len() as u32).to_le_bytes().to_vec();
    for (id, st) in nodes.iter() {
        graph.extend_from_slice(id);
        graph.extend_from_slice(&(st.len() as u32).to_le_bytes());
        graph.extend_from_slice(st);
    }
    let mut secs: Vec<(u8, Vec<u8>)> = vec![(1, contract.to_vec()), (2, graph)];
    if !assets.is_empty() {
        secs.push((3, asset_table(assets)));
    }
    secs.push((4, Vec::new()));
    container(b"WWLD", &secs)
}

pub(crate) fn asset_table(assets: &[AssetEntryV]) -> Vec<u8> {
    let mut a = (assets.len() as u32).to_le_bytes().to_vec();
    for (h, r, n) in assets {
        a.extend_from_slice(h);
        a.push(*r);
        a.extend_from_slice(&(n.len() as u32).to_le_bytes());
        a.extend_from_slice(n.as_bytes());
    }
    a
}

/// A section-table container as interactive-worlds §4 lays it out.
pub(crate) fn container(magic: &[u8; 4], secs: &[(u8, Vec<u8>)]) -> Vec<u8> {
    let mut out = magic.to_vec();
    out.extend_from_slice(&(secs.len() as u16).to_le_bytes());
    let mut off = 0u32;
    for (k, body) in secs {
        out.push(*k);
        out.extend_from_slice(&off.to_le_bytes());
        out.extend_from_slice(&(body.len() as u32).to_le_bytes());
        off += body.len().div_ceil(8) as u32 * 8;
    }
    for (_, body) in secs {
        out.extend_from_slice(body);
        out.extend(std::iter::repeat_n(0u8, body.len().div_ceil(8) * 8 - body.len()));
    }
    out
}

/// A `WSCN` payload of a contract, objects of gain 1 and assets (§5.3).
pub(crate) fn audio_bytes(contract: &[u8], objects: &[ObjectKeys], assets: &[AssetEntryV]) -> Vec<u8> {
    let mut t = (objects.len() as u32).to_le_bytes().to_vec();
    for (src, keys) in objects {
        t.extend_from_slice(src);
        t.extend_from_slice(&Fx::ONE.to_bits().to_le_bytes());
        t.extend_from_slice(&(keys.len() as u16).to_le_bytes());
        for (s, p) in keys {
            t.extend_from_slice(&s.to_le_bytes());
            for x in p {
                t.extend_from_slice(&x.to_le_bytes());
            }
        }
    }
    let mut secs: Vec<(u8, Vec<u8>)> = vec![(1, contract.to_vec()), (2, t)];
    if !assets.is_empty() {
        secs.push((3, asset_table(assets)));
    }
    container(b"WSCN", &secs)
}

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

    #[test]
    fn media_capabilities_follow_the_grammar() {
        for ok in ["wai.video.av1", "wai.audio.flac", "wai.image.png", "wai.video.int_motion"] {
            assert!(media_capability_ok(ok), "{ok}");
        }
        for bad in ["wai.world.scene", "video.av1", "wai.video.", "wai.video.AV1", "wai.video..x", "wai.audio"] {
            assert!(!media_capability_ok(bad), "{bad}");
        }
        assert_eq!(parse_urn(&format!("{ASSET_URN}{}", "ab".repeat(32))), Some([0xab; 32]));
        assert_eq!(parse_urn(&format!("{ASSET_URN}{}", "AB".repeat(32))), None);
    }

    #[test]
    fn the_containers_this_module_writes_are_the_ones_wai_rs_reads() {
        let contract = br#"{"checkpoint_every":600,"numeric":"wai.det.fixed64","resolve":{"default":"wai.world.resolve.lww"},"rng":{"alg":"xoshiro256++","seed":"0000000000000000000000000000000000000000000000000000000000000000"},"sim":"wai.world.sim.kinematic0","tick_rate":60}"#;
        let mut nodes = vec![([2u8; 16], kinematic_state([1, 2, 3])), ([1u8; 16], kinematic_state([4, 5, 6]))];
        let w = world_bytes(contract, &mut nodes, &[(vec![7; 32], 0, "cube.glb".into())]);
        let c = crate::world::WorldContainer::from_bytes(&w).unwrap();
        assert_eq!(c.to_bytes(), w, "the world container wai-rs writes from it is the same bytes");
        let ac = br#"{"distance":"wai.audio.dist.inverse_clamped","duration":256,"floor_layout":"stereo","numeric":"wai.det.fixed64","pan_law":"wai.audio.pan.sqrt_constpower","sample_rate":48000}"#;
        let a = audio_bytes(ac, &[([9; 32], vec![(0, [1, 2, 3]), (100, [4, 5, 6])])], &[(vec![9; 32], 0, "tone".into())]);
        let s = crate::audio_scene::SpatialScene::from_bytes(&a).unwrap();
        assert_eq!(s.to_bytes(), a);
    }
}
