//! The stable `extern "C"` surface. `cbindgen` turns this module into
//! `include/punktfunk_core.h` (see `build.rs`).
//!
//! ## Principles (plan §5)
//! - Opaque handles only: C sees `PunktfunkSession*`, never a Rust type's fields.
//! - All cross-boundary structs are `#[repr(C)]`; buffers are pointer + length.
//! - Explicit ownership: every handle from `*_new` / `*_pair` must be passed to
//!   [`punktfunk_session_free`]. A [`PunktfunkFrame`]'s `data` is borrowed until the next
//!   `poll`/`free` on that session — copy it out before then.
//! - Versioned: [`punktfunk_abi_version`] + `PunktfunkConfig::struct_size` for forward-compat.
//! - Panics never cross the boundary. Status-returning entry points use [`guard`], which reports a
//!   panic as `PunktfunkStatus::Panic`; the teardown/mutator ones that return nothing use
//!   [`guard_void`], which swallows and logs. The remaining bare ones are bare ON PURPOSE and only
//!   because they cannot panic: [`punktfunk_abi_version`] returns a constant, and the
//!   `punktfunk_connect*` shims forward every argument unchanged into a guarded implementation.

// The crate denies `unsafe_code` (lib.rs): the network plane is safe Rust by compiler-enforced
// invariant. This module is one of the two documented carve-outs — an `extern "C"` surface for
// the platform clients cannot exist without unsafe, and every site below carries its proof.
#![allow(unsafe_code)]

// THE ABI CONTRACT, stated once - most `// SAFETY:` proofs below are an instance of it.
//
// Every pointer crossing this boundary is C memory the CALLER owns, and the header
// (`include/punktfunk_core.h`) is where that contract is published. Three shapes recur:
//
//  * HANDLES (`PunktfunkSession*`, `PunktfunkConnection*`, ...) come from a `*_new`/`*_pair` and
//    stay valid until the matching `*_free`. They are only ever reached through `as_mut()`/
//    `as_ref()`, which turn null into `None` - so a null handle is a `NullPointer` status, never a
//    dereference. Using one after `*_free` is the caller's error and the one thing this layer
//    cannot defend against.
//  * OUT-PARAMS are caller-owned writable slots of the matching `#[repr(C)]` type. Where the header
//    documents one as optional it is null-checked here before it is written; where it does not, a
//    null is rejected by the entry point's own guard before any store.
//  * C STRINGS are NUL-terminated or null, and are read through `opt_cstr`, which handles both and
//    borrows for the call only.
//
// Two properties hold everywhere and are not repeated per site: no pointer is retained past the
// call that received it (a `PunktfunkFrame`'s `data` is borrowed until the next `poll`/`free` on
// that session, as the module header says), and every entry point runs inside `guard`'s
// `catch_unwind`, so a panic becomes a status code rather than unwinding into C.

use crate::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
use crate::crypto::SessionKey;
use crate::error::PunktfunkStatus;
use crate::input::InputEvent;
use crate::reanchor::{GateVerdict, ReanchorGate};
use crate::session::Session;
use crate::stats::Stats;
use crate::transport::{loopback_pair, Transport, UdpTransport};
use std::ffi::{c_void, CStr};
use std::os::raw::c_char;
use std::panic::AssertUnwindSafe;
use std::ptr;

/// Poison-recovering lock for the C ABI surface. `.lock().unwrap()` inside an `extern "C"` fn
/// turns a poisoned mutex (some other thread panicked mid-write) into a panic across the C
/// boundary — an abort since Rust 1.81, exactly the class the panic-in-extern grep gate exists
/// for. The slots behind these mutexes are plain last-value caches (frame/audio/cursor/clip), so
/// whatever a poisoned writer left behind is still structurally valid data to overwrite or hand
/// out; recovering the guard is strictly better than aborting the embedding application.
/// (Ungated since v25: [`punktfunk_set_log_callback`]'s sink slot uses it on every build.)
fn lock_recover<T>(m: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
    m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}

/// Opaque session handle. Pointer-only from C.
pub struct PunktfunkSession {
    inner: Session,
    /// Keeps the most recently polled frame alive so [`PunktfunkFrame::data`] stays valid
    /// until the next poll or free.
    last_frame: Option<crate::session::Frame>,
    input_cb: Option<(PunktfunkInputCb, *mut c_void)>,
}

/// Forward-compatible session configuration. The caller MUST set `struct_size` to
/// `sizeof(PunktfunkConfig)`; the core uses it to detect ABI skew.
#[repr(C)]
#[derive(Clone, Copy)]
pub struct PunktfunkConfig {
    pub struct_size: u32,
    /// 0 = host, 1 = client.
    pub role: u32,
    /// 1 = P1 (GameStream-compatible), 2 = P2 (`punktfunk/1`).
    pub phase: u32,
    /// 0 = GF(2⁸), 1 = GF(2¹⁶).
    pub fec_scheme: u32,
    pub fec_percent: u32,
    pub max_data_per_block: u32,
    pub shard_payload: u32,
    /// Non-zero enables AES-128-GCM.
    pub encrypt: u32,
    pub key: [u8; 16],
    pub salt: [u8; 4],
    /// Test hook for the loopback transport; 0 in production.
    pub loopback_drop_period: u32,
    /// Largest encoded access unit the receiver will accept (bounds reassembler memory).
    pub max_frame_bytes: u64,
}

impl PunktfunkConfig {
    fn to_config(self) -> Result<Config, PunktfunkStatus> {
        let role = match self.role {
            0 => Role::Host,
            1 => Role::Client,
            _ => return Err(PunktfunkStatus::InvalidArg),
        };
        let phase = match self.phase {
            1 => ProtocolPhase::P1GameStream,
            2 => ProtocolPhase::P2Punktfunk,
            _ => return Err(PunktfunkStatus::InvalidArg),
        };
        // Range-check before narrowing: a `300` fec_percent or `65600` block size must be
        // rejected, not silently truncated to a valid-looking value.
        let scheme = u8::try_from(self.fec_scheme)
            .ok()
            .and_then(FecScheme::from_u8)
            .ok_or(PunktfunkStatus::InvalidArg)?;
        let fec_percent =
            u8::try_from(self.fec_percent).map_err(|_| PunktfunkStatus::InvalidArg)?;
        let max_data_per_block =
            u16::try_from(self.max_data_per_block).map_err(|_| PunktfunkStatus::InvalidArg)?;
        // The one narrowing here that differs by target width: on 32-bit (armeabi-v7a) an
        // `as usize` silently truncates a >4 GiB value to a plausible-looking residue that
        // passes validate() — reject it instead, like every narrowing above.
        let max_frame_bytes =
            usize::try_from(self.max_frame_bytes).map_err(|_| PunktfunkStatus::InvalidArg)?;
        let cfg = Config {
            role,
            phase,
            fec: FecConfig {
                scheme,
                fec_percent,
                max_data_per_block,
            },
            shard_payload: self.shard_payload as usize,
            max_frame_bytes,
            encrypt: self.encrypt != 0,
            // The C ABI keeps its fixed 16-byte key and always selects AES-128-GCM — no
            // ABI_VERSION bump. Raw-`Config` C embedders can't negotiate ChaCha; the Swift/
            // Kotlin clients are aarch64 with AES CE and never want it.
            key: SessionKey::Aes128Gcm(self.key),
            salt: self.salt,
            loopback_drop_period: self.loopback_drop_period,
        };
        cfg.validate().map_err(|e| e.status())?;
        Ok(cfg)
    }
}

/// Read a `PunktfunkConfig` from a caller pointer, enforcing the `struct_size` ABI-skew
/// guard *before* reading the whole struct: a caller compiled against a smaller (older)
/// layout is rejected rather than causing an out-of-bounds read.
///
/// # Safety
/// `cfg` must either be null or point to at least its own declared `struct_size` bytes.
unsafe fn config_from_ptr(cfg: *const PunktfunkConfig) -> Result<Config, PunktfunkStatus> {
    if cfg.is_null() {
        return Err(PunktfunkStatus::NullPointer);
    }
    // Read only the 4-byte size prefix first to bound the subsequent full read.
    // SAFETY: `addr_of!` forms a raw pointer WITHOUT creating a reference, which is the point: the
    // caller's struct may be an older, smaller version, so the field is read by offset rather than
    // through a `&`.
    let declared = unsafe { std::ptr::addr_of!((*cfg).struct_size).read_unaligned() } as usize;
    if declared < std::mem::size_of::<PunktfunkConfig>() {
        return Err(PunktfunkStatus::InvalidArg);
    }
    // SAFETY: per the ABI contract - the pointer operands in this block are caller-supplied and
    // are null-checked or handle-validated on this path before they are read.
    unsafe { *cfg }.to_config()
}

/// A reassembled access unit. `data`/`len` borrow session-owned memory valid until the
/// next `punktfunk_client_poll_frame`/`punktfunk_session_free` on the same session.
#[repr(C)]
pub struct PunktfunkFrame {
    pub data: *const u8,
    pub len: usize,
    pub frame_index: u32,
    pub pts_ns: u64,
    pub flags: u32,
    /// Wall-clock reassembly-completion instant (ns since the Unix epoch, CLOCK_REALTIME — the
    /// clock `pts_ns` and the skew handshake use). THIS is the receipt stamp for latency math:
    /// a stamp the embedder takes itself at the poll return additionally contains the
    /// pre-decode hand-off queue wait, so a client-side standing backlog would masquerade as
    /// network latency (ABI v9 — the 2026-07 two-pair standing-latency investigation).
    pub received_ns: u64,
}

/// Snapshot of session counters.
#[repr(C)]
#[derive(Clone, Copy, Default)]
pub struct PunktfunkStats {
    pub frames_submitted: u64,
    pub frames_completed: u64,
    pub frames_dropped: u64,
    pub packets_sent: u64,
    pub packets_received: u64,
    pub packets_dropped: u64,
    /// Packets dropped on the host send path because the kernel buffer was full (WouldBlock) — the
    /// dominant loss mode at very high bitrate; distinct from `packets_dropped` (recv-side).
    pub packets_send_dropped: u64,
    pub fec_recovered_shards: u64,
    pub bytes_sent: u64,
    pub bytes_received: u64,
}

impl From<Stats> for PunktfunkStats {
    fn from(s: Stats) -> Self {
        PunktfunkStats {
            frames_submitted: s.frames_submitted,
            frames_completed: s.frames_completed,
            frames_dropped: s.frames_dropped,
            packets_sent: s.packets_sent,
            packets_received: s.packets_received,
            packets_dropped: s.packets_dropped,
            packets_send_dropped: s.packets_send_dropped,
            fec_recovered_shards: s.fec_recovered_shards,
            bytes_sent: s.bytes_sent,
            bytes_received: s.bytes_received,
        }
    }
}

/// Host-side callback invoked for each input event drained by `punktfunk_host_poll_input`.
pub type PunktfunkInputCb = extern "C" fn(event: *const InputEvent, user: *mut c_void);

#[inline]
fn guard<F: FnOnce() -> PunktfunkStatus>(f: F) -> PunktfunkStatus {
    std::panic::catch_unwind(AssertUnwindSafe(f)).unwrap_or(PunktfunkStatus::Panic)
}

/// `guard` for the entry points that return nothing — the teardown/mutator calls, which have no
/// status to report a panic through. They still must not let one unwind into C: since Rust 1.81
/// that is a hard abort rather than undefined behaviour, but aborting the CALLER'S process because
/// one of our `Drop` impls hit a poisoned mutex is not an acceptable failure mode for a library.
/// Swallowing is right here — the object is being torn down either way.
fn guard_void<F: FnOnce()>(f: F) {
    if std::panic::catch_unwind(AssertUnwindSafe(f)).is_err() {
        tracing::error!("panic escaped a punktfunk_* teardown entry point; swallowed at the C ABI");
    }
}

fn new_handle(session: Session) -> *mut PunktfunkSession {
    Box::into_raw(Box::new(PunktfunkSession {
        inner: session,
        last_frame: None,
        input_cb: None,
    }))
}

/// Current ABI version. Mismatch with [`crate::ABI_VERSION`] means incompatible core.
#[unsafe(no_mangle)]
pub extern "C" fn punktfunk_abi_version() -> u32 {
    crate::ABI_VERSION
}

/// A log line from the core (ABI v25, [`punktfunk_set_log_callback`]). `level` is 1 = error,
/// 2 = warn, 3 = info, 4 = debug, 5 = trace. `target` is the Rust module path the line came from
/// (`punktfunk_core::transport::udp`, `quinn::connection`, …) and `message` the formatted text;
/// both are NUL-terminated UTF-8, borrowed for the duration of the call only — copy them out.
/// Called from whichever thread logged, so the callback must be thread-safe, must not block for
/// long (it sits on the transport and pump threads), and must not call back into the core's
/// logging (it would be re-entered).
pub type PunktfunkLogCb = Option<
    unsafe extern "C" fn(
        level: u8,
        target: *const c_char,
        message: *const c_char,
        user: *mut c_void,
    ),
>;

#[derive(Clone, Copy)]
struct LogSink {
    cb: unsafe extern "C" fn(u8, *const c_char, *const c_char, *mut c_void),
    user: *mut c_void,
}
// SAFETY: the user pointer is an opaque token handed back to the caller's own callback, which the
// contract above requires to be thread-safe; the core never dereferences it.
unsafe impl Send for LogSink {}

static LOG_SINK: std::sync::Mutex<Option<LogSink>> = std::sync::Mutex::new(None);
static LOG_INSTALLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();

/// The `log` backend behind [`punktfunk_set_log_callback`]: every record the core (and its
/// dependencies that log through `log`, plus its own `tracing` events via tracing's `log` bridge)
/// emits is handed to the registered sink. Installed once; the sink slot is swappable after.
struct CallbackLogger;

impl log::Log for CallbackLogger {
    fn enabled(&self, _: &log::Metadata) -> bool {
        // Level gating is `log::set_max_level`, applied by `punktfunk_set_log_callback`.
        true
    }

    fn log(&self, record: &log::Record) {
        // Copy the sink OUT of the lock before calling it: a callback that logs (it shouldn't, but
        // an embedder's mistake must be a duplicate line, not a deadlock) re-enters `log` cleanly.
        let Some(sink) = *lock_recover(&LOG_SINK) else {
            return;
        };
        let cstr = |s: String| {
            // An interior NUL can't cross as a C string; drop the byte rather than the line.
            let mut bytes = s.into_bytes();
            bytes.retain(|&b| b != 0);
            std::ffi::CString::new(bytes).unwrap_or_default()
        };
        let target = cstr(record.target().to_string());
        let message = cstr(record.args().to_string());
        // SAFETY: the sink was registered through the ABI with exactly this signature; both
        // strings outlive the call (they are locals dropped after it) and are NUL-terminated.
        unsafe {
            (sink.cb)(
                record.level() as u8,
                target.as_ptr(),
                message.as_ptr(),
                sink.user,
            )
        };
    }

    fn flush(&self) {}
}

/// Receive the core's log lines (ABI v25). The core logs through `tracing`; on the desktop and
/// Android shells a subscriber/logger installed by the shell picks those up, but an embedder that
/// installs none (Swift, any C host) saw NOTHING — every transport warning (socket-buffer clamp,
/// QoS refusal), every quinn connection event and every rustls handshake note vanished, and a
/// client log bundle carried the shell's half of the story only. This routes them to `cb`.
///
/// `max_level` is the most verbose level delivered (1 = error … 5 = trace; 0 = nothing) —
/// `log::set_max_level`, so anything above it costs no formatting. 3 (info) is the right default
/// for a field log ring; quinn's debug/trace is per-packet and would churn any bounded ring.
/// `cb == NULL` detaches the sink (lines are dropped again). `user` is handed back on every call.
///
/// Returns `Ok`, or `Unsupported` when another `log` backend is already installed in this
/// process (e.g. the Android shell's `android_logger`) — the core cannot replace it, and that
/// backend already receives everything this one would. Idempotent: call again to change the
/// level or the sink.
///
/// # Safety
/// `cb`, if non-null, must remain a valid function for as long as it is installed (until the next
/// call with NULL), and `user` must stay valid for every call the core may make meanwhile.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_set_log_callback(
    max_level: u8,
    cb: PunktfunkLogCb,
    user: *mut c_void,
) -> PunktfunkStatus {
    guard(|| {
        let installed = *LOG_INSTALLED.get_or_init(|| log::set_logger(&CallbackLogger).is_ok());
        if !installed {
            return PunktfunkStatus::Unsupported;
        }
        *lock_recover(&LOG_SINK) = cb.map(|cb| LogSink { cb, user });
        log::set_max_level(match (cb.is_some(), max_level) {
            (false, _) | (_, 0) => log::LevelFilter::Off,
            (_, 1) => log::LevelFilter::Error,
            (_, 2) => log::LevelFilter::Warn,
            (_, 3) => log::LevelFilter::Info,
            (_, 4) => log::LevelFilter::Debug,
            _ => log::LevelFilter::Trace,
        });
        PunktfunkStatus::Ok
    })
}

/// Send a Wake-on-LAN magic packet to wake sleeping host NIC(s).
///
/// `macs` points to `mac_count` contiguous 6-byte MAC addresses (`mac_count * 6` bytes total) —
/// a host may report several NICs; all are woken. `last_known_ip`, if non-NULL, is an IPv4
/// dotted-quad string additionally targeted by unicast (pass NULL to skip). The packet is
/// broadcast to every local interface's subnet-directed broadcast and to `255.255.255.255` on
/// ports 9 and 7. This does NOT require an open connection and is not part of the QUIC surface.
///
/// Returns `Ok` if at least one datagram was sent. Call off the UI thread.
///
/// # Safety
/// `macs` must point to at least `mac_count * 6` readable bytes. `last_known_ip`, if non-NULL,
/// must be a NUL-terminated string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_wake_on_lan(
    macs: *const u8,
    mac_count: usize,
    last_known_ip: *const c_char,
) -> PunktfunkStatus {
    guard(|| {
        if macs.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        if mac_count == 0 {
            return PunktfunkStatus::InvalidArg;
        }
        // SAFETY: per the ABI contract - a caller-supplied pointer/length pair describing one
        // readable region, borrowed only for this call.
        let bytes = unsafe { std::slice::from_raw_parts(macs, mac_count * 6) };
        let mac_vec: Vec<crate::wol::Mac> = bytes
            .chunks_exact(6)
            .map(|c| {
                let mut m = [0u8; 6];
                m.copy_from_slice(c);
                m
            })
            .collect();
        let ip = if last_known_ip.is_null() {
            None
        } else {
            // SAFETY: per the ABI contract - a caller-supplied C string, NUL-terminated or null,
            // borrowed only for this call.
            match unsafe { CStr::from_ptr(last_known_ip) }
                .to_str()
                .ok()
                .and_then(|s| s.parse::<std::net::Ipv4Addr>().ok())
            {
                Some(ip) => Some(ip),
                None => return PunktfunkStatus::InvalidArg,
            }
        };
        match crate::wol::send_magic_packet(&mac_vec, ip) {
            Ok(()) => PunktfunkStatus::Ok,
            Err(_) => PunktfunkStatus::Io,
        }
    })
}

/// Create a session over a real UDP transport (`local`/`peer` are `host:port` strings).
/// Returns NULL on error.
///
/// # Safety
/// `cfg`, `local`, `peer` must be valid pointers; the strings must be NUL-terminated.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_session_new(
    cfg: *const PunktfunkConfig,
    local: *const c_char,
    peer: *const c_char,
) -> *mut PunktfunkSession {
    let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
        if cfg.is_null() || local.is_null() || peer.is_null() {
            return ptr::null_mut();
        }
        // SAFETY: per the ABI contract - the pointer operands in this block are caller-supplied
        // and are null-checked or handle-validated on this path before they are read.
        let config = match unsafe { config_from_ptr(cfg) } {
            Ok(c) => c,
            Err(_) => return ptr::null_mut(),
        };
        // SAFETY: per the ABI contract - a caller-supplied C string, NUL-terminated or null,
        // borrowed only for this call.
        let local = match unsafe { CStr::from_ptr(local) }.to_str() {
            Ok(s) => s,
            Err(_) => return ptr::null_mut(),
        };
        // SAFETY: per the ABI contract - a caller-supplied C string, NUL-terminated or null,
        // borrowed only for this call.
        let peer = match unsafe { CStr::from_ptr(peer) }.to_str() {
            Ok(s) => s,
            Err(_) => return ptr::null_mut(),
        };
        let transport: Box<dyn Transport> = match UdpTransport::connect(local, peer) {
            Ok(t) => Box::new(t),
            Err(_) => return ptr::null_mut(),
        };
        match Session::new(config, transport) {
            Ok(s) => new_handle(s),
            Err(_) => ptr::null_mut(),
        }
    }));
    result.unwrap_or(ptr::null_mut())
}

/// Create a connected host+client session pair sharing an in-process loopback
/// transport. Test/dev only — exercises the full FEC + framing path without a network.
///
/// # Safety
/// All four pointers must be valid; the two out-params receive owned handles.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_test_loopback_pair(
    host_cfg: *const PunktfunkConfig,
    client_cfg: *const PunktfunkConfig,
    out_host: *mut *mut PunktfunkSession,
    out_client: *mut *mut PunktfunkSession,
) -> PunktfunkStatus {
    guard(|| {
        if host_cfg.is_null() || client_cfg.is_null() || out_host.is_null() || out_client.is_null()
        {
            return PunktfunkStatus::NullPointer;
        }
        // SAFETY: per the ABI contract - the pointer operands in this block are caller-supplied
        // and are null-checked or handle-validated on this path before they are read.
        let hconf = match unsafe { config_from_ptr(host_cfg) } {
            Ok(c) => c,
            Err(s) => return s,
        };
        // SAFETY: per the ABI contract - the pointer operands in this block are caller-supplied
        // and are null-checked or handle-validated on this path before they are read.
        let cconf = match unsafe { config_from_ptr(client_cfg) } {
            Ok(c) => c,
            Err(s) => return s,
        };
        let (ht, ct) = loopback_pair(hconf.loopback_drop_period, cconf.loopback_drop_period);
        let hs = match Session::new(hconf, Box::new(ht)) {
            Ok(s) => s,
            Err(e) => return e.status(),
        };
        let cs = match Session::new(cconf, Box::new(ct)) {
            Ok(s) => s,
            Err(e) => return e.status(),
        };
        // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the matching
        // `#[repr(C)]` type, written once by value.
        unsafe {
            *out_host = new_handle(hs);
            *out_client = new_handle(cs);
        }
        PunktfunkStatus::Ok
    })
}

/// Free a session handle. Safe to call with NULL.
///
/// # Safety
/// `s` must be a handle from `punktfunk_session_new`/`punktfunk_test_loopback_pair`, freed once.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_session_free(s: *mut PunktfunkSession) {
    guard_void(|| {
        if !s.is_null() {
            // SAFETY: per the ABI contract - the pointer operands in this block are caller-supplied
            // and are null-checked or handle-validated on this path before they are read.
            drop(unsafe { Box::from_raw(s) });
        }
    });
}

/// Host: FEC-protect, packetize, seal and send one encoded access unit.
///
/// # Safety
/// `s` is a valid host handle; `data` points to `len` readable bytes (or `len == 0`).
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_host_submit_frame(
    s: *mut PunktfunkSession,
    data: *const u8,
    len: usize,
    pts_ns: u64,
    flags: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let s = match unsafe { s.as_mut() } {
            Some(s) => s,
            None => return PunktfunkStatus::NullPointer,
        };
        if data.is_null() && len != 0 {
            return PunktfunkStatus::NullPointer;
        }
        let slice = if len == 0 {
            &[][..]
        } else {
            // SAFETY: per the ABI contract - a caller-supplied pointer/length pair describing one
            // readable region, borrowed only for this call.
            unsafe { std::slice::from_raw_parts(data, len) }
        };
        match s.inner.submit_frame(slice, pts_ns, flags) {
            Ok(()) => PunktfunkStatus::Ok,
            Err(e) => e.status(),
        }
    })
}

/// Client: poll for the next reassembled access unit. Returns [`PunktfunkStatus::NoFrame`]
/// when nothing is ready yet. On `Ok`, `*out` borrows session memory until the next poll.
///
/// # Safety
/// `s` is a valid client handle; `out` points to a writable `PunktfunkFrame`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_client_poll_frame(
    s: *mut PunktfunkSession,
    out: *mut PunktfunkFrame,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let s = match unsafe { s.as_mut() } {
            Some(s) => s,
            None => return PunktfunkStatus::NullPointer,
        };
        if out.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        match s.inner.poll_frame() {
            Ok(frame) => {
                let f = s.last_frame.insert(frame);
                // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
                // matching `#[repr(C)]` type, written once by value.
                unsafe {
                    *out = PunktfunkFrame {
                        data: f.data.as_ptr(),
                        len: f.data.len(),
                        frame_index: f.frame_index,
                        pts_ns: f.pts_ns,
                        flags: f.flags,
                        received_ns: f.received_ns,
                    };
                }
                PunktfunkStatus::Ok
            }
            Err(e) => e.status(),
        }
    })
}

/// Client: serialize and send one input event to the host.
///
/// Returns `InvalidArg` if `ev->kind` is not a recognized event kind.
///
/// # Safety
/// `s` is a valid client handle; `ev` points to a readable `InputEvent`-sized allocation.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_send_input(
    s: *mut PunktfunkSession,
    ev: *const InputEvent,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let s = match unsafe { s.as_mut() } {
            Some(s) => s,
            None => return PunktfunkStatus::NullPointer,
        };
        // SAFETY: `read_input_event` upholds this file's failures-become-status-codes principle
        // for the one field where a reference formed too early would be UB instead.
        let ev = match unsafe { read_input_event(ev) } {
            Ok(e) => e,
            Err(status) => return status,
        };
        match s.inner.send_input(ev) {
            Ok(()) => PunktfunkStatus::Ok,
            Err(e) => e.status(),
        }
    })
}

/// Validate caller memory as an [`InputEvent`] WITHOUT forming the reference first.
///
/// `InputEvent.kind` is a `#[repr(u8)]` enum with 16 valid discriminants, and a C embedder
/// writing `ev->kind = 42` is not a decodable error once `&InputEvent` exists — forming the
/// reference IS the UB, by the language's validity rule. So the tag is read as a raw byte and
/// validated through the same `InputKind::from_u8` the wire path uses (`input.rs::decode`),
/// and the typed reference comes into existence only afterwards. Every other field is a plain
/// integer (or the `[u8; 3]` pad), valid for any bit pattern.
///
/// # Safety
/// `ev` is null (reported as a status) or readable for `size_of::<InputEvent>()` bytes.
unsafe fn read_input_event<'a>(ev: *const InputEvent) -> Result<&'a InputEvent, PunktfunkStatus> {
    if ev.is_null() {
        return Err(PunktfunkStatus::NullPointer);
    }
    // SAFETY: non-null per the check above, readable per this fn's contract; a one-byte read
    // at offset 0 (the `kind` tag — repr(C) puts it first) cannot itself be UB for any value.
    if crate::input::InputKind::from_u8(unsafe { ev.cast::<u8>().read() }).is_none() {
        return Err(PunktfunkStatus::InvalidArg);
    }
    // SAFETY: non-null, readable, and the discriminant byte was just validated — every field
    // of the repr(C) struct now holds a valid bit pattern for its type.
    Ok(unsafe { &*ev })
}

/// Register the host-side input callback (pass a NULL fn pointer to clear). The callback
/// fires from within [`punktfunk_host_poll_input`], on the calling thread.
///
/// # Safety
/// `s` is a valid host handle; `user` is passed back verbatim to `cb`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_set_input_callback(
    s: *mut PunktfunkSession,
    // Written as an explicit `Option<fn>` (not the `PunktfunkInputCb` alias) so cbindgen
    // emits a nullable C function pointer rather than an opaque wrapper struct.
    cb: Option<extern "C" fn(event: *const InputEvent, user: *mut c_void)>,
    user: *mut c_void,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let s = match unsafe { s.as_mut() } {
            Some(s) => s,
            None => return PunktfunkStatus::NullPointer,
        };
        s.input_cb = cb.map(|c| (c, user));
        PunktfunkStatus::Ok
    })
}

/// Host: drain all pending input events, invoking the registered callback for each.
/// Returns the count dispatched (≥ 0), or a negative [`PunktfunkStatus`] on error.
///
/// # Safety
/// `s` is a valid host handle.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_host_poll_input(s: *mut PunktfunkSession) -> i32 {
    let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
        let mut count = 0i32;
        loop {
            // Narrow scope: re-derive the handle and pull ONE event, then drop the borrow
            // before dispatching. The callback may legally re-enter `punktfunk_*` on this
            // handle (get_stats, send_input, clearing the callback) — with a `&mut` held
            // across the call that re-entry aliased it (UB under noalias). Re-reading
            // `input_cb` per iteration also makes a mid-drain
            // `punktfunk_set_input_callback(s, NULL, NULL)` take effect immediately instead
            // of firing the cleared callback for the queued remainder. (Freeing the session
            // from inside the callback remains forbidden, as on every entry point.)
            let (ev, cb) = {
                // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the
                // caller has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and
                // the `match` here handles.
                let s = match unsafe { s.as_mut() } {
                    Some(s) => s,
                    None => return PunktfunkStatus::NullPointer as i32,
                };
                match s.inner.poll_input() {
                    Ok(Some(ev)) => (ev, s.input_cb),
                    Ok(None) => break,
                    Err(e) => return e.status() as i32,
                }
            };
            if let Some((cb, user)) = cb {
                cb(&ev as *const InputEvent, user);
            }
            count += 1;
        }
        count
    }));
    r.unwrap_or(PunktfunkStatus::Panic as i32)
}

/// Copy session counters into `*out`.
///
/// # Safety
/// `s` is a valid handle; `out` points to a writable `PunktfunkStats`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_get_stats(
    s: *mut PunktfunkSession,
    out: *mut PunktfunkStats,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let s = match unsafe { s.as_ref() } {
            Some(s) => s,
            None => return PunktfunkStatus::NullPointer,
        };
        if out.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        let stats = s.inner.stats();
        // SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path, written
        // once by value.
        unsafe { *out = PunktfunkStats::from(stats) };
        PunktfunkStatus::Ok
    })
}

// ---------------------------------------------------------------------------------------------
// punktfunk/1 connection API (`quic` feature) — the embeddable client connector platform clients
// link (SwiftUI/VideoToolbox, Android, …). In the generated header these are guarded by
// `PUNKTFUNK_FEATURE_QUIC`; define it when linking a punktfunk-core built with `--features quic`.
// ---------------------------------------------------------------------------------------------

