//! HTTP Fetch module (node-fetch compatible)
//!
//! Native implementation of the 'node-fetch' npm package using reqwest.
//! Provides fetch() function for making HTTP requests.

use perry_runtime::{
    js_array_alloc, js_array_push, js_object_alloc, js_object_set_field, js_object_set_keys,
    js_string_from_bytes, JSValue, StringHeader,
};
use std::collections::HashMap;
use std::sync::Mutex;

use crate::common::async_bridge::{queue_promise_resolution, spawn};

// Response handle storage
lazy_static::lazy_static! {
    static ref FETCH_RESPONSES: Mutex<HashMap<usize, FetchResponse>> = Mutex::new(HashMap::new());
    static ref NEXT_RESPONSE_ID: Mutex<usize> = Mutex::new(1);
    static ref STREAM_HANDLES: Mutex<HashMap<usize, StreamState>> = Mutex::new(HashMap::new());
    static ref NEXT_STREAM_ID: Mutex<usize> = Mutex::new(1);

    /// Shared HTTP client — reuses connection pool, DNS cache, and TLS session cache
    /// across all fetch() calls. Without this, each fetch allocates a fresh
    /// reqwest::Client (~250KB of state per request) and the memory never gets
    /// reused, causing unbounded RSS growth in long-running services.
    ///
    /// Sets a default `User-Agent` so endpoints that reject anonymous requests
    /// (api.github.com being the canonical example — closes #236) work out of
    /// the box. Per-request `User-Agent` headers passed via `fetch(url, {
    /// headers: { "User-Agent": "..." } })` override this default; reqwest's
    /// `RequestBuilder::header` replaces the client-level value.
    static ref HTTP_CLIENT: reqwest::Client = reqwest::Client::builder()
        .user_agent(concat!("perry/", env!("CARGO_PKG_VERSION")))
        .pool_idle_timeout(std::time::Duration::from_secs(90))
        .pool_max_idle_per_host(16)
        .tcp_keepalive(std::time::Duration::from_secs(60))
        .build()
        .unwrap_or_else(|_| reqwest::Client::new());
}

struct StreamState {
    status: u8, // 0=connecting, 1=streaming, 2=done, 3=error
    pending_lines: Vec<String>,
    partial: String,
    #[allow(dead_code)]
    http_status: u16,
    #[allow(dead_code)]
    error: String,
}

struct FetchResponse {
    status: u16,
    status_text: String,
    headers: HashMap<String, String>,
    body: Vec<u8>,
    /// Cached Headers handle id, allocated on first `response.headers`
    /// access. None until a property/method dispatcher needs to expose
    /// the headers as a Headers instance. The Vec form (insertion-ordered
    /// HeadersStore) is what dispatch_headers_method reads, so we copy
    /// the HashMap into a fresh HeadersStore the first time and cache
    /// the resulting registry id; subsequent reads of `.headers` return
    /// the same id (preserves `res.headers === res.headers`).
    cached_headers_id: Option<usize>,
}

/// Extract the registry id from a Web Fetch handle f64 value.
///
/// Web Fetch handles (Request / Response / Headers / Blob) are returned by
/// constructors as NaN-boxed POINTER_TAG values via `js_nanbox_pointer`, so
/// they look like pointers to the runtime's dispatchers (`js_object_get_field_by_name`,
/// `js_native_call_method`) and route through `HANDLE_PROPERTY/METHOD_DISPATCH`
/// for untyped property access — fixing the hono `request.url` blocker (#421).
///
/// This helper is tolerant during the cross-subsystem migration: it accepts both
/// the canonical NaN-boxed form (top16 ≥ 0x7FF8) AND the legacy raw-float form
/// (`1.0` = id 1, denormal bits, etc.). Other handle subsystems (streams / ws /
/// net / DB) still use the legacy form until Phase 2 of the unification migrates
/// them to NaN-boxed too.
#[inline]
pub(crate) fn handle_id(value: f64) -> usize {
    let bits = value.to_bits();
    let top16 = bits >> 48;
    if top16 >= 0x7FF8 {
        // NaN-boxed (POINTER_TAG / STRING_TAG / etc.): extract lower 48 bits.
        (bits & 0x0000_FFFF_FFFF_FFFF) as usize
    } else if top16 == 0 && bits != 0 {
        // Raw integer bits as f64 (denormal-encoded handle id): use bits directly.
        bits as usize
    } else {
        // Legacy float form (1.0 → 1): float-to-int truncation.
        value as usize
    }
}

/// NaN-box a Web Fetch handle id (registry index) into a POINTER_TAG f64
/// for return across the FFI boundary. Pairs with `handle_id` on accessor entry.
#[inline]
pub(crate) fn handle_to_f64(id: usize) -> f64 {
    perry_runtime::value::js_nanbox_pointer(id as i64)
}

/// Helper to extract string from StringHeader pointer
pub(crate) unsafe fn string_from_header(ptr: *const StringHeader) -> Option<String> {
    // NaN-boxed TAG_UNDEFINED (0x7FFC_0000_0000_0001) unboxes to 0x1
    // after POINTER_MASK. Treat any pointer below page size as invalid.
    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);
    std::str::from_utf8(bytes).ok().map(|s| s.to_string())
}

/// Diagnostic: return the number of FETCH_RESPONSES entries.
/// Useful for detecting response handle leaks in long-running services.
#[no_mangle]
pub extern "C" fn js_fetch_response_count() -> i64 {
    FETCH_RESPONSES.lock().map(|g| g.len() as i64).unwrap_or(-1)
}

/// Build a NaN-boxed JSValue holding a real `Error` object for promise
/// rejection. Pre-fix (#236) every fetch error site NaN-boxed a bare
/// `*StringHeader` with `POINTER_TAG` (0x7FFD), which the uncaught-exception
/// printer in `perry-runtime/src/exception.rs` then read as an
/// `*ObjectHeader.object_type` u32 — `byte_len` of the message string is
/// neither `OBJECT_TYPE_ERROR` (2) nor `OBJECT_TYPE_REGULAR` (1), so the
/// printer fell through to the generic stringifier which printed
/// `Uncaught exception: [object Object]`. Allocating a real
/// `ErrorHeader` makes the printer take the dedicated Error arm and emit
/// `Uncaught exception: Error: <message>` with a stack frame.
unsafe fn fetch_error_bits<S: AsRef<str>>(msg: S) -> u64 {
    let s = msg.as_ref();
    let msg_str = js_string_from_bytes(s.as_ptr(), s.len() as u32);
    let err = perry_runtime::error::js_error_new_with_message(msg_str);
    JSValue::pointer(err as *const u8).bits()
}

/// Perform a GET request
/// fetch(url) -> Promise<Response>
#[no_mangle]
pub unsafe extern "C" fn js_fetch_get(url_ptr: *const StringHeader) -> *mut perry_runtime::Promise {
    let promise = perry_runtime::js_promise_new();
    let promise_ptr = promise as usize;

    let url = match string_from_header(url_ptr) {
        Some(u) => u,
        None => {
            let err_msg = "Invalid URL";
            let err_bits = fetch_error_bits(err_msg);
            queue_promise_resolution(promise_ptr, false, err_bits);
            return promise;
        }
    };

    spawn(async move {
        match HTTP_CLIENT.get(&url).send().await {
            Ok(response) => {
                let status = response.status().as_u16();
                let status_text = response
                    .status()
                    .canonical_reason()
                    .unwrap_or("")
                    .to_string();

                let mut headers = HashMap::new();
                for (key, value) in response.headers() {
                    if let Ok(v) = value.to_str() {
                        headers.insert(key.to_string(), v.to_string());
                    }
                }

                let body = response.bytes().await.unwrap_or_default().to_vec();

                // Store response
                let mut id_guard = NEXT_RESPONSE_ID.lock().unwrap();
                let response_id = *id_guard;
                *id_guard += 1;
                drop(id_guard);

                FETCH_RESPONSES.lock().unwrap().insert(
                    response_id,
                    FetchResponse {
                        status,
                        status_text,
                        headers,
                        body,
                        cached_headers_id: None,
                    },
                );

                // Return response handle
                let result_bits = handle_to_f64(response_id).to_bits();
                queue_promise_resolution(promise_ptr, true, result_bits);
            }
            Err(e) => {
                let err_msg = format!("Fetch error: {}", e);
                let err_bits = fetch_error_bits(&err_msg);
                queue_promise_resolution(promise_ptr, false, err_bits);
            }
        }
    });

    promise
}

