// Copyright 2018-2026 the Deno authors. MIT license.

// On Windows MSVC, bindgen generates nghttp2 enum constants as i32 (C `int`),
// while on Unix they are u32. Explicit `as` casts are needed for cross-platform
// compatibility but trigger unnecessary_cast on the platform where the type
// already matches.
#![allow(clippy::unnecessary_cast, reason = "platform specific")]

use std::cell::RefCell;
use std::cell::UnsafeCell;
use std::collections::HashMap;
use std::collections::VecDeque;
use std::ffi::c_void;
use std::rc::Rc;

use deno_core::OpState;
use deno_core::cppgc;
use deno_core::op2;
use deno_core::serde_v8;
use deno_core::v8;
use libnghttp2 as ffi;
use serde::Serialize;

/// Match the exact callback return type generated by libnghttp2 bindings on
/// the current platform instead of guessing the Windows `ssize_t` mapping.
type CSsizeT = ffi::nghttp2_ssize;

use super::stream::Http2Headers;
use super::stream::Http2Priority;
use super::stream::Http2Stream;
use super::types::*;

// Thread-local state buffers

const SESSION_STATE_LEN: usize = SessionStateIndex::Count as usize;
const STREAM_STATE_LEN: usize = StreamStateIndex::Count as usize;
const OPTIONS_LEN: usize = OptionsIndex::Flags as usize + 1;
const SETTINGS_LEN: usize =
  SettingsIndex::Count as usize + 1 + 1 + (2 * MAX_ADDITIONAL_SETTINGS);

thread_local! {
  static SESSION_STATE: UnsafeCell<[f32; SESSION_STATE_LEN]> =
    const { UnsafeCell::new([0.0; SESSION_STATE_LEN]) };

  static STREAM_STATE: UnsafeCell<[f32; STREAM_STATE_LEN]> =
    const { UnsafeCell::new([0.0; STREAM_STATE_LEN]) };

  static OPTIONS: UnsafeCell<[u32; OPTIONS_LEN]> =
    const { UnsafeCell::new([0; OPTIONS_LEN]) };

  static SETTINGS: UnsafeCell<[u32; SETTINGS_LEN]> =
    const { UnsafeCell::new([0; SETTINGS_LEN]) };
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct JSHttp2State<'a> {
  session_state: serde_v8::Value<'a>,
  stream_state: serde_v8::Value<'a>,
  options_buffer: serde_v8::Value<'a>,
  settings_buffer: serde_v8::Value<'a>,
}

impl<'a> JSHttp2State<'a> {
  pub fn create(scope: &mut v8::PinScope<'a, 'a>) -> Self {
    let session_state = SESSION_STATE.with(|cell| {
      // SAFETY: thread-local UnsafeCell, no concurrent access possible
      let ptr = unsafe { (*cell.get()).as_mut_ptr() };
      create_f32_array(scope, ptr, SESSION_STATE_LEN)
    });

    let stream_state = STREAM_STATE.with(|cell| {
      // SAFETY: thread-local UnsafeCell, no concurrent access possible
      let ptr = unsafe { (*cell.get()).as_mut_ptr() };
      create_f32_array(scope, ptr, STREAM_STATE_LEN)
    });

    let options_buffer = OPTIONS.with(|cell| {
      // SAFETY: thread-local UnsafeCell, no concurrent access possible
      let ptr = unsafe { (*cell.get()).as_mut_ptr() };
      create_u32_array(scope, ptr, OPTIONS_LEN)
    });

    let settings_buffer = SETTINGS.with(|cell| {
      // SAFETY: thread-local UnsafeCell, no concurrent access possible
      let ptr = unsafe { (*cell.get()).as_mut_ptr() };
      create_u32_array(scope, ptr, SETTINGS_LEN)
    });

    Self {
      session_state,
      stream_state,
      options_buffer,
      settings_buffer,
    }
  }
}

fn create_f32_array<'a>(
  scope: &mut v8::PinScope<'a, 'a>,
  buffer: *mut f32,
  len: usize,
) -> serde_v8::Value<'a> {
  // SAFETY: buffer points to valid thread-local static with matching len
  unsafe {
    let bs = v8::ArrayBuffer::new_backing_store_from_ptr(
      buffer as *mut c_void,
      len * std::mem::size_of::<f32>(),
      nop_deleter,
      std::ptr::null_mut(),
    );
    let ab = v8::ArrayBuffer::with_backing_store(scope, &bs.make_shared());
    v8::Float32Array::new(scope, ab, 0, len).unwrap().into()
  }
}

fn create_u32_array<'a>(
  scope: &mut v8::PinScope<'a, 'a>,
  buffer: *mut u32,
  len: usize,
) -> serde_v8::Value<'a> {
  // SAFETY: buffer points to valid thread-local static with matching len
  unsafe {
    let bs = v8::ArrayBuffer::new_backing_store_from_ptr(
      buffer as *mut c_void,
      len * std::mem::size_of::<u32>(),
      nop_deleter,
      std::ptr::null_mut(),
    );
    let ab = v8::ArrayBuffer::with_backing_store(scope, &bs.make_shared());
    v8::Uint32Array::new(scope, ab, 0, len).unwrap().into()
  }
}

unsafe extern "C" fn nop_deleter(
  _data: *mut c_void,
  _byte_length: usize,
  _deleter_data: *mut c_void,
) {
}

fn with_settings<F, R>(f: F) -> R
where
  F: FnOnce(&mut [u32; SETTINGS_LEN]) -> R,
{
  SETTINGS.with(|cell| {
    // SAFETY: thread-local UnsafeCell, no concurrent access possible
    let buffer = unsafe { &mut *cell.get() };
    f(buffer)
  })
}

/// Serialize the session's tracked custom settings into the shared settings
/// buffer so JS's `addCustomSettingsToObj` can read them. Mirrors the custom
/// settings tail of Node's `Http2Settings::Update`. Entries whose high bits
/// are set (registered but never received from the peer) are skipped so the
/// JS object only exposes settings that actually have a value.
fn write_custom_settings_to_buffer(
  buffer: &mut [u32; SETTINGS_LEN],
  list: &[(i32, u32)],
) {
  let offset = SettingsIndex::Count as usize + 2;
  let mut count: usize = 0;
  for (key, val) in list {
    let id = *key as u32;
    if id & !0xffff_u32 != 0 {
      continue;
    }
    let lo = id & 0xffff;
    let mut updated = false;
    for j in 0..count {
      if buffer[offset + j * 2] & 0xffff == lo {
        buffer[offset + j * 2] = lo;
        buffer[offset + j * 2 + 1] = *val;
        updated = true;
        break;
      }
    }
    if !updated && count < MAX_ADDITIONAL_SETTINGS {
      buffer[offset + count * 2] = lo;
      buffer[offset + count * 2 + 1] = *val;
      count += 1;
    }
  }
  buffer[SettingsIndex::Count as usize + 1] = count as u32;
}

fn with_options<F, R>(f: F) -> R
where
  F: FnOnce(&[u32; OPTIONS_LEN]) -> R,
{
  OPTIONS.with(|cell| {
    // SAFETY: thread-local UnsafeCell, no concurrent access possible
    let buffer = unsafe { &*cell.get() };
    f(buffer)
  })
}

#[repr(C)]
struct H2WriteReq {
  uv_req: deno_core::uv_compat::UvWrite,
  data: Vec<u8>,
}

unsafe extern "C" fn h2_write_cb(
  req: *mut deno_core::uv_compat::UvWrite,
  _status: i32,
) {
  // Get the session pointer from the stream handle's data field.
  // SAFETY: req is valid per libuv write callback contract; handle was set by uv_write
  let stream_handle = unsafe { (*req).handle };
  if !stream_handle.is_null() {
    // SAFETY: stream_handle is valid per libuv; casting to UvHandle to read data field
    let session_ptr =
      unsafe { (*(stream_handle as *mut deno_core::uv_compat::UvHandle)).data };
    if !session_ptr.is_null() {
      // SAFETY: session_ptr was set in consume_stream and points to a valid Session
      let session = unsafe { &mut *(session_ptr as *mut Session) };
      session.maybe_notify_graceful_close_complete();
    }
  }

  // Free the write request
  // SAFETY: req was created by Box::into_raw in send_pending_data
  let _ = unsafe { Box::from_raw(req as *mut H2WriteReq) };
}

unsafe extern "C" fn h2_stream_close_cb(
  handle: *mut deno_core::uv_compat::UvHandle,
) {
  // The stream handle was a UvTcp allocated by the TCP cppgc object.
  // `consume_stream` transferred ownership to the session via
  // `TCPWrap::detach`, so the session is now responsible for freeing it.
  // SAFETY: handle was allocated by Box::into_raw and ownership transferred via detach
  let _ = unsafe { Box::from_raw(handle as *mut deno_core::uv_compat::UvTcp) };
}

unsafe extern "C" fn h2_shutdown_cb(
  req: *mut deno_core::uv_compat::UvShutdown,
  _status: i32,
) {
  // After graceful shutdown (FIN sent), close the handle to free it.
  // SAFETY: req.handle was set by uv_shutdown and is a valid stream handle
  let stream_handle = unsafe { (*req).handle };
  if !stream_handle.is_null() {
    // SAFETY: stream_handle is valid per uv_shutdown callback contract
    unsafe {
      deno_core::uv_compat::uv_close(
        stream_handle as *mut deno_core::uv_compat::UvHandle,
        Some(h2_stream_close_cb),
      );
    }
  }
  // SAFETY: req was allocated by Box::into_raw in destroy()
  let _ = unsafe { Box::from_raw(req) };
}

unsafe extern "C" fn h2_alloc_cb(
  _handle: *mut deno_core::uv_compat::UvHandle,
  suggested_size: usize,
  buf: *mut deno_core::uv_compat::UvBuf,
) {
  let data = vec![0u8; suggested_size];
  let leaked = Box::into_raw(data.into_boxed_slice());
  // SAFETY: buf is valid per libuv alloc callback contract; leaked is freshly allocated
  unsafe {
    (*buf).base = (*leaked).as_mut_ptr() as *mut _;
    (*buf).len = suggested_size;
  }
}

unsafe extern "C" fn h2_read_cb(
  handle: *mut deno_core::uv_compat::UvStream,
  nread: isize,
  buf: *const deno_core::uv_compat::UvBuf,
) {
  // Get the session from the handle's data pointer
  // SAFETY: handle is valid per libuv read callback contract
  let session_ptr = unsafe { (*handle).data as *mut Session };
  if session_ptr.is_null() {
    // Free the buffer
    // SAFETY: buf is valid per libuv read callback contract
    if !buf.is_null() && !unsafe { (*buf).base.is_null() } {
      // SAFETY: buf.base was allocated by Box::into_raw in h2_alloc_cb
      let _ = unsafe {
        Box::from_raw(std::ptr::slice_from_raw_parts_mut(
          (*buf).base as *mut u8,
          (*buf).len,
        ))
      };
    }
    return;
  }

  // SAFETY: session_ptr was set in consume_stream and is non-null (checked above)
  let session = unsafe { &mut *session_ptr };

  if nread < 0 {
    // EOF or error - stop reading and notify the session so that
    // streams are properly closed (emitting 'aborted'/'close' events).
    // SAFETY: handle is valid per libuv read callback contract
    unsafe {
      deno_core::uv_compat::uv_read_stop(handle);
    }
    // Notify nghttp2 about the EOF by terminating the session.
    // SAFETY: session.session is a valid nghttp2_session pointer
    unsafe {
      ffi::nghttp2_session_terminate_session(
        session.session,
        ffi::NGHTTP2_CONNECT_ERROR as u32,
      );
    }
    session.send_pending_data();

    // Notify JS that the underlying transport has closed. In Node.js,
    // EOF flows through PassReadErrorToPreviousListener → socket 'close'
    // event → socketOnClose, which closes all streams and destroys the
    // session. Since consume_stream bypasses the socket layer, we call
    // the handle's onstreamclose callback directly.
    {
      // SAFETY: isolate pointer is valid for the session's lifetime
      let mut isolate =
        unsafe { v8::Isolate::from_raw_isolate_ptr(session.isolate) };
      v8::scope!(let scope, &mut isolate);
      let context = v8::Local::new(scope, session.context.clone());
      let scope = &mut v8::ContextScope::new(scope, context);
      let this_local = v8::Local::new(scope, &session.this);
      let key = v8::String::new(scope, "onstreamclose").unwrap();
      if let Some(Ok(cb)) = this_local
        .get(scope, key.into())
        .map(v8::Local::<v8::Function>::try_from)
      {
        cb.call(scope, this_local.into(), &[]);
      }
    }

    // Free the buffer
    // SAFETY: buf is valid per libuv read callback contract
    if !buf.is_null() && !unsafe { (*buf).base.is_null() } {
      // SAFETY: buf.base was allocated by Box::into_raw in h2_alloc_cb
      let _ = unsafe {
        Box::from_raw(std::ptr::slice_from_raw_parts_mut(
          (*buf).base as *mut u8,
          (*buf).len,
        ))
      };
    }
    return;
  }

  if nread > 0 {
    // SAFETY: buf.base is valid for nread bytes per libuv contract
    let data = unsafe {
      std::slice::from_raw_parts((*buf).base as *const u8, nread as usize)
    };
    session.receive_data(data);
  }

  // Free the buffer
  // SAFETY: buf is valid per libuv read callback contract
  if !buf.is_null() && !unsafe { (*buf).base.is_null() } {
    // SAFETY: buf.base was allocated by Box::into_raw in h2_alloc_cb
    let _ = unsafe {
      Box::from_raw(std::ptr::slice_from_raw_parts_mut(
        (*buf).base as *mut u8,
        (*buf).len,
      ))
    };
  }
}

// Http2Options

const OPTIONS_FLAG_NO_RECV_CLIENT_MAGIC: u32 = 0x2;
const OPTIONS_FLAG_NO_HTTP_MESSAGING: u32 = 0x4;

const DEFAULT_MAX_HEADER_LIST_PAIRS: u32 = 128;

struct Http2Options {
  options: *mut ffi::nghttp2_option,
  padding_strategy: PaddingStrategy,
  max_header_pairs: u32,
}