/// Opaque handle to a live `punktfunk/1` connection (QUIC control plane + UDP data plane, all
/// pumped on internal threads).
///
/// Thread contract: each plane (video `next_au`, audio `next_audio`, rumble `next_rumble`)
/// may be pulled from its own thread, at most one thread per plane. The accessors only
/// take shared references internally (per-plane mutexed borrow slots), so cross-plane
/// concurrency is sound — never two threads on the *same* plane.
#[cfg(feature = "quic")]
pub struct PunktfunkConnection {
    inner: crate::client::NativeClient,
    /// Backs the pointer returned by the last `punktfunk_connection_next_au` (borrow-until-next-call).
    last: std::sync::Mutex<Option<crate::session::Frame>>,
    /// Same, for `punktfunk_connection_next_audio` (independent of the video slot).
    last_audio: std::sync::Mutex<Option<crate::client::AudioPacket>>,
    /// Decode-in-core state for `punktfunk_connection_next_audio_pcm` (Apple / any embedder
    /// without a multistream Opus decoder). The decoder is built lazily from the negotiated
    /// `inner.audio_channels`; `pcm` is a fixed-capacity reusable buffer the returned pointer
    /// borrows until the next PCM call (same contract as `last_audio`).
    audio_pcm: std::sync::Mutex<AudioPcmState>,
    /// Backs the `data`/`len` pointer of the last `punktfunk_connection_next_clipboard` event
    /// (a fetched payload, an offer's format list, or a fetch-request's MIME) —
    /// borrow-until-next-call, same contract as `last`.
    last_clip: std::sync::Mutex<Option<Vec<u8>>>,
    /// The last cursor shape handed out — `next_cursor_shape`'s `rgba` pointer borrows it
    /// until the next cursor-shape call (the `last_audio` contract).
    last_cursor_shape: std::sync::Mutex<Option<crate::quic::CursorShape>>,
}

/// The session's resolved audio format, as the in-core decode path needs it.
///
/// Gathered into one value rather than threaded as four more parameters because the two decode
/// entry points below take it whole, and because the four fields are only meaningful together: a
/// rate without the codec cannot tell a 48 kHz PCM session from a 48 kHz Opus one.
///
/// Read fresh off the connection on every call, which is free and cannot drift: the format is
/// resolved once at handshake and the host never changes it mid-session (the client's output
/// device is open at a fixed format, so a change would mean a re-open).
#[cfg(feature = "quic")]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct AudioFormat {
    /// [`crate::quic::AUDIO_CODEC_OPUS`] (`0xC9`) or [`crate::quic::AUDIO_CODEC_PCM`] (`0xD3`) —
    /// what SELECTS the decoder below, and the only field that can.
    codec: u8,
    /// The resolved sample rate. 48 000 on every Opus session; any rate
    /// [`crate::audio::pcm::rate_is_supported`] admits on the lossless plane.
    rate_hz: u32,
    /// The resolved sample depth (16 or 24) — the stride `0xD3` payloads are unpacked at.
    /// Meaningless on the Opus plane, which decodes to f32 regardless.
    bits: u8,
    /// The resolved channel count, already through [`crate::audio::normalize_channels`].
    channels: u8,
    /// One frame in microseconds: the negotiated `audio_frame_us` on the lossless plane, the
    /// `0xC9` plane's fixed 5 ms otherwise. Carried because the concealment CAP is a duration
    /// (`crate::audio::MAX_CONCEAL_MS`) and only this turns it into the frame count `ensure_buffer`
    /// has to leave room for — the two must be computed from the same number or the cap can outrun
    /// the buffer.
    frame_us: u32,
}

#[cfg(feature = "quic")]
impl AudioFormat {
    fn of(c: &crate::client::NativeClient) -> AudioFormat {
        AudioFormat {
            codec: c.audio_codec,
            // A zero rate is inexpressible off the wire — `Welcome::decode` folds both absence
            // and a literal 0 to the legacy 48 kHz — but the buffer sizing below divides by
            // 1000 and uses "buffer is non-empty" as its already-sized latch, so a 0 here would
            // mean a zero-length buffer that re-sizes (and so reallocates) on every packet.
            // That is the one invariant on this type that must not depend on a peer's honesty.
            rate_hz: if c.audio_sample_rate_hz == 0 {
                crate::audio::SAMPLE_RATE_HZ
            } else {
                c.audio_sample_rate_hz
            },
            bits: c.audio_bits,
            channels: crate::audio::normalize_channels(c.audio_channels),
            // `audio_frame_us` is `0` on an Opus session and from any host older than the
            // lossless plane, which is not a frame length — fold it to the `0xC9` plane's fixed
            // 5 ms so the concealment cap below is computed against something real either way.
            frame_us: if c.audio_frame_us == 0 {
                crate::audio::FRAME_MS * 1000
            } else {
                c.audio_frame_us as u32
            },
        }
    }

    /// True when this session runs the lossless `0xD3` plane rather than Opus on `0xC9`.
    fn is_pcm(&self) -> bool {
        self.codec == crate::quic::AUDIO_CODEC_PCM
    }
}

/// Lazily-initialized in-core decode state, for either audio plane.
///
/// On Opus (`0xC9`) a coupled-1-stream multistream decoder is equivalent to a plain stereo
/// decoder, so one [`opus::MSDecoder`] handles 2/6/8 channels. On the lossless plane (`0xD3`)
/// there is no decoder at all — [`crate::audio::pcm::to_f32`] unpacks the samples — and no
/// libopus PLC either, which is why [`crate::audio::pcm::PcmConceal`] rides alongside.
#[cfg(feature = "quic")]
#[derive(Default)]
struct AudioPcmState {
    decoder: Option<opus::MSDecoder>,
    /// Interleaved f32 PCM, wire channel order. Pre-sized ONCE by `ensure_buffer` for the largest
    /// frame its plane can present plus a full concealment run, so decode never reallocates
    /// (which would dangle the pointer handed to the embedder).
    pcm: Vec<f32>,
    /// Loss detector — the same seq-gap accounting the other clients run in their own decode
    /// loops (`pf-client-core`'s session pump, Android's native pump), here for the one decoder
    /// that lives in core. Without it a lost 5 ms packet reaches the embedder's playout ring as
    /// a hard time-domain gap: a click per loss, sustained crackle on lossy Wi-Fi.
    gaps: crate::audio::AudioGapTracker,
    /// Per-channel sample count of the last real decode — sizes each synthesized concealment
    /// frame. 0 until the first decode, which skips concealment (nothing to size it from),
    /// exactly like the other clients.
    frame_samples: usize,
    /// Frames synthesized for a packet DROUGHT since the last real packet
    /// ([`punktfunk_connection_audio_plc`]), subtracted from the loss concealment the next packet
    /// asks for: a packet genuinely lost inside a drought this already covered must not be covered
    /// twice, which would insert audio the stream never carried and push everything after it
    /// later. The subtraction has to happen HERE — the gap tracker is on this side of the ABI, so
    /// the embedder driving the drought cannot see what it is about to be charged for.
    drought_frames: u32,
    /// Concealment for the lossless plane, where libopus PLC does not exist and cannot: there is
    /// nothing in a raw frame from which to synthesize its successor (`design/hi-res-audio.md`
    /// §4.5). Unused on an Opus session, which keeps using the decoder's own PLC.
    conceal_pcm: crate::audio::pcm::PcmConceal,
    /// Staging for one `0xD3` frame — decoded into here, then COPIED into `pcm` at the right
    /// offset. The indirection is what preserves the no-realloc invariant:
    /// [`crate::audio::pcm::to_f32`] clears and reserves its output, either of which would move
    /// `pcm` out from under the pointer the embedder is still holding, and it always writes from
    /// index 0 — where a concealed frame may already be sitting.
    scratch_pcm: Vec<f32>,
}

#[cfg(feature = "quic")]
impl AudioPcmState {
    /// Size `pcm` once, from the negotiated format, and never again.
    ///
    /// ⚠ **The buffer must NEVER be reallocated after this.** The embedder is handed a raw
    /// pointer INTO it by [`punktfunk_connection_next_audio_pcm`] and reads it until its next
    /// call, so a later `Vec` growth would leave that pointer dangling — a use-after-free the
    /// embedder cannot see coming and which would not reproduce on a machine whose allocator
    /// happened to grow in place. Everything downstream therefore writes into a fixed-length
    /// slice and CLAMPS to it rather than extending: `decode_float` is given a bounded
    /// subslice, and the PCM path copies through `scratch_pcm`.
    ///
    /// "Already sized" is `!pcm.is_empty()`, which is why [`AudioFormat::of`] refuses to produce
    /// a zero rate: a zero-length buffer would re-enter this function on every packet.
    fn ensure_buffer(&mut self, fmt: AudioFormat) {
        if !self.pcm.is_empty() {
            return;
        }
        let ch = fmt.channels.max(1) as usize;
        // Per-channel samples in the largest frame this plane can ever hand out. The two planes
        // differ by 24×, so they are sized separately rather than both to the Opus worst case:
        //
        // - Opus: the largest legal frame is 120 ms (5760 samples/ch at 48 kHz).
        // - `0xD3`: the longest rung of `FRAME_US_LADDER`, because the frame duration is chosen
        //   from the path MTU at session start and the plane is never fragmented, so nothing
        //   longer can arrive from a conforming host. (A non-conforming one is bounded anyway —
        //   every copy into `pcm` below is clamped to what is left.)
        let per_ch = if fmt.is_pcm() {
            crate::audio::pcm::samples_per_frame(
                fmt.rate_hz,
                crate::audio::pcm::FRAME_US_LADDER[0],
                1,
            )
        } else {
            fmt.rate_hz as usize / 1000 * 120
        };
        // A gap can owe a whole concealment run of frames in front of the real one, and they are
        // handed out as one contiguous buffer.
        //
        // ⚠ The COUNT comes from the session's resolved frame (the cap is 50 ms of audio, so a
        // 2 ms frame owes 25 of them, not 10) while the SIZE comes from the longest rung the
        // ladder can offer. Deliberately mismatched, and only in the safe direction: sizing the
        // frames from `fmt.frame_us` too would be tighter, but it would make a host that
        // negotiated 2 ms and then sent 5 ms truncate audio instead of merely wasting memory, and
        // this buffer can never be grown to recover from that.
        let run = crate::audio::max_conceal_packets(fmt.frame_us) as usize;
        self.pcm = vec![0f32; (1 + run) * per_ch.max(1) * ch];
        // The tracker must cap at exactly the run this buffer was sized for. It is set HERE, under
        // the same one-shot latch, because a cap that outran the buffer would be silently
        // truncated frames — and a buffer sized for a run the tracker never asks for is dead
        // memory nobody would notice.
        self.gaps.set_frame_us(fmt.frame_us);
    }

    /// Copy `n` samples of `scratch_pcm` into `pcm` at `filled`, returning how many actually
    /// landed. The clamp is load-bearing, not defensive tidiness: it is what makes an oversized
    /// or malformed datagram a truncated frame instead of a buffer overrun or a reallocation.
    fn stage_scratch(&mut self, filled: usize, n: usize) -> usize {
        let n = n.min(self.scratch_pcm.len()).min(self.pcm.len() - filled);
        self.pcm[filled..filled + n].copy_from_slice(&self.scratch_pcm[..n]);
        n
    }

    /// The lossless (`0xD3`) half of [`decode_packet`](Self::decode_packet): unpack interleaved
    /// LE samples, with [`crate::audio::pcm::PcmConceal`] standing in for the PLC a lossless
    /// format cannot have. Shape-for-shape the Opus path — concealed frames first, the real one
    /// after, one contiguous buffer, same return contract — so the ABI above needs no branch.
    fn decode_pcm_packet(
        &mut self,
        data: &[u8],
        seq: u32,
        fmt: AudioFormat,
    ) -> Result<usize, PunktfunkStatus> {
        let ch = fmt.channels.max(1) as usize;
        self.ensure_buffer(fmt);

        // Same accounting as the Opus path, including the drought credit: whatever the embedder
        // already covered while the wire was quiet comes off the top.
        let missing = self
            .gaps
            .missing_before(seq)
            .saturating_sub(std::mem::take(&mut self.drought_frames));
        let mut filled = 0usize;
        for _ in 0..missing {
            if !self.conceal_pcm.conceal(&mut self.scratch_pcm) {
                break; // nothing has arrived yet — nothing to build a repeat from
            }
            let n = self.scratch_pcm.len();
            let staged = self.stage_scratch(filled, n);
            filled += staged;
            if staged < n {
                break; // buffer full: stop rather than emit a torn frame
            }
        }

        if data.is_empty() {
            // No host emits an empty `0xD3` payload — PCM has no DTX — but a torn datagram can
            // present as one, and it must NOT reach `PcmConceal::accept`: accepting an empty
            // frame would clear the last good frame and leave the next loss with nothing to
            // conceal from. Treated exactly like the Opus DTX marker: the slot is accounted (so
            // it is never itself "concealed" later) and any concealment owed still goes out.
            return Ok(filled);
        }
        match crate::audio::pcm::to_f32(data, fmt.bits, &mut self.scratch_pcm) {
            Some(n) => {
                let staged = self.stage_scratch(filled, n);
                // The next loss is concealed from what the embedder actually HEARD — the staged
                // frame, not the decoded one. They differ only for an oversized datagram no
                // conforming host sends, and taking the staged length there keeps the
                // concealment source bounded by the same fixed buffer as everything else.
                self.conceal_pcm.accept(&self.scratch_pcm[..staged]);
                self.frame_samples = staged / ch;
                Ok(filled + staged)
            }
            // Not a whole number of samples at this depth — a truncated or hostile datagram.
            // Same disposal as an undecodable Opus packet: hand out the concealment the gap
            // before it earned rather than dropping that with the packet.
            None if filled > 0 => Ok(filled),
            None => Err(PunktfunkStatus::BadPacket),
        }
    }

    /// Decode one arriving audio packet into `self.pcm`, synthesizing packet-loss concealment for
    /// any packets the sequence says went missing immediately before it — the concealed frames
    /// land first, the real frame after, one contiguous interleaved buffer.
    ///
    /// `fmt` selects the plane: Opus off `0xC9` (libopus, and libopus PLC for the gaps) or
    /// lossless PCM off `0xD3` (a stride unpack, and [`crate::audio::pcm::PcmConceal`] for the
    /// gaps — a lossless format has no PLC to borrow). The return contract is identical either
    /// way, so the C surface above never branches on the plane.
    ///
    /// Returns the interleaved sample count now valid at the front of `pcm`; `Ok(0)` means
    /// nothing to hand out this call (a DTX silence marker with no loss before it). An empty
    /// `data` is the DTX marker: it still advances the loss accounting (so the silent slot is
    /// never itself "concealed" later) and flushes any concealment owed, but is never decoded —
    /// `decode_float` would treat it as a loss and synthesize the buffer's full capacity.
    fn decode_packet(
        &mut self,
        data: &[u8],
        seq: u32,
        fmt: AudioFormat,
    ) -> Result<usize, PunktfunkStatus> {
        if fmt.is_pcm() {
            return self.decode_pcm_packet(data, seq, fmt);
        }
        let channels = fmt.channels;
        let ch = channels as usize;
        if self.decoder.is_none() {
            let layout = crate::audio::layout_for(channels, false);
            // The negotiated rate, not a constant. On this plane it is always 48 000 — libopus
            // accepts only 8/12/16/24/48 kHz and rejects 96 000 outright, which is the entire
            // reason `0xD3` exists — so passing it through costs nothing and makes libopus itself
            // the validator: a host that claimed Opus at a rate libopus cannot open fails loudly
            // here instead of quietly decoding at the wrong one.
            match opus::MSDecoder::new(fmt.rate_hz, layout.streams, layout.coupled, layout.mapping)
            {
                Ok(d) => {
                    self.ensure_buffer(fmt);
                    self.decoder = Some(d);
                }
                Err(_) => return Err(PunktfunkStatus::Unsupported),
            }
        }
        let dec = self.decoder.as_mut().unwrap();

        // Conceal lost packets (a seq gap) before decoding the one that arrived: empty input
        // synthesizes `frame_samples` of interpolation per missing packet — an inaudible fade
        // instead of the click a hard gap makes in the ring. Mirrors the Linux/Windows session
        // pump and the Android native pump; capped by the tracker at 50 ms. Whatever a drought
        // already covered comes off the top — that audio is in the embedder's ring already.
        let missing = self
            .gaps
            .missing_before(seq)
            .saturating_sub(std::mem::take(&mut self.drought_frames));
        let mut filled = 0usize;
        if self.frame_samples > 0 {
            for _ in 0..missing {
                let plc = self.frame_samples * ch;
                match dec.decode_float(&[], &mut self.pcm[filled..filled + plc], false) {
                    Ok(samples) => filled += samples * ch,
                    Err(_) => break,
                }
            }
        }

        if data.is_empty() {
            // DTX silence marker (a legal wire form) — never decoded (see above); the sink
            // underruns to silence on its own. Concealment owed for losses before it still
            // goes out.
            return Ok(filled);
        }
        match dec.decode_float(data, &mut self.pcm[filled..], false) {
            Ok(samples) => {
                self.frame_samples = samples;
                Ok(filled + samples * ch)
            }
            // An undecodable packet: hand out whatever concealment the gap before it earned
            // rather than dropping it with the packet. Its own 5 ms slot plays as a ring gap,
            // as on every other client (the tracker has already anchored at this seq).
            Err(_) if filled > 0 => Ok(filled),
            Err(_) => Err(PunktfunkStatus::BadPacket),
        }
    }

    /// Synthesize ONE concealment frame with no packet involved — the drought half of concealment
    /// (see [`punktfunk_connection_audio_plc`] for what asks for it and why).
    ///
    /// Returns the interleaved sample count now valid at the front of `pcm`, or `Ok(0)` when
    /// nothing has decoded yet: both concealers extrapolate from the LAST decoded frame, so
    /// before there is one there is neither state to extrapolate from nor a frame size to ask for.
    fn conceal(&mut self, fmt: AudioFormat) -> Result<usize, PunktfunkStatus> {
        if fmt.is_pcm() {
            // `PcmConceal`, never libopus PLC. There is no decoder on this plane to ask, and even
            // if one were built it would be extrapolating from nothing: a lossless frame carries
            // no model of the signal, only the signal. `conceal` returns false before the first
            // real frame — the same "nothing to hand out" the Opus arm reports below.
            if !self.conceal_pcm.conceal(&mut self.scratch_pcm) {
                return Ok(0);
            }
            let n = self.scratch_pcm.len();
            let staged = self.stage_scratch(0, n);
            if staged == 0 {
                return Ok(0);
            }
            self.drought_frames = self.drought_frames.saturating_add(1);
            return Ok(staged);
        }
        let ch = fmt.channels as usize;
        let plc = self.frame_samples * ch;
        if plc == 0 {
            return Ok(0);
        }
        let Some(dec) = self.decoder.as_mut() else {
            return Ok(0);
        };
        match dec.decode_float(&[], &mut self.pcm[..plc], false) {
            Ok(samples) => {
                self.drought_frames = self.drought_frames.saturating_add(1);
                Ok(samples * ch)
            }
            // libopus declined to interpolate. Nothing to hand out, and the caller's response is
            // a timeout's: write nothing and let its ring's own underrun path have the drought.
            Err(_) => Ok(0),
        }
    }
}

/// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid).
pub const PUNKTFUNK_HIDOUT_LED: u8 = 1;
/// `PunktfunkHidOutput::kind` — player-indicator LEDs (`player_bits` valid, low 5 bits).
pub const PUNKTFUNK_HIDOUT_PLAYER_LEDS: u8 = 2;
/// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid).
pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
/// `PunktfunkHidOutput::kind` — a trackpad haptic pulse (Steam Controller voice-coils). `which` =
/// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
/// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4;
/// `PunktfunkHidOutput::kind` — the audio-control region of a DS5 output report (pad-audio
/// routing/volumes; the audio SAMPLES arrive via [`punktfunk_connection_next_pad_audio`]).
/// `which` = the condensed audio flags (bit0 = haptics-select, bits1..4 = the report's
/// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim
/// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only.
pub const PUNKTFUNK_HIDOUT_AUDIO_CTL: u8 = 5;
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;

/// One DualSense HID-output feedback event a game wrote to the host's virtual pad
/// ([`punktfunk_connection_next_hidout`]). `kind` selects which fields are meaningful — replay it
/// on a real DualSense (lightbar color, player LEDs, or an adaptive-trigger effect via the
/// platform's `GCDualSenseAdaptiveTrigger`-style API).
#[cfg(feature = "quic")]
#[repr(C)]
#[derive(Clone, Copy)]
pub struct PunktfunkHidOutput {
    /// One of `PUNKTFUNK_HIDOUT_*`.
    pub kind: u8,
    /// Gamepad index.
    pub pad: u8,
    /// LED: lightbar red.
    pub r: u8,
    /// LED: lightbar green.
    pub g: u8,
    /// LED: lightbar blue.
    pub b: u8,
    /// PlayerLeds: lit player indicators (low 5 bits).
    pub player_bits: u8,
    /// Trigger: 0 = L2, 1 = R2.
    pub which: u8,
    /// Trigger: number of valid bytes in `effect` (≤ `PUNKTFUNK_HID_EFFECT_MAX`).
    pub effect_len: u8,
    /// Trigger: the raw DualSense trigger parameter block (mode + params).
    /// Sized off [`PUNKTFUNK_HID_EFFECT_MAX`] rather than a second literal `11` — the constant is
    /// exported precisely so embedders can size their own buffers against it, and it declaring one
    /// number while the struct it describes hardcoded another was the whole hazard.
    pub effect: [u8; PUNKTFUNK_HID_EFFECT_MAX as usize],
}

#[cfg(feature = "quic")]
impl PunktfunkHidOutput {
    /// `None` for a [`HidOutput::HidRaw`](crate::quic::HidOutput) — a raw passthrough report
    /// (up to 64 bytes) doesn't fit this struct's 11-byte `effect` buffer, and no C-ABI embedder
    /// declares the as-is SC2 kind that would receive one; the pull site skips it rather than
    /// truncating it into an unreplayable stub.
    fn from_hid(h: &crate::quic::HidOutput) -> Option<PunktfunkHidOutput> {
        use crate::quic::HidOutput;
        let mut out = PunktfunkHidOutput {
            kind: 0,
            pad: 0,
            r: 0,
            g: 0,
            b: 0,
            player_bits: 0,
            which: 0,
            effect_len: 0,
            effect: [0u8; 11],
        };
        match h {
            HidOutput::Led { pad, r, g, b } => {
                out.kind = PUNKTFUNK_HIDOUT_LED;
                out.pad = *pad;
                out.r = *r;
                out.g = *g;
                out.b = *b;
            }
            HidOutput::PlayerLeds { pad, bits } => {
                out.kind = PUNKTFUNK_HIDOUT_PLAYER_LEDS;
                out.pad = *pad;
                out.player_bits = *bits;
            }
            HidOutput::Trigger { pad, which, effect } => {
                out.kind = PUNKTFUNK_HIDOUT_TRIGGER;
                out.pad = *pad;
                out.which = *which;
                let n = effect.len().min(out.effect.len());
                out.effect[..n].copy_from_slice(&effect[..n]);
                out.effect_len = n as u8;
            }
            HidOutput::TrackpadHaptic {
                pad,
                side,
                amplitude,
                period,
                count,
            } => {
                // No new struct (PunktfunkHidOutput has no size guard): pack into the existing
                // `which` (side) + `effect[0..6]` (amplitude/period/count LE), `effect_len = 6`.
                out.kind = PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC;
                out.pad = *pad;
                out.which = *side;
                out.effect[0..2].copy_from_slice(&amplitude.to_le_bytes());
                out.effect[2..4].copy_from_slice(&period.to_le_bytes());
                out.effect[4..6].copy_from_slice(&count.to_le_bytes());
                out.effect_len = 6;
            }
            HidOutput::HidRaw { .. } => return None,
            HidOutput::AudioCtl { pad, flags, raw } => {
                // Same packing idiom as TrackpadHaptic: `which` carries the flags byte,
                // `effect[0..6]` the raw audio region. The u16 wire pad narrows losslessly
                // because `HidOutput::decode` refuses one at or above `input::MAX_PADS` (B27) —
                // it is enforced there, not merely assumed here.
                out.kind = PUNKTFUNK_HIDOUT_AUDIO_CTL;
                out.pad = *pad as u8;
                out.which = *flags;
                out.effect[0..6].copy_from_slice(raw);
                out.effect_len = 6;
            }
        }
        Some(out)
    }
}

/// Static HDR metadata for an HDR session ([`punktfunk_connection_next_hdr_meta`]): SMPTE ST.2086
/// mastering display colour volume + CEA-861.3 content light level. All fields are in the standard
/// HDR10 SEI fixed-point units (primaries/white in 1/50000, luminance in 0.0001 cd/m²), ready for
/// DXGI `DXGI_HDR_METADATA_HDR10` / Apple `CAEDRMetadata` / Android `KEY_HDR_STATIC_INFO`.
#[cfg(feature = "quic")]
#[repr(C)]
#[derive(Clone, Copy)]
pub struct PunktfunkHdrMeta {
    /// Display-primaries x-chromaticities in 1/50000 units, ST.2086 order [green, blue, red].
    pub display_primaries_x: [u16; 3],
    /// Display-primaries y-chromaticities in 1/50000 units, ST.2086 order [green, blue, red].
    pub display_primaries_y: [u16; 3],
    /// White-point x-chromaticity, 1/50000 units.
    pub white_point_x: u16,
    /// White-point y-chromaticity, 1/50000 units.
    pub white_point_y: u16,
    /// Max display mastering luminance, 0.0001 cd/m² units.
    pub max_display_mastering_luminance: u32,
    /// Min display mastering luminance, 0.0001 cd/m² units.
    pub min_display_mastering_luminance: u32,
    /// Maximum content light level (MaxCLL), nits. 0 = unknown.
    pub max_cll: u16,
    /// Maximum frame-average light level (MaxFALL), nits. 0 = unknown.
    pub max_fall: u16,
}

#[cfg(feature = "quic")]
impl PunktfunkHdrMeta {
    fn from_meta(m: &crate::quic::HdrMeta) -> PunktfunkHdrMeta {
        PunktfunkHdrMeta {
            display_primaries_x: [
                m.display_primaries[0][0],
                m.display_primaries[1][0],
                m.display_primaries[2][0],
            ],
            display_primaries_y: [
                m.display_primaries[0][1],
                m.display_primaries[1][1],
                m.display_primaries[2][1],
            ],
            white_point_x: m.white_point[0],
            white_point_y: m.white_point[1],
            max_display_mastering_luminance: m.max_display_mastering_luminance,
            min_display_mastering_luminance: m.min_display_mastering_luminance,
            max_cll: m.max_cll,
            max_fall: m.max_fall,
        }
    }
}

/// One access unit's host-side processing time ([`punktfunk_connection_next_host_timing`]):
/// capture → fully sent, i.e. the whole host pipeline (capture read/convert, encode, FEC+seal,
/// paced send). Correlate to the AU whose `PunktfunkFrame::pts_ns` equals `pts_ns`, then
/// `network = (received_instant + clock_offset − pts_ns) − host_us` — the unified stats HUD's
/// `host` / `network` split (design/stats-unification.md Phase 2). Best-effort: a lost datagram
/// means that frame simply contributes no sample.
#[cfg(feature = "quic")]
#[repr(C)]
#[derive(Clone, Copy)]
pub struct PunktfunkHostTiming {
    /// The AU's capture stamp (host capture clock — matches `PunktfunkFrame::pts_ns` exactly).
    pub pts_ns: u64,
    /// Host capture→sent duration, µs.
    pub host_us: u32,
}

/// `PunktfunkRichInput::kind` — a touchpad contact (`finger`/`active`/`x`/`y` valid).
pub const PUNKTFUNK_RICH_TOUCHPAD: u8 = 1;
/// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid).
pub const PUNKTFUNK_RICH_MOTION: u8 = 2;
/// `RichInput::TouchpadEx` kind on the wire — an extended trackpad contact that identifies the
/// surface (0 single / 1 Steam-left / 2 Steam-right) and carries click + pressure. The host decodes
/// it today; *sending* it from a C client needs the size-prefixed `PunktfunkRichInputEx` +
/// `punktfunk_connection_send_rich_input2` (added with client capture).
pub const PUNKTFUNK_RICH_TOUCHPAD_EX: u8 = 3;

/// One rich client→host input for the host's virtual DualSense
/// ([`punktfunk_connection_send_rich_input`]): a touchpad contact or a motion sample. Set `kind`
/// and the matching fields; the others are ignored.
#[cfg(feature = "quic")]
#[repr(C)]
#[derive(Clone, Copy)]
pub struct PunktfunkRichInput {
    /// One of `PUNKTFUNK_RICH_*`.
    pub kind: u8,
    /// Gamepad index.
    pub pad: u8,
    /// Touchpad: contact id (0 or 1).
    pub finger: u8,
    /// Touchpad: 1 = finger down, 0 = lifted.
    pub active: u8,
    /// Touchpad: normalized x, 0..=65535 across the touchpad.
    pub x: u16,
    /// Touchpad: normalized y, 0..=65535 across the touchpad.
    pub y: u16,
    /// Motion: gyro (pitch, yaw, roll), raw signed-16.
    pub gyro: [i16; 3],
    /// Motion: accelerometer (x, y, z), raw signed-16.
    pub accel: [i16; 3],
}

#[cfg(feature = "quic")]
impl PunktfunkRichInput {
    fn to_rich(self) -> Option<crate::quic::RichInput> {
        use crate::quic::RichInput;
        match self.kind {
            PUNKTFUNK_RICH_TOUCHPAD => Some(RichInput::Touchpad {
                pad: self.pad,
                finger: self.finger,
                active: self.active != 0,
                x: self.x,
                y: self.y,
            }),
            PUNKTFUNK_RICH_MOTION => Some(RichInput::Motion {
                pad: self.pad,
                gyro: self.gyro,
                accel: self.accel,
            }),
            _ => None,
        }
    }
}

/// Forward-compatible superset of [`PunktfunkRichInput`] that can also express the rich Steam
/// surfaces: a *second* trackpad (`surface`), a distinct `click` vs touch, signed coordinates, and
/// pressure. Sent via [`punktfunk_connection_send_rich_input2`] — the only way a C client can emit a
/// `TouchpadEx`. The caller MUST set `struct_size = sizeof(PunktfunkRichInputEx)` (the ABI-skew
/// guard, like [`PunktfunkConfig`]); the legacy [`PunktfunkRichInput`] +
/// [`punktfunk_connection_send_rich_input`] stay byte-for-byte for existing callers.
#[cfg(feature = "quic")]
#[repr(C)]
#[derive(Clone, Copy)]
pub struct PunktfunkRichInputEx {
    /// MUST equal `sizeof(PunktfunkRichInputEx)`.
    pub struct_size: u32,
    /// One of `PUNKTFUNK_RICH_*` (`TOUCHPAD` / `MOTION` / `TOUCHPAD_EX`).
    pub kind: u8,
    /// Gamepad index.
    pub pad: u8,
    /// Touchpad/TouchpadEx: contact id.
    pub finger: u8,
    /// Touchpad/TouchpadEx: 1 = finger down / touching, 0 = lifted.
    pub active: u8,
    /// TouchpadEx: which surface — 0 = single/DualSense, 1 = Steam left pad, 2 = Steam right pad.
    pub surface: u8,
    /// TouchpadEx: 1 = the pad is physically clicked (depressed), distinct from a touch contact.
    pub click: u8,
    /// Reserved for alignment; set to 0.
    pub _reserved: [u8; 2],
    /// TouchpadEx: x coordinate — **signed**, centred at 0 (the real Steam report convention). For a
    /// legacy `TOUCHPAD` kind sent through this struct, store the unsigned `0..=65535` value's bits.
    pub x: i16,
    /// TouchpadEx: y coordinate — signed, centred at 0.
    pub y: i16,
    /// TouchpadEx: contact pressure (`0` if the surface has no force sensor).
    pub pressure: u16,
    /// Motion: gyro (pitch, yaw, roll), raw signed-16.
    pub gyro: [i16; 3],
    /// Motion: accelerometer (x, y, z), raw signed-16.
    pub accel: [i16; 3],
}

