//! The encode record, `wai.video.encode` ([platform-encode] §6, §7): a signed statement
//! of who made an AVC or HEVC elementary stream, from what, with which settings and at
//! what measured cost. It names; it does not reproduce. A verifier holds the stream to
//! its digest and to §4.3's shape, and never re-encodes.
//!
//! [platform-encode]: ../../../../extensions/platform-encode.md

use super::annexb;
use super::{Codec, Encoded, hex, sha256_hex};
use ed25519_dalek::{Signer, SigningKey};
use serde_json::{Map, Value, json};

/// The record's `kind`.
pub const KIND: &str = "wai-encode";
/// The signature's domain (§6.2).
pub const SIGN_DOMAIN: &[u8] = b"wai:video-encode\x01";
/// The record digest's domain (§6.2).
pub const DIGEST_DOMAIN: &[u8] = b"wai:video-encode-record\x01";
/// The largest integer a record holds.
pub const MAX_INT: u64 = (1 << 53) - 1;

/// The members of each object, in the order §6.1 lists them.
pub const RECORD_MEMBERS: [&str; 13] = ["kind", "capability", "encoder", "settings", "source", "idr", "stream", "energy", "wall_us", "package", "issued_at", "signer_pubkey", "sig"];
pub const ENCODER_MEMBERS: [&str; 7] = ["api", "name", "version", "hardware", "device", "driver", "library_sha256"];
pub const SETTINGS_MEMBERS: [&str; 7] = ["width", "height", "fps_num", "fps_den", "kbps", "range", "resample"];
pub const SOURCE_MEMBERS: [&str; 4] = ["frames", "width", "height", "sha256"];
pub const STREAM_MEMBERS: [&str; 6] = ["sha256", "bytes", "units", "profile_idc", "level_idc", "tier"];
pub const ENERGY_MEMBERS: [&str; 9] = ["joules_micro", "energy_provenance", "energy_uncertainty", "energy_counter_filtering", "energy_calibration_ref", "coverage", "counter", "attribution", "fabrics"];
pub const PACKAGE_MEMBERS: [&str; 3] = ["receipt_sha256", "rung", "pieces"];
pub const PIECE_MEMBERS: [&str; 4] = ["start", "frames", "motif", "object"];

/// The interfaces (§5).
pub const APIS: [&str; 4] = ["platform", "va", "gpu-driver", "software"];
/// The acquisition classes of energy-measurement §2 an encode record admits.
pub const CLASSES: [&str; 4] = ["CalibratedInstrument", "OnChipCounter", "ModelBased", "Estimator"];
/// What a figure is the energy of (§8).
pub const ATTRIBUTIONS: [&str; 3] = ["task", "rail", "bracket"];
pub const FABRICS: [&str; 4] = ["cpu", "gpu", "media", "npu"];

/// Why a record is refused (§9).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Reason {
    RecordMalformed,
    BadKey,
    BadSignature,
    Capability,
    IdentityIncomplete,
    IdentityInconsistent,
    SettingsInvalid,
    PlanInvalid,
    EnergyInvalid,
    EnergyCoverage,
    StreamMismatch,
    StreamMalformed,
    FrameCount,
    KeyframeMismatch,
    ParameterSets,
    StreamReordered,
    StreamUnsupported,
    SpsMismatch,
    SourceMismatch,
    PackageMismatch,
}

impl Reason {
    pub const ALL: [Reason; 20] = [
        Reason::RecordMalformed,
        Reason::BadKey,
        Reason::BadSignature,
        Reason::Capability,
        Reason::IdentityIncomplete,
        Reason::IdentityInconsistent,
        Reason::SettingsInvalid,
        Reason::PlanInvalid,
        Reason::EnergyInvalid,
        Reason::EnergyCoverage,
        Reason::StreamMismatch,
        Reason::StreamMalformed,
        Reason::FrameCount,
        Reason::KeyframeMismatch,
        Reason::ParameterSets,
        Reason::StreamReordered,
        Reason::StreamUnsupported,
        Reason::SpsMismatch,
        Reason::SourceMismatch,
        Reason::PackageMismatch,
    ];