impl Http2Options {
  fn new(
    session_type: SessionType,
    no_strict_field_ws_validation: bool,
  ) -> Self {
    let mut options: *mut ffi::nghttp2_option = std::ptr::null_mut();
    // SAFETY: passing valid pointer to be initialized by nghttp2
    unsafe { ffi::nghttp2_option_new(&mut options) };

    let mut max_header_pairs: u32 = DEFAULT_MAX_HEADER_LIST_PAIRS;
    let padding_strategy = with_options(|buffer| {
      let flags = buffer[OptionsIndex::Flags as usize];

      if flags & (1 << OptionsIndex::MaxHeaderListPairs as u32) != 0 {
        max_header_pairs = buffer[OptionsIndex::MaxHeaderListPairs as usize];
      }

      // SAFETY: options was successfully initialized by nghttp2_option_new
      unsafe {
        ffi::nghttp2_option_set_no_closed_streams(options, 1);

        // Always disable nghttp2's automatic WINDOW_UPDATE replenishment.
        // Mirrors Node's `Http2Session::Http2Session` (`src/node_http2.cc`),
        // which unconditionally sets this so flow control is gated on
        // application-level consumption (`consume_stream`/`consume_connection`).
        // Without this, nghttp2 auto-replenishes the local stream window and a
        // misbehaving peer never trips NGHTTP2_FLOW_CONTROL_ERROR even when its
        // sends exceed the advertised initial window.
        ffi::nghttp2_option_set_no_auto_window_update(options, 1);

        if flags & OPTIONS_FLAG_NO_RECV_CLIENT_MAGIC != 0 {
          ffi::nghttp2_option_set_no_recv_client_magic(options, 1);
        }

        if flags & OPTIONS_FLAG_NO_HTTP_MESSAGING != 0 {
          ffi::nghttp2_option_set_no_http_messaging(options, 1);
        }

        let max_deflate =
          buffer[OptionsIndex::MaxDeflateDynamicTableSize as usize];
        if max_deflate > 0 {
          ffi::nghttp2_option_set_max_deflate_dynamic_table_size(
            options,
            max_deflate as usize,
          );
        }

        let max_reserved =
          buffer[OptionsIndex::MaxReservedRemoteStreams as usize];
        if max_reserved > 0 {
          ffi::nghttp2_option_set_max_reserved_remote_streams(
            options,
            max_reserved,
          );
        }

        let max_send_header =
          buffer[OptionsIndex::MaxSendHeaderBlockLength as usize];
        if max_send_header > 0 {
          ffi::nghttp2_option_set_max_send_header_block_length(
            options,
            max_send_header as usize,
          );
        }

        let peer_max_concurrent =
          buffer[OptionsIndex::PeerMaxConcurrentStreams as usize];
        if peer_max_concurrent > 0 {
          ffi::nghttp2_option_set_peer_max_concurrent_streams(
            options,
            peer_max_concurrent,
          );
        } else {
          ffi::nghttp2_option_set_peer_max_concurrent_streams(options, 100);
        }

        // Gate maxOutstandingPings on its flag bit. The optionsBuffer is a
        // process-global Uint32Array reused across sessions, so a prior
        // session's value would otherwise leak into a later session that
        // didn't pass the option and could lower nghttp2's max_outbound_ack
        // (e.g. to 1), making a legitimate second concurrent ping trip
        // NGHTTP2_ERR_FLOODED.
        if flags & (1 << OptionsIndex::MaxOutstandingPings as u32) != 0 {
          let max_outstanding_pings =
            buffer[OptionsIndex::MaxOutstandingPings as usize];
          if max_outstanding_pings > 0 {
            ffi::nghttp2_option_set_max_outbound_ack(
              options,
              max_outstanding_pings as usize,
            );
          }
        }

        // Note: maxOutstandingSettings is a Node.js-internal queue limit for
        // outbound SETTINGS frames awaiting peer ACK. nghttp2 has no equivalent
        // option, so we accept the value for API compatibility but do not map
        // it to nghttp2_option_set_max_settings (which is the maxSettings limit
        // on inbound SETTINGS *entries* per frame — handled below).

        let max_settings = buffer[OptionsIndex::MaxSettings as usize];
        if max_settings > 0 {
          ffi::nghttp2_option_set_max_settings(options, max_settings as usize);
        }

        if matches!(session_type, SessionType::Client) {
          ffi::nghttp2_option_set_builtin_recv_extension_type(
            options,
            ffi::NGHTTP2_ALTSVC as u8,
          );
          ffi::nghttp2_option_set_builtin_recv_extension_type(
            options,
            ffi::NGHTTP2_ORIGIN as u8,
          );
        }

        // strictFieldWhitespaceValidation: when disabled, tell nghttp2 to skip
        // RFC 9113 leading/trailing whitespace validation so headers with
        // surrounding whitespace are delivered to on_header_callback instead
        // of being routed to on_invalid_header_callback (and dropped).
        if no_strict_field_ws_validation {
          ffi::nghttp2_option_set_no_rfc9113_leading_and_trailing_ws_validation(
            options, 1,
          );
        }
      }

      let padding = buffer[OptionsIndex::PaddingStrategy as usize];
      match padding {
        1 => PaddingStrategy::Aligned,
        2 => PaddingStrategy::Max,
        3 => PaddingStrategy::Callback,
        _ => PaddingStrategy::None,
      }
    });

    // Apply Node.js semantics: server min 4, client min 1.
    // See node_http_common-inl.h: GetServerMaxHeaderPairs / GetClientMaxHeaderPairs.
    let min_pairs = match session_type {
      SessionType::Server => 4,
      SessionType::Client => 1,
    };
    let max_header_pairs = std::cmp::max(max_header_pairs, min_pairs);

    Self {
      options,
      padding_strategy,
      max_header_pairs,
    }
  }

  fn ptr(&self) -> *mut ffi::nghttp2_option {
    self.options
  }

  fn padding_strategy(&self) -> PaddingStrategy {
    self.padding_strategy
  }

  fn max_header_pairs(&self) -> u32 {
    self.max_header_pairs
  }
}

impl Drop for Http2Options {
  fn drop(&mut self) {
    if !self.options.is_null() {
      // SAFETY: options was allocated by nghttp2_option_new and is non-null
      unsafe { ffi::nghttp2_option_del(self.options) };
    }
  }
}

// Http2Settings

const SETTINGS_ENTRY_COUNT: usize =
  SettingsIndex::Count as usize + MAX_ADDITIONAL_SETTINGS;

struct Http2Settings {
  entries: [ffi::nghttp2_settings_entry; SETTINGS_ENTRY_COUNT],
  count: usize,
  session: *mut Session,
}

impl Http2Settings {
  fn init(session: *mut Session) -> Self {
    with_settings(|buffer| {
      let flags = buffer[SettingsIndex::Count as usize];
      let mut count: usize = 0;
      let mut entries = [ffi::nghttp2_settings_entry {
        settings_id: 0,
        value: 0,
      }; SETTINGS_ENTRY_COUNT];

      macro_rules! grab_setting {
        ($index:expr, $nghttp2_id:expr) => {
          if flags & (1 << $index as u8) != 0 {
            let val = buffer[$index as usize];
            if count < entries.len() {
              entries[count] = ffi::nghttp2_settings_entry {
                settings_id: $nghttp2_id as _,
                value: val,
              };
              count += 1;
            }
          }
        };
      }

      grab_setting!(
        SettingsIndex::HeaderTableSize,
        ffi::NGHTTP2_SETTINGS_HEADER_TABLE_SIZE
      );
      grab_setting!(
        SettingsIndex::EnablePush,
        ffi::NGHTTP2_SETTINGS_ENABLE_PUSH
      );
      grab_setting!(
        SettingsIndex::InitialWindowSize,
        ffi::NGHTTP2_SETTINGS_INITIAL_WINDOW_SIZE
      );
      grab_setting!(
        SettingsIndex::MaxFrameSize,
        ffi::NGHTTP2_SETTINGS_MAX_FRAME_SIZE
      );
      grab_setting!(
        SettingsIndex::MaxConcurrentStreams,
        ffi::NGHTTP2_SETTINGS_MAX_CONCURRENT_STREAMS
      );
      grab_setting!(
        SettingsIndex::MaxHeaderListSize,
        ffi::NGHTTP2_SETTINGS_MAX_HEADER_LIST_SIZE
      );
      grab_setting!(
        SettingsIndex::EnableConnectProtocol,
        ffi::NGHTTP2_SETTINGS_ENABLE_CONNECT_PROTOCOL
      );

      let num_add_settings = buffer[SettingsIndex::Count as usize + 1] as usize;

      if num_add_settings > 0 {
        let offset = SettingsIndex::Count as usize + 2;
        for i in 0..num_add_settings {
          let key = buffer[offset + i * 2];
          let val = buffer[offset + i * 2 + 1];
          if count < entries.len() {
            entries[count] = ffi::nghttp2_settings_entry {
              settings_id: key as i32,
              value: val,
            };
            count += 1;
          }
        }
      }

      Self {
        session,
        entries,
        count,
      }
    })
  }

  fn send(&self) {
    // SAFETY: self.session points to a valid Session allocated in Http2Session::create
    unsafe {
      let session = &mut *self.session;
      session.update_local_custom_settings(&self.entries[..self.count]);
      ffi::nghttp2_submit_settings(
        session.session,
        ffi::NGHTTP2_FLAG_NONE as _,
        self.entries.as_ptr(),
        self.count,
      );
    }
  }
}

// Callbacks

fn frame_id(frame: *const ffi::nghttp2_frame) -> i32 {
  // SAFETY: frame is valid per nghttp2 callback contract
  unsafe {
    let frame = &*frame;
    if frame.hd.type_ as u32 == ffi::NGHTTP2_PUSH_PROMISE as u32 {
      frame.push_promise.promised_stream_id
    } else {
      frame.hd.stream_id
    }
  }
}

fn frame_type(frame: *const ffi::nghttp2_frame) -> u8 {
  // SAFETY: frame is valid per nghttp2 callback contract
  unsafe { (*frame).hd.type_ }
}

fn frame_flags(frame: *const ffi::nghttp2_frame) -> u8 {
  // SAFETY: frame is valid per nghttp2 callback contract
  unsafe { (*frame).hd.flags }
}

fn frame_headers_category(
  frame: *const ffi::nghttp2_frame,
) -> ffi::nghttp2_headers_category {
  // SAFETY: frame is valid per nghttp2 callback contract
  unsafe { (*frame).headers.cat }
}

fn rcbuf_to_slice(rcbuf: *mut ffi::nghttp2_rcbuf) -> &'static [u8] {
  // SAFETY: rcbuf is valid per nghttp2 callback contract
  unsafe {
    let buf = ffi::nghttp2_rcbuf_get_buf(rcbuf);
    std::slice::from_raw_parts(buf.base, buf.len)
  }
}

fn frame_header_length(frame: *const ffi::nghttp2_frame) -> usize {
  // SAFETY: frame is valid per nghttp2 callback contract
  unsafe { (*frame).hd.length }
}

unsafe extern "C" fn on_begin_headers_callbacks(
  ng_session: *mut ffi::nghttp2_session,
  frame: *const ffi::nghttp2_frame,
  data: *mut c_void,
) -> i32 {
  // SAFETY: data is the user_data pointer set during session creation
  let session = unsafe { Session::from_user_data(data) };
  let id = frame_id(frame);
  let cat = frame_headers_category(frame);

  match session.find_stream(id) {
    None => {
      if session.is_graceful_closing() {
        // SAFETY: ng_session is valid per nghttp2 callback contract
        unsafe {
          ffi::nghttp2_submit_rst_stream(
            ng_session,
            ffi::NGHTTP2_FLAG_NONE as u8,
            id,
            ffi::NGHTTP2_REFUSED_STREAM as u32,
          );
        }
        return ffi::NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE as i32;
      }
      let (obj, stream) = Http2Stream::new(session, id, cat);
      stream.start_headers(cat);
      session.streams.insert(id, (obj, stream));
    }
    Some(s) => {
      s.start_headers(cat);
    }
  }

  0
}

unsafe extern "C" fn on_header_callback(
  _session: *mut ffi::nghttp2_session,
  frame: *const ffi::nghttp2_frame,
  name: *mut ffi::nghttp2_rcbuf,
  value: *mut ffi::nghttp2_rcbuf,
  flags: u8,
  data: *mut c_void,
) -> i32 {
  // SAFETY: data is the user_data pointer set during session creation
  let session = unsafe { Session::from_user_data(data) };
  let id = frame_id(frame);

  if let Some(stream) = session.find_stream(id) {
    let name_slice = rcbuf_to_slice(name);
    let value_slice = rcbuf_to_slice(value);

    if !stream.add_header(name_slice, value_slice, flags) {
      // SAFETY: session.session is a valid nghttp2 session pointer
      unsafe {
        ffi::nghttp2_submit_rst_stream(
          session.session,
          ffi::NGHTTP2_FLAG_NONE as u8,
          id,
          ffi::NGHTTP2_ENHANCE_YOUR_CALM as u32,
        );
      }
      return ffi::NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE as i32;
    }
  }

  0
}

unsafe extern "C" fn on_frame_recv_callback(
  _session: *mut ffi::nghttp2_session,
  frame: *const ffi::nghttp2_frame,
  data: *mut c_void,
) -> i32 {
  // SAFETY: data is the user_data pointer set during session creation
  let session = unsafe { Session::from_user_data(data) };

  // Count every received frame so the JS layer can surface it via the
  // `Http2Session` PerformanceObserver entry's `framesReceived` field.
  // Mirrors Node's `Http2Session::OnFrameReceive` updating `statistics_`.
  session.frames_received = session.frames_received.saturating_add(1);

  let ft = frame_type(frame) as u32;
  let ff = frame_flags(frame);
  #[allow(clippy::unnecessary_cast, reason = "cast needed for type alignment")]
  if ft == ffi::NGHTTP2_DATA as u32 {
    let has_end_stream = ff & ffi::NGHTTP2_FLAG_END_STREAM as u8 != 0;
    if has_end_stream {
      handle_data_end_stream(session, frame);
    } else if frame_header_length(frame) == 0 {
      // Empty DATA frame without END_STREAM. nghttp2 doesn't treat this as
      // a protocol violation, but Node.js counts it against
      // `maxSessionInvalidFrames` (see HandleDataFrame in node_http2.cc).
      // Post-increment comparison: with max=0 the second such frame trips.
      let count = session.invalid_frame_count;
      session.invalid_frame_count = count.saturating_add(1);
      if count > session.max_invalid_frames {
        session.custom_recv_error_code =
          Some("ERR_HTTP2_TOO_MANY_INVALID_FRAMES");
        return 1;
      }
    }
  } else if ft == ffi::NGHTTP2_PUSH_PROMISE as u32
    || ft == ffi::NGHTTP2_HEADERS as u32
  {
    handle_headers_frame(session, frame);
    if ff & ffi::NGHTTP2_FLAG_END_STREAM as u8 != 0 {
      handle_data_end_stream(session, frame);
    }
  } else if ft == ffi::NGHTTP2_SETTINGS as u32 {
    if ff & ffi::NGHTTP2_FLAG_ACK as u8 == 0 {
      // Peer's actual settings. Update our tracked remote custom settings
      // before firing the JS callback so `session.remoteSettings` returns
      // the latest values.
      // SAFETY: frame is a valid SETTINGS frame; the union access matches
      // the frame type checked above. niv/iv come straight from nghttp2.
      unsafe {
        let settings = &(*frame).settings;
        if settings.niv > 0 && !settings.iv.is_null() {
          let iv =
            std::slice::from_raw_parts(settings.iv, settings.niv as usize);
          session.update_remote_custom_settings_from_iv(iv);
        }
      }
      // Mark handshake-time as over so on_frame_send_callback's protocol
      // error hook only fires on early (pre-SETTINGS) GOAWAYs.
      session.remote_settings_received = true;
      handle_settings_frame(session);
    } else {
      // ACK for one of our outgoing SETTINGS frames. Pop the FIFO of pending
      // callbacks and invoke it so JS can fire the `localSettings` event.
      handle_settings_ack(session);
    }
  } else if ft == ffi::NGHTTP2_PRIORITY as u32 {
    handle_priority_frame(session, frame);
  } else if ft == ffi::NGHTTP2_GOAWAY as u32 {
    handle_goaway_frame(session, frame);
  } else if ft == ffi::NGHTTP2_PING as u32 {
    if ff & ffi::NGHTTP2_FLAG_ACK as u8 != 0 {
      // PING ACK from the peer. If we have an outstanding PING, consume it
      // and let JS handle the ack. Otherwise the peer sent us an
      // unsolicited PING ACK — Node treats this as a connection-level
      // protocol error (see HandlePingFrame in node_http2.cc) and surfaces
      // it via the session's internal-error callback so the JS layer can
      // destroy the session with NghttpError(NGHTTP2_ERR_PROTO).
      if session.pending_pings > 0 {
        session.pending_pings -= 1;
        handle_ping_frame(session, frame, true);
      } else {
        handle_unsolicited_ping_ack(session);
      }
    } else {
      handle_ping_frame(session, frame, false);
    }
  } else if ft == ffi::NGHTTP2_ALTSVC as u32 {
    handle_alt_svc_frame(session, frame);
  } else if ft == ffi::NGHTTP2_ORIGIN as u32 {
    handle_origin_frame(session, frame);
  }

  0
}

fn handle_data_end_stream(session: &Session, frame: *const ffi::nghttp2_frame) {
  let id = frame_id(frame);
  let Some(stream_obj) = session.find_stream_obj(id) else {
    return;
  };

  let mut isolate =
    // SAFETY: isolate pointer is valid for the session's lifetime
    unsafe { v8::Isolate::from_raw_isolate_ptr(session.isolate) };
  v8::scope!(let scope, &mut isolate);
  let context = v8::Local::new(scope, session.context.clone());
  let scope = &mut v8::ContextScope::new(scope, context);

  let handle = v8::Local::new(scope, stream_obj);

  let onread_key = v8::String::new(scope, "onread").unwrap();
  let Some(onread_val) = handle.get(scope, onread_key.into()) else {
    return;
  };
  let Ok(onread_fn) = v8::Local::<v8::Function>::try_from(onread_val) else {
    return;
  };

  // Signal EOF: onStreamRead(undefined, UV_EOF)
  let eof = v8::Number::new(scope, -4095.0);
  let undef = v8::undefined(scope);
  onread_fn.call(scope, handle.into(), &[undef.into(), eof.into()]);
}

