//! Canonical JSON — a character-faithful `JSON.stringify`, plus content hashing.
//!
//! `serde_json::to_string` is **not** `JSON.stringify`, and the runtime hashes
//! session nodes with `sha256(JSON.stringify({parent, turn, createdAt}))[:32]`.
//! The swarm and connectors cache/dedupe by content hash, and the wire/auth
//! writers must byte-match JS serialization. So this module hand-rolls the exact
//! `JSON.stringify` semantics rather than going through serde:
//!
//! - **Object key order** — JS `[[OwnPropertyKeys]]`: canonical array-index keys
//!   ascending first, then the remaining keys in insertion order. We rely on
//!   `serde_json`'s `preserve_order` (an `IndexMap` map) to keep insertion order
//!   for the non-index keys; without it, the per-object insertion order would be
//!   lost. (Cross-crate consumers that hash must enable that feature.)
//! - **Dropped optionals** — JS drops keys whose value is `undefined`. In a
//!   `serde_json::Value` there is no `undefined`; the caller must omit such keys
//!   before hashing (the wire codec does exactly that). A JSON `null` is kept and
//!   rendered as `null`, matching `JSON.stringify(null) === "null"`.
//! - **Number formatting** — ECMA-262 `Number::toString` (radix 10): integral
//!   doubles drop the `.0`, exponent forms `1e+21` / `1e-7`, `NaN`/`Infinity`
//!   become `null`.
//! - **String escaping** — non-ASCII raw, control chars `\u`-escaped, lone
//!   surrogates `\uXXXX`. (Rust `String` is well-formed UTF-8 and cannot hold a
//!   lone surrogate, so the surrogate branch is unreachable from a `&str` input;
//!   it is documented for parity completeness.)
//!
//! Ported line-for-line from the Python rebuild's `canonical_json` /
//! `runtime/store/hash.py`, which is in turn a character-faithful port of the TS
//! `JSON.stringify`. A cross-language golden in the tests pins this against a
//! Node-emitted hash.

use serde_json::Value;
use sha2::{Digest, Sha256};

/// Width, in hex characters, of a truncated content hash.
pub const HASH_WIDTH: usize = 32;

/// The largest array index per ECMAScript: `2**32 - 2`.
const MAX_ARRAY_INDEX: u64 = (1u64 << 32) - 2;

/// Encode `value` exactly as `JSON.stringify(value)` would: no whitespace,
/// JS key order, JS number formatting, JS string escaping.
pub fn canonical_json(value: &Value) -> String {
    let mut out = String::new();
    write_value(value, &mut out);
    out
}

/// The content hash that serves as a node's id and the cache/dedupe key:
/// `hex(sha256(canonical_json(value)))[..HASH_WIDTH]`. This is the
/// parity-critical surface — see the cross-language golden test.
pub fn content_hash(value: &Value) -> String {
    let bytes = canonical_json(value);
    let digest = Sha256::digest(bytes.as_bytes());
    let hex = hex::encode(digest);
    hex[..HASH_WIDTH].to_string()
}

fn write_value(value: &Value, out: &mut String) {
    match value {
        Value::Null => out.push_str("null"),
        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
        Value::Number(n) => write_number(n, out),
        Value::String(s) => write_string(s, out),
        Value::Array(items) => {
            out.push('[');
            for (index, item) in items.iter().enumerate() {
                if index > 0 {
                    out.push(',');
                }
                write_value(item, out);
            }
            out.push(']');
        }
        Value::Object(map) => {
            out.push('{');
            for (position, key) in js_key_order(map).into_iter().enumerate() {
                if position > 0 {
                    out.push(',');
                }
                write_string(key, out);
                out.push(':');
                // `map[key]` always exists — keys came from the map itself.
                write_value(&map[key], out);
            }
            out.push('}');
        }
    }
}

/// JS `[[OwnPropertyKeys]]` ordering: canonical array-index keys ascending
/// first, then the remaining keys in insertion order (preserved by serde_json's
/// `preserve_order` feature). Returns borrowed keys to avoid allocation churn.
fn js_key_order(map: &serde_json::Map<String, Value>) -> Vec<&String> {
    let mut indices: Vec<&String> = map.keys().filter(|k| is_array_index(k)).collect();
    if indices.is_empty() {
        // Fast path: no numeric keys, insertion order is already correct.
        return map.keys().collect();
    }
    let others: Vec<&String> = map.keys().filter(|k| !is_array_index(k)).collect();
    indices.sort_by_key(|k| k.parse::<u64>().unwrap_or(0));
    indices.extend(others);
    indices
}