    pub fn code(self) -> &'static str {
        match self {
            Reason::RecordMalformed => "record_malformed",
            Reason::BadKey => "bad_key",
            Reason::BadSignature => "bad_signature",
            Reason::Capability => "encode-capability",
            Reason::IdentityIncomplete => "encode-identity-incomplete",
            Reason::IdentityInconsistent => "encode-identity-inconsistent",
            Reason::SettingsInvalid => "encode-settings-invalid",
            Reason::PlanInvalid => "encode-plan-invalid",
            Reason::EnergyInvalid => "encode-energy-invalid",
            Reason::EnergyCoverage => "encode-energy-coverage",
            Reason::StreamMismatch => "encode-stream-mismatch",
            Reason::StreamMalformed => "encode-stream-malformed",
            Reason::FrameCount => "encode-frame-count",
            Reason::KeyframeMismatch => "encode-keyframe-mismatch",
            Reason::ParameterSets => "encode-parameter-sets",
            Reason::StreamReordered => "encode-stream-reordered",
            Reason::StreamUnsupported => "encode-stream-unsupported",
            Reason::SpsMismatch => "encode-sps-mismatch",
            Reason::SourceMismatch => "encode-source-mismatch",
            Reason::PackageMismatch => "encode-package-mismatch",
        }
    }

    pub fn from_code(c: &str) -> Option<Reason> {
        Reason::ALL.into_iter().find(|r| r.code() == c)
    }

    /// The reason of a stream check (§4.3).
    pub fn of_stream(code: &str) -> Reason {
        Reason::from_code(code).unwrap_or(Reason::StreamMalformed)
    }
}

impl std::fmt::Display for Reason {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.code())
    }
}

// ---------------------------------------------------------------------------------------
// §6.1 Reading: strict JSON
// ---------------------------------------------------------------------------------------

/// Scan `s` as RFC 8259 JSON under §6.1's limits before it is parsed: no member named
/// twice in one object, at most 32 levels, and every number an unsigned integer of
/// decimal digits with no leading zero, no sign, fraction or exponent.
fn strict_scan(s: &[u8]) -> bool {
    let mut stack: Vec<Option<Vec<String>>> = Vec::new();
    let mut i = 0usize;
    let mut expect_key = false;
    while i < s.len() {
        let c = s[i];
        match c {
            b'{' => {
                stack.push(Some(Vec::new()));
                expect_key = true;
                if stack.len() > 32 {
                    return false;
                }
                i += 1;
            }
            b'[' => {
                stack.push(None);
                expect_key = false;
                if stack.len() > 32 {
                    return false;
                }
                i += 1;
            }
            b'}' | b']' => {
                stack.pop();
                expect_key = false;
                i += 1;
            }
            b',' => {
                expect_key = matches!(stack.last(), Some(Some(_)));
                i += 1;
            }
            b':' => {
                expect_key = false;
                i += 1;
            }
            b'"' => {
                let start = i + 1;
                i += 1;
                while i < s.len() && s[i] != b'"' {
                    if s[i] == b'\\' {
                        i += 1;
                    }
                    i += 1;
                }
                if i >= s.len() {
                    return false;
                }
                if expect_key {
                    let Ok(raw) = std::str::from_utf8(&s[start..i]) else { return false };
                    let Ok(key) = serde_json::from_str::<String>(&format!("\"{raw}\"")) else { return false };
                    if let Some(Some(keys)) = stack.last_mut() {
                        if keys.contains(&key) {
                            return false;
                        }
                        keys.push(key);
                    }
                    expect_key = false;
                }
                i += 1;
            }
            b'-' | b'.' | b'e' | b'E' | b'+' => return false,
            b'0'..=b'9' => {
                let start = i;
                while i < s.len() && s[i].is_ascii_digit() {
                    i += 1;
                }
                if s[start] == b'0' && i - start > 1 {
                    return false;
                }
                if i < s.len() && matches!(s[i], b'.' | b'e' | b'E') {
                    return false;
                }
            }
            _ => i += 1,
        }
    }
    true
}