/// Perform a GET request with Authorization header
/// Used when fetch(url, { headers: { Authorization: "Bearer ..." } }) is needed
#[no_mangle]
pub unsafe extern "C" fn js_fetch_get_with_auth(
    url_ptr: *const StringHeader,
    auth_header_ptr: *const StringHeader,
) -> *mut perry_runtime::Promise {
    let promise = perry_runtime::js_promise_new();
    let promise_ptr = promise as usize;

    let url = match string_from_header(url_ptr) {
        Some(u) => u,
        None => {
            let err_msg = "Invalid URL";
            let err_bits = fetch_error_bits(err_msg);
            queue_promise_resolution(promise_ptr, false, err_bits);
            return promise;
        }
    };

    let auth_header = string_from_header(auth_header_ptr).unwrap_or_default();

    spawn(async move {
        let client = HTTP_CLIENT.clone();
        let mut request = client.get(&url);
        if !auth_header.is_empty() {
            request = request.header("Authorization", &auth_header);
        }
        match request.send().await {
            Ok(response) => {
                let status = response.status().as_u16();
                let status_text = response
                    .status()
                    .canonical_reason()
                    .unwrap_or("")
                    .to_string();

                let mut headers = HashMap::new();
                for (key, value) in response.headers() {
                    if let Ok(v) = value.to_str() {
                        headers.insert(key.to_string(), v.to_string());
                    }
                }

                let body = response.bytes().await.unwrap_or_default().to_vec();

                let mut id_guard = NEXT_RESPONSE_ID.lock().unwrap();
                let response_id = *id_guard;
                *id_guard += 1;
                drop(id_guard);

                FETCH_RESPONSES.lock().unwrap().insert(
                    response_id,
                    FetchResponse {
                        status,
                        status_text,
                        headers,
                        body,
                        cached_headers_id: None,
                    },
                );

                let result_bits = handle_to_f64(response_id).to_bits();
                queue_promise_resolution(promise_ptr, true, result_bits);
            }
            Err(e) => {
                let err_msg = format!("Fetch error: {}", e);
                let err_bits = fetch_error_bits(&err_msg);
                queue_promise_resolution(promise_ptr, false, err_bits);
            }
        }
    });

    promise
}

/// Perform a POST request with Authorization header and JSON body
/// fetchPostWithAuth(url, authHeader, body) -> Promise<Response>
#[no_mangle]
pub unsafe extern "C" fn js_fetch_post_with_auth(
    url_ptr: *const StringHeader,
    auth_header_ptr: *const StringHeader,
    body_ptr: *const StringHeader,
) -> *mut perry_runtime::Promise {
    let promise = perry_runtime::js_promise_new();
    let promise_ptr = promise as usize;

    let url = match string_from_header(url_ptr) {
        Some(u) => u,
        None => {
            let err_msg = "Invalid URL";
            let err_bits = fetch_error_bits(err_msg);
            queue_promise_resolution(promise_ptr, false, err_bits);
            return promise;
        }
    };

    let auth_header = string_from_header(auth_header_ptr).unwrap_or_default();
    let body = string_from_header(body_ptr).unwrap_or_default();

    spawn(async move {
        let client = HTTP_CLIENT.clone();
        let mut request = client.post(&url).header("Content-Type", "application/json");
        if !auth_header.is_empty() {
            request = request.header("Authorization", &auth_header);
        }
        request = request.body(body);
        match request.send().await {
            Ok(response) => {
                let status = response.status().as_u16();
                let status_text = response
                    .status()
                    .canonical_reason()
                    .unwrap_or("")
                    .to_string();

                let mut headers = HashMap::new();
                for (key, value) in response.headers() {
                    if let Ok(v) = value.to_str() {
                        headers.insert(key.to_string(), v.to_string());
                    }
                }

                let body = response.bytes().await.unwrap_or_default().to_vec();

                let mut id_guard = NEXT_RESPONSE_ID.lock().unwrap();
                let response_id = *id_guard;
                *id_guard += 1;
                drop(id_guard);

                FETCH_RESPONSES.lock().unwrap().insert(
                    response_id,
                    FetchResponse {
                        status,
                        status_text,
                        headers,
                        body,
                        cached_headers_id: None,
                    },
                );

                let result_bits = handle_to_f64(response_id).to_bits();
                queue_promise_resolution(promise_ptr, true, result_bits);
            }
            Err(e) => {
                let err_msg = format!("Fetch error: {}", e);
                let err_bits = fetch_error_bits(&err_msg);
                queue_promise_resolution(promise_ptr, false, err_bits);
            }
        }
    });

    promise
}

/// Perform a POST request with body
/// fetch(url, { method: 'POST', body: '...' }) -> Promise<Response>
#[no_mangle]
pub unsafe extern "C" fn js_fetch_post(
    url_ptr: *const StringHeader,
    body_ptr: *const StringHeader,
    content_type_ptr: *const StringHeader,
) -> *mut perry_runtime::Promise {
    let promise = perry_runtime::js_promise_new();
    let promise_ptr = promise as usize;

    let url = match string_from_header(url_ptr) {
        Some(u) => u,
        None => {
            let err_msg = "Invalid URL";
            let err_bits = fetch_error_bits(err_msg);
            queue_promise_resolution(promise_ptr, false, err_bits);
            return promise;
        }
    };

    let body = string_from_header(body_ptr).unwrap_or_default();
    let content_type =
        string_from_header(content_type_ptr).unwrap_or_else(|| "application/json".to_string());

    spawn(async move {
        let client = HTTP_CLIENT.clone();
        match client
            .post(&url)
            .header("Content-Type", &content_type)
            .body(body)
            .send()
            .await
        {
            Ok(response) => {
                let status = response.status().as_u16();
                let status_text = response
                    .status()
                    .canonical_reason()
                    .unwrap_or("")
                    .to_string();

                let mut headers = HashMap::new();
                for (key, value) in response.headers() {
                    if let Ok(v) = value.to_str() {
                        headers.insert(key.to_string(), v.to_string());
                    }
                }

                let body = response.bytes().await.unwrap_or_default().to_vec();

                // Store response
                let mut id_guard = NEXT_RESPONSE_ID.lock().unwrap();
                let response_id = *id_guard;
                *id_guard += 1;
                drop(id_guard);

                FETCH_RESPONSES.lock().unwrap().insert(
                    response_id,
                    FetchResponse {
                        status,
                        status_text,
                        headers,
                        body,
                        cached_headers_id: None,
                    },
                );

                // Return response handle
                let result_bits = handle_to_f64(response_id).to_bits();
                queue_promise_resolution(promise_ptr, true, result_bits);
            }
            Err(e) => {
                let err_msg = format!("Fetch error: {}", e);
                let err_bits = fetch_error_bits(&err_msg);
                queue_promise_resolution(promise_ptr, false, err_bits);
            }
        }
    });

    promise
}

/// Perform a fetch request with full options (method, headers, body)
/// This is the most flexible fetch function
#[no_mangle]
pub unsafe extern "C" fn js_fetch_with_options(
    url_ptr: *const StringHeader,
    method_ptr: *const StringHeader,
    body_ptr: *const StringHeader,
    headers_json_ptr: *const StringHeader,
) -> *mut perry_runtime::Promise {
    let promise = perry_runtime::js_promise_new();
    let promise_ptr = promise as usize;

    let url = match string_from_header(url_ptr) {
        Some(u) => u,
        None => {
            let err_msg = "Invalid URL";
            let err_bits = fetch_error_bits(err_msg);
            queue_promise_resolution(promise_ptr, false, err_bits);
            return promise;
        }
    };

    let method = string_from_header(method_ptr).unwrap_or_else(|| "GET".to_string());
    let body = string_from_header(body_ptr);
    let headers_json = string_from_header(headers_json_ptr).unwrap_or_else(|| "{}".to_string());

    // Parse headers from JSON
    let custom_headers: HashMap<String, String> =
        serde_json::from_str(&headers_json).unwrap_or_default();

    spawn(async move {
        let client = HTTP_CLIENT.clone();
        let mut request = match method.to_uppercase().as_str() {
            "POST" => client.post(&url),
            "PUT" => client.put(&url),
            "DELETE" => client.delete(&url),
            "PATCH" => client.patch(&url),
            "HEAD" => client.head(&url),
            _ => client.get(&url), // Default to GET
        };

        // Add custom headers
        for (key, value) in &custom_headers {
            request = request.header(key.as_str(), value.as_str());
        }

        // Add body if present
        if let Some(b) = body {
            request = request.body(b);
        }

        match request.send().await {
            Ok(response) => {
                let status = response.status().as_u16();
                let status_text = response
                    .status()
                    .canonical_reason()
                    .unwrap_or("")
                    .to_string();

                let mut headers = HashMap::new();
                for (key, value) in response.headers() {
                    if let Ok(v) = value.to_str() {
                        headers.insert(key.to_string(), v.to_string());
                    }
                }

                let body = response.bytes().await.unwrap_or_default().to_vec();

                // Store response
                let mut id_guard = NEXT_RESPONSE_ID.lock().unwrap();
                let response_id = *id_guard;
                *id_guard += 1;
                drop(id_guard);

                FETCH_RESPONSES.lock().unwrap().insert(
                    response_id,
                    FetchResponse {
                        status,
                        status_text,
                        headers,
                        body,
                        cached_headers_id: None,
                    },
                );

                // Return response handle
                let result_bits = handle_to_f64(response_id).to_bits();
                queue_promise_resolution(promise_ptr, true, result_bits);
            }
            Err(e) => {
                let err_msg = format!("Fetch error: {}", e);
                let err_bits = fetch_error_bits(&err_msg);
                queue_promise_resolution(promise_ptr, false, err_bits);
            }
        }
    });

    promise
}