fn handle_headers_frame(session: &Session, frame: *const ffi::nghttp2_frame) {
  let mut isolate =
    // SAFETY: isolate pointer is valid for the session's lifetime
    unsafe { v8::Isolate::from_raw_isolate_ptr(session.isolate) };
  v8::scope!(let scope, &mut isolate);
  let context = v8::Local::new(scope, session.context.clone());
  let scope = &mut v8::ContextScope::new(scope, context);

  let id = frame_id(frame);
  let Some(stream_ref) = session.find_stream(id) else {
    return;
  };

  let headers = stream_ref.current_headers.borrow();
  if headers.is_empty() {
    return;
  }

  let headers_array = v8::Array::new(scope, (headers.len() * 2) as i32);
  // Mirrors Node's HandleHeadersFrame: collect names of headers that arrived
  // with NGHTTP2_NV_FLAG_NO_INDEX so JS can expose them via
  // headers[http2.sensitiveHeaders].
  let sensitive_array = v8::Array::new(scope, 0);
  let mut sensitive_count: u32 = 0;
  for (i, (name, value, flags)) in headers.iter().enumerate() {
    // Header bytes are arbitrary; decode as Latin-1 (one byte per code unit)
    // to match Node's LATIN1 path and preserve non-UTF-8 byte values.
    let name_str =
      v8::String::new_from_one_byte(scope, name, v8::NewStringType::Normal)
        .unwrap();
    let value_str =
      v8::String::new_from_one_byte(scope, value, v8::NewStringType::Normal)
        .unwrap();
    headers_array.set_index(scope, (i * 2) as u32, name_str.into());
    headers_array.set_index(scope, (i * 2 + 1) as u32, value_str.into());
    if flags & (ffi::NGHTTP2_NV_FLAG_NO_INDEX as u8) != 0 {
      sensitive_array.set_index(scope, sensitive_count, name_str.into());
      sensitive_count += 1;
    }
  }

  drop(headers);
  stream_ref.clear_headers();

  let stream_obj = session.find_stream_obj(id).unwrap();
  let state = session.op_state.borrow();
  let callbacks = state.borrow::<SessionCallbacks>();
  let recv = v8::Local::new(scope, &session.this);
  let callback = v8::Local::new(scope, &callbacks.headers_frame_cb);
  drop(state);

  let handle = v8::Local::new(scope, stream_obj);
  let id_num = v8::Number::new(scope, id.into());
  // For HEADERS frames, expose the nghttp2 category so the JS polyfill can
  // distinguish 'response', 'push', 'trailers', 'headers'. PUSH_PROMISE
  // frames don't carry a category — leave it null and let the polyfill
  // treat the new-stream branch (the stream is still being created).
  let cat: v8::Local<v8::Value> =
    if frame_type(frame) as u32 == ffi::NGHTTP2_HEADERS as u32 {
      // SAFETY: frame is a HEADERS frame per the type check above
      let c = unsafe { (*frame).headers.cat } as i32;
      v8::Integer::new(scope, c).into()
    } else {
      v8::null(scope).into()
    };
  let flags = v8::Number::new(scope, frame_flags(frame).into());

  callback.call(
    scope,
    recv.into(),
    &[
      handle.into(),
      id_num.into(),
      cat,
      flags.into(),
      headers_array.into(),
      sensitive_array.into(),
    ],
  );
}

fn handle_settings_ack(session: &mut Session) {
  // FIFO pop. Mirrors Node's BaseObjectPtr<Http2Settings> PopSettings().
  let Some(cb) = session.pending_settings_acks.pop_front() else {
    return;
  };

  let mut isolate =
    // SAFETY: isolate pointer is valid for the session's lifetime
    unsafe { v8::Isolate::from_raw_isolate_ptr(session.isolate) };
  v8::scope!(let scope, &mut isolate);
  let context = v8::Local::new(scope, session.context.clone());
  let scope = &mut v8::ContextScope::new(scope, context);

  let recv = v8::undefined(scope);
  let ack = v8::Boolean::new(scope, true);
  let duration = v8::Number::new(scope, 0.0);
  let cb_local = v8::Local::new(scope, &cb);
  cb_local.call(scope, recv.into(), &[ack.into(), duration.into()]);
}

fn handle_settings_frame(session: &Session) {
  let mut isolate =
    // SAFETY: isolate pointer is valid for the session's lifetime
    unsafe { v8::Isolate::from_raw_isolate_ptr(session.isolate) };
  v8::scope!(let scope, &mut isolate);
  let context = v8::Local::new(scope, session.context.clone());
  let scope = &mut v8::ContextScope::new(scope, context);

  let state = session.op_state.borrow();
  let callbacks = state.borrow::<SessionCallbacks>();
  let recv = v8::Local::new(scope, &session.this);
  let callback = v8::Local::new(scope, &callbacks.settings_frame_cb);
  drop(state);

  callback.call(scope, recv.into(), &[]);
}

fn handle_ping_frame(
  session: &Session,
  frame: *const ffi::nghttp2_frame,
  is_ack: bool,
) {
  let mut isolate =
    // SAFETY: isolate pointer is valid for the session's lifetime
    unsafe { v8::Isolate::from_raw_isolate_ptr(session.isolate) };
  v8::scope!(let scope, &mut isolate);
  let context = v8::Local::new(scope, session.context.clone());
  let scope = &mut v8::ContextScope::new(scope, context);

  // SAFETY: frame is a valid PING frame per nghttp2 callback contract
  let opaque = unsafe { (*frame).ping.opaque_data };
  let array_buffer = v8::ArrayBuffer::new(scope, opaque.len());
  let backing_store = array_buffer.get_backing_store();
  if let Some(backing_data) = backing_store.data() {
    // SAFETY: src and dst are valid, non-overlapping, dst has room for 8 bytes
    unsafe {
      std::ptr::copy_nonoverlapping(
        opaque.as_ptr(),
        backing_data.as_ptr() as *mut u8,
        opaque.len(),
      );
    }
  }
  let payload =
    v8::Uint8Array::new(scope, array_buffer, 0, opaque.len()).unwrap();

  let state = session.op_state.borrow();
  let callbacks = state.borrow::<SessionCallbacks>();
  let recv = v8::Local::new(scope, &session.this);
  let callback = v8::Local::new(scope, &callbacks.ping_frame_cb);
  drop(state);

  let is_ack_v = v8::Boolean::new(scope, is_ack);
  callback.call(scope, recv.into(), &[payload.into(), is_ack_v.into()]);
}

/// Inbound PING ACK with no matching outstanding PING. Mirrors Node's
/// HandlePingFrame "unsolicited ack" branch in src/node_http2.cc: surface
/// `NGHTTP2_ERR_PROTO` to the JS internal-error callback so the session is
/// destroyed with `NghttpError(-505)` (code `ERR_HTTP2_ERROR`,
/// message "Protocol error").
fn handle_unsolicited_ping_ack(session: &Session) {
  let mut isolate =
    // SAFETY: isolate pointer is valid for the session's lifetime
    unsafe { v8::Isolate::from_raw_isolate_ptr(session.isolate) };
  v8::scope!(let scope, &mut isolate);
  let context = v8::Local::new(scope, session.context.clone());
  let scope = &mut v8::ContextScope::new(scope, context);

  let state = session.op_state.borrow();
  let callbacks = state.borrow::<SessionCallbacks>();
  let recv = v8::Local::new(scope, &session.this);
  let callback = v8::Local::new(scope, &callbacks.session_internal_error_cb);
  drop(state);

  let code = v8::Integer::new(scope, ffi::NGHTTP2_ERR_PROTO);
  callback.call(scope, recv.into(), &[code.into()]);
}

fn handle_goaway_frame(session: &Session, frame: *const ffi::nghttp2_frame) {
  let mut isolate =
    // SAFETY: isolate pointer is valid for the session's lifetime
    unsafe { v8::Isolate::from_raw_isolate_ptr(session.isolate) };
  v8::scope!(let scope, &mut isolate);
  let context = v8::Local::new(scope, session.context.clone());
  let scope = &mut v8::ContextScope::new(scope, context);

  // SAFETY: frame is valid per nghttp2 callback contract
  let goaway_frame = unsafe { (*frame).goaway };

  let error_code = v8::Number::new(scope, goaway_frame.error_code.into());
  let last_stream_id =
    v8::Number::new(scope, goaway_frame.last_stream_id.into());

  let opaque_data: v8::Local<v8::Value> = if goaway_frame.opaque_data_len > 0 {
    // SAFETY: opaque_data pointer and length are set by nghttp2
    let data_slice = unsafe {
      std::slice::from_raw_parts(
        goaway_frame.opaque_data,
        goaway_frame.opaque_data_len,
      )
    };
    let array_buffer = v8::ArrayBuffer::new(scope, data_slice.len());
    let backing_store = array_buffer.get_backing_store();
    if let Some(backing_data) = backing_store.data() {
      // SAFETY: src and dst are valid, non-overlapping, and dst has sufficient capacity
      unsafe {
        std::ptr::copy_nonoverlapping(
          data_slice.as_ptr(),
          backing_data.as_ptr() as *mut u8,
          data_slice.len(),
        );
      }
    }
    v8::Uint8Array::new(scope, array_buffer, 0, data_slice.len())
      .unwrap()
      .into()
  } else {
    v8::undefined(scope).into()
  };

  let state = session.op_state.borrow();
  let callbacks = state.borrow::<SessionCallbacks>();
  let recv = v8::Local::new(scope, &session.this);
  let callback = v8::Local::new(scope, &callbacks.goaway_data_cb);
  drop(state);

  callback.call(
    scope,
    recv.into(),
    &[error_code.into(), last_stream_id.into(), opaque_data],
  );
}

fn handle_priority_frame(session: &Session, frame: *const ffi::nghttp2_frame) {
  let mut isolate =
    // SAFETY: isolate pointer is valid for the session's lifetime
    unsafe { v8::Isolate::from_raw_isolate_ptr(session.isolate) };
  v8::scope!(let scope, &mut isolate);
  let context = v8::Local::new(scope, session.context.clone());
  let scope = &mut v8::ContextScope::new(scope, context);

  // SAFETY: frame is valid per nghttp2 callback contract
  let priority_frame = unsafe { (*frame).priority };
  let id = frame_id(frame);
  let spec = priority_frame.pri_spec;

  let stream_id = v8::Number::new(scope, id.into());
  let parent_stream_id = v8::Number::new(scope, spec.stream_id.into());
  let weight = v8::Number::new(scope, spec.weight.into());
  let exclusive = v8::Boolean::new(scope, spec.exclusive != 0);

  let state = session.op_state.borrow();
  let callbacks = state.borrow::<SessionCallbacks>();
  let recv = v8::Local::new(scope, &session.this);
  let callback = v8::Local::new(scope, &callbacks.priority_frame_cb);
  drop(state);

  callback.call(
    scope,
    recv.into(),
    &[
      stream_id.into(),
      parent_stream_id.into(),
      weight.into(),
      exclusive.into(),
    ],
  );
}

fn handle_alt_svc_frame(session: &Session, frame: *const ffi::nghttp2_frame) {
  let mut isolate =
    // SAFETY: isolate pointer is valid for the session's lifetime
    unsafe { v8::Isolate::from_raw_isolate_ptr(session.isolate) };
  v8::scope!(let scope, &mut isolate);
  let context = v8::Local::new(scope, session.context.clone());
  let scope = &mut v8::ContextScope::new(scope, context);

  let id = frame_id(frame);

  // SAFETY: frame is valid per nghttp2 callback contract
  let ext = unsafe { (*frame).ext };
  let altsvc = ext.payload as *const ffi::nghttp2_ext_altsvc;

  // SAFETY: altsvc origin pointer and length are set by nghttp2
  let origin_slice = unsafe {
    std::slice::from_raw_parts((*altsvc).origin, (*altsvc).origin_len)
  };
  // SAFETY: altsvc field_value pointer and length are set by nghttp2
  let field_value_slice = unsafe {
    std::slice::from_raw_parts((*altsvc).field_value, (*altsvc).field_value_len)
  };

  let origin_str = std::str::from_utf8(origin_slice)
    .map(|s| v8::String::new(scope, s).unwrap())
    .unwrap_or_else(|_| v8::String::new(scope, "").unwrap());
  let field_value_str = std::str::from_utf8(field_value_slice)
    .map(|s| v8::String::new(scope, s).unwrap())
    .unwrap_or_else(|_| v8::String::new(scope, "").unwrap());

  let state = session.op_state.borrow();
  let callbacks = state.borrow::<SessionCallbacks>();
  let recv = v8::Local::new(scope, &session.this);
  let callback = v8::Local::new(scope, &callbacks.alt_svc_cb);
  drop(state);

  let stream_id = v8::Number::new(scope, id.into());
  callback.call(
    scope,
    recv.into(),
    &[stream_id.into(), origin_str.into(), field_value_str.into()],
  );
}

fn handle_origin_frame(session: &Session, frame: *const ffi::nghttp2_frame) {
  let mut isolate =
    // SAFETY: isolate pointer is valid for the session's lifetime
    unsafe { v8::Isolate::from_raw_isolate_ptr(session.isolate) };
  v8::scope!(let scope, &mut isolate);
  let context = v8::Local::new(scope, session.context.clone());
  let scope = &mut v8::ContextScope::new(scope, context);

  // SAFETY: frame is valid per nghttp2 callback contract
  let ext = unsafe { (*frame).ext };
  let origin = ext.payload as *const ffi::nghttp2_ext_origin;

  // SAFETY: origin payload is valid and initialized by nghttp2
  let nov = unsafe { (*origin).nov };
  // SAFETY: origin payload is valid and initialized by nghttp2
  let origins_ptr = unsafe { (*origin).ov };

  if nov == 0 {
    return;
  }

  let origins_array = v8::Array::new(scope, nov as i32);
  for i in 0..nov {
    // SAFETY: origins_ptr points to nov valid entries per nghttp2 contract
    let entry = unsafe { *origins_ptr.add(i) };
    let origin_slice =
      // SAFETY: entry origin pointer and length are set by nghttp2
      unsafe { std::slice::from_raw_parts(entry.origin, entry.origin_len) };
    if let Ok(origin_str) = std::str::from_utf8(origin_slice) {
      let js_string = v8::String::new(scope, origin_str).unwrap();
      origins_array.set_index(scope, i as u32, js_string.into());
    }
  }

  let state = session.op_state.borrow();
  let callbacks = state.borrow::<SessionCallbacks>();
  let recv = v8::Local::new(scope, &session.this);
  let callback = v8::Local::new(scope, &callbacks.origin_frame_cb);
  drop(state);

  callback.call(scope, recv.into(), &[origins_array.into()]);
}

unsafe extern "C" fn on_stream_close_callback(
  _session: *mut ffi::nghttp2_session,
  stream_id: i32,
  error_code: u32,
  data: *mut c_void,
) -> i32 {
  // SAFETY: data is the user_data pointer set during session creation
  let session = unsafe { Session::from_user_data(data) };
  let Some(stream_obj) = session.find_stream_obj(stream_id).cloned() else {
    return 0;
  };

  // Mark the stream as being closed by nghttp2 BEFORE calling JS.
  // This prevents shutdown() from calling resume_data(), which would
  // re-activate the data provider for a stream that close_stream is
  // about to destroy (double-free with no_closed_streams=1).
  if let Some(stream) = session.find_stream(stream_id) {
    *stream.closed_by_nghttp2.borrow_mut() = true;
  }

  // SAFETY: isolate pointer is valid for the session's lifetime
  let mut isolate =
    unsafe { v8::Isolate::from_raw_isolate_ptr(session.isolate) };
  v8::scope!(let scope, &mut isolate);
  let context = v8::Local::new(scope, session.context.clone());
  let scope = &mut v8::ContextScope::new(scope, context);

  let state = session.op_state.borrow();
  let callbacks = state.borrow::<SessionCallbacks>();
  let callback = v8::Local::new(scope, &callbacks.stream_close_cb);
  drop(state);

  let recv = v8::Local::new(scope, stream_obj);
  let code = v8::Integer::new_from_unsigned(scope, error_code);

  let result = callback.call(scope, recv.into(), &[code.into()]);

  if result.is_none() || result.map(|v| v.is_false()).unwrap_or(false) {
    session.streams.remove(&stream_id);
  }

  0
}

