//! JSON handling — JSON.parse(), JSON.stringify(), and specialized variants
//!
//! Provides all core JSON functions used by compiled TypeScript programs.
//! These live in perry-runtime (not perry-stdlib) so that programs that
//! only use JSON don't need to link the full stdlib.
//!
//! The file was split (issue #1105 wave) into topical sub-modules:
//!
//! - `simd`         — SIMD string-terminator scanners
//! - `parser`       — `DirectParser` recursive-descent implementation
//! - `parse_api`    — `js_json_parse` / `js_json_parse_typed_array` FFI
//! - `stringify`    — core stringify traversal (object/array/scalar emitters)
//! - `stringify_api` — `js_json_stringify*` / `js_json_get_*` FFI entries
//! - `replacer`     — replacer + pretty/indent + array-replacer variants
//! - `reviver`      — `JSON.parse(text, reviver)` support
//!
//! Shared TLS state, file-local constants, and tiny helpers used by every
//! sibling stay in this module.

use crate::string::js_string_from_ascii_bytes;
use crate::{js_string_from_bytes, JSValue, StringHeader};
use std::cell::RefCell;

mod parse_api;
mod parser;
mod replacer;
mod reviver;
mod simd;
mod stringify;
mod stringify_api;

// Public FFI re-exports — preserve the `crate::json::js_json_*` path used by
// the rest of perry-runtime, perry-stdlib, and code generated by perry-codegen.
pub use parse_api::{js_json_parse, js_json_parse_or_null, js_json_parse_typed_array};
pub use replacer::{js_json_stringify_full, js_json_stringify_with_replacer};
pub use reviver::js_json_parse_with_reviver;
pub use stringify_api::{
    js_json_get_bool, js_json_get_number, js_json_get_string, js_json_is_valid, js_json_stringify,
    js_json_stringify_bool, js_json_stringify_null, js_json_stringify_number,
    js_json_stringify_string,
};

// Crate-internal symbol surface — siblings reach these via `use super::*;`
// so every cross-sibling helper must be visible at the mod-root scope.
// `ShapeTemplate` is referenced by the `SHAPE_CACHE` thread-local declared
// below so the re-export must precede the `thread_local!` block.
//
// `#[allow(unused_imports)]`: many helpers are self-referenced from their
// home sibling and don't need the re-export there, but keeping the surface
// uniform makes the split predictable and resilient to future cross-
// sibling references.
#[cfg(test)]
pub(crate) use parse_api::test_json_parse_direct;
#[allow(unused_imports)]
pub(crate) use parse_api::{try_parse_via_tape, TapeMode};
#[allow(unused_imports)]
pub(crate) use parser::{DirectParser, ObjectShapeHint};
#[cfg(test)]
pub(crate) use reviver::test_apply_reviver_for_value;
#[allow(unused_imports)]
pub(crate) use simd::find_string_terminator;
#[allow(unused_imports)]
pub(crate) use stringify::{
    build_shape_prefix_template, estimate_json_size, is_closure_value, is_object_pointer,
    object_get_to_json, shape_template_for, stringify_array, stringify_array_depth,
    stringify_buffer, stringify_object, stringify_object_inner, stringify_value,
    stringify_value_depth, try_emit_shape_element, write_escaped_string, write_number,
    ShapeTemplate,
};
#[allow(unused_imports)]
pub(crate) use stringify_api::{
    redirect_lazy_to_materialized, string_from_header, try_stringify_lazy_array,
};

