//! `wai score` — write a `wai.audio.score` (`WSCR`) from integer note fields, and render
//! one with the reference synth, printing the digests a sink checks its own mixdown
//! against ([score](../../../../extensions/score.md) §4). A build with the feature
//! `score`.
//!
//! ```text
//! wai score build  <notes.json> -o <out.wscr> [--json]
//! wai score render <in.wscr> [--pcm <out.s16>] [--json]
//! ```
//!
//! `build` reads the authoring form (score §2.3): the contract and every note as
//! integers, `phase_inc`, `gain` and `sustain` as raw Q32.32 bits. No floating-point
//! value is read: a number with a fraction or an exponent is refused, so a note's
//! pitch is whatever integer the author committed, on every machine. It writes the
//! container with the reference writer (`Score::to_bytes`).
//!
//! `render` reconstructs the canonical mono `i16` mixdown and prints `samples`,
//! `sample_rate`, `channels`, `notes`, `pcm_sha256` (SHA-256 over the samples as
//! signed 16-bit little-endian integers, SPEC §7 *Digests*), `mixdown_blake3` (the
//! extension's mixdown hash) and `score_hash`. `--pcm` writes those samples.

use super::opt;
use serde_json::{Map, Value};
use wai::score::{Note, Score, ScoreContract};
use wai::world::{Fx, NUMERIC_FIXED64};

const USAGE: &str = "usage:
  wai score build  <notes.json> -o <out.wscr> [--json]
  wai score render <in.wscr> [--pcm <out.s16>] [--json]";

/// The longest mixdown this tool renders, in samples (2^26: about 50 minutes at
/// 22 050 Hz). The reference synth holds the whole mix in memory, eight bytes a
/// sample, so a longer contract is refused before anything is allocated.
pub const MAX_SAMPLES: u64 = 1 << 26;

const NOTE_MEMBERS: [&str; 9] = ["start", "dur", "phase_inc", "gain", "waveform", "attack", "decay", "sustain", "release"];
const TOP_MEMBERS: [&str; 5] = ["sample_rate", "channels", "duration", "numeric", "notes"];

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

fn members(o: &Map<String, Value>, allowed: &[&str], what: &str) -> Result<(), String> {
    match o.keys().find(|k| !allowed.contains(&k.as_str())) {
        Some(k) => Err(format!("{what}: member `{k}` is not one of {allowed:?}")),
        None => Ok(()),
    }
}

fn uint(o: &Map<String, Value>, k: &str, what: &str) -> Result<u64, String> {
    match o.get(k) {
        None => Err(format!("{what}: `{k}` is missing")),
        Some(v) => v.as_u64().ok_or_else(|| format!("{what}: `{k}` is not a non-negative integer (got {v})")),
    }
}

/// A raw Q32.32 value in `0 ..= 2^32` (0 to 1.0).
fn unit(o: &Map<String, Value>, k: &str, what: &str, hi_inclusive: bool) -> Result<Fx, String> {
    let n = uint(o, k, what)?;
    let one = 1u64 << 32;
    if n > one || (!hi_inclusive && n == one) {
        let range = if hi_inclusive { "0 to 4294967296 (1.0)" } else { "0 to 4294967295 (below 1.0)" };
        return Err(format!("{what}: `{k}` is {n}; it is raw Q32.32 bits from {range}"));
    }
    Ok(Fx::from_bits(n as i64))
}