unsafe extern "C" fn on_data_chunk_recv_callback(
  ng_session: *mut ffi::nghttp2_session,
  _flags: u8,
  stream_id: i32,
  data_ptr: *const u8,
  len: usize,
  user_data: *mut c_void,
) -> i32 {
  if len == 0 {
    return 0;
  }
  // SAFETY: user_data is the user_data pointer set during session creation
  let session = unsafe { Session::from_user_data(user_data) };

  // Always replenish the connection-level flow-control window. Stream-level
  // consumption is gated on whether the JS Readable side is actively
  // reading: when paused, defer it so a peer that ignores stream-level
  // flow control gets a NGHTTP2_FLOW_CONTROL_ERROR. Mirrors Node's
  // `Http2Session::OnDataChunkReceived` (`src/node_http2.cc`).
  // SAFETY: ng_session is valid per nghttp2 callback contract
  unsafe {
    ffi::nghttp2_session_consume_connection(ng_session, len);
  };
  if let Some(stream) = session.find_stream(stream_id) {
    if *stream.reading.borrow() {
      // SAFETY: ng_session is valid per nghttp2 callback contract
      unsafe {
        ffi::nghttp2_session_consume_stream(ng_session, stream_id, len);
      };
    } else {
      *stream.inbound_consumed_data_while_paused.borrow_mut() += len;
    }
  } else {
    // SAFETY: ng_session is valid per nghttp2 callback contract
    unsafe {
      ffi::nghttp2_session_consume_stream(ng_session, stream_id, len);
    };
  }

  // Deliver data to the JS stream via its onread callback
  let Some(stream_obj) = session.find_stream_obj(stream_id) else {
    return 0;
  };

  let mut isolate =
    // SAFETY: isolate pointer is valid for the session's lifetime
    unsafe { v8::Isolate::from_raw_isolate_ptr(session.isolate) };
  v8::scope!(let scope, &mut isolate);
  let context = v8::Local::new(scope, session.context.clone());
  let scope = &mut v8::ContextScope::new(scope, context);

  let handle = v8::Local::new(scope, stream_obj);

  // Get the onread property
  let onread_key = v8::String::new(scope, "onread").unwrap();
  let Some(onread_val) = handle.get(scope, onread_key.into()) else {
    return 0;
  };
  let Ok(onread_fn) = v8::Local::<v8::Function>::try_from(onread_val) else {
    return 0;
  };

  // Create a Buffer with the received data
  // SAFETY: data_ptr is valid for len bytes per nghttp2 callback contract
  let data_slice = unsafe { std::slice::from_raw_parts(data_ptr, len) };
  let ab = v8::ArrayBuffer::new(scope, len);
  let backing_store = ab.get_backing_store();
  let dst = backing_store.data().unwrap().as_ptr() as *mut u8;
  // SAFETY: src and dst are valid, non-overlapping, and dst has len bytes capacity
  unsafe { std::ptr::copy_nonoverlapping(data_slice.as_ptr(), dst, len) };
  let uint8_array = v8::Uint8Array::new(scope, ab, 0, len).unwrap();

  let nread = v8::Number::new(scope, len as f64);
  // onStreamRead(arrayBuffer, nread) with `this` = handle
  onread_fn.call(scope, handle.into(), &[uint8_array.into(), nread.into()]);

  0
}

pub unsafe extern "C" fn on_stream_read_callback(
  _session: *mut ffi::nghttp2_session,
  stream_id: i32,
  buf: *mut u8,
  length: usize,
  data_flags: *mut u32,
  _source: *mut ffi::nghttp2_data_source,
  user_data: *mut c_void,
) -> CSsizeT {
  // SAFETY: user_data is the user_data pointer set during session creation
  let session = unsafe { Session::from_user_data(user_data) };

  // Gather data and determine flags while holding borrows, then call
  // into JS (on_trailers) only after dropping all borrows. This prevents
  // holding a RefMut<pending_data> or &Ref<Http2Stream> (borrowed from
  // session.streams HashMap) while JS callbacks fire, which could
  // invalidate the references.
  //
  // For aligned padding, switch to NGHTTP2_DATA_FLAG_NO_COPY so the
  // actual payload is emitted by `on_send_data_callback` as separate
  // chunks (header / pad_length / data / padding). For the default
  // strategy (no padding) we keep the in-place copy: nghttp2 packs the
  // data into the framebuf and OB_SEND_DATA returns it as one chunk,
  // matching how Deno previously framed unpadded responses (and what
  // tests like test-http2-res-corked rely on for chunk count).
  let no_copy = matches!(session.padding_strategy, PaddingStrategy::Aligned);

  let mut amount: usize = 0;
  let mut need_eof = false;
  let mut need_trailers = false;
  let mut is_deferred = false;
  let mut have_data = false;

  if let Some(stream) = session.find_stream(stream_id) {
    if no_copy {
      let pending_data = stream.pending_data.borrow();
      if !pending_data.is_empty() {
        let amt = std::cmp::min(pending_data.len(), length);
        if amt > 0 {
          amount = amt;
          have_data = true;
          // pending_data is consumed in on_send_data_callback so the
          // bytes survive across the read -> send transition.
          if pending_data.len() == amt && *stream.writable_ended.borrow() {
            need_eof = true;
            need_trailers = stream.has_trailers();
          }
        }
      } else if *stream.writable_ended.borrow() {
        need_eof = true;
        need_trailers = stream.has_trailers();
      } else {
        is_deferred = true;
      }
    } else {
      let mut pending_data = stream.pending_data.borrow_mut();
      if !pending_data.is_empty() {
        let amt = std::cmp::min(pending_data.len(), length);
        if amt > 0 {
          let data_slice = pending_data.split_to(amt);
          // SAFETY: buf has capacity for `length` bytes per nghttp2 contract
          unsafe {
            std::ptr::copy_nonoverlapping(data_slice.as_ptr(), buf, amt)
          };
          *stream.available_outbound_length.borrow_mut() -= amt;
          amount = amt;

          if pending_data.is_empty() && *stream.writable_ended.borrow() {
            need_eof = true;
            need_trailers = stream.has_trailers();
          }
        }
      } else if *stream.writable_ended.borrow() {
        need_eof = true;
        need_trailers = stream.has_trailers();
      } else {
        is_deferred = true;
      }
    }
    // pending_data borrow and stream reference are dropped here
  } else {
    return ffi::NGHTTP2_ERR_DEFERRED as _;
  }

  if is_deferred {
    return ffi::NGHTTP2_ERR_DEFERRED as _;
  }

  if no_copy && have_data {
    // SAFETY: data_flags is a valid out-pointer per nghttp2 contract
    unsafe { *data_flags |= ffi::NGHTTP2_DATA_FLAG_NO_COPY as u32 };
  }

  if need_eof {
    // SAFETY: data_flags is a valid out-pointer per nghttp2 contract
    unsafe { *data_flags |= ffi::NGHTTP2_DATA_FLAG_EOF as u32 };
    if need_trailers {
      // SAFETY: data_flags is a valid out-pointer per nghttp2 contract
      unsafe { *data_flags |= ffi::NGHTTP2_DATA_FLAG_NO_END_STREAM as u32 };
      // Re-lookup stream after dropping previous borrows; the JS
      // callback in on_trailers() could modify session.streams.
      if let Some(stream) = session.find_stream(stream_id) {
        stream.on_trailers();
      }
    }
    // Mark EOF as emitted on this stream so the subsequent shutdown()
    // op (from Writable._final / shutdownWritable) skips its
    // resume_data and nghttp2 doesn't pack a redundant empty trailing
    // DATA frame just to carry END_STREAM.
    if let Some(stream) = session.find_stream(stream_id) {
      *stream.eof_sent.borrow_mut() = true;
    }
    // Complete shutdown now. All borrows have been dropped above,
    // so it's safe to call into JS. This must happen before
    // on_stream_close_callback fires (also during mem_send) so that
    // writableFinished is true when the close event is emitted.
    if let Some(stream) = session.find_stream(stream_id) {
      stream.complete_shutdown();
    }
  }

  amount as CSsizeT
}

unsafe extern "C" fn on_select_padding(
  _session: *mut ffi::nghttp2_session,
  frame: *const ffi::nghttp2_frame,
  max_payload_len: usize,
  user_data: *mut c_void,
) -> CSsizeT {
  // SAFETY: user_data is the user_data pointer set during session creation
  let session = unsafe { Session::from_user_data(user_data) };
  let padding = frame_header_length(frame);

  let result = match session.padding_strategy {
    PaddingStrategy::None => padding,
    PaddingStrategy::Max => {
      session.on_max_frame_size_padding(padding, max_payload_len)
    }
    PaddingStrategy::Aligned | PaddingStrategy::Callback => {
      session.on_dword_aligned_padding(padding, max_payload_len)
    }
  };

  result as CSsizeT
}

unsafe extern "C" fn on_frame_not_send_callback(
  _session: *mut ffi::nghttp2_session,
  frame: *const ffi::nghttp2_frame,
  lib_error_code: i32,
  data: *mut c_void,
) -> i32 {
  // Per Node.js parity, swallow events for frames that fail because the
  // session/stream is already closing — the close path will surface the
  // error through other channels.
  if lib_error_code == ffi::NGHTTP2_ERR_SESSION_CLOSING
    || lib_error_code == ffi::NGHTTP2_ERR_STREAM_CLOSED
    || lib_error_code == ffi::NGHTTP2_ERR_STREAM_CLOSING
  {
    return 0;
  }

  // SAFETY: data is the user_data pointer set during session creation
  let session = unsafe { Session::from_user_data(data) };

  let id = frame_id(frame);
  let ftype = frame_type(frame);
  let translated = translate_nghttp2_error_code(lib_error_code);

  let mut isolate =
    // SAFETY: isolate pointer is valid for the session's lifetime
    unsafe { v8::Isolate::from_raw_isolate_ptr(session.isolate) };
  v8::scope!(let scope, &mut isolate);
  let context = v8::Local::new(scope, session.context.clone());
  let scope = &mut v8::ContextScope::new(scope, context);

  let stream_id = v8::Integer::new(scope, id);
  let frame_type_v = v8::Integer::new_from_unsigned(scope, ftype as u32);
  let code = v8::Integer::new_from_unsigned(scope, translated);

  let state = session.op_state.borrow();
  let callbacks = state.borrow::<SessionCallbacks>();
  let recv = v8::Local::new(scope, &session.this);
  let callback = v8::Local::new(scope, &callbacks.frame_error_cb);
  drop(state);

  callback.call(
    scope,
    recv.into(),
    &[stream_id.into(), frame_type_v.into(), code.into()],
  );
  0
}

// Maps an nghttp2 library error (negative) to the HTTP/2 protocol error
// code (RFC 7540 §7) surfaced to JavaScript, matching Node's
// TranslateNghttp2ErrorCode in src/node_http2.cc.
fn translate_nghttp2_error_code(lib_err: i32) -> u32 {
  match lib_err {
    ffi::NGHTTP2_ERR_STREAM_CLOSED => 5, // NGHTTP2_STREAM_CLOSED
    ffi::NGHTTP2_ERR_HEADER_COMP => 9,   // NGHTTP2_COMPRESSION_ERROR
    ffi::NGHTTP2_ERR_FRAME_SIZE_ERROR => 6, // NGHTTP2_FRAME_SIZE_ERROR
    ffi::NGHTTP2_ERR_FLOW_CONTROL => 3,  // NGHTTP2_FLOW_CONTROL_ERROR
    ffi::NGHTTP2_ERR_REFUSED_STREAM => 7, // NGHTTP2_REFUSED_STREAM
    ffi::NGHTTP2_ERR_PROTO
    | ffi::NGHTTP2_ERR_HTTP_HEADER
    | ffi::NGHTTP2_ERR_HTTP_MESSAGING => 1, // NGHTTP2_PROTOCOL_ERROR
    _ => 2,                              // NGHTTP2_INTERNAL_ERROR
  }
}

unsafe extern "C" fn on_invalid_header_callback(
  _session: *mut ffi::nghttp2_session,
  _frame: *const ffi::nghttp2_frame,
  _name: *mut ffi::nghttp2_rcbuf,
  _value: *mut ffi::nghttp2_rcbuf,
  _flags: u8,
  _data: *mut c_void,
) -> i32 {
  0
}

unsafe extern "C" fn on_nghttp_error_callback(
  _session: *mut ffi::nghttp2_session,
  _lib_error_code: i32,
  _msg: *const std::ffi::c_char,
  _len: usize,
  _data: *mut c_void,
) -> i32 {
  0
}

unsafe extern "C" fn on_send_data_callback(
  _session: *mut ffi::nghttp2_session,
  frame: *mut ffi::nghttp2_frame,
  framehd: *const u8,
  length: usize,
  _source: *mut ffi::nghttp2_data_source,
  user_data: *mut c_void,
) -> i32 {
  // SAFETY: user_data is the user_data pointer set during session creation
  let session = unsafe { Session::from_user_data(user_data) };

  // SAFETY: frame is valid for the duration of the callback. `data`
  // and `hd` are union fields in `nghttp2_frame`; accessing them is
  // unsafe but valid because pack_data set the active variant before
  // dispatching this callback.
  let (stream_id, padlen) = unsafe {
    let frame = &*frame;
    // padlen is total trailing padding INCLUDING the pad_length byte,
    // so pad_length value (the byte itself) = padlen - 1, and the
    // zero-padding bytes count = padlen - 1.
    (frame.hd.stream_id, frame.data.padlen)
  };

  // Header (NGHTTP2_FRAME_HDLEN = 9 bytes per RFC 7540 §4.1) packed
  // into the framebuf by pack_data; emit it as one chunk on the wire.
  let header_len = 9usize;
  // SAFETY: framehd points to the header packed by nghttp2; we copy
  // it before returning so it doesn't outlive the callback.
  let header = unsafe { std::slice::from_raw_parts(framehd, header_len) };
  session.outgoing_chunks.push_back(header.to_vec());

  if padlen > 0 {
    // Pad length octet: value is the trailing-zero byte count, i.e.
    // padlen - 1. nghttp2 reserved this byte slot in the framebuf via
    // frame_set_pad's `framehd_only` shift but didn't fill it.
    session.outgoing_chunks.push_back(vec![(padlen - 1) as u8]);
  }

  if length > 0 {
    let chunk_opt = session.find_stream(stream_id).and_then(|stream| {
      let mut pending = stream.pending_data.borrow_mut();
      let amt = std::cmp::min(pending.len(), length);
      if amt > 0 {
        let chunk = pending.split_to(amt);
        *stream.available_outbound_length.borrow_mut() -= amt;
        Some(chunk.to_vec())
      } else {
        None
      }
    });
    // Borrow released; safe to mutate session now.
    if let Some(chunk) = chunk_opt {
      session.outgoing_chunks.push_back(chunk);
    }
    // If pending shrank short of `length` (shouldn't happen — read
    // callback already promised this many bytes), the wire will be
    // short by the missing amount; nghttp2 will fail the session via
    // its error callback rather than silently corrupt the stream.
  }

  if padlen > 1 {
    // Trailing padding bytes (zeros). padlen - 1 of them.
    session
      .outgoing_chunks
      .push_back(vec![0u8; (padlen - 1) as usize]);
  }

  0
}

unsafe extern "C" fn on_invalid_frame_recv_callback(
  _session: *mut ffi::nghttp2_session,
  _frame: *const ffi::nghttp2_frame,
  lib_error_code: i32,
  data: *mut c_void,
) -> i32 {
  // SAFETY: data is the user_data pointer set during session creation
  let session = unsafe { Session::from_user_data(data) };
  let count = session.invalid_frame_count;
  session.invalid_frame_count = count.saturating_add(1);
  // Node.js compares post-increment against the limit (see node_http2.cc
  // OnInvalidFrame: `if (invalid_frame_count_++ > max_invalid_frames)`).
  // Returning a non-zero value tells nghttp2 to terminate the session
  // immediately; receive_data picks up the custom error code below and
  // surfaces it to JS as ERR_HTTP2_TOO_MANY_INVALID_FRAMES.
  if count > session.max_invalid_frames {
    session.custom_recv_error_code = Some("ERR_HTTP2_TOO_MANY_INVALID_FRAMES");
    return 1;
  }
  // Surface protocol-level violations (e.g. server attempting to disable
  // SETTINGS_ENABLE_CONNECT_PROTOCOL after enabling it) to JS as a session
  // 'error' event. Mirrors Node.js OnInvalidFrame which forwards specific
  // lib error codes to http2session_on_error_function.
  // SAFETY: nghttp2_is_fatal is a pure function on the lib_error_code.
  let is_fatal = unsafe { ffi::nghttp2_is_fatal(lib_error_code) } != 0;
  if is_fatal
    || lib_error_code == ffi::NGHTTP2_ERR_PROTO as i32
    || lib_error_code == ffi::NGHTTP2_ERR_STREAM_CLOSED as i32
    || lib_error_code == ffi::NGHTTP2_ERR_FLOW_CONTROL as i32
  {
    invoke_session_internal_error(session, lib_error_code);
  }
  0
}