// ─── Circular reference detection ────────────────────────────────────────────
thread_local! {
    /// Stack of object pointers currently being stringified (for circular detection).
    pub(crate) static STRINGIFY_STACK: RefCell<Vec<usize>> = const { RefCell::new(Vec::new()) };

    /// Reusable scratch buffer for JSON.stringify (issue #64). Avoids the
    /// per-call `String::with_capacity` allocate+free that dominated the
    /// small-stringify path. Wrapped in `Cell<Option<_>>` so reentrant calls
    /// (via `toJSON` callbacks etc.) get a fresh buffer instead of panicking
    /// on a `RefCell` borrow conflict; the larger of the two is restored.
    pub(crate) static STRINGIFY_BUF: std::cell::Cell<Option<String>> =
        std::cell::Cell::new(Some(String::with_capacity(4096)));

    /// Per-call shape-template cache (#64 follow-up). Keys on `keys_array`
    /// raw pointer — within one top-level stringify call no GC runs over
    /// the user object graph (the buffer/result allocations don't move
    /// keys arrays), so pointer identity is a stable shape ID. Cleared
    /// (saved+restored) at the entry of each `js_json_stringify` /
    /// `js_json_stringify_full` / `..with_replacer` call so reentrant
    /// `toJSON` callbacks don't return stale templates.
    ///
    /// `Box<ShapeTemplate>` lives on the heap so its address is stable
    /// even when the cache `Vec` reallocates — we hand out raw pointers
    /// to the templates and they must outlive the borrow.
    pub(crate) static SHAPE_CACHE: RefCell<Vec<(*mut crate::ArrayHeader, Box<ShapeTemplate>)>> =
        const { RefCell::new(Vec::new()) };

    /// Key string intern cache for JSON.parse (issue #51 follow-up).
    /// Maps key bytes → already-allocated StringHeader pointer.
    /// Avoids re-allocating "id", "name", etc. for every record in a
    /// homogeneous JSON array. Cleared at the end of each top-level parse.
    /// `pub(crate)` so `json_tape`'s materializer can share the cache —
    /// without this, each tape-path force-materialize re-allocates every
    /// key and burns 3× the time + RSS vs the direct parser.
    pub(crate) static PARSE_KEY_CACHE: RefCell<std::collections::HashMap<Vec<u8>, *const StringHeader>> =
        RefCell::new(std::collections::HashMap::new());
    /// Tiny hot-key mirror for homogeneous object parses. Most API JSON
    /// repeats the same handful of property names thousands of times; a
    /// short linear probe avoids hashing + HashMap probing on the steady
    /// state while PARSE_KEY_CACHE remains the owning/rooting store.
    static PARSE_KEY_RING: RefCell<Vec<*const StringHeader>> = const { RefCell::new(Vec::new()) };

    /// Shared keys_array cache for direct JSON.parse objects.
    ///
    /// The generic object setter builds a shape one property at a time. For
    /// homogeneous JSON records that means every parsed object participates in
    /// the transition-cache machinery before field values are written. The tape
    /// path avoids that churn by materializing from a compact token stream; the
    /// direct parser needs the same shape sharing so `PERRY_JSON_TAPE=0` does
    /// not manufacture short-lived keys arrays and forwarding stubs in old
    /// pages.
    pub(crate) static PARSE_SHAPE_CACHE: RefCell<Vec<ParseShapeCacheEntry>> =
        const { RefCell::new(Vec::new()) };

    /// Reentrancy depth counter for JSON.stringify (issue #67). 0 means
    /// no call in progress; ≥1 means a reentrant (toJSON callback) path.
    /// Used to skip the shape_cache save/restore dance for the common
    /// non-reentrant case — a plain `clear_shape_cache` at the outermost
    /// call's exit handles correctness without a Vec alloc/swap.
    pub(crate) static STRINGIFY_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };

    /// GC roots for in-progress JSON.parse. Each entry is a JSValue bit pattern
    /// (stored as f64 so the scanner can hand it to the NaN-boxed mark path).
    ///
    /// Why this exists (issue #46): parse_array/parse_object build their result
    /// incrementally over thousands of iterations. Mid-parse heap allocations
    /// (`js_string_from_bytes` → gc_malloc → adaptive count trigger, or an arena
    /// block overflow) run GC while the in-progress array/object lives only on
    /// the Rust call stack. The conservative stack scan only captures callee-
    /// saved registers via setjmp; values held in caller-saved regs (or on
    /// the Rust-heap backing of `Vec<(Vec<u8>, JSValue)>` inside parse_object)
    /// are invisible and get swept. Symptom was `JSON.parse(big_array)` silently
    /// truncating at ~1666 records (= when the second adaptive malloc GC fires).
    pub(crate) static PARSE_ROOTS: RefCell<Vec<f64>> = const { RefCell::new(Vec::new()) };
}