/// Get response status code
/// response.status -> number
#[no_mangle]
pub extern "C" fn js_fetch_response_status(handle: f64) -> f64 {
    let response_id = handle_id(handle);
    let guard = FETCH_RESPONSES.lock().unwrap();
    match guard.get(&response_id) {
        Some(resp) => resp.status as f64,
        None => 0.0,
    }
}

/// Get response status text
/// response.statusText -> string
#[no_mangle]
pub extern "C" fn js_fetch_response_status_text(handle: f64) -> *mut StringHeader {
    let response_id = handle_id(handle);
    let guard = FETCH_RESPONSES.lock().unwrap();
    match guard.get(&response_id) {
        Some(resp) => {
            js_string_from_bytes(resp.status_text.as_ptr(), resp.status_text.len() as u32)
        }
        None => std::ptr::null_mut(),
    }
}

/// Check if response was successful (status 200-299)
/// response.ok -> boolean
#[no_mangle]
pub extern "C" fn js_fetch_response_ok(handle: f64) -> f64 {
    let response_id = handle_id(handle);
    let guard = FETCH_RESPONSES.lock().unwrap();
    match guard.get(&response_id) {
        Some(resp) => {
            if resp.status >= 200 && resp.status < 300 {
                1.0
            } else {
                0.0
            }
        }
        None => 0.0,
    }
}

/// Get response body as text
/// response.text() -> Promise<string>
///
/// The body is already in-memory at the point of the call, so resolve
/// the promise synchronously via `js_promise_resolve` rather than
/// routing through the deferred `PENDING_RESOLUTIONS` queue. This
/// avoids a hang in the LLVM backend's await loop (which does not
/// drain the pump — see `crates/perry-codegen/src/expr.rs`
/// `Expr::Await` for the rationale).
#[no_mangle]
pub unsafe extern "C" fn js_fetch_response_text(handle: f64) -> *mut perry_runtime::Promise {
    let promise = perry_runtime::js_promise_new();
    let response_id = handle_id(handle);

    // Clone the body — JS spec says each body is readable once, but other
    // accessors (status, headers) may still be used afterwards. The Response
    // entry stays in FETCH_RESPONSES until cleanup; in practice the test suite
    // doesn't accumulate enough responses to matter.
    let body = {
        let guard = FETCH_RESPONSES.lock().unwrap();
        match guard.get(&response_id) {
            Some(resp) => resp.body.clone(),
            None => {
                let err_msg = "Invalid response handle";
                let err_nan = f64::from_bits(fetch_error_bits(err_msg));
                perry_runtime::js_promise_reject(promise, err_nan);
                return promise;
            }
        }
    };

    // Convert body to string and resolve synchronously.
    let text = String::from_utf8_lossy(&body).to_string();
    let result_str = js_string_from_bytes(text.as_ptr(), text.len() as u32);
    let result_nan = f64::from_bits(JSValue::string_ptr(result_str).bits());
    perry_runtime::js_promise_resolve(promise, result_nan);
    promise
}

/// Convert serde_json::Value to JSValue
unsafe fn json_value_to_jsvalue(value: &serde_json::Value) -> JSValue {
    match value {
        serde_json::Value::Null => JSValue::null(),
        serde_json::Value::Bool(b) => JSValue::bool(*b),
        serde_json::Value::Number(n) => {
            if let Some(f) = n.as_f64() {
                JSValue::number(f)
            } else if let Some(i) = n.as_i64() {
                JSValue::number(i as f64)
            } else {
                JSValue::number(0.0)
            }
        }
        serde_json::Value::String(s) => {
            let ptr = js_string_from_bytes(s.as_ptr(), s.len() as u32);
            JSValue::string_ptr(ptr)
        }
        serde_json::Value::Array(arr) => {
            let js_arr = js_array_alloc(arr.len() as u32);
            for item in arr {
                js_array_push(js_arr, json_value_to_jsvalue(item));
            }
            JSValue::object_ptr(js_arr as *mut u8)
        }
        serde_json::Value::Object(obj) => {
            let js_obj = js_object_alloc(0, obj.len() as u32);
            // Create keys array for property names
            let keys_arr = js_array_alloc(obj.len() as u32);
            for (idx, (key, val)) in obj.iter().enumerate() {
                // Add key to keys array
                let key_ptr = js_string_from_bytes(key.as_ptr(), key.len() as u32);
                js_array_push(keys_arr, JSValue::string_ptr(key_ptr));
                // Set field value
                js_object_set_field(js_obj, idx as u32, json_value_to_jsvalue(val));
            }
            // Associate keys with object
            js_object_set_keys(js_obj, keys_arr);
            JSValue::object_ptr(js_obj as *mut u8)
        }
    }
}

/// Get response body as JSON (parses and returns proper JS object)
/// response.json() -> Promise<object>
#[no_mangle]
pub unsafe extern "C" fn js_fetch_response_json(handle: f64) -> *mut perry_runtime::Promise {
    let promise = perry_runtime::js_promise_new();
    let response_id = handle_id(handle);

    // Take (not clone) the body — consumes the FETCH_RESPONSES entry.
    let body = {
        let guard = FETCH_RESPONSES.lock().unwrap();
        match guard.get(&response_id) {
            Some(resp) => resp.body.clone(),
            None => {
                let err_msg = "Invalid response handle";
                let err_nan = f64::from_bits(fetch_error_bits(err_msg));
                perry_runtime::js_promise_reject(promise, err_nan);
                return promise;
            }
        }
    };

    // Convert body to string and parse as JSON. Resolve the promise
    // synchronously — see comment on `js_fetch_response_text`.
    let text = String::from_utf8_lossy(&body).to_string();
    match serde_json::from_str::<serde_json::Value>(&text) {
        Ok(json_value) => {
            let js_value = json_value_to_jsvalue(&json_value);
            let result_nan = f64::from_bits(js_value.bits());
            perry_runtime::js_promise_resolve(promise, result_nan);
        }
        Err(e) => {
            let err_msg = format!("JSON parse error: {}", e);
            let err_nan = f64::from_bits(fetch_error_bits(&err_msg));
            perry_runtime::js_promise_reject(promise, err_nan);
        }
    }

    promise
}

/// Simple fetch that returns text directly (convenience function)
/// fetchText(url) -> Promise<string>
#[no_mangle]
pub unsafe extern "C" fn js_fetch_text(
    url_ptr: *const StringHeader,
) -> *mut perry_runtime::Promise {
    let promise = perry_runtime::js_promise_new();
    let promise_ptr = promise as usize;

    let url = match string_from_header(url_ptr) {
        Some(u) => u,
        None => {
            let err_msg = "Invalid URL";
            let err_bits = fetch_error_bits(err_msg);
            queue_promise_resolution(promise_ptr, false, err_bits);
            return promise;
        }
    };

    spawn(async move {
        match HTTP_CLIENT.get(&url).send().await {
            Ok(response) => match response.text().await {
                Ok(text) => {
                    let result_str = js_string_from_bytes(text.as_ptr(), text.len() as u32);
                    let result_bits = JSValue::pointer(result_str as *const u8).bits();
                    queue_promise_resolution(promise_ptr, true, result_bits);
                }
                Err(e) => {
                    let err_msg = format!("Read error: {}", e);
                    let err_bits = fetch_error_bits(&err_msg);
                    queue_promise_resolution(promise_ptr, false, err_bits);
                }
            },
            Err(e) => {
                let err_msg = format!("Fetch error: {}", e);
                let err_bits = fetch_error_bits(&err_msg);
                queue_promise_resolution(promise_ptr, false, err_bits);
            }
        }
    });

    promise
}

// ========================================================================
// SSE Streaming Functions
// ========================================================================