fn invoke_session_internal_error(session: &Session, lib_error_code: i32) {
  let mut isolate =
    // SAFETY: isolate pointer is valid for the session's lifetime
    unsafe { v8::Isolate::from_raw_isolate_ptr(session.isolate) };
  v8::scope!(let scope, &mut isolate);
  let context = v8::Local::new(scope, session.context.clone());
  let scope = &mut v8::ContextScope::new(scope, context);

  let state = session.op_state.borrow();
  let callbacks = state.borrow::<SessionCallbacks>();
  let recv = v8::Local::new(scope, &session.this);
  let callback = v8::Local::new(scope, &callbacks.session_internal_error_cb);
  drop(state);

  let arg = v8::Integer::new(scope, lib_error_code);
  callback.call(scope, recv.into(), &[arg.into()]);
}

unsafe extern "C" fn on_frame_send_callback(
  _session: *mut ffi::nghttp2_session,
  frame: *const ffi::nghttp2_frame,
  data: *mut c_void,
) -> i32 {
  // SAFETY: data is the user_data pointer set during session creation
  let session = unsafe { Session::from_user_data(data) };
  session.frames_sent = session.frames_sent.saturating_add(1);
  // SAFETY: frame is valid per nghttp2 callback contract
  let f = unsafe { &*frame };
  // SAFETY: union access of `hd` is always valid (every nghttp2 frame has a
  // header). Reading `type_` to discriminate which other variant is active.
  let frame_type = unsafe { f.hd.type_ };
  if frame_type == ffi::NGHTTP2_GOAWAY as u8 {
    // SAFETY: union access valid because we verified type_ == NGHTTP2_GOAWAY
    let goaway = unsafe { &f.goaway };
    session.sent_goaway_code = Some(goaway.error_code);
    // When nghttp2 detects a connection-level violation while parsing inbound
    // bytes (e.g. an unknown extension frame whose declared length exceeds
    // SETTINGS_MAX_FRAME_SIZE, or any other terminate_session_with_reason
    // path), it queues a GOAWAY whose error_code carries the protocol-level
    // reason. mem_recv itself returns success in this case, so without
    // observing the outgoing GOAWAY we'd lose the error and the JS layer
    // would just see a graceful close. Mirror Node's
    // `Http2Session::OnFrameSent` GOAWAY branch: if the GOAWAY we're sending
    // isn't NGHTTP2_NO_ERROR, surface it to JS through
    // `onSessionInternalError(NGHTTP2_ERR_PROTO)` so the session is destroyed
    // with the same `NghttpError("Protocol error")` Node produces.
    if session.pending_user_goaway > 0 {
      // User-initiated GOAWAY (from the `goaway()` op): consume one pending
      // marker and stay quiet. nghttp2 only ever sends one GOAWAY per
      // submit, so the user counter mirrors what we put on the wire.
      //
      // Known race: the marker is associated with a *count*, not with a
      // specific outgoing frame. If a user `goaway()` and an internal
      // `terminate_session_with_reason` GOAWAY are queued simultaneously and
      // ship in the opposite order from how they were submitted, we'll
      // attribute the internal GOAWAY to the user (no protocol error fired)
      // and the user GOAWAY to nghttp2 (may fire if the user passed a
      // non-NO_ERROR code and SETTINGS hasn't arrived yet). This is
      // acceptable since racing `destroy()` against parser-induced internal
      // GOAWAYs is not a real-world program shape, and the worst-case
      // outcome is just a misclassified error -- not a crash or hang.
      session.pending_user_goaway =
        session.pending_user_goaway.saturating_sub(1);
    } else if session.is_client
      && goaway.error_code != ffi::NGHTTP2_NO_ERROR as u32
      && !session.protocol_error_emitted
      && !session.remote_settings_received
    {
      // nghttp2 itself queued this GOAWAY before SETTINGS exchange completed
      // (e.g. via `terminate_session_with_reason` after parsing garbage that
      // looks like an oversize unknown frame). Surface it to JS as
      // `NghttpError("Protocol error")` so the session is destroyed with the
      // same error Node produces (matches `test-http2-client-http1-server`).
      //
      // Why gate on `is_client`: the only case where nghttp2's internal
      // GOAWAY otherwise vanishes is on the client side talking to a non-h2
      // peer (the bytes are queued but never reach a listening h2 stack). On
      // the server side, internal GOAWAYs are already routed through the
      // normal close path; firing onSessionInternalError there destroys the
      // server session before the GOAWAY bytes flush to the client, breaking
      // tests like `test-http2-max-settings` that rely on the client seeing
      // a connection-level error.
      //
      // Why gate on `!remote_settings_received`: late connection-level
      // GOAWAYs originating from peer-malformed-frame echoes are surfaced
      // through `onGoawayData`. Firing the protocol error hook for those too
      // produced regressions in `test-http2-timeout-large-write-file.js`
      // (uncaught Protocol error during legitimate session shutdown).
      // Handshake-time failures are the only case where mem_recv silently
      // swallows the error and we have to reach in via the send-side
      // callback to surface it.
      session.protocol_error_emitted = true;
      invoke_session_internal_error(session, ffi::NGHTTP2_ERR_PROTO);
    }
  }
  0
}

/// Detect stream-level flow-control violations on inbound DATA frames before
/// nghttp2 processes the payload. Upstream nghttp2 (>= 1.65) reacts to a
/// stream-level overflow by tearing down the *whole session* with GOAWAY
/// (NGHTTP2_FLOW_CONTROL_ERROR), which closes every active stream with code
/// `NGHTTP2_REFUSED_STREAM`. Node ships an older vendored nghttp2 whose
/// `nghttp2_session_update_recv_stream_window_size` calls
/// `nghttp2_session_add_rst_stream(stream_id, NGHTTP2_FLOW_CONTROL_ERROR)`
/// instead, so the offending stream alone closes with
/// `NGHTTP2_FLOW_CONTROL_ERROR`. Tests like
/// `parallel/test-http2-misbehaving-flow-control-paused` assert the
/// stream-level form, so we replicate it here:
///
///   1. peek the DATA frame header
///   2. if `effective_recv_data_length + length > effective_local_window_size`,
///      bump the stream's local window to NGHTTP2_MAX_WINDOW_SIZE so nghttp2's
///      own check won't fire (and won't tear down the whole session), and
///   3. submit `RST_STREAM(NGHTTP2_FLOW_CONTROL_ERROR)` for the offending
///      stream, so when nghttp2 closes it the registered close callback emits
///      `ERR_HTTP2_STREAM_ERROR("Stream closed with error code NGHTTP2_FLOW_CONTROL_ERROR")`.
unsafe extern "C" fn on_begin_frame_callback(
  ng_session: *mut ffi::nghttp2_session,
  hd: *const ffi::nghttp2_frame_hd,
  _user_data: *mut c_void,
) -> i32 {
  // SAFETY: hd is valid per nghttp2 callback contract
  let hd = unsafe { &*hd };
  if hd.type_ != ffi::NGHTTP2_DATA as u8 || hd.stream_id == 0 {
    return 0;
  }

  // SAFETY: ng_session is valid per nghttp2 callback contract
  let eff_local_window = unsafe {
    ffi::nghttp2_session_get_stream_effective_local_window_size(
      ng_session,
      hd.stream_id,
    )
  };
  if eff_local_window < 0 {
    return 0;
  }
  // SAFETY: ng_session is valid per nghttp2 callback contract
  let eff_recv = unsafe {
    ffi::nghttp2_session_get_stream_effective_recv_data_length(
      ng_session,
      hd.stream_id,
    )
  };
  if eff_recv < 0 {
    return 0;
  }
  let projected = (eff_recv as i64) + (hd.length as i64);
  if projected <= eff_local_window as i64 {
    return 0;
  }

  // Disable nghttp2's own flow-control check for this stream by raising
  // its local window to the protocol maximum. We've already decided to
  // RST_STREAM the offender; we don't want nghttp2 to also terminate the
  // entire session before our RST_STREAM frame ships out.
  // SAFETY: ng_session is valid per nghttp2 callback contract
  unsafe {
    // NGHTTP2_MAX_WINDOW_SIZE = (1 << 31) - 1 (RFC 7540).
    ffi::nghttp2_session_set_local_window_size(
      ng_session,
      ffi::NGHTTP2_FLAG_NONE as u8,
      hd.stream_id,
      i32::MAX,
    );
    ffi::nghttp2_submit_rst_stream(
      ng_session,
      ffi::NGHTTP2_FLAG_NONE as u8,
      hd.stream_id,
      ffi::NGHTTP2_FLOW_CONTROL_ERROR as u32,
    );
  }
  0
}

fn create_callbacks() -> *mut ffi::nghttp2_session_callbacks {
  let mut callbacks: *mut ffi::nghttp2_session_callbacks = std::ptr::null_mut();

  // SAFETY: passing valid pointer to be initialized by nghttp2
  unsafe {
    assert_eq!(ffi::nghttp2_session_callbacks_new(&mut callbacks), 0);

    ffi::nghttp2_session_callbacks_set_on_begin_headers_callback(
      callbacks,
      Some(on_begin_headers_callbacks),
    );
    ffi::nghttp2_session_callbacks_set_on_header_callback2(
      callbacks,
      Some(on_header_callback),
    );
    ffi::nghttp2_session_callbacks_set_on_frame_recv_callback(
      callbacks,
      Some(on_frame_recv_callback),
    );
    ffi::nghttp2_session_callbacks_set_on_stream_close_callback(
      callbacks,
      Some(on_stream_close_callback),
    );
    ffi::nghttp2_session_callbacks_set_on_data_chunk_recv_callback(
      callbacks,
      Some(on_data_chunk_recv_callback),
    );
    ffi::nghttp2_session_callbacks_set_on_frame_not_send_callback(
      callbacks,
      Some(on_frame_not_send_callback),
    );
    ffi::nghttp2_session_callbacks_set_on_invalid_header_callback2(
      callbacks,
      Some(on_invalid_header_callback),
    );
    ffi::nghttp2_session_callbacks_set_error_callback2(
      callbacks,
      Some(on_nghttp_error_callback),
    );
    ffi::nghttp2_session_callbacks_set_send_data_callback(
      callbacks,
      Some(on_send_data_callback),
    );
    ffi::nghttp2_session_callbacks_set_on_invalid_frame_recv_callback(
      callbacks,
      Some(on_invalid_frame_recv_callback),
    );
    ffi::nghttp2_session_callbacks_set_on_frame_send_callback(
      callbacks,
      Some(on_frame_send_callback),
    );
    ffi::nghttp2_session_callbacks_set_select_padding_callback2(
      callbacks,
      Some(on_select_padding),
    );
    ffi::nghttp2_session_callbacks_set_on_begin_frame_callback(
      callbacks,
      Some(on_begin_frame_callback),
    );
  }

  callbacks
}

// Session

#[allow(dead_code, reason = "fields are stored for prevent GC of v8 handles")]
pub struct SessionCallbacks {
  pub session_internal_error_cb: v8::Global<v8::Function>,
  pub priority_frame_cb: v8::Global<v8::Function>,
  pub settings_frame_cb: v8::Global<v8::Function>,
  pub ping_frame_cb: v8::Global<v8::Function>,
  pub headers_frame_cb: v8::Global<v8::Function>,
  pub frame_error_cb: v8::Global<v8::Function>,
  pub goaway_data_cb: v8::Global<v8::Function>,
  pub alt_svc_cb: v8::Global<v8::Function>,
  pub stream_trailers_cb: v8::Global<v8::Function>,
  pub stream_close_cb: v8::Global<v8::Function>,
  pub origin_frame_cb: v8::Global<v8::Function>,
}

#[derive(Debug)]
pub struct NgHttp2StreamWrite {
  pub data: bytes::Bytes,
  #[allow(dead_code, reason = "stored for debugging")]
  pub stream_id: i32,
}

impl NgHttp2StreamWrite {
  pub fn new(data: bytes::Bytes, stream_id: i32) -> Self {
    Self { data, stream_id }
  }

  pub fn len(&self) -> usize {
    self.data.len()
  }
}