pub(crate) struct ParseShapeCacheEntry {
    pub(crate) keys: Vec<*const StringHeader>,
    pub(crate) keys_array: *mut crate::ArrayHeader,
}

pub(crate) const PARSE_SHAPE_CACHE_CAP: usize = 256;

// ─── Shared file-local NaN-box tag / type-hint constants ─────────────────────

pub(crate) const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001;
pub(crate) const TAG_NULL: u64 = 0x7FFC_0000_0000_0002;
pub(crate) const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003;
pub(crate) const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004;
pub(crate) const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000;
pub(crate) const STRING_TAG: u64 = 0x7FFF_0000_0000_0000;
pub(crate) const BIGINT_TAG: u64 = 0x7FFA_0000_0000_0000;
pub(crate) const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF;

pub(crate) const TYPE_UNKNOWN: u32 = 0;
pub(crate) const TYPE_OBJECT: u32 = 1;
pub(crate) const TYPE_ARRAY: u32 = 2;

/// Maximum cache entries per call. Workloads with more distinct shapes
/// fall back to per-object building. We never evict — the raw pointers we
/// hand out must outlive cache mutations, and `swap_remove` would drop a
/// `Box` whose heap address might be in active use up the call stack.
/// 32 entries × ~40 bytes ≈ 1.3 KB; a stringify graph that exceeds this
/// gets the pre-cache slow path on overflow shapes only.
pub(crate) const SHAPE_CACHE_CAP: usize = 32;

/// Stringify depth counter — avoids TLS `STRINGIFY_STACK` access for
/// shallow (non-circular) object graphs. Only activates full tracking
/// at depth > MAX_FAST_DEPTH to catch genuine circular refs.
pub(crate) const MAX_FAST_DEPTH: u32 = 128;

// ─── Parse-roots helpers ─────────────────────────────────────────────────────

#[inline]
pub(crate) fn parse_root_push(v: JSValue) -> usize {
    PARSE_ROOTS.with(|r| {
        let mut r = r.borrow_mut();
        let idx = r.len();
        r.push(f64::from_bits(v.bits()));
        idx
    })
}

#[inline]
pub(crate) fn parse_root_set(idx: usize, v: JSValue) {
    PARSE_ROOTS.with(|r| {
        if let Some(slot) = r.borrow_mut().get_mut(idx) {
            *slot = f64::from_bits(v.bits());
        }
    });
}

#[inline]
pub(crate) fn parse_root_get(idx: usize) -> JSValue {
    PARSE_ROOTS.with(|r| {
        r.borrow()
            .get(idx)
            .map(|slot| JSValue::from_bits(slot.to_bits()))
            .unwrap_or_else(JSValue::undefined)
    })
}

#[inline]
pub(crate) fn parse_root_object_ptr(idx: usize) -> *mut crate::ObjectHeader {
    (parse_root_get(idx).bits() & POINTER_MASK) as *mut crate::ObjectHeader
}

#[inline]
pub(crate) fn parse_root_array_ptr(idx: usize) -> *mut crate::ArrayHeader {
    (parse_root_get(idx).bits() & POINTER_MASK) as *mut crate::ArrayHeader
}

#[inline]
pub(crate) fn parse_root_save_len() -> usize {
    PARSE_ROOTS.with(|r| r.borrow().len())
}

#[inline]
pub(crate) fn parse_root_restore(len: usize) {
    PARSE_ROOTS.with(|r| r.borrow_mut().truncate(len));
}