#[cfg(feature = "quic")]
impl PunktfunkRichInputEx {
    fn to_rich(self) -> Option<crate::quic::RichInput> {
        use crate::quic::RichInput;
        match self.kind {
            PUNKTFUNK_RICH_TOUCHPAD_EX => Some(RichInput::TouchpadEx {
                pad: self.pad,
                surface: self.surface,
                finger: self.finger,
                touch: self.active != 0,
                click: self.click != 0,
                x: self.x,
                y: self.y,
                pressure: self.pressure,
            }),
            PUNKTFUNK_RICH_MOTION => Some(RichInput::Motion {
                pad: self.pad,
                gyro: self.gyro,
                accel: self.accel,
            }),
            PUNKTFUNK_RICH_TOUCHPAD => Some(RichInput::Touchpad {
                pad: self.pad,
                finger: self.finger,
                active: self.active != 0,
                x: self.x as u16,
                y: self.y as u16,
            }),
            _ => None,
        }
    }
}

/// [`PunktfunkPenSample::state`] bit: the pen hovers in range (implied by `TOUCHING`).
pub const PUNKTFUNK_PEN_IN_RANGE: u8 = 0x01;
/// [`PunktfunkPenSample::state`] bit: the tip is in contact.
pub const PUNKTFUNK_PEN_TOUCHING: u8 = 0x02;
/// [`PunktfunkPenSample::state`] bit: primary barrel button (or squeeze mapping) held.
pub const PUNKTFUNK_PEN_BARREL1: u8 = 0x04;
/// [`PunktfunkPenSample::state`] bit: secondary barrel button (or double-tap mapping) held.
pub const PUNKTFUNK_PEN_BARREL2: u8 = 0x08;
/// [`PunktfunkPenSample::tool`]: the pen tip.
pub const PUNKTFUNK_PEN_TOOL_PEN: u8 = 0;
/// [`PunktfunkPenSample::tool`]: the eraser (a client-side mode — Apple Pencil has no
/// hardware eraser end; the squeeze/double-tap mapping usually drives this).
pub const PUNKTFUNK_PEN_TOOL_ERASER: u8 = 1;
/// Most samples one [`punktfunk_connection_send_pen`] call accepts (one wire batch).
pub const PUNKTFUNK_PEN_BATCH_MAX: u32 = 8;
/// [`PunktfunkPenSample::tilt_deg`] sentinel: no tilt reading.
pub const PUNKTFUNK_PEN_TILT_UNKNOWN: u8 = 0xFF;
/// [`PunktfunkPenSample::azimuth_deg`] / `roll_deg` sentinel: no reading.
pub const PUNKTFUNK_PEN_ANGLE_UNKNOWN: u16 = 0xFFFF;
/// [`PunktfunkPenSample::distance`] sentinel: no hover-distance reading.
pub const PUNKTFUNK_PEN_DISTANCE_UNKNOWN: u16 = 0xFFFF;

/// One complete stylus state at one instant ([`punktfunk_connection_send_pen`];
/// design/pen-tablet-input.md). STATE-FULL, never an edge event: fill every field on every
/// sample (unknown axes take their `*_UNKNOWN` sentinel) — the host diffs consecutive samples
/// and synthesizes down/up/button transitions itself, which is what makes a lost datagram
/// self-heal. `x`/`y` are normalized `0.0..=1.0` in VIDEO-FRAME space (map your letterbox
/// before filling, exactly like wire touches).
#[cfg(feature = "quic")]
#[repr(C)]
#[derive(Clone, Copy)]
pub struct PunktfunkPenSample {
    /// Normalized `0.0..=1.0` across the video frame. Must be finite.
    pub x: f32,
    /// Normalized `0.0..=1.0` across the video frame. Must be finite.
    pub y: f32,
    /// Tip force, `0..=65535` full scale (`0` while hovering).
    pub pressure: u16,
    /// Hover distance `0..=65534` (0 = at the hover floor), or `PUNKTFUNK_PEN_DISTANCE_UNKNOWN`.
    pub distance: u16,
    /// Tilt azimuth, degrees `0..=359` clockwise from north, or `PUNKTFUNK_PEN_ANGLE_UNKNOWN`.
    pub azimuth_deg: u16,
    /// Barrel roll (Apple Pencil Pro `rollAngle`), degrees `0..=359`, or
    /// `PUNKTFUNK_PEN_ANGLE_UNKNOWN`.
    pub roll_deg: u16,
    /// µs since the previous sample in the same call (`0` for the first) — the coalesced
    /// capture spacing.
    pub dt_us: u16,
    /// Bitfield of `PUNKTFUNK_PEN_*` state bits. Unknown bits are rejected (`InvalidArg`).
    pub state: u8,
    /// `PUNKTFUNK_PEN_TOOL_PEN` or `PUNKTFUNK_PEN_TOOL_ERASER`.
    pub tool: u8,
    /// Tilt from the surface normal, degrees `0..=90`, or `PUNKTFUNK_PEN_TILT_UNKNOWN`.
    pub tilt_deg: u8,
    /// Set to 0.
    pub _reserved: [u8; 3],
}

#[cfg(feature = "quic")]
impl PunktfunkPenSample {
    /// `None` = invalid field (non-finite coordinate, unknown state bit, unknown tool) —
    /// embedder input is validated strictly, unlike the loss-tolerant wire decode.
    fn to_sample(self) -> Option<crate::quic::PenSample> {
        use crate::quic as q;
        let known = q::PEN_IN_RANGE | q::PEN_TOUCHING | q::PEN_BARREL1 | q::PEN_BARREL2;
        if !self.x.is_finite() || !self.y.is_finite() || self.state & !known != 0 {
            return None;
        }
        let tool = match self.tool {
            PUNKTFUNK_PEN_TOOL_PEN => q::PenTool::Pen,
            PUNKTFUNK_PEN_TOOL_ERASER => q::PenTool::Eraser,
            _ => return None,
        };
        Some(q::PenSample {
            state: self.state,
            tool,
            x: self.x,
            y: self.y,
            pressure: self.pressure,
            distance: self.distance,
            tilt_deg: self.tilt_deg,
            azimuth_deg: self.azimuth_deg,
            roll_deg: self.roll_deg,
            dt_us: self.dt_us,
        })
    }
}

/// Read an optional NUL-terminated UTF-8 string parameter; `Err` = invalid pointer/UTF-8.
#[cfg(feature = "quic")]
unsafe fn opt_cstr<'a>(p: *const std::os::raw::c_char) -> std::result::Result<Option<&'a str>, ()> {
    if p.is_null() {
        return Ok(None);
    }
    // SAFETY: per the ABI contract - a caller-supplied C string, NUL-terminated or null, borrowed
    // only for this call.
    unsafe { std::ffi::CStr::from_ptr(p) }
        .to_str()
        .map(Some)
        .map_err(|_| ())
}

/// Compositor preference for [`punktfunk_connect_ex`] (`compositor` arg). `AUTO` lets the host
/// pick (auto-detect from its running desktop); a concrete value is honored only if that backend
/// is available on the host right now, else the host falls back to auto-detect. The resolved
/// choice is reported back over the protocol (see `punktfunk/1` `Welcome`).
pub const PUNKTFUNK_COMPOSITOR_AUTO: u32 = 0;
/// KWin / KDE Plasma.
pub const PUNKTFUNK_COMPOSITOR_KWIN: u32 = 1;
/// wlroots (Sway / Hyprland).
pub const PUNKTFUNK_COMPOSITOR_WLROOTS: u32 = 2;
/// Mutter / GNOME.
pub const PUNKTFUNK_COMPOSITOR_MUTTER: u32 = 3;
/// gamescope (spawned nested).
pub const PUNKTFUNK_COMPOSITOR_GAMESCOPE: u32 = 4;

/// Gamepad-backend preference for [`punktfunk_connect_ex2`] (`gamepad` arg): which virtual pad
/// the host creates for this session's controllers. Precedence host-side: an explicit client
/// choice > the host's `PUNKTFUNK_GAMEPAD` env var > X-Box 360. `AUTO` (or any unrecognized
/// value) = host decides. The resolved choice is echoed over the protocol (`Welcome`) and
/// readable via [`punktfunk_connection_gamepad`].
pub const PUNKTFUNK_GAMEPAD_AUTO: u32 = 0;
/// uinput X-Box 360 pad (the universal default — every game speaks XInput).
pub const PUNKTFUNK_GAMEPAD_XBOX360: u32 = 1;
/// UHID DualSense (kernel `hid-playstation`): adaptive triggers, lightbar, touchpad, motion —
/// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored on
/// Linux (UHID) and Windows (UMDF minidriver) hosts; otherwise the host falls back to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2;
/// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so
/// games show One/Series glyphs. XInput-identical to `XBOX360` otherwise (no game-visible gain;
/// impulse-trigger rumble is unreachable through THIS pad — evdev's `FF_RUMBLE` is two
/// magnitudes and has no third, so a uinput backend can never source it. The Windows HID Xbox
/// backend can, off its output report `0x03`; see
/// [`punktfunk_connection_next_rumble_cmd2`]). Useful for glyph-matching a
/// physical X-Box One/Series controller on the client.
pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3;
/// UHID DualShock 4 (kernel `hid-playstation` ≥ 6.2): lightbar, touchpad, motion, rumble — the
/// touchpad/motion arrive over the rich-input plane and lightbar over the HID-output plane, like
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored on Linux (UHID) and Windows
/// (UMDF minidriver) hosts; otherwise the host falls back to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
/// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
/// Steam Deck controller (Valve `28DE:1205`): full Deck gamepad incl. the four back grips, both
/// trackpads, and the IMU; re-grabbed by Steam Input with native glyphs when Steam runs on the
/// host. Honored on Linux AND Windows hosts; else folds to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
/// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
/// client's back paddles land on native slots. Honored on Linux (UHID `hid-playstation`) and
/// Windows (UMDF) hosts; otherwise the host falls back to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_DUALSENSEEDGE: u32 = 7;
/// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs +
/// positional layout, gyro/accel, HD rumble. Honored only where available (Linux hosts, UHID
/// `hid-nintendo`); otherwise the host falls back to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_SWITCHPRO: u32 = 8;
/// New Steam Controller (2026, Valve `28DE:1302`) passed through AS-IS: the host mirrors the
/// client's raw Triton input reports out of a virtual SC2 with the real identity, and Steam's
/// hidraw writes (lizard mode, IMU enable, rumble/haptics) come back raw for the physical pad.
/// Steam Input is the consumer (no kernel driver binds the PID). Honored on Linux (UHID);
/// else folds to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2: u32 = 9;
/// Steam Controller Puck dongle (`28DE:1304`) passed through with its native seven-interface
/// topology and four controller slots. Used by capture clients that own the physical Puck;
/// ordinary wired/BLE SC2 capture remains `STEAMCONTROLLER2`.
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK: u32 = 10;
/// Xbox Elite Wireless Controller Series 2 (`045E:0B22`, Bluetooth): a Windows-only HID identity
/// through the UMDF minidriver, so glyphs and the device name read Elite. Folds to X-Box 360
/// elsewhere. ⚠️ Identity only — the four paddles still fold/drop exactly as on the other X-Box
/// classes (`DUALSENSEEDGE` is the pad with native back-button slots).
pub const PUNKTFUNK_GAMEPAD_XBOXELITE: u32 = 11;

/// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
/// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + the misc/capture button, in Moonlight's
/// `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`.
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE1: u32 = 0x0001_0000;
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE2: u32 = 0x0002_0000;
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE3: u32 = 0x0004_0000;
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE4: u32 = 0x0008_0000;
pub const PUNKTFUNK_GAMEPAD_BTN_MISC1: u32 = 0x0020_0000;

/// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`.
/// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to
/// [`punktfunk_connect_ex`] with `compositor = PUNKTFUNK_COMPOSITOR_AUTO`.
///
/// Video-capability bit for [`punktfunk_connect_ex5`] (`video_caps`): the client can decode a
/// 10-bit (Main10) HEVC stream. (Mirrors `quic::VIDEO_CAP_10BIT`.)
pub const PUNKTFUNK_VIDEO_CAP_10BIT: u8 = 0x01;
/// Video-capability bit for [`punktfunk_connect_ex5`] (`video_caps`): the client can present
/// BT.2020 PQ HDR10 (implies 10-bit). (Mirrors `quic::VIDEO_CAP_HDR`.)
pub const PUNKTFUNK_VIDEO_CAP_HDR: u8 = 0x02;
/// Video-capability bit for [`punktfunk_connect_ex5`] (`video_caps`): the client can decode a
/// full-chroma 4:4:4 HEVC stream (Range Extensions). The host emits 4:4:4 only when this is set,
/// the host opted in, the codec is HEVC, and the GPU supports it — else the stream stays 4:2:0 and
/// [`punktfunk_connection_chroma_format`] reports the real value. (Mirrors `quic::VIDEO_CAP_444`.)
pub const PUNKTFUNK_VIDEO_CAP_444: u8 = 0x04;

/// Codec bit for [`punktfunk_connect_ex7`] (`video_codecs` / `preferred_codec`) and the value
/// [`punktfunk_connection_codec`] returns: H.264 / AVC. (Mirrors `quic::CODEC_H264`.)
pub const PUNKTFUNK_CODEC_H264: u8 = 0x01;
/// Codec bit: H.265 / HEVC — the default codec. (Mirrors `quic::CODEC_HEVC`.)
pub const PUNKTFUNK_CODEC_HEVC: u8 = 0x02;
/// Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.)
pub const PUNKTFUNK_CODEC_AV1: u8 = 0x04;
/// Codec bit: PyroWave — the opt-in wired-LAN intra-only wavelet codec. Never auto-selected:
/// the host picks it ONLY when the client also passes it as `preferred_codec`
/// (design/pyrowave-codec-plan.md §3). (Mirrors `quic::CODEC_PYROWAVE`.)
pub const PUNKTFUNK_CODEC_PYROWAVE: u8 = 0x08;

/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host applies gamepad-state
/// snapshots (a capable client sends full-state snapshots instead of per-transition events).
/// (Mirrors `quic::HOST_CAP_GAMEPAD_STATE`.)
pub const PUNKTFUNK_HOST_CAP_GAMEPAD_STATE: u8 = 0x01;
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host supports the shared
/// clipboard, so a client may offer the toggle. (Mirrors `quic::HOST_CAP_CLIPBOARD`.)
pub const PUNKTFUNK_HOST_CAP_CLIPBOARD: u8 = 0x02;
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host injects full-fidelity
/// stylus input, so a capable client splits pen contacts out of its touch path and sends them
/// via [`punktfunk_connection_send_pen`]; without the bit that call returns `Unsupported` and
/// the client keeps its pen-as-touch fallback. (Mirrors `quic::HOST_CAP_PEN`;
/// design/pen-tablet-input.md.)
pub const PUNKTFUNK_HOST_CAP_PEN: u8 = 0x10;
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host can capture per-gamepad
/// audio (DualSense voice-coil haptics + speaker) and emit it on the 0xD1 plane toward pads
/// declared capable via [`punktfunk_connection_set_pad_audio_caps`]. Set only when the client
/// asked via [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`]. (Mirrors `quic::HOST_CAP_PAD_AUDIO`.)
pub const PUNKTFUNK_HOST_CAP_PAD_AUDIO: u8 = 0x40;
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host resolved this session onto
/// the LOSSLESS audio plane (`0xD3`) instead of Opus. Set only when the client asked via
/// [`punktfunk_connect_ex11`]; it is a statement about the wire, not an offer to decline — the
/// session runs one plane or the other for its whole life.
///
/// A C embedder needs it for exactly one thing: telling the two planes apart when it drains raw
/// frames through [`punktfunk_connection_next_audio`], because a 48 kHz/16-bit lossless session
/// and a 48 kHz Opus session report identical rate, depth and channels. Embedders on
/// [`punktfunk_connection_next_audio_pcm`] never need it — core decodes both planes behind it —
/// but they should still read [`punktfunk_connection_audio_sample_rate`] to size their ring.
/// (Mirrors `quic::HOST_CAP_AUDIO_HIRES`.)
pub const PUNKTFUNK_HOST_CAP_AUDIO_HIRES: u8 = 0x80;

/// Pad-audio `kind` ([`punktfunk_connection_next_pad_audio`]): the BACK channel pair — DualSense
/// voice-coil haptics, 5 ms Opus frames. (Mirrors `quic::PAD_AUDIO_KIND_HAPTICS`.)
pub const PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS: u8 = 0;
/// Pad-audio `kind`: the FRONT channel pair — the controller's built-in speaker, 10 ms Opus
/// frames. (Mirrors `quic::PAD_AUDIO_KIND_SPEAKER`.)
pub const PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER: u8 = 1;

/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the HAPTICS
/// stream (a real DualSense's voice coils).
pub const PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS: u8 = 0x01;
/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the SPEAKER
/// stream.
pub const PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER: u8 = 0x02;

// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
#[cfg(feature = "quic")]
const _: () = {
    assert!(PUNKTFUNK_VIDEO_CAP_10BIT == crate::quic::VIDEO_CAP_10BIT);
    assert!(PUNKTFUNK_VIDEO_CAP_HDR == crate::quic::VIDEO_CAP_HDR);
    assert!(PUNKTFUNK_VIDEO_CAP_444 == crate::quic::VIDEO_CAP_444);
    assert!(PUNKTFUNK_CODEC_H264 == crate::quic::CODEC_H264);
    assert!(PUNKTFUNK_CODEC_HEVC == crate::quic::CODEC_HEVC);
    assert!(PUNKTFUNK_CODEC_AV1 == crate::quic::CODEC_AV1);
    assert!(PUNKTFUNK_CODEC_PYROWAVE == crate::quic::CODEC_PYROWAVE);
    assert!(PUNKTFUNK_HOST_CAP_GAMEPAD_STATE == crate::quic::HOST_CAP_GAMEPAD_STATE);
    assert!(PUNKTFUNK_HOST_CAP_CLIPBOARD == crate::quic::HOST_CAP_CLIPBOARD);
    assert!(PUNKTFUNK_HOST_CAP_PEN == crate::quic::HOST_CAP_PEN);
    assert!(PUNKTFUNK_HOST_CAP_PAD_AUDIO == crate::quic::HOST_CAP_PAD_AUDIO);
    assert!(PUNKTFUNK_HOST_CAP_AUDIO_HIRES == crate::quic::HOST_CAP_AUDIO_HIRES);
    assert!(PUNKTFUNK_CLIENT_CAP_PAD_AUDIO == crate::quic::CLIENT_CAP_PAD_AUDIO);
    assert!(PUNKTFUNK_CLIENT_CAP_AUDIO_HIRES == crate::quic::CLIENT_CAP_AUDIO_HIRES);
    assert!(PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS == crate::quic::PAD_AUDIO_KIND_HAPTICS);
    assert!(PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER == crate::quic::PAD_AUDIO_KIND_SPEAKER);
    // The setter's caps bits are the arrival flags bits 8/9 shifted down (the wire packing
    // `input::encode_gamepad_arrival` applies).
    assert!(
        (PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS as u32) << 8
            == crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS
    );
    assert!(
        (PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER as u32) << 8
            == crate::input::ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
    );
    assert!(PUNKTFUNK_PEN_IN_RANGE == crate::quic::PEN_IN_RANGE);
    assert!(PUNKTFUNK_PEN_TOUCHING == crate::quic::PEN_TOUCHING);
    assert!(PUNKTFUNK_PEN_BARREL1 == crate::quic::PEN_BARREL1);
    assert!(PUNKTFUNK_PEN_BARREL2 == crate::quic::PEN_BARREL2);
    assert!(PUNKTFUNK_PEN_BATCH_MAX as usize == crate::quic::PEN_BATCH_MAX);
    assert!(PUNKTFUNK_PEN_TILT_UNKNOWN == crate::quic::PEN_TILT_UNKNOWN);
    assert!(PUNKTFUNK_PEN_ANGLE_UNKNOWN == crate::quic::PEN_ANGLE_UNKNOWN);
    assert!(PUNKTFUNK_PEN_DISTANCE_UNKNOWN == crate::quic::PEN_DISTANCE_UNKNOWN);
};

// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
const _: () = {
    use crate::config::GamepadPref;
    use crate::input::gamepad as g;
    assert!(PUNKTFUNK_GAMEPAD_AUTO == GamepadPref::Auto.to_u8() as u32);
    assert!(PUNKTFUNK_GAMEPAD_XBOX360 == GamepadPref::Xbox360.to_u8() as u32);
    assert!(PUNKTFUNK_GAMEPAD_DUALSENSE == GamepadPref::DualSense.to_u8() as u32);
    assert!(PUNKTFUNK_GAMEPAD_XBOXONE == GamepadPref::XboxOne.to_u8() as u32);
    assert!(PUNKTFUNK_GAMEPAD_DUALSHOCK4 == GamepadPref::DualShock4.to_u8() as u32);
    assert!(PUNKTFUNK_GAMEPAD_STEAMCONTROLLER == GamepadPref::SteamController.to_u8() as u32);
    assert!(PUNKTFUNK_GAMEPAD_STEAMDECK == GamepadPref::SteamDeck.to_u8() as u32);
    assert!(PUNKTFUNK_GAMEPAD_DUALSENSEEDGE == GamepadPref::DualSenseEdge.to_u8() as u32);
    assert!(PUNKTFUNK_GAMEPAD_SWITCHPRO == GamepadPref::SwitchPro.to_u8() as u32);
    assert!(PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2 == GamepadPref::SteamController2.to_u8() as u32);
    assert!(
        PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK == GamepadPref::SteamController2Puck.to_u8() as u32
    );
    assert!(PUNKTFUNK_GAMEPAD_XBOXELITE == GamepadPref::XboxElite.to_u8() as u32);
    // Extended button bits mirror the wire `input::gamepad` constants.
    assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1);
    assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2);
    assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE3 == g::BTN_PADDLE3);
    assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE4 == g::BTN_PADDLE4);
    assert!(PUNKTFUNK_GAMEPAD_BTN_MISC1 == g::BTN_MISC1);
};

// The additive M3 kinds (TouchpadEx / TrackpadHaptic) must never grow the legacy ABI structs —
// they have no `struct_size` guard, so a layout change would corrupt old-built callers' buffers.
#[cfg(feature = "quic")]
const _: () = {
    assert!(core::mem::size_of::<PunktfunkRichInput>() == 20);
    assert!(core::mem::size_of::<PunktfunkHidOutput>() == 19);
};

/// Trust: `pin_sha256` (NULL or 32 bytes) is the expected SHA-256 fingerprint of the host's
/// certificate — a mismatching host is rejected. NULL = trust on first use; persist the
/// fingerprint written to `observed_sha256_out` (NULL or 32 bytes, filled on success) and
/// pass it as the pin on every later connect.
///
/// Identity: `client_cert_pem`/`client_key_pem` (both NULL, or both NUL-terminated PEM
/// strings — see [`punktfunk_generate_identity`]) are presented via TLS client auth so a
/// host can recognize this client once paired ([`punktfunk_pair`]). NULL = anonymous;
/// hosts running `--require-pairing` reject anonymous sessions.
///
/// # Safety
/// `host` is a NUL-terminated UTF-8 string (IP or hostname resolvable by the platform);
/// `pin_sha256`/`observed_sha256_out` are each NULL or valid for 32 bytes;
/// `client_cert_pem`/`client_key_pem` are each NULL or NUL-terminated UTF-8.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connect(
    host: *const std::os::raw::c_char,
    port: u16,
    width: u32,
    height: u32,
    refresh_hz: u32,
    pin_sha256: *const u8,
    observed_sha256_out: *mut u8,
    client_cert_pem: *const std::os::raw::c_char,
    client_key_pem: *const std::os::raw::c_char,
    timeout_ms: u32,
) -> *mut PunktfunkConnection {
    // SAFETY: the pointer arguments are forwarded UNCHANGED to the versioned entry point, which
    // applies the same ABI contract to them; this shim dereferences nothing itself.
    unsafe {
        punktfunk_connect_ex(
            host,
            port,
            width,
            height,
            refresh_hz,
            PUNKTFUNK_COMPOSITOR_AUTO,
            pin_sha256,
            observed_sha256_out,
            client_cert_pem,
            client_key_pem,
            timeout_ms,
        )
    }
}

/// Like [`punktfunk_connect`], but requests a specific `compositor` backend on the host (one of
/// the `PUNKTFUNK_COMPOSITOR_*` values). `PUNKTFUNK_COMPOSITOR_AUTO` (or any unrecognized value)
/// lets the host decide; a concrete value is honored only if available, else the host falls back
/// to auto-detect. The resolved choice is logged host-side and returned over the protocol.
/// Equivalent to [`punktfunk_connect_ex2`] with `gamepad = PUNKTFUNK_GAMEPAD_AUTO`.
///
/// # Safety
/// Same as [`punktfunk_connect`].
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connect_ex(
    host: *const std::os::raw::c_char,
    port: u16,
    width: u32,
    height: u32,
    refresh_hz: u32,
    compositor: u32,
    pin_sha256: *const u8,
    observed_sha256_out: *mut u8,
    client_cert_pem: *const std::os::raw::c_char,
    client_key_pem: *const std::os::raw::c_char,
    timeout_ms: u32,
) -> *mut PunktfunkConnection {
    // SAFETY: the pointer arguments are forwarded UNCHANGED to the versioned entry point, which
    // applies the same ABI contract to them; this shim dereferences nothing itself.
    unsafe {
        punktfunk_connect_ex2(
            host,
            port,
            width,
            height,
            refresh_hz,
            compositor,
            PUNKTFUNK_GAMEPAD_AUTO,
            pin_sha256,
            observed_sha256_out,
            client_cert_pem,
            client_key_pem,
            timeout_ms,
        )
    }
}

/// Like [`punktfunk_connect_ex`], but additionally requests which virtual `gamepad` backend the
/// host creates for this session's pads (one of the `PUNKTFUNK_GAMEPAD_*` values).
/// `PUNKTFUNK_GAMEPAD_AUTO` (or any unrecognized value) lets the host decide (its
/// `PUNKTFUNK_GAMEPAD` env var, else X-Box 360); a concrete value is honored only if that
/// backend is available on the host. The resolved choice is readable via
/// [`punktfunk_connection_gamepad`] — only a DualSense session emits HID-output feedback.
///
/// # Safety
/// Same as [`punktfunk_connect`].
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connect_ex2(
    host: *const std::os::raw::c_char,
    port: u16,
    width: u32,
    height: u32,
    refresh_hz: u32,
    compositor: u32,
    gamepad: u32,
    pin_sha256: *const u8,
    observed_sha256_out: *mut u8,
    client_cert_pem: *const std::os::raw::c_char,
    client_key_pem: *const std::os::raw::c_char,
    timeout_ms: u32,
) -> *mut PunktfunkConnection {
    // SAFETY: the pointer arguments are forwarded UNCHANGED to the versioned entry point, which
    // applies the same ABI contract to them; this shim dereferences nothing itself.
    unsafe {
        punktfunk_connect_ex3(
            host,
            port,
            width,
            height,
            refresh_hz,
            compositor,
            gamepad,
            0, // bitrate_kbps = 0: let the host pick its default
            pin_sha256,
            observed_sha256_out,
            client_cert_pem,
            client_key_pem,
            timeout_ms,
        )
    }
}

/// Like [`punktfunk_connect_ex2`], but additionally requests the video encoder `bitrate_kbps`
/// (kilobits per second). `0` lets the host pick its default; any other value is clamped to the
/// host's supported range. After a speed test ([`punktfunk_connection_speed_test`]) a client can
/// reconnect (or pick at connect time) with the measured rate. The value the host actually
/// configured is readable via [`punktfunk_connection_bitrate`].
///
/// # Safety
/// Same as [`punktfunk_connect`].
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connect_ex3(
    host: *const std::os::raw::c_char,
    port: u16,
    width: u32,
    height: u32,
    refresh_hz: u32,
    compositor: u32,
    gamepad: u32,
    bitrate_kbps: u32,
    pin_sha256: *const u8,
    observed_sha256_out: *mut u8,
    client_cert_pem: *const std::os::raw::c_char,
    client_key_pem: *const std::os::raw::c_char,
    timeout_ms: u32,
) -> *mut PunktfunkConnection {
    // Delegate to the launch-aware variant with no game requested (the host's default session).
    // SAFETY: the pointer arguments are forwarded UNCHANGED to the versioned entry point, which
    // applies the same ABI contract to them; this shim dereferences nothing itself.
    unsafe {
        punktfunk_connect_ex4(
            host,
            port,
            width,
            height,
            refresh_hz,
            compositor,
            gamepad,
            bitrate_kbps,
            std::ptr::null(),
            pin_sha256,
            observed_sha256_out,
            client_cert_pem,
            client_key_pem,
            timeout_ms,
        )
    }
}

/// Like [`punktfunk_connect_ex3`], but additionally asks the host to launch a library title in
/// this session. `launch_id` is a store-qualified [`crate::library`-style] id as returned by the
/// host's `GET /api/v1/library` (`steam:<appid>` / `custom:<id>`); the host resolves it against
/// its OWN library and runs the matching recipe — the client never sends a raw command. `NULL`
/// (or an empty / unknown id) ⇒ the host's default session, no game launched.
///
/// # Safety
/// Same as [`punktfunk_connect`]; `launch_id`, when non-NULL, must be a NUL-terminated C string.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connect_ex4(
    host: *const std::os::raw::c_char,
    port: u16,
    width: u32,
    height: u32,
    refresh_hz: u32,
    compositor: u32,
    gamepad: u32,
    bitrate_kbps: u32,
    launch_id: *const std::os::raw::c_char,
    pin_sha256: *const u8,
    observed_sha256_out: *mut u8,
    client_cert_pem: *const std::os::raw::c_char,
    client_key_pem: *const std::os::raw::c_char,
    timeout_ms: u32,
) -> *mut PunktfunkConnection {
    // Back-compat: ex4 advertises no video caps (8-bit BT.709 SDR). HDR-capable embedders call
    // `punktfunk_connect_ex5` with the cap bits.
    // SAFETY: the pointer arguments are forwarded UNCHANGED to the versioned entry point, which
    // applies the same ABI contract to them; this shim dereferences nothing itself.
    unsafe {
        punktfunk_connect_ex5(
            host,
            port,
            width,
            height,
            refresh_hz,
            compositor,
            gamepad,
            bitrate_kbps,
            0,
            launch_id,
            pin_sha256,
            observed_sha256_out,
            client_cert_pem,
            client_key_pem,
            timeout_ms,
        )
    }
}