pub struct Session {
  pub session: *mut ffi::nghttp2_session,
  pub streams: HashMap<i32, (v8::Global<v8::Object>, cppgc::Ref<Http2Stream>)>,
  pub outgoing_buffers: Vec<NgHttp2StreamWrite>,
  pub outgoing_length: usize,
  pub isolate: v8::UnsafeRawIsolatePtr,
  pub context: v8::Global<v8::Context>,
  pub op_state: Rc<RefCell<OpState>>,
  pub this: v8::Global<v8::Object>,
  pub padding_strategy: PaddingStrategy,
  pub graceful_close_initiated: bool,
  pub stream: Option<*mut deno_core::uv_compat::UvStream>,
  /// Prevents recursive send_pending_data calls. When mem_recv fires
  /// JS callbacks that call back into send_pending_data, the nested
  /// mem_send can close/free streams that mem_recv still references
  /// (double-free). Like Node.js's is_sending() guard.
  pub is_sending: bool,
  /// Set when destroy() is called while is_sending is true. The TCP
  /// handle close is deferred until send_pending_data can run, allowing
  /// GOAWAY to be sent before the connection closes.
  pub pending_destroy: bool,
  /// RST_STREAM submissions deferred because is_sending was true.
  /// Matches Node.js's pending_rst_streams_ mechanism: submitting
  /// RST_STREAM during mem_recv/mem_send can cause nghttp2 to
  /// double-free a stream (with no_closed_streams=1). Instead, we
  /// defer and flush them after send_pending_data completes.
  pub pending_rst_streams: Vec<(i32, u32)>,
  /// Original stream.data pointer saved before consume_stream overwrites it.
  /// Restored when the session releases the stream.
  pub orig_stream_data: *mut std::ffi::c_void,
  /// Maximum number of header pairs allowed per stream. Mirrors Node.js's
  /// per-session limit derived from the `maxHeaderListPairs` option.
  /// Streams that receive more headers are reset with NGHTTP2_ENHANCE_YOUR_CALM.
  pub max_header_pairs: u32,
  /// Pre-formatted chunks queued by `on_send_data_callback` (NO_COPY DATA
  /// frames). `get_outgoing_chunk` drains this before falling through to
  /// `nghttp2_session_mem_send`, so each part of a padded DATA frame
  /// (header / pad_length / payload / padding) becomes its own
  /// socket.write, matching the per-uv_buf_t output of Node's libuv send
  /// path that test-http2-padding-aligned asserts.
  pub outgoing_chunks: VecDeque<Vec<u8>>,
  /// Maximum number of invalid HTTP/2 frames the peer may send before the
  /// session is terminated. Mirrors Node.js's `maxSessionInvalidFrames`
  /// option (default 1000). The check uses post-increment comparison
  /// (`count++ > max`), so `0` means the second invalid frame triggers.
  pub max_invalid_frames: u32,
  /// Running count of invalid frames received on this session.
  pub invalid_frame_count: u32,
  /// Total HTTP/2 frames received on this session, used for the
  /// `framesReceived` field of perf_hooks `Http2Session` entries.
  pub frames_received: u32,
  /// Total HTTP/2 frames sent on this session, used for the
  /// `framesSent` field of perf_hooks `Http2Session` entries.
  pub frames_sent: u32,
  /// Custom error code set by an nghttp2 callback when it returns a fatal
  /// error so that `receive_data` can surface it to JS via
  /// `session_internal_error_cb`. Mirrors Node's
  /// `Http2Session::custom_recv_error_code_`.
  pub custom_recv_error_code: Option<&'static str>,
  /// Error code from the last GOAWAY frame this side sent. Set in
  /// `on_frame_send_callback`. The JS layer reads it via
  /// `handle.lastSentGoawayCode()` so that streams torn down because
  /// nghttp2 has already terminated the session can carry the actual
  /// connection-level error (e.g. NGHTTP2_FLOW_CONTROL_ERROR) instead of
  /// being papered over with NGHTTP2_CANCEL.
  pub sent_goaway_code: Option<u32>,
  /// Custom settings the local peer has sent. Surfaced via
  /// `session.localSettings.customSettings`. Mirrors Node's
  /// `Http2Session::local_custom_settings_`.
  pub local_custom_settings: Vec<(i32, u32)>,
  /// Custom settings the remote peer is allowed to send (`remoteCustomSettings`
  /// option) plus their last-received values. The high bit (1 << 16) of the
  /// stored ID flags an entry as "registered but no value received yet".
  /// Mirrors Node's `Http2Session::remote_custom_settings_`.
  pub remote_custom_settings: Vec<(i32, u32)>,
  /// FIFO of JS callbacks waiting for SETTINGS ACK from the peer. Each
  /// `session.settings()` invocation enqueues one entry; an inbound SETTINGS
  /// frame with the ACK flag pops the front and invokes it with
  /// `(ack=true, duration=0)`. Mirrors Node's `outstanding_settings_` queue.
  ///
  /// Invariant: every SETTINGS frame submitted via `Http2Settings::send`
  /// (including the constructor's initial settings, which the JS layer
  /// submits via `Http2Session.prototype.settings` with a bound
  /// `settingsCallback`) pushes exactly one entry; every inbound ACK pops
  /// exactly one. Because the only call site is `fn settings(cb)`, push and
  /// submit are paired atomically.
  pub pending_settings_acks: VecDeque<v8::Global<v8::Function>>,
  /// Count of outstanding outbound PINGs awaiting an ACK from the peer.
  /// Mirrors Node's `outstanding_pings_` queue depth. Receiving a PING ACK
  /// when this is zero is treated as a connection-level protocol error
  /// (see `HandlePingFrame` in node_http2.cc): there is no legitimate
  /// reason for a peer to send an unsolicited PING ACK.
  pub pending_pings: u32,
  /// True after we've surfaced an internal protocol error to JS. Used by
  /// `on_frame_send_callback`'s GOAWAY hook so we don't re-fire the
  /// `onSessionInternalError` JS callback for every GOAWAY in a teardown
  /// sequence (e.g. a peer GOAWAY echo).
  pub protocol_error_emitted: bool,
  /// Number of GOAWAYs the user has submitted via the `goaway()` op that
  /// have not yet drained through `on_frame_send_callback`. Used to skip
  /// the protocol-error hook for user-initiated GOAWAYs (e.g. a normal
  /// `session.destroy(code)`); only nghttp2-internal GOAWAYs (queued from
  /// `terminate_session_with_reason` after a protocol/frame-size violation)
  /// should surface as `NghttpError("Protocol error")` to JS.
  pub pending_user_goaway: u32,
  /// Set true once we receive any SETTINGS frame from the peer. We use this
  /// to gate the `on_frame_send_callback` protocol-error hook so it only
  /// fires for handshake-time failures (where nghttp2 internally tears the
  /// session down before SETTINGS exchange completes). Late connection-level
  /// GOAWAYs originating from server-side state (e.g. timeout-driven
  /// tear-downs that don't go through the user `goaway()` op) flow through
  /// the normal close path instead.
  pub remote_settings_received: bool,
  /// True if this is a client session, false for server. Used to scope the
  /// `on_frame_send_callback` protocol-error hook to client sessions only:
  /// the hook exists to surface "client connected to non-h2 server" as an
  /// error (otherwise nghttp2's internal GOAWAY is silently swallowed).
  /// On the server side, an internal GOAWAY (e.g. from maxSettings violation
  /// in `test-http2-max-settings`) is already handled by the normal close
  /// path — firing onSessionInternalError there destroys the session before
  /// the GOAWAY bytes flush, preventing the client from seeing the error.
  pub is_client: bool,
}

impl Session {
  /// Read the list of remote custom settings IDs the user wants to track.
  /// JS writes them into the shared settings buffer via
  /// `remoteCustomSettingsToBuffer` immediately before constructing the
  /// session. Mirrors Node's `Http2Session::FetchAllowedRemoteCustomSettings`.
  pub fn fetch_allowed_remote_custom_settings(&mut self) {
    with_settings(|buffer| {
      let count = buffer[SettingsIndex::Count as usize + 1] as usize;
      if count == 0 {
        return;
      }
      let imax = count.min(MAX_ADDITIONAL_SETTINGS);
      let offset = SettingsIndex::Count as usize + 2;
      for i in 0..imax {
        let key = buffer[offset + i * 2] & 0xffff;
        // bit 16 marks "registered but not yet received".
        let marked = (key | (1 << 16)) as i32;
        self.remote_custom_settings.push((marked, 0));
      }
      // Reset the count so a later read of localSettings/remoteSettings on
      // a session without any local custom settings doesn't see leftover.
      buffer[SettingsIndex::Count as usize + 1] = 0;
    });
  }

  /// Record the custom settings we just sent so they can be surfaced via
  /// `session.localSettings.customSettings`. Mirrors Node's
  /// `Http2Session::UpdateLocalCustomSettings`.
  pub fn update_local_custom_settings(
    &mut self,
    entries: &[ffi::nghttp2_settings_entry],
  ) {
    for entry in entries {
      // Standard nghttp2 setting IDs are 1..=6 and 8. Node treats anything
      // >= IDX_SETTINGS_COUNT (7) as custom; we follow the same rule.
      if entry.settings_id < SettingsIndex::Count as i32 {
        continue;
      }
      let id = entry.settings_id;
      let mut updated = false;
      for slot in self.local_custom_settings.iter_mut() {
        if slot.0 == id {
          slot.1 = entry.value;
          updated = true;
          break;
        }
      }
      if !updated && self.local_custom_settings.len() < MAX_ADDITIONAL_SETTINGS
      {
        self.local_custom_settings.push((id, entry.value));
      }
    }
  }

  /// Walk a received SETTINGS frame's iv array and update the registered
  /// remote custom settings with the new values. Settings whose IDs were
  /// not registered via `remoteCustomSettings` are dropped, matching Node's
  /// behaviour in `Http2Session::HandleSettingsFrame`.
  pub fn update_remote_custom_settings_from_iv(
    &mut self,
    iv: &[ffi::nghttp2_settings_entry],
  ) {
    if self.remote_custom_settings.is_empty() {
      return;
    }
    for entry in iv {
      if entry.settings_id < SettingsIndex::Count as i32 {
        continue;
      }
      let id_lo = (entry.settings_id as u32) & 0xffff;
      for slot in self.remote_custom_settings.iter_mut() {
        if (slot.0 as u32) & 0xffff == id_lo {
          slot.0 = id_lo as i32; // clear bit 16 to mark "received".
          slot.1 = entry.value;
          break;
        }
      }
    }
  }

  pub fn find_stream(&self, id: i32) -> Option<&cppgc::Ref<Http2Stream>> {
    self.streams.get(&id).map(|v| &v.1)
  }

  pub fn find_stream_obj(&self, id: i32) -> Option<&v8::Global<v8::Object>> {
    self.streams.get(&id).map(|v| &v.0)
  }

  pub fn push_outgoing_buffer(&mut self, write: NgHttp2StreamWrite) {
    self.outgoing_length += write.len();
    self.outgoing_buffers.push(write);
  }

  pub fn clear_outgoing(&mut self) {
    self.outgoing_buffers.clear();
    self.outgoing_length = 0;
  }

  /// Submit RST_STREAM, or defer it if we're inside mem_recv/mem_send.
  /// Matches Node.js's Http2Stream::SubmitRstStream: submitting
  /// RST_STREAM while nghttp2 is processing frames can cause
  /// double-free with no_closed_streams=1.
  pub fn submit_rst_stream(&mut self, stream_id: i32, code: u32) {
    if self.is_sending {
      self.pending_rst_streams.push((stream_id, code));
      return;
    }

    // Submit RST_STREAM before flushing so nghttp2 can prioritise it over
    // any queued DATA frames (e.g. an END_STREAM data frame queued by a
    // prior stream.end()). Flushing first would push the END_STREAM frame
    // and let nghttp2 free the stream (no_closed_streams=1), after which
    // the RST_STREAM would be silently dropped and the peer would never
    // see an error. This matches Node.js's SubmitRstStream which calls
    // nghttp2_submit_rst_stream directly and then schedules a write.
    // SAFETY: self.session is a valid nghttp2 session pointer
    let stream_ptr =
      unsafe { ffi::nghttp2_session_find_stream(self.session, stream_id) };
    if stream_ptr.is_null() {
      return;
    }

    // SAFETY: self.session is a valid nghttp2 session pointer
    unsafe {
      ffi::nghttp2_submit_rst_stream(
        self.session,
        ffi::NGHTTP2_FLAG_NONE as u8,
        stream_id,
        code,
      );
    }

    self.send_pending_data();
  }

  /// Flush deferred RST_STREAM submissions. Called after
  /// send_pending_data completes.
  fn flush_pending_rst_streams(&mut self) {
    if self.pending_rst_streams.is_empty() {
      return;
    }
    let pending: Vec<_> = std::mem::take(&mut self.pending_rst_streams);
    for (stream_id, code) in pending {
      // Check if the stream still exists.
      // SAFETY: self.session is a valid nghttp2 session pointer
      let stream_ptr =
        unsafe { ffi::nghttp2_session_find_stream(self.session, stream_id) };
      if stream_ptr.is_null() {
        continue;
      }
      // Submit RST_STREAM before flushing so nghttp2 can prioritise it
      // over any queued DATA frames (e.g. an END_STREAM data frame).
      // SAFETY: self.session is a valid nghttp2 session pointer
      unsafe {
        ffi::nghttp2_submit_rst_stream(
          self.session,
          ffi::NGHTTP2_FLAG_NONE as u8,
          stream_id,
          code,
        );
      }
    }
    self.send_pending_data();
  }

  pub fn is_graceful_closing(&self) -> bool {
    self.graceful_close_initiated
  }

  pub fn start_graceful_close(&mut self) {
    self.graceful_close_initiated = true;
  }

  pub fn active_stream_count(&self) -> usize {
    self.streams.len()
  }

  /// Check if graceful close is complete and notify JS if so.
  /// Mirrors Node.js's MaybeNotifyGracefulCloseComplete in node_http2.cc.
  /// Called after writes complete to detect when the session can be destroyed.
  pub fn maybe_notify_graceful_close_complete(&mut self) {
    if !self.graceful_close_initiated {
      return;
    }

    let want_write =
      // SAFETY: self.session is a valid nghttp2 session pointer
      unsafe { ffi::nghttp2_session_want_write(self.session) };
    let want_read =
      // SAFETY: self.session is a valid nghttp2 session pointer
      unsafe { ffi::nghttp2_session_want_read(self.session) };

    if want_write != 0 || want_read != 0 {
      return;
    }

    // SAFETY: isolate pointer is valid for the session's lifetime
    let mut isolate =
      unsafe { v8::Isolate::from_raw_isolate_ptr(self.isolate) };
    v8::scope!(let scope, &mut isolate);
    let context = v8::Local::new(scope, self.context.clone());
    let scope = &mut v8::ContextScope::new(scope, context);

    let this_local = v8::Local::new(scope, &self.this);
    let key = v8::String::new(scope, "ongracefulclosecomplete").unwrap();
    if let Some(Ok(cb)) = this_local
      .get(scope, key.into())
      .map(v8::Local::<v8::Function>::try_from)
    {
      cb.call(scope, this_local.into(), &[]);
    }
  }

  pub unsafe fn from_user_data<'a>(user_data: *mut c_void) -> &'a mut Self {
    // SAFETY: caller guarantees user_data points to a valid Session
    unsafe { &mut *(user_data as *mut Session) }
  }

