//! Native bindings for Node `net.Socket` — TCP plus optional TLS upgrade.
//!
//! Ported from `crates/perry-stdlib/src/net/mod.rs` to perry-ffi v0.5.x's
//! stable surface as part of #466 Phase 5. Architecturally the same as the
//! perry-stdlib copy: one tokio task per socket reads in a `select!` loop
//! and drives an mpsc command channel for writes/end/destroy/upgrade. Read
//! data is queued as raw `Vec<u8>` into `NET_PENDING_EVENTS` and converted
//! to `Buffer` on the main thread inside `js_net_process_pending` — the
//! same arena-safety rule as perry-stdlib (JSValue construction MUST run
//! on the main thread, never on a tokio worker).
//!
//! # Differences from the perry-stdlib version
//!
//! - Uses `perry_ffi::spawn_blocking` + `tokio::runtime::Handle::current().block_on`
//!   instead of `crate::common::async_bridge::spawn` (cooperative async over
//!   the shared runtime). Each socket reader task ties up one blocking-pool
//!   thread for the socket's lifetime — fine for v0.5.x (default blocking
//!   pool is 512); cooperative `spawn_async` is a v0.6.0 optimization.
//! - Uses `perry_ffi::JsClosure` instead of raw `js_closure_call*` extern fns.
//! - Uses `perry_ffi::alloc_buffer` / `BufferHeader` instead of
//!   `perry-runtime::buffer::*` directly.
//! - GC root scanner registered via `perry_ffi::gc_register_mutable_root_scanner`.
//!   Listeners stored inside the `NET_LISTENERS` map need this — issue #35
//!   pattern — and the mutable visitor lets copied-minor GC rewrite moved
//!   closure pointers in place.
//!
//! TLS is unconditionally compiled in (no `#[cfg(feature = "tls")]` gates
//! like perry-stdlib has) — keeping the wrapper crate simple, the deps are
//! small. perry-stdlib's umbrella `net = ["async-runtime"]` + separate
//! `tls = ["net", ...]` feature split is preserved on the perry-stdlib side
//! for backwards compat; the well-known flip routes here.

use perry_ffi::{
    alloc_buffer, alloc_string, build_object_shape, gc_register_mutable_root_scanner,
    js_object_alloc_with_shape, js_object_set_field, nanbox_string_bits, BufferHeader,
    GcRootVisitor, JsClosure, JsPromise, JsValue, ObjectHeader, RawClosureHeader, StringHeader,
};
use std::collections::HashMap;
use std::io;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::Mutex;
use std::task::{Context, Poll};

use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{mpsc, oneshot};

use tokio_rustls::{client::TlsStream, rustls, TlsConnector};

// ─── Transport enum (plain or TLS, swappable at runtime) ─────────────────────

enum Transport {
    Plain(TcpStream),
    Tls(Box<TlsStream<TcpStream>>),
}

impl AsyncRead for Transport {
    fn poll_read(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
        buf: &mut ReadBuf<'_>,
    ) -> Poll<io::Result<()>> {
        match self.get_mut() {
            Transport::Plain(s) => Pin::new(s).poll_read(cx, buf),
            Transport::Tls(s) => Pin::new(&mut **s).poll_read(cx, buf),
        }
    }
}

impl AsyncWrite for Transport {
    fn poll_write(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
        buf: &[u8],
    ) -> Poll<io::Result<usize>> {
        match self.get_mut() {
            Transport::Plain(s) => Pin::new(s).poll_write(cx, buf),
            Transport::Tls(s) => Pin::new(&mut **s).poll_write(cx, buf),
        }
    }
    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
        match self.get_mut() {
            Transport::Plain(s) => Pin::new(s).poll_flush(cx),
            Transport::Tls(s) => Pin::new(&mut **s).poll_flush(cx),
        }
    }
    fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
        match self.get_mut() {
            Transport::Plain(s) => Pin::new(s).poll_shutdown(cx),
            Transport::Tls(s) => Pin::new(&mut **s).poll_shutdown(cx),
        }
    }
}

// ─── Handle storage ──────────────────────────────────────────────────────────
//
// We keep our own integer-keyed handle map here rather than going through
// perry-ffi's generic registry, because every socket needs *two* parallel
// data structures (state + listeners) keyed by the same id. Splitting them
// across two registry types would force two lookups per FFI entry; bundling
// them into one `SocketHandle` value would make the GC scanner walk noisier.
// The pattern matches perry-stdlib's existing copy exactly.

mod statics {
    use super::*;
    use std::sync::OnceLock;

    pub fn sockets() -> &'static Mutex<HashMap<i64, SocketState>> {
        static S: OnceLock<Mutex<HashMap<i64, SocketState>>> = OnceLock::new();
        S.get_or_init(|| Mutex::new(HashMap::new()))
    }

    pub fn listeners() -> &'static Mutex<HashMap<i64, HashMap<String, Vec<i64>>>> {
        static L: OnceLock<Mutex<HashMap<i64, HashMap<String, Vec<i64>>>>> = OnceLock::new();
        L.get_or_init(|| Mutex::new(HashMap::new()))
    }

    pub fn pending_events() -> &'static Mutex<Vec<PendingNetEvent>> {
        static P: OnceLock<Mutex<Vec<PendingNetEvent>>> = OnceLock::new();
        P.get_or_init(|| Mutex::new(Vec::new()))
    }

    pub fn next_net_id() -> &'static Mutex<i64> {
        static N: OnceLock<Mutex<i64>> = OnceLock::new();
        N.get_or_init(|| Mutex::new(1))
    }

    /// Server registry — `net.createServer(...)` returns a handle here.
    /// Separate from the socket map: server handles host an accept-loop
    /// shutdown channel and a bound port; sockets host a per-connection
    /// command channel + per-connection listener map. Keyed by the same
    /// monotonic id counter as sockets, so handles never collide.
    pub fn servers() -> &'static Mutex<HashMap<i64, ServerState>> {
        static S: OnceLock<Mutex<HashMap<i64, ServerState>>> = OnceLock::new();
        S.get_or_init(|| Mutex::new(HashMap::new()))
    }
}

/// Backing state for an `net.Server` handle (`net.createServer(...)`).
/// Mirrors `perry-ext-http-server::HttpServer` in shape but stripped to
/// the raw-TCP surface — no hyper, no request/response channels, just
/// the accept loop's shutdown sender + bound address. Per-server event
/// listeners (`'connection'`, `'listening'`, `'close'`, `'error'`) live
/// in the shared `statics::listeners()` map keyed by the server's id;
/// reusing the socket listener map keeps the GC scanner walk single-
/// pass instead of needing a second per-server scanner.
pub(crate) struct ServerState {
    /// Set by `.listen()`, dropped by `.close()`. Send on this channel
    /// to break the accept loop's `tokio::select!`.
    pub shutdown_tx: Option<oneshot::Sender<()>>,
    pub bound_port: u16,
    pub bound_host: String,
    pub listening: bool,
}

static NET_GC_REGISTERED: std::sync::Once = std::sync::Once::new();

/// Register the net GC root scanner exactly once. Safe to call from any
/// `js_net_*` entry point on the main thread.
fn ensure_gc_scanner_registered() {
    NET_GC_REGISTERED.call_once(|| {
        gc_register_mutable_root_scanner(scan_net_roots);
    });
}

/// GC root scanner for net.Socket event listener closures.
///
/// Without this, any GC cycle between `.on()` and the next dispatch would
/// sweep the closure; the next `closure.call*()` would dereference freed
/// memory. Same pattern as perry-stdlib's net mod and perry-ext-events.
fn scan_net_roots(visitor: &mut GcRootVisitor<'_>) {
    if let Ok(mut listeners) = statics::listeners().lock() {
        for per_socket in listeners.values_mut() {
            for cb_vec in per_socket.values_mut() {
                for cb in cb_vec.iter_mut() {
                    visitor.visit_i64_slot(cb);
                }
            }
        }
    }
}

struct SocketState {
    cmd_tx: mpsc::UnboundedSender<SocketCommand>,
    /// `Some` only between `js_net_socket_alloc` and the first
    /// `js_net_socket_method_connect`. Held here so the deferred-connect
    /// path (issue #422: `new net.Socket()` then `sock.connect(port,host)`)
    /// can move it into the spawned task at connect time.
    pending_rx: Option<mpsc::UnboundedReceiver<SocketCommand>>,
    is_open: bool,
}

enum SocketCommand {
    Write(Vec<u8>),
    End,
    Destroy,
    UpgradeTls {
        servername: String,
        verify: bool,
        reply: oneshot::Sender<Result<(), String>>,
    },
}

#[derive(Debug)]
enum PendingNetEvent {
    Connect(i64),
    Data(i64, Vec<u8>),
    Close(i64),
    Error(i64, String),
    /// Issue #1123 followup — accept-loop on a `net.Server` produced
    /// a new client socket. Fires the server's `'connection'`
    /// listeners with the new socket handle.
    ///   `.0` = server id (for listener lookup)
    ///   `.1` = socket id (passed to listeners as the arg)
    ServerConnection(i64, i64),
    /// Issue #1123 followup — `listener.bind()` resolved + accept
    /// loop is running. Fires `'listening'` listeners + the
    /// `.listen(port, cb)` callback. `.0` = server id.
    ServerListening(i64),
    /// Issue #1123 followup — accept-loop exited (after `.close()`
    /// or bind failure). Fires `'close'` listeners on the server.
    ServerClose(i64),
    /// Issue #1123 followup — bind / accept I/O error on the server.
    /// Fires `'error'` listeners with an Error-shaped object.
    ///   `.0` = server id, `.1` = error message.
    ServerError(i64, String),
}