#[no_mangle]
pub unsafe extern "C" fn js_fetch_stream_start(
    url_ptr: *const StringHeader,
    method_ptr: *const StringHeader,
    body_ptr: *const StringHeader,
    headers_json_ptr: *const StringHeader,
) -> f64 {
    let url = string_from_header(url_ptr).unwrap_or_default();
    let method = string_from_header(method_ptr).unwrap_or_else(|| "POST".to_string());
    let body = string_from_header(body_ptr);
    let headers_json = string_from_header(headers_json_ptr).unwrap_or_else(|| "{}".to_string());
    let custom_headers: HashMap<String, String> =
        serde_json::from_str(&headers_json).unwrap_or_default();
    let mut id_guard = NEXT_STREAM_ID.lock().unwrap();
    let stream_id = *id_guard;
    *id_guard += 1;
    drop(id_guard);
    STREAM_HANDLES.lock().unwrap().insert(
        stream_id,
        StreamState {
            status: 0,
            pending_lines: Vec::new(),
            partial: String::new(),
            http_status: 0,
            error: String::new(),
        },
    );
    let sid = stream_id;
    spawn(async move {
        let client = HTTP_CLIENT.clone();
        let mut request = match method.to_uppercase().as_str() {
            "POST" => client.post(&url),
            "PUT" => client.put(&url),
            "PATCH" => client.patch(&url),
            _ => client.get(&url),
        };
        for (key, value) in &custom_headers {
            request = request.header(key.as_str(), value.as_str());
        }
        if let Some(b) = body {
            request = request.body(b);
        }
        match request.send().await {
            Ok(mut response) => {
                let http_status = response.status().as_u16();
                {
                    let mut g = STREAM_HANDLES.lock().unwrap();
                    if let Some(s) = g.get_mut(&sid) {
                        s.http_status = http_status;
                        s.status = 1;
                    }
                }
                loop {
                    match response.chunk().await {
                        Ok(Some(chunk)) => {
                            let text = String::from_utf8_lossy(&chunk).to_string();
                            let mut g = STREAM_HANDLES.lock().unwrap();
                            if let Some(s) = g.get_mut(&sid) {
                                s.partial.push_str(&text);
                                loop {
                                    if let Some(pos) = s.partial.find('\n') {
                                        let line = s.partial[..pos].to_string();
                                        s.partial = s.partial[pos + 1..].to_string();
                                        if !line.is_empty() {
                                            s.pending_lines.push(line);
                                        }
                                    } else {
                                        break;
                                    }
                                }
                            } else {
                                break;
                            }
                        }
                        Ok(None) => {
                            let mut g = STREAM_HANDLES.lock().unwrap();
                            if let Some(s) = g.get_mut(&sid) {
                                if !s.partial.is_empty() {
                                    let r = std::mem::take(&mut s.partial);
                                    s.pending_lines.push(r);
                                }
                                s.status = 2;
                            }
                            break;
                        }
                        Err(e) => {
                            let mut g = STREAM_HANDLES.lock().unwrap();
                            if let Some(s) = g.get_mut(&sid) {
                                s.error = format!("Stream error: {}", e);
                                s.status = 3;
                            }
                            break;
                        }
                    }
                }
            }
            Err(e) => {
                let mut g = STREAM_HANDLES.lock().unwrap();
                if let Some(s) = g.get_mut(&sid) {
                    s.error = format!("Connection error: {}", e);
                    s.status = 3;
                }
            }
        }
    });
    stream_id as f64
}

#[no_mangle]
pub extern "C" fn js_fetch_stream_poll(handle: f64) -> *mut StringHeader {
    let id = handle as usize;
    let mut g = STREAM_HANDLES.lock().unwrap();
    if let Some(s) = g.get_mut(&id) {
        if !s.pending_lines.is_empty() {
            let line = s.pending_lines.remove(0);
            return js_string_from_bytes(line.as_ptr(), line.len() as u32);
        }
    }
    js_string_from_bytes("".as_ptr(), 0)
}

#[no_mangle]
pub extern "C" fn js_fetch_stream_status(handle: f64) -> f64 {
    let id = handle as usize;
    let g = STREAM_HANDLES.lock().unwrap();
    if let Some(s) = g.get(&id) {
        s.status as f64
    } else {
        3.0
    }
}

#[no_mangle]
pub extern "C" fn js_fetch_stream_close(handle: f64) -> f64 {
    let id = handle as usize;
    let mut g = STREAM_HANDLES.lock().unwrap();
    if g.remove(&id).is_some() {
        1.0
    } else {
        0.0
    }
}

// ========================================================================
// Web Fetch API: Headers, Request, Response constructors and methods
// ========================================================================

pub(crate) const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001;
const TAG_NULL: u64 = 0x7FFC_0000_0000_0002;
const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003;
const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004;

#[derive(Clone, Default)]
struct HeadersStore {
    /// (lowercase_name, value) entries — insertion order preserved
    entries: Vec<(String, String)>,
}

impl HeadersStore {
    fn set(&mut self, key: &str, value: &str) {
        let lk = key.to_ascii_lowercase();
        for entry in self.entries.iter_mut() {
            if entry.0 == lk {
                entry.1 = value.to_string();
                return;
            }
        }
        self.entries.push((lk, value.to_string()));
    }
    fn get(&self, key: &str) -> Option<&str> {
        let lk = key.to_ascii_lowercase();
        self.entries
            .iter()
            .find(|(k, _)| *k == lk)
            .map(|(_, v)| v.as_str())
    }
    fn has(&self, key: &str) -> bool {
        let lk = key.to_ascii_lowercase();
        self.entries.iter().any(|(k, _)| *k == lk)
    }
    fn delete(&mut self, key: &str) {
        let lk = key.to_ascii_lowercase();
        self.entries.retain(|(k, _)| *k != lk);
    }
    fn from_hashmap(m: &HashMap<String, String>) -> Self {
        let mut s = Self::default();
        for (k, v) in m {
            s.set(k, v);
        }
        s
    }
}

#[derive(Clone)]
struct RequestRecord {
    url: String,
    method: String,
    body: Option<String>,
    #[allow(dead_code)]
    headers: HeadersStore,
}

lazy_static::lazy_static! {
    static ref HEADERS_REGISTRY: Mutex<HashMap<usize, HeadersStore>> = Mutex::new(HashMap::new());
    static ref NEXT_HEADERS_ID: Mutex<usize> = Mutex::new(1);
    static ref REQUEST_REGISTRY: Mutex<HashMap<usize, RequestRecord>> = Mutex::new(HashMap::new());
    static ref NEXT_REQUEST_ID: Mutex<usize> = Mutex::new(1);
    pub(crate) static ref BLOB_REGISTRY: Mutex<HashMap<usize, BlobData>> = Mutex::new(HashMap::new());
    static ref NEXT_BLOB_ID: Mutex<usize> = Mutex::new(1);
}

#[derive(Clone)]
pub(crate) struct BlobData {
    pub(crate) body: Vec<u8>,
    pub(crate) content_type: String,
    // Issue #1211: when the handle was created via `new File(parts, name, opts)`
    // these mirror the spec File interface — Blobs leave both at None.
    pub(crate) file_name: Option<String>,
    pub(crate) last_modified_ms: Option<f64>,
}

impl BlobData {
    pub(crate) fn blob(body: Vec<u8>, content_type: String) -> Self {
        BlobData {
            body,
            content_type,
            file_name: None,
            last_modified_ms: None,
        }
    }
}

pub(crate) fn alloc_blob(data: BlobData) -> usize {
    let mut id_guard = NEXT_BLOB_ID.lock().unwrap();
    let id = *id_guard;
    *id_guard += 1;
    drop(id_guard);
    BLOB_REGISTRY.lock().unwrap().insert(id, data);
    id
}

fn alloc_headers(store: HeadersStore) -> usize {
    let mut id_guard = NEXT_HEADERS_ID.lock().unwrap();
    let id = *id_guard;
    *id_guard += 1;
    drop(id_guard);
    HEADERS_REGISTRY.lock().unwrap().insert(id, store);
    id
}

fn alloc_response(status: u16, status_text: String, headers: HeadersStore, body: Vec<u8>) -> usize {
    let mut id_guard = NEXT_RESPONSE_ID.lock().unwrap();
    let id = *id_guard;
    *id_guard += 1;
    drop(id_guard);
    let hdr_map: HashMap<String, String> = headers.entries.iter().cloned().collect();
    FETCH_RESPONSES.lock().unwrap().insert(
        id,
        FetchResponse {
            status,
            status_text,
            headers: hdr_map,
            body,
            cached_headers_id: None,
        },
    );
    id
}

// ----------------- Headers FFI -----------------