  pub fn send_pending_data(&mut self) {
    // Prevent recursive calls. JS callbacks from nghttp2_session_mem_recv
    // can call back into send_pending_data via scheduleSendPending. Nested
    // nghttp2_session_mem_send can close/free streams that mem_recv still
    // references (double-free with no_closed_streams=1). Matches Node.js
    // is_sending() guard in SendPendingData.
    if self.is_sending {
      return;
    }
    let stream = match self.stream {
      Some(stream) => stream,
      None => {
        // JS write path: no consumed stream, but still check for graceful
        // close completion. The JS side handles data serialization via
        // getOutgoingChunk/sendPending, but the graceful close notification
        // must still fire when nghttp2 reports no more pending I/O.
        self.maybe_notify_graceful_close_complete();
        return;
      }
    };

    // Safety check: ensure the stream handle is still a valid TCP handle
    let handle_type =
      // SAFETY: stream pointer is valid, stored in self.stream by consume_stream
      unsafe { (*(stream as *mut deno_core::uv_compat::UvHandle)).r#type };
    if handle_type != deno_core::uv_compat::uv_handle_type::UV_TCP {
      self.stream = None;
      return;
    }

    self.is_sending = true;
    loop {
      // Drain any chunks queued by on_send_data_callback (NO_COPY DATA
      // frames) before pulling the next frame from nghttp2.
      while let Some(chunk) = self.outgoing_chunks.pop_front() {
        let write_req = Box::new(H2WriteReq {
          uv_req: deno_core::uv_compat::new_write(),
          data: chunk,
        });
        let write_ptr = Box::into_raw(write_req);
        // SAFETY: write_ptr is freshly allocated; stream is a valid libuv handle
        unsafe {
          let buf = deno_core::uv_compat::UvBuf {
            base: (*write_ptr).data.as_ptr() as *mut _,
            len: (*write_ptr).data.len(),
          };
          let ret = deno_core::uv_compat::uv_write(
            &mut (*write_ptr).uv_req,
            stream,
            &buf,
            1,
            Some(h2_write_cb),
          );
          if ret != 0 {
            let _ = Box::from_raw(write_ptr);
          }
        }
      }
      let mut src = std::ptr::null();
      let src_len =
        // SAFETY: self.session is a valid nghttp2 session pointer
        unsafe { ffi::nghttp2_session_mem_send(self.session, &mut src) };

      if src_len > 0 {
        // SAFETY: src and src_len are valid per nghttp2_session_mem_send contract
        let data = unsafe { std::slice::from_raw_parts(src, src_len as usize) };
        // Write to libuv stream
        let data_copy = data.to_vec();
        let write_req = Box::new(H2WriteReq {
          uv_req: deno_core::uv_compat::new_write(),
          data: data_copy,
        });
        let write_ptr = Box::into_raw(write_req);
        // SAFETY: write_ptr is freshly allocated; stream is a valid libuv handle
        unsafe {
          let buf = deno_core::uv_compat::UvBuf {
            base: (*write_ptr).data.as_ptr() as *mut _,
            len: (*write_ptr).data.len(),
          };
          let ret = deno_core::uv_compat::uv_write(
            &mut (*write_ptr).uv_req,
            stream,
            &buf,
            1,
            Some(h2_write_cb),
          );
          if ret != 0 {
            let _ = Box::from_raw(write_ptr);
          }
        }
      } else if self.outgoing_chunks.is_empty() {
        break;
      }
    }
    self.is_sending = false;

    if !self.outgoing_buffers.is_empty() {
      for buffer in &self.outgoing_buffers {
        let data = buffer.data.as_ref();
        let data_copy = data.to_vec();
        let write_req = Box::new(H2WriteReq {
          uv_req: deno_core::uv_compat::new_write(),
          data: data_copy,
        });
        let write_ptr = Box::into_raw(write_req);
        // SAFETY: write_ptr is freshly allocated; stream is a valid libuv handle
        unsafe {
          let buf = deno_core::uv_compat::UvBuf {
            base: (*write_ptr).data.as_ptr() as *mut _,
            len: (*write_ptr).data.len(),
          };
          let ret = deno_core::uv_compat::uv_write(
            &mut (*write_ptr).uv_req,
            stream,
            &buf,
            1,
            Some(h2_write_cb),
          );
          if ret != 0 {
            let _ = Box::from_raw(write_ptr);
          }
        }
      }
      self.clear_outgoing();
    }

    self.maybe_notify_graceful_close_complete();

    // Flush any RST_STREAM submissions that were deferred during
    // mem_recv/mem_send (is_sending was true). Matches Node.js's
    // pending_rst_streams_ flush at the end of SendPendingData.
    self.flush_pending_rst_streams();
  }

  pub fn receive_data(&mut self, data: &[u8]) {
    if data.is_empty() {
      return;
    }
    // Block re-entrant send_pending_data calls from JS callbacks during
    // mem_recv. A single mem_recv can process multiple frames (e.g.
    // END_STREAM + RST_STREAM). If a callback from frame N triggers
    // mem_send which closes/frees a stream, frame N+1 (RST_STREAM for
    // the same stream) would crash with a double-free. Matches Node.js
    // behavior where sends are deferred until after mem_recv completes.
    self.is_sending = true;
    // SAFETY: self.session is valid; data slice pointer and length are valid
    let ret = unsafe {
      ffi::nghttp2_session_mem_recv(
        self.session,
        data.as_ptr() as _,
        data.len(),
      )
    };
    self.is_sending = false;
    if (ret as i64) < 0 {
      // nghttp2 reported a fatal error processing the inbound frames.
      // Mirrors Node.js HTTP2Session::OnStreamRead: hand the error to JS
      // via onSessionInternalError so it can destroy the session.
      self.emit_session_internal_error(ret as i32);
    }
    self.send_pending_data();

    // Complete deferred destroy: close the TCP handle now that
    // send_pending_data has had a chance to send GOAWAY etc.
    if self.pending_destroy {
      self.pending_destroy = false;
      if let Some(stream) = self.stream.take() {
        // Restore original stream.data that was saved in consume_stream
        // SAFETY: stream is a valid libuv handle
        unsafe {
          (*stream).data = self.orig_stream_data;
        }
        self.orig_stream_data = std::ptr::null_mut();
        // SAFETY: stream is a valid libuv handle
        unsafe {
          deno_core::uv_compat::uv_read_stop(stream);
          let req =
            Box::into_raw(Box::new(deno_core::uv_compat::new_shutdown()));
          let ret = deno_core::uv_compat::uv_shutdown(
            req,
            stream,
            Some(h2_shutdown_cb),
          );
          if ret != 0 {
            let _ = Box::from_raw(req);
            deno_core::uv_compat::uv_close(
              stream as *mut deno_core::uv_compat::UvHandle,
              Some(h2_stream_close_cb),
            );
          }
        }
      }
    }
  }

  /// Invoke `onSessionInternalError(integerCode, customErrorCode)` on the
  /// JS handle. Used when nghttp2 returns a fatal error from mem_recv —
  /// the JS side maps `customErrorCode` (e.g.
  /// `ERR_HTTP2_TOO_MANY_INVALID_FRAMES`) to the proper error and destroys
  /// the session, which in turn propagates the error to streams.
  fn emit_session_internal_error(&mut self, errno: i32) {
    let custom_code = self.custom_recv_error_code.take();

    let mut isolate =
      // SAFETY: isolate pointer is valid for the session's lifetime
      unsafe { v8::Isolate::from_raw_isolate_ptr(self.isolate) };
    v8::scope!(let scope, &mut isolate);
    let context = v8::Local::new(scope, self.context.clone());
    let scope = &mut v8::ContextScope::new(scope, context);

    let state = self.op_state.borrow();
    let callbacks = state.borrow::<SessionCallbacks>();
    let callback = v8::Local::new(scope, &callbacks.session_internal_error_cb);
    let recv = v8::Local::new(scope, &self.this);
    drop(state);

    let errno_v = v8::Integer::new(scope, errno);
    let custom_code_v: v8::Local<v8::Value> = match custom_code {
      Some(code) => v8::String::new(scope, code).unwrap().into(),
      None => v8::undefined(scope).into(),
    };

    callback.call(scope, recv.into(), &[errno_v.into(), custom_code_v]);
  }

  pub fn on_dword_aligned_padding(
    &self,
    frame_len: usize,
    max_payload_len: usize,
  ) -> usize {
    let r = (frame_len + 9) % 8;
    if r == 0 {
      return frame_len;
    }
    let pad = frame_len + (8 - r);
    std::cmp::min(max_payload_len, pad)
  }

  pub fn on_max_frame_size_padding(
    &self,
    _frame_len: usize,
    max_payload_len: usize,
  ) -> usize {
    max_payload_len
  }
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Http2SessionState {
  pub effective_local_window_size: f64,
  pub effective_recv_data_length: f64,
  pub next_stream_id: f64,
  pub local_window_size: f64,
  pub last_proc_stream_id: f64,
  pub remote_window_size: f64,
  pub outbound_queue_size: f64,
  pub hd_deflate_dynamic_table_size: f64,
  pub hd_inflate_dynamic_table_size: f64,
}

pub struct Http2Session {
  #[allow(dead_code, reason = "stored for future use")]
  type_: SessionType,
  session: *mut ffi::nghttp2_session,
  #[allow(dead_code, reason = "owns the allocation to prevent premature free")]
  callbacks: *mut ffi::nghttp2_session_callbacks,
  pub(crate) inner: *mut Session,
}

// SAFETY: Http2Session pointers are traced by cppgc
unsafe impl deno_core::GarbageCollected for Http2Session {
  fn trace(&self, _: &mut v8::cppgc::Visitor) {}

  fn get_name(&self) -> &'static std::ffi::CStr {
    c"Http2Session"
  }
}

impl Http2Session {
  fn create(
    this: v8::Global<v8::Object>,
    isolate: &v8::Isolate,
    scope: &mut v8::PinScope<'_, '_>,
    op_state: Rc<RefCell<OpState>>,
    session_type: SessionType,
    no_strict_field_ws_validation: bool,
  ) -> Self {
    let mut session: *mut ffi::nghttp2_session = std::ptr::null_mut();
    let options =
      Http2Options::new(session_type, no_strict_field_ws_validation);

    let context = scope.get_current_context();
    let context = v8::Global::new(scope, context);
    // SAFETY: isolate reference is valid; raw pointer stored for later use
    let isolate_ptr = unsafe { isolate.as_raw_isolate_ptr() };

    let inner = Box::into_raw(Box::new(Session {
      session,
      streams: HashMap::new(),
      op_state,
      context,
      isolate: isolate_ptr,
      this,
      outgoing_buffers: Vec::with_capacity(32),
      outgoing_length: 0,
      padding_strategy: options.padding_strategy(),
      graceful_close_initiated: false,
      stream: None,
      is_sending: false,
      pending_destroy: false,
      pending_rst_streams: Vec::new(),
      orig_stream_data: std::ptr::null_mut(),
      max_header_pairs: options.max_header_pairs(),
      outgoing_chunks: VecDeque::new(),
      max_invalid_frames: 1000,
      invalid_frame_count: 0,
      frames_received: 0,
      frames_sent: 0,
      custom_recv_error_code: None,
      sent_goaway_code: None,
      local_custom_settings: Vec::new(),
      remote_custom_settings: Vec::new(),
      pending_settings_acks: VecDeque::new(),
      pending_pings: 0,
      protocol_error_emitted: false,
      pending_user_goaway: 0,
      remote_settings_received: false,
      is_client: matches!(session_type, SessionType::Client),
    }));

    // SAFETY: inner is valid (just allocated); callbacks and options are valid
    unsafe {
      let callbacks = create_callbacks();
      match session_type {
        SessionType::Server => ffi::nghttp2_session_server_new3(
          &mut session,
          callbacks,
          inner as *mut _,
          options.ptr(),
          std::ptr::null_mut(),
        ),
        SessionType::Client => ffi::nghttp2_session_client_new3(
          &mut session,
          callbacks,
          inner as *mut _,
          options.ptr(),
          std::ptr::null_mut(),
        ),
      };
      (*inner).session = session;
      // Import the user's remoteCustomSettings list (JS writes the IDs to
      // the shared settings buffer immediately before constructing us).
      (*inner).fetch_allowed_remote_custom_settings();
    }

    Self {
      type_: session_type,
      session,
      callbacks: std::ptr::null_mut(),
      inner,
    }
  }

  fn submit_request(
    &self,
    priority: Http2Priority,
    headers: Http2Headers,
    options: i32,
  ) -> i32 {
    let has_data = (options & STREAM_OPTION_EMPTY_PAYLOAD) == 0;
    let mut data_provider = ffi::nghttp2_data_provider2 {
      source: ffi::nghttp2_data_source {
        ptr: std::ptr::null_mut(),
      },
      read_callback: Some(on_stream_read_callback),
    };

    let dp_ptr = if has_data {
      &mut data_provider as *mut _
    } else {
      std::ptr::null_mut()
    };

    // SAFETY: self.session, priority, headers, and data_provider are valid
    let ret = unsafe {
      ffi::nghttp2_submit_request2(
        self.session,
        &priority.spec,
        headers.data(),
        headers.len(),
        dp_ptr,
        std::ptr::null_mut(),
      )
    };

    const NGHTTP2_ERR_NOMEM: i32 = -901;
    assert_ne!(ret, NGHTTP2_ERR_NOMEM);

    if ret > 0 {
      // SAFETY: self.inner was allocated by Box::into_raw and is valid
      let session = unsafe { &mut *self.inner };
      let (obj, stream) =
        Http2Stream::new(session, ret, ffi::NGHTTP2_HCAT_HEADERS);
      stream.start_headers(ffi::NGHTTP2_HCAT_HEADERS);
      if (options & STREAM_OPTION_GET_TRAILERS) != 0 {
        stream.set_has_trailers(true);
      }
      session.streams.insert(ret, (obj, stream));
      session.send_pending_data();
    }

    ret
  }
}

#[op2]
impl Http2Session {
  #[constructor]
  #[cppgc]
  fn new(
    #[this] this: v8::Global<v8::Object>,
    isolate: &v8::Isolate,
    scope: &mut v8::PinScope<'_, '_>,
    op_state: Rc<RefCell<OpState>>,
    #[smi] type_: i32,
    no_strict_field_ws_validation: bool,
  ) -> Http2Session {
    Http2Session::create(
      this,
      isolate,
      scope,
      op_state,
      match type_ {
        0 => SessionType::Server,
        1 => SessionType::Client,
        _ => unreachable!(),
      },
      no_strict_field_ws_validation,
    )
  }

  #[fast]
  fn consume_stream(&self, #[cppgc] tcp: &crate::ops::tcp_wrap::TCPWrap) {
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &mut *self.inner };
    // Take ownership of the underlying TCP handle away from the `TCPWrap`. The
    // session now owns the `UvTcp` allocation and is solely responsible for
    // freeing it (via `h2_stream_close_cb`). Without this transfer both the
    // `TCPWrap` and the session would free the same allocation, causing a
    // double free / use-after-free.
    let stream = tcp.detach() as *mut deno_core::uv_compat::UvStream;
    if stream.is_null() {
      // The TCP handle was never allocated or was already detached; nothing to
      // consume.
      return;
    }

    // Save the original stream.data (LibUvStreamWrap's StreamHandleData)
    // before overwriting it with our session pointer.
    // SAFETY: stream is a valid libuv stream handle from TCPWrap
    let orig_data = unsafe { (*stream).data };
    session.orig_stream_data = orig_data;

    // Stop the existing read on the TCP handle
    // SAFETY: stream is a valid libuv stream handle from TCP object
    unsafe {
      deno_core::uv_compat::uv_read_stop(stream);
    }

    // Store the session pointer in the stream's data field so
    // the read callback can access it
    // SAFETY: stream is a valid libuv handle; self.inner is a valid pointer
    unsafe {
      (*stream).data = self.inner as *mut std::ffi::c_void;
    }

    session.stream = Some(stream);

    // Start reading from the stream
    // SAFETY: stream is a valid libuv stream handle with valid callbacks
    let ret = unsafe {
      deno_core::uv_compat::uv_read_start(
        stream,
        Some(h2_alloc_cb),
        Some(h2_read_cb),
      )
    };
    let _ = ret;
  }