#[inline]
pub(crate) fn cached_parse_key_ptr(key_bytes: &[u8]) -> *const StringHeader {
    let ring_hit = PARSE_KEY_RING.with(|ring| {
        for &ptr in ring.borrow().iter() {
            if ptr.is_null() {
                continue;
            }
            unsafe {
                let len = (*ptr).byte_len as usize;
                if len != key_bytes.len() {
                    continue;
                }
                let data = (ptr as *const u8).add(std::mem::size_of::<StringHeader>());
                if std::slice::from_raw_parts(data, len) == key_bytes {
                    return Some(ptr);
                }
            }
        }
        None
    });
    if let Some(ptr) = ring_hit {
        return ptr;
    }

    let cached = PARSE_KEY_CACHE.with(|c| c.borrow().get(key_bytes).copied());
    if let Some(ptr) = cached {
        remember_parse_key_ring(ptr);
        return ptr;
    }

    // Issue #179: allocate cached key strings in the longlived arena. They
    // are rooted by `scan_parse_roots_mut` and reused across repeated parses
    // of homogeneous JSON records.
    let ptr =
        crate::string::js_string_from_bytes_longlived(key_bytes.as_ptr(), key_bytes.len() as u32);
    PARSE_KEY_CACHE.with(|c| {
        c.borrow_mut().insert(key_bytes.to_vec(), ptr);
    });
    remember_parse_key_ring(ptr);
    ptr
}

#[inline]
fn remember_parse_key_ring(ptr: *const StringHeader) {
    PARSE_KEY_RING.with(|ring| {
        let mut ring = ring.borrow_mut();
        if ring.contains(&ptr) {
            return;
        }
        if ring.len() == 16 {
            ring.remove(0);
        }
        ring.push(ptr);
    });
}

pub(crate) fn clear_parse_key_ring() {
    PARSE_KEY_RING.with(|ring| ring.borrow_mut().clear());
}

#[inline]
pub(crate) unsafe fn parse_shape_keys_array(
    keys: &[*const StringHeader],
) -> *mut crate::ArrayHeader {
    PARSE_SHAPE_CACHE.with(|cache| {
        {
            let cache = cache.borrow();
            for entry in cache.iter().rev() {
                if entry.keys.len() == keys.len()
                    && entry
                        .keys
                        .iter()
                        .zip(keys.iter())
                        .all(|(a, b)| std::ptr::eq(*a, *b))
                {
                    return entry.keys_array;
                }
            }
        }

        let arr = crate::array::js_array_alloc_with_length_longlived(keys.len() as u32);
        let elements_ptr =
            (arr as *mut u8).add(std::mem::size_of::<crate::ArrayHeader>()) as *mut f64;
        for (i, &key_ptr) in keys.iter().enumerate() {
            let bits = crate::value::STRING_TAG | (key_ptr as u64 & crate::value::POINTER_MASK);
            *elements_ptr.add(i) = f64::from_bits(bits);
            crate::gc::layout_note_slot(arr as usize, i, bits);
        }
        let header = (arr as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader;
        (*header).gc_flags |= crate::gc::GC_FLAG_SHAPE_SHARED;

        let mut cache = cache.borrow_mut();
        if cache.len() < PARSE_SHAPE_CACHE_CAP {
            cache.push(ParseShapeCacheEntry {
                keys: keys.to_vec(),
                keys_array: arr,
            });
        }
        arr
    })
}

// ─── Stringify scratch / shape-cache lifecycle ───────────────────────────────

/// Take the shared scratch buffer (or allocate a fresh one on reentrancy).
#[inline]
pub(crate) fn take_stringify_buf() -> String {
    STRINGIFY_BUF.with(|b| b.take()).unwrap_or_default()
}

/// Restore the scratch buffer after use. In a reentrant call the inner
/// restore runs first with a (typically smaller) buffer, but the outer
/// restore runs last and overwrites with its larger buffer — so the
/// final TLS state always holds the largest recently-used buffer without
/// needing a per-call capacity comparison.
#[inline]
pub(crate) fn restore_stringify_buf(mut buf: String) {
    buf.clear();
    STRINGIFY_BUF.with(|b| b.set(Some(buf)));
}

#[inline]
pub(crate) fn json_string_from_output_bytes(bytes: &[u8]) -> *mut StringHeader {
    if bytes.is_ascii() {
        js_string_from_ascii_bytes(bytes.as_ptr(), bytes.len() as u32)
    } else {
        js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32)
    }
}