/// Like [`punktfunk_connect_ex4`], but additionally advertises the embedder's video decode/present
/// capabilities as `video_caps` — a bitfield of `PUNKTFUNK_VIDEO_CAP_10BIT` (can decode 10-bit
/// Main10) and `PUNKTFUNK_VIDEO_CAP_HDR` (can present BT.2020 PQ HDR10). The host upgrades to a
/// 10-bit / HDR encode ONLY when the matching bit is set (and the host opted in); `0` keeps the
/// 8-bit BT.709 SDR stream. After connecting, read the resolved colour via
/// [`punktfunk_connection_color_info`] and drain the mastering metadata via
/// [`punktfunk_connection_next_hdr_meta`].
///
/// # Safety
/// Same as [`punktfunk_connect`]; `launch_id`, when non-NULL, must be a NUL-terminated C string.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex5(
    host: *const std::os::raw::c_char,
    port: u16,
    width: u32,
    height: u32,
    refresh_hz: u32,
    compositor: u32,
    gamepad: u32,
    bitrate_kbps: u32,
    video_caps: u8,
    launch_id: *const std::os::raw::c_char,
    pin_sha256: *const u8,
    observed_sha256_out: *mut u8,
    client_cert_pem: *const std::os::raw::c_char,
    client_key_pem: *const std::os::raw::c_char,
    timeout_ms: u32,
) -> *mut PunktfunkConnection {
    // Delegate to the surround-aware variant requesting stereo (the pre-surround behaviour).
    // SAFETY: the pointer arguments are forwarded UNCHANGED to the versioned entry point, which
    // applies the same ABI contract to them; this shim dereferences nothing itself.
    unsafe {
        punktfunk_connect_ex6(
            host,
            port,
            width,
            height,
            refresh_hz,
            compositor,
            gamepad,
            bitrate_kbps,
            video_caps,
            2, // audio_channels = stereo
            launch_id,
            pin_sha256,
            observed_sha256_out,
            client_cert_pem,
            client_key_pem,
            timeout_ms,
        )
    }
}

/// Like [`punktfunk_connect_ex5`], but additionally requests the audio channel count:
/// `2` (stereo, the default behaviour of every earlier variant), `6` (5.1) or `8` (7.1). The host
/// clamps the request to what it can actually capture and echoes the resolved count via
/// [`punktfunk_connection_audio_channels`]. Advertises HEVC-only with no codec preference (call
/// [`punktfunk_connect_ex7`] to negotiate the codec).
///
/// # Safety
/// Same as [`punktfunk_connect`].
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex6(
    host: *const std::os::raw::c_char,
    port: u16,
    width: u32,
    height: u32,
    refresh_hz: u32,
    compositor: u32,
    gamepad: u32,
    bitrate_kbps: u32,
    video_caps: u8,
    audio_channels: u8,
    launch_id: *const std::os::raw::c_char,
    pin_sha256: *const u8,
    observed_sha256_out: *mut u8,
    client_cert_pem: *const std::os::raw::c_char,
    client_key_pem: *const std::os::raw::c_char,
    timeout_ms: u32,
) -> *mut PunktfunkConnection {
    // SAFETY: the pointer arguments are forwarded UNCHANGED to the versioned entry point, which
    // applies the same ABI contract to them; this shim dereferences nothing itself.
    unsafe {
        punktfunk_connect_ex7(
            host,
            port,
            width,
            height,
            refresh_hz,
            compositor,
            gamepad,
            bitrate_kbps,
            video_caps,
            audio_channels,
            PUNKTFUNK_CODEC_HEVC, // pre-negotiation default: HEVC-only, no preference
            0,
            launch_id,
            pin_sha256,
            observed_sha256_out,
            client_cert_pem,
            client_key_pem,
            timeout_ms,
        )
    }
}

/// Like [`punktfunk_connect_ex6`], but additionally advertises the codecs the client can decode
/// (`video_codecs` — a bitfield of [`PUNKTFUNK_CODEC_H264`] / [`PUNKTFUNK_CODEC_HEVC`] /
/// [`PUNKTFUNK_CODEC_AV1`]) and a soft `preferred_codec` (a single codec bit, `0` = no preference).
/// The host resolves the codec it emits from these (preference honored when it can also produce it,
/// else best shared codec) and reports it via [`punktfunk_connection_codec`]. A client that omits
/// this (calls `ex6`) advertises HEVC-only, no preference — the pre-negotiation behaviour.
///
/// # Safety
/// Same as [`punktfunk_connect`].
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex7(
    host: *const std::os::raw::c_char,
    port: u16,
    width: u32,
    height: u32,
    refresh_hz: u32,
    compositor: u32,
    gamepad: u32,
    bitrate_kbps: u32,
    video_caps: u8,
    audio_channels: u8,
    video_codecs: u8,
    preferred_codec: u8,
    launch_id: *const std::os::raw::c_char,
    pin_sha256: *const u8,
    observed_sha256_out: *mut u8,
    client_cert_pem: *const std::os::raw::c_char,
    client_key_pem: *const std::os::raw::c_char,
    timeout_ms: u32,
) -> *mut PunktfunkConnection {
    // SAFETY: the pointer arguments are forwarded UNCHANGED to the versioned entry point, which
    // applies the same ABI contract to them; this shim dereferences nothing itself.
    unsafe {
        connect_ex_impl(
            host,
            port,
            0, // pre-v11 variant: no client caps
            width,
            height,
            refresh_hz,
            compositor,
            gamepad,
            bitrate_kbps,
            video_caps,
            audio_channels,
            video_codecs,
            preferred_codec,
            launch_id,
            pin_sha256,
            observed_sha256_out,
            client_cert_pem,
            client_key_pem,
            std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands
            // pre-v24 variant: the audio format is UNSPECIFIED (0/0), not "48 kHz/16-bit". The
            // distinction is load-bearing — `advertised_client_caps` reads any non-zero value as
            // "the caller asked for the lossless plane", so passing an explicit 48 000/16 here
            // would make every legacy C embedder start requesting it. 0/0 is what keeps this
            // variant's Hello byte-identical to what it has always sent.
            0,
            0,
            timeout_ms,
            std::ptr::null_mut(),
        )
    }
}

/// Like [`punktfunk_connect_ex7`], but additionally reports WHY a failed connect failed:
/// `status_out` (nullable — null is exactly `ex7`) receives a [`PunktfunkStatus`] as `i32` —
/// `Ok` on success, the mapped error otherwise, including the typed host-rejection block
/// (`PUNKTFUNK_STATUS_REJECTED_NOT_ARMED` … `PUNKTFUNK_STATUS_REJECTED_BUSY`) decoded from the
/// host's application close. That lets an embedder tell "denied in the console" / "nobody
/// approved in time" / "host busy" / "versions don't match" apart from plain unreachability
/// (`Io`/`Timeout`) — a NULL return alone can't say which.
///
/// # Safety
/// Same as [`punktfunk_connect`]; `status_out`, when non-null, must point to a writable `i32`.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex8(
    host: *const std::os::raw::c_char,
    port: u16,
    width: u32,
    height: u32,
    refresh_hz: u32,
    compositor: u32,
    gamepad: u32,
    bitrate_kbps: u32,
    video_caps: u8,
    audio_channels: u8,
    video_codecs: u8,
    preferred_codec: u8,
    launch_id: *const std::os::raw::c_char,
    pin_sha256: *const u8,
    observed_sha256_out: *mut u8,
    client_cert_pem: *const std::os::raw::c_char,
    client_key_pem: *const std::os::raw::c_char,
    timeout_ms: u32,
    status_out: *mut i32,
) -> *mut PunktfunkConnection {
    // SAFETY: the pointer arguments are forwarded UNCHANGED to the versioned entry point, which
    // applies the same ABI contract to them; this shim dereferences nothing itself.
    unsafe {
        connect_ex_impl(
            host,
            port,
            0, // pre-v11 variant: no client caps
            width,
            height,
            refresh_hz,
            compositor,
            gamepad,
            bitrate_kbps,
            video_caps,
            audio_channels,
            video_codecs,
            preferred_codec,
            launch_id,
            pin_sha256,
            observed_sha256_out,
            client_cert_pem,
            client_key_pem,
            std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands
            // pre-v24 variant: the audio format is UNSPECIFIED (0/0), not "48 kHz/16-bit". The
            // distinction is load-bearing — `advertised_client_caps` reads any non-zero value as
            // "the caller asked for the lossless plane", so passing an explicit 48 000/16 here
            // would make every legacy C embedder start requesting it. 0/0 is what keeps this
            // variant's Hello byte-identical to what it has always sent.
            0,
            0,
            timeout_ms,
            status_out,
        )
    }
}

/// Like [`punktfunk_connect_ex8`], plus `client_caps` (ABI v11): a bitfield of
/// `PUNKTFUNK_CLIENT_CAP_CURSOR` (0x01). Setting the cursor bit asks the host to STOP
/// compositing the pointer into the video and forward it out-of-band instead — the embedder
/// MUST then drain [`punktfunk_connection_next_cursor_shape`] /
/// [`punktfunk_connection_next_cursor_state`] and draw the pointer itself, or the session has
/// no visible cursor at all. Pass 0 for the composited behavior of every earlier variant.
///
/// # Safety
/// Same as [`punktfunk_connect_ex8`].
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex9(
    host: *const std::os::raw::c_char,
    port: u16,
    width: u32,
    height: u32,
    refresh_hz: u32,
    compositor: u32,
    gamepad: u32,
    bitrate_kbps: u32,
    video_caps: u8,
    audio_channels: u8,
    video_codecs: u8,
    preferred_codec: u8,
    client_caps: u8,
    launch_id: *const std::os::raw::c_char,
    pin_sha256: *const u8,
    observed_sha256_out: *mut u8,
    client_cert_pem: *const std::os::raw::c_char,
    client_key_pem: *const std::os::raw::c_char,
    timeout_ms: u32,
    status_out: *mut i32,
) -> *mut PunktfunkConnection {
    // SAFETY: the pointer arguments are forwarded UNCHANGED to the versioned entry point, which
    // applies the same ABI contract to them; this shim dereferences nothing itself.
    unsafe {
        connect_ex_impl(
            host,
            port,
            client_caps,
            width,
            height,
            refresh_hz,
            compositor,
            gamepad,
            bitrate_kbps,
            video_caps,
            audio_channels,
            video_codecs,
            preferred_codec,
            launch_id,
            pin_sha256,
            observed_sha256_out,
            client_cert_pem,
            client_key_pem,
            std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands
            // pre-v24 variant: the audio format is UNSPECIFIED (0/0), not "48 kHz/16-bit". The
            // distinction is load-bearing — `advertised_client_caps` reads any non-zero value as
            // "the caller asked for the lossless plane", so passing an explicit 48 000/16 here
            // would make every legacy C embedder start requesting it. 0/0 is what keeps this
            // variant's Hello byte-identical to what it has always sent.
            0,
            0,
            timeout_ms,
            status_out,
        )
    }
}

/// Like [`punktfunk_connect_ex9`], plus `device_name` (ABI v21): the human-readable label this
/// device knocks with — what the host's **pending-approval** list (and the web console's
/// outstanding-pairings view and its approve dialog) shows for an unpaired client, and what the
/// trust store files it under once approved. Pass the name the user already recognises this
/// device by: `Host.current().localizedName` on macOS, `UIDevice.current.name` on iOS/tvOS,
/// `Settings.Global.DEVICE_NAME` on Android.
///
/// NULL / empty = the [`crate::client::device_name`] default, exactly as every earlier variant.
/// That default is an OS hostname, which no Apple GUI process could reach until v21 — every one
/// of them knocked as the literal "This device", so a console with three of them pending showed
/// three identical rows. Longer than [`crate::quic::HELLO_NAME_MAX`] bytes of UTF-8 is truncated
/// (on a character boundary) rather than rejected: a too-long label is a cosmetic problem, and
/// failing a connect over it would be a much worse one.
///
/// # Safety
/// Same as [`punktfunk_connect_ex9`]; `device_name`, when non-null, must be a NUL-terminated C
/// string that stays valid for the duration of the call.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex10(
    host: *const std::os::raw::c_char,
    port: u16,
    width: u32,
    height: u32,
    refresh_hz: u32,
    compositor: u32,
    gamepad: u32,
    bitrate_kbps: u32,
    video_caps: u8,
    audio_channels: u8,
    video_codecs: u8,
    preferred_codec: u8,
    client_caps: u8,
    launch_id: *const std::os::raw::c_char,
    pin_sha256: *const u8,
    observed_sha256_out: *mut u8,
    client_cert_pem: *const std::os::raw::c_char,
    client_key_pem: *const std::os::raw::c_char,
    device_name: *const std::os::raw::c_char,
    timeout_ms: u32,
    status_out: *mut i32,
) -> *mut PunktfunkConnection {
    // SAFETY: the pointer arguments are forwarded UNCHANGED to the versioned entry point, which
    // applies the same ABI contract to them; this shim dereferences nothing itself.
    unsafe {
        connect_ex_impl(
            host,
            port,
            client_caps,
            width,
            height,
            refresh_hz,
            compositor,
            gamepad,
            bitrate_kbps,
            video_caps,
            audio_channels,
            video_codecs,
            preferred_codec,
            launch_id,
            pin_sha256,
            observed_sha256_out,
            client_cert_pem,
            client_key_pem,
            device_name,
            // pre-v24 variant: the audio format is UNSPECIFIED (0/0), not "48 kHz/16-bit". The
            // distinction is load-bearing — `advertised_client_caps` reads any non-zero value as
            // "the caller asked for the lossless plane", so passing an explicit 48 000/16 here
            // would make every legacy C embedder start requesting it. 0/0 is what keeps this
            // variant's Hello byte-identical to what it has always sent.
            0,
            0,
            timeout_ms,
            status_out,
        )
    }
}

/// Like [`punktfunk_connect_ex10`], plus the audio format this client is **asking** for (ABI v24):
/// `audio_rate_hz` — `48000`, `96000`, or the 44.1 kHz family `44100` / `88200` / `176400` — and
/// `audio_bits` (`16` or `24`).
///
/// Passing a format AT ALL — any non-zero `audio_rate_hz`/`audio_bits`, `48000`/`16` included —
/// sets `CLIENT_CAP_AUDIO_HIRES` in the `Hello` and asks the host for the LOSSLESS `0xD3` plane,
/// bit-exact PCM instead of Opus. (This line once said "anything other than `48000`/`16`", which
/// was the rule until the cheapest rung turned out to be the one nobody could ask for; the ⚠ below
/// is the whole story.) That is an opt-in on both ends, and it is meant to be: it costs
/// **1.5–4.6 Mbps** taken off the top of the link (audio rides QUIC datagrams outside the ABR
/// loop, so ABR can neither see it nor reclaim it), against the ~256 kbps Opus this replaces. Only
/// pass a format when the user turned the feature on AND this embedder can genuinely open an
/// output device at it.
///
/// **The request is not the answer.** The host runs a five-condition gate
/// (`design/hi-res-audio.md` §8.4 — client asked, operator policy allows, stereo, the capture
/// path can *really* deliver the rate, and the link can afford it) and any failure resolves the
/// session back to Opus at 48 kHz. That is not an error and the connect still succeeds. Read
/// [`punktfunk_connection_audio_sample_rate`] / [`punktfunk_connection_audio_bits`] afterwards
/// and open the device from THOSE — opening at what you asked for is
/// `design/hi-res-audio.md` §4.3's failure repeated at the client end.
///
/// ⚠ **Passing `48000`/`16` is NOT the same as [`punktfunk_connect_ex10`], and an earlier version
/// of this comment claimed it was.** `ex10` passes `0`/`0` — *unspecified* — and the capability bit
/// keys on "the caller specified a format", not on "the format differs from the default". So an
/// explicit 48 kHz/16-bit is a genuine request for the cheapest lossless rung: the `Hello` carries
/// [`quic::CLIENT_CAP_AUDIO_HIRES`](crate::quic::CLIENT_CAP_AUDIO_HIRES), the host's gate accepts
/// 48/16 as a supported format, and a host with the operator policy on will resolve the session
/// onto the lossless plane at 1.5 Mbps.
///
/// That is the intended behaviour — 48/16 would otherwise be the one rung on the ladder nobody
/// could ask for — but it makes `0`/`0` load-bearing. **An embedder whose user chose "Opus" must
/// pass `0`/`0` here, or call [`punktfunk_connect_ex10`].** Forwarding a hardcoded 48 000/16 as a
/// stand-in for "default" silently opts every ordinary session into the lossless plane.
///
/// The 44.1 kHz family is on the ladder, and was not always: core's de-jitter policy divided the
/// rate by 1 000 before it multiplied, which made 44 100 Hz "44 samples per millisecond" and every
/// buffer figure 2.3 % low, so §4.1 deferred those rates behind fixing that arithmetic. It is
/// fixed. Note what it does NOT buy: at 44 100 Hz **no** rung of the frame ladder is a whole
/// number of samples, so [`punktfunk_connection_audio_frame_us`] becomes a nominal length rather
/// than a duration. An embedder that advances any clock by it runs 0.23 % fast forever; the honest
/// figure is the samples it actually rendered, divided by
/// [`punktfunk_connection_audio_sample_rate`].
///
/// A NEW symbol, not a widened one — `ex10` keeps its parameter list AND its behaviour.
///
/// # Safety
/// Same as [`punktfunk_connect_ex10`].
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex11(
    host: *const std::os::raw::c_char,
    port: u16,
    width: u32,
    height: u32,
    refresh_hz: u32,
    compositor: u32,
    gamepad: u32,
    bitrate_kbps: u32,
    video_caps: u8,
    audio_channels: u8,
    audio_rate_hz: u32,
    audio_bits: u8,
    video_codecs: u8,
    preferred_codec: u8,
    client_caps: u8,
    launch_id: *const std::os::raw::c_char,
    pin_sha256: *const u8,
    observed_sha256_out: *mut u8,
    client_cert_pem: *const std::os::raw::c_char,
    client_key_pem: *const std::os::raw::c_char,
    device_name: *const std::os::raw::c_char,
    timeout_ms: u32,
    status_out: *mut i32,
) -> *mut PunktfunkConnection {
    // SAFETY: the pointer arguments are forwarded UNCHANGED to the versioned entry point, which
    // applies the same ABI contract to them; this shim dereferences nothing itself.
    unsafe {
        connect_ex_impl(
            host,
            port,
            client_caps,
            width,
            height,
            refresh_hz,
            compositor,
            gamepad,
            bitrate_kbps,
            video_caps,
            audio_channels,
            video_codecs,
            preferred_codec,
            launch_id,
            pin_sha256,
            observed_sha256_out,
            client_cert_pem,
            client_key_pem,
            device_name,
            audio_rate_hz,
            audio_bits,
            timeout_ms,
            status_out,
        )
    }
}

/// [`punktfunk_connect_ex9`] `client_caps` bit: render the host cursor locally (the cursor
/// channel, `design/remote-desktop-sweep.md` M2).
pub const PUNKTFUNK_CLIENT_CAP_CURSOR: u8 = 0x01;

/// [`punktfunk_connect_ex9`] `client_caps` bit: this client's presenter is vsync-aware and
/// feeds [`punktfunk_connection_report_phase`] (design/phase-locked-capture.md). Advisory in
/// v1 — the host arms on report receipt — but honest advertisement keeps the negotiation
/// forward-compatible.
pub const PUNKTFUNK_CLIENT_CAP_PHASE_LOCK: u8 = 0x02;

/// [`punktfunk_connect_ex9`] `client_caps` bit: the client understands the pad-audio plane
/// (0xD1 — per-gamepad DualSense voice-coil haptics + speaker). The embedder MUST then drain
/// [`punktfunk_connection_next_pad_audio`] and declare each capable pad via
/// [`punktfunk_connection_set_pad_audio_caps`]; the host emits pad audio only when it answers
/// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 0x08;

/// [`punktfunk_connect_ex9`] `client_caps` bit: ask for the LOSSLESS audio plane (`0xD3`).
///
/// **Normally you do not set this by hand** — pass a non-default `audio_rate_hz`/`audio_bits` to
/// [`punktfunk_connect_ex11`] and core sets it for you, which keeps "the bit" and "the format it
/// is asking for" from ever disagreeing. The one case that needs it explicitly is asking for
/// lossless at the DEFAULT 48 kHz/16-bit, whose parameters are indistinguishable from a legacy
/// request; core ORs the derived bit into what you pass rather than replacing it, so setting it
/// here works. Do it only if this embedder can genuinely open a 48 kHz/16-bit output and its user
/// asked for lossless — 1.5 Mbps to sound like transparent 256 kbps Opus is a poor trade, and
/// 24-bit is where the plane earns its bandwidth. (Mirrors `quic::CLIENT_CAP_AUDIO_HIRES`.)
pub const PUNKTFUNK_CLIENT_CAP_AUDIO_HIRES: u8 = 0x10;

/// A [`punktfunk_connect_ex10`] device name cut to what a [`crate::quic::Hello`] carries.
/// [`crate::quic::HELLO_NAME_MAX`] is a BYTE cap while the cut must land on a character
/// boundary — "Wohnzimmer-Fernseher überm Sofa" is 33 characters and 34 bytes, and slicing a
/// name mid-scalar panics. Too long is truncated rather than rejected: the wire encoder would
/// truncate it anyway, and failing a connect over a cosmetic label would be far worse than
/// showing a shortened one.
#[cfg(feature = "quic")]
fn clamp_device_name(s: &str) -> String {
    let end = s
        .char_indices()
        .map(|(i, c)| i + c.len_utf8())
        .take_while(|&i| i <= crate::quic::HELLO_NAME_MAX)
        .last()
        .unwrap_or(0);
    s[..end].to_string()
}

/// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out`
/// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`],
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked. `device_name` (nullable,
/// [`punktfunk_connect_ex10`]) is the label this device knocks with; null = the OS default.
/// `audio_rate_hz`/`audio_bits` ([`punktfunk_connect_ex11`]) are the audio format being asked
/// for; every earlier variant passes the legacy 48 kHz/16-bit pair, which is what keeps their
/// `Hello` byte-identical to the pre-hi-res one.
#[cfg(feature = "quic")]
#[allow(clippy::too_many_arguments)]
unsafe fn connect_ex_impl(
    host: *const std::os::raw::c_char,
    port: u16,
    client_caps: u8,
    width: u32,
    height: u32,
    refresh_hz: u32,
    compositor: u32,
    gamepad: u32,
    bitrate_kbps: u32,
    video_caps: u8,
    audio_channels: u8,
    video_codecs: u8,
    preferred_codec: u8,
    launch_id: *const std::os::raw::c_char,
    pin_sha256: *const u8,
    observed_sha256_out: *mut u8,
    client_cert_pem: *const std::os::raw::c_char,
    client_key_pem: *const std::os::raw::c_char,
    device_name: *const std::os::raw::c_char,
    audio_rate_hz: u32,
    audio_bits: u8,
    timeout_ms: u32,
    status_out: *mut i32,
) -> *mut PunktfunkConnection {
    let set_status = |s: crate::error::PunktfunkStatus| {
        if !status_out.is_null() {
            // SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
            // written once by value.
            unsafe { *status_out = s as i32 };
        }
    };
    let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
        if host.is_null() {
            set_status(crate::error::PunktfunkStatus::InvalidArg);
            return std::ptr::null_mut();
        }
        // SAFETY: per the ABI contract - a caller-supplied C string, NUL-terminated or null,
        // borrowed only for this call.
        let host = match unsafe { std::ffi::CStr::from_ptr(host) }.to_str() {
            Ok(s) => s,
            Err(_) => {
                set_status(crate::error::PunktfunkStatus::InvalidArg);
                return std::ptr::null_mut();
            }
        };
        // A bad-UTF-8 launch id is non-fatal — treat it as "no game" rather than failing connect.
        // SAFETY: per the ABI contract - the pointer operands in this block are caller-supplied
        // and are null-checked or handle-validated on this path before they are read.
        let launch = match unsafe { opt_cstr(launch_id) } {
            Ok(Some(s)) if !s.is_empty() => Some(s.to_string()),
            _ => None,
        };
        // The label the host's pending-approval list shows. Same non-fatal treatment as `launch`:
        // an absent / empty / bad-UTF-8 name falls back to the OS default rather than failing a
        // connect over a cosmetic field. Truncation is on a CHARACTER boundary — `HELLO_NAME_MAX`
        // is a byte cap, and slicing a multi-byte name mid-scalar would panic.
        // SAFETY: per the ABI contract - a caller-supplied C string, NUL-terminated or null,
        // borrowed only for this call.
        let name = match unsafe { opt_cstr(device_name) } {
            Ok(Some(s)) if !s.trim().is_empty() => clamp_device_name(s.trim()),
            _ => crate::client::device_name(),
        };
        let mode = crate::config::Mode {
            width,
            height,
            refresh_hz,
        };
        // "Any unrecognized value = Auto" must hold for the FULL u32 domain — `as u8`
        // would wrap 0x101 into a concrete choice before from_u8's fallback could apply.
        let pref = u8::try_from(compositor)
            .map(crate::config::CompositorPref::from_u8)
            .unwrap_or_default();
        let gamepad = u8::try_from(gamepad)
            .map(crate::config::GamepadPref::from_u8)
            .unwrap_or_default();
        let pin = if pin_sha256.is_null() {
            None
        } else {
            let mut p = [0u8; 32];
            // SAFETY: per the ABI contract - a caller-supplied pointer/length pair describing one
            // readable region, borrowed only for this call.
            p.copy_from_slice(unsafe { std::slice::from_raw_parts(pin_sha256, 32) });
            Some(p)
        };
        // SAFETY: per the ABI contract - the pointer operands in this block are caller-supplied
        // and are null-checked or handle-validated on this path before they are read.
        let identity = match (unsafe { opt_cstr(client_cert_pem) }, unsafe {
            opt_cstr(client_key_pem)
        }) {
            (Ok(Some(c)), Ok(Some(k))) => Some((c.to_string(), k.to_string())),
            (Ok(None), Ok(None)) => None,
            _ => {
                // Half an identity / bad UTF-8: fail closed.
                set_status(crate::error::PunktfunkStatus::InvalidArg);
                return std::ptr::null_mut();
            }
        };
        match crate::client::NativeClient::connect_with_audio_format(
            host,
            port,
            mode,
            pref,
            gamepad,
            bitrate_kbps,
            video_caps,
            crate::audio::normalize_channels(audio_channels),
            // The audio format asked for ([`punktfunk_connect_ex11`]); the legacy 48 kHz/16-bit
            // pair from every earlier variant. Passed through UNVALIDATED on purpose: a rate or
            // depth this plane cannot carry is the host's to decline (it answers with what it
            // resolved, and the client opens from that), and rejecting a connect over an audio
            // preference would be a far worse failure than a session that plays Opus.
            audio_rate_hz,
            audio_bits,
            video_codecs,
            preferred_codec,
            // No display-HDR-volume parameter in the C ABI yet: Apple/Android clients tone-map
            // themselves (EDR / MediaCodec), so the host's EDID defaults are fine there. An `ex8`
            // variant can carry it if a passthrough embedder ever needs it.
            None,
            // ABI v11 ([`punktfunk_connect_ex9`]): CLIENT_CAP_CURSOR here asks the host to STOP
            // compositing the pointer — only an embedder that renders the cursor planes
            // ([`punktfunk_connection_next_cursor_shape`]/`_state`) may set it. ex7/ex8 pass 0.
            client_caps,
            // The C ABI cannot carry slice-progressive parts yet — `PunktfunkFrame` has no
            // part/completeness fields, so a part would be indistinguishable from a whole AU.
            // An `ex11` variant adds the opt-in together with those fields when an ABI embedder
            // (Apple) grows a partial-feed decode path.
            false,
            launch,
            // What the host's pending-approval list shows when this embedder knocks unpaired, and
            // the trust-store label on approval. [`punktfunk_connect_ex10`]'s `device_name` when
            // the embedder supplied one (the name the USER knows the device by — an Apple app has
            // it and the OS default cannot reach it), else that OS default.
            Some(name),
            pin,
            identity,
            std::time::Duration::from_millis(timeout_ms as u64),
            // No abort switch in the C ABI: `punktfunk_connect*` is a blocking call with
            // nothing to poll a flag from. An `ex` variant can take one when an ABI embedder
            // grows a cancelable connect screen.
            None,
        ) {
            Ok(c) => {
                if !observed_sha256_out.is_null() {
                    // SAFETY: per the ABI contract - a caller-owned output buffer of exactly the
                    // documented fixed length, non-null on this path and written once.
                    unsafe {
                        std::slice::from_raw_parts_mut(observed_sha256_out, 32)
                            .copy_from_slice(&c.host_fingerprint);
                    }
                }
                set_status(crate::error::PunktfunkStatus::Ok);
                Box::into_raw(Box::new(PunktfunkConnection {
                    inner: c,
                    last: std::sync::Mutex::new(None),
                    last_audio: std::sync::Mutex::new(None),
                    audio_pcm: std::sync::Mutex::new(AudioPcmState::default()),
                    last_clip: std::sync::Mutex::new(None),
                    last_cursor_shape: std::sync::Mutex::new(None),
                }))
            }
            Err(e) => {
                set_status(e.status());
                std::ptr::null_mut()
            }
        }
    }));
    r.unwrap_or_else(|_| {
        set_status(crate::error::PunktfunkStatus::Panic);
        std::ptr::null_mut()
    })
}

/// Generate a persistent client identity: a self-signed certificate + private key, both
/// PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both
/// strings (Keychain etc.), pass them to [`punktfunk_pair`] and every
/// [`punktfunk_connect`] — the certificate's fingerprint is how hosts recognize this
/// client. 4096-byte buffers are ample.
///
/// # Safety
/// `cert_pem_out` is writable for `cert_cap` bytes; `key_pem_out` for `key_cap`.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_generate_identity(
    cert_pem_out: *mut std::os::raw::c_char,
    cert_cap: usize,
    key_pem_out: *mut std::os::raw::c_char,
    key_cap: usize,
) -> PunktfunkStatus {
    guard(|| {
        if cert_pem_out.is_null() || key_pem_out.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        let (cert, key) = match crate::quic::endpoint::generate_identity() {
            Ok(t) => t,
            Err(_) => return PunktfunkStatus::Io,
        };
        if cert.len() + 1 > cert_cap || key.len() + 1 > key_cap {
            return PunktfunkStatus::InvalidArg;
        }
        // SAFETY: per the ABI contract - the pointer operands in this block are caller-supplied
        // and are null-checked or handle-validated on this path before they are read.
        unsafe {
            // `.cast()`, not `as *mut u8`: `c_char` is i8 on x86_64 but u8 on aarch64, so the
            // `as` form is a REQUIRED conversion on one and a no-op clippy rejects on the other.
            std::ptr::copy_nonoverlapping(cert.as_ptr(), cert_pem_out.cast::<u8>(), cert.len());
            *cert_pem_out.add(cert.len()) = 0;
            std::ptr::copy_nonoverlapping(key.as_ptr(), key_pem_out.cast::<u8>(), key.len());
            *key_pem_out.add(key.len()) = 0;
        }
        PunktfunkStatus::Ok
    })
}