/// new Headers() — returns NaN-boxed POINTER_TAG handle as f64.
/// See `handle_to_f64` / `handle_id` for the encoding contract.
#[no_mangle]
pub extern "C" fn js_headers_new() -> f64 {
    handle_to_f64(alloc_headers(HeadersStore::default()))
}

#[no_mangle]
pub unsafe extern "C" fn js_headers_set(
    handle: f64,
    key_ptr: *const StringHeader,
    value_ptr: *const StringHeader,
) -> f64 {
    let id = handle_id(handle);
    let key = string_from_header(key_ptr).unwrap_or_default();
    let value = string_from_header(value_ptr).unwrap_or_default();
    if let Some(store) = HEADERS_REGISTRY.lock().unwrap().get_mut(&id) {
        store.set(&key, &value);
    }
    f64::from_bits(TAG_UNDEFINED)
}

#[no_mangle]
pub unsafe extern "C" fn js_headers_get(
    handle: f64,
    key_ptr: *const StringHeader,
) -> *mut StringHeader {
    let id = handle_id(handle);
    let key = match string_from_header(key_ptr) {
        Some(k) => k,
        None => return std::ptr::null_mut(),
    };
    if let Some(store) = HEADERS_REGISTRY.lock().unwrap().get(&id) {
        if let Some(v) = store.get(&key) {
            return js_string_from_bytes(v.as_ptr(), v.len() as u32);
        }
    }
    std::ptr::null_mut()
}

#[no_mangle]
pub unsafe extern "C" fn js_headers_has(handle: f64, key_ptr: *const StringHeader) -> f64 {
    let id = handle_id(handle);
    let key = match string_from_header(key_ptr) {
        Some(k) => k,
        None => return f64::from_bits(TAG_FALSE),
    };
    if let Some(store) = HEADERS_REGISTRY.lock().unwrap().get(&id) {
        if store.has(&key) {
            return f64::from_bits(TAG_TRUE);
        }
    }
    f64::from_bits(TAG_FALSE)
}

#[no_mangle]
pub unsafe extern "C" fn js_headers_delete(handle: f64, key_ptr: *const StringHeader) -> f64 {
    let id = handle_id(handle);
    let key = string_from_header(key_ptr).unwrap_or_default();
    if let Some(store) = HEADERS_REGISTRY.lock().unwrap().get_mut(&id) {
        store.delete(&key);
    }
    f64::from_bits(TAG_UNDEFINED)
}

/// Snapshot the headers store sorted by key (WHATWG spec: iteration order is
/// sorted lexicographically by name, regardless of insertion order). Used by
/// `forEach`, `keys`, `values`, `entries`, and `Symbol.iterator` so all five
/// surfaces agree byte-for-byte (refs #576).
fn snapshot_sorted(handle: f64) -> Vec<(String, String)> {
    let id = handle_id(handle);
    let mut entries: Vec<(String, String)> = match HEADERS_REGISTRY.lock().unwrap().get(&id) {
        Some(s) => s.entries.clone(),
        None => return Vec::new(),
    };
    entries.sort_by(|a, b| a.0.cmp(&b.0));
    entries
}

#[no_mangle]
pub extern "C" fn js_headers_for_each(handle: f64, callback: f64) -> f64 {
    let entries = snapshot_sorted(handle);
    // Extract closure pointer from NaN-boxed callback
    let cb_bits = callback.to_bits();
    let cb_ptr = (cb_bits & 0x0000_FFFF_FFFF_FFFF) as i64;
    if cb_ptr == 0 {
        return f64::from_bits(TAG_UNDEFINED);
    }
    let closure = cb_ptr as *const perry_runtime::ClosureHeader;
    for (k, v) in entries {
        let v_ptr = js_string_from_bytes(v.as_ptr(), v.len() as u32);
        let k_ptr = js_string_from_bytes(k.as_ptr(), k.len() as u32);
        let v_nan = JSValue::string_ptr(v_ptr).bits();
        let k_nan = JSValue::string_ptr(k_ptr).bits();
        perry_runtime::js_closure_call2(closure, f64::from_bits(v_nan), f64::from_bits(k_nan));
    }
    f64::from_bits(TAG_UNDEFINED)
}

const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000;

#[inline]
fn nanbox_array_pointer(arr: *mut perry_runtime::ArrayHeader) -> f64 {
    let bits = POINTER_TAG | ((arr as u64) & 0x0000_FFFF_FFFF_FFFF);
    f64::from_bits(bits)
}

/// `headers.keys()` — returns a sorted-by-key array of header names. The
/// returned array is itself iterable, so `for (const k of headers.keys())`,
/// spread, and `Array.from` all work via the array's existing `Symbol.iterator`
/// (refs #576).
#[no_mangle]
pub extern "C" fn js_headers_keys(handle: f64) -> f64 {
    let entries = snapshot_sorted(handle);
    let mut arr = perry_runtime::js_array_alloc(entries.len() as u32);
    for (k, _) in entries {
        let k_ptr = js_string_from_bytes(k.as_ptr(), k.len() as u32);
        let k_nan = JSValue::string_ptr(k_ptr).bits();
        arr = perry_runtime::js_array_push_f64(arr, f64::from_bits(k_nan));
    }
    nanbox_array_pointer(arr)
}

/// `headers.values()` — sorted-by-key array of header values. See `js_headers_keys`.
#[no_mangle]
pub extern "C" fn js_headers_values(handle: f64) -> f64 {
    let entries = snapshot_sorted(handle);
    let mut arr = perry_runtime::js_array_alloc(entries.len() as u32);
    for (_, v) in entries {
        let v_ptr = js_string_from_bytes(v.as_ptr(), v.len() as u32);
        let v_nan = JSValue::string_ptr(v_ptr).bits();
        arr = perry_runtime::js_array_push_f64(arr, f64::from_bits(v_nan));
    }
    nanbox_array_pointer(arr)
}

/// `headers.entries()` — sorted-by-key array of `[key, value]` pair arrays.
/// `for (const [k, v] of headers.entries())` and `for (const [k, v] of h)` both
/// route here (the latter via the `Symbol.iterator` alias, see #576).
#[no_mangle]
pub extern "C" fn js_headers_entries(handle: f64) -> f64 {
    let entries = snapshot_sorted(handle);
    let mut arr = perry_runtime::js_array_alloc(entries.len() as u32);
    for (k, v) in entries {
        let k_ptr = js_string_from_bytes(k.as_ptr(), k.len() as u32);
        let v_ptr = js_string_from_bytes(v.as_ptr(), v.len() as u32);
        let k_nan = JSValue::string_ptr(k_ptr).bits();
        let v_nan = JSValue::string_ptr(v_ptr).bits();
        let mut pair = perry_runtime::js_array_alloc(2);
        pair = perry_runtime::js_array_push_f64(pair, f64::from_bits(k_nan));
        pair = perry_runtime::js_array_push_f64(pair, f64::from_bits(v_nan));
        arr = perry_runtime::js_array_push_f64(arr, nanbox_array_pointer(pair));
    }
    nanbox_array_pointer(arr)
}

// ----------------- Response FFI (constructor + extra methods) -----------------

/// new Response(body, statusOpt, statusTextPtrOpt, headersHandleOpt)
/// - body_ptr: StringHeader for the body, or null for ""
/// - status: f64 (200 default)
/// - status_text_ptr: StringHeader for statusText, or null for ""
/// - headers_handle: f64 numeric handle from js_headers_new, or 0
#[no_mangle]
pub unsafe extern "C" fn js_response_new(
    body_ptr: *const StringHeader,
    status: f64,
    status_text_ptr: *const StringHeader,
    headers_handle: f64,
) -> f64 {
    let body_str = string_from_header(body_ptr).unwrap_or_default();
    let body = body_str.into_bytes();
    let status_u16 = if status.is_nan() || status == 0.0 {
        200
    } else {
        status as u16
    };
    let status_text = string_from_header(status_text_ptr)
        .unwrap_or_else(|| canonical_reason(status_u16).to_string());
    let headers_id = handle_id(headers_handle);
    let headers = if headers_id != 0 {
        HEADERS_REGISTRY
            .lock()
            .unwrap()
            .get(&headers_id)
            .cloned()
            .unwrap_or_default()
    } else {
        HeadersStore::default()
    };
    handle_to_f64(alloc_response(status_u16, status_text, headers, body))
}

fn canonical_reason(status: u16) -> &'static str {
    match status {
        200 => "OK",
        201 => "Created",
        204 => "No Content",
        301 => "Moved Permanently",
        302 => "Found",
        304 => "Not Modified",
        400 => "Bad Request",
        401 => "Unauthorized",
        403 => "Forbidden",
        404 => "Not Found",
        500 => "Internal Server Error",
        _ => "",
    }
}