/// Save & clear the shape cache for the duration of a top-level stringify
/// call. Reentrant `toJSON` callbacks would otherwise inherit the outer
/// call's templates and (worse) clear them on exit, dangling pointers we
/// already handed out. Mirrors `take_stringify_buf` in spirit.
#[inline]
pub(crate) fn take_shape_cache() -> Vec<(*mut crate::ArrayHeader, Box<ShapeTemplate>)> {
    SHAPE_CACHE.with(|c| std::mem::take(&mut *c.borrow_mut()))
}

#[inline]
pub(crate) fn restore_shape_cache(saved: Vec<(*mut crate::ArrayHeader, Box<ShapeTemplate>)>) {
    SHAPE_CACHE.with(|c| *c.borrow_mut() = saved);
}

/// Clear cache without allocating a fresh Vec (keeps capacity, drops entries).
/// Used in place of restore when we know the cache was empty at call entry —
/// the outermost stringify call in a tight loop skips the save entirely.
#[inline]
pub(crate) fn clear_shape_cache() {
    SHAPE_CACHE.with(|c| c.borrow_mut().clear());
}

#[inline]
pub(crate) fn make_pointer_bits(ptr: *const u8) -> u64 {
    POINTER_TAG | (ptr as u64 & POINTER_MASK)
}

// ─── GC root scanners ────────────────────────────────────────────────────────

/// Root scanner called by GC — marks every value in PARSE_ROOTS as live.
pub fn scan_parse_roots(mark: &mut dyn FnMut(f64)) {
    let mut visitor = crate::gc::RuntimeRootVisitor::for_copy(mark);
    scan_parse_roots_mut(&mut visitor);
}

pub fn scan_parse_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) {
    PARSE_ROOTS.with(|r| {
        for v in r.borrow_mut().iter_mut() {
            visitor.visit_nanbox_f64_slot(v);
        }
    });
    // Also mark interned key strings so GC doesn't sweep them mid-parse.
    PARSE_KEY_CACHE.with(|c| {
        for ptr in c.borrow_mut().values_mut() {
            visitor.visit_tagged_raw_const_ptr_slot(ptr, crate::value::STRING_TAG);
        }
    });
    PARSE_SHAPE_CACHE.with(|c| {
        for entry in c.borrow_mut().iter_mut() {
            visitor.visit_raw_mut_ptr_slot(&mut entry.keys_array);
            for key in entry.keys.iter_mut() {
                visitor.visit_tagged_raw_const_ptr_slot(key, crate::value::STRING_TAG);
            }
        }
    });
}

#[cfg(test)]
pub(crate) fn test_seed_parse_roots(value: f64, key_ptr: *const StringHeader) {
    PARSE_ROOTS.with(|r| {
        let mut r = r.borrow_mut();
        r.clear();
        r.push(value);
    });
    PARSE_KEY_CACHE.with(|c| {
        let mut c = c.borrow_mut();
        c.clear();
        c.insert(b"test".to_vec(), key_ptr);
    });
}

#[cfg(test)]
pub(crate) fn test_parse_roots_snapshot() -> (u64, usize) {
    let value_bits =
        PARSE_ROOTS.with(|r| r.borrow().first().copied().map(f64::to_bits).unwrap_or(0));
    let key_ptr = PARSE_KEY_CACHE.with(|c| {
        c.borrow()
            .get(b"test".as_slice())
            .copied()
            .map(|ptr| ptr as usize)
            .unwrap_or(0)
    });
    (value_bits, key_ptr)
}

// ─── Zero-copy string access + tag/pointer helpers ───────────────────────────