  #[fast]
  #[reentrant]
  fn receive(&self, #[buffer] data: &[u8]) {
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &mut *self.inner };
    session.receive_data(data);
  }

  #[fast]
  #[reentrant]
  fn send_pending(&self) {
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &mut *self.inner };
    session.send_pending_data();
  }

  /// Drain a single outgoing h2 chunk from nghttp2's send queue.
  ///
  /// Returns one buffer per logical frame slice so the JS write path
  /// can issue one socket.write per chunk, matching the libuv
  /// consume_stream path that queues a separate uv_write per chunk in
  /// `send_pending_data`. For NO_COPY DATA frames, `on_send_data_callback`
  /// pushes header / pad_length / payload / padding into
  /// `session.outgoing_chunks`, which we drain first; non-DATA frames
  /// come straight from `nghttp2_session_mem_send`. An empty buffer
  /// signals "no more pending data"; fatal nghttp2 errors are logged
  /// here via `nghttp2_strerror` so they aren't silently swallowed.
  #[buffer]
  #[reentrant]
  fn get_outgoing_chunk(&self) -> Box<[u8]> {
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &mut *self.inner };
    loop {
      if let Some(chunk) = session.outgoing_chunks.pop_front() {
        return chunk.into_boxed_slice();
      }
      let mut src = std::ptr::null();
      let src_len =
        // SAFETY: session.session is a valid nghttp2 session pointer
        unsafe { ffi::nghttp2_session_mem_send(session.session, &mut src) };
      if src_len > 0 {
        // SAFETY: src and src_len are valid per nghttp2_session_mem_send
        let data = unsafe { std::slice::from_raw_parts(src, src_len as usize) };
        return data.to_vec().into_boxed_slice();
      }
      if src_len < 0 {
        // SAFETY: nghttp2_strerror returns a static C string for any input
        let msg = unsafe {
          let p = ffi::nghttp2_strerror(src_len as i32);
          std::ffi::CStr::from_ptr(p).to_string_lossy()
        };
        log::debug!("nghttp2_session_mem_send failed: {} ({})", msg, src_len);
        return Box::new([]);
      }
      // src_len == 0: nghttp2 has nothing more directly, but
      // on_send_data_callback may have just pushed chunks (NO_COPY
      // DATA) — loop and pop them. If the queue is also empty, the
      // next iteration's pop returns None and we hit mem_send again
      // which returns 0, exiting via the early return below.
      if session.outgoing_chunks.is_empty() {
        return Box::new([]);
      }
    }
  }

  #[fast]
  #[reentrant]
  fn destroy(
    &self,
    #[this] this: v8::Global<v8::Object>,
    scope: &mut v8::PinScope<'_, '_>,
  ) {
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &mut *self.inner };

    if session.is_sending {
      // We're inside receive_data's mem_recv. Defer the TCP handle
      // close so that send_pending_data (called after mem_recv) can
      // still send pending frames (e.g. GOAWAY) before the socket
      // closes.
      session.pending_destroy = true;
    } else {
      // Close the stream handle we took ownership of via consume_stream.
      // Use uv_shutdown first to send TCP FIN (graceful close) so the
      // peer can read any remaining buffered data. On Windows, calling
      // uv_close directly sends TCP RST which discards buffered data.
      if let Some(stream) = session.stream.take() {
        // SAFETY: stream is a valid libuv handle taken via consume_stream
        unsafe {
          deno_core::uv_compat::uv_read_stop(stream);
          let req =
            Box::into_raw(Box::new(deno_core::uv_compat::new_shutdown()));
          let ret = deno_core::uv_compat::uv_shutdown(
            req,
            stream,
            Some(h2_shutdown_cb),
          );
          if ret != 0 {
            // Shutdown failed (e.g. not connected), fall back to
            // closing the handle directly.
            let _ = Box::from_raw(req);
            deno_core::uv_compat::uv_close(
              stream as *mut deno_core::uv_compat::UvHandle,
              Some(h2_stream_close_cb),
            );
          }
        }
      }
    }

    // Call ondone callback if set
    let this_local = v8::Local::new(scope, &this);
    let ondone_key = v8::String::new(scope, "ondone").unwrap();
    if let Some(Ok(ondone_fn)) = this_local
      .get(scope, ondone_key.into())
      .map(v8::Local::<v8::Function>::try_from)
    {
      ondone_fn.call(scope, this_local.into(), &[]);
    }
  }

  #[fast]
  fn settings(&self, cb: v8::Local<v8::Function>) -> bool {
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &mut *self.inner };
    // SAFETY: session.isolate is valid for this session's lifetime
    let isolate = unsafe { v8::Isolate::from_raw_isolate_ptr(session.isolate) };
    // Enqueue BEFORE submit so the FIFO entry is in place no matter what.
    // The JS layer guarantees this op is the sole entry point that submits
    // SETTINGS — including the constructor's initial settings, which flows
    // through `Http2Session.prototype.settings` with a bound
    // `settingsCallback`. That keeps `pending_settings_acks` and outbound
    // SETTINGS frames 1:1.
    session
      .pending_settings_acks
      .push_back(v8::Global::new(&isolate, cb));
    let settings = Http2Settings::init(self.inner);
    settings.send();
    session.send_pending_data();
    true
  }

  #[reentrant]
  fn goaway(
    &self,
    code: u32,
    last_stream_id: i32,
    #[anybuffer] maybe_data: Option<&[u8]>,
  ) {
    let (data_ptr, data_len) = maybe_data
      .map(|d| (d.as_ptr(), d.len()))
      .unwrap_or((std::ptr::null(), 0));

    // When lastStreamID <= 0, use the last processed stream ID
    // so that in-progress streams are not refused.
    let effective_last_stream_id = if last_stream_id <= 0 {
      // SAFETY: self.session is a valid nghttp2 session pointer
      unsafe { ffi::nghttp2_session_get_last_proc_stream_id(self.session) }
    } else {
      last_stream_id
    };

    // SAFETY: self.session is valid; data_ptr and data_len are valid
    unsafe {
      ffi::nghttp2_submit_goaway(
        self.session,
        ffi::NGHTTP2_FLAG_NONE as _,
        effective_last_stream_id,
        code,
        data_ptr,
        data_len,
      );
    }
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &mut *self.inner };
    // Mark this GOAWAY as user-initiated so on_frame_send_callback skips the
    // internal-protocol-error hook when nghttp2 ships it.
    session.pending_user_goaway = session.pending_user_goaway.saturating_add(1);
    session.send_pending_data();
  }

  #[fast]
  fn set_graceful_close(&self) {
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &mut *self.inner };
    session.graceful_close_initiated = true;
  }

  #[fast]
  fn is_graceful_closing(&self) -> bool {
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &*self.inner };
    session.is_graceful_closing()
  }

  #[fast]
  fn submit_shutdown_notice(&self) {
    // SAFETY: self.session is a valid nghttp2 session pointer
    unsafe { ffi::nghttp2_submit_shutdown_notice(self.session) };
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &mut *self.inner };
    session.start_graceful_close();
    session.send_pending_data();
  }

  #[fast]
  #[smi]
  fn active_stream_count(&self) -> u32 {
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &*self.inner };
    session.active_stream_count() as u32
  }

  #[fast]
  #[smi]
  fn frames_received(&self) -> u32 {
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &*self.inner };
    session.frames_received
  }

  #[fast]
  #[smi]
  fn frames_sent(&self) -> u32 {
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &*self.inner };
    session.frames_sent
  }

  #[fast]
  fn has_pending_data(&self) -> bool {
    // SAFETY: self.session is a valid nghttp2 session pointer
    unsafe {
      let want_write = ffi::nghttp2_session_want_write(self.session);
      let want_read = ffi::nghttp2_session_want_read(self.session);
      want_write != 0 || want_read != 0
    }
  }

  /// Returns the error code from the most recent GOAWAY frame this session
  /// has sent, or -1 if no GOAWAY has been sent yet.
  #[fast]
  #[smi]
  fn last_sent_goaway_code(&self) -> i32 {
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &*self.inner };
    match session.sent_goaway_code {
      Some(code) => code as i32,
      None => -1,
    }
  }

  #[fast]
  fn local_settings(&self) {
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &*self.inner };
    // SAFETY: self.session is a valid nghttp2 session pointer
    with_settings(|buffer| unsafe {
      buffer[SettingsIndex::HeaderTableSize as usize] =
        ffi::nghttp2_session_get_local_settings(
          self.session,
          ffi::NGHTTP2_SETTINGS_HEADER_TABLE_SIZE,
        ) as u32;
      buffer[SettingsIndex::EnablePush as usize] =
        ffi::nghttp2_session_get_local_settings(
          self.session,
          ffi::NGHTTP2_SETTINGS_ENABLE_PUSH,
        ) as u32;
      buffer[SettingsIndex::MaxConcurrentStreams as usize] =
        ffi::nghttp2_session_get_local_settings(
          self.session,
          ffi::NGHTTP2_SETTINGS_MAX_CONCURRENT_STREAMS,
        ) as u32;
      buffer[SettingsIndex::InitialWindowSize as usize] =
        ffi::nghttp2_session_get_local_settings(
          self.session,
          ffi::NGHTTP2_SETTINGS_INITIAL_WINDOW_SIZE,
        ) as u32;
      buffer[SettingsIndex::MaxFrameSize as usize] =
        ffi::nghttp2_session_get_local_settings(
          self.session,
          ffi::NGHTTP2_SETTINGS_MAX_FRAME_SIZE,
        ) as u32;
      buffer[SettingsIndex::MaxHeaderListSize as usize] =
        ffi::nghttp2_session_get_local_settings(
          self.session,
          ffi::NGHTTP2_SETTINGS_MAX_HEADER_LIST_SIZE,
        ) as u32;
      buffer[SettingsIndex::EnableConnectProtocol as usize] =
        ffi::nghttp2_session_get_local_settings(
          self.session,
          ffi::NGHTTP2_SETTINGS_ENABLE_CONNECT_PROTOCOL,
        ) as u32;
      write_custom_settings_to_buffer(buffer, &session.local_custom_settings);
    });
  }

  #[fast]
  fn remote_settings(&self) {
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &*self.inner };
    // SAFETY: self.session is a valid nghttp2 session pointer
    with_settings(|buffer| unsafe {
      buffer[SettingsIndex::HeaderTableSize as usize] =
        ffi::nghttp2_session_get_remote_settings(
          self.session,
          ffi::NGHTTP2_SETTINGS_HEADER_TABLE_SIZE,
        ) as u32;
      buffer[SettingsIndex::EnablePush as usize] =
        ffi::nghttp2_session_get_remote_settings(
          self.session,
          ffi::NGHTTP2_SETTINGS_ENABLE_PUSH,
        ) as u32;
      buffer[SettingsIndex::MaxConcurrentStreams as usize] =
        ffi::nghttp2_session_get_remote_settings(
          self.session,
          ffi::NGHTTP2_SETTINGS_MAX_CONCURRENT_STREAMS,
        ) as u32;
      buffer[SettingsIndex::InitialWindowSize as usize] =
        ffi::nghttp2_session_get_remote_settings(
          self.session,
          ffi::NGHTTP2_SETTINGS_INITIAL_WINDOW_SIZE,
        ) as u32;
      buffer[SettingsIndex::MaxFrameSize as usize] =
        ffi::nghttp2_session_get_remote_settings(
          self.session,
          ffi::NGHTTP2_SETTINGS_MAX_FRAME_SIZE,
        ) as u32;
      buffer[SettingsIndex::MaxHeaderListSize as usize] =
        ffi::nghttp2_session_get_remote_settings(
          self.session,
          ffi::NGHTTP2_SETTINGS_MAX_HEADER_LIST_SIZE,
        ) as u32;
      buffer[SettingsIndex::EnableConnectProtocol as usize] =
        ffi::nghttp2_session_get_remote_settings(
          self.session,
          ffi::NGHTTP2_SETTINGS_ENABLE_CONNECT_PROTOCOL,
        ) as u32;
      write_custom_settings_to_buffer(buffer, &session.remote_custom_settings);
    });
  }

  #[serde]
  fn get_state(&self) -> Http2SessionState {
    // SAFETY: self.session is a valid nghttp2 session pointer
    unsafe {
      Http2SessionState {
        effective_local_window_size:
          ffi::nghttp2_session_get_effective_local_window_size(self.session)
            as f64,
        effective_recv_data_length:
          ffi::nghttp2_session_get_effective_recv_data_length(self.session)
            as f64,
        next_stream_id: ffi::nghttp2_session_get_next_stream_id(self.session)
          as f64,
        local_window_size: ffi::nghttp2_session_get_local_window_size(
          self.session,
        ) as f64,
        last_proc_stream_id: ffi::nghttp2_session_get_last_proc_stream_id(
          self.session,
        ) as f64,
        remote_window_size: ffi::nghttp2_session_get_remote_window_size(
          self.session,
        ) as f64,
        outbound_queue_size: ffi::nghttp2_session_get_outbound_queue_size(
          self.session,
        ) as f64,
        hd_deflate_dynamic_table_size:
          ffi::nghttp2_session_get_hd_deflate_dynamic_table_size(self.session)
            as f64,
        hd_inflate_dynamic_table_size:
          ffi::nghttp2_session_get_hd_inflate_dynamic_table_size(self.session)
            as f64,
      }
    }
  }

  #[fast]
  #[rename("setNextStreamID")]
  fn set_next_stream_id(&self, id: i32) -> bool {
    let ret =
      // SAFETY: self.session is a valid nghttp2 session pointer
      unsafe { ffi::nghttp2_session_set_next_stream_id(self.session, id) };
    if ret < 0 {
      log::debug!("failed to set next stream id to {}", id);
      return false;
    }
    log::debug!("set next stream id to {}", id);
    true
  }

  #[fast]
  fn set_max_invalid_frames(&self, value: u32) {
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &mut *self.inner };
    session.max_invalid_frames = value;
  }

  #[fast]
  fn set_local_window_size(&self, window_size: i32) -> i32 {
    // SAFETY: self.session is a valid nghttp2 session pointer
    unsafe {
      ffi::nghttp2_session_set_local_window_size(
        self.session,
        ffi::NGHTTP2_FLAG_NONE as u8,
        0,
        window_size,
      )
    }
  }

  #[fast]
  fn update_chunks_sent(&self) -> u32 {
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &*self.inner };
    session.outgoing_buffers.len() as u32
  }

  #[fast]
  fn origin(&self, #[string] origins: &str, count: i32) -> i32 {
    // Origins are concatenated and separated by NUL bytes (Node-compatible
    // serialization from JS: `arr += `${origin}\0``).
    let mut ov: Vec<ffi::nghttp2_origin_entry> =
      Vec::with_capacity(count as usize);
    let origins_bytes = origins.as_bytes();
    let mut start = 0;

    while ov.len() < count as usize && start < origins_bytes.len() {
      let end = origins_bytes[start..]
        .iter()
        .position(|&b| b == 0)
        .map(|p| start + p)
        .unwrap_or(origins_bytes.len());
      ov.push(ffi::nghttp2_origin_entry {
        origin: origins_bytes[start..end].as_ptr() as *mut u8,
        origin_len: end - start,
      });
      start = end + 1;
    }

    // SAFETY: self.session is valid; ov slice pointer and length are valid
    let ret = unsafe {
      ffi::nghttp2_submit_origin(
        self.session,
        ffi::NGHTTP2_FLAG_NONE as u8,
        ov.as_ptr(),
        ov.len(),
      )
    };
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &mut *self.inner };
    session.send_pending_data();
    ret
  }

  #[fast]
  fn altsvc(
    &self,
    stream_id: i32,
    #[string] origin: &str,
    #[string] value: &str,
  ) -> i32 {
    let origin_bytes = origin.as_bytes();
    let value_bytes = value.as_bytes();

    if origin_bytes.len() + value_bytes.len() > 16382 {
      return -1;
    }

    if (origin_bytes.is_empty() && stream_id == 0)
      || (!origin_bytes.is_empty() && stream_id != 0)
    {
      return -1;
    }

    // SAFETY: self.session is valid; origin and value byte slices are valid
    let ret = unsafe {
      ffi::nghttp2_submit_altsvc(
        self.session,
        ffi::NGHTTP2_FLAG_NONE as u8,
        stream_id,
        origin_bytes.as_ptr(),
        origin_bytes.len(),
        value_bytes.as_ptr(),
        value_bytes.len(),
      )
    };
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &mut *self.inner };
    session.send_pending_data();
    ret
  }

  #[fast]
  fn ping(&self, #[buffer] payload: &[u8]) -> i32 {
    if payload.len() != 8 {
      return -1;
    }

    // SAFETY: self.session is valid; payload is exactly 8 bytes (checked above)
    let ret = unsafe {
      ffi::nghttp2_submit_ping(
        self.session,
        ffi::NGHTTP2_FLAG_NONE as u8,
        payload.as_ptr(),
      )
    };
    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &mut *self.inner };
    if ret == 0 {
      // Track the outstanding ping so an inbound ACK can be matched. If
      // an ACK arrives when this counter is zero, the peer sent an
      // unsolicited PING ACK and we treat it as a protocol error.
      session.pending_pings = session.pending_pings.saturating_add(1);
    }
    session.send_pending_data();
    ret
  }

  fn request<'s>(
    &self,
    scope: &mut v8::PinScope<'s, '_>,
    headers: v8::Local<v8::String>,
    count: u32,
    options: i32,
    stream_id: i32,
    weight: i32,
    exclusive: bool,
  ) -> v8::Local<'s, v8::Value> {
    let priority = Http2Priority::new(stream_id, weight, exclusive);
    let headers = Http2Headers::from_v8_string(scope, headers, count as usize);

    let ret = self.submit_request(priority, headers, options);
    if ret <= 0 {
      return v8::Integer::new(scope, ret).into();
    }

    // SAFETY: self.inner was allocated by Box::into_raw and is valid
    let session = unsafe { &*self.inner };
    if let Some(stream_obj) = session.find_stream_obj(ret) {
      return v8::Local::new(scope, stream_obj).into();
    }
    v8::Integer::new(scope, -1).into()
  }
}

#[op2]
pub fn op_http2_callbacks(
  state: &mut OpState,
  #[scoped] session_internal_error_cb: v8::Global<v8::Function>,
  #[scoped] priority_frame_cb: v8::Global<v8::Function>,
  #[scoped] settings_frame_cb: v8::Global<v8::Function>,
  #[scoped] ping_frame_cb: v8::Global<v8::Function>,
  #[scoped] headers_frame_cb: v8::Global<v8::Function>,
  #[scoped] frame_error_cb: v8::Global<v8::Function>,
  #[scoped] goaway_data_cb: v8::Global<v8::Function>,
  #[scoped] alt_svc_cb: v8::Global<v8::Function>,
  #[scoped] origin_frame_cb: v8::Global<v8::Function>,
  #[scoped] stream_trailers_cb: v8::Global<v8::Function>,
  #[scoped] stream_close_cb: v8::Global<v8::Function>,
) {
  state.put(SessionCallbacks {
    session_internal_error_cb,
    priority_frame_cb,
    settings_frame_cb,
    ping_frame_cb,
    headers_frame_cb,
    frame_error_cb,
    goaway_data_cb,
    alt_svc_cb,
    origin_frame_cb,
    stream_trailers_cb,
    stream_close_cb,
  });
}

#[op2]
#[serde]
pub fn op_http2_http_state<'a>(
  scope: &mut v8::PinScope<'a, 'a>,
) -> JSHttp2State<'a> {
  JSHttp2State::create(scope)
}

/// Look up the human-readable string for an nghttp2 integer error code via
/// `nghttp2_strerror`. Used by the JS `NghttpError` class so an
/// `ERR_HTTP2_ERROR` raised from the binding carries the same `.message`
/// (e.g. "Protocol error" for `NGHTTP2_ERR_PROTO`) as Node.js produces from
/// its own binding.
#[op2]
#[string]
pub fn op_http2_error_string(code: i32) -> String {
  // Per https://nghttp2.org/documentation/nghttp2_strerror.html the input
  // must be one of `nghttp2_error`; for unknown codes the function may
  // return NULL, so we guard before constructing a `CStr`.
  // SAFETY: nghttp2_strerror returns either NULL or a static C string.
  let p = unsafe { ffi::nghttp2_strerror(code) };
  if p.is_null() {
    return String::new();
  }
  // SAFETY: p is a non-null, NUL-terminated static C string from nghttp2.
  unsafe { std::ffi::CStr::from_ptr(p) }
    .to_string_lossy()
    .into_owned()
}