/// response.headers — returns a Headers handle (f64). Lazily allocates a Headers entry
/// from the response's stored header HashMap if one doesn't exist yet.
#[no_mangle]
pub extern "C" fn js_response_get_headers(handle: f64) -> f64 {
    let id = handle_id(handle);
    let store = {
        let guard = FETCH_RESPONSES.lock().unwrap();
        match guard.get(&id) {
            Some(resp) => HeadersStore::from_hashmap(&resp.headers),
            None => return f64::from_bits(TAG_UNDEFINED),
        }
    };
    handle_to_f64(alloc_headers(store))
}

/// response.clone() — duplicates the response (deep copy of body + headers)
#[no_mangle]
pub extern "C" fn js_response_clone(handle: f64) -> f64 {
    let id = handle_id(handle);
    let cloned = {
        let guard = FETCH_RESPONSES.lock().unwrap();
        guard.get(&id).map(|resp| FetchResponse {
            status: resp.status,
            status_text: resp.status_text.clone(),
            headers: resp.headers.clone(),
            body: resp.body.clone(),
            cached_headers_id: None,
        })
    };
    if let Some(new_resp) = cloned {
        let mut id_guard = NEXT_RESPONSE_ID.lock().unwrap();
        let new_id = *id_guard;
        *id_guard += 1;
        drop(id_guard);
        FETCH_RESPONSES.lock().unwrap().insert(new_id, new_resp);
        return handle_to_f64(new_id);
    }
    f64::from_bits(TAG_UNDEFINED)
}

/// response.arrayBuffer() — returns a real BufferHeader holding the body bytes,
/// NaN-boxed as POINTER_TAG so that `new Uint8Array(buf)` and `Buffer.from(buf)`
/// see the actual byte contents. `.byteLength` / `.length` access routes through
/// the BufferHeader property dispatch in `value.rs`. Resolved synchronously so
/// the LLVM backend's await loop (which doesn't pump deferred resolutions)
/// doesn't hang. See `js_fetch_response_text` for rationale.
#[no_mangle]
pub unsafe extern "C" fn js_response_array_buffer(handle: f64) -> *mut perry_runtime::Promise {
    let promise = perry_runtime::js_promise_new();
    let id = handle_id(handle);
    let body: Vec<u8> = {
        let guard = FETCH_RESPONSES.lock().unwrap();
        match guard.get(&id) {
            Some(resp) => resp.body.clone(),
            None => Vec::new(),
        }
    };
    let buf = perry_runtime::buffer::buffer_alloc(body.len() as u32);
    (*buf).length = body.len() as u32;
    if !body.is_empty() {
        std::ptr::copy_nonoverlapping(
            body.as_ptr(),
            perry_runtime::buffer::buffer_data_mut(buf),
            body.len(),
        );
    }
    let val = JSValue::object_ptr(buf as *mut u8);
    perry_runtime::js_promise_resolve(promise, f64::from_bits(val.bits()));
    promise
}

/// response.blob() — registers a real Blob in BLOB_REGISTRY (cloning body
/// bytes + content-type) and resolves with the numeric blob handle as f64.
/// Resolved synchronously; see `js_fetch_response_text`.
///
/// Closes #234 (followup of #232 / #227): pre-fix this returned a
/// metadata-only stub `{size, type}` and silently dropped `resp.body`. The
/// codegen-side dispatch arm at `crates/perry-codegen/src/lower_call.rs`
/// (module=="blob") routes `.arrayBuffer()` / `.text()` / `.bytes()` /
/// `.slice()` / `.size` / `.type` to the FFIs below.
#[no_mangle]
pub unsafe extern "C" fn js_response_blob(handle: f64) -> *mut perry_runtime::Promise {
    let promise = perry_runtime::js_promise_new();
    let id = handle_id(handle);
    let data = {
        let guard = FETCH_RESPONSES.lock().unwrap();
        match guard.get(&id) {
            Some(resp) => {
                let ct = resp
                    .headers
                    .iter()
                    .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
                    .map(|(_, v)| v.clone())
                    .unwrap_or_default();
                BlobData::blob(resp.body.clone(), ct)
            }
            None => BlobData::blob(Vec::new(), String::new()),
        }
    };
    let blob_id = alloc_blob(data);
    perry_runtime::js_promise_resolve(promise, handle_to_f64(blob_id));
    promise
}

// ----------------- Blob FFI -----------------
//
// Blob handles flow as NaN-boxed POINTER_TAG f64 values (registry IDs into
// BLOB_REGISTRY), matching the migrated Response/Request/Headers handle ABI
// (Phase 1 of the handle-NaN-boxing unification). Constructors wrap return
// values via `handle_to_f64`; accessors unbox via `handle_id` (tolerant of
// the legacy raw-float form during the cross-subsystem transition).
// Codegen passes them through as DOUBLE arg kinds — no `fptosi` needed.
// See `lower_call.rs::module=="blob"` arm.

/// blob.size — body byte length as f64.
#[no_mangle]
pub extern "C" fn js_blob_size(handle: f64) -> f64 {
    let id = handle_id(handle);
    BLOB_REGISTRY
        .lock()
        .unwrap()
        .get(&id)
        .map(|b| b.body.len() as f64)
        .unwrap_or(0.0)
}

/// blob.type — content_type as `*mut StringHeader` (codegen NaN-boxes with STRING_TAG).
#[no_mangle]
pub unsafe extern "C" fn js_blob_type(handle: f64) -> *mut StringHeader {
    let id = handle_id(handle);
    let ct = BLOB_REGISTRY
        .lock()
        .unwrap()
        .get(&id)
        .map(|b| b.content_type.clone())
        .unwrap_or_default();
    js_string_from_bytes(ct.as_ptr(), ct.len() as u32)
}

/// blob.arrayBuffer() — allocates a `BufferHeader` holding the body bytes,
/// resolves the promise with it NaN-boxed as POINTER_TAG. Mirrors
/// `js_response_array_buffer` (closes #227 path). `new Uint8Array(buf)` and
/// `Buffer.from(buf)` see the actual byte contents via the BufferHeader
/// property dispatch in `value.rs`. Resolved synchronously.
#[no_mangle]
pub unsafe extern "C" fn js_blob_array_buffer(handle: f64) -> *mut perry_runtime::Promise {
    let promise = perry_runtime::js_promise_new();
    let id = handle_id(handle);
    let body: Vec<u8> = BLOB_REGISTRY
        .lock()
        .unwrap()
        .get(&id)
        .map(|b| b.body.clone())
        .unwrap_or_default();
    let buf = perry_runtime::buffer::buffer_alloc(body.len() as u32);
    (*buf).length = body.len() as u32;
    if !body.is_empty() {
        std::ptr::copy_nonoverlapping(
            body.as_ptr(),
            perry_runtime::buffer::buffer_data_mut(buf),
            body.len(),
        );
    }
    let val = JSValue::object_ptr(buf as *mut u8);
    perry_runtime::js_promise_resolve(promise, f64::from_bits(val.bits()));
    promise
}

/// blob.bytes() — alias for arrayBuffer() (the BufferHeader is already
/// byte-array-shaped; users wrap in Uint8Array via `new Uint8Array(buf)` which
/// hits the `is_registered_buffer` path from #227).
#[no_mangle]
pub unsafe extern "C" fn js_blob_bytes(handle: f64) -> *mut perry_runtime::Promise {
    js_blob_array_buffer(handle)
}

/// blob.text() — UTF-8-decodes the body bytes into a `StringHeader` and
/// resolves the promise with it NaN-boxed as STRING_TAG. Lossy decode for
/// invalid sequences (matches WHATWG Blob.text() spec which uses replacement
/// characters; lossy_utf8 produces U+FFFD identically).
#[no_mangle]
pub unsafe extern "C" fn js_blob_text(handle: f64) -> *mut perry_runtime::Promise {
    let promise = perry_runtime::js_promise_new();
    let id = handle_id(handle);
    let body: Vec<u8> = BLOB_REGISTRY
        .lock()
        .unwrap()
        .get(&id)
        .map(|b| b.body.clone())
        .unwrap_or_default();
    let s = String::from_utf8_lossy(&body).into_owned();
    let str_ptr = js_string_from_bytes(s.as_ptr(), s.len() as u32);
    let val = JSValue::string_ptr(str_ptr);
    perry_runtime::js_promise_resolve(promise, f64::from_bits(val.bits()));
    promise
}