/// Reachability probe: attempt the QUIC handshake to `host:port` and report whether the host
/// answered — trust-agnostic and mDNS-INDEPENDENT. A host reached over a routed network
/// (Tailscale/VPN/another subnet) answers here even though it never advertises on mDNS, so the
/// clients' saved-host "online" pips can reflect real reachability instead of LAN presence (the
/// display-side companion to the dial-first connect fix). Returns [`PunktfunkStatus::Ok`] when
/// reachable, [`PunktfunkStatus::Timeout`] when not (or on any connect error). Blocks up to
/// `timeout_ms`; call off the UI thread.
///
/// # Safety
/// `host` must be a NUL-terminated UTF-8 string.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_probe(
    host: *const std::os::raw::c_char,
    port: u16,
    timeout_ms: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - the pointer operands in this block are caller-supplied
        // and are null-checked or handle-validated on this path before they are read.
        let Ok(Some(host)) = (unsafe { opt_cstr(host) }) else {
            return PunktfunkStatus::NullPointer;
        };
        if crate::client::NativeClient::probe(
            host,
            port,
            std::time::Duration::from_millis(timeout_ms as u64),
        ) {
            PunktfunkStatus::Ok
        } else {
            PunktfunkStatus::Timeout
        }
    })
}

/// Run the PIN pairing ceremony against a host (see the protocol docs in punktfunk-core):
/// the host displays a short PIN; the user types it into the client app, which passes it
/// here. On success the host has stored this client's identity, the now-verified host
/// fingerprint is written to `host_sha256_out` (32 bytes) — persist it and pass it as
/// `pin_sha256` to [`punktfunk_connect`] from then on. Returns
/// [`PunktfunkStatus::Crypto`] for a wrong PIN.
///
/// # Safety
/// `host`/`client_cert_pem`/`client_key_pem`/`pin`/`name` are NUL-terminated UTF-8;
/// `host_sha256_out` is writable for 32 bytes.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_pair(
    host: *const std::os::raw::c_char,
    port: u16,
    client_cert_pem: *const std::os::raw::c_char,
    client_key_pem: *const std::os::raw::c_char,
    pin: *const std::os::raw::c_char,
    name: *const std::os::raw::c_char,
    host_sha256_out: *mut u8,
    timeout_ms: u32,
) -> PunktfunkStatus {
    guard(|| {
        let (Ok(Some(host)), Ok(Some(cert)), Ok(Some(key)), Ok(Some(pin)), Ok(Some(name))) = (
            // SAFETY: per the ABI contract - the pointer operands in this block are caller-
            // supplied and are null-checked or handle-validated on this path before they are read.
            unsafe { opt_cstr(host) },
            // SAFETY: per the ABI contract - the pointer operands in this block are caller-
            // supplied and are null-checked or handle-validated on this path before they are read.
            unsafe { opt_cstr(client_cert_pem) },
            // SAFETY: per the ABI contract - the pointer operands in this block are caller-
            // supplied and are null-checked or handle-validated on this path before they are read.
            unsafe { opt_cstr(client_key_pem) },
            // SAFETY: per the ABI contract - the pointer operands in this block are caller-
            // supplied and are null-checked or handle-validated on this path before they are read.
            unsafe { opt_cstr(pin) },
            // SAFETY: per the ABI contract - the pointer operands in this block are caller-
            // supplied and are null-checked or handle-validated on this path before they are read.
            unsafe { opt_cstr(name) },
        ) else {
            return PunktfunkStatus::NullPointer;
        };
        if host_sha256_out.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        match crate::client::NativeClient::pair(
            host,
            port,
            (cert, key),
            pin,
            name,
            std::time::Duration::from_millis(timeout_ms as u64),
        ) {
            Ok(fp) => {
                // SAFETY: per the ABI contract - a caller-owned output buffer of exactly the
                // documented fixed length, non-null on this path and written once.
                unsafe {
                    std::slice::from_raw_parts_mut(host_sha256_out, 32).copy_from_slice(&fp);
                }
                PunktfunkStatus::Ok
            }
            Err(e) => e.status(),
        }
    })
}

/// Pull the next reassembled access unit, waiting up to `timeout_ms`. Returns
/// [`PunktfunkStatus::NoFrame`] on timeout and [`PunktfunkStatus::Closed`] once the session ended.
/// On `Ok`, `*out` borrows connection memory **until the next `next_au` call** on this
/// handle (the audio/rumble planes do not invalidate it).
///
/// # Safety
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls video —
/// it may run concurrently with one audio-pulling and one rumble-pulling thread.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_au(
    c: *mut PunktfunkConnection,
    out: *mut PunktfunkFrame,
    timeout_ms: u32,
) -> PunktfunkStatus {
    guard(|| {
        // Shared reference only: video and audio threads must never alias a `&mut`.
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if out.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        match c
            .inner
            .next_frame(std::time::Duration::from_millis(timeout_ms as u64))
        {
            Ok(frame) => {
                let mut slot = lock_recover(&c.last);
                let f = slot.insert(frame);
                // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
                // matching `#[repr(C)]` type, written once by value.
                unsafe {
                    *out = PunktfunkFrame {
                        data: f.data.as_ptr(),
                        len: f.data.len(),
                        frame_index: f.frame_index,
                        pts_ns: f.pts_ns,
                        flags: f.flags,
                        received_ns: f.received_ns,
                    };
                }
                PunktfunkStatus::Ok
            }
            Err(e) => e.status(),
        }
    })
}

/// One audio packet pulled off a `punktfunk/1` connection — an Opus frame (48 kHz, 5 ms) on
/// every ordinary session, or one lossless PCM frame on a session that resolved the `0xD3` plane.
/// `data` borrows connection memory until the next `punktfunk_connection_next_audio` call.
///
/// Nothing here says which: the plane is a property of the SESSION, read once via
/// `punktfunk_connection_host_caps() & PUNKTFUNK_HOST_CAP_AUDIO_HIRES` (with the format itself
/// from [`punktfunk_connection_audio_sample_rate`] / [`punktfunk_connection_audio_bits`]). An
/// embedder that never asks for the lossless plane can only ever be handed Opus, so this struct's
/// meaning is unchanged for it — and one that does is far better off on
/// [`punktfunk_connection_next_audio_pcm`], which decodes both planes in core.
#[cfg(feature = "quic")]
#[repr(C)]
pub struct PunktfunkAudioPacket {
    pub data: *const u8,
    pub len: usize,
    pub seq: u32,
    pub pts_ns: u64,
}

/// Pull the next audio packet (see [`PunktfunkAudioPacket`] for what it holds), waiting up to
/// `timeout_ms`. Returns [`PunktfunkStatus::NoFrame`] on timeout and [`PunktfunkStatus::Closed`]
/// once the session ended. On `Ok`, `out->data` borrows connection memory **until the next audio
/// call** on this handle (independent of the video slot). Drain from a dedicated audio thread —
/// Opus packets arrive every 5 ms (lossless ones every 1–5 ms, per the negotiated frame length)
/// and the internal queue holds 320 ms.
///
/// # Safety
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio —
/// it may run concurrently with the video/rumble pullers.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_audio(
    c: *mut PunktfunkConnection,
    out: *mut PunktfunkAudioPacket,
    timeout_ms: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if out.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        match c
            .inner
            .next_audio(std::time::Duration::from_millis(timeout_ms as u64))
        {
            Ok(pkt) => {
                let mut slot = lock_recover(&c.last_audio);
                let p = slot.insert(pkt);
                // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
                // matching `#[repr(C)]` type, written once by value.
                unsafe {
                    *out = PunktfunkAudioPacket {
                        data: p.data.as_ptr(),
                        len: p.data.len(),
                        seq: p.seq,
                        pts_ns: p.pts_ns,
                    };
                }
                PunktfunkStatus::Ok
            }
            Err(e) => e.status(),
        }
    })
}

/// Read the audio channel count the host resolved for this session (from its Welcome): `2`
/// (stereo), `6` (5.1) or `8` (7.1). `*out` is filled when non-NULL. The `0xC9` Opus frames are
/// (multistream-)encoded for this layout; an embedder decoding raw frames itself must build its
/// decoder from THIS value (see [`crate::audio::layout_for`]) — or use
/// [`punktfunk_connection_next_audio_pcm`], which decodes in-core. Available immediately after a
/// successful connect (it doesn't change without a reconfigure).
///
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_audio_channels(
    c: *mut PunktfunkConnection,
    out: *mut u8,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if !out.is_null() {
            // SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`.
            unsafe { *out = c.inner.audio_channels };
        }
        PunktfunkStatus::Ok
    })
}

/// Read the sample rate the host resolved for this session (from its Welcome): `48000` for every
/// Opus session — and for every host older than the lossless plane — or the rate a hi-res session
/// actually landed on, which may be LOWER than the client asked for. `*out` is filled when
/// non-NULL. Available immediately after a successful connect; it never changes mid-session.
///
/// ⚠ **Open the output device from this, not from `PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ`.** That
/// compile-time constant keeps its value and its meaning — it is the DEFAULT/legacy rate, and
/// every ring sized from it is still correct for every session that resolves to Opus — but on a
/// hi-res session it is simply not the rate on the wire. Opening at 96 kHz because you asked for
/// 96 kHz, when the host answered 48 kHz, is `design/hi-res-audio.md` §4.3's failure repeated at
/// the other end of the link: everything audits clean and the content is wrong.
///
/// An ACCESSOR rather than a field on [`PunktfunkAudioPcm`] or `PunktfunkStats`: both are
/// `#[repr(C)]` with no `struct_size` guard and are allocated by value by C embedders, so growing
/// either would break every one of them at once. Same rule ABI 18 set with
/// `punktfunk_connection_next_rumble_cmd2` — added, not widened — so an embedder that never calls
/// this behaves exactly as it did before it existed.
///
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u32`.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_audio_sample_rate(
    c: *mut PunktfunkConnection,
    out: *mut u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if !out.is_null() {
            // SAFETY: `out` is non-null and the caller guarantees it is writable for one `u32`.
            unsafe { *out = c.inner.audio_sample_rate_hz };
        }
        PunktfunkStatus::Ok
    })
}

/// Read the sample depth the host resolved for this session (from its Welcome): `16` on every
/// Opus session and every older host, `16` or `24` on the lossless plane. `*out` is filled when
/// non-NULL. Available immediately after a successful connect; it never changes mid-session.
///
/// Only sessions on the lossless plane can report `24`, and only they need it: the depth is the
/// stride the `0xD3` payload is unpacked at, and [`punktfunk_connection_next_audio_pcm`] already
/// does that unpacking in core — it hands out f32 either way. This is here so an embedder can
/// *report* the format honestly (a UI that says "24-bit" while the host declined is the same
/// class of lie as claiming a rate you did not get) and so one draining raw frames through
/// [`punktfunk_connection_next_audio`] can unpack them itself. Which PLANE a session is on is
/// `punktfunk_connection_host_caps() & PUNKTFUNK_HOST_CAP_AUDIO_HIRES`, not this: 48 kHz/16-bit
/// reads identically on both.
///
/// ADDED, not widened — see [`punktfunk_connection_audio_sample_rate`] for why.
///
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_audio_bits(
    c: *mut PunktfunkConnection,
    out: *mut u8,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if !out.is_null() {
            // SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`.
            unsafe { *out = c.inner.audio_bits };
        }
        PunktfunkStatus::Ok
    })
}

/// The resolved audio frame length in MICROSECONDS — how much audio one datagram carries.
///
/// `PUNKTFUNK_AUDIO_FRAME_MS` is the Opus plane's 5 ms and stays that way, but the lossless plane
/// sizes its frame to the path MTU: 4 ms at 48 kHz/24-bit and 2 ms at 96 kHz/24-bit under the
/// default ceiling. An embedder that ports the de-jitter policy (rather than draining
/// [`punktfunk_connection_next_audio_pcm`] and letting core do it) needs the real figure — the
/// shed drops exactly one frame and the target floor is a device quantum plus one frame, so a
/// policy compiled against 5 ms sheds 2.5 frames at a time on a 96 kHz session.
///
/// Microseconds rather than milliseconds because the ladder has sub-millisecond rungs; `0` means
/// the host did not state one, in which case `PUNKTFUNK_AUDIO_FRAME_MS × 1000` is correct.
///
/// ⚠⚠ **A NOMINAL length, not a duration, and on the 44.1 kHz family they differ.** A frame
/// carries a whole number of samples per channel, and 44 100 Hz divides no rung of the ladder: a
/// 5 ms frame there is 220 samples per channel, which is 4 988 662 ns. Sizing a ring from this is
/// right (that is what it is for); advancing a **clock** by it is not — it invents 2.3 ms of time
/// per second, indefinitely, and every stat downstream will agree with the lie because the
/// timestamps stay self-consistent. Derive elapsed time from the samples rendered and
/// [`punktfunk_connection_audio_sample_rate`] instead.
///
/// **Not derivable from `next_audio_pcm`'s `frame_count`.** That call prepends concealed frames
/// into the same buffer, so its count is "how many samples you got", not "how long one frame is".
///
/// ADDED, not widened — see [`punktfunk_connection_audio_sample_rate`] for why.
///
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u16`.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_audio_frame_us(
    c: *mut PunktfunkConnection,
    out: *mut u16,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if !out.is_null() {
            // SAFETY: `out` is non-null and the caller guarantees it is writable for one `u16`.
            unsafe { *out = c.inner.audio_frame_us };
        }
        PunktfunkStatus::Ok
    })
}

/// WHY this session ended: `*out` receives a [`PunktfunkEndReason`] byte
/// (`PUNKTFUNK_END_REASON_*`). The return status reports only whether the handle was usable.
///
/// Read it once a plane has returned [`PunktfunkStatus::Closed`] (or the embedder's own
/// end-of-session signal fired); before that it reads `NONE`. It latches, so it is still readable
/// while the connection is torn down, and a client that never calls it behaves exactly as it did
/// before this existed.
///
/// **Most endings are not failures.** Before this, a client had no way to tell a player quitting
/// their game from a host falling off the network, so every client wrote one message for all of
/// them and every client chose an error. Use `LOCAL`/`GAME_EXITED`/`HOST_ENDED` to stay quiet (and
/// `GAME_EXITED` to return to the library the title was launched from), and keep the alarming copy
/// for `HOST_ERROR` and `LOST`.
///
/// Treat an unrecognized value as `NONE` — this crosses an ABI and the core may be newer than you.
///
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_end_reason(
    c: *mut PunktfunkConnection,
    out: *mut u8,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if !out.is_null() {
            // SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`.
            unsafe { *out = c.inner.end_reason() as u8 };
        }
        PunktfunkStatus::Ok
    })
}

/// One decoded audio frame from [`punktfunk_connection_next_audio_pcm`]: interleaved 32-bit
/// float PCM in the canonical wire channel order `FL FR FC LFE RL RR SL SR` (the first
/// `channels` of it). `samples` points at `frame_count * channels` floats and borrows
/// connection memory **until the next PCM call** on this handle.
///
/// **The sample rate is not in this struct and never will be.** It was 48 kHz for every session
/// until the lossless plane, and it is still 48 kHz for every Opus one — but a hi-res session
/// resolves its own rate and depth, and this is a `#[repr(C)]` type with no `struct_size` guard
/// that C embedders allocate BY VALUE. Adding a field would silently change its layout under
/// every one of them. So the format is read through
/// [`punktfunk_connection_audio_sample_rate`] / [`punktfunk_connection_audio_bits`] instead —
/// accessors ADDED, not structs widened, which is the rule ABI 18 set with
/// `punktfunk_connection_next_rumble_cmd2`. An embedder that never calls them keeps sizing its
/// ring for 48 kHz, which is exactly right for the sessions it can already play.
#[cfg(feature = "quic")]
#[repr(C)]
pub struct PunktfunkAudioPcm {
    /// Interleaved f32 samples (wire channel order), `frame_count * channels` long.
    pub samples: *const f32,
    /// Samples per channel in this frame.
    pub frame_count: u32,
    /// Channel count (2/6/8) — the negotiated [`punktfunk_connection_audio_channels`].
    pub channels: u8,
    /// Source packet sequence number.
    pub seq: u32,
    /// Capture presentation timestamp (ns).
    pub pts_ns: u64,
}

/// Pull the next audio frame and **decode it in-core** to interleaved f32 PCM — for embedders
/// without a multistream-capable Opus decoder (e.g. Apple, whose AudioToolbox Opus path is
/// stereo-only). The decoder is built once from the negotiated channel count and handles 2/6/8
/// channels (a 1-coupled-stream multistream decoder is exactly a stereo decoder). Same
/// timeout/closed semantics as [`punktfunk_connection_next_audio`]; `out->samples` borrows
/// connection memory until the next PCM call on this handle. Use EITHER this or
/// [`punktfunk_connection_next_audio`] on a given connection, from one dedicated audio thread —
/// not both (they share the underlying queue).
///
/// **Both audio planes come out of this one call.** On a session that resolved the lossless
/// `0xD3` plane there is no Opus decoder at all — the samples are unpacked at the negotiated
/// depth — but the output is the same interleaved f32 in the same borrowed buffer, so an embedder
/// needs no branch. It DOES need [`punktfunk_connection_audio_sample_rate`] to size its ring and
/// open its device: `frame_count` is samples per channel, and at 96 kHz they arrive twice as fast.
///
/// **Loss concealment**: packets the wire lost (a gap in the sequence, after the redundant-plane
/// recovery has had its chance) are synthesized and returned IN FRONT of the arriving frame in
/// the same buffer — `out->frame_count` then covers the concealed frames plus the real one
/// (`out->seq`/`out->pts_ns` are the real packet's). The embedder just writes the whole buffer to
/// its ring, same as any other frame; gaps arrive pre-healed, exactly as they do on the clients
/// that decode outside core. That covers a gap a LATER packet reveals; when the wire goes quiet
/// instead, see [`punktfunk_connection_audio_plc`].
///
/// What synthesizes them differs by plane, and has to: Opus gaps use libopus PLC, which
/// extrapolates from the decoder's model of the signal. A lossless frame has no such model — only
/// the signal — so `0xD3` gaps are concealed by repeating the last good frame under a raised-cosine
/// fade, decaying to silence across a sustained gap (`design/hi-res-audio.md` §4.5). Same shape,
/// same buffer, same cap; only the material differs.
///
/// # Safety
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
    c: *mut PunktfunkConnection,
    out: *mut PunktfunkAudioPcm,
    timeout_ms: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if out.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        let fmt = AudioFormat::of(&c.inner);
        let channels = fmt.channels;
        let pkt = match c
            .inner
            .next_audio(std::time::Duration::from_millis(timeout_ms as u64))
        {
            Ok(pkt) => pkt,
            Err(e) => return e.status(),
        };
        let mut state = lock_recover(&c.audio_pcm);
        match state.decode_packet(&pkt.data, pkt.seq, fmt) {
            // Nothing to hand out this call: a DTX silence marker with no loss owed before it.
            Ok(0) => PunktfunkStatus::NoFrame,
            Ok(samples) => {
                // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
                // matching `#[repr(C)]` type, written once by value.
                unsafe {
                    *out = PunktfunkAudioPcm {
                        samples: state.pcm.as_ptr(),
                        frame_count: (samples / channels.max(1) as usize) as u32,
                        channels,
                        seq: pkt.seq,
                        pts_ns: pkt.pts_ns,
                    };
                }
                PunktfunkStatus::Ok
            }
            Err(status) => status,
        }
    })
}

/// Synthesize ONE frame of concealment from the in-core decoder's own state — no packet involved,
/// nothing pulled off the wire (design/host-source-stutter-fixes.md, WP-C1).
///
/// [`punktfunk_connection_next_audio_pcm`] heals a gap the SEQUENCE reveals, which needs a later
/// packet to arrive and reveal it. When the wire simply goes quiet — a delivery stall on a
/// bunching Wi-Fi link, or a host whose capture stalled — nothing arrives to reveal anything: the
/// embedder's playout ring drains to empty, its callback runs short, and its de-jitter policy
/// de-primes and then re-primes a whole target's worth of fresh silence. The artifact is far
/// longer than the audio actually missing.
///
/// So on a `NO_FRAME` timeout with a DRAINING ring, ask for this instead. The policy stays on the
/// embedder's side because that is where its two ingredients live — the ring depth and the clock
/// since the last packet — and it must be: bounded in TIME (roughly twice the ring's own de-prime
/// fuse), never in callbacks or frames, and gated on the ring genuinely running out. A drought a
/// deep ring covers is inaudible, and concealing it would insert audio the late packets are about
/// to duplicate, pushing the stream permanently later. Core supplies only the mechanism, one frame
/// per call, at the cadence the embedder drains at.
///
/// Works on both audio planes, using each one's own concealer — libopus PLC on `0xC9`, the
/// repeat-and-fade of [`crate::audio::pcm::PcmConceal`] on `0xD3` (a lossless frame carries no
/// model of the signal for PLC to extrapolate from; `design/hi-res-audio.md` §4.5).
///
/// Returns [`PunktfunkStatus::NoFrame`] when nothing has decoded yet — both concealers build on
/// the last decoded frame, so before there is one there is nothing to build from — and if libopus
/// declines to interpolate. Both mean "write nothing this tick", exactly like a timeout.
///
/// `out->seq` and `out->pts_ns` read 0: this frame was never on the wire, so it has no sequence
/// number and no capture instant, and it must never be fed to an A/V-sync observation.
/// `out->samples` borrows connection memory until the next PCM call on this handle — the SAME
/// slot [`punktfunk_connection_next_audio_pcm`] hands out, so call both from the one audio thread.
///
/// Frames taken this way are subtracted from the concealment the next arriving packet asks for, so
/// a packet genuinely lost inside a covered drought is not concealed twice.
///
/// # Safety
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_audio_plc(
    c: *mut PunktfunkConnection,
    out: *mut PunktfunkAudioPcm,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if out.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        let fmt = AudioFormat::of(&c.inner);
        let channels = fmt.channels;
        let mut state = lock_recover(&c.audio_pcm);
        match state.conceal(fmt) {
            Ok(0) => PunktfunkStatus::NoFrame,
            Ok(samples) => {
                // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
                // matching `#[repr(C)]` type, written once by value.
                unsafe {
                    *out = PunktfunkAudioPcm {
                        samples: state.pcm.as_ptr(),
                        frame_count: (samples / channels.max(1) as usize) as u32,
                        channels,
                        seq: 0,
                        pts_ns: 0,
                    };
                }
                PunktfunkStatus::Ok
            }
            Err(status) => status,
        }
    })
}

/// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics
/// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
/// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to
/// `timeout_ms`. The payload is COPIED into `buf` (no borrow-until-next-call slot); the return
/// value is its length in bytes, `0` = nothing this poll (timeout — or a DTX/oversized frame,
/// both of which an embedder treats the same way), `-1` = the session ended (or an invalid
/// handle/buffer). All pads/kinds share one queue — fan out by `*out_pad`/`*out_kind` to
/// per-actuator Opus decoders. A frame larger than `buf_len` is dropped like the timeout case
/// (the plane is lossy by design; any real Opus frame fits a 1500-byte buffer). Only a session
/// connected with [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`] against a
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — with the pad declared via
/// [`punktfunk_connection_set_pad_audio_caps`] — ever receives any. Drain from a dedicated
/// thread (one puller, may run alongside the other planes' pullers).
///
/// # Safety
/// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
/// `buf` is writable for `buf_len` bytes.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_pad_audio(
    c: *mut PunktfunkConnection,
    out_pad: *mut u8,
    out_kind: *mut u8,
    out_seq: *mut u32,
    out_pts_ns: *mut u64,
    buf: *mut u8,
    buf_len: usize,
    timeout_ms: u32,
) -> i32 {
    let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return -1,
        };
        if buf.is_null() && buf_len != 0 {
            return -1;
        }
        match c
            .inner
            .next_pad_audio(std::time::Duration::from_millis(timeout_ms as u64))
        {
            Some(f) => {
                if f.opus.is_empty() || f.opus.len() > buf_len {
                    // DTX silence (skipped like the audio-PCM path — decoding an empty payload
                    // as loss would synthesize concealment) or doesn't fit — report "nothing
                    // this poll" (the next_hidout HidRaw-skip precedent; truncated Opus would
                    // be undecodable anyway).
                    return 0;
                }
                // SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-
                // checked before it is written; `buf` is a caller-owned writable region of
                // `buf_len` bytes and the copy length was just bounds-checked against it.
                unsafe {
                    if !out_pad.is_null() {
                        *out_pad = f.pad;
                    }
                    if !out_kind.is_null() {
                        *out_kind = f.kind;
                    }
                    if !out_seq.is_null() {
                        *out_seq = f.seq;
                    }
                    if !out_pts_ns.is_null() {
                        *out_pts_ns = f.pts_ns;
                    }
                    std::ptr::copy_nonoverlapping(f.opus.as_ptr(), buf, f.opus.len());
                }
                f.opus.len() as i32
            }
            // `None` folds timeout and closed; the shutdown flag tells them apart so the
            // embedder's plane loop can exit instead of polling a dead session forever.
            None if c.inner.is_session_ended() => -1,
            None => 0,
        }
    }));
    r.unwrap_or(-1)
}

/// Declare wire pad `pad`'s pad-audio render capabilities (`audio_caps`: OR of
/// [`PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS`] / [`PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER`]) — how a client
/// tells the host WHICH pads can actually play the 0xD1 streams. Call at controller attach,
/// BEFORE the pad's arrival event is sent (the [`punktfunk_connection_set_rumble_quirks`]
/// timing): the core folds the bits into the arrival's flags (bits 8/9), and only toward a
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — never calling this leaves the wire bytes exactly as
/// before. Latest-wins per pad; unknown bits are masked off.
///
/// # Safety
/// `c` is a valid connection handle. Callable from any thread.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_set_pad_audio_caps(
    c: *mut PunktfunkConnection,
    pad: u8,
    audio_caps: u8,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        c.inner.set_pad_audio_caps(pad, audio_caps);
        PunktfunkStatus::Ok
    })
}

/// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
/// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
///
/// This drops the self-terminating TTL of a v2 rumble envelope — an embedder that only calls this
/// keeps its own staleness policy, exactly as before. Use [`punktfunk_connection_next_rumble2`] to
/// honor the host-supplied lease and delete the client-side timeout heuristics.
///
/// # Safety
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At
/// most one thread pulls rumble — it may run concurrently with the video/audio pullers.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_rumble(
    c: *mut PunktfunkConnection,
    pad: *mut u16,
    low: *mut u16,
    high: *mut u16,
    timeout_ms: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        match c
            .inner
            .next_rumble(std::time::Duration::from_millis(timeout_ms as u64))
        {
            Ok((p, l, h)) => {
                // SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-
                // checked before it is written; a non-null one is a caller-owned writable slot.
                unsafe {
                    if !pad.is_null() {
                        *pad = p;
                    }
                    if !low.is_null() {
                        *low = l;
                    }
                    if !high.is_null() {
                        *high = h;
                    }
                }
                PunktfunkStatus::Ok
            }
            Err(e) => e.status(),
        }
    })
}

/// `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble
/// datagram — an old host that sent no self-termination lease. The client then falls back to its
/// own staleness heuristic for that update instead of a host-supplied deadline.
pub const PUNKTFUNK_RUMBLE_NO_TTL: u32 = 0xFFFF_FFFF;

/// Pull the next rumble update *including its self-termination TTL* (v2 envelopes), waiting up to
/// `timeout_ms`. Same `pad`/`low`/`high` semantics as [`punktfunk_connection_next_rumble`], plus
/// `*ttl_ms`: how long (milliseconds) to render this level before silencing unless the host renews
/// it. [`PUNKTFUNK_RUMBLE_NO_TTL`] means "no lease" — a legacy host; fall back to a client-side
/// timeout. The reorder gate (seq) is applied inside the core before the update surfaces here, so a
/// stale/reordered envelope never reaches the caller.
///
/// # Safety
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
/// thread pulls rumble — it may run concurrently with the video/audio pullers.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_rumble2(
    c: *mut PunktfunkConnection,
    pad: *mut u16,
    low: *mut u16,
    high: *mut u16,
    ttl_ms: *mut u32,
    timeout_ms: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        match c
            .inner
            .next_rumble_ttl(std::time::Duration::from_millis(timeout_ms as u64))
        {
            Ok((p, l, h, ttl)) => {
                // SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-
                // checked before it is written; a non-null one is a caller-owned writable slot.
                unsafe {
                    if !pad.is_null() {
                        *pad = p;
                    }
                    if !low.is_null() {
                        *low = l;
                    }
                    if !high.is_null() {
                        *high = h;
                    }
                    if !ttl_ms.is_null() {
                        *ttl_ms = ttl.map_or(PUNKTFUNK_RUMBLE_NO_TTL, u32::from);
                    }
                }
                PunktfunkStatus::Ok
            }
            Err(e) => e.status(),
        }
    })
}

/// `flags` bit for [`punktfunk_connection_set_rumble_quirks`]: alternate the low motor's LSB on
/// keepalive re-emits (imperceptible) so an SDL-class layer that no-ops identical values still
/// writes the device — the Steam Deck's dedupe-defeat.
pub const PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER: u32 = 1;

/// Pull the next EFFECTIVE rumble command from the shared policy engine — the uniform replacement
/// for per-platform rumble policy. Unlike [`punktfunk_connection_next_rumble2`], the caller never
/// sees a TTL and never owns a deadline: the engine emits the level on every wire update (renewals
/// re-arm duration-parameterized APIs), an explicit zero at lease expiry / legacy-host staleness
/// (a uniform 1 s) / connection close, and any keepalives declared via
/// [`punktfunk_connection_set_rumble_quirks`]. Apply commands verbatim: `(0, 0)` = stop now;
/// non-zero = run at this level, with `*backstop_ms` as the safety-net duration for platform APIs
/// that take one (explicit-stop APIs ignore it; it is `0` on stop commands).
/// [`PunktfunkStatus::NoFrame`] on timeout; [`PunktfunkStatus::Closed`] once the session ended AND
/// every close-drain stop was delivered — silence all actuators on it.
///
/// **Handle motors only.** A pad also carries two Xbox impulse-trigger levels, which this entry
/// point has no out-params for and never will —
/// [`punktfunk_connection_next_rumble_cmd2`] is the four-motor pull. Staying here is a supported
/// choice, not a deprecation: for a controller with no trigger motors — every pad but an Xbox
/// One/Series/Elite — the two views are identical, and where they differ, "the handles are silent"
/// is exactly the right instruction for the motors this API owns.
///
/// The one observable difference against a trigger-driving host: a rumble that moves only the
/// triggers still produces commands here, carrying `low == high == 0`. They are idempotent stops
/// for the handles; the engine's redundant-stop suppression cannot fold them away, because the
/// command is not silent — some motor on that pad is running.
///
/// An embedder uses EITHER this (or its `2` form) or `next_rumble`/`next_rumble2` for a
/// connection's lifetime, never both (they consume the same wire plane).
///
/// # Safety
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
/// thread pulls rumble — it may run concurrently with the video/audio pullers.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd(
    c: *mut PunktfunkConnection,
    pad: *mut u16,
    low: *mut u16,
    high: *mut u16,
    backstop_ms: *mut u32,
    timeout_ms: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        match c
            .inner
            .next_rumble_command(std::time::Duration::from_millis(timeout_ms as u64))
        {
            Ok(cmd) => {
                // SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-
                // checked before it is written; a non-null one is a caller-owned writable slot.
                unsafe {
                    if !pad.is_null() {
                        *pad = cmd.pad;
                    }
                    if !low.is_null() {
                        *low = cmd.low;
                    }
                    if !high.is_null() {
                        *high = cmd.high;
                    }
                    if !backstop_ms.is_null() {
                        *backstop_ms = cmd.backstop_ms;
                    }
                }
                PunktfunkStatus::Ok
            }
            Err(e) => e.status(),
        }
    })
}