// ─── Helpers ─────────────────────────────────────────────────────────────────

/// Issue #811 — `net.isIP(s)` returns 0/4/6 (number).
fn classify_ip(s: &str) -> i32 {
    if is_ipv4_str(s) {
        4
    } else if is_ipv6_str(s) {
        6
    } else {
        0
    }
}

fn is_ipv4_str(s: &str) -> bool {
    s.parse::<std::net::Ipv4Addr>().is_ok()
}

fn is_ipv6_str(s: &str) -> bool {
    // Node's `net.isIPv6` rejects brackets and zone-id (`%`) suffixes —
    // those forms aren't bare addresses.
    if s.contains('[') || s.contains(']') || s.contains('%') {
        return false;
    }
    s.parse::<std::net::Ipv6Addr>().is_ok()
}

#[no_mangle]
pub unsafe extern "C" fn js_net_is_ip(s_ptr: i64) -> f64 {
    let kind = match string_from_header_i64(s_ptr) {
        Some(s) => classify_ip(&s),
        None => 0,
    };
    f64::from_bits(JsValue::from_number(kind as f64).0)
}

#[no_mangle]
pub unsafe extern "C" fn js_net_is_ipv4(s_ptr: i64) -> f64 {
    let is = match string_from_header_i64(s_ptr) {
        Some(s) => is_ipv4_str(&s),
        None => false,
    };
    f64::from_bits(JsValue::from_bool(is).0)
}

#[no_mangle]
pub unsafe extern "C" fn js_net_is_ipv6(s_ptr: i64) -> f64 {
    let is = match string_from_header_i64(s_ptr) {
        Some(s) => is_ipv6_str(&s),
        None => false,
    };
    f64::from_bits(JsValue::from_bool(is).0)
}

// Happy-Eyeballs (auto-select-family) defaults. Process-wide globals
// that `getDefault*` reads and `setDefault*` writes.
static AUTO_SELECT_FAMILY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
// Node's current default is 500ms (raised from 250 in v20.x); pin to
// 500 so byte-for-byte parity holds against `node --experimental-strip-types`.
static AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS: std::sync::atomic::AtomicI32 =
    std::sync::atomic::AtomicI32::new(500);

#[no_mangle]
pub unsafe extern "C" fn js_net_get_default_auto_select_family() -> f64 {
    let v = AUTO_SELECT_FAMILY.load(std::sync::atomic::Ordering::Relaxed);
    f64::from_bits(JsValue::from_bool(v).0)
}

#[no_mangle]
pub unsafe extern "C" fn js_net_set_default_auto_select_family(val_f64: f64) -> f64 {
    let val = JsValue(val_f64.to_bits()).to_bool();
    AUTO_SELECT_FAMILY.store(val, std::sync::atomic::Ordering::Relaxed);
    f64::from_bits(JsValue::UNDEFINED.0)
}

#[no_mangle]
pub unsafe extern "C" fn js_net_get_default_auto_select_family_attempt_timeout() -> f64 {
    let v = AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS.load(std::sync::atomic::Ordering::Relaxed);
    f64::from_bits(JsValue::from_number(v as f64).0)
}

#[no_mangle]
pub unsafe extern "C" fn js_net_set_default_auto_select_family_attempt_timeout(ms_f64: f64) -> f64 {
    let n = JsValue(ms_f64.to_bits()).to_number();
    let ms = if n.is_finite() && n >= 0.0 {
        n as i32
    } else {
        0
    };
    AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS.store(ms, std::sync::atomic::Ordering::Relaxed);
    f64::from_bits(JsValue::UNDEFINED.0)
}

unsafe fn string_from_header_i64(ptr: i64) -> Option<String> {
    let p = ptr as usize;
    if p < 0x1000 {
        return None;
    }
    let hdr = ptr as *const StringHeader;
    let len = (*hdr).byte_len as usize;
    let data_ptr = (hdr 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())
}

// Runtime entrypoints provided by perry-runtime (declared as extern so
// perry-ext-net doesn't need to depend on the perry-runtime rlib).
extern "C" {
    fn js_string_from_bytes(data: *const u8, len: u32) -> *mut StringHeader;
    fn js_object_get_field_by_name_f64(obj: *const ObjectHeader, key: *const StringHeader) -> f64;
    /// Issue #1131 — returns 1 if `ptr` is a registered Buffer /
    /// Uint8Array in the runtime's `BUFFER_REGISTRY`. This is the only
    /// safe way to tell a `BufferHeader` apart from a `StringHeader`
    /// after both have been NaN-boxed and stripped to a raw pointer
    /// (a `Buffer` carries `POINTER_TAG`, a JS string `STRING_TAG`, but
    /// the dispatch shims pass us the full NaN-box bits and we still
    /// have to distinguish a pointer-tagged Buffer from a
    /// pointer-tagged non-buffer object). Defined in
    /// `crates/perry-runtime/src/buffer.rs::js_buffer_is_buffer`.
    fn js_buffer_is_buffer(ptr: i64) -> i32;
}