/// Read the authoring form into a score, holding it to rules under which the
/// reference synth renders every note whole: one channel, a duration of at most
/// [`MAX_SAMPLES`], `phase_inc` below one cycle a sample, `gain` and `sustain` from 0
/// to 1.0, a reference waveform (0 to 3), and every note's release ending by the
/// contract's duration. A member given twice, in the document or in a note, is
/// refused: the committed document is the definition (score §2.3), and readers differ
/// on which of two values they keep.
pub fn from_notes_json(bytes: &[u8]) -> Result<Score, String> {
    let v: Value = super::strict_json::from_slice(bytes).map_err(|e| match e {
        super::strict_json::Refused::Repeated(m) => m,
        super::strict_json::Refused::NotJson(m) => format!("not JSON: {m}"),
    })?;
    let o = v.as_object().ok_or("not a JSON object")?;
    members(o, &TOP_MEMBERS, "score")?;
    let sample_rate = uint(o, "sample_rate", "score")?;
    if sample_rate == 0 || sample_rate > u32::MAX as u64 {
        return Err(format!("score: `sample_rate` is {sample_rate}; it is 1 to {}", u32::MAX));
    }
    let channels = uint(o, "channels", "score")?;
    if channels != 1 {
        return Err(format!("score: `channels` is {channels}; the reference mixdown is one channel"));
    }
    let duration = uint(o, "duration", "score")?;
    if duration == 0 || duration > MAX_SAMPLES {
        return Err(format!("score: `duration` is {duration}; this tool takes 1 to {MAX_SAMPLES} samples"));
    }
    if let Some(n) = o.get("numeric").filter(|n| n.as_str() != Some(NUMERIC_FIXED64)) {
        return Err(format!("score: `numeric` is {n}; a score renders in {NUMERIC_FIXED64}"));
    }
    let list = o.get("notes").and_then(Value::as_array).ok_or("score: `notes` is not an array")?;
    if list.len() > u32::MAX as usize {
        return Err("score: more notes than the container counts".into());
    }
    let mut notes = Vec::with_capacity(list.len());
    for (i, n) in list.iter().enumerate() {
        let what = format!("note {i}");
        let n = n.as_object().ok_or_else(|| format!("{what}: not an object"))?;
        members(n, &NOTE_MEMBERS, &what)?;
        let waveform = uint(n, "waveform", &what)?;
        if waveform > 3 {
            return Err(format!("{what}: `waveform` is {waveform}; the reference timbres are 0 saw, 1 square, \
                                2 triangle, 3 25%-pulse"));
        }
        let note = Note {
            start: uint(n, "start", &what)?,
            dur: uint(n, "dur", &what)?,
            phase_inc: unit(n, "phase_inc", &what, false)?,
            gain: unit(n, "gain", &what, true)?,
            waveform: waveform as u8,
            attack: uint(n, "attack", &what)?,
            decay: uint(n, "decay", &what)?,
            sustain: unit(n, "sustain", &what, true)?,
            release: uint(n, "release", &what)?,
        };
        for (k, t) in [("attack", note.attack), ("decay", note.decay)] {
            if t > duration {
                return Err(format!("{what}: `{k}` is {t} samples, longer than the score ({duration})"));
            }
        }
        let end = note.start.checked_add(note.dur).and_then(|e| e.checked_add(note.release));
        match end {
            Some(e) if e <= duration => {}
            _ => {
                return Err(format!(
                    "{what}: start + dur + release passes the score's duration ({duration}), so the mixdown \
                     would cut its release; lengthen `duration` to cover it"
                ))
            }
        }
        notes.push(note);
    }
    let contract = ScoreContract { sample_rate: sample_rate as u32, numeric: NUMERIC_FIXED64.into(), channels: 1, duration };
    Ok(Score { contract_bytes: contract.to_canonical_json(), contract, notes })
}

/// The note count a `WSCR` declares, checked against the bytes its notes section
/// holds, before `Score::from_bytes` reserves room for that many notes. A file of a few
/// bytes can declare 2^32 − 1 notes; the reader would try to reserve them all.
fn declared_notes_fit(b: &[u8]) -> Result<(), String> {
    let rd32 = |p: usize| b.get(p..p + 4).map(|s| u32::from_le_bytes(s.try_into().unwrap()) as usize);
    if b.len() < 6 || &b[..4] != b"WSCR" {
        return Ok(()); // the reader reports it
    }
    let n = u16::from_le_bytes([b[4], b[5]]) as usize;
    let pay = 6 + n * 9;
    for i in 0..n {
        let e = 6 + i * 9;
        if b.get(e) != Some(&0x02) {
            continue;
        }
        let (Some(off), Some(len)) = (rd32(e + 1), rd32(e + 5)) else { return Ok(()) };
        let Some(count) = pay.checked_add(off).and_then(rd32) else { return Ok(()) };
        if count.checked_mul(65).and_then(|x| x.checked_add(4)).is_none_or(|need| need > len) {
            return Err(format!("the notes section declares {count} notes and holds {len} bytes \
                                (65 bytes a note, after a 4-byte count)"));
        }
    }
    Ok(())
}