fn uint(v: &Value) -> Option<u64> {
    v.as_u64().filter(|&n| n <= MAX_INT)
}

fn text(v: &Value) -> Option<&str> {
    v.as_str()
}

fn hexn<const N: usize>(v: &Value) -> Option<[u8; N]> {
    let s = v.as_str()?;
    if s.len() != 2 * N || !s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) {
        return None;
    }
    let mut out = [0u8; N];
    for (i, o) in out.iter_mut().enumerate() {
        *o = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
    }
    Some(out)
}

/// A term (jwp-receipts §2.4.3): 1 to 64 bytes of `a`–`z`, `0`–`9`, `-`, `_`, `.`.
fn term(v: &Value) -> Option<&str> {
    v.as_str().filter(|s| !s.is_empty() && s.len() <= 64 && s.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, b'-' | b'_' | b'.')))
}

/// An RFC 3339 date-time in UTC: `YYYY-MM-DDTHH:MM:SS` with up to nine fraction digits
/// and `Z`.
fn date_time(s: &str) -> bool {
    let b = s.as_bytes();
    if b.len() < 20 || b[b.len() - 1] != b'Z' {
        return false;
    }
    let d = |r: std::ops::Range<usize>| b.get(r.clone()).filter(|x| x.iter().all(u8::is_ascii_digit)).map(|x| x.iter().fold(0u32, |v, &c| v * 10 + u32::from(c - b'0')));
    let ok = b[4] == b'-' && b[7] == b'-' && b[10] == b'T' && b[13] == b':' && b[16] == b':';
    let (Some(year), Some(mo), Some(da), Some(h), Some(mi), Some(se)) = (d(0..4), d(5..7), d(8..10), d(11..13), d(14..16), d(17..19)) else { return false };
    let frac = &b[19..b.len() - 1];
    let frac_ok = frac.is_empty() || (frac[0] == b'.' && (2..=10).contains(&frac.len()) && frac[1..].iter().all(u8::is_ascii_digit));
    let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
    let days = match mo {
        2 if leap => 29,
        2 => 28,
        4 | 6 | 9 | 11 => 30,
        _ => 31,
    };
    ok && (1..=12).contains(&mo) && (1..=days).contains(&da) && h <= 23 && mi <= 59 && se <= 60 && frac_ok
}

fn exact<'a>(v: &'a Value, members: &[&str]) -> Option<&'a Map<String, Value>> {
    let o = v.as_object()?;
    (o.len() == members.len() && members.iter().all(|m| o.contains_key(*m))).then_some(o)
}

fn nonblank(s: &str) -> bool {
    !s.trim().is_empty()
}