#[inline]
pub(crate) unsafe fn str_from_header<'a>(ptr: *const StringHeader) -> Option<&'a str> {
    if ptr.is_null() || (ptr as usize) < 0x1000 {
        return None;
    }
    let len = (*ptr).byte_len as usize;
    let data_ptr = (ptr as *const u8).add(std::mem::size_of::<StringHeader>());
    let bytes = std::slice::from_raw_parts(data_ptr, len);
    Some(std::str::from_utf8_unchecked(bytes))
}

#[inline]
pub(crate) fn is_raw_pointer(bits: u64) -> bool {
    let exponent = (bits >> 52) & 0x7FF;
    let mantissa = bits & 0x000F_FFFF_FFFF_FFFF;
    let sign = bits >> 63;
    exponent == 0 && mantissa != 0 && sign == 0
}

#[inline]
pub(crate) unsafe fn extract_pointer(bits: u64) -> Option<*const u8> {
    let tag = bits & 0xFFFF_0000_0000_0000;
    if tag == POINTER_TAG {
        Some((bits & POINTER_MASK) as *const u8)
    } else if is_raw_pointer(bits) {
        Some(bits as *const u8)
    } else {
        None
    }
}

/// Read the GC header's object type tag for a user-space heap pointer.
/// The GcHeader sits 8 bytes before `ptr`; its first byte is `obj_type`.
/// Returns 0 when `ptr` is null or in the low-memory guard range.
#[inline]
pub(crate) unsafe fn gc_obj_type(ptr: *const u8) -> u8 {
    if ptr.is_null() || (ptr as usize) < 0x1000 {
        return 0;
    }
    // GcHeader.obj_type is at offset 0 (see crate::gc::GcHeader layout).
    *(ptr.sub(crate::gc::GC_HEADER_SIZE))
}

/// NaN-box a string pointer as f64 (STRING_TAG)
#[inline]
pub(crate) fn nanbox_string_f64(ptr: *const StringHeader) -> f64 {
    f64::from_bits(STRING_TAG | (ptr as u64 & POINTER_MASK))
}

/// NaN-box an object/array pointer as f64 (POINTER_TAG)
#[inline]
pub(crate) fn nanbox_pointer_f64(ptr: *const u8) -> f64 {
    f64::from_bits(POINTER_TAG | (ptr as u64 & POINTER_MASK))
}