/// True for keys JS enumerates numerically (canonical array indices): all ASCII
/// digits, no leading zero (except the literal `"0"`), value `<= 2**32 - 2`.
fn is_array_index(key: &str) -> bool {
    if key.is_empty() || !key.bytes().all(|b| b.is_ascii_digit()) {
        return false;
    }
    if key.len() > 1 && key.starts_with('0') {
        return false; // "01" is not canonical
    }
    key.parse::<u64>()
        .map(|n| n <= MAX_ARRAY_INDEX)
        .unwrap_or(false)
}

/// Escape exactly as `JSON.stringify`: ASCII control chars use the short escapes
/// or `\u00XX`, `"` and `\` are escaped, every other char (including non-ASCII)
/// is emitted raw. Lone surrogates would be `\uXXXX`-escaped, but a Rust `&str`
/// cannot contain one, so that branch is structurally unreachable here.
fn write_string(s: &str, out: &mut String) {
    out.push('"');
    for ch in s.chars() {
        match ch {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\u{08}' => out.push_str("\\b"),
            '\u{0c}' => out.push_str("\\f"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if (c as u32) < 0x20 => {
                // Other control chars: \u00XX, lowercase hex (matches V8/Node).
                out.push_str(&format!("\\u{:04x}", c as u32));
            }
            c => out.push(c),
        }
    }
    out.push('"');
}

/// Format a JSON number per ECMA-262 `Number::toString` (radix 10).
///
/// Integers (which serde_json keeps as `i64`/`u64`) render as their decimal
/// digits directly — and, as a deliberate port-local improvement, integers
/// beyond `2**53` keep full precision (JS could not hold them). Floats go
/// through the ECMA-262 decimal-exponent layout: plain notation within
/// `1e-6 <= |x| < 1e21`, exponential outside, `NaN`/`Infinity` → `null`.
fn write_number(n: &serde_json::Number, out: &mut String) {
    if let Some(i) = n.as_i64() {
        out.push_str(&i.to_string());
        return;
    }
    if let Some(u) = n.as_u64() {
        out.push_str(&u.to_string());
        return;
    }
    // Floats and (with the arbitrary_precision feature off) anything else.
    match n.as_f64() {
        Some(f) => out.push_str(&js_number(f)),
        None => out.push_str("null"),
    }
}

/// Format an `f64` per ECMA-262 `Number::toString`. Mirrors the Python
/// `_js_number`, which decomposes the shortest round-tripping digit string and
/// lays it out by the spec's decimal-exponent rules.
fn js_number(value: f64) -> String {
    if value.is_nan() || value.is_infinite() {
        return "null".to_string();
    }
    if value == 0.0 {
        // JSON.stringify(-0) === "0"
        return "0".to_string();
    }

    let negative = value < 0.0;
    let magnitude = value.abs();

    // Rust's `{}` for f64 already yields the shortest round-tripping decimal
    // (Ryū / Grisu), the same property V8 guarantees. Decompose it into a
    // sign-free digit string `digits` (no trailing zeros) and the exponent `n`
    // such that value = 0.<digits> * 10**n.
    let (digits, n) = decompose(magnitude);

    let k = digits.len() as i64;
    let prefix = if negative { "-" } else { "" };

    let body = if k <= n && n <= 21 {
        // digits, then (n - k) trailing zeros.
        let mut s = digits;
        for _ in 0..(n - k) {
            s.push('0');
        }
        s
    } else if 0 < n && n <= 21 {
        // Insert a decimal point after the first n digits.
        let split = n as usize;
        format!("{}.{}", &digits[..split], &digits[split..])
    } else if -6 < n && n <= 0 {
        // 0.00..digits
        let zeros = "0".repeat((-n) as usize);
        format!("0.{}{}", zeros, digits)
    } else {
        // Exponential: d[.ddd]e±X
        let mantissa = if k > 1 {
            format!("{}.{}", &digits[..1], &digits[1..])
        } else {
            digits.clone()
        };
        let e = n - 1;
        let sign = if e >= 0 { "e+" } else { "e-" };
        format!("{}{}{}", mantissa, sign, e.abs())
    };

    format!("{prefix}{body}")
}

/// Decompose a positive finite `f64` into (sign-free digit string with no
/// trailing zeros, decimal exponent `n` where value = 0.<digits> * 10**n).
///
/// Built off Rust's shortest-round-trip `{}` formatting so the digit string
/// matches what JS emits, then normalized into the spec's `(digits, n)` form.
fn decompose(magnitude: f64) -> (String, i64) {
    // `{:e}` gives `d.ddde±E` with a shortest mantissa. Parse that: it isolates
    // the significant digits and the power-of-ten exponent unambiguously.
    let sci = format!("{magnitude:e}"); // e.g. "1.5e2", "5e-1", "1e21"
    let (mantissa, exp_str) = sci.split_once('e').expect("{:e} always has an 'e'");
    let exp: i64 = exp_str.parse().expect("exponent is an integer");

    // mantissa is "d" or "d.ddd"; build the bare digit string and count the
    // fractional length so we can compute n.
    let (int_part, frac_part) = match mantissa.split_once('.') {
        Some((i, f)) => (i, f),
        None => (mantissa, ""),
    };
    let mut digits = String::with_capacity(int_part.len() + frac_part.len());
    digits.push_str(int_part);
    digits.push_str(frac_part);

    // Strip trailing zeros from the digit string (the spec's k has none),
    // adjusting nothing about n because trailing zeros are below the point in
    // this representation: value = <digits> * 10**(exp - frac_len).
    let frac_len = frac_part.len() as i64;
    while digits.len() > 1 && digits.ends_with('0') {
        digits.pop();
    }
    // Also strip a leading zero that can only appear for "0" itself (handled by
    // the caller's value==0 short-circuit), so int_part here is always nonzero.

    let k = digits.len() as i64;
    // value = <digits-as-integer> * 10**(exp - frac_len_after_strip?)
    // We stripped trailing zeros AFTER fixing frac_len, so reconstruct the
    // power: original significand had (int_len + frac_len) digits at scale
    // 10**(exp - frac_len). Stripping trailing zeros multiplies the significand
    // by 10**(removed) and the scale by 10**(-removed) — they cancel, so the
    // decimal point exponent is stable. n = (digits-integer-exponent) + k.
    // digits-as-integer * 10**(pow) == value, where pow = exp - frac_len + removed.
    let original_len = (int_part.len() + frac_part.len()) as i64;
    let removed = original_len - k;
    let pow = exp - frac_len + removed;
    // value = D * 10**pow with D a k-digit integer => value = 0.<digits> * 10**(pow + k)
    let n = pow + k;
    (digits, n)
}

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

    // --- canonical_json shape -------------------------------------------------

    #[test]
    fn primitives_match_json_stringify() {
        assert_eq!(canonical_json(&json!(null)), "null");
        assert_eq!(canonical_json(&json!(true)), "true");
        assert_eq!(canonical_json(&json!(false)), "false");
        assert_eq!(canonical_json(&json!("hi")), "\"hi\"");
        assert_eq!(canonical_json(&json!(42)), "42");
        assert_eq!(canonical_json(&json!(-7)), "-7");
    }

    #[test]
    fn objects_have_no_whitespace_and_keep_insertion_order() {
        // Requires serde_json `preserve_order` for the insertion-order guarantee.
        let v = json!({ "b": 1, "a": 2, "c": 3 });
        assert_eq!(canonical_json(&v), r#"{"b":1,"a":2,"c":3}"#);
    }

    #[test]
    fn arrays_have_compact_separators() {
        assert_eq!(canonical_json(&json!([1, 2, 3])), "[1,2,3]");
        assert_eq!(canonical_json(&json!([])), "[]");
    }

    #[test]
    fn numeric_keys_sort_ascending_before_string_keys() {
        // JS [[OwnPropertyKeys]]: "2","10" ascend numerically; then "z","a" in
        // insertion order.
        let v = json!({ "10": 0, "z": 0, "2": 0, "a": 0 });
        assert_eq!(canonical_json(&v), r#"{"2":0,"10":0,"z":0,"a":0}"#);
    }

    #[test]
    fn non_canonical_numeric_keys_keep_insertion_order() {
        // "01" is not a canonical array index, so it stays in insertion order.
        let v = json!({ "01": 0, "1": 0 });
        assert_eq!(canonical_json(&v), r#"{"1":0,"01":0}"#);
    }

    // --- string escaping ------------------------------------------------------

    #[test]
    fn string_escaping_matches_json_stringify() {
        assert_eq!(canonical_json(&json!("a\"b\\c")), r#""a\"b\\c""#);
        assert_eq!(canonical_json(&json!("tab\there")), r#""tab\there""#);
        assert_eq!(canonical_json(&json!("line\nbreak")), r#""line\nbreak""#);
        // Other control char -> \u00XX lowercase.
        assert_eq!(canonical_json(&json!("\u{01}")), "\"\\u0001\"");
        // Non-ASCII emitted raw.
        assert_eq!(canonical_json(&json!("世界😀")), "\"世界😀\"");
    }

    // --- number formatting (ECMA-262) ----------------------------------------

    #[test]
    fn integral_floats_drop_the_fraction() {
        assert_eq!(js_number(1.0), "1");
        assert_eq!(js_number(100.0), "100");
        assert_eq!(js_number(-3.0), "-3");
    }

    #[test]
    fn fractional_floats_use_plain_notation_in_range() {
        assert_eq!(js_number(0.5), "0.5");
        assert_eq!(js_number(-3.25), "-3.25");
        assert_eq!(js_number(1.5), "1.5");
        assert_eq!(js_number(123.456), "123.456");
    }

    #[test]
    fn small_and_large_magnitudes_use_exponent_forms() {
        // Matches JS: 1e21 -> "1e+21", 1e-7 -> "1e-7".
        assert_eq!(js_number(1e21), "1e+21");
        assert_eq!(js_number(1e-7), "1e-7");
        assert_eq!(js_number(1e-6), "0.000001"); // boundary stays plain
        assert_eq!(js_number(1e20), "100000000000000000000");
    }

    #[test]
    fn zero_and_non_finite() {
        assert_eq!(js_number(0.0), "0");
        assert_eq!(js_number(-0.0), "0");
        assert_eq!(js_number(f64::NAN), "null");
        assert_eq!(js_number(f64::INFINITY), "null");
        assert_eq!(js_number(f64::NEG_INFINITY), "null");
    }

    // --- THE cross-language golden (spike 2) ---------------------------------

    /// Golden generated 2026-06-18 from BOTH the Node `JSON.stringify` + sha256
    /// path (the TS ground-truth hash surface) and the Python rebuild's
    /// `canonical_json` + sha256 (the verified port). Both produced
    /// byte-identical payloads and the same hash. The exact command:
    ///
    /// ```text
    /// node -e 'const {createHash}=require("node:crypto");
    ///   const v={parent:"abc123",turn:{role:"assistant",blocks:[
    ///     {kind:"text",text:"Hello 世界 😀"},
    ///     {kind:"tool_call",id:"call_0",name:"bash",
    ///      input:{cmd:"ls",n:42,ratio:0.5,big:1e21,tiny:1e-7,neg:-3.25}}]},
    ///     createdAt:1718000000000};
    ///   console.log(createHash("sha256").update(JSON.stringify(v)).digest("hex").slice(0,32));'
    /// # => 49a843a2b9dfc7f04a4fe5be52b90bfa
    /// ```
    ///
    /// The emitted canonical payload was:
    /// `{"parent":"abc123","turn":{"role":"assistant","blocks":[{"kind":"text",`
    /// `"text":"Hello 世界 😀"},{"kind":"tool_call","id":"call_0","name":"bash",`
    /// `"input":{"cmd":"ls","n":42,"ratio":0.5,"big":1e+21,"tiny":1e-7,"neg":-3.25}}]},`
    /// `"createdAt":1718000000000}`
    const GOLDEN_HASH: &str = "49a843a2b9dfc7f04a4fe5be52b90bfa";
    const GOLDEN_PAYLOAD: &str = "{\"parent\":\"abc123\",\"turn\":{\"role\":\"assistant\",\"blocks\":[{\"kind\":\"text\",\"text\":\"Hello 世界 😀\"},{\"kind\":\"tool_call\",\"id\":\"call_0\",\"name\":\"bash\",\"input\":{\"cmd\":\"ls\",\"n\":42,\"ratio\":0.5,\"big\":1e+21,\"tiny\":1e-7,\"neg\":-3.25}}]},\"createdAt\":1718000000000}";

    fn golden_value() -> Value {
        json!({
            "parent": "abc123",
            "turn": {
                "role": "assistant",
                "blocks": [
                    { "kind": "text", "text": "Hello 世界 😀" },
                    {
                        "kind": "tool_call",
                        "id": "call_0",
                        "name": "bash",
                        "input": { "cmd": "ls", "n": 42, "ratio": 0.5, "big": 1e21, "tiny": 1e-7, "neg": -3.25 }
                    }
                ]
            },
            "createdAt": 1718000000000i64
        })
    }

    #[test]
    fn canonical_payload_matches_node_and_python_byte_for_byte() {
        assert_eq!(canonical_json(&golden_value()), GOLDEN_PAYLOAD);
    }

    #[test]
    fn content_hash_matches_cross_language_golden() {
        assert_eq!(content_hash(&golden_value()), GOLDEN_HASH);
        assert_eq!(content_hash(&golden_value()).len(), HASH_WIDTH);
    }
}