/// Read a record's text under §6.1: strict JSON, exactly the members each object lists,
/// each of its type and encoding. Returns the record and its signer.
pub fn read(text_: &str) -> Result<(Value, [u8; 32]), Reason> {
    let m = || Reason::RecordMalformed;
    if !strict_scan(text_.as_bytes()) {
        return Err(m());
    }
    let v: Value = serde_json::from_str(text_).map_err(|_| m())?;
    let o = exact(&v, &RECORD_MEMBERS).ok_or_else(m)?;
    if o["kind"].as_str() != Some(KIND) || text(&o["capability"]).is_none() {
        return Err(m());
    }
    let e = exact(&o["encoder"], &ENCODER_MEMBERS).ok_or_else(m)?;
    if term(&e["api"]).is_none() || ["name", "version", "device", "driver"].iter().any(|k| text(&e[*k]).is_none_or(|s| s.len() > 1024)) || !matches!(e["hardware"].as_str(), Some("yes" | "no")) {
        return Err(m());
    }
    if !e["library_sha256"].is_null() && hexn::<32>(&e["library_sha256"]).is_none() {
        return Err(m());
    }
    let st = exact(&o["settings"], &SETTINGS_MEMBERS).ok_or_else(m)?;
    if ["width", "height", "fps_num", "fps_den", "kbps"].iter().any(|k| uint(&st[*k]).is_none()) || !matches!(st["range"].as_str(), Some("video" | "full")) || term(&st["resample"]).is_none() {
        return Err(m());
    }
    let so = exact(&o["source"], &SOURCE_MEMBERS).ok_or_else(m)?;
    if ["frames", "width", "height"].iter().any(|k| uint(&so[*k]).is_none()) || hexn::<32>(&so["sha256"]).is_none() {
        return Err(m());
    }
    let idr = o["idr"].as_array().ok_or_else(m)?;
    if idr.iter().any(|x| uint(x).is_none()) {
        return Err(m());
    }
    let s = exact(&o["stream"], &STREAM_MEMBERS).ok_or_else(m)?;
    if hexn::<32>(&s["sha256"]).is_none() || ["bytes", "units", "profile_idc", "level_idc"].iter().any(|k| uint(&s[*k]).is_none()) || !matches!(s["tier"].as_str(), Some("main" | "high")) {
        return Err(m());
    }
    if !o["energy"].is_null() {
        let en = exact(&o["energy"], &ENERGY_MEMBERS).ok_or_else(m)?;
        if uint(&en["joules_micro"]).is_none() || text(&en["energy_provenance"]).is_none() || text(&en["coverage"]).is_none() || term(&en["counter"]).is_none() || text(&en["attribution"]).is_none() {
            return Err(m());
        }
        if !en["energy_uncertainty"].is_null() {
            let u = exact(&en["energy_uncertainty"], &["relative_ppm", "window_us"]).ok_or_else(m)?;
            if uint(&u["relative_ppm"]).is_none() || uint(&u["window_us"]).is_none() {
                return Err(m());
            }
        }
        for k in ["energy_counter_filtering", "energy_calibration_ref"] {
            if !en[k].is_null() && text(&en[k]).is_none() {
                return Err(m());
            }
        }
        let f = en["fabrics"].as_array().ok_or_else(m)?;
        if f.iter().any(|x| text(x).is_none()) {
            return Err(m());
        }
    }
    if uint(&o["wall_us"]).is_none() || !o["issued_at"].as_str().is_some_and(date_time) {
        return Err(m());
    }
    if !o["package"].is_null() {
        let p = exact(&o["package"], &PACKAGE_MEMBERS).ok_or_else(m)?;
        if hexn::<32>(&p["receipt_sha256"]).is_none() || term(&p["rung"]).is_none() {
            return Err(m());
        }
        for pc in p["pieces"].as_array().ok_or_else(m)? {
            let q = exact(pc, &PIECE_MEMBERS).ok_or_else(m)?;
            if uint(&q["start"]).is_none() || uint(&q["frames"]).is_none() || hexn::<32>(&q["motif"]).is_none() || hexn::<32>(&q["object"]).is_none() {
                return Err(m());
            }
        }
    }
    let signer = hexn::<32>(&o["signer_pubkey"]).ok_or_else(m)?;
    if hexn::<64>(&o["sig"]).is_none() {
        return Err(m());
    }
    Ok((v, signer))
}

// ---------------------------------------------------------------------------------------
// §6.2 Signing and the record digest
// ---------------------------------------------------------------------------------------

/// The bytes a record is signed over: the domain, then JCS of the record without `sig`.
pub fn signing_bytes(record: &Value) -> Vec<u8> {
    let mut r = record.clone();
    if let Some(o) = r.as_object_mut() {
        o.remove("sig");
    }
    let mut m = SIGN_DOMAIN.to_vec();
    m.extend_from_slice(crate::jcs::jcs(&r).expect("a record in the subset").as_bytes());
    m
}