// ─── Tests ───────────────────────────────────────────────────────────────────

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

    #[test]
    fn direct_parse_shared_shape_preserves_duplicate_key_semantics() {
        let input = br#"{"a":1,"a":2,"b":{"c":3}}"#;
        let text = js_string_from_bytes(input.as_ptr(), input.len() as u32);
        let value = unsafe { js_json_parse(text) };
        let obj = (value.bits() & POINTER_MASK) as *const crate::ObjectHeader;
        let key_a = js_string_from_bytes(b"a".as_ptr(), 1);
        let key_b = js_string_from_bytes(b"b".as_ptr(), 1);

        unsafe {
            assert_eq!((*(*obj).keys_array).length, 2);
        }
        let a = crate::object::js_object_get_field_by_name(obj, key_a);
        assert_eq!(f64::from_bits(a.bits()), 2.0);

        let b = crate::object::js_object_get_field_by_name(obj, key_b);
        assert!(b.is_pointer());
        let nested = (b.bits() & POINTER_MASK) as *const crate::ObjectHeader;
        unsafe {
            assert_eq!((*(*nested).keys_array).length, 1);
        }
    }

    #[test]
    fn direct_parse_shared_shape_roundtrips_array_records() {
        let input = br#"[{"id":1,"name":"item_1","nested":{"x":1,"y":2}},{"id":2,"name":"item_2","nested":{"x":2,"y":4}}]"#;
        let text = js_string_from_bytes(input.as_ptr(), input.len() as u32);
        let value = unsafe { js_json_parse(text) };
        let output = unsafe { js_json_stringify(f64::from_bits(value.bits()), TYPE_UNKNOWN) };

        assert_eq!(
            unsafe { str_from_header(output).unwrap() },
            std::str::from_utf8(input).unwrap()
        );
    }

    #[test]
    fn stringify_non_ascii_output_keeps_utf16_len_distinct_from_bytes() {
        let input = crate::string::js_string_from_bytes("é".as_ptr(), "é".len() as u32);
        let value = f64::from_bits(crate::value::JSValue::string_ptr(input).bits());
        let output = unsafe { js_json_stringify(value, TYPE_UNKNOWN) };

        assert_eq!(unsafe { str_from_header(output).unwrap() }, "\"é\"");
        assert_eq!(unsafe { (*output).byte_len }, 4);
        assert_eq!(unsafe { (*output).utf16_len }, 3);
        assert_eq!(unsafe { (*output).flags }, 0);
    }

    #[test]
    fn stringify_wtf8_lone_surrogate_emits_unicode_escape() {
        // Issue #1182: a string containing a WTF-8 lone high surrogate
        // (U+D83D as bytes 0xED 0xA0 0xBD) must stringify to the `\ud83d`
        // escape Node's JSON.stringify produces, not the raw invalid-UTF-8
        // bytes.
        let bytes: [u8; 3] = [0xED, 0xA0, 0xBD];
        let input = crate::string::js_string_from_wtf8_bytes(bytes.as_ptr(), bytes.len() as u32);
        let value = f64::from_bits(crate::value::JSValue::string_ptr(input).bits());
        let output = unsafe { js_json_stringify(value, TYPE_UNKNOWN) };

        assert_eq!(unsafe { str_from_header(output).unwrap() }, "\"\\ud83d\"");
    }

    #[test]
    fn stringify_pairs_adjacent_wtf8_surrogates_as_astral_utf8() {
        // Issue #1182 follow-up: when a StringDecoder('utf16le') stream is
        // flushed mid-pair via .end(), the two halves end up as 6 WTF-8
        // bytes (high triple + low triple) in the concatenated result.
        // V8's JSON.stringify pairs them and emits the astral codepoint's
        // UTF-8; Perry must match. 👍 = U+1F44D = F0 9F 91 8D.
        let bytes: [u8; 6] = [0xED, 0xA0, 0xBD, 0xED, 0xB1, 0x8D];
        let input = crate::string::js_string_from_wtf8_bytes(bytes.as_ptr(), bytes.len() as u32);
        let value = f64::from_bits(crate::value::JSValue::string_ptr(input).bits());
        let output = unsafe { js_json_stringify(value, TYPE_UNKNOWN) };

        assert_eq!(unsafe { str_from_header(output).unwrap() }, "\"\u{1F44D}\"");
    }

    #[test]
    fn stringify_high_surrogate_then_bmp_still_escapes_high() {
        // Sanity: a high surrogate followed by a non-surrogate must NOT
        // be re-encoded — only valid adjacent (high, low) pairs collapse.
        // Input is 0xED A0 BD ('a' = 0x61), expected `"\ud83da"`.
        let bytes: [u8; 4] = [0xED, 0xA0, 0xBD, b'a'];
        let input = crate::string::js_string_from_wtf8_bytes(bytes.as_ptr(), bytes.len() as u32);
        let value = f64::from_bits(crate::value::JSValue::string_ptr(input).bits());
        let output = unsafe { js_json_stringify(value, TYPE_UNKNOWN) };

        assert_eq!(unsafe { str_from_header(output).unwrap() }, "\"\\ud83da\"");
    }

    #[test]
    fn stringify_3byte_utf8_below_surrogate_block_is_unchanged() {
        // U+D7FF is the codepoint immediately before the surrogate block.
        // It also starts with 0xED (0xED 0x9F 0xBF) so it exercises the
        // boundary in the WTF-8 detector — must NOT escape, must pass
        // through as raw UTF-8 bytes.
        let s = "\u{D7FF}";
        let input = crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32);
        let value = f64::from_bits(crate::value::JSValue::string_ptr(input).bits());
        let output = unsafe { js_json_stringify(value, TYPE_UNKNOWN) };

        let mut expected = String::from("\"");
        expected.push_str(s);
        expected.push('"');
        assert_eq!(unsafe { str_from_header(output).unwrap() }, expected);
    }
}