/// blob.slice(start?, end?, type?) — returns a NEW blob handle covering
/// [start, end) of the body. `f64::NAN` sentinel for missing numeric args.
/// `type_ptr` may be null to inherit the original content-type. Negative
/// indices count from the end; out-of-range values clamp to [0, len] per
/// WHATWG Blob spec.
#[no_mangle]
pub unsafe extern "C" fn js_blob_slice(
    handle: f64,
    start: f64,
    end: f64,
    type_ptr: *const StringHeader,
) -> f64 {
    let id = handle_id(handle);
    let body: Vec<u8> = {
        let guard = BLOB_REGISTRY.lock().unwrap();
        guard.get(&id).map(|b| b.body.clone()).unwrap_or_default()
    };
    let len = body.len() as i64;
    let normalize = |v: f64, default: i64| -> i64 {
        if v.is_nan() {
            return default;
        }
        let n = v as i64;
        if n < 0 {
            (len + n).max(0)
        } else {
            n.min(len)
        }
    };
    let s = normalize(start, 0);
    let e = normalize(end, len);
    let (lo, hi) = if e < s { (s, s) } else { (s, e) };
    let slice = body[lo as usize..hi as usize].to_vec();
    // Per WHATWG Blob spec: when `contentType` is absent, the new blob's
    // type is the empty string — NOT inherited from the original. Same
    // applies when the caller's type string fails to decode.
    let new_type = if type_ptr.is_null() {
        String::new()
    } else {
        string_from_header(type_ptr).unwrap_or_default()
    };
    handle_to_f64(alloc_blob(BlobData::blob(slice, new_type)))
}

// Issue #1211: Blob / File constructors + object-URL registry now
// live in sibling `fetch_blob.rs` to keep this file under the
// 2,000-line size gate.  See that module for the FFI entry points.

// ----------------- Web Streams bridge helpers (issue #237) -----------------
//
// `streams.rs` reaches in here for the bytes backing `blob.stream()` and
// `response.body`. Going through these `pub(crate)` shims (rather than
// re-implementing the `BLOB_REGISTRY` / `FETCH_RESPONSES` lookups in
// `streams.rs`) keeps the registry types private to fetch.rs.

/// Clone the bytes backing the given Blob handle. Returns `None` for an
/// unknown handle.
#[doc(hidden)]
pub fn blob_bytes_clone(blob_id: usize) -> Option<Vec<u8>> {
    BLOB_REGISTRY
        .lock()
        .unwrap()
        .get(&blob_id)
        .map(|b| b.body.clone())
}

/// Clone the body bytes of the given fetch Response handle. Returns
/// `None` for an unknown handle.
#[doc(hidden)]
pub fn response_bytes_clone(resp_id: usize) -> Option<Vec<u8>> {
    FETCH_RESPONSES
        .lock()
        .unwrap()
        .get(&resp_id)
        .map(|r| r.body.clone())
}

// ----------------- Untyped property dispatch (refs #421) -----------------
//
// When user code accesses a property on a Web Fetch handle whose static type
// isn't known to codegen (e.g. hono's bundled JS where TS annotations are
// stripped: `(request) => request.url`), the codegen falls through to
// `js_object_get_field_by_name` in perry-runtime. That dispatcher strips
// the POINTER_TAG, sees a small id, and routes to `HANDLE_PROPERTY_DISPATCH`
// (registered by perry-stdlib's `dispatch.rs`). The functions below let the
// stdlib dispatcher resolve Web Fetch properties without exposing the
// per-subsystem registries publicly.

/// Try to read a property off a Request handle by registry id.
/// Returns `Some(value)` only when both (a) the id is in REQUEST_REGISTRY AND
/// (b) the property name is one this type exposes. Returns `None` otherwise so
/// the dispatcher falls through to the next handler — Web Fetch registries use
/// disjoint integer-id namespaces (Request id 1 ≠ Response id 1), so a
/// "membership only" Some(undefined) catch-all would shadow legitimate
/// property reads on a Response handle whose id collides with a Request id.
#[doc(hidden)]
pub fn dispatch_request_property(req_id: usize, prop: &str) -> Option<f64> {
    let guard = REQUEST_REGISTRY.lock().unwrap();
    let req = guard.get(&req_id)?;
    let bits = match prop {
        "url" => {
            let p = js_string_from_bytes(req.url.as_ptr(), req.url.len() as u32);
            JSValue::string_ptr(p).bits()
        }
        "method" => {
            let p = js_string_from_bytes(req.method.as_ptr(), req.method.len() as u32);
            JSValue::string_ptr(p).bits()
        }
        "body" => match &req.body {
            Some(b) => {
                let p = js_string_from_bytes(b.as_ptr(), b.len() as u32);
                JSValue::string_ptr(p).bits()
            }
            None => TAG_NULL,
        },
        // Other Request properties not yet wired — fall through so other
        // dispatchers (or the final undefined fallback) can answer.
        _ => return None,
    };
    Some(f64::from_bits(bits))
}

/// Try to read a property off a Response handle by registry id.
/// Returns `None` if the id isn't a known Response or the property is unknown.
#[doc(hidden)]
pub fn dispatch_response_property(resp_id: usize, prop: &str) -> Option<f64> {
    // `response.headers` — lazily allocate a Headers registry entry
    // backed by the response's stored header map and cache the id on the
    // FetchResponse so repeat reads return the same handle (preserves
    // `res.headers === res.headers`). Hono's `#newResponse` mutates the
    // returned Headers object via `.set(k, v)`, but our snapshot is a
    // copy of the response's header HashMap — mutations land on the
    // Headers handle's HeadersStore, not back on the FetchResponse.
    // For the read-only case (the issue #486 acceptance) this is
    // sufficient; spec-perfect "live header view" would need the
    // FetchResponse's storage to be the same Vec as the Headers
    // entries, which is a wider refactor.
    if prop == "headers" {
        let cached = {
            let guard = FETCH_RESPONSES.lock().unwrap();
            guard.get(&resp_id)?.cached_headers_id
        };
        let id = match cached {
            Some(id) => id,
            None => {
                let store = {
                    let guard = FETCH_RESPONSES.lock().unwrap();
                    HeadersStore::from_hashmap(&guard.get(&resp_id)?.headers)
                };
                let new_id = alloc_headers(store);
                if let Some(resp) = FETCH_RESPONSES.lock().unwrap().get_mut(&resp_id) {
                    resp.cached_headers_id = Some(new_id);
                }
                new_id
            }
        };
        return Some(handle_to_f64(id));
    }
    let guard = FETCH_RESPONSES.lock().unwrap();
    let resp = guard.get(&resp_id)?;
    let bits = match prop {
        "status" => return Some(resp.status as f64),
        "statusText" => {
            let p = js_string_from_bytes(resp.status_text.as_ptr(), resp.status_text.len() as u32);
            JSValue::string_ptr(p).bits()
        }
        "ok" => {
            return Some(f64::from_bits(if resp.status >= 200 && resp.status < 300 {
                TAG_TRUE
            } else {
                TAG_FALSE
            }))
        }
        _ => return None,
    };
    Some(f64::from_bits(bits))
}

/// Try to read a property off a Headers handle by registry id.
/// Returns `None` always today — Headers has no scalar properties exposed
/// via property reads (`.get(k)` / `.has(k)` etc. are method calls that route
/// through `HANDLE_METHOD_DISPATCH`).
#[doc(hidden)]
pub fn dispatch_headers_property(_headers_id: usize, _prop: &str) -> Option<f64> {
    None
}

/// Try to dispatch a method call on a Response handle. Returns `Some(result)`
/// only when the id is a known Response AND the method is supported. Returns
/// `None` (fall through to the next dispatcher) otherwise. Required because
/// Web Fetch handle id namespaces are disjoint from each other and from other
/// stdlib registries — id collision (Request id 1 ≠ Response id 1) means
/// claiming membership alone would shadow legitimate calls on other handles.
#[doc(hidden)]
pub fn dispatch_response_method(resp_id: usize, method: &str, _args: &[f64]) -> Option<f64> {
    {
        let guard = FETCH_RESPONSES.lock().unwrap();
        guard.get(&resp_id)?;
    }
    let resp_f64 = handle_to_f64(resp_id);
    unsafe {
        match method {
            "text" => {
                let promise = js_fetch_response_text(resp_f64);
                Some(f64::from_bits(JSValue::pointer(promise as *mut u8).bits()))
            }
            "json" => {
                let promise = js_fetch_response_json(resp_f64);
                Some(f64::from_bits(JSValue::pointer(promise as *mut u8).bits()))
            }
            "arrayBuffer" => {
                let promise = js_response_array_buffer(resp_f64);
                Some(f64::from_bits(JSValue::pointer(promise as *mut u8).bits()))
            }
            "blob" => {
                let promise = js_response_blob(resp_f64);
                Some(f64::from_bits(JSValue::pointer(promise as *mut u8).bits()))
            }
            "clone" => Some(js_response_clone(resp_f64)),
            _ => None,
        }
    }
}