/// [`punktfunk_connection_next_rumble_cmd`] with the two Xbox impulse-trigger motors: the same
/// command, all four of its levels. `*left_trigger` / `*right_trigger` are on the same
/// `0..=0xFFFF` scale as `low`/`high`, and a stop is all four at zero.
///
/// A NEW symbol rather than a wider signature on the old one, following the
/// `next_rumble` → `next_rumble2` precedent in this file: an exported entry point's parameter list
/// is part of the contract, and silently growing one breaks every out-of-tree embedder at once,
/// with a stack-corruption signature rather than a link error. Old callers keep the old symbol and
/// simply never see the trigger levels.
///
/// **Render the trigger levels only on a pad that actually has trigger motors, and drop them
/// otherwise** — do not fold them into the handles. Impulse-trigger content is continuous
/// (a racing title drives engine RPM and tyre slip into the triggers while the handles stay near
/// silent), so folding it produces a handle motor droning flat-out for the whole race at a level
/// the game never asked for. Query the hardware: SDL's
/// `SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN`, Apple's `GCDeviceHaptics.supportedLocalities`
/// (`GCHapticsLocalityLeftTrigger`/`…RightTrigger`). A pad without them is the common case and not
/// an error — do not log per command.
///
/// **Nothing has driven these levels non-zero end to end yet, and that is structural, not an
/// oversight.** Exactly one producer can ever source them — the Windows HID Xbox pad's output
/// report `0x03` — because classic XInput's `XINPUT_VIBRATION` has two members and evdev's
/// `FF_RUMBLE` has two, so no other host backend on any OS has the channel. That producer is
/// reachable only through GameInput/WGI, and an xinputhid-promoted Xbox pad is not enumerated by
/// GameInput at all (measured against a real Microsoft Elite, which is equally invisible there
/// while XInput reads it live). So this delivery path is deliberately built ahead of its producer:
/// the wire, the engine and this entry point are exercised only by synthetic levels.
///
/// Same threading, timeout and close semantics as
/// [`punktfunk_connection_next_rumble_cmd`]; the two share one wire plane and one policy engine,
/// so an embedder calls exactly one of them.
///
/// # Safety
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
/// thread pulls rumble — it may run concurrently with the video/audio pullers.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd2(
    c: *mut PunktfunkConnection,
    pad: *mut u16,
    low: *mut u16,
    high: *mut u16,
    left_trigger: *mut u16,
    right_trigger: *mut u16,
    backstop_ms: *mut u32,
    timeout_ms: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        match c
            .inner
            .next_rumble_command(std::time::Duration::from_millis(timeout_ms as u64))
        {
            Ok(cmd) => {
                // SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-
                // checked before it is written; a non-null one is a caller-owned writable slot.
                unsafe {
                    if !pad.is_null() {
                        *pad = cmd.pad;
                    }
                    if !low.is_null() {
                        *low = cmd.low;
                    }
                    if !high.is_null() {
                        *high = cmd.high;
                    }
                    if !left_trigger.is_null() {
                        *left_trigger = cmd.left_trigger;
                    }
                    if !right_trigger.is_null() {
                        *right_trigger = cmd.right_trigger;
                    }
                    if !backstop_ms.is_null() {
                        *backstop_ms = cmd.backstop_ms;
                    }
                }
                PunktfunkStatus::Ok
            }
            Err(e) => e.status(),
        }
    })
}

/// Declare a physical actuator's quirks for wire pad `pad` — how a platform parameterizes the
/// shared rumble policy engine instead of forking it (typically called at controller attach).
/// `keepalive_ms`: re-emit an unchanged non-zero level at this cadence for actuators whose
/// hardware output decays between wire renewals (the Steam Deck's ≈ 40 is the one in-tree user);
/// `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands — no in-tree caller
/// sets it, it exists for embedders whose duration-taking API rejects short values. `flags`:
/// [`PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER`]. All-zero (the initial state) describes a well-behaved
/// actuator. See [`ActuatorQuirks`](crate::client::rumble::ActuatorQuirks) for why a renderer that
/// dedupes its own writes (the Apple HID path) cannot use `keepalive_ms` and keeps its own.
///
/// # Safety
/// `c` is a valid connection handle. Callable from any thread.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_set_rumble_quirks(
    c: *mut PunktfunkConnection,
    pad: u16,
    keepalive_ms: u16,
    min_pulse_ms: u16,
    flags: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        c.inner.set_rumble_quirks(
            pad,
            crate::client::ActuatorQuirks {
                keepalive_ms,
                min_pulse_ms,
                dedup_jitter: flags & PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER != 0,
            },
        );
        PunktfunkStatus::Ok
    })
}

/// Pull the next DualSense HID-output feedback event (lightbar / player LEDs / adaptive trigger)
/// the host's virtual pad received from a game, into `*out`. [`PunktfunkStatus::NoFrame`] on
/// timeout, [`PunktfunkStatus::Closed`] once the session ended. Only the DualSense host backend
/// emits these. Same threading rules as [`punktfunk_connection_next_rumble`] (one puller, may run
/// alongside the other planes).
///
/// # Safety
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkHidOutput`.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_hidout(
    c: *mut PunktfunkConnection,
    out: *mut PunktfunkHidOutput,
    timeout_ms: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if out.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        match c
            .inner
            .next_hidout(std::time::Duration::from_millis(timeout_ms as u64))
        {
            Ok(h) => match PunktfunkHidOutput::from_hid(&h) {
                Some(v) => {
                    // SAFETY: per the ABI contract - a caller-owned out-param, non-null on this
                    // path, written once by value.
                    unsafe { *out = v };
                    PunktfunkStatus::Ok
                }
                // A raw as-is passthrough report (no C representation) — report "nothing this
                // poll" and let the embedder's poll loop continue; see `from_hid`.
                None => PunktfunkStatus::NoFrame,
            },
            Err(e) => e.status(),
        }
    })
}

/// Pull the next static HDR metadata update (ST.2086 mastering display + content light level) for
/// an HDR session, into `*out`. [`PunktfunkStatus::NoFrame`] on timeout, [`PunktfunkStatus::Closed`]
/// once the session ended. The host sends one near session start and re-sends it on mastering
/// changes / keyframes; apply the latest to the display (`SetHDRMetaData` / `CAEDRMetadata` /
/// `KEY_HDR_STATIC_INFO`). Only an HDR session (`punktfunk_connection_color_info` reports a PQ
/// transfer) ever emits these. Same threading rules as [`punktfunk_connection_next_rumble`] (one
/// puller, may run alongside the other planes).
///
/// # Safety
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkHdrMeta`.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_hdr_meta(
    c: *mut PunktfunkConnection,
    out: *mut PunktfunkHdrMeta,
    timeout_ms: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if out.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        match c
            .inner
            .next_hdr_meta(std::time::Duration::from_millis(timeout_ms as u64))
        {
            Ok(m) => {
                // SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
                // written once by value.
                unsafe { *out = PunktfunkHdrMeta::from_meta(&m) };
                PunktfunkStatus::Ok
            }
            Err(e) => e.status(),
        }
    })
}

/// One forwarded host-cursor shape (ABI v11, the cursor channel): straight-alpha RGBA8, no
/// padding, `len == w * h * 4`, hotspot within `w`×`h`. `serial` is the identity
/// [`PunktfunkCursorState`] refers to — cache the built OS cursor by it.
#[repr(C)]
pub struct PunktfunkCursorShape {
    pub serial: u32,
    pub w: u16,
    pub h: u16,
    pub hot_x: u16,
    pub hot_y: u16,
    /// Borrows connection memory until the NEXT cursor-shape call (the audio contract).
    pub rgba: *const u8,
    pub len: usize,
}

/// Per-frame host-cursor state (ABI v11): position (the pointer/hotspot point in the host
/// video's pixel space), visibility, and the host-driven relative-mode hint. `flags` bit 0 =
/// visible, bit 1 = relative hint (a host app grabbed/hid the pointer — run captured
/// relative; clear = return to absolute, reappearing at `x`/`y`).
#[repr(C)]
pub struct PunktfunkCursorState {
    pub serial: u32,
    pub flags: u8,
    pub x: i32,
    pub y: i32,
}

/// Pull the next forwarded cursor SHAPE (sent on pointer-bitmap change over the reliable
/// control stream; only a session connected with `PUNKTFUNK_CLIENT_CAP_CURSOR` against a
/// capable host receives any). On `Ok`, `out->rgba` borrows connection memory until the next
/// cursor-shape call on this handle. Drain from a dedicated thread (one thread per plane).
///
/// # Safety
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls cursor
/// shapes; it may run concurrently with every other plane's puller.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_cursor_shape(
    c: *mut PunktfunkConnection,
    out: *mut PunktfunkCursorShape,
    timeout_ms: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if out.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        match c
            .inner
            .next_cursor_shape(std::time::Duration::from_millis(timeout_ms as u64))
        {
            Ok(shape) => {
                let mut slot = lock_recover(&c.last_cursor_shape);
                let sh = slot.insert(shape);
                // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
                // matching `#[repr(C)]` type, written once by value.
                unsafe {
                    *out = PunktfunkCursorShape {
                        serial: sh.serial,
                        w: sh.w,
                        h: sh.h,
                        hot_x: sh.hot_x,
                        hot_y: sh.hot_y,
                        rgba: sh.rgba.as_ptr(),
                        len: sh.rgba.len(),
                    };
                }
                PunktfunkStatus::Ok
            }
            Err(e) => e.status(),
        }
    })
}

/// Pull the next cursor STATE (a `0xD0` datagram per host encode tick — latest-wins; drain
/// the queue and apply only the newest). Same negotiation gate as
/// [`punktfunk_connection_next_cursor_shape`].
///
/// # Safety
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls cursor
/// state; it may run concurrently with every other plane's puller.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_cursor_state(
    c: *mut PunktfunkConnection,
    out: *mut PunktfunkCursorState,
    timeout_ms: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if out.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        match c
            .inner
            .next_cursor_state(std::time::Duration::from_millis(timeout_ms as u64))
        {
            Ok(st) => {
                // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
                // matching `#[repr(C)]` type, written once by value.
                unsafe {
                    *out = PunktfunkCursorState {
                        serial: st.serial,
                        flags: st.flags,
                        x: st.x,
                        y: st.y,
                    };
                }
                PunktfunkStatus::Ok
            }
            Err(e) => e.status(),
        }
    })
}

/// Tell the host who renders the pointer (design/remote-desktop-sweep.md §8 — the mid-stream
/// mouse-model flip): `client_draws = true` = this client draws it locally (the desktop mouse
/// model; the host excludes the pointer from the video and forwards shape/state), `false` =
/// the host composites it into the video (the capture model — full fidelity, the pre-channel
/// look). Idempotent, latest-wins; harmless against hosts without the cursor cap (an unknown
/// control message type, ignored). ABI v12.
///
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_set_cursor_render(
    c: *mut PunktfunkConnection,
    client_draws: bool,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        match c.inner.set_cursor_render(client_draws) {
            Ok(()) => PunktfunkStatus::Ok,
            Err(e) => e.status(),
        }
    })
}

/// Pull the next per-AU host timing (0xCF) into `*out`: the host's capture→sent duration for one
/// access unit, correlated to the AU by `pts_ns` (see [`PunktfunkHostTiming`]).
/// [`PunktfunkStatus::NoFrame`] on timeout, [`PunktfunkStatus::Closed`] once the session ended.
/// A stats consumer drains this non-blockingly (`timeout_ms = 0`) alongside its frame samples;
/// an older host never emits any — keep showing the combined `host+network` stage then. Same
/// threading rules as [`punktfunk_connection_next_rumble`] (one puller, may run alongside the
/// other planes).
///
/// # Safety
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkHostTiming`.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_host_timing(
    c: *mut PunktfunkConnection,
    out: *mut PunktfunkHostTiming,
    timeout_ms: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if out.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        match c
            .inner
            .next_host_timing(std::time::Duration::from_millis(timeout_ms as u64))
        {
            Ok(t) => {
                // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
                // matching `#[repr(C)]` type, written once by value.
                unsafe {
                    *out = PunktfunkHostTiming {
                        pts_ns: t.pts_ns,
                        host_us: t.host_us,
                    }
                };
                PunktfunkStatus::Ok
            }
            Err(e) => e.status(),
        }
    })
}

/// Read the session's resolved colour signalling + encode bit depth (from the host's Welcome).
/// Each out pointer is filled when non-NULL: `primaries`/`transfer`/`matrix` are CICP code points
/// (BT.709 = 1; BT.2020 = 9; PQ transfer = 16, HLG = 18; BT.2020-NCL matrix = 9), `full_range` is
/// 0 (limited) or 1 (full), `bit_depth` is 8 or 10. A `transfer` of 16/18 means HDR — configure an
/// HDR present path and drain [`punktfunk_connection_next_hdr_meta`]. Available immediately after a
/// successful connect (these don't change without a reconfigure).
///
/// # Safety
/// `c` is a valid connection handle; each out pointer is NULL or writable for its scalar.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_color_info(
    c: *mut PunktfunkConnection,
    primaries: *mut u8,
    transfer: *mut u8,
    matrix: *mut u8,
    full_range: *mut u8,
    bit_depth: *mut u8,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        let color = c.inner.color;
        // SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-checked
        // before it is written; a non-null one is a caller-owned writable slot.
        unsafe {
            if !primaries.is_null() {
                *primaries = color.primaries;
            }
            if !transfer.is_null() {
                *transfer = color.transfer;
            }
            if !matrix.is_null() {
                *matrix = color.matrix;
            }
            if !full_range.is_null() {
                *full_range = color.full_range;
            }
            if !bit_depth.is_null() {
                *bit_depth = c.inner.bit_depth;
            }
        }
        PunktfunkStatus::Ok
    })
}

/// Read the session's resolved chroma subsampling (from the host's Welcome) as the HEVC
/// `chroma_format_idc`: `1` = 4:2:0 (the default every pre-4:4:4 host produced), `3` = full-chroma
/// 4:4:4. `*out` is filled when non-NULL. The in-band SPS is authoritative; this lets the embedder
/// pre-size its decoder / pick a 4:4:4 pixel format up front. Available immediately after a
/// successful connect (it doesn't change without a reconfigure).
///
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_chroma_format(
    c: *mut PunktfunkConnection,
    out: *mut u8,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if !out.is_null() {
            // SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`.
            unsafe { *out = c.inner.chroma_format };
        }
        PunktfunkStatus::Ok
    })
}

/// Read the video codec the host resolved for this session (from its Welcome): one of
/// [`PUNKTFUNK_CODEC_H264`] / [`PUNKTFUNK_CODEC_HEVC`] / [`PUNKTFUNK_CODEC_AV1`]. The embedder builds
/// its decoder from THIS (never assuming HEVC). `*out` is filled when non-NULL. Available
/// immediately after a successful connect (it doesn't change without a reconfigure). An older host
/// that didn't negotiate a codec reports [`PUNKTFUNK_CODEC_HEVC`].
///
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_codec(
    c: *mut PunktfunkConnection,
    out: *mut u8,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if !out.is_null() {
            // SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`.
            unsafe { *out = c.inner.codec };
        }
        PunktfunkStatus::Ok
    })
}

/// Read the session's negotiated wire shard payload (the `Welcome`'s value, bytes). This is the
/// parse-window size of a [`USER_FLAG_CHUNK_ALIGNED`] AU (PyroWave datagram-aligned mode,
/// design/pyrowave-codec-plan.md §4.4): every `shard_payload`-sized window of the frame buffer
/// starts a fresh self-delimiting chunk. Clients that decode PyroWave natively (the Apple Metal
/// port) need it to walk those AUs; other codecs never need this.
///
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u32`.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_shard_payload(
    c: *mut PunktfunkConnection,
    out: *mut u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if !out.is_null() {
            // SAFETY: `out` is non-null and the caller guarantees it is writable for one `u32`.
            unsafe { *out = u32::from(c.inner.shard_payload) };
        }
        PunktfunkStatus::Ok
    })
}

/// Send one input event to the host as a QUIC datagram (non-blocking enqueue).
///
/// Returns `InvalidArg` if `ev->kind` is not a recognized event kind.
///
/// # Safety
/// `c` is a valid connection handle; `ev` points to a readable `InputEvent`-sized allocation.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_send_input(
    c: *mut PunktfunkConnection,
    ev: *const InputEvent,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        // SAFETY: `read_input_event` upholds this file's failures-become-status-codes principle
        // for the one field where a reference formed too early would be UB instead.
        let ev = match unsafe { read_input_event(ev) } {
            Ok(e) => e,
            Err(status) => return status,
        };
        match c.inner.send_input(ev) {
            Ok(()) => PunktfunkStatus::Ok,
            Err(e) => e.status(),
        }
    })
}

/// Send one Opus mic frame to the host as a QUIC datagram (48 kHz; the host decodes it into a
/// virtual microphone source its apps can record). Non-blocking enqueue; the host uses `seq`/
/// `pts_ns` (the caller's own counters) only for diagnostics. `opus_data`/`len` may be empty
/// (a DTX silence frame). The data is copied; the caller may reuse the buffer after this returns.
///
/// # Safety
/// `c` is a valid connection handle; `opus_data` is valid for `len` bytes (or `len == 0`).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_send_mic(
    c: *mut PunktfunkConnection,
    opus_data: *const u8,
    len: usize,
    seq: u32,
    pts_ns: u64,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if opus_data.is_null() && len != 0 {
            return PunktfunkStatus::NullPointer;
        }
        let opus = if len == 0 {
            Vec::new()
        } else {
            // SAFETY: per the ABI contract - a caller-supplied pointer/length pair describing one
            // readable region, borrowed only for this call.
            unsafe { std::slice::from_raw_parts(opus_data, len) }.to_vec()
        };
        match c.inner.send_mic(seq, pts_ns, opus) {
            Ok(()) => PunktfunkStatus::Ok,
            Err(e) => e.status(),
        }
    })
}

/// Send one rich input event (DualSense touchpad contact or motion sample) to the host as a QUIC
/// datagram (non-blocking enqueue). The host applies it to its virtual DualSense pad — a no-op
/// unless the host runs the DualSense gamepad backend. [`PunktfunkStatus::InvalidArg`] on an
/// unknown `kind`.
///
/// # Safety
/// `c` is a valid connection handle; `rich` points to a valid [`PunktfunkRichInput`].
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_send_rich_input(
    c: *mut PunktfunkConnection,
    rich: *const PunktfunkRichInput,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let rich = match unsafe { rich.as_ref() } {
            Some(r) => r,
            None => return PunktfunkStatus::NullPointer,
        };
        match rich.to_rich() {
            Some(r) => match c.inner.send_rich_input(r) {
                Ok(()) => PunktfunkStatus::Ok,
                Err(e) => e.status(),
            },
            None => PunktfunkStatus::InvalidArg,
        }
    })
}

/// Send a rich client→host input via the forward-compatible [`PunktfunkRichInputEx`] — the only way
/// a C client can emit a `TouchpadEx` (a second trackpad / signed coords / pressure). Set
/// `rich->struct_size = sizeof(PunktfunkRichInputEx)`; a smaller (older-layout) value is rejected.
///
/// # Safety
/// `c` is a valid connection handle; `rich` is null or points to at least its declared
/// `struct_size` bytes.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_send_rich_input2(
    c: *mut PunktfunkConnection,
    rich: *const PunktfunkRichInputEx,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if rich.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        // Read only the 4-byte size prefix first to bound the subsequent full read (the
        // `PunktfunkConfig` ABI-skew precedent).
        // SAFETY: `addr_of!` forms a raw pointer WITHOUT creating a reference, which is the point:
        // the caller's struct may be an older, smaller version, so the field is read by offset
        // rather than through a `&`.
        let declared = unsafe { std::ptr::addr_of!((*rich).struct_size).read_unaligned() } as usize;
        if declared < std::mem::size_of::<PunktfunkRichInputEx>() {
            return PunktfunkStatus::InvalidArg;
        }
        // SAFETY: per the ABI contract - the pointer operands in this block are caller-supplied
        // and are null-checked or handle-validated on this path before they are read.
        match unsafe { *rich }.to_rich() {
            Some(r) => match c.inner.send_rich_input(r) {
                Ok(()) => PunktfunkStatus::Ok,
                Err(e) => e.status(),
            },
            None => PunktfunkStatus::InvalidArg,
        }
    })
}

/// Send one stylus sample batch — `count` (`1..=PUNKTFUNK_PEN_BATCH_MAX`) state-full
/// [`PunktfunkPenSample`]s, oldest first (a capture callback's coalesced samples) — as one
/// `0xCC/0x05` pen datagram (non-blocking enqueue; design/pen-tablet-input.md). Split longer
/// runs into consecutive calls. Gate on `punktfunk_connection_host_caps() &
/// PUNKTFUNK_HOST_CAP_PEN`: toward a host without the bit this returns
/// [`PunktfunkStatus::Unsupported`] — keep the pen-as-touch fallback there.
/// [`PunktfunkStatus::InvalidArg`] on a bad count or a bad sample (non-finite coordinate,
/// unknown state bit / tool).
///
/// # Safety
/// `c` is a valid connection handle; `samples` is null or points to `count` valid
/// [`PunktfunkPenSample`]s.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_send_pen(
    c: *mut PunktfunkConnection,
    samples: *const PunktfunkPenSample,
    count: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if samples.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        if count == 0 || count > PUNKTFUNK_PEN_BATCH_MAX {
            return PunktfunkStatus::InvalidArg;
        }
        // SAFETY: per the ABI contract - a caller-supplied pointer/length pair describing one
        // readable region, borrowed only for this call.
        let raw = unsafe { std::slice::from_raw_parts(samples, count as usize) };
        let mut batch = [crate::quic::PenSample::default(); crate::quic::PEN_BATCH_MAX];
        for (slot, s) in batch.iter_mut().zip(raw) {
            match s.to_sample() {
                Some(v) => *slot = v,
                None => return PunktfunkStatus::InvalidArg,
            }
        }
        match c.inner.send_pen(&batch[..count as usize]) {
            Ok(()) => PunktfunkStatus::Ok,
            Err(e) => e.status(),
        }
    })
}

/// The currently active session mode — the Welcome's, until an accepted
/// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect.
///
/// # Safety
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_mode(
    c: *const PunktfunkConnection,
    width: *mut u32,
    height: *mut u32,
    refresh_hz: *mut u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        let mode = c.inner.mode();
        // SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-checked
        // before it is written; a non-null one is a caller-owned writable slot.
        unsafe {
            if !width.is_null() {
                *width = mode.width;
            }
            if !height.is_null() {
                *height = mode.height;
            }
            if !refresh_hz.is_null() {
                *refresh_hz = mode.refresh_hz;
            }
        }
        PunktfunkStatus::Ok
    })
}

/// The virtual gamepad backend the host actually resolved for this session (one of the
/// `PUNKTFUNK_GAMEPAD_*` values; the `Welcome`'s echo of the [`punktfunk_connect_ex2`]
/// preference). `PUNKTFUNK_GAMEPAD_AUTO` = an older host that didn't say — assume X-Box 360,
/// no HID-output feedback. Safe any time after connect.
///
/// # Safety
/// `c` is a valid connection handle; `gamepad` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_gamepad(
    c: *const PunktfunkConnection,
    gamepad: *mut u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        // SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-checked
        // before it is written; a non-null one is a caller-owned writable slot.
        unsafe {
            if !gamepad.is_null() {
                *gamepad = c.inner.resolved_gamepad.to_u8() as u32;
            }
        }
        PunktfunkStatus::Ok
    })
}

// ============================================================================================
// Shared clipboard (design/clipboard-and-file-transfer.md §5.1). Additive, ABI v6. All poll/serve
// bytes ride the mTLS-pinned QUIC session; nothing here opens a new listener or port.
// ============================================================================================

/// [`PunktfunkClipEvent::kind`] — the host announced clipboard content is available
/// (`transfer_id` = the offer `seq`; `data`/`len` = a `\n`-separated `"<mime>\t<size_hint>"`
/// format list). Fetch it lazily (only on a local paste) via
/// [`punktfunk_connection_clipboard_fetch`].
pub const PUNKTFUNK_CLIP_REMOTE_OFFER: u8 = 1;
/// [`PunktfunkClipEvent::kind`] — host ack / policy / backend update (`enabled`/`policy`/`reason`
/// valid). Reflect it in the toggle UI.
pub const PUNKTFUNK_CLIP_STATE: u8 = 2;
/// [`PunktfunkClipEvent::kind`] — the host is pasting our offered data: answer with
/// [`punktfunk_connection_clipboard_serve`] (`transfer_id` = `req_id`; `seq`/`file_index` valid;
/// `data`/`len` = the requested MIME).
pub const PUNKTFUNK_CLIP_FETCH_REQUEST: u8 = 3;
/// [`PunktfunkClipEvent::kind`] — bytes for a fetch we started (`transfer_id` = `xfer_id`;
/// `data`/`len` = the payload, borrowed until the next `next_clipboard`; `last` = final chunk).
pub const PUNKTFUNK_CLIP_DATA: u8 = 4;
/// [`PunktfunkClipEvent::kind`] — a transfer was cancelled (`transfer_id` = the id).
pub const PUNKTFUNK_CLIP_CANCELLED: u8 = 5;
/// [`PunktfunkClipEvent::kind`] — a transfer failed (`transfer_id` = the id; `status` = a
/// `PunktfunkStatus` code).
pub const PUNKTFUNK_CLIP_ERROR: u8 = 6;

/// One advertised clipboard format passed to [`punktfunk_connection_clipboard_offer`].
#[cfg(feature = "quic")]
#[repr(C)]
pub struct PunktfunkClipKind {
    /// NUL-terminated UTF-8 wire MIME (e.g. `text/plain;charset=utf-8`). ≤ 128 bytes on the wire.
    pub mime: *const std::os::raw::c_char,
    /// Best-effort size in bytes; `0` = unknown.
    pub size_hint: u64,
}

/// A shared-clipboard event, filled by [`punktfunk_connection_next_clipboard`]. A flat tagged
/// struct (like `PunktfunkHidOutput`): read the fields named in the `kind`'s doc; the rest are 0.
#[cfg(feature = "quic")]
#[repr(C)]
#[derive(Clone, Copy)]
pub struct PunktfunkClipEvent {
    /// One of `PUNKTFUNK_CLIP_*`.
    pub kind: u8,
    /// `State`: 1 = enabled, 0 = disabled.
    pub enabled: u8,
    /// `State`: bitfield of `quic::CLIP_POLICY_*` — what the host currently permits.
    pub policy: u8,
    /// `State`: one of `quic::CLIP_REASON_*`.
    pub reason: u8,
    /// `Data`: 1 = final chunk of this transfer.
    pub last: u8,
    /// Per-transfer id: the offer `seq` (RemoteOffer), the `req_id` (FetchRequest), or the
    /// `xfer_id` (Data/Cancelled/Error).
    pub transfer_id: u32,
    /// `FetchRequest`: the offer `seq` the request is against.
    pub seq: u32,
    /// `FetchRequest`: file index, or `quic::CLIP_FILE_INDEX_NONE`.
    pub file_index: u32,
    /// `Error`: a `PunktfunkStatus` code (negative); 0 otherwise.
    pub status: i32,
    /// RemoteOffer/FetchRequest/Data: a pointer into a per-connection slot, valid until the next
    /// `next_clipboard` call; NULL for the other kinds.
    pub data: *const u8,
    /// Byte length of `data` (0 when `data` is NULL).
    pub len: usize,
}

/// Fill a [`PunktfunkClipEvent`] from a core event, parking any variable-length bytes in `slot`
/// (borrow-until-next-call) and pointing `data`/`len` at them.
#[cfg(feature = "quic")]
fn build_clip_event(
    ev: crate::clipboard::ClipEventCore,
    slot: &mut Option<Vec<u8>>,
) -> PunktfunkClipEvent {
    use crate::clipboard::ClipEventCore as E;
    let mut out = PunktfunkClipEvent {
        kind: 0,
        enabled: 0,
        policy: 0,
        reason: 0,
        last: 0,
        transfer_id: 0,
        seq: 0,
        file_index: 0,
        status: 0,
        data: std::ptr::null(),
        len: 0,
    };
    *slot = None;
    match ev {
        E::RemoteOffer { seq, kinds } => {
            out.kind = PUNKTFUNK_CLIP_REMOTE_OFFER;
            out.transfer_id = seq;
            let mut blob = String::new();
            for k in &kinds {
                blob.push_str(&k.mime);
                blob.push('\t');
                blob.push_str(&k.size_hint.to_string());
                blob.push('\n');
            }
            *slot = Some(blob.into_bytes());
        }
        E::State {
            enabled,
            policy,
            reason,
        } => {
            out.kind = PUNKTFUNK_CLIP_STATE;
            out.enabled = enabled as u8;
            out.policy = policy;
            out.reason = reason;
        }
        E::FetchRequest {
            req_id,
            seq,
            file_index,
            mime,
        } => {
            out.kind = PUNKTFUNK_CLIP_FETCH_REQUEST;
            out.transfer_id = req_id;
            out.seq = seq;
            out.file_index = file_index;
            *slot = Some(mime.into_bytes());
        }
        E::Data {
            xfer_id,
            bytes,
            last,
        } => {
            out.kind = PUNKTFUNK_CLIP_DATA;
            out.transfer_id = xfer_id;
            out.last = last as u8;
            *slot = Some(bytes);
        }
        E::Cancelled { id } => {
            out.kind = PUNKTFUNK_CLIP_CANCELLED;
            out.transfer_id = id;
        }
        E::Error { id, code } => {
            out.kind = PUNKTFUNK_CLIP_ERROR;
            out.transfer_id = id;
            out.status = code;
        }
    }
    if let Some(v) = slot.as_ref() {
        out.data = v.as_ptr();
        out.len = v.len();
    }
    out
}