/// The record digest: SHA-256 of the domain and JCS of the whole record, lowercase hex.
pub fn digest(record: &Value) -> String {
    let mut m = DIGEST_DOMAIN.to_vec();
    m.extend_from_slice(crate::jcs::jcs(record).expect("a record in the subset").as_bytes());
    sha256_hex(&m)
}

/// Sign a record body (every member but `signer_pubkey` and `sig`).
pub fn sign(mut body: Value, key: &SigningKey) -> Value {
    let o = body.as_object_mut().expect("a record is an object");
    o.insert("signer_pubkey".into(), json!(hex(&key.verifying_key().to_bytes())));
    o.remove("sig");
    let sig = key.sign(&signing_bytes(&body));
    body.as_object_mut().expect("an object").insert("sig".into(), json!(hex(&sig.to_bytes())));
    // Members in §6.1's order, so a record's text reads the same however it was built.
    let mut ordered = Map::new();
    for k in RECORD_MEMBERS {
        if let Some(v) = body.get(k) {
            ordered.insert(k.to_string(), v.clone());
        }
    }
    Value::Object(ordered)
}

/// The record's text: JCS, so one record has one spelling.
pub fn to_text(record: &Value) -> String {
    crate::jcs::jcs(record).expect("a record in the subset")
}

// ---------------------------------------------------------------------------------------
// Building a record from an encode
// ---------------------------------------------------------------------------------------

/// A measured energy figure, as §8 states it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Energy {
    pub joules_micro: u64,
    pub class: &'static str,
    /// `(relative_ppm, window_us)`, for a hardware class.
    pub uncertainty: Option<(u64, u64)>,
    /// `OnChipCounter`'s filtering state.
    pub filtering: Option<&'static str>,
    pub coverage: &'static str,
    pub counter: &'static str,
    pub attribution: &'static str,
    pub fabrics: Vec<&'static str>,
}

impl Energy {
    pub fn to_json(&self) -> Value {
        json!({
            "joules_micro": self.joules_micro,
            "energy_provenance": self.class,
            "energy_uncertainty": self.uncertainty.map(|(p, w)| json!({"relative_ppm": p, "window_us": w})),
            "energy_counter_filtering": self.filtering,
            "energy_calibration_ref": Value::Null,
            "coverage": self.coverage,
            "counter": self.counter,
            "attribution": self.attribution,
            "fabrics": self.fabrics,
        })
    }
}

/// What an encode was made from (§6.1 `source`, `settings.resample`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceInfo {
    pub frames: u64,
    pub width: u32,
    pub height: u32,
    /// SHA-256 of the source's frames' 4:2:0 planes, one frame after another, hex.
    pub sha256: String,
}

/// The record body of an encode, unsigned.
pub fn body(enc: &Encoded, cfg: &super::Config, source: &SourceInfo, idr: &[u64], energy: Option<&Energy>, package: Option<Value>, issued_at: &str) -> Value {
    let id = &enc.identity;
    let resample = if (source.width, source.height) == (cfg.width, cfg.height) { "none" } else { super::scale::NAME };
    json!({
        "kind": KIND,
        "capability": cfg.codec.capability(),
        "encoder": {
            "api": id.api,
            "name": id.encoder,
            "version": id.version,
            "hardware": if id.hardware { "yes" } else { "no" },
            "device": id.device,
            "driver": id.driver,
            "library_sha256": id.library_sha256,
        },
        "settings": {
            "width": cfg.width, "height": cfg.height, "fps_num": cfg.fps_num, "fps_den": cfg.fps_den,
            "kbps": cfg.kbps, "range": if cfg.full_range { "full" } else { "video" }, "resample": resample,
        },
        "source": {"frames": source.frames, "width": source.width, "height": source.height, "sha256": source.sha256},
        "idr": idr,
        "stream": {
            "sha256": sha256_hex(&enc.stream), "bytes": enc.stream.len() as u64, "units": enc.units.len() as u64,
            "profile_idc": enc.sps.profile_idc, "level_idc": enc.sps.level_idc, "tier": if enc.sps.high_tier { "high" } else { "main" },
        },
        "energy": energy.map(Energy::to_json),
        "wall_us": enc.wall_us,
        "package": package,
        "issued_at": issued_at,
    })
}