/// Read a `WSCR` and check it can be rendered: the reader's checks, then a contract of
/// one channel in `wai.det.fixed64` (the reference mixdown is one channel of that
/// profile, so a contract stating two channels or another profile would print a
/// `channels` or a sample layout the samples written are not), a duration of at most
/// [`MAX_SAMPLES`], and note times whose sums (start + dur + release, dur + release,
/// attack + decay, which the synth forms in `u64`) do not overflow. `build` refuses the
/// same contracts. A note cut by the duration renders as the extension says, cut.
fn read_score(path: &str) -> Result<Score, String> {
    let bytes = std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?;
    declared_notes_fit(&bytes).map_err(|e| format!("{path}: {e}"))?;
    let s = Score::from_bytes(&bytes).map_err(|e| format!("{path}: {e}"))?;
    if s.contract.channels != 1 {
        return Err(format!("{path}: refused: the contract states {} channels; the reference mixdown is one \
                            channel, so this tool renders only a one-channel contract", s.contract.channels));
    }
    if s.contract.numeric != NUMERIC_FIXED64 {
        return Err(format!("{path}: refused: the contract's numeric profile is `{}`; a score renders in \
                            {NUMERIC_FIXED64}", s.contract.numeric));
    }
    if s.contract.duration > MAX_SAMPLES {
        return Err(format!("{path}: the score is {} samples; this tool renders at most {MAX_SAMPLES}",
                           s.contract.duration));
    }
    for (i, n) in s.notes.iter().enumerate() {
        let sums = n.start.checked_add(n.dur).and_then(|e| e.checked_add(n.release)).is_some()
            && n.attack.checked_add(n.decay).is_some();
        if !sums {
            return Err(format!("{path}: note {i}: a sum of its times overflows 64 bits"));
        }
    }
    Ok(s)
}

fn build(args: &[String]) -> Result<(), String> {
    let input = args.first().ok_or_else(|| USAGE.to_string())?;
    let out = opt(args, "-o").ok_or_else(|| format!("-o is required\n{USAGE}"))?;
    let bytes = std::fs::read(input).map_err(|e| format!("read {input}: {e}"))?;
    let s = from_notes_json(&bytes).map_err(|e| format!("{input}: refused: {e}"))?;
    let wscr = s.to_bytes();
    crate::out::write(out, &wscr).map_err(|e| format!("write {out}: {e}"))?;
    let score_hash = hex(&s.score_hash());
    if args.iter().any(|a| a == "--json") {
        println!("{}", serde_json::json!({
            "bytes": wscr.len(), "notes": s.notes.len(), "duration": s.contract.duration, "score_hash": score_hash,
        }));
    } else {
        println!("built → {out}  ({} B, {} notes, {} samples)  score_hash {score_hash}",
                 wscr.len(), s.notes.len(), s.contract.duration);
    }
    Ok(())
}

fn render(args: &[String]) -> Result<(), String> {
    let input = args.first().ok_or_else(|| USAGE.to_string())?;
    let s = read_score(input)?;
    let pcm: Vec<u8> = s.mixdown().iter().flat_map(|x| x.to_le_bytes()).collect();
    let pcm_sha256 = wai::pin::sha256_hex(&pcm);
    let (mixdown, score_hash) = (hex(&s.mixdown_hash()), hex(&s.score_hash()));
    if let Some(p) = opt(args, "--pcm") {
        crate::out::write(p, &pcm).map_err(|e| format!("write {p}: {e}"))?;
    }
    if args.iter().any(|a| a == "--json") {
        println!("{}", serde_json::json!({
            "samples": pcm.len() / 2, "sample_rate": s.contract.sample_rate, "channels": s.contract.channels,
            "notes": s.notes.len(), "pcm_sha256": pcm_sha256, "mixdown_blake3": mixdown, "score_hash": score_hash,
        }));
    } else {
        println!("rendered {input}: {} samples at {} Hz, {} notes", pcm.len() / 2, s.contract.sample_rate, s.notes.len());
        println!("  pcm_sha256     {pcm_sha256}");
        println!("  mixdown_blake3 {mixdown}");
        println!("  score_hash     {score_hash}");
    }
    Ok(())
}

pub fn main(args: &[String]) -> Result<(), String> {
    match args.first().map(String::as_str) {
        Some("build") => build(&args[1..]),
        Some("render") => render(&args[1..]),
        _ => Err(USAGE.into()),
    }
}