/// The host's management-API port, from this session's `Welcome` — where its game library is
/// served (distinct from the streaming ports). `0` means the host did not advertise one: an older
/// host, or the standalone `punktfunk1-host` binary, which has no management API. Treat `0` as
/// "unknown" and fall back to your own default (47990), never as a port to dial.
///
/// This exists so a client does NOT need mDNS to find the library. The port used to live only in
/// the host's mDNS TXT, so a host that had moved it off 47990 — the supported way to coexist with
/// a Sunshine fork, whose web UI owns that port — was reachable only where multicast worked. Read
/// this after connect and prefer it over any cached or default value. Safe any time after connect.
///
/// # Safety
/// `c` is a valid connection handle; `port` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_mgmt_port(
    c: *const PunktfunkConnection,
    port: *mut u16,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        // SAFETY: per the ABI contract - the out-param is OPTIONAL, so it is null-checked before
        // it is written; a non-null one is a caller-owned writable slot.
        unsafe {
            if !port.is_null() {
                *port = c.inner.mgmt_port();
            }
        }
        PunktfunkStatus::Ok
    })
}

/// The host capability bitfield the session's `Welcome` carried — a bitfield of
/// `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` / `PUNKTFUNK_HOST_CAP_CLIPBOARD` /
/// `PUNKTFUNK_HOST_CAP_PEN`. A client tests `caps & PUNKTFUNK_HOST_CAP_CLIPBOARD` to decide
/// whether to offer the shared-clipboard toggle, `caps & PUNKTFUNK_HOST_CAP_PEN` before
/// sending stylus batches. Safe any time after connect.
///
/// # Safety
/// `c` is a valid connection handle; `caps` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_host_caps(
    c: *const PunktfunkConnection,
    caps: *mut u8,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        // SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-checked
        // before it is written; a non-null one is a caller-owned writable slot.
        unsafe {
            if !caps.is_null() {
                *caps = c.inner.host_caps();
            }
        }
        PunktfunkStatus::Ok
    })
}

/// The session's LIVE effective access grants — a `PUNKTFUNK_GRANT_*` bitmask
/// (per-client access, `design/per-client-access.md` §7): seeded from the `Welcome` advert
/// and moved by every mid-session `AccessUpdate` the host sends (latest wins), so this is
/// current state, NOT a connect-time snapshot. An old host advertises nothing and this reads
/// `PUNKTFUNK_GRANT_ALL` — full control, the pre-grants behavior, so an embedder keying UI
/// off it changes nothing there.
///
/// Courtesy truth only: the HOST enforces the mask whatever a client renders. Use it to not
/// capture what can't land (no pointer lock / keyboard grab without the bits) and to label
/// the session ("Controller only"). Cheap (one relaxed atomic load) — poll it alongside a
/// stats tick rather than caching it for the session. Safe any time after connect.
///
/// # Safety
/// `c` is a valid connection handle; `grants` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_grants(
    c: *const PunktfunkConnection,
    grants: *mut u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        // SAFETY: per the ABI contract - the out-param is OPTIONAL, so it is null-checked before
        // it is written; a non-null one is a caller-owned writable slot.
        unsafe {
            if !grants.is_null() {
                *grants = c.inner.access_grants();
            }
        }
        PunktfunkStatus::Ok
    })
}

/// Seconds until this session's access expires, LIVE — counted down from the `Welcome`'s
/// `expires_in_secs` and re-anchored by every mid-session `AccessUpdate`, so successive reads
/// shrink on their own (render a countdown by polling this, ~1 Hz). `0` = permanent: today's
/// default, and everything an old host's Welcome decodes to — show nothing then. The deadline
/// is anchored to the CLIENT's clock at receipt (the wire carries relative seconds), so
/// host/client skew never moves the countdown.
///
/// While a deadline exists the value never reads `0`: in the sliver between the deadline
/// passing and the host's typed expiry close (`PUNKTFUNK_STATUS_REJECTED_ACCESS_EXPIRED`
/// via [`punktfunk_connection_end_reject`]) it clamps to `1`, so `0` stays unambiguous.
/// The T−5 m / T−1 m warnings are the embedder's to derive from the countdown crossing
/// those marks. Safe any time after connect.
///
/// # Safety
/// `c` is a valid connection handle; `secs` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_access_expires_in(
    c: *const PunktfunkConnection,
    secs: *mut u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        let remaining = match c.inner.access_deadline_unix() {
            None => 0,
            Some(deadline) => {
                let now = crate::quic::wall_clock_ns() / 1_000_000_000;
                // Clamp to ≥ 1 while a deadline is set: 0 means "permanent", never "expired".
                u32::try_from(deadline.saturating_sub(now))
                    .unwrap_or(u32::MAX)
                    .max(1)
            }
        };
        // SAFETY: per the ABI contract - the out-param is OPTIONAL, so it is null-checked before
        // it is written; a non-null one is a caller-owned writable slot.
        unsafe {
            if !secs.is_null() {
                *secs = remaining;
            }
        }
        PunktfunkStatus::Ok
    })
}

/// The typed rejection a MID-SESSION close carried, as its `PUNKTFUNK_STATUS_REJECTED_*`
/// value (`0` = none — every ordinary end). Exists because
/// [`punktfunk_connection_end_reason`] can only file an unrecognized deliberate close under
/// `PUNKTFUNK_END_REASON_HOST_ERROR`, and "the host ended the session with an error" is the
/// wrong sentence for an access expiry (`PUNKTFUNK_STATUS_REJECTED_ACCESS_EXPIRED`) — the
/// case this was added for; any future typed mid-session close surfaces the same way. Ask
/// AFTER the session ended, before freeing the handle, exactly like `end_reason` (the two
/// latch together); connect-time rejections never land here — they come back from the
/// connect call itself.
///
/// # Safety
/// `c` is a valid connection handle; `status` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_end_reject(
    c: *const PunktfunkConnection,
    status: *mut i32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        let value = match c.inner.end_reject() {
            Some(reason) => crate::error::PunktfunkError::Rejected(reason).status() as i32,
            None => 0,
        };
        // SAFETY: per the ABI contract - the out-param is OPTIONAL, so it is null-checked before
        // it is written; a non-null one is a caller-owned writable slot.
        unsafe {
            if !status.is_null() {
                *status = value;
            }
        }
        PunktfunkStatus::Ok
    })
}

/// Enable or disable the shared clipboard for this session (`design` §3.1). Opt-in: nothing is
/// announced or served until this is called with `enabled = true`. `flags` carries
/// `quic::CLIP_FLAG_FILES` (allow file transfer). The host replies with a `State` event.
///
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clipboard_control(
    c: *const PunktfunkConnection,
    enabled: bool,
    flags: u8,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        match c.inner.clip_control(enabled, flags) {
            Ok(()) => PunktfunkStatus::Ok,
            Err(e) => e.status(),
        }
    })
}

/// Announce that the local clipboard changed — the lazy format-list offer. `seq` is a monotonic
/// per-sender counter (newest wins); `kinds`/`n` is the advertised formats (≤ 16). The bytes cross
/// only if the host later fetches.
///
/// # Safety
/// `c` is a valid connection handle; `kinds` points to `n` `PunktfunkClipKind`s (NULL allowed only
/// when `n == 0`), each `mime` a valid NUL-terminated UTF-8 string.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clipboard_offer(
    c: *const PunktfunkConnection,
    seq: u32,
    kinds: *const PunktfunkClipKind,
    n: usize,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if kinds.is_null() && n != 0 {
            return PunktfunkStatus::NullPointer;
        }
        let mut out = Vec::with_capacity(n);
        if n != 0 {
            // SAFETY: per the ABI contract - a caller-supplied pointer/length pair describing one
            // readable region, borrowed only for this call.
            let slice = unsafe { std::slice::from_raw_parts(kinds, n) };
            for k in slice {
                let mime = if k.mime.is_null() {
                    String::new()
                } else {
                    // SAFETY: per the ABI contract - a caller-supplied C string, NUL-terminated or
                    // null, borrowed only for this call.
                    match unsafe { std::ffi::CStr::from_ptr(k.mime) }.to_str() {
                        Ok(s) => s.to_string(),
                        Err(_) => return PunktfunkStatus::InvalidArg,
                    }
                };
                out.push(crate::quic::ClipKind {
                    mime,
                    size_hint: k.size_hint,
                });
            }
        }
        match c.inner.clip_offer(seq, out) {
            Ok(()) => PunktfunkStatus::Ok,
            Err(e) => e.status(),
        }
    })
}

/// Start pulling one format (`mime`) of the host's current offer `seq` — lazily, on a local paste.
/// `file_index` selects a file for a file transfer, or `quic::CLIP_FILE_INDEX_NONE` for a non-file
/// format. Writes the transfer id (echoed on the resulting `Data`/`Error`/`Cancelled` event) to
/// `xfer_id_out`.
///
/// # Safety
/// `c` is a valid connection handle; `mime` is a valid NUL-terminated UTF-8 string; `xfer_id_out`
/// is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clipboard_fetch(
    c: *const PunktfunkConnection,
    seq: u32,
    mime: *const std::os::raw::c_char,
    file_index: u32,
    xfer_id_out: *mut u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if mime.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        // SAFETY: per the ABI contract - a caller-supplied C string, NUL-terminated or null,
        // borrowed only for this call.
        let mime = match unsafe { std::ffi::CStr::from_ptr(mime) }.to_str() {
            Ok(s) => s.to_string(),
            Err(_) => return PunktfunkStatus::InvalidArg,
        };
        match c.inner.clip_fetch(seq, mime, file_index) {
            Ok(xfer_id) => {
                // SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-
                // checked before it is written; a non-null one is a caller-owned writable slot.
                unsafe {
                    if !xfer_id_out.is_null() {
                        *xfer_id_out = xfer_id;
                    }
                }
                PunktfunkStatus::Ok
            }
            Err(e) => e.status(),
        }
    })
}

/// Provide bytes answering a `FetchRequest` event (the host is pasting our offered data). Call
/// repeatedly to stream a large payload; `last = true` completes it. `data` may be NULL only when
/// `len == 0` (e.g. a final empty chunk). `punktfunk_connection_clipboard_cancel(req_id)` aborts.
///
/// # Safety
/// `c` is a valid connection handle; `data` points to `len` bytes (NULL allowed only when
/// `len == 0`).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clipboard_serve(
    c: *const PunktfunkConnection,
    req_id: u32,
    data: *const u8,
    len: usize,
    last: bool,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if data.is_null() && len != 0 {
            return PunktfunkStatus::NullPointer;
        }
        let bytes = if len == 0 {
            Vec::new()
        } else {
            // SAFETY: per the ABI contract - a caller-supplied pointer/length pair describing one
            // readable region, borrowed only for this call.
            unsafe { std::slice::from_raw_parts(data, len) }.to_vec()
        };
        match c.inner.clip_serve(req_id, bytes, last) {
            Ok(()) => PunktfunkStatus::Ok,
            Err(e) => e.status(),
        }
    })
}

/// Cancel a clipboard transfer by id — either an outbound fetch (`xfer_id` from
/// [`punktfunk_connection_clipboard_fetch`]) or an inbound serve (`req_id` from a `FetchRequest`).
///
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clipboard_cancel(
    c: *const PunktfunkConnection,
    id: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        match c.inner.clip_cancel(id) {
            Ok(()) => PunktfunkStatus::Ok,
            Err(e) => e.status(),
        }
    })
}

/// Pull the next shared-clipboard event into `*out`. [`PunktfunkStatus::NoFrame`] on timeout,
/// [`PunktfunkStatus::Closed`] once the session ended. A native client drains this on its own
/// thread and drives the OS pasteboard from it. The `data`/`len` pointer (when non-NULL) borrows a
/// per-connection buffer valid until the next `next_clipboard` call on this handle.
///
/// # Safety
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkClipEvent`.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_clipboard(
    c: *mut PunktfunkConnection,
    out: *mut PunktfunkClipEvent,
    timeout_ms: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if out.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        match c
            .inner
            .next_clip(std::time::Duration::from_millis(timeout_ms as u64))
        {
            Ok(ev) => {
                let mut slot = lock_recover(&c.last_clip);
                let out_ev = build_clip_event(ev, &mut slot);
                // SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
                // written once by value.
                unsafe { *out = out_ev };
                PunktfunkStatus::Ok
            }
            Err(e) => {
                // Release the parked payload once the embedder polls past it: clipboard
                // traffic is sporadic, so without this a one-off 50 MiB paste stays resident
                // for the rest of the session (there is no other release entry point). The
                // borrow contract already says `out` data is valid only until the next call.
                *lock_recover(&c.last_clip) = None;
                e.status()
            }
        }
    })
}

/// The compositor backend the host actually resolved for this session (one of the
/// `PUNKTFUNK_COMPOSITOR_*` values; the `Welcome`'s echo of the [`punktfunk_connect_ex`]
/// preference). `PUNKTFUNK_COMPOSITOR_AUTO` = an older host that didn't say. Clients use it for
/// compositor-specific behavior — e.g. a client-side cursor by default on
/// `PUNKTFUNK_COMPOSITOR_GAMESCOPE`, whose PipeWire capture carries no cursor. Safe any time after
/// connect.
///
/// # Safety
/// `c` is a valid connection handle; `compositor` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_compositor(
    c: *const PunktfunkConnection,
    compositor: *mut u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        // SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-checked
        // before it is written; a non-null one is a caller-owned writable slot.
        unsafe {
            if !compositor.is_null() {
                *compositor = c.inner.resolved_compositor.to_u8() as u32;
            }
        }
        PunktfunkStatus::Ok
    })
}

/// The video encoder bitrate (kilobits per second) the host actually configured for this session
/// — the [`punktfunk_connect_ex3`] request clamped to the host's range, or its default when `0`
/// was requested. `0` = an older host that didn't report it. Safe any time after connect.
///
/// # Safety
/// `c` is a valid connection handle; `bitrate_kbps` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_bitrate(
    c: *const PunktfunkConnection,
    bitrate_kbps: *mut u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        // SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-checked
        // before it is written; a non-null one is a caller-owned writable slot.
        unsafe {
            if !bitrate_kbps.is_null() {
                *bitrate_kbps = c.inner.resolved_bitrate_kbps;
            }
        }
        PunktfunkStatus::Ok
    })
}

/// The host↔client wall-clock offset (nanoseconds, **host minus client**) measured by the
/// connect-time skew handshake. Add it to a local receive/present timestamp (same realtime clock,
/// `CLOCK_REALTIME` / `gettimeofday`-epoch nanoseconds) to express that instant in the host's
/// capture clock — the clock the per-access-unit `pts_ns` is stamped in — so glass-to-glass latency
/// (e.g. present-time minus `pts_ns`) is valid across machines. `0` = no correction: either an older
/// host that didn't answer the handshake, or genuinely synchronized clocks. Safe any time after
/// connect.
///
/// # Safety
/// `c` is a valid connection handle; `offset_ns` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clock_offset_ns(
    c: *const PunktfunkConnection,
    offset_ns: *mut i64,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        // SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-checked
        // before it is written; a non-null one is a caller-owned writable slot.
        unsafe {
            if !offset_ns.is_null() {
                *offset_ns = c.inner.clock_offset_ns;
            }
        }
        PunktfunkStatus::Ok
    })
}

/// The **live** host↔client wall-clock offset (nanoseconds, host minus client): the
/// connect-time estimate of [`punktfunk_connection_clock_offset_ns`], updated by every applied
/// mid-stream clock re-sync. Ongoing latency math (per-frame `received − pts` splits, the
/// glass-to-glass meter) must use this one — after a wall-clock step/slew the frozen
/// connect-time value reads tens of milliseconds wrong for the rest of the session, while the
/// core itself has already re-synced. Same clock contract as the connect-time getter.
///
/// # Safety
/// `c` is a valid connection handle; `offset_ns` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clock_offset_now_ns(
    c: *const PunktfunkConnection,
    offset_ns: *mut i64,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        // SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-checked
        // before it is written; a non-null one is a caller-owned writable slot.
        unsafe {
            if !offset_ns.is_null() {
                *offset_ns = c.inner.clock_offset_now_ns();
            }
        }
        PunktfunkStatus::Ok
    })
}

/// Ask the host to switch the live session to `width`x`height`@`refresh_hz` without
/// reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the
/// stream continues at the new mode — the first new-mode access unit is an IDR with
/// in-band parameter sets (rebuild the decoder from it) — and
/// [`punktfunk_connection_mode`] reflects the switch. A rejected request leaves the
/// session unchanged.
///
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_request_mode(
    c: *const PunktfunkConnection,
    width: u32,
    height: u32,
    refresh_hz: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        match c.inner.request_mode(crate::config::Mode {
            width,
            height,
            refresh_hz,
        }) {
            Ok(()) => PunktfunkStatus::Ok,
            Err(e) => e.status(),
        }
    })
}

/// Ask the host's encoder to emit a fresh IDR keyframe now — client recovery when the
/// decoder has stalled (the infinite-GOP stream sends one opening IDR then P-frames only, so
/// a wedged decoder would otherwise freeze until the next loss-triggered recovery keyframe).
/// Non-blocking, fire-and-forget; the recovered keyframe is the only ack. The caller should
/// THROTTLE — the decode stays wedged for several frames until the IDR lands, so requesting
/// every frame would flood the control stream.
///
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_request_keyframe(
    c: *const PunktfunkConnection,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        match c.inner.request_keyframe() {
            Ok(()) => PunktfunkStatus::Ok,
            Err(e) => e.status(),
        }
    })
}

/// Ask the host to recover from loss by **reference-frame invalidation** rather than a full IDR:
/// report the range `[first_frame, last_frame]` of access units the client can no longer trust
/// (the first missing `frame_index` through the newest received). An RFI-capable host (AMD LTR /
/// NVENC) re-references a known-good picture before `first_frame` and emits a clean P-frame tagged
/// `USER_FLAG_RECOVERY_ANCHOR` — no 20-40x IDR spike; a host that can't RFI forces an IDR instead
/// (same effect as [`punktfunk_connection_request_keyframe`]). Non-blocking, fire-and-forget; the
/// recovered frame is the only ack, so THROTTLE it exactly like the keyframe request. Prefer this
/// over the keyframe request on loss so AMD/RFI hosts avoid the spike; keep the keyframe request as
/// the backstop for when the recovery frame itself is lost.
///
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_request_rfi(
    c: *const PunktfunkConnection,
    first_frame: u32,
    last_frame: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        match c.inner.request_rfi(first_frame, last_frame) {
            Ok(()) => PunktfunkStatus::Ok,
            Err(e) => e.status(),
        }
    })
}

/// Feed each received frame's `frame_index` (the [`PunktfunkFrame::frame_index`] field, in receive
/// order) so the client recovers from loss with a cheap reference-frame invalidation instead of a
/// full IDR. On a forward gap (a `frame_index` jump = the intervening frames were lost and the
/// following AUs reference a picture that never arrived) this fires a THROTTLED
/// [`punktfunk_connection_request_rfi`] for the lost range; an RFI-capable host (AMD LTR / NVENC)
/// then recovers with a clean P-frame instead of a 20-40x IDR spike. Call it for every received
/// frame — it is cheap and idempotent, and the [`punktfunk_connection_frames_dropped`]-driven
/// keyframe request stays the backstop. Writes whether a forward gap was detected this call to
/// `gap_out` (nullable — a client with a post-loss display freeze can use it to re-arm; most
/// clients pass NULL and ignore it).
///
/// # Safety
/// `c` is a valid connection handle; `gap_out` is writable or NULL.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_note_frame_index(
    c: *const PunktfunkConnection,
    frame_index: u32,
    gap_out: *mut bool,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        let gap = c.inner.note_frame_index(frame_index);
        if !gap_out.is_null() {
            // SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
            // written once by value.
            unsafe { *gap_out = gap > 0 };
        }
        PunktfunkStatus::Ok
    })
}

/// [`punktfunk_connection_note_frame_index`] with the gap WIDTH instead of a yes/no: writes to
/// `gap_width_out` how many frames this arrival revealed as missing (0 = contiguous/straggler).
/// A client with a post-loss display freeze passes the width to
/// [`punktfunk_reanchor_gate_arm_expecting_drops`] so the reassembler's later `frames_dropped`
/// climb for the SAME loss cannot re-freeze a stream an RFI anchor already healed (the double-arm
/// race — see the gate function's doc).
///
/// # Safety
/// `c` is a valid connection handle; `gap_width_out` is writable or NULL.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_note_frame_index_ex(
    c: *const PunktfunkConnection,
    frame_index: u32,
    gap_width_out: *mut u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        let gap = c.inner.note_frame_index(frame_index);
        if !gap_width_out.is_null() {
            // SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
            // written once by value.
            unsafe { *gap_width_out = gap };
        }
        PunktfunkStatus::Ok
    })
}

/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
/// rebuild them). A video loop polls this and calls [`punktfunk_connection_request_keyframe`]
/// when it climbs — the correct loss trigger under the host's infinite GOP, where unrecoverable
/// loss yields reference-missing delta frames the decoder *silently conceals* (frozen / garbage
/// picture, no decode error), so a decode-error trigger rarely fires. Monotonic for the session;
/// compare against the last observed value. Writes 0 to `out` on a NULL connection.
///
/// # Safety
/// `c` is a valid connection handle; `out` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_frames_dropped(
    c: *const PunktfunkConnection,
    out: *mut u64,
) -> PunktfunkStatus {
    guard(|| {
        // The header promises "writes 0 on a NULL connection" — honor it BEFORE the handle
        // check, so an embedder that skips the status never reads an uninitialized slot.
        if !out.is_null() {
            // SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
            // written once by value.
            unsafe { *out = 0 };
        }
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        // SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-checked
        // before it is written; a non-null one is a caller-owned writable slot.
        unsafe {
            if !out.is_null() {
                *out = c.inner.frames_dropped();
            }
        }
        PunktfunkStatus::Ok
    })
}

/// Report one decoded frame's decode-stage latency, in microseconds: the wall-clock elapsed from
/// the access unit leaving [`punktfunk_connection_next_au`] to its decoded output becoming
/// available (VideoToolbox/D3D11VA/… produced the frame). This feeds the "Automatic" bitrate
/// controller's decode signal — the only one that sees the client's own decoder, so the rate is
/// capped at the real decode limit instead of climbing to the network link ceiling and choking a
/// slower hardware decoder (a fast LAN feeding a mobile-class decoder). Measure from the AU pull,
/// NOT from the decoder-submit call, so decoder-input backpressure (the backlog) is included;
/// exclude the presenter's vsync wait so a paced/capped frame rate doesn't read as decode latency.
/// Cheap — the client may call it every frame; the controller ignores it unless armed (query
/// [`punktfunk_connection_wants_decode_latency`] once to skip the measurement entirely when it's not).
///
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_report_decode_us(
    c: *const PunktfunkConnection,
    us: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        c.inner.report_decode_us(us);
        PunktfunkStatus::Ok
    })
}

/// Report this client's display-latch grid so the host can phase-lock its capture tick
/// (design/phase-locked-capture.md). `next_latch_host_ns` must already be host clock — convert
/// with the connection's clock offset (`T_host = T_client + offset`). Fire-and-forget; call ~1 Hz
/// from a vsync-aware presenter. No-op toward a host that never negotiated the capability.
///
/// # Safety
/// `c` is an opaque handle from a `*_new`/`*_pair` the caller has not yet freed, or null (an
/// error, not UB).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_report_phase(
    c: *const PunktfunkConnection,
    next_latch_host_ns: u64,
    latch_period_ns: u32,
    uncertainty_ns: u32,
    arrival_lead_ns: u32,
    coherence_milli: u16,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        c.inner.report_phase(
            next_latch_host_ns,
            latch_period_ns,
            uncertainty_ns,
            arrival_lead_ns,
            coherence_milli,
        );
        PunktfunkStatus::Ok
    })
}

/// Whether [`punktfunk_connection_report_decode_us`] is worth calling this session: writes 1 to
/// `out` only when the adaptive-bitrate controller is armed (Automatic bitrate, non-PyroWave), so a
/// client can skip the per-frame decode-latency measurement entirely for explicit-bitrate and
/// PyroWave sessions (where the signal is ignored). Constant for the session — query once. Writes 0
/// on a NULL connection.
///
/// # Safety
/// `c` is a valid connection handle; `out` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_wants_decode_latency(
    c: *const PunktfunkConnection,
    out: *mut bool,
) -> PunktfunkStatus {
    guard(|| {
        // The header promises "writes 0 on a NULL connection" — honor it BEFORE the handle
        // check: an uninitialized byte is not even a valid C++/Swift bool to read.
        if !out.is_null() {
            // SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
            // written once by value.
            unsafe { *out = false };
        }
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        // SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-checked
        // before it is written; a non-null one is a caller-owned writable slot.
        unsafe {
            if !out.is_null() {
                *out = c.inner.wants_decode_latency();
            }
        }
        PunktfunkStatus::Ok
    })
}

/// A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until
/// the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the
/// delivered wire throughput to drive a bitrate choice from; `loss_pct` is the link loss and
/// `host_drop_pct` the host-side send-buffer drop (raise `net.core.wmem_max`) — they're measured
/// separately so a host that can't keep up reads differently from a lossy link.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default)]
pub struct PunktfunkProbeResult {
    /// 1 once the host's end-of-burst report arrived (measurement final); else 0 (partial).
    pub done: u8,
    /// Delivered wire bytes (header + shard) / packets the client received during the burst.
    pub recv_bytes: u64,
    pub recv_packets: u32,
    /// Application goodput bytes / access units the host offered.
    pub host_bytes: u64,
    pub host_packets: u32,
    /// The throughput denominator, milliseconds: the client-measured burst receive interval
    /// (first → last probe-packet arrival) once `done`; the host's measured send-window
    /// duration when fewer than two probe packets arrived (no interval to measure from). The
    /// host duration alone overstates throughput — its window closes while the bottleneck
    /// queue is still draining toward the client.
    pub elapsed_ms: u32,
    /// Delivered wire throughput = `recv_bytes * 8 / elapsed_ms` (kilobits/second).
    pub throughput_kbps: u32,
    /// Link loss `(wire_packets_sent − recv_packets) / wire_packets_sent` as a percentage.
    pub loss_pct: f32,
    /// Host-side send-buffer drop `send_dropped / (wire_packets_sent + send_dropped)`, percent.
    pub host_drop_pct: f32,
    /// Wire packets the host put on the link, and the ones its send buffer dropped (raw counts).
    pub wire_packets_sent: u32,
    pub send_dropped: u32,
}

/// Start a bandwidth speed test: ask the host to burst filler over the data plane at
/// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 10 Gbps / ≤ 5 s),
/// *briefly pausing video*. Non-blocking — poll [`punktfunk_connection_probe_result`] until its
/// `done` field is 1. Starting a probe resets any prior measurement.
///
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_speed_test(
    c: *const PunktfunkConnection,
    target_kbps: u32,
    duration_ms: u32,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        match c.inner.request_probe(target_kbps, duration_ms) {
            Ok(()) => PunktfunkStatus::Ok,
            Err(e) => e.status(),
        }
    })
}

/// Read the current speed-test measurement into `*out` (partial until `out->done == 1`). Safe to
/// poll repeatedly after [`punktfunk_connection_speed_test`]; before any probe it reports zeros.
///
/// # Safety
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkProbeResult` (NULL is an
/// error).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_probe_result(
    c: *const PunktfunkConnection,
    out: *mut PunktfunkProbeResult,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let c = match unsafe { c.as_ref() } {
            Some(c) => c,
            None => return PunktfunkStatus::NullPointer,
        };
        if out.is_null() {
            return PunktfunkStatus::NullPointer;
        }
        let o = c.inner.probe_result();
        // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the matching
        // `#[repr(C)]` type, written once by value.
        unsafe {
            *out = PunktfunkProbeResult {
                done: o.done as u8,
                recv_bytes: o.recv_bytes,
                recv_packets: o.recv_packets,
                host_bytes: o.host_bytes,
                host_packets: o.host_packets,
                elapsed_ms: o.elapsed_ms,
                throughput_kbps: o.throughput_kbps,
                loss_pct: o.loss_pct,
                host_drop_pct: o.host_drop_pct,
                wire_packets_sent: o.wire_packets_sent,
                send_dropped: o.send_dropped,
            };
        }
        PunktfunkStatus::Ok
    })
}

/// Signal a **deliberate quit** (a user "stop", not a network drop) before closing: the connection
/// closes with [`QUIT_CLOSE_CODE`] instead of code 0, so the host tears the session down immediately
/// (skips the keep-alive linger) rather than holding it for a reconnect. Call this right before
/// [`punktfunk_connection_close`] on a user-initiated disconnect; a plain close (network drop,
/// backgrounding) leaves the linger intact. NULL is a no-op.
///
/// # Safety
/// `c` was returned by [`punktfunk_connect`] and remains valid (closed via `punktfunk_connection_close`).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_disconnect_quit(c: *mut PunktfunkConnection) {
    guard_void(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller has
        // not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match` here
        // handles.
        if let Some(c) = unsafe { c.as_ref() } {
            c.inner.disconnect_quit();
        }
    });
}

/// Close the connection and free the handle (joins the internal threads). NULL is a no-op.
///
/// # Safety
/// `c` was returned by [`punktfunk_connect`] and is not used after this call.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_close(c: *mut PunktfunkConnection) {
    guard_void(|| {
        if !c.is_null() {
            // SAFETY: per the ABI contract - the pointer operands in this block are caller-supplied
            // and are null-checked or handle-validated on this path before they are read.
            drop(unsafe { Box::from_raw(c) });
        }
    });
}

// ---- Post-loss re-anchor freeze gate ----
//
// The shared [`ReanchorGate`](crate::reanchor::ReanchorGate) exposed for the Swift client (Rust
// embedders — Android/Windows/Linux — use the struct directly). After an unrecoverable reference
// loss the decoder silently conceals the missing-reference deltas (gray/garbage picture, no error);
// the client freezes on the last good frame and lifts only on a proven clean re-anchor. The gate
// takes time internally (`Instant::now`) so no timestamps cross the boundary. Drive it per session:
// `arm` on a loss (frame-index gap from `punktfunk_connection_note_frame_index`, a decoder
// wedge/demotion), `on_decoded` per decoded frame to gate presentation, `on_no_output` per AU that
// produced nothing, and `poll` each iteration for the dropped-count climb + overdue backstop. Route
// the returned keyframe intents through the client's existing request throttle.

/// Create a re-anchor gate seeded with the session's current `frames_dropped` (so the first
/// [`punktfunk_reanchor_gate_poll`] doesn't read the baseline as a loss). Free with
/// [`punktfunk_reanchor_gate_free`]. Never returns NULL.
#[unsafe(no_mangle)]
pub extern "C" fn punktfunk_reanchor_gate_new(frames_dropped: u64) -> *mut ReanchorGate {
    Box::into_raw(Box::new(ReanchorGate::new(frames_dropped)))
}

/// Free a gate created by [`punktfunk_reanchor_gate_new`]. NULL is a no-op.
///
/// # Safety
/// `g` was returned by [`punktfunk_reanchor_gate_new`] and is not used after this call.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_free(g: *mut ReanchorGate) {
    guard_void(|| {
        if !g.is_null() {
            // SAFETY: per the ABI contract - the pointer operands in this block are caller-supplied
            // and are null-checked or handle-validated on this path before they are read.
            drop(unsafe { Box::from_raw(g) });
        }
    });
}