/// `seconds` after the Unix epoch as an RFC 3339 date-time in UTC.
pub fn rfc3339_utc(seconds: u64) -> String {
    let days = seconds / 86_400;
    let rem = seconds % 86_400;
    // Civil date from days since 1970-01-01 (proleptic Gregorian).
    let z = days as i64 + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z.rem_euclid(146_097);
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = yoe + era * 400 + i64::from(m <= 2);
    format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, (rem / 60) % 60, rem % 60)
}

/// Now, as [`rfc3339_utc`].
pub fn now_rfc3339() -> String {
    rfc3339_utc(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0))
}

// ---------------------------------------------------------------------------------------
// §7 Verification
// ---------------------------------------------------------------------------------------

/// What a verifier holds beside the record.
#[derive(Default)]
pub struct Held<'a> {
    /// The elementary stream.
    pub stream: Option<&'a [u8]>,
    /// The source: its header, and its frames' planes one after another (the bytes
    /// `source.sha256` covers).
    pub source: Option<(&'a super::y4m::Header, &'a [u8])>,
}

/// The checks of §7 that need only the record (steps 3 to 7).
pub fn check_record(v: &Value) -> Result<(), Reason> {
    let cap = v["capability"].as_str().unwrap_or_default();
    let Some(codec) = Codec::from_capability(cap) else { return Err(Reason::Capability) };
    let e = &v["encoder"];
    if ["name", "version", "device", "driver"].iter().any(|k| !e[*k].as_str().is_some_and(nonblank)) {
        return Err(Reason::IdentityIncomplete);
    }
    let api = e["api"].as_str().unwrap_or_default();
    let hardware = e["hardware"].as_str() == Some("yes");
    let lib = !e["library_sha256"].is_null();
    if !APIS.contains(&api) || (api == "software") != lib || (api == "software" && hardware) || (matches!(api, "va" | "gpu-driver") && !hardware) || (api == "software" && codec != Codec::Avc) {
        return Err(Reason::IdentityInconsistent);
    }
    let st = &v["settings"];
    let n = |o: &Value, k: &str| o[k].as_u64().unwrap_or(0);
    let (w, h) = (n(st, "width"), n(st, "height"));
    let so = &v["source"];
    let (sw, sh, frames) = (n(so, "width"), n(so, "height"), n(so, "frames"));
    let size_ok = |w: u64, h: u64| (16..=8192).contains(&w) && (16..=8192).contains(&h) && w.is_multiple_of(2) && h.is_multiple_of(2);
    let source_ok = |w: u64, h: u64| (2..=16384).contains(&w) && (2..=16384).contains(&h) && w.is_multiple_of(2) && h.is_multiple_of(2);
    let resample = st["resample"].as_str().unwrap_or_default();
    if !size_ok(w, h) || !source_ok(sw, sh) || n(st, "fps_num") == 0 || n(st, "fps_den") == 0 || n(st, "fps_num") > u64::from(u32::MAX) || n(st, "fps_den") > u64::from(u32::MAX) || !(1..=1_000_000).contains(&n(st, "kbps")) || frames == 0 {
        return Err(Reason::SettingsInvalid);
    }
    if !((resample == "none" && (w, h) == (sw, sh)) || (resample == super::scale::NAME && (w, h) != (sw, sh))) {
        return Err(Reason::SettingsInvalid);
    }
    let idr: Vec<u64> = v["idr"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
    if idr.first() != Some(&0) || idr.windows(2).any(|p| p[0] >= p[1]) || idr.last().is_some_and(|&l| l >= frames) {
        return Err(Reason::PlanInvalid);
    }
    if !v["energy"].is_null() {
        check_energy(&v["energy"], hardware)?;
    }
    if let Some(p) = v["package"].as_object() {
        let pieces = p["pieces"].as_array().map(Vec::as_slice).unwrap_or_default();
        let starts: Vec<u64> = pieces.iter().map(|q| n(q, "start")).collect();
        let total: u64 = pieces.iter().map(|q| n(q, "frames")).sum();
        let tiles = pieces.iter().enumerate().all(|(i, q)| n(q, "frames") > 0 && starts.get(i + 1).is_none_or(|&s| s == n(q, "start") + n(q, "frames")));
        if starts != idr || total != frames || !tiles {
            return Err(Reason::PlanInvalid);
        }
    }
    Ok(())
}

/// §8: an energy figure's members, as jwp-receipts §2.4.5's rules 1 to 3 hold an energy
/// claim's, then this extension's own.
fn check_energy(en: &Value, hardware: bool) -> Result<(), Reason> {
    let bad = Err(Reason::EnergyInvalid);
    let j = en["joules_micro"].as_u64().unwrap_or(0);
    if j == 0 {
        return bad;
    }
    let class = en["energy_provenance"].as_str().unwrap_or_default();
    if !CLASSES.contains(&class) {
        return bad;
    }
    let u = &en["energy_uncertainty"];
    let filt = &en["energy_counter_filtering"];
    let cal = &en["energy_calibration_ref"];
    match class {
        "OnChipCounter" | "CalibratedInstrument" => {
            let (p, w) = (u["relative_ppm"].as_u64().unwrap_or(0), u["window_us"].as_u64().unwrap_or(0));
            if u.is_null() || p == 0 || w == 0 || p > u64::from(u32::MAX) || u128::from(j) * u128::from(p) < 288_676 {
                return bad;
            }
            if class == "OnChipCounter" && (!matches!(filt.as_str(), Some("On" | "Off" | "Unknown")) || !cal.is_null()) {
                return bad;
            }
            if class == "CalibratedInstrument" && (!filt.is_null() || !cal.as_str().is_some_and(nonblank)) {
                return bad;
            }
        }
        _ => {
            if !u.is_null() || !filt.is_null() || !cal.is_null() {
                return bad;
            }
        }
    }
    let coverage = en["coverage"].as_str().unwrap_or_default();
    if !matches!(coverage, "Whole" | "Partial") || !ATTRIBUTIONS.contains(&en["attribution"].as_str().unwrap_or_default()) {
        return bad;
    }
    let fabrics: Vec<&str> = en["fabrics"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
    let ordered = fabrics.windows(2).all(|p| FABRICS.iter().position(|f| *f == p[0]) < FABRICS.iter().position(|f| *f == p[1]));
    if fabrics.is_empty() || !fabrics.iter().all(|f| FABRICS.contains(f)) || !ordered {
        return bad;
    }
    // A hardware encode's figure that counts no engine (`gpu` or `media`) is not the
    // encode's whole cost.
    if hardware && coverage == "Whole" && !fabrics.iter().any(|f| matches!(*f, "gpu" | "media")) {
        return Err(Reason::EnergyCoverage);
    }
    Ok(())
}

/// The checks of §7 against the stream (step 8) and the source (step 9).
pub fn check_held(v: &Value, held: &Held<'_>) -> Result<(), Reason> {
    let n = |o: &Value, k: &str| o[k].as_u64().unwrap_or(0);
    if let Some(stream) = held.stream {
        let s = &v["stream"];
        if s["sha256"].as_str() != Some(sha256_hex(stream).as_str()) || n(s, "bytes") != stream.len() as u64 {
            return Err(Reason::StreamMismatch);
        }
        let codec = Codec::from_capability(v["capability"].as_str().unwrap_or_default()).ok_or(Reason::Capability)?;
        let idr: Vec<u64> = v["idr"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
        let frames = n(&v["source"], "frames");
        let c = annexb::check_stream(codec, stream, frames, &idr).map_err(|r| Reason::of_stream(r.code))?;
        if n(s, "units") != c.units.len() as u64 {
            return Err(Reason::FrameCount);
        }
        let st = &v["settings"];
        let tier = if c.sps.high_tier { "high" } else { "main" };
        if (u64::from(c.sps.width), u64::from(c.sps.height)) != (n(st, "width"), n(st, "height")) || n(s, "profile_idc") != u64::from(c.sps.profile_idc) || n(s, "level_idc") != u64::from(c.sps.level_idc) || s["tier"].as_str() != Some(tier) {
            return Err(Reason::SpsMismatch);
        }
        if c.sps.full_range.is_some_and(|f| f != (st["range"].as_str() == Some("full"))) {
            return Err(Reason::SpsMismatch);
        }
    }
    if let Some((hd, planes)) = held.source {
        let so = &v["source"];
        let st = &v["settings"];
        let frames = if hd.frame_len() == 0 { 0 } else { (planes.len() / hd.frame_len()) as u64 };
        let range = if hd.full_range { "full" } else { "video" };
        if so["sha256"].as_str() != Some(sha256_hex(planes).as_str())
            || (n(so, "width"), n(so, "height"), n(so, "frames")) != (u64::from(hd.width), u64::from(hd.height), frames)
            || (n(st, "fps_num"), n(st, "fps_den")) != (u64::from(hd.fps_num), u64::from(hd.fps_den))
            || st["range"].as_str() != Some(range)
        {
            return Err(Reason::SourceMismatch);
        }
    }
    Ok(())
}

/// Verify a record's text with whatever is held (§7): steps 1 and 2 (reading, the
/// signature), the record's own checks, then the stream and the source where held.
pub fn verify(text_: &str, held: &Held<'_>) -> Result<Value, Reason> {
    let (v, signer) = read(text_)?;
    let sig: [u8; 64] = hexn(&v["sig"]).ok_or(Reason::RecordMalformed)?;
    crate::sig::verify_strict(&signer, &signing_bytes(&v), &sig).map_err(|e| match e {
        crate::sig::SigError::BadKey => Reason::BadKey,
        crate::sig::SigError::BadSignature => Reason::BadSignature,
    })?;
    check_record(&v)?;
    check_held(&v, held)?;
    Ok(v)
}

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

    #[test]
    fn the_scan_refuses_what_6_1_refuses() {
        assert!(strict_scan(br#"{"a":1,"b":[1,2,{"c":"x\"y"}]}"#));
        assert!(!strict_scan(br#"{"a":1,"a":2}"#));
        assert!(!strict_scan(br#"{"a":-1}"#));
        assert!(!strict_scan(br#"{"a":1.0}"#));
        assert!(!strict_scan(br#"{"a":1e3}"#));
        assert!(!strict_scan(br#"{"a":01}"#));
        assert!(strict_scan(br#"{"a":{"a":1}}"#));
        assert!(!strict_scan(br#"{"a":1,"a":2}"#));
        let deep = "[".repeat(33) + &"]".repeat(33);
        assert!(!strict_scan(deep.as_bytes()));
    }

    #[test]
    fn civil_time() {
        assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z");
        assert_eq!(rfc3339_utc(1_792_962_000), "2026-10-25T21:00:00Z");
        assert_eq!(rfc3339_utc(951_782_400), "2000-02-29T00:00:00Z");
    }

    #[test]
    fn date_times() {
        assert!(date_time("2026-10-25T21:00:00Z"));
        assert!(date_time("2026-10-25T21:00:00.123Z"));
        assert!(!date_time("2026-10-25T21:00:00+00:00"));
        assert!(!date_time("2026-13-25T21:00:00Z"));
        assert!(!date_time("2026-02-29T00:00:00Z"));
        assert!(date_time("2028-02-29T00:00:00Z"));
        assert!(!date_time("2026-04-31T00:00:00Z"));
        assert!(date_time("2016-12-31T23:59:60Z"));
    }
}