/// Try to dispatch a method call on a Blob handle. Returns `None` for unknown
/// ids or methods.
#[doc(hidden)]
pub fn dispatch_blob_method(blob_id: usize, method: &str, args: &[f64]) -> Option<f64> {
    {
        let guard = BLOB_REGISTRY.lock().unwrap();
        guard.get(&blob_id)?;
    }
    let blob_f64 = handle_to_f64(blob_id);
    unsafe {
        match method {
            "text" => {
                let promise = js_blob_text(blob_f64);
                Some(f64::from_bits(JSValue::pointer(promise as *mut u8).bits()))
            }
            "arrayBuffer" => {
                let promise = js_blob_array_buffer(blob_f64);
                Some(f64::from_bits(JSValue::pointer(promise as *mut u8).bits()))
            }
            "bytes" => {
                let promise = js_blob_bytes(blob_f64);
                Some(f64::from_bits(JSValue::pointer(promise as *mut u8).bits()))
            }
            "slice" => {
                let start = args.first().copied().unwrap_or(f64::NAN);
                let end = args.get(1).copied().unwrap_or(f64::NAN);
                Some(js_blob_slice(blob_f64, start, end, std::ptr::null()))
            }
            _ => None,
        }
    }
}

/// Try to dispatch a method call on a Headers handle. Returns `None` for
/// unknown ids or methods.
#[doc(hidden)]
pub fn dispatch_headers_method(headers_id: usize, method: &str, args: &[f64]) -> Option<f64> {
    {
        let guard = HEADERS_REGISTRY.lock().unwrap();
        guard.get(&headers_id)?;
    }
    let h_f64 = handle_to_f64(headers_id);
    // String args arrive as NaN-boxed STRING_TAG f64 values; extract the raw
    // StringHeader pointer for the runtime helpers.
    let str_arg = |i: usize| -> *const StringHeader {
        if i < args.len() {
            let v = args[i];
            (v.to_bits() & 0x0000_FFFF_FFFF_FFFF) as *const StringHeader
        } else {
            std::ptr::null()
        }
    };
    unsafe {
        match method {
            "get" => Some(f64::from_bits(
                JSValue::string_ptr(js_headers_get(h_f64, str_arg(0))).bits(),
            )),
            "set" => Some(js_headers_set(h_f64, str_arg(0), str_arg(1))),
            "has" => Some(js_headers_has(h_f64, str_arg(0))),
            "delete" => Some(js_headers_delete(h_f64, str_arg(0))),
            "forEach" => {
                let cb = args.first().copied().unwrap_or(f64::NAN);
                Some(js_headers_for_each(h_f64, cb))
            }
            _ => None,
        }
    }
}

/// Try to read a property off a Blob handle by registry id.
/// Returns `None` if the id isn't a known Blob or the property is unknown.
#[doc(hidden)]
pub fn dispatch_blob_property(blob_id: usize, prop: &str) -> Option<f64> {
    let guard = BLOB_REGISTRY.lock().unwrap();
    let blob = guard.get(&blob_id)?;
    let bits = match prop {
        "size" => return Some(blob.body.len() as f64),
        "type" => {
            let p =
                js_string_from_bytes(blob.content_type.as_ptr(), blob.content_type.len() as u32);
            JSValue::string_ptr(p).bits()
        }
        _ => return None,
    };
    Some(f64::from_bits(bits))
}

/// `blob.stream()` — returns a single-chunk ReadableStream handle (f64,
/// numeric registry id) over the blob's byte payload. Closes the stream
/// after the one chunk is delivered.
#[no_mangle]
pub unsafe extern "C" fn js_blob_stream(handle: f64) -> f64 {
    let id = handle_id(handle);
    let bytes = blob_bytes_clone(id).unwrap_or_default();
    // Streams are still a Phase 2 subsystem — keep their handle in the legacy
    // raw-float form (`as f64`) so streams.rs's accessors continue to round-trip.
    crate::streams::alloc_readable_from_bytes(bytes) as f64
}

/// `response.body` — returns a single-chunk ReadableStream handle over
/// the buffered response body. Returns `null`-tagged f64 for an unknown
/// response handle (matching the spec's `Response.body: ReadableStream
/// | null`).
#[no_mangle]
pub unsafe extern "C" fn js_response_body(handle: f64) -> f64 {
    let id = handle_id(handle);
    match response_bytes_clone(id) {
        // Streams are still a Phase 2 subsystem — keep their handle in the
        // legacy raw-float form so streams.rs's accessors round-trip.
        Some(bytes) => crate::streams::alloc_readable_from_bytes(bytes) as f64,
        None => f64::from_bits(TAG_NULL),
    }
}

/// Response.json(value) — static method. Allocates a Response with JSON-stringified body
/// and Content-Type: application/json. The value is passed as NaN-boxed JSValue bits (f64).
#[no_mangle]
pub unsafe extern "C" fn js_response_static_json(value: f64) -> f64 {
    // Stringify via runtime (type_hint 1 = object)
    extern "C" {
        fn js_json_stringify(value: f64, type_hint: u32) -> *mut StringHeader;
    }
    let str_ptr = js_json_stringify(value, 1);
    let body_str = if str_ptr.is_null() {
        "null".to_string()
    } else {
        string_from_header(str_ptr).unwrap_or_else(|| "null".to_string())
    };
    let mut headers = HeadersStore::default();
    headers.set("content-type", "application/json");
    handle_to_f64(alloc_response(
        200,
        "OK".to_string(),
        headers,
        body_str.into_bytes(),
    ))
}

/// Response.redirect(url, status) — static method. Allocates a redirect response.
#[no_mangle]
pub unsafe extern "C" fn js_response_static_redirect(
    url_ptr: *const StringHeader,
    status: f64,
) -> f64 {
    let url = string_from_header(url_ptr).unwrap_or_default();
    let status_u16 = if status == 0.0 || status.is_nan() {
        302
    } else {
        status as u16
    };
    let mut headers = HeadersStore::default();
    headers.set("location", &url);
    handle_to_f64(alloc_response(
        status_u16,
        canonical_reason(status_u16).to_string(),
        headers,
        Vec::new(),
    ))
}

// ----------------- Request FFI -----------------

/// new Request(url, methodOpt, bodyOpt, headersHandleOpt)
#[no_mangle]
pub unsafe extern "C" fn js_request_new(
    url_ptr: *const StringHeader,
    method_ptr: *const StringHeader,
    body_ptr: *const StringHeader,
    headers_handle: f64,
) -> f64 {
    let url = string_from_header(url_ptr).unwrap_or_default();
    let method = string_from_header(method_ptr).unwrap_or_else(|| "GET".to_string());
    let body = string_from_header(body_ptr);
    let headers_id_in = handle_id(headers_handle);
    let headers = if headers_id_in != 0 {
        HEADERS_REGISTRY
            .lock()
            .unwrap()
            .get(&headers_id_in)
            .cloned()
            .unwrap_or_default()
    } else {
        HeadersStore::default()
    };
    let mut id_guard = NEXT_REQUEST_ID.lock().unwrap();
    let id = *id_guard;
    *id_guard += 1;
    drop(id_guard);
    REQUEST_REGISTRY.lock().unwrap().insert(
        id,
        RequestRecord {
            url,
            method,
            body,
            headers,
        },
    );
    handle_to_f64(id)
}

#[no_mangle]
pub extern "C" fn js_request_get_url(handle: f64) -> *mut StringHeader {
    let id = handle_id(handle);
    let guard = REQUEST_REGISTRY.lock().unwrap();
    match guard.get(&id) {
        Some(req) => js_string_from_bytes(req.url.as_ptr(), req.url.len() as u32),
        None => std::ptr::null_mut(),
    }
}

#[no_mangle]
pub extern "C" fn js_request_get_method(handle: f64) -> *mut StringHeader {
    let id = handle_id(handle);
    let guard = REQUEST_REGISTRY.lock().unwrap();
    match guard.get(&id) {
        Some(req) => js_string_from_bytes(req.method.as_ptr(), req.method.len() as u32),
        None => std::ptr::null_mut(),
    }
}

/// req.body — returns a string body or null. NaN-boxed return.
#[no_mangle]
pub extern "C" fn js_request_get_body(handle: f64) -> f64 {
    let id = handle_id(handle);
    let guard = REQUEST_REGISTRY.lock().unwrap();
    match guard.get(&id) {
        Some(req) => match &req.body {
            Some(b) => {
                let s = js_string_from_bytes(b.as_ptr(), b.len() as u32);
                f64::from_bits(JSValue::string_ptr(s).bits())
            }
            None => f64::from_bits(TAG_NULL),
        },
        None => f64::from_bits(TAG_NULL),
    }
}
