//! RFC 8785 (JSON Canonicalization Scheme) over the subset WAI signs and digests.
//!
//! One canonical form for every JSON object WAI hashes or signs: a claim's body and
//! signing input (`claim_receipt`), a key log's revisions, grants and statuses
//! (`key_log`), an energy figure's claim body (`energy_figure`), and a packaging
//! policy's digest (`extensions/packaging.md` §3). It needs `serde_json` alone, so it is
//! compiled under every feature set.
//!
//! The subset: `null`, `true`, `false`, strings, arrays, objects, and integers in
//! `0..=MAX_INTEGER` written in plain decimal. Member names are sorted by their UTF-16
//! code units and strings are escaped as RFC 8785 §3.2.2.2 requires, which is how
//! `serde_json` escapes them. Any other number (a negative, a fraction, an exponent
//! form, an integer past `2^53 − 1`) is outside the subset and refused, so a value is
//! never canonicalised in a form two readers could read differently.

use serde_json::Value;
use std::fmt::Write as _;

/// The largest integer the subset admits: I-JSON's (RFC 7493) exact-integer bound,
/// which is what keeps RFC 8785 number serialisation trivial.
///
/// cbindgen:ignore
pub const MAX_INTEGER: u64 = (1 << 53) - 1;

/// Why a value has no canonical form in the subset.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JcsError {
    /// A number that is not an integer in `0..=MAX_INTEGER`.
    Number,
    /// A string or member name the serialiser refused (a valid `String` never is).
    String,
}

impl std::fmt::Display for JcsError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            JcsError::Number => "a number outside the integers 0..=2^53-1",
            JcsError::String => "a string the serialiser refused",
        })
    }
}

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

/// The canonical form of `v`: RFC 8785 over the subset above.
pub fn jcs(v: &Value) -> Result<String, JcsError> {
    let mut out = String::new();
    jcs_into(v, &mut out)?;
    Ok(out)
}

fn jcs_into(v: &Value, out: &mut String) -> Result<(), JcsError> {
    match v {
        Value::Null => out.push_str("null"),
        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
        Value::Number(n) => match n.as_u64() {
            Some(u) if u <= MAX_INTEGER => {
                let _ = write!(out, "{u}");
            }
            _ => return Err(JcsError::Number),
        },
        Value::String(s) => out.push_str(&serde_json::to_string(s).map_err(|_| JcsError::String)?),
        Value::Array(a) => {
            out.push('[');
            for (i, x) in a.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                jcs_into(x, out)?;
            }
            out.push(']');
        }
        Value::Object(m) => {
            let mut keys: Vec<&String> = m.keys().collect();
            keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
            out.push('{');
            for (i, k) in keys.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                out.push_str(&serde_json::to_string(k).map_err(|_| JcsError::String)?);
                out.push(':');
                jcs_into(&m[k.as_str()], out)?;
            }
            out.push('}');
        }
    }
    Ok(())
}

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

    /// Member order by UTF-16 code units (so a supplementary-plane character sorts
    /// before U+FF61, which it follows in code-point order), RFC 8785's escapes, and
    /// `null` admitted.
    #[test]
    fn sorts_by_utf16_and_escapes_like_rfc8785() {
        let v = json!({"b": 1, "a": "x\u{1}\n\"", "A": [2, "é"], "é": null});
        assert_eq!(jcs(&v).unwrap(), "{\"A\":[2,\"é\"],\"a\":\"x\\u0001\\n\\\"\",\"b\":1,\"é\":null}");
        let v = json!({"\u{ff61}": 1, "\u{1f600}": 2});
        assert_eq!(jcs(&v).unwrap(), "{\"\u{1f600}\":2,\"\u{ff61}\":1}");
    }

    /// The subset's numbers: integers up to `2^53 − 1`, and nothing else.
    #[test]
    fn refuses_numbers_outside_the_subset() {
        assert_eq!(jcs(&json!({"n": -1})), Err(JcsError::Number));
        assert_eq!(jcs(&json!({"n": 1.5})), Err(JcsError::Number));
        assert_eq!(jcs(&json!([MAX_INTEGER + 1])), Err(JcsError::Number));
        assert_eq!(jcs(&json!({"n": MAX_INTEGER})).unwrap(), "{\"n\":9007199254740991}");
        assert_eq!(jcs(&json!(0)).unwrap(), "0");
    }
}