/// Arm the freeze: a loss was detected (a frame-index gap, or a decoder wedge/demotion). Zeroes the
/// recovery-mark count and (re-)sets the backstop deadline. NULL is a no-op.
///
/// # Safety
/// `g` is a valid gate handle.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_arm(g: *mut ReanchorGate) {
    guard_void(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller has
        // not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match` here
        // handles.
        if let Some(g) = unsafe { g.as_mut() } {
            g.arm(std::time::Instant::now());
        }
    });
}

/// [`punktfunk_reanchor_gate_arm`] for a loss detected as a **frame-index gap**, where the caller
/// knows how many frames the gap skipped ([`punktfunk_connection_note_frame_index_ex`]). On top of
/// arming, the gate pre-credits the reassembler's `frames_dropped` climb those same lost frames
/// will produce up to ~120 ms later, so [`punktfunk_reanchor_gate_poll`] does not treat that
/// delayed bookkeeping as a SECOND loss — without the credit, a fast LTR-RFI anchor lifts the
/// freeze between the two signals and the stale climb re-freezes a healed stream (the double-arm
/// race). Use the plain arm for non-gap loss signals (decoder wedge/demotion). NULL is a no-op.
///
/// # Safety
/// `g` is a valid gate handle.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_arm_expecting_drops(
    g: *mut ReanchorGate,
    expected_drops: u64,
) {
    guard_void(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller has
        // not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match` here
        // handles.
        if let Some(g) = unsafe { g.as_mut() } {
            g.arm_expecting_drops(std::time::Instant::now(), expected_drops);
        }
    });
}

/// Fold one decoded frame and write to `out_present` whether to display it (`true`) or withhold it as
/// a post-loss concealment (`false`). `flags` is the AU's `user_flags` word ([`PunktfunkFrame::flags`]):
/// the gate reads `FLAG_SOF` (the host's IDR marker), `USER_FLAG_RECOVERY_ANCHOR` and
/// `USER_FLAG_RECOVERY_POINT`. Pass `decoder_keyframe = false` where the platform decoder doesn't flag
/// IDRs (VideoToolbox/MediaCodec) — the wire `FLAG_SOF` covers it.
///
/// This is the uncorroborated entry point and deliberately stays that way. Rust embedders whose
/// decoder parses the bitstream call [`ReanchorGate::on_decoded_corroborated`] to let their own
/// parser refute a `USER_FLAG_RECOVERY_ANCHOR` that names a picture they had to conceal; every
/// client reachable through THIS surface (Apple VideoToolbox, Android MediaCodec) uses a platform
/// decoder that surfaces no such fact, so it would have nothing to pass but
/// `AnchorEvidence::Unavailable` — which is exactly what this wrapper already means. Growing a
/// second export for a corroboration no C caller can supply would spend an ABI version bump on
/// dead surface.
///
/// # Safety
/// `g` is a valid gate handle; `out_present` is writable or NULL.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_on_decoded(
    g: *mut ReanchorGate,
    flags: u32,
    decoder_keyframe: bool,
    out_present: *mut bool,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let g = match unsafe { g.as_mut() } {
            Some(g) => g,
            None => return PunktfunkStatus::NullPointer,
        };
        let present = g.on_decoded(flags, decoder_keyframe, std::time::Instant::now())
            == GateVerdict::Present;
        if !out_present.is_null() {
            // SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
            // written once by value.
            unsafe { *out_present = present };
        }
        PunktfunkStatus::Ok
    })
}

/// A received AU produced no decoded frame. Writes to `out_request_kf` whether the no-output streak has
/// tripped and the client should (throttled) request a keyframe — the gate arms the freeze at the same
/// time.
///
/// # Safety
/// `g` is a valid gate handle; `out_request_kf` is writable or NULL.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_on_no_output(
    g: *mut ReanchorGate,
    out_request_kf: *mut bool,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let g = match unsafe { g.as_mut() } {
            Some(g) => g,
            None => return PunktfunkStatus::NullPointer,
        };
        let request = g.on_no_output(std::time::Instant::now());
        if !out_request_kf.is_null() {
            // SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
            // written once by value.
            unsafe { *out_request_kf = request };
        }
        PunktfunkStatus::Ok
    })
}

/// Periodic fold of the session's `frames_dropped` counter plus the overdue backstop. Writes to
/// `out_request_kf` whether the client should (throttled) request a keyframe (a drop-count climb armed
/// a fresh freeze, or the freeze is overdue and re-asks while it keeps holding).
///
/// # Safety
/// `g` is a valid gate handle; `out_request_kf` is writable or NULL.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_poll(
    g: *mut ReanchorGate,
    frames_dropped: u64,
    out_request_kf: *mut bool,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let g = match unsafe { g.as_mut() } {
            Some(g) => g,
            None => return PunktfunkStatus::NullPointer,
        };
        let request = g.poll(frames_dropped, std::time::Instant::now());
        if !out_request_kf.is_null() {
            // SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
            // written once by value.
            unsafe { *out_request_kf = request };
        }
        PunktfunkStatus::Ok
    })
}

/// Whether the gate is currently withholding concealed frames (frozen on the last good picture).
/// Writes `false` on a NULL gate.
///
/// # Safety
/// `g` is a valid gate handle; `out_holding` is writable or NULL.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
    g: *const ReanchorGate,
    out_holding: *mut bool,
) -> PunktfunkStatus {
    guard(|| {
        // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
        // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
        // here handles.
        let holding = unsafe { g.as_ref() }.is_some_and(ReanchorGate::is_holding);
        if !out_holding.is_null() {
            // SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
            // written once by value.
            unsafe { *out_holding = holding };
        }
        PunktfunkStatus::Ok
    })
}

#[cfg(test)]
mod log_sink_tests {
    use super::*;
    use std::sync::Mutex;

    /// `(level, target, message, user token)` per delivered line. The collector asserts nothing
    /// itself (an `extern "C"` fn must not panic — the hygiene gate enforces it); the test body
    /// checks what landed.
    static LINES: Mutex<Vec<(u8, String, String, usize)>> = Mutex::new(Vec::new());

    unsafe extern "C" fn collect(
        level: u8,
        target: *const c_char,
        message: *const c_char,
        user: *mut c_void,
    ) {
        // SAFETY: the core hands NUL-terminated strings valid for this call, per the callback contract.
        let (t, m) = unsafe { (CStr::from_ptr(target), CStr::from_ptr(message)) };
        lock_recover(&LINES).push((
            level,
            t.to_string_lossy().into_owned(),
            m.to_string_lossy().into_owned(),
            user as usize,
        ));
    }

    /// End to end through both doors: a `log` record and a `tracing` event (via tracing's `log`
    /// feature) reach the C callback with level, real target, message and the user token; an
    /// interior NUL is dropped rather than truncating the line; the level ceiling is honoured;
    /// NULL detaches.
    #[test]
    fn callback_receives_log_and_tracing_lines() {
        // SAFETY: `collect` is a valid fn for the life of the test binary, the user token is an
        // opaque integer.
        let st = unsafe { punktfunk_set_log_callback(3, Some(collect), 0x5151 as *mut c_void) };
        assert_eq!(st, PunktfunkStatus::Ok);

        log::warn!(target: "quinn::connection", "handshake \0 done");
        tracing::info!(target: "punktfunk_core::transport", buf = 4096, "socket buffer clamped");
        log::debug!(target: "quinn::connection", "must not arrive (above the ceiling)");

        let lines = lock_recover(&LINES).clone();
        let warn = lines
            .iter()
            .find(|l| l.1 == "quinn::connection")
            .expect("log record delivered");
        assert_eq!(warn.0, 2);
        assert_eq!(warn.2, "handshake  done", "interior NUL dropped, line kept");
        assert_eq!(warn.3, 0x5151, "the user token must come back unchanged");
        let info = lines
            .iter()
            .find(|l| l.1 == "punktfunk_core::transport")
            .expect("tracing event delivered through the log bridge");
        assert_eq!(info.0, 3);
        assert!(
            info.2.contains("socket buffer clamped") && info.2.contains("buf=4096"),
            "{}",
            info.2
        );
        assert!(!lines.iter().any(|l| l.2.contains("must not arrive")));

        // SAFETY: NULL callback detaches; no pointer is retained.
        let detached = unsafe { punktfunk_set_log_callback(3, None, ptr::null_mut()) };
        assert_eq!(detached, PunktfunkStatus::Ok);
        let before = lock_recover(&LINES).len();
        log::error!(target: "quinn::connection", "after detach");
        assert_eq!(
            lock_recover(&LINES).len(),
            before,
            "a detached sink hears nothing"
        );
    }
}

#[cfg(all(test, feature = "quic"))]
mod tests {
    use super::*;

    /// The `ex10` device name is cut to the Hello's BYTE budget on a CHARACTER boundary — the
    /// naive `s[..HELLO_NAME_MAX]` panics on any multi-byte name that straddles it, and an
    /// operator naming a device in German or Japanese is not an edge case.
    #[test]
    fn device_name_truncates_on_a_character_boundary() {
        let max = crate::quic::HELLO_NAME_MAX;
        assert_eq!(clamp_device_name("Enrico's iPad"), "Enrico's iPad");

        // Straddling: 2-byte characters over an odd-length prefix, so the cap lands mid-scalar.
        let straddle = format!("{}{}", "x".repeat(max - 1), "ü".repeat(4));
        let cut = clamp_device_name(&straddle);
        assert!(cut.len() <= max, "{} bytes exceeds the cap", cut.len());
        assert_eq!(
            cut,
            "x".repeat(max - 1),
            "must drop the whole ü, not half of it"
        );

        // A name whose FIRST character already exceeds the cap has nothing to keep — the
        // `unwrap_or(0)` path, which must yield "" rather than panicking on an empty iterator.
        assert_eq!(clamp_device_name(&"あ".repeat(max)), "あ".repeat(max / 3));
    }

    /// A C embedder writing `ev->kind = 42` must come back as a status code, not UB. The test
    /// stages the event in `MaybeUninit` storage so no `&InputEvent` to an invalid value ever
    /// exists on the test's own side either.
    #[test]
    fn read_input_event_rejects_null_and_bad_discriminant() {
        // SAFETY: null is the documented reported-not-UB case.
        let null_result = unsafe { read_input_event(std::ptr::null()) };
        assert_eq!(null_result.unwrap_err(), PunktfunkStatus::NullPointer);

        let mut slot = core::mem::MaybeUninit::<InputEvent>::zeroed();
        let p = slot.as_mut_ptr();
        // SAFETY: writing one byte at offset 0 of aligned, sized storage.
        unsafe { p.cast::<u8>().write(42) };
        // SAFETY: `p` is aligned and readable for the full struct.
        let bad_tag = unsafe { read_input_event(p) };
        assert_eq!(bad_tag.unwrap_err(), PunktfunkStatus::InvalidArg);

        // SAFETY: as above; tag 0 (KeyDown) + zeroed fields is a fully valid event.
        unsafe { p.cast::<u8>().write(0) };
        // SAFETY: as above.
        let ev = unsafe { read_input_event(p) }.expect("valid tag must pass");
        assert_eq!(ev.kind, crate::input::InputKind::KeyDown);
    }

    /// The `AudioCtl` → `PunktfunkHidOutput` mapping: kind 5, pad narrowed, `which` carries the
    /// flags byte, `effect[0..6]` the raw audio region with `effect_len = 6` (the TrackpadHaptic
    /// packing idiom — no struct growth, so the size guard above stays at 19).
    #[test]
    fn hidout_abi_maps_audio_ctl() {
        let out = PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::AudioCtl {
            pad: 3,
            flags: 0x17,
            raw: [0x50, 0x60, 0x70, 0x05, 0, 0],
        })
        .unwrap();
        assert_eq!(out.kind, PUNKTFUNK_HIDOUT_AUDIO_CTL);
        assert_eq!(out.pad, 3);
        assert_eq!(out.which, 0x17);
        assert_eq!(out.effect_len, 6);
        assert_eq!(out.effect[..6], [0x50, 0x60, 0x70, 0x05, 0, 0]);
        assert_eq!(out.effect[6..], [0; 5]);
        // A raw passthrough report still has no C representation (skipped at the pull site).
        assert!(
            PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::HidRaw {
                pad: 0,
                kind: 0,
                data: vec![0x80],
            })
            .is_none()
        );
    }

    /// The legacy audio format: Opus on `0xC9`, 48 kHz, 16-bit, stereo. What every session ran
    /// before the lossless plane existed, and what every embedder that does not call
    /// `punktfunk_connect_ex11` still gets.
    const OPUS_48K: AudioFormat = AudioFormat {
        codec: crate::quic::AUDIO_CODEC_OPUS,
        rate_hz: crate::audio::SAMPLE_RATE_HZ,
        bits: crate::audio::pcm::BITS_16,
        channels: 2,
        frame_us: crate::audio::FRAME_MS * 1000,
    };

    /// A lossless session at the depth the feature exists for.
    const PCM_48K_24: AudioFormat = AudioFormat {
        codec: crate::quic::AUDIO_CODEC_PCM,
        rate_hz: crate::audio::SAMPLE_RATE_HZ,
        bits: crate::audio::pcm::BITS_24,
        channels: 2,
        frame_us: crate::audio::pcm::FRAME_US_LADDER[0],
    };

    /// The concealment run a 5 ms session owes: ten frames — exactly the flat count the cap used
    /// to be, which is what keeps every geometry assertion below the pre-hi-res one.
    const CONCEAL_RUN: u32 = crate::audio::max_conceal_packets(crate::audio::FRAME_MS * 1000);

    /// One `0xD3` payload of `n` interleaved stereo samples at `bits`, built from a deterministic
    /// ramp across the full code range so any stride or sign-extension error is visible.
    fn pcm_frame(n: usize, bits: u8) -> (Vec<f32>, Vec<u8>) {
        let mut samples = Vec::with_capacity(n);
        for i in 0..n {
            samples.push((i as f32 / n as f32) * 1.8 - 0.9);
        }
        let mut wire = Vec::new();
        crate::audio::pcm::from_f32(&samples, bits, &mut wire);
        // Quantised once, so the expectation is what the wire really carries rather than the
        // pre-quantisation floats.
        let mut expect = Vec::new();
        crate::audio::pcm::to_f32(&wire, bits, &mut expect).expect("whole samples");
        (expect, wire)
    }

    /// The claim the lossless plane exists to make, at the ABI boundary an embedder actually sees:
    /// what `next_audio_pcm` hands out must be the samples the host quantised, not merely
    /// something close to them. Anything less and the plane is spending 2.3 Mbps for a label.
    #[test]
    fn the_pcm_plane_decodes_bit_exactly() {
        for bits in [crate::audio::pcm::BITS_16, crate::audio::pcm::BITS_24] {
            let fmt = AudioFormat { bits, ..PCM_48K_24 };
            // 5 ms at 48 kHz stereo — the longest rung of the ladder.
            let (expect, wire) = pcm_frame(240 * 2, bits);
            let mut state = AudioPcmState::default();
            let n = state.decode_packet(&wire, 0, fmt).expect("decodes");
            assert_eq!(n, expect.len(), "{bits}-bit: sample count");
            assert_eq!(
                &state.pcm[..n],
                &expect[..],
                "{bits}-bit PCM must reach the embedder unchanged"
            );

            // …and it stays exact packet after packet, at the buffer offsets a real session uses.
            let (expect2, wire2) = pcm_frame(240 * 2, bits);
            let n2 = state.decode_packet(&wire2, 1, fmt).expect("decodes");
            assert_eq!(&state.pcm[..n2], &expect2[..]);
        }
    }

    /// A missing `0xD3` frame must be concealed by `PcmConceal`, NOT by libopus PLC — there is no
    /// decoder on this plane to ask, and a lossless frame carries no model of the signal to
    /// extrapolate from (`design/hi-res-audio.md` §4.5). The tell is structural rather than
    /// aesthetic: no Opus decoder is ever constructed, and the concealed frame is built from the
    /// last real one (first sample equal, tail faded) rather than synthesized.
    #[test]
    fn a_missing_pcm_frame_is_concealed_without_libopus() {
        let bits = crate::audio::pcm::BITS_24;
        let (expect, wire) = pcm_frame(240 * 2, bits);
        let mut state = AudioPcmState::default();
        assert_eq!(state.decode_packet(&wire, 0, PCM_48K_24), Ok(expect.len()));

        // Seq 2 is lost: one concealed frame lands in front of the real one, contiguously — the
        // same shape the Opus path produces, so nothing downstream branches on the plane.
        let (expect3, wire3) = pcm_frame(240 * 2, bits);
        let n = state.decode_packet(&wire3, 2, PCM_48K_24).expect("decodes");
        assert_eq!(
            n,
            2 * expect3.len(),
            "one concealed frame, then the real one"
        );
        assert!(
            state.decoder.is_none(),
            "a libopus decoder must never be built on the lossless plane"
        );
        // The concealed frame is the previous one faded, so it starts essentially where the real
        // audio was (the raised cosine is ~1.0 at the head) and ends at silence. A PLC-synthesized
        // frame would match neither end.
        assert!(
            (state.pcm[0] - expect[0]).abs() < 1e-3,
            "concealment must repeat the last good frame, got {} vs {}",
            state.pcm[0],
            expect[0]
        );
        let tail = state.pcm[expect.len() - 1];
        assert!(
            tail.abs() < 0.01,
            "the fade must land on silence, got {tail}"
        );
        // And the real frame follows it, still bit-exact.
        assert_eq!(&state.pcm[expect3.len()..n], &expect3[..]);

        // The drought path takes the same route: PcmConceal, one frame, credited against the
        // next arrival's loss so the gap is never concealed twice.
        let before = state.drought_frames;
        assert_eq!(state.conceal(PCM_48K_24), Ok(expect.len()));
        assert_eq!(state.drought_frames, before + 1);
        assert!(state.decoder.is_none());
    }

    /// ⚠ The buffer the embedder holds a pointer INTO must never be reallocated: a `Vec` growth
    /// would dangle it, on a path where the embedder is reading concurrently and the failure
    /// would not reproduce wherever the allocator happened to grow in place. Pin both halves —
    /// the allocation never moves, and an oversized/malformed datagram truncates instead of
    /// extending it.
    #[test]
    fn the_pcm_buffer_is_never_reallocated() {
        for fmt in [OPUS_48K, PCM_48K_24] {
            let mut state = AudioPcmState::default();
            let (_, wire) = pcm_frame(240 * 2, fmt.bits);
            // Prime it. (On the Opus arm the payload is undecodable, which is fine: the buffer is
            // sized before the packet is looked at, which is the property under test.)
            let _ = state.decode_packet(&wire, 0, fmt);
            let ptr = state.pcm.as_ptr();
            let len = state.pcm.len();
            assert!(len > 0, "sizing must have happened on the first packet");

            // A datagram far longer than any rung of the ladder — the case that would otherwise
            // grow the buffer.
            let (_, huge) = pcm_frame(200_000, fmt.bits);
            let _ = state.decode_packet(&huge, 1, fmt);
            // A long concealment run, at the far end of the buffer.
            for seq in 2..40 {
                let _ = state.decode_packet(&wire, seq * 7, fmt);
                let _ = state.conceal(fmt);
            }
            assert_eq!(state.pcm.as_ptr(), ptr, "the buffer moved");
            assert_eq!(state.pcm.len(), len, "the buffer was resized");
        }
    }

    /// A truncated `0xD3` payload — not a whole number of samples at the negotiated depth — must
    /// be refused rather than decoded as a shifted frame, which would desync every sample after
    /// it. Same disposal as an undecodable Opus packet: concealment already earned still goes out.
    #[test]
    fn a_torn_pcm_datagram_is_refused_not_shifted() {
        let mut state = AudioPcmState::default();
        let (_, wire) = pcm_frame(240 * 2, crate::audio::pcm::BITS_24);
        assert_eq!(
            state.decode_packet(&wire[..wire.len() - 1], 0, PCM_48K_24),
            Err(PunktfunkStatus::BadPacket)
        );
        // With a gap owed, the concealment survives the bad packet instead of dying with it.
        assert_eq!(state.decode_packet(&wire, 1, PCM_48K_24), Ok(240 * 2));
        assert_eq!(
            state.decode_packet(&wire[..wire.len() - 1], 3, PCM_48K_24),
            Ok(240 * 2),
            "the gap before a torn packet is still concealed"
        );
        // An empty payload must NOT wipe the concealment source: PCM has no DTX, so this is a
        // torn datagram, and clearing `prev` would leave the next loss with nothing to repeat.
        assert_eq!(state.decode_packet(&[], 4, PCM_48K_24), Ok(0));
        assert_eq!(
            state.decode_packet(&wire, 6, PCM_48K_24),
            Ok(2 * 240 * 2),
            "the frame before the empty one must still be there to conceal from"
        );
    }

    /// A legacy session must be untouched by all of the above: the same libopus decoder at the
    /// same 48 kHz, the same buffer geometry, and `PcmConceal` never involved. This is the
    /// regression the whole hi-res pass has to not cause.
    #[test]
    fn an_opus_session_is_unaffected_by_the_lossless_plane() {
        let l = crate::audio::LAYOUT_STEREO;
        let mut enc = opus::MSEncoder::new(
            48_000,
            l.streams,
            l.coupled,
            l.mapping,
            opus::Application::LowDelay,
        )
        .expect("MSEncoder");
        enc.set_vbr(false).unwrap();
        let mut frame = vec![0f32; 240 * 2];
        for (i, s) in frame.iter_mut().enumerate() {
            *s = 0.25 * (i as f32 * 0.05).sin();
        }
        let mut out = vec![0u8; 1500];
        let n = enc.encode_float(&frame, &mut out).unwrap();
        out.truncate(n);

        let mut state = AudioPcmState::default();
        assert_eq!(state.decode_packet(&out, 0, OPUS_48K), Ok(240 * 2));
        assert!(state.decoder.is_some(), "still a libopus decoder");
        // Byte-for-byte the pre-hi-res geometry: 120 ms of Opus plus a full concealment run.
        assert_eq!(state.pcm.len(), (1 + CONCEAL_RUN as usize) * 5760 * 2);
        // Gaps and droughts still go through libopus PLC, and PcmConceal is never fed.
        assert_eq!(state.decode_packet(&out, 2, OPUS_48K), Ok(2 * 240 * 2));
        assert_eq!(state.conceal(OPUS_48K), Ok(240 * 2));
        assert_eq!(state.conceal_pcm.run(), 0, "PcmConceal must be untouched");
        assert!(state.scratch_pcm.is_empty());

        // And the format an old embedder sees is exactly the legacy one — the accessors report
        // 48 kHz / 16-bit, which is what `PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ` has always said.
        assert_eq!(OPUS_48K.rate_hz, crate::audio::SAMPLE_RATE_HZ);
        assert!(!OPUS_48K.is_pcm());
    }

    /// 96 kHz doubles every sample count, and the buffer has to follow the negotiated rate rather
    /// than the 48 kHz constant it used to hardcode — otherwise a hi-res session's concealment run
    /// would be clamped to half the audio it owes.
    #[test]
    fn the_buffer_follows_the_negotiated_rate() {
        let hi = AudioFormat {
            rate_hz: 96_000,
            ..PCM_48K_24
        };
        let mut state = AudioPcmState::default();
        // The longest ladder rung at 96 kHz is 5 ms = 480 samples/ch.
        let (expect, wire) = pcm_frame(480 * 2, hi.bits);
        assert_eq!(state.decode_packet(&wire, 0, hi), Ok(expect.len()));
        assert_eq!(&state.pcm[..expect.len()], &expect[..]);
        assert_eq!(
            state.pcm.len(),
            (1 + CONCEAL_RUN as usize) * 480 * 2,
            "sized from 96 kHz, not from the 48 kHz default"
        );
        // A full concealment run fits, which is the point of sizing it that way.
        let n = state
            .decode_packet(&wire, 1 + CONCEAL_RUN, hi)
            .expect("decodes");
        assert_eq!(n, (1 + CONCEAL_RUN as usize) * expect.len());
    }

    /// The concealment run is 50 ms of audio, so a SHORT frame owes more frames — 25 at 2 ms, not
    /// 10 — and this buffer has to have been sized for the run the tracker will actually ask for.
    ///
    /// ⚠ This is the invariant with teeth: [`punktfunk_connection_next_audio_pcm`] hands the
    /// embedder a raw pointer INTO `pcm` and it reads from there until its next call, so the `Vec`
    /// must never grow. A cap that outran the buffer would not crash — `stage_scratch` clamps —
    /// it would silently truncate a concealment run, which is exactly the kind of quiet
    /// almost-right this plane is being built against. Driven at 44 100 Hz because that is the
    /// rate where a frame is no longer `rate × µs`: the frames are 88 samples per channel.
    #[test]
    fn a_short_frame_owes_more_concealment_and_the_buffer_was_sized_for_it() {
        let short = AudioFormat {
            rate_hz: 44_100,
            frame_us: 2_000,
            ..PCM_48K_24
        };
        let run = crate::audio::max_conceal_packets(short.frame_us);
        assert_eq!(run, 25, "50 ms of 2 ms frames");

        // 2 ms at 44 100 Hz stereo: 88 samples per channel, NOT 88.2.
        let frame = crate::audio::pcm::samples_per_frame(44_100, 2_000, 2);
        assert_eq!(frame, 176);
        let (expect, wire) = pcm_frame(frame, short.bits);

        let mut state = AudioPcmState::default();
        assert_eq!(state.decode_packet(&wire, 0, short), Ok(expect.len()));
        // Sized for the run at THIS frame, from the longest rung's frame size (5 ms = 220/ch).
        assert_eq!(state.pcm.len(), (1 + run as usize) * 220 * 2);
        let base = state.pcm.as_ptr();

        // A maximal gap: 25 concealed frames plus the real one, contiguous, and the buffer did
        // not move under the embedder's pointer.
        let n = state.decode_packet(&wire, 1 + run, short).expect("decodes");
        assert_eq!(n, (1 + run as usize) * expect.len());
        assert!(n <= state.pcm.len(), "the run must fit what was allocated");
        assert!(
            std::ptr::eq(base, state.pcm.as_ptr()),
            "pcm was reallocated"
        );
    }

    /// The in-core PCM decoder heals seq gaps with concealment, exactly like the decode loops
    /// the other clients run themselves: a lost packet's worth of PLC lands in front of the
    /// arriving frame, DTX markers advance the accounting without being decoded, and a gap is
    /// capped at the tracker's 50 ms.
    #[test]
    fn audio_pcm_decode_conceals_seq_gaps() {
        const FRAME: usize = 240; // 5 ms @ 48 kHz, per channel
        let l = crate::audio::LAYOUT_STEREO;
        let mut enc = opus::MSEncoder::new(
            48_000,
            l.streams,
            l.coupled,
            l.mapping,
            opus::Application::LowDelay,
        )
        .expect("MSEncoder");
        enc.set_vbr(false).unwrap();
        let mut packet = |tone: f32| {
            let mut frame = vec![0f32; FRAME * 2];
            for (i, s) in frame.iter_mut().enumerate() {
                *s = 0.25 * (i as f32 * tone).sin();
            }
            let mut out = vec![0u8; 1500];
            let n = enc.encode_float(&frame, &mut out).unwrap();
            out.truncate(n);
            out
        };

        let mut state = AudioPcmState::default();
        // In-order packets decode to exactly one frame each.
        assert_eq!(
            state.decode_packet(&packet(0.05), 0, OPUS_48K),
            Ok(FRAME * 2)
        );
        assert_eq!(
            state.decode_packet(&packet(0.05), 1, OPUS_48K),
            Ok(FRAME * 2)
        );
        // Seq 2 lost: one concealed frame precedes the real one, contiguously.
        assert_eq!(
            state.decode_packet(&packet(0.06), 3, OPUS_48K),
            Ok(2 * FRAME * 2)
        );
        // A duplicate conceals nothing.
        assert_eq!(
            state.decode_packet(&packet(0.06), 3, OPUS_48K),
            Ok(FRAME * 2)
        );
        // DTX marker, nothing lost before it: nothing to emit (the ABI maps 0 to NoFrame)...
        assert_eq!(state.decode_packet(&[], 4, OPUS_48K), Ok(0));
        // ...but a DTX marker AFTER a loss still flushes the concealment owed (seq 5 lost).
        assert_eq!(state.decode_packet(&[], 6, OPUS_48K), Ok(FRAME * 2));
        // And the DTX slot itself was accounted, not treated as a loss.
        assert_eq!(
            state.decode_packet(&packet(0.07), 7, OPUS_48K),
            Ok(FRAME * 2)
        );
        // A huge gap is capped at 50 ms of concealment — ten frames at this session's 5 ms.
        assert_eq!(
            state.decode_packet(&packet(0.07), 1000, OPUS_48K),
            Ok((CONCEAL_RUN as usize + 1) * FRAME * 2)
        );
    }

    /// Drought concealment (WP-C1): the embedder asks for a frame at a time while the wire is
    /// quiet, and the loss path must then charge only for what the drought did NOT already cover
    /// — concealing a packet lost inside a covered drought a second time would insert audio the
    /// stream never carried and push everything after it later. The subtraction lives here
    /// because the gap tracker does; the embedder driving the drought cannot see it.
    #[test]
    fn drought_concealment_is_not_charged_again_by_the_loss_path() {
        const FRAME: usize = 240; // 5 ms @ 48 kHz, per channel
        let l = crate::audio::LAYOUT_STEREO;
        let mut enc = opus::MSEncoder::new(
            48_000,
            l.streams,
            l.coupled,
            l.mapping,
            opus::Application::LowDelay,
        )
        .expect("MSEncoder");
        enc.set_vbr(false).unwrap();
        let mut packet = |tone: f32| {
            let mut frame = vec![0f32; FRAME * 2];
            for (i, s) in frame.iter_mut().enumerate() {
                *s = 0.25 * (i as f32 * tone).sin();
            }
            let mut out = vec![0u8; 1500];
            let n = enc.encode_float(&frame, &mut out).unwrap();
            out.truncate(n);
            out
        };

        let mut state = AudioPcmState::default();
        // Nothing has decoded: PLC has no state to extrapolate from, and the ABI reports NoFrame.
        assert_eq!(state.conceal(OPUS_48K), Ok(0));

        assert_eq!(
            state.decode_packet(&packet(0.05), 0, OPUS_48K),
            Ok(FRAME * 2)
        );
        // The wire goes quiet; the embedder covers four frames of it.
        for _ in 0..4 {
            assert_eq!(state.conceal(OPUS_48K), Ok(FRAME * 2));
        }
        // It comes back at seq 7 — six packets missing, four of them already in the ring.
        assert_eq!(
            state.decode_packet(&packet(0.06), 7, OPUS_48K),
            Ok(3 * FRAME * 2)
        );
        // …and the next drought starts from nothing owed, not from a stale credit.
        assert_eq!(
            state.decode_packet(&packet(0.06), 9, OPUS_48K),
            Ok(2 * FRAME * 2)
        );
    }
}