/// Issue #1131 — read a NaN-boxed JS value as the raw bytes to put on
/// the wire for `socket.write(chunk)`. Outbound mirror of
/// `perry-ext-http-server`'s `jsvalue_to_body_bytes` (#1124): a JS
/// string and a `Buffer` have *different* memory layouts
/// (`StringHeader` is 20 bytes, `{ utf16_len, byte_len, capacity,
/// refcount, flags }`; `BufferHeader` is 8 bytes, `{ length, capacity
/// }`, data immediately after). The pre-#1131 code unconditionally
/// reinterpreted the chunk pointer as a `*const BufferHeader`, so
/// `socket.write("ping")` read the string's `utf16_len` as the buffer
/// length and pulled "data" from `ptr + 8` — the middle of the
/// `StringHeader` struct — emitting garbage instead of the UTF-8
/// bytes.
///
/// Probe the runtime's `BUFFER_REGISTRY` first (`js_buffer_is_buffer`)
/// to pick the `BufferHeader` layout for real Buffers / Uint8Arrays;
/// otherwise read through the `StringHeader` layout for JS strings;
/// otherwise stringify numbers / bools the same way `res.write(n)`
/// does (Node throws `ERR_INVALID_ARG_TYPE` here, but Perry's existing
/// body-write paths are lenient and stringify — keep parity with
/// that). `null` / `undefined` produce `None` (no bytes written).
unsafe fn jsvalue_to_socket_bytes(value: f64) -> Option<Vec<u8>> {
    let v = JsValue::from_bits(value.to_bits());
    if v.is_undefined() || v.is_null() {
        return None;
    }
    // JS string — STRING_TAG, `StringHeader` layout.
    if v.is_string() {
        let ptr = unbox_pointer(value) as *const StringHeader;
        if ptr.is_null() {
            return None;
        }
        let len = (*ptr).byte_len as usize;
        let data = (ptr as *const u8).add(std::mem::size_of::<StringHeader>());
        return Some(std::slice::from_raw_parts(data, len).to_vec());
    }
    // Heap pointer — could be a Buffer / Uint8Array (BufferHeader
    // layout) or some other object. Probe the registry first.
    if v.is_pointer() {
        let raw = (value.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64;
        if js_buffer_is_buffer(raw) != 0 {
            let buf = raw as *const BufferHeader;
            if !buf.is_null() {
                let len = (*buf).length as usize;
                let data = (buf as *const u8).add(std::mem::size_of::<BufferHeader>());
                return Some(std::slice::from_raw_parts(data, len).to_vec());
            }
        }
        // Non-buffer pointer — fall back to the string-shaped header
        // for runtime strings the codegen happened to NaN-box with
        // POINTER_TAG instead of STRING_TAG.
        let sptr = raw as *const StringHeader;
        if !sptr.is_null() {
            let len = (*sptr).byte_len as usize;
            if len <= (1 << 30) {
                let data = (sptr as *const u8).add(std::mem::size_of::<StringHeader>());
                return Some(std::slice::from_raw_parts(data, len).to_vec());
            }
        }
        return None;
    }
    // Number / bool — stringify (parity with the lenient
    // `res.write(value)` body path; Node would throw here).
    if v.is_number() {
        return Some(v.to_number().to_string().into_bytes());
    }
    if v.is_bool() {
        return Some(
            if v.to_bool() { "true" } else { "false" }
                .to_string()
                .into_bytes(),
        );
    }
    None
}

/// True iff `val_f64` carries `POINTER_TAG` (0x7FFD) — a real pointer
/// to a heap object or closure. Used to discriminate the
/// positional `net.connect(port, host)` overload (arg1 is a plain
/// number) from the options-object `net.connect({host, port}, cb?)`
/// overload (arg1 is a NaN-boxed object pointer), and to detect a
/// real `connectListener` closure in the trailing arg slot.
///
/// Narrower than "any NaN-tagged value": the dispatch table pads
/// missing user args with `TAG_UNDEFINED` (`0x7FFC` band), so this
/// check has to reject `undefined` cleanly to keep "user passed only
/// 2 args" from misfiring as "user passed a callback". Issue #770.
fn is_nanboxed_pointer(val_f64: f64) -> bool {
    (val_f64.to_bits() >> 48) == 0x7FFD
}

/// Unbox a NaN-boxed value to the raw 48-bit pointer payload, regardless
/// of which `0x7FFx` tag it carries.
unsafe fn unbox_pointer(val_f64: f64) -> *mut u8 {
    let bits = val_f64.to_bits();
    (bits & 0x0000_FFFF_FFFF_FFFF) as *mut u8
}

/// Extract a string field from a NaN-boxed JS object. Accepts string
/// values and numeric values (numbers stringified) — Node accepts both
/// shapes for `port` etc.
unsafe fn get_object_string_field(obj_f64: f64, field_name: &str) -> Option<String> {
    if !is_nanboxed_pointer(obj_f64) {
        return None;
    }
    let obj_ptr = unbox_pointer(obj_f64) as *const ObjectHeader;
    if obj_ptr.is_null() {
        return None;
    }
    let key = js_string_from_bytes(field_name.as_ptr(), field_name.len() as u32);
    let val_f64 = js_object_get_field_by_name_f64(obj_ptr, key);
    let val = JsValue::from_bits(val_f64.to_bits());
    if val.is_undefined() || val.is_null() {
        return None;
    }
    if val.is_string() {
        return string_from_header_i64(val.as_string_ptr() as i64);
    }
    if val.is_number() {
        return Some(format!("{}", val.to_number() as i64));
    }
    None
}

unsafe fn get_object_number_field(obj_f64: f64, field_name: &str) -> Option<f64> {
    if !is_nanboxed_pointer(obj_f64) {
        return None;
    }
    let obj_ptr = unbox_pointer(obj_f64) as *const ObjectHeader;
    if obj_ptr.is_null() {
        return None;
    }
    let key = js_string_from_bytes(field_name.as_ptr(), field_name.len() as u32);
    let val_f64 = js_object_get_field_by_name_f64(obj_ptr, key);
    let val = JsValue::from_bits(val_f64.to_bits());
    if val.is_undefined() || val.is_null() {
        return None;
    }
    if val.is_number() {
        return Some(val.to_number());
    }
    // Some npm code passes `port` as a string — accept that too.
    if val.is_string() {
        if let Some(s) = string_from_header_i64(val.as_string_ptr() as i64) {
            if let Ok(n) = s.parse::<f64>() {
                return Some(n);
            }
        }
    }
    None
}

/// Build an `Error`-shaped object `{ message: msg }` so user code can
/// read `err.message` from the `'error'` listener — Node emits Error
/// instances, not raw strings. Returns a NaN-boxed `f64` pointing at
/// the object. Issue #770.
unsafe fn build_error_object(msg: &str) -> f64 {
    let keys: [&str; 1] = ["message"];
    let (packed, shape_id) = build_object_shape(&keys);
    let obj: *mut ObjectHeader =
        js_object_alloc_with_shape(shape_id, 1, packed.as_ptr(), packed.len() as u32);
    if obj.is_null() {
        // Fall back to the bare string so the listener still receives
        // *something* if the object alloc failed.
        let s = alloc_string(msg);
        return f64::from_bits(nanbox_string_bits(s.as_raw()));
    }
    let s = alloc_string(msg);
    let v = JsValue::from_string_ptr(s.as_raw());
    js_object_set_field(obj, 0, v);
    let obj_v = JsValue::from_object_ptr(obj as *mut u8);
    f64::from_bits(obj_v.bits())
}

fn next_id() -> i64 {
    let mut g = statics::next_net_id().lock().unwrap();
    let id = *g;
    *g += 1;
    id
}

fn push_event(ev: PendingNetEvent) {
    statics::pending_events().lock().unwrap().push(ev);
    // Wake the main thread so its `js_wait_for_event` returns
    // promptly instead of waiting on the heartbeat cap (#84
    // sub-millisecond responsiveness). perry-ffi shipped this
    // surface in v0.5.567.
    perry_ffi::notify_main_thread();
}

fn mark_closed(id: i64) {
    if let Some(s) = statics::sockets().lock().unwrap().get_mut(&id) {
        s.is_open = false;
    }
}

// ─── rustls config ───────────────────────────────────────────────────────────

fn build_tls_connector(verify: bool) -> Result<TlsConnector, String> {
    if !verify {
        return build_tls_connector_insecure();
    }
    let mut root_store = rustls::RootCertStore::empty();
    let native = rustls_native_certs::load_native_certs();
    for cert in native.certs {
        let _ = root_store.add(cert);
    }
    let config = rustls::ClientConfig::builder()
        .with_root_certificates(root_store)
        .with_no_client_auth();
    Ok(TlsConnector::from(Arc::new(config)))
}

fn build_tls_connector_insecure() -> Result<TlsConnector, String> {
    use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
    use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
    use rustls::{DigitallySignedStruct, SignatureScheme};

    #[derive(Debug)]
    struct NoVerify;

    impl ServerCertVerifier for NoVerify {
        fn verify_server_cert(
            &self,
            _end_entity: &CertificateDer<'_>,
            _intermediates: &[CertificateDer<'_>],
            _server_name: &ServerName<'_>,
            _ocsp: &[u8],
            _now: UnixTime,
        ) -> Result<ServerCertVerified, rustls::Error> {
            Ok(ServerCertVerified::assertion())
        }
        fn verify_tls12_signature(
            &self,
            _message: &[u8],
            _cert: &CertificateDer<'_>,
            _dss: &DigitallySignedStruct,
        ) -> Result<HandshakeSignatureValid, rustls::Error> {
            Ok(HandshakeSignatureValid::assertion())
        }
        fn verify_tls13_signature(
            &self,
            _message: &[u8],
            _cert: &CertificateDer<'_>,
            _dss: &DigitallySignedStruct,
        ) -> Result<HandshakeSignatureValid, rustls::Error> {
            Ok(HandshakeSignatureValid::assertion())
        }
        fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
            vec![
                SignatureScheme::RSA_PKCS1_SHA256,
                SignatureScheme::RSA_PKCS1_SHA384,
                SignatureScheme::RSA_PKCS1_SHA512,
                SignatureScheme::ECDSA_NISTP256_SHA256,
                SignatureScheme::ECDSA_NISTP384_SHA384,
                SignatureScheme::RSA_PSS_SHA256,
                SignatureScheme::RSA_PSS_SHA384,
                SignatureScheme::RSA_PSS_SHA512,
                SignatureScheme::ED25519,
            ]
        }
    }

    let config = rustls::ClientConfig::builder()
        .dangerous()
        .with_custom_certificate_verifier(Arc::new(NoVerify))
        .with_no_client_auth();
    Ok(TlsConnector::from(Arc::new(config)))
}

async fn do_tls_handshake(
    tcp: TcpStream,
    servername: &str,
    verify: bool,
) -> Result<TlsStream<TcpStream>, String> {
    let connector = build_tls_connector(verify)?;
    let server_name = rustls::pki_types::ServerName::try_from(servername.to_string())
        .map_err(|e| format!("invalid servername '{}': {}", servername, e))?;
    connector
        .connect(server_name, tcp)
        .await
        .map_err(|e| format!("tls handshake: {}", e))
}

// ─── Spawning helper ─────────────────────────────────────────────────────────
//
// perry-ffi v0.5.x's only async-runtime entry point is `spawn_blocking`,
// which boxes a `FnOnce()` to run on tokio's blocking pool. We bridge to
// async-Rust by calling `tokio::runtime::Handle::current().block_on(...)`
// inside the closure — same pattern axios / better-sqlite3 / iroh use.
// One thread per socket for its lifetime; the perry-stdlib version uses
// the same shared tokio runtime via `crate::common::async_bridge::spawn`,
// which is a regular `tokio::spawn` (cooperative). Neither approach is
// "wrong" — the cooperative version is denser, the blocking-pool version
// is simpler. Wrapper-side simplicity wins for a v0 port.
fn spawn_socket_runner<F>(fut_factory: F)
where
    F: FnOnce() -> Pin<Box<dyn std::future::Future<Output = ()> + Send>> + Send + 'static,
{
    // Schedule the future onto a tokio runtime worker (which has
    // the I/O reactor) instead of spawn_blocking + block_on (which
    // creates a fresh current_thread runtime without I/O reactor).
    // The v0.5.578 `spawn_blocking_with_reactor` shim runs the
    // closure inside an `async` block on the multi-thread runtime,
    // so `tokio::spawn(fut)` from inside picks up the I/O reactor
    // properly. Detached spawn — we don't wait for the socket task
    // to complete (that's the whole point: it loops until close).
    perry_ffi::spawn_blocking_with_reactor(move || {
        // We're already inside a tokio task (the
        // spawn_blocking_with_reactor shim wraps us in
        // `runtime().spawn(async {...})`), so `block_on` would
        // panic with "cannot start a runtime from within a
        // runtime". Schedule the socket future as a fresh
        // detached task on the same multi-thread runtime
        // instead — the future will drive itself to completion
        // via `await` chains while we return immediately.
        //
        // Defeat the LTO pass that dead-strips perry-ext-net's
        // private copy of tokio's CONTEXT statics. Without these
        // touches, the subsequent `Handle::current()` panics with
        // "there is no reactor running" — even though perry-stdlib's
        // tokio runtime has the context entered. Two layers:
        //   - eprintln of try_current() debug result keeps the
        //     CONTEXT static referenced AT MULTIPLE call sites.
        //   - black_box on the spawn handle prevents the
        //     compiler from collapsing this whole closure into a
        //     no-op when nothing later uses the result.
        let try_h = tokio::runtime::Handle::try_current();
        std::hint::black_box(&try_h);
        if try_h.is_err() {
            eprintln!(
                "[perry-ext-net] BUG: spawn_socket_runner Handle::try_current returned Err — \
                 LTO has likely dead-stripped tokio's CONTEXT statics. This will panic on \
                 the subsequent `Handle::current()`."
            );
        }
        let handle = tokio::runtime::Handle::current();
        let fut = fut_factory();
        // Detach via JoinHandle drop — tokio doesn't cancel on drop
        // (only on explicit `abort()`), unlike `JoinSet` semantics.
        let jh = handle.spawn(fut);
        std::hint::black_box(&jh);
        std::mem::forget(jh);
    });
}

// ─── FFI: net.createConnection / net.connect ─────────────────────────────────

/// `net.createConnection(...)` / `net.connect(...)` — returns a handle
/// immediately; connection happens in the background and emits
/// `'connect'` or `'error'`. Supports both Node overloads:
///
/// - Positional: `net.connect(port, host, cb?)`. `arg1_f64` is the
///   port as a regular f64 number, `arg2_f64` carries the host as a
///   NaN-boxed string, `arg3_f64` is the optional `connectListener`.
/// - Options object: `net.connect({ host, port }, cb?)`. `arg1_f64`
///   is a NaN-boxed pointer to a JS object with `host`/`hostname`/
///   `port`; `arg2_f64` is the optional `connectListener`. In this
///   form `arg3_f64` is unused (the dispatch table pads it with
///   `undefined`). Issue #770.
///
/// The `connectListener` (whichever slot it ends up in) is
/// auto-registered as a `'connect'` listener on the new socket
/// handle, matching the Node spec.
///
/// # Safety
///
/// All three args must be NaN-boxed Perry-runtime values per the
/// codegen ABI — see `NA_F64` lowering in perry-codegen.
#[no_mangle]
pub unsafe extern "C" fn js_net_socket_connect(arg1_f64: f64, arg2_f64: f64, arg3_f64: f64) -> i64 {
    /// Register `cb_f64` as a `'connect'` listener on `handle` if it
    /// carries a real closure pointer. No-op otherwise.
    fn register_connect_cb(handle: i64, cb_f64: f64) {
        if handle == 0 || !is_nanboxed_pointer(cb_f64) {
            return;
        }
        let cb_ptr = unsafe { unbox_pointer(cb_f64) } as i64;
        if cb_ptr == 0 {
            return;
        }
        let mut listeners = statics::listeners().lock().unwrap();
        listeners
            .entry(handle)
            .or_default()
            .entry("connect".to_string())
            .or_default()
            .push(cb_ptr);
    }

    if is_nanboxed_pointer(arg1_f64) {
        // Options-object overload: extract host/port from the object.
        let host = match get_object_string_field(arg1_f64, "host")
            .or_else(|| get_object_string_field(arg1_f64, "hostname"))
        {
            Some(h) if !h.is_empty() => h,
            _ => "localhost".to_string(),
        };
        let port = match get_object_number_field(arg1_f64, "port") {
            Some(p) => p as u16,
            None => return 0,
        };
        let handle = spawn_socket_task(host, port, /* direct_tls: */ None);
        // connectListener lives in arg2 for the options form.
        register_connect_cb(handle, arg2_f64);
        return handle;
    }
    // Positional overload: arg1 is the port number, arg2 is the host
    // string (NaN-boxed), arg3 is the optional connectListener. Reuse
    // the runtime's string-pointer unifier (handles STRING_TAG and
    // POINTER_TAG strings the same way).
    extern "C" {
        fn js_get_string_pointer_unified(value: f64) -> i64;
    }
    let host_ptr = js_get_string_pointer_unified(arg2_f64);
    let host = match string_from_header_i64(host_ptr) {
        Some(h) => h,
        None => return 0,
    };
    let port = arg1_f64 as u16;
    let handle = spawn_socket_task(host, port, /* direct_tls: */ None);
    register_connect_cb(handle, arg3_f64);
    handle
}

// ─── FFI: new net.Socket() (alloc-only, deferred connect) ────────────────────

/// `new net.Socket()` — allocates an unconnected socket handle. The TCP
/// connection is deferred until `js_net_socket_method_connect` runs. Issue
/// #422 added this path; pre-#422 only the eager `createConnection` factory
/// existed.
#[no_mangle]
pub unsafe extern "C" fn js_net_socket_alloc() -> i64 {
    ensure_gc_scanner_registered();
    let id = next_id();
    let (tx, rx) = mpsc::unbounded_channel::<SocketCommand>();
    statics::sockets().lock().unwrap().insert(
        id,
        SocketState {
            cmd_tx: tx,
            pending_rx: Some(rx),
            is_open: false,
        },
    );
    statics::listeners()
        .lock()
        .unwrap()
        .insert(id, HashMap::new());
    id
}

// ─── FFI: net.createServer(options?, connectionListener?) ────────────────────

/// Issue #1123 — `net.createServer(options?, connectionListener?)`. Allocates
/// a placeholder server handle so the dotted (`net.createServer(...)`) and
/// named-import (`import { createServer } from "node:net"`) forms both have
/// a non-undefined return value to bind to `server`. The full event-driven
/// accept loop (mirroring `js_node_http_server_listen`) is a separate
/// follow-up — today this is just enough for the codegen lowering (`Expr::
/// NetCreateServer` in `crates/perry-codegen/src/expr.rs`) and the HIR
/// named-import bridge (`crates/perry-hir/src/lower/expr_call.rs`'s
/// `("net", "createServer")` arm) to compile + link + run without
/// "expression NetCreateServer not yet supported" / `_js_net_create_server`
/// link errors.
///
/// Returns a positive integer handle as `f64` (the same convention as
/// `js_net_socket_alloc` for the i64 socket id — callers store it through
/// the codegen's DOUBLE return slot, so a raw small-integer f64 is the
/// expected wire format here). If a `connectionListener` closure is
/// provided (POINTER_TAG-tagged i64 from the codegen's `unbox_to_i64`),
/// we stash it under the `'connection'` event slot in `NET_LISTENERS`
/// so a future full implementation of `js_net_server_listen` can fire
/// it. Options are accepted but ignored for now.
///
/// # Safety
///
/// `connection_listener_i64` may be 0 (no listener provided) or a raw
/// (`POINTER_TAG`-stripped) `*const RawClosureHeader`. We only walk the
/// pointer through `JsClosure::from_raw`'s null-tolerant API.
#[no_mangle]
pub unsafe extern "C" fn js_net_create_server(
    _options_i64: i64,
    connection_listener_i64: i64,
) -> i64 {
    ensure_gc_scanner_registered();
    let id = next_id();
    statics::listeners()
        .lock()
        .unwrap()
        .insert(id, HashMap::new());
    // Issue #1123 followup — register the server in the dedicated
    // `servers()` map alongside the listener-map entry. The accept
    // loop in `js_net_server_listen` populates `shutdown_tx` + the
    // bound address fields; `js_net_server_close` consumes the
    // shutdown sender to wake the accept loop.
    statics::servers().lock().unwrap().insert(
        id,
        ServerState {
            shutdown_tx: None,
            bound_port: 0,
            bound_host: String::new(),
            listening: false,
        },
    );
    if connection_listener_i64 != 0 {
        if let Ok(mut listeners) = statics::listeners().lock() {
            listeners
                .entry(id)
                .or_default()
                .entry("connection".to_string())
                .or_default()
                .push(connection_listener_i64);
        }
    }
    // Issue #1123 followup — return the raw `i64` handle so the codegen
    // can NaN-box it with POINTER_TAG (mirroring `js_node_http_create_server`).
    // Without this, downstream `server.listen(...)` couldn't recover
    // the handle: `unbox_to_i64` on a bare `1.0` would mask to 0 because
    // the lower 48 bits of `0x3FF0_0000_0000_0000` are all zero.
    //
    // Side effect: `typeof server` now reports `"object"` (the canonical
    // Node value — `net.Server extends EventEmitter`) instead of the
    // `"number"` the initial #1123 placeholder briefly surfaced. The
    // existing test fixture's expected stdout was updated to match.
    id
}

// ─── FFI: net.Server.listen / .close / .address / .on ────────────────────────
//
// Issue #1123 followup — full accept-loop wiring for the server handle
// allocated by `js_net_create_server` above. Mirrors the shape of
// `perry-ext-http-server::js_node_http_server_listen` (bind + spawn
// accept loop on the multi-thread tokio runtime, then return
// immediately; per-event dispatch happens on the main thread inside
// `js_net_process_pending`).
//
// **Why not reuse the http-server scaffolding?** That crate hard-wires
// hyper's HTTP/1 service path into every accepted connection — every
// byte goes through `service_fn` + `Response<Full<Bytes>>`. raw TCP
// (`net.createServer`) emits the accepted `TcpStream` directly to the
// user's `'connection'` listener wrapped in a `net.Socket` handle. The
// two paths share the "spawn accept loop + drain on main thread"
// pattern but nothing past the listener.bind() call.

/// `server.listen(port, callback?)` — bind a tokio `TcpListener` on
/// `0.0.0.0:port` and spawn an accept loop on the shared multi-thread
/// runtime. The `callback` (a NaN-boxed closure pointer in the codegen's
/// NA_PTR slot, raw i64 here after unboxing in lower_call.rs) is
/// registered as a one-shot `'listening'` listener; when the bind
/// resolves, the accept-loop task pushes a `ServerListening` event so
/// the main-thread pump invokes both the user's `.on('listening', cb)`
/// listeners and the trailing `.listen(port, cb)` callback.
///
/// Bind failures emit a `ServerError` and a `ServerClose` so the user's
/// `.on('error', err => …)` + `.on('close', () => …)` listeners fire
/// the same way they would on Node.
///
/// # Safety
///
/// `handle` must be a server id returned by `js_net_create_server`.
/// `callback_i64` may be 0 (no callback) or a raw `*const RawClosureHeader`
/// cast to `i64` — the codegen ABI for NA_PTR-unboxed closures.
#[no_mangle]
pub unsafe extern "C" fn js_net_server_listen(handle: i64, port: f64, callback_i64: i64) {
    ensure_gc_scanner_registered();
    let port_u16 = port as u16;
    let host = "0.0.0.0".to_string();

    let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();

    // Mark the server as listening + stash the shutdown sender. If the
    // handle isn't registered, bail before touching tokio.
    {
        let mut servers = match statics::servers().lock() {
            Ok(g) => g,
            Err(_) => return,
        };
        let s = match servers.get_mut(&handle) {
            Some(s) => s,
            None => return,
        };
        s.shutdown_tx = Some(shutdown_tx);
        s.bound_port = port_u16;
        s.bound_host = host.clone();
        s.listening = true;
    }

    // Stash the listen-callback under `'listening'` so the pump fires
    // it on the first ServerListening event, then drops it (matching
    // Node's "callback runs once on listen" semantics).
    if callback_i64 != 0 {
        if let Ok(mut listeners) = statics::listeners().lock() {
            listeners
                .entry(handle)
                .or_default()
                .entry("listening".to_string())
                .or_default()
                .push(callback_i64);
        }
    }

    let host_for_spawn = host.clone();
    let server_id = handle;

    // Schedule the accept loop on the multi-thread tokio runtime that
    // perry-stdlib hosts. Mirrors `js_node_http_server_listen`'s
    // spawn_blocking_with_reactor → tokio::spawn pattern: the outer
    // closure tickles tokio's CONTEXT statics through LTO; the inner
    // `tokio::spawn` actually runs the async work.
    perry_ffi::spawn_blocking_with_reactor(move || {
        // Same LTO-defeating dance as `spawn_socket_runner` above.
        let try_h = tokio::runtime::Handle::try_current();
        std::hint::black_box(&try_h);
        if try_h.is_err() {
            eprintln!(
                "[perry-ext-net] BUG: js_net_server_listen Handle::try_current returned Err — \
                 LTO has likely dead-stripped tokio's CONTEXT statics."
            );
        }
        let rt = tokio::runtime::Handle::current();
        let jh = rt.spawn(async move {
            let bind_str = format!("{}:{}", host_for_spawn, port_u16);
            let listener = match TcpListener::bind(&bind_str).await {
                Ok(l) => l,
                Err(e) => {
                    push_event(PendingNetEvent::ServerError(
                        server_id,
                        format!("bind {}: {}", bind_str, e),
                    ));
                    push_event(PendingNetEvent::ServerClose(server_id));
                    if let Ok(mut servers) = statics::servers().lock() {
                        if let Some(s) = servers.get_mut(&server_id) {
                            s.listening = false;
                        }
                    }
                    return;
                }
            };
            // bind succeeded — fire `'listening'`.
            push_event(PendingNetEvent::ServerListening(server_id));

            loop {
                tokio::select! {
                    accepted = listener.accept() => {
                        match accepted {
                            Ok((stream, _peer)) => {
                                // Allocate a fresh Socket handle that
                                // shares the existing socket machinery
                                // (run_socket_task, command channel,
                                // 'data'/'end'/'close'/'error' pump
                                // dispatch). The accept side doesn't
                                // need a tokio TcpStream::connect — we
                                // already have the stream — so we
                                // bypass `spawn_socket_task` (which
                                // calls TcpStream::connect inside) and
                                // call `run_socket_task` directly with
                                // the accepted stream.
                                let socket_id = next_id();
                                let (tx, rx) = mpsc::unbounded_channel::<SocketCommand>();
                                statics::sockets().lock().unwrap().insert(
                                    socket_id,
                                    SocketState {
                                        cmd_tx: tx,
                                        pending_rx: None,
                                        is_open: true,
                                    },
                                );
                                statics::listeners()
                                    .lock()
                                    .unwrap()
                                    .insert(socket_id, HashMap::new());

                                // Surface the new socket to the user's
                                // `'connection'` listener on the main
                                // thread *before* spawning the read
                                // loop — the listener typically registers
                                // its own `.on('data', ...)` handlers
                                // and we want those in place before
                                // bytes start arriving. The accepted
                                // stream's read loop spawns next.
                                push_event(PendingNetEvent::ServerConnection(server_id, socket_id));

                                // Spawn the per-socket read/write loop
                                // on the same runtime as the accept
                                // loop. We use a fresh `tokio::spawn`
                                // rather than `spawn_socket_runner` to
                                // avoid the nested `spawn_blocking_with_reactor`
                                // wrap — we're already inside a tokio
                                // task, so direct `tokio::spawn` is fine
                                // (the LTO black_box workaround only
                                // matters at the FFI entry point where
                                // we cross back into Rust-from-C
                                // territory).
                                tokio::spawn(async move {
                                    let mut rx = rx;
                                    run_socket_task(
                                        socket_id,
                                        Transport::Plain(stream),
                                        &mut rx,
                                    )
                                    .await;
                                });
                            }
                            Err(e) => {
                                push_event(PendingNetEvent::ServerError(
                                    server_id,
                                    format!("accept: {}", e),
                                ));
                                // Don't break the loop on a transient
                                // accept error — Node doesn't.
                            }
                        }
                    }
                    _ = &mut shutdown_rx => {
                        break;
                    }
                }
            }
            // Loop exited (close() called or fatal error) — emit
            // a final 'close' event so user code can see the
            // server stopped.
            push_event(PendingNetEvent::ServerClose(server_id));
            if let Ok(mut servers) = statics::servers().lock() {
                if let Some(s) = servers.get_mut(&server_id) {
                    s.listening = false;
                }
            }
        });
        std::hint::black_box(&jh);
        std::mem::forget(jh);
    });
}

/// `server.close(callback?)` — break the accept loop and fire the
/// optional callback once it exits. The actual `'close'` listener
/// dispatch happens in the main-thread pump when the accept-loop
/// task pushes its terminal `ServerClose` event.
///
/// # Safety
///
/// `handle` must be a server id; `callback_i64` is a raw closure ptr.
#[no_mangle]
pub unsafe extern "C" fn js_net_server_close(handle: i64, callback_i64: i64) {
    // Stash the user's close callback under `'close'` so the pump fires
    // it alongside the registered listeners when the accept loop exits.
    if callback_i64 != 0 {
        if let Ok(mut listeners) = statics::listeners().lock() {
            listeners
                .entry(handle)
                .or_default()
                .entry("close".to_string())
                .or_default()
                .push(callback_i64);
        }
    }
    // Drop the shutdown sender — the accept loop's `tokio::select!`
    // wakes immediately on the receiver side and exits its loop.
    if let Ok(mut servers) = statics::servers().lock() {
        if let Some(s) = servers.get_mut(&handle) {
            s.shutdown_tx.take();
        }
    }
}

/// `server.address()` — returns a JSON string the TS-side wrapper can
/// `JSON.parse` into `{ port, address, family }`. Matches the
/// perry-ext-http-server contract (`js_node_http_server_address_json`).
///
/// Returns `null` (as a JS string) for an unlistening server.
///
/// # Safety
///
/// `handle` must be a server id. The returned `*mut StringHeader` is
/// allocated in the runtime arena and follows perry-ffi's standard
/// ownership: the caller hands it to user code as a NaN-boxed JS string.
#[no_mangle]
pub unsafe extern "C" fn js_net_server_address(handle: i64) -> *mut StringHeader {
    let json = match statics::servers().lock() {
        Ok(g) => match g.get(&handle) {
            Some(s) if s.listening => {
                let family = if s.bound_host.contains(':') {
                    "IPv6"
                } else {
                    "IPv4"
                };
                format!(
                    "{{\"port\":{},\"address\":\"{}\",\"family\":\"{}\"}}",
                    s.bound_port, s.bound_host, family
                )
            }
            _ => "null".to_string(),
        },
        Err(_) => "null".to_string(),
    };
    alloc_string(&json).as_raw()
}

/// `server.on(event, cb)` — register a server-level listener for
/// `'connection'`, `'listening'`, `'close'`, or `'error'`. Reuses
/// the shared listener map keyed on the server id (server ids and
/// socket ids are drawn from the same monotonic counter so they
/// never collide).
///
/// # Safety
///
/// `event_ptr` must be null or a Perry-runtime `StringHeader`. `cb`
/// is a raw `*const ClosureHeader` cast to `i64`.
#[no_mangle]
pub unsafe extern "C" fn js_net_server_on(handle: i64, event_ptr: i64, cb: i64) {
    ensure_gc_scanner_registered();
    let event = match string_from_header_i64(event_ptr) {
        Some(e) => e,
        None => return,
    };
    let mut listeners = statics::listeners().lock().unwrap();
    let entry = listeners.entry(handle).or_default();
    entry.entry(event).or_default().push(cb);
}

// ─── FFI: socket.connect(port, host) (instance method on existing handle) ─────

/// `socket.connect(port, host)` — initiates a TCP connection on a socket
/// previously allocated by `new net.Socket()`. Pulls its receiver out of
/// the `SocketState::pending_rx` slot rather than allocating a fresh
/// channel, so any listener already registered (`sock.on('data', cb)`)
/// sees the same handle id once the connect completes.
///
/// # Safety
///
/// See `js_net_socket_connect`.
#[no_mangle]
pub unsafe extern "C" fn js_net_socket_method_connect(handle: i64, port: f64, host_ptr: i64) {
    let host = match string_from_header_i64(host_ptr) {
        Some(h) => h,
        None => {
            push_event(PendingNetEvent::Error(
                handle,
                "socket.connect: invalid host string".to_string(),
            ));
            return;
        }
    };
    let port = port as u16;

    let rx = {
        let mut guard = statics::sockets().lock().unwrap();
        match guard.get_mut(&handle).and_then(|s| s.pending_rx.take()) {
            Some(rx) => rx,
            None => {
                push_event(PendingNetEvent::Error(
                    handle,
                    "socket already connected (or unknown handle)".to_string(),
                ));
                return;
            }
        }
    };

    spawn_socket_runner(move || {
        Box::pin(async move {
            let mut rx = rx;
            let addr = format!("{}:{}", host, port);
            let tcp = match TcpStream::connect(&addr).await {
                Ok(s) => s,
                Err(e) => {
                    push_event(PendingNetEvent::Error(handle, format!("{}", e)));
                    push_event(PendingNetEvent::Close(handle));
                    mark_closed(handle);
                    return;
                }
            };

            if let Some(s) = statics::sockets().lock().unwrap().get_mut(&handle) {
                s.is_open = true;
            }
            push_event(PendingNetEvent::Connect(handle));

            run_socket_task(handle, Transport::Plain(tcp), &mut rx).await;
        })
    });
}

// ─── FFI: tls.connect(host, port, servername) ────────────────────────────────

/// `tls.connect(host, port, servername, verify)` — opens a plain TCP socket
/// and runs the TLS handshake before firing `'connect'`. Use this for
/// HTTPS-style protocols that start TLS from byte 0.
///
/// # Safety
///
/// `host_ptr` and `servername_ptr` must be null or Perry-runtime
/// `StringHeader` pointers cast to `i64`.
#[no_mangle]
pub unsafe extern "C" fn js_tls_connect(
    host_ptr: i64,
    port: f64,
    servername_ptr: i64,
    verify: f64,
) -> i64 {
    let host = match string_from_header_i64(host_ptr) {
        Some(h) => h,
        None => return 0,
    };
    let servername = match string_from_header_i64(servername_ptr) {
        Some(s) => s,
        None => host.clone(),
    };
    let port = port as u16;
    let verify = verify != 0.0;
    spawn_socket_task(host, port, Some((servername, verify)))
}

/// Internal: allocate the handle, spawn the tokio task.
/// `direct_tls = Some((servername, verify))` runs a TLS handshake before
/// firing 'connect'; None keeps the socket in plain TCP mode.
fn spawn_socket_task(host: String, port: u16, direct_tls: Option<(String, bool)>) -> i64 {
    ensure_gc_scanner_registered();
    let id = next_id();
    let (tx, rx) = mpsc::unbounded_channel::<SocketCommand>();

    statics::sockets().lock().unwrap().insert(
        id,
        SocketState {
            cmd_tx: tx,
            pending_rx: None,
            is_open: false,
        },
    );
    statics::listeners()
        .lock()
        .unwrap()
        .insert(id, HashMap::new());

    spawn_socket_runner(move || {
        Box::pin(async move {
            let mut rx = rx;
            let addr = format!("{}:{}", host, port);
            let tcp = match TcpStream::connect(&addr).await {
                Ok(s) => s,
                Err(e) => {
                    push_event(PendingNetEvent::Error(id, format!("{}", e)));
                    push_event(PendingNetEvent::Close(id));
                    mark_closed(id);
                    return;
                }
            };

            let transport = match direct_tls {
                Some((servername, verify)) => {
                    match do_tls_handshake(tcp, &servername, verify).await {
                        Ok(tls) => Transport::Tls(Box::new(tls)),
                        Err(e) => {
                            push_event(PendingNetEvent::Error(id, e));
                            push_event(PendingNetEvent::Close(id));
                            mark_closed(id);
                            return;
                        }
                    }
                }
                None => Transport::Plain(tcp),
            };

            if let Some(s) = statics::sockets().lock().unwrap().get_mut(&id) {
                s.is_open = true;
            }
            push_event(PendingNetEvent::Connect(id));

            run_socket_task(id, transport, &mut rx).await;
        })
    });

    id
}

/// The read/write/command loop. Shared by plain-TCP and direct-TLS paths.
async fn run_socket_task(
    id: i64,
    initial_transport: Transport,
    rx: &mut mpsc::UnboundedReceiver<SocketCommand>,
) {
    let mut transport: Option<Transport> = Some(initial_transport);
    let mut buf = vec![0u8; 16 * 1024];

    loop {
        let t = match transport.as_mut() {
            Some(t) => t,
            None => break,
        };

        tokio::select! {
            read_result = t.read(&mut buf) => {
                match read_result {
                    Ok(0) => {
                        push_event(PendingNetEvent::Close(id));
                        mark_closed(id);
                        break;
                    }
                    Ok(n) => {
                        push_event(PendingNetEvent::Data(id, buf[..n].to_vec()));
                    }
                    Err(e) => {
                        push_event(PendingNetEvent::Error(id, format!("{}", e)));
                        push_event(PendingNetEvent::Close(id));
                        mark_closed(id);
                        break;
                    }
                }
            }
            cmd = rx.recv() => {
                match cmd {
                    Some(SocketCommand::Write(bytes)) => {
                        if let Err(e) = t.write_all(&bytes).await {
                            push_event(PendingNetEvent::Error(id, format!("{}", e)));
                            push_event(PendingNetEvent::Close(id));
                            mark_closed(id);
                            break;
                        }
                    }
                    Some(SocketCommand::End) => {
                        let _ = t.shutdown().await;
                    }
                    Some(SocketCommand::Destroy) | None => {
                        push_event(PendingNetEvent::Close(id));
                        mark_closed(id);
                        break;
                    }
                    Some(SocketCommand::UpgradeTls { servername, verify, reply }) => {
                        let old = transport.take();
                        match old {
                            Some(Transport::Plain(tcp)) => {
                                match do_tls_handshake(tcp, &servername, verify).await {
                                    Ok(tls) => {
                                        transport = Some(Transport::Tls(Box::new(tls)));
                                        let _ = reply.send(Ok(()));
                                    }
                                    Err(e) => {
                                        let _ = reply.send(Err(e.clone()));
                                        push_event(PendingNetEvent::Error(id, e));
                                        push_event(PendingNetEvent::Close(id));
                                        mark_closed(id);
                                        break;
                                    }
                                }
                            }
                            Some(already_tls @ Transport::Tls(_)) => {
                                transport = Some(already_tls);
                                let _ = reply.send(Err("socket is already TLS".to_string()));
                            }
                            None => {
                                let _ = reply.send(Err("socket closed".to_string()));
                                break;
                            }
                        }
                    }
                }
            }
        }
    }
}

// ─── FFI: socket.write(buf) ──────────────────────────────────────────────────

/// `socket.write(chunk)` — enqueues bytes for the writer task.
///
/// Issue #1131 — `chunk_bits` is the full NaN-boxed JS value (codegen
/// passes `NA_JSV`, the dispatch shims pass `args[0].to_bits()`), NOT a
/// pre-stripped `BufferHeader` pointer. `jsvalue_to_socket_bytes`
/// probes the actual type (Buffer / Uint8Array vs JS string vs
/// number / bool) and reads through the correct memory layout, so
/// `socket.write("ping")` now sends the string's UTF-8 bytes instead
/// of garbage read out of the wrong header shape. Outbound mirror of
/// the #1124 inbound `res.write(Buffer)` fix.
///
/// # Safety
///
/// `chunk_bits` must be a valid NaN-boxed JS value (string / Buffer /
/// number / bool / null / undefined). String / Buffer pointers must
/// reference live runtime allocations.
#[no_mangle]
pub unsafe extern "C" fn js_net_socket_write(handle: i64, chunk_bits: i64) {
    let bytes = match jsvalue_to_socket_bytes(f64::from_bits(chunk_bits as u64)) {
        Some(b) => b,
        None => return,
    };

    let sockets = statics::sockets().lock().unwrap();
    if let Some(s) = sockets.get(&handle) {
        let _ = s.cmd_tx.send(SocketCommand::Write(bytes));
    }
}

// ─── FFI: socket.end() ───────────────────────────────────────────────────────

/// `socket.end()` — graceful shutdown.
#[no_mangle]
pub unsafe extern "C" fn js_net_socket_end(handle: i64) {
    let sockets = statics::sockets().lock().unwrap();
    if let Some(s) = sockets.get(&handle) {
        let _ = s.cmd_tx.send(SocketCommand::End);
    }
}

// ─── FFI: socket.destroy() ───────────────────────────────────────────────────

/// `socket.destroy()` — hard close.
#[no_mangle]
pub unsafe extern "C" fn js_net_socket_destroy(handle: i64) {
    let sockets = statics::sockets().lock().unwrap();
    if let Some(s) = sockets.get(&handle) {
        let _ = s.cmd_tx.send(SocketCommand::Destroy);
    }
}

// ─── FFI: socket.on(event, callback) ─────────────────────────────────────────

/// `socket.on(event, cb)` — registers a listener. Closures are stored as
/// raw `i64` pointers; the GC root scanner keeps them alive across cycles.
///
/// # Safety
///
/// `event_ptr` must be null or a Perry-runtime `StringHeader`. `cb` is a
/// raw `*const ClosureHeader` cast to `i64` (codegen ABI for NA_PTR).
#[no_mangle]
pub unsafe extern "C" fn js_net_socket_on(handle: i64, event_ptr: i64, cb: i64) {
    ensure_gc_scanner_registered();
    let event = match string_from_header_i64(event_ptr) {
        Some(e) => e,
        None => return,
    };
    let mut listeners = statics::listeners().lock().unwrap();
    let entry = listeners.entry(handle).or_default();
    entry.entry(event).or_default().push(cb);
}

// ─── FFI: socket.upgradeToTLS(servername, verify) -> Promise ─────────────────

/// `socket.upgradeToTLS(servername, verify)` — sends an UpgradeTls command
/// to the socket task and returns a Promise that resolves when the
/// handshake completes (or rejects on failure).
///
/// # Safety
///
/// `servername_ptr` must be null or a Perry-runtime `StringHeader`.
#[no_mangle]
pub unsafe extern "C" fn js_net_socket_upgrade_tls(
    handle: i64,
    servername_ptr: i64,
    verify: f64,
) -> *mut perry_ffi::Promise {
    let promise = JsPromise::new();
    let promise_raw = promise.as_raw();

    let servername = match string_from_header_i64(servername_ptr) {
        Some(s) => s,
        None => {
            // Reject on the same thread we're called from — works because
            // the resolution is queued and processed on the main thread by
            // the runtime's promise dispatcher.
            promise.reject_string("invalid servername");
            return promise_raw;
        }
    };

    let cmd_tx = {
        let sockets = statics::sockets().lock().unwrap();
        match sockets.get(&handle) {
            Some(s) => s.cmd_tx.clone(),
            None => {
                promise.reject_string(&format!("socket {} not found", handle));
                return promise_raw;
            }
        }
    };

    let (reply_tx, reply_rx) = oneshot::channel::<Result<(), String>>();
    let verify_bool = verify != 0.0;
    if cmd_tx
        .send(SocketCommand::UpgradeTls {
            servername,
            verify: verify_bool,
            reply: reply_tx,
        })
        .is_err()
    {
        promise.reject_string("socket task is gone");
        return promise_raw;
    }

    // Hand the JsPromise to a blocking thread that awaits the oneshot reply.
    perry_ffi::spawn_blocking(move || {
        let handle_rt = tokio::runtime::Handle::current();
        handle_rt.block_on(async move {
            match reply_rx.await {
                Ok(Ok(())) => promise.resolve_undefined(),
                Ok(Err(msg)) => promise.reject_string(&msg),
                Err(_) => promise.reject_string("upgrade reply dropped"),
            }
        });
    });

    promise_raw
}

// ─── Main-thread event pump ──────────────────────────────────────────────────

/// Dispatches queued socket events to JS listeners on the main thread.
/// Called from codegen's event-loop tick (via the well-known pending-events
/// pump).
///
/// Per the arena-safety rule: JSValue construction (Buffer, error string)
/// happens HERE on the main thread, never in the tokio read task.
///
/// Returns the number of events fired in this pass.
///
/// #1114 followup (mysql wedge): this pump runs on EVERY iteration of
/// the generated event loop AND every iteration of every inline `await`
/// poll loop. `@perryts/mysql` (pure-TS driver) drives all its bytes
/// through `net.Socket`, so under a `setInterval` + async-query JobLoop
/// this function is the dominant per-tick path. The original
/// `Vec::drain(..).collect()` allocated a fresh Vec every call
/// (mirroring the fastify wedge that e538caa7 fixed) → GC `madvise`
/// page-churn. Reuse a per-thread scratch buffer (moved out across
/// dispatch so a re-entrant pump from inside a user callback is safe;
/// capacity retained → zero steady-state allocation).
#[no_mangle]
pub unsafe extern "C" fn js_net_process_pending() -> i32 {
    thread_local! {
        static SCRATCH: std::cell::RefCell<Vec<PendingNetEvent>> =
            const { std::cell::RefCell::new(Vec::new()) };
    }
    let mut events = SCRATCH.with(|s| std::mem::take(&mut *s.borrow_mut()));
    events.clear();
    {
        let mut g = statics::pending_events().lock().unwrap();
        events.append(&mut *g);
    }
    let count = events.len() as i32;

    for ev in events.drain(..) {
        match ev {
            PendingNetEvent::Connect(id) => {
                for cb in listeners_for(id, "connect") {
                    if cb != 0 {
                        let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call0();
                    }
                }
            }
            PendingNetEvent::Data(id, bytes) => {
                let cbs = listeners_for(id, "data");
                if cbs.is_empty() {
                    continue;
                }
                let buf = alloc_buffer(&bytes);
                if buf.is_null() {
                    continue;
                }
                // POINTER_TAG over the buffer pointer.
                let buf_f64 =
                    f64::from_bits(0x7FFD_0000_0000_0000 | (buf as u64 & 0x0000_FFFF_FFFF_FFFF));
                for cb in cbs {
                    if cb != 0 {
                        let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call1(buf_f64);
                    }
                }
            }
            PendingNetEvent::Error(id, msg) => {
                let cbs = listeners_for(id, "error");
                if cbs.is_empty() {
                    continue;
                }
                // Issue #770 — emit an Error-shaped object `{message: msg}`
                // so user code can read `err.message`. Pre-fix this was a
                // raw NaN-boxed string and `err.message` was `undefined`.
                let err_f64 = build_error_object(&msg);
                for cb in cbs {
                    if cb != 0 {
                        let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call1(err_f64);
                    }
                }
            }
            PendingNetEvent::Close(id) => {
                for cb in listeners_for(id, "close") {
                    if cb != 0 {
                        let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call0();
                    }
                }
                statics::listeners().lock().unwrap().remove(&id);
                statics::sockets().lock().unwrap().remove(&id);
            }
            // Issue #1123 followup — server-side events. The
            // accept loop pushes `ServerConnection`/`ServerListening`/
            // `ServerError`/`ServerClose`; the main-thread pump
            // converts them into the appropriate JS dispatch.
            PendingNetEvent::ServerConnection(server_id, socket_id) => {
                let cbs = listeners_for(server_id, "connection");
                if cbs.is_empty() {
                    continue;
                }
                // Sockets returned by the codegen's `net.connect`
                // path (`js_net_socket_connect` → NR_PTR ret kind in
                // lower_call.rs) are NaN-boxed with POINTER_TAG over
                // the raw socket id. Match that here so user code
                // sees the same value shape regardless of which side
                // produced the socket: `sock.on(...)` then dispatches
                // through the `("net", true, "on", Some("Socket"))`
                // NATIVE_MODULE_TABLE row (which `unbox_to_i64`s the
                // receiver back to the raw id). Bare-number sockets
                // skipped the dispatch and hit the generic property
                // path → `(number).on is not a function`.
                let sock_f64 = f64::from_bits(
                    0x7FFD_0000_0000_0000 | (socket_id as u64 & 0x0000_FFFF_FFFF_FFFF),
                );
                for cb in cbs {
                    if cb != 0 {
                        let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call1(sock_f64);
                    }
                }
            }
            PendingNetEvent::ServerListening(server_id) => {
                // Take + drain the 'listening' listeners so the
                // optional `listen(port, cb)` callback fires exactly
                // once (Node's semantics). Subsequent
                // `.on('listening', ...)` registrations would have
                // to wait for another `.listen(...)` cycle — fine,
                // re-binding without close() in between would error
                // on bind anyway.
                let cbs = {
                    let mut listeners = statics::listeners().lock().unwrap();
                    if let Some(per_server) = listeners.get_mut(&server_id) {
                        per_server.remove("listening").unwrap_or_default()
                    } else {
                        Vec::new()
                    }
                };
                for cb in cbs {
                    if cb != 0 {
                        let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call0();
                    }
                }
            }
            PendingNetEvent::ServerClose(server_id) => {
                // Drain close listeners (one-shot, like Node).
                let cbs = {
                    let mut listeners = statics::listeners().lock().unwrap();
                    if let Some(per_server) = listeners.get_mut(&server_id) {
                        per_server.remove("close").unwrap_or_default()
                    } else {
                        Vec::new()
                    }
                };
                for cb in cbs {
                    if cb != 0 {
                        let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call0();
                    }
                }
                // Tear down the server entry so the keepalive gate
                // (`js_ext_net_has_active_handles`) lets the runtime
                // exit cleanly after the user's close() resolves.
                statics::servers().lock().unwrap().remove(&server_id);
                statics::listeners().lock().unwrap().remove(&server_id);
            }
            PendingNetEvent::ServerError(server_id, msg) => {
                let cbs = listeners_for(server_id, "error");
                if cbs.is_empty() {
                    // Node prints to stderr if there's no handler and
                    // crashes the process; we just log and continue —
                    // less hostile to test harnesses that haven't
                    // wired an error listener yet.
                    eprintln!("[perry-ext-net] server {} error: {}", server_id, msg);
                    continue;
                }
                let err_f64 = build_error_object(&msg);
                for cb in cbs {
                    if cb != 0 {
                        let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call1(err_f64);
                    }
                }
            }
        }
    }

    // Restore the (capacity-retaining) buffer to the thread-local so the
    // next tick reuses it. A re-entrant pump call during dispatch may
    // have left a grown buffer in the slot — keep whichever is larger.
    SCRATCH.with(|s| {
        let mut slot = s.borrow_mut();
        if events.capacity() >= slot.capacity() {
            *slot = events;
        }
    });

    count
}

fn listeners_for(id: i64, event: &str) -> Vec<i64> {
    statics::listeners()
        .lock()
        .unwrap()
        .get(&id)
        .and_then(|m| m.get(event).cloned())
        .unwrap_or_default()
}

/// Returns 1 if there are pending events or live sockets keeping the
/// runtime's main loop alive.
#[no_mangle]
pub extern "C" fn js_net_has_pending() -> i32 {
    if !statics::pending_events().lock().unwrap().is_empty() {
        return 1;
    }
    if !statics::sockets().lock().unwrap().is_empty() {
        return 1;
    }
    // Issue #1123 followup — a listening `net.Server` keeps the loop
    // alive too: pre-fix, `createServer(...).listen(port, cb)` would
    // exit before any client ever connected because the gate only
    // counted accepted sockets.
    if !statics::servers().lock().unwrap().is_empty() {
        return 1;
    }
    0
}

/// True iff `handle` is a currently-registered net socket id. Mirrors the
/// perry-stdlib export so codegen's `HANDLE_METHOD_DISPATCH` keeps working.
pub fn is_net_socket_handle(handle: i64) -> bool {
    statics::sockets().lock().unwrap().contains_key(&handle)
}

/// `extern "C"` form of `is_net_socket_handle` — used by
/// perry-stdlib's `common::dispatch::dispatch_handle_method`
/// (HANDLE_METHOD_DISPATCH) when bundled-net is stripped and
/// the well-known flip routes 'net' to perry-ext-net. Returns
/// 1 for a registered socket handle, 0 otherwise.
///
/// Closes the issue #91 regression: Map.get'd / struct-field /
/// wrapper-function receivers where codegen lost the static type
/// fall through to `js_native_call_method` →
/// `dispatch_handle_method` → this query → `dispatch_net_socket`.
/// Without the extern, the dispatch tower's `is_net_socket_handle`
/// reference resolved to perry-stdlib's no-op stub (compiled-out
/// when bundled-net is off) and Map-retrieved sockets silently
/// dispatched to undefined.
#[no_mangle]
pub extern "C" fn js_ext_net_is_socket_handle(handle: i64) -> i32 {
    if is_net_socket_handle(handle) {
        1
    } else {
        0
    }
}

/// Returns 1 if there are pending socket events or live socket handles
/// keeping the runtime's event loop alive.
///
/// "Live" here means *registered* — including sockets still establishing
/// their TCP connection. Counting only fully-open sockets caused the
/// runtime to exit before async `connect` ever completed (the open flag
/// flips inside the spawned task, after `await TcpStream::connect`).
///
/// Without this, `await new Promise(r => sock.on('connect', r))` from
/// a TS-source npm driver (e.g. `@perryts/mysql`) returns a Promise the
/// runtime can't see is pending, so the event loop exits before the
/// socket's 'connect' event ever fires through the pump. Issue #536.
#[no_mangle]
pub extern "C" fn js_ext_net_has_active_handles() -> i32 {
    if !statics::pending_events().lock().unwrap().is_empty() {
        return 1;
    }
    if !statics::sockets().lock().unwrap().is_empty() {
        return 1;
    }
    // Issue #1123 followup — same rationale as `js_net_has_pending`:
    // a listening server must keep the event loop alive until
    // `.close()` drops the shutdown sender and the accept-loop task
    // pushes its terminal `ServerClose` event.
    if !statics::servers().lock().unwrap().is_empty() {
        return 1;
    }
    0
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::{Mutex, MutexGuard};

    static GC_TEST_LOCK: Mutex<()> = Mutex::new(());

    struct GcTestGuard {
        frame: u64,
        _lock: MutexGuard<'static, ()>,
    }

    impl GcTestGuard {
        fn new() -> Self {
            let lock = GC_TEST_LOCK
                .lock()
                .unwrap_or_else(|poisoned| poisoned.into_inner());
            perry_runtime::gc::js_gc_write_barriers_emitted(1);
            let frame = perry_runtime::gc::js_shadow_frame_push(0);
            Self { frame, _lock: lock }
        }
    }

    impl Drop for GcTestGuard {
        fn drop(&mut self) {
            perry_runtime::gc::js_shadow_frame_pop(self.frame);
            perry_runtime::gc::js_gc_write_barriers_emitted(0);
        }
    }

    struct NetHandleCleanup {
        handles: Vec<i64>,
    }

    impl NetHandleCleanup {
        fn new(handles: Vec<i64>) -> Self {
            Self { handles }
        }
    }

    impl Drop for NetHandleCleanup {
        fn drop(&mut self) {
            let mut listeners = statics::listeners().lock().unwrap();
            for handle in &self.handles {
                listeners.remove(handle);
            }
            drop(listeners);

            let mut sockets = statics::sockets().lock().unwrap();
            for handle in &self.handles {
                sockets.remove(handle);
            }
        }
    }

    fn young_gc_root() -> i64 {
        perry_runtime::arena::arena_alloc_gc(32, 8, perry_runtime::gc::GC_TYPE_STRING) as i64
    }

    fn assert_rewritten(before: i64, after: i64) {
        assert_ne!(after, before);
        assert!(perry_runtime::arena::pointer_in_nursery(after as usize));
    }

    #[test]
    fn gc_mutable_scanner_rewrites_listener_roots() {
        let _guard = GcTestGuard::new();
        perry_ffi::gc_register_mutable_root_scanner(scan_net_roots);

        let socket_id = -9_001;
        let _cleanup = NetHandleCleanup::new(vec![socket_id]);
        let callback = young_gc_root();
        {
            let mut listeners = statics::listeners().lock().unwrap();
            listeners
                .entry(socket_id)
                .or_default()
                .entry("data".to_string())
                .or_default()
                .push(callback);
        }

        let _ = perry_runtime::gc::gc_collect_minor();

        let after = {
            let listeners = statics::listeners().lock().unwrap();
            listeners
                .get(&socket_id)
                .and_then(|per_socket| per_socket.get("data"))
                .and_then(|callbacks| callbacks.first())
                .copied()
        };
        statics::listeners().lock().unwrap().remove(&socket_id);
        assert_rewritten(
            callback,
            after.expect("listener callback should remain registered"),
        );
    }

    /// Issuing two `js_net_socket_alloc()` calls must not panic and must
    /// register the GC scanner exactly once. Both handles should be
    /// distinct positive integers.
    #[test]
    fn alloc_is_idempotent() {
        let _lock = GC_TEST_LOCK
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner());
        let h1 = unsafe { js_net_socket_alloc() };
        let h2 = unsafe { js_net_socket_alloc() };
        let _cleanup = NetHandleCleanup::new(vec![h1, h2]);
        assert!(h1 > 0);
        assert!(h2 > 0);
        assert_ne!(h1, h2);
        assert!(is_net_socket_handle(h1));
        assert!(is_net_socket_handle(h2));
    }

    /// `js_net_has_pending()` returns 0 when no sockets are registered
    /// and no events are pending — the loop-keepalive baseline.
    ///
    /// We can't truly assert "no sockets registered" because earlier
    /// tests in the same process leave entries behind (the registry is
    /// process-wide). Instead, allocate a socket, drop it via the close
    /// path, and check that has_pending eventually returns to 0.
    #[test]
    fn has_pending_false_when_idle() {
        let _lock = GC_TEST_LOCK
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner());
        // Drain any leftover events from sibling tests.
        let _ = unsafe { js_net_process_pending() };
        // Snapshot: with no real connection in flight, has_pending may
        // still return 1 because of the alloc-test sockets above leaving
        // handles in the registry. The contract documented here is that
        // it returns *some* non-negative integer without crashing.
        let v = js_net_has_pending();
        assert!(v == 0 || v == 1, "has_pending must be 0 or 1, got {}", v);
    }

    /// Listener registration round-trip: `.on('data', cb)` stores the
    /// callback pointer in the per-socket listener map. We use a non-zero
    /// sentinel so we never try to invoke it.
    #[test]
    fn listener_registration_round_trip() {
        let _lock = GC_TEST_LOCK
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner());
        let h = unsafe { js_net_socket_alloc() };
        let _cleanup = NetHandleCleanup::new(vec![h]);
        let event = alloc_string("data");
        unsafe {
            js_net_socket_on(h, event.as_raw() as i64, 0xDEADBEEF_i64);
            js_net_socket_on(h, event.as_raw() as i64, 0xCAFEBABE_i64);
        }
        let cbs = listeners_for(h, "data");
        assert_eq!(cbs.len(), 2);
        assert_eq!(cbs[0], 0xDEADBEEF_i64);
        assert_eq!(cbs[1], 0xCAFEBABE_i64);
    }
}
