/*******************************************************************************
 * Copyright (c) 2026, Jan Koester jan.koester@gmx.net
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 * Redistributions of source code must retain the above copyright
 *      notice, this list of conditions and the following disclaimer.
 * Redistributions in binary form must reproduce the above copyright
 *      notice, this list of conditions and the following disclaimer in the
 *      documentation and/or other materials provided with the distribution.
 * Neither the name of the <organization> nor the
 *      names of its contributors may be used to endorse or promote products
 *      derived from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
 * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *******************************************************************************/

#include "socket.h"
#include "eventapi.h"
#include "exception.h"
#include "random.h"
#include "threadpool.h"
#include "crypto/aes.h"
#include "crypto/sha.h"
#include "crypto/curve25519.h"
#include "crypto/rsa.h"
#include "crypto/rsa_pss_sha256.h"
#include "crypto/rsa_pss_sha384.h"

#include <algorithm>
#include <thread>
#include <chrono>
#include <cstdio>
#include <cmath>

#define QUIC_DBG(fmt, ...) do { \
    if (getenv("QUIC_TRACE")) { \
        fprintf(stderr, "[%.6f] " fmt "\n", \
                std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count(), ##__VA_ARGS__); \
    } \
} while(0)

namespace netplus {

namespace {
// Connection IDs are peer-chosen and RFC 9000 allows 0-20 bytes, so they
// can't be packed into a fixed-width integer key without either truncating
// (risking two different CIDs colliding on the same key) or zero-padding
// (risking a short CID colliding with a longer one that happens to start
// with the same bytes followed by zeros). std::string has neither problem
// (it carries its own length) and, for CIDs up to the small-string-optimized
// capacity (>=15 bytes on libstdc++/libc++) — which covers every CID this
// codebase generates (always 8 bytes, see generateConnectionId) and most
// real-world peer-chosen ones — needs no heap allocation, unlike the
// std::vector<uint8_t> key this replaces.
inline std::string cidKey(const uint8_t* data, size_t len) {
    return std::string(reinterpret_cast<const char*>(data), len);
}
inline std::string cidKey(const std::vector<uint8_t>& v) {
    return cidKey(v.data(), v.size());
}

// Shared pool for running stream-callback dispatches off the epoll worker
// threads (see quic::scheduleDispatches). Process-wide and lazily
// constructed on first use (C++11 guarantees thread-safe init of function-
// local statics); sized generously since this work is dominated by waiting
// on network round trips, not CPU, so oversubscribing relative to core
// count is fine — the cost of a mostly-idle extra thread is far smaller
// than the cost of a slow callback blocking one of the few epoll workers.
ThreadPool& quicDispatchPool() {
    static ThreadPool pool((std::max<unsigned>)(4, std::thread::hardware_concurrency() * 2));
    return pool;
}
// F32 (performance report): deliberately NOT split into several
// independent sub-pools ("sharding", e.g. hashed by connection ID) as the
// report suggests, to defuse this pool's one shared queue as a contention
// point under very many concurrently promoted connections (see
// scheduleDispatches()'s comment). Reason: at most one runner is ever
// active per connection anyway (exactly what scheduleDispatches()'s own
// FIFO guarantee enforces), so the shared queue is only briefly locked at
// enqueue/dequeue, not for a whole callback's duration — real contention
// on this queue would only show up with very many concurrently promoted
// (i.e. already "slow"-marked) connections and a correspondingly high
// core count. This sandbox has neither the core count nor the load
// profile to even produce that contention, let alone verify a sharding
// change against it — a blindly added hash-sharding layer would be
// nothing more than an unsubstantiated guess here.
} // namespace

// ============================================================================
// Constructors & Destructor
// ============================================================================

quic::quic() : udp() {
    _Socket = -1;
    _Type = sockettype::QUIC;  // QUIC runs over UDP but has its own type
    _conn_state.store(ConnectionState::Idle);
    _is_server = false;
    _handshake_complete = false;
    _version = QUIC_VERSION_1;
    _last_activity = std::chrono::steady_clock::now();
    
    // Generate local connection ID
    generateConnectionId(_local_cid, 8);
}

quic::quic(SOCKET sock) : udp(sock) {
    _Type = sockettype::QUIC;
    _conn_state.store(ConnectionState::Idle);
    _is_server = false;
    _handshake_complete = false;
    _version = QUIC_VERSION_1;
    _last_activity = std::chrono::steady_clock::now();
    
    // Generate local connection ID
    generateConnectionId(_local_cid, 8);
}

quic::quic(const std::string& addr, int port, int maxconnections, int sockopts)
    : udp(addr, port, maxconnections, sockopts) {
    _Type = sockettype::QUIC;
    _is_server = true;
    _conn_state.store(ConnectionState::Idle);
    _handshake_complete = false;
    _version = QUIC_VERSION_1;
    _last_activity = std::chrono::steady_clock::now();
    
    // Enlarge the receive buffer so high-throughput clients don't
    // overrun the kernel queue between epoll_wait cycles.  4 MB keeps
    // up with ~3000 in-flight 1200-byte QUIC datagrams.

    int rcvbuf = 4 * 1024 * 1024;
    int sndbuf = 4 * 1024 * 1024;

    #ifdef _WIN32
    const char* rcvopt = reinterpret_cast<const char*>(&rcvbuf);
    const char* sndopt = reinterpret_cast<const char*>(&sndbuf);
    #else
    int* rcvopt = &rcvbuf;
    int* sndopt = &sndbuf;
    #endif
        
    ::setsockopt(_Socket, SOL_SOCKET, SO_RCVBUF, rcvopt, sizeof(rcvbuf));
    
    ::setsockopt(_Socket, SOL_SOCKET, SO_SNDBUF, sndopt, sizeof(sndbuf));

#ifdef Windows
    // Disable SIO_UDP_CONNRESET: on Windows, an ICMP "port unreachable"
    // response causes the next recvfrom to fail with WSAECONNRESET.
    {
        BOOL bNewBehavior = FALSE;
        DWORD dwBytesReturned = 0;
        ::WSAIoctl(_Socket, SIO_UDP_CONNRESET, &bNewBehavior, sizeof(bNewBehavior),
                  NULL, 0, &dwBytesReturned, NULL, NULL);
    }
#endif

    // Set non-blocking: on Windows MSG_DONTWAIT is a no-op (defined as 0),
    // so the server socket must be non-blocking to prevent stalls.
    setNonBlock();

    // Probe for UDP GSO/GRO kernel offload support
    probeGSOGRO();

    // Generate local connection ID for server
    generateConnectionId(_local_cid, 8);

    // Secret backing Retry/NEW_TOKEN tokens for this listener's lifetime.
    _token_secret.resize(32);
    fillRandomBytes(_token_secret.data(), _token_secret.size());

    // Secret backing session tickets (0-RTT PSKs) for this listener's
    // lifetime — separate from _token_secret since it seals more sensitive
    // payload (see its declaration in socket.h).
    _ticket_secret.resize(32);
    fillRandomBytes(_ticket_secret.data(), _ticket_secret.size());
}

quic::quic(const std::map<std::string, ssl::CertificateBundle>& certs,
           const std::string& addr, int port, int maxconnections, int sockopts)
    : udp(addr, port, maxconnections, sockopts) {
    _Type = sockettype::QUIC;
    _is_server = true;
    _conn_state.store(ConnectionState::Idle);
    _handshake_complete = false;
    _version = QUIC_VERSION_1;
    _cert_map = certs;
    _last_activity = std::chrono::steady_clock::now();
    
    // Enlarge the receive/send buffers so high-throughput clients don't
    // overrun the kernel queue between epoll_wait cycles.  4 MB keeps
    // up with ~3000 in-flight 1200-byte QUIC datagrams.
    int rcvbuf = 4 * 1024 * 1024;
    int sndbuf = 4 * 1024 * 1024;

#ifdef _WIN32
    const char* rcvopt = reinterpret_cast<const char*>(&rcvbuf);
    const char* sndopt = reinterpret_cast<const char*>(&sndbuf);
#else
    int* rcvopt = &rcvbuf;
    int* sndopt = &sndbuf;
#endif

    ::setsockopt(_Socket, SOL_SOCKET, SO_RCVBUF, rcvopt, sizeof(rcvbuf));
    
    ::setsockopt(_Socket, SOL_SOCKET, SO_SNDBUF, sndopt, sizeof(sndbuf));

#ifdef Windows
    // Disable SIO_UDP_CONNRESET: on Windows, an ICMP "port unreachable"
    // response causes the next recvfrom to fail with WSAECONNRESET.
    BOOL bNewBehavior = FALSE;
    DWORD dwBytesReturned = 0;
    ::WSAIoctl(_Socket, SIO_UDP_CONNRESET, &bNewBehavior, sizeof(bNewBehavior),
              NULL, 0, &dwBytesReturned, NULL, NULL);
#endif

    // Set non-blocking: on Windows MSG_DONTWAIT is a no-op (defined as 0),
    // so the server socket must be non-blocking to prevent recvfrom/sendto
    // from stalling the accept loop.
    setNonBlock();

    // Probe for UDP GSO/GRO kernel offload support
    probeGSOGRO();

    // Generate local connection ID for server
    generateConnectionId(_local_cid, 8);

    // Secret backing Retry/NEW_TOKEN tokens for this listener's lifetime.
    _token_secret.resize(32);
    fillRandomBytes(_token_secret.data(), _token_secret.size());

    // Secret backing session tickets (0-RTT PSKs) for this listener's
    // lifetime — separate from _token_secret since it seals more sensitive
    // payload (see its declaration in socket.h).
    _ticket_secret.resize(32);
    fillRandomBytes(_ticket_secret.data(), _ticket_secret.size());
}

quic::~quic() {
    std::lock_guard<std::recursive_mutex> lock(quic_mtx());

    // Deliberately does NOT touch _parent->_child_connections: this
    // destructor can run as a direct side effect of the registry itself
    // dropping the last shared_ptr to this connection (accept()'s purge
    // step erases both of a connection's registry entries while holding
    // _registry_mutex — the second erase can synchronously destroy it).
    // If this destructor also tried to lock _registry_mutex here, that
    // would be a nested lock of a non-recursive mutex the same thread
    // already holds — deadlock/UB. Registry membership is only ever
    // removed by code that already holds _registry_mutex (accept()'s
    // purge and stale-reconnect paths), which extracts the shared_ptr(s)
    // and lets them go only after releasing the lock — see there for the
    // actual cleanup. close() also relies on that same purge step rather
    // than removing itself, for the same reason.
    if (_parent != nullptr) {
        // Child connections share the parent's socket fd — do NOT close it.
        // Invalidate _Socket so that ~udp() won't call ::close() on the
        // parent's listening socket (which would cause a double-close /
        // "free(): invalid pointer" crash when the parent or another child
        // tries to use the already-closed fd).
        _Socket = -1;
    }
    
    // Clean up streams
    _streams.clear();
    
    // Clear send queue
    _send_queue.clear();
    
    // Clear crypto buffers
    _crypto_recv_initial.clear();
    _crypto_recv_handshake.clear();
    _crypto_recv_app.clear();
    _crypto_send_initial.clear();
    _crypto_send_handshake.clear();
    _crypto_ranges_initial.clear();
    _crypto_ranges_handshake.clear();
    _crypto_ranges_app.clear();
    
    // Clear TLS state
    _tls_transcript.clear();
    
    // Zero out keys for security (also drops the cached AES cipher objects —
    // LevelKeys::zero() covers both, unlike the old per-array memsets this
    // replaced, which never released the header-protection ciphers).
    _initial_keys.zero();
    _hs_keys.zero();
    _app_keys.zero();

    _conn_state.store(ConnectionState::Closed);
}

// ============================================================================
// Helper: Generate Connection ID
// ============================================================================

void quic::generateConnectionId(std::vector<uint8_t>& cid, size_t len) {
    cid.resize(len);
    fillRandomBytes(cid.data(), len);
}

// ============================================================================
// Helper: Send packet to peer (uses udp class methods)
// Retries on EAGAIN up to 5 times with 1ms back-off to avoid losing
// packets when the kernel TX buffer is temporarily full (e.g. after
// sending the 1200-byte ServerHello, the Handshake flight may EAGAIN).
// ============================================================================

ssize_t quic::sendPacket(const uint8_t* data, size_t len) {
    if (_Socket < 0) {
        QUIC_DBG("sendPacket: _Socket < 0, dropping %zu bytes", len);
        return -1;
    }

    // RFC 9000 §8.1 anti-amplification limit: prior to validating the
    // CLIENT's address, a SERVER must never send more than 3x what it has
    // received from that address (protects a third party from having this
    // server used as a reflector against a spoofed victim IP). This is not
    // a restriction on the client's own sends — a client isn't a
    // reflection risk to anyone, and gating it here would (and, before
    // this `_is_server` guard was added, silently did — caught by 0-RTT's
    // sendStreamDataEarly() being the first client code path to ever call
    // through sendPacket() before receiving anything back from the server)
    // block a client's own 0-RTT packets from ever going out at all, since
    // _bytes_received_unvalidated is necessarily still 0 for a connection
    // that hasn't heard back from the server yet. (The client's very first
    // Initial send happens to dodge this today via a different code path —
    // connect() calls udp::sendData() directly rather than through here —
    // but that's incidental, not a substitute for gating this correctly.)
    // Known consequence for the server side (not something this check
    // tries to work around): a large enough handshake flight (e.g. an
    // oversized certificate chain) may need more than one round trip to
    // complete under strict enforcement — see _address_validated's comment
    // in socket.h.
    if (_is_server && !_address_validated && _bytes_sent_unvalidated + len > 3 * _bytes_received_unvalidated) {
        QUIC_DBG("sendPacket: anti-amplification limit reached, dropping %zu bytes "
                 "(sent=%lu recv=%lu)", len, (unsigned long)_bytes_sent_unvalidated,
                 (unsigned long)_bytes_received_unvalidated);
        return -1;
    }

    QUIC_DBG("sendPacket: len=%zu parent=%p _Socket=%d", len, (void*)_parent, _Socket);

    for (int attempt = 0; attempt < 5; ++attempt) {
        buffer send_buf(reinterpret_cast<const char*>(data), len);

        try {
            size_t sent;
            if (_parent != nullptr) {
                QUIC_DBG("sendPacket: sendTo (child) attempt=%d", attempt);
                sent = udp::sendTo(send_buf, MSG_DONTWAIT);
            } else {
                QUIC_DBG("sendPacket: sendData (server) attempt=%d", attempt);
                sent = udp::sendData(send_buf, MSG_DONTWAIT);
            }

            QUIC_DBG("sendPacket: sent=%zu", sent);
            if (sent > 0) {
                _last_activity = std::chrono::steady_clock::now();
                if (!_address_validated) _bytes_sent_unvalidated += sent;
            }
            return static_cast<ssize_t>(sent);
        } catch (NetException& e) {
            QUIC_DBG("sendPacket: exception attempt=%d type=%d", attempt, e.getErrorType());
            // EAGAIN/EWOULDBLOCK — poll for writable
            if (e.getErrorType() == NetException::Note && attempt < 4) {
                socketwait sw;
                sw.waitWrite(*this, 0);
                continue;
            }
            return -1;
        }
    }
    return -1;
}

// ============================================================================
// Batched Packet Sending: accumulate packets, flush via udp class
// ============================================================================

void quic::batchPacket(const uint8_t* data, size_t len) {
    // Flat arena instead of one heap vector per queued packet (see
    // BatchEntry's comment in socket.h). Sized to the currently confirmed
    // usable payload (getMaxUdpPayload()) times BATCH_MAX, grown only if a
    // packet or a PMTU-discovery increase needs more room — grow is O(1)
    // amortized just like std::vector's own, and rare in practice.
    size_t needed = _send_arena_used + len;
    if (_send_arena.size() < needed) {
        size_t grow_to = std::max(needed, BATCH_MAX * std::max<size_t>(_max_udp_payload, len));
        _send_arena.resize(grow_to);
    }
    std::memcpy(_send_arena.data() + _send_arena_used, data, len);
    _send_batch.push_back({_send_arena_used, len});
    _send_arena_used += len;
    if (_send_batch.size() >= BATCH_MAX) {
        flushBatch();
    }
}

ssize_t quic::flushBatch() {
    if (_send_batch.empty()) return 0;
    if (_Socket < 0) { _send_batch.clear(); _send_arena_used = 0; return -1; }

    // Build pointer/length pairs for udp::sendBatch
    std::vector<std::pair<const uint8_t*, size_t>> datagrams;
    datagrams.reserve(_send_batch.size());
    for (auto& entry : _send_batch)
        datagrams.emplace_back(_send_arena.data() + entry.offset, entry.len);

    // Child connections send to a specific peer address
    sockaddr_storage* dest = nullptr;
    socklen_t dest_len = 0;
    if (_parent != nullptr) {
        dest = &_Addr;
        dest_len = _AddrLen;
    }

    ssize_t sent = udp::sendBatch(datagrams, dest, dest_len);
    if (sent > 0) {
        _last_activity = std::chrono::steady_clock::now();
        const size_t sent_count = static_cast<size_t>(sent);
        if (sent_count >= _send_batch.size()) {
            _send_batch.clear();
            _send_arena_used = 0;
        } else {
            // Compact the still-unsent tail down to the front of the arena
            // so _send_arena_used stays bounded across repeated partial
            // sends instead of growing forever.
            size_t new_used = 0;
            for (size_t i = sent_count; i < _send_batch.size(); ++i) {
                auto& e = _send_batch[i];
                if (e.offset != new_used) {
                    std::memmove(_send_arena.data() + new_used, _send_arena.data() + e.offset, e.len);
                }
                e.offset = new_used;
                new_used += e.len;
            }
            _send_batch.erase(_send_batch.begin(), _send_batch.begin() + sent_count);
            _send_arena_used = new_used;
        }
        return sent;
    }
    if (sent == 0) {
        return 0;
    }
    // Keep unsent packets queued so the caller can retry after writable/ACK progress.
    return sent;
}

// ============================================================================
// Batched processing of 1-RTT packets
//
// "Batched" here means ACK coalescing only: one ACK is sent after the whole
// batch instead of one per packet (a measured ~30x reduction in ACK-only
// sendto() calls under load — see pumpIncoming()'s comment). Each packet's
// AEAD decrypt/header-protection/frame processing below still runs one at a
// time in the loop; there is no cross-packet crypto batching (each packet
// number requires its own AEAD nonce, so that work is not shareable across
// packets the way the ACK is).
// ============================================================================

void quic::processApplicationPacketsBatch(
    const std::vector<std::pair<const uint8_t*, size_t>>& packets)
{
    _current_enc_level = EncryptionLevel::Application;

    std::vector<uint8_t>& header = _recv_header_scratch;
    std::vector<uint8_t>& payload = _recv_payload_scratch;
    for (auto& [data, len] : packets) {
        uint64_t pn;

        if (!unprotectPacket(data, len, header, payload, pn, EncryptionLevel::Application, _app_keys.recv)) {
            QUIC_DBG("processApplicationPacketsBatch: decrypt FAILED");
            continue;
        }
        QUIC_DBG("processApplicationPacketsBatch: decrypt OK pn=%lu payload=%zu",
                 (unsigned long)pn, payload.size());

        if (pn > _app_pn_recv) {
            _app_pn_recv = pn;
        }
        trackRecvPN(pn, _app_pn_recv_ranges);

        // Defer ACK across the whole batch — sent once below (or piggybacked
        // sooner if frame processing triggers an outgoing data packet), so N
        // packets arriving in one recv cycle cost one ACK instead of N. Only
        // set if at least one ack-eliciting frame was seen (RFC 9000
        // §13.2.1) — otherwise an ACK-only packet from the peer would
        // trigger an ACK back, which triggers another, forever.
        size_t offset = 0;
        while (offset < payload.size()) {
            bool ack_eliciting = false;
            processFrame(payload.data(), payload.size(), offset, ack_eliciting);
            if (ack_eliciting) {
                if (!_app_ack_pending) _app_ack_elicit_since = std::chrono::steady_clock::now();
                _app_ack_pending = true;
            }
        }
    }

    if (_app_ack_pending) {
        sendAppAck();
        _app_ack_pending = false;
    }
}

// ============================================================================
// Congestion Control: NewReno (RFC 9002)
// ============================================================================

bool quic::cwndAllowsSend(size_t packet_size) const {
    // RFC 9002: A sender MUST be able to send at least one minimum-sized
    // datagram when nothing is in flight, even if it exceeds cwnd.
    if (_bytes_in_flight == 0) return true;
    return _bytes_in_flight + packet_size <= _cwnd;
}

void quic::onPacketSentCC(size_t packet_size) {
    _bytes_in_flight += packet_size;
}

void quic::onPacketAckedCC(size_t packet_size, uint64_t pn) {
    if (_bytes_in_flight >= packet_size) {
        _bytes_in_flight -= packet_size;
    } else {
        _bytes_in_flight = 0;
    }

    if (_cc_algorithm == CongestionControlAlgorithm::BbrLite) {
        // Entirely separate cwnd-setting strategy — see
        // setCongestionControlAlgorithm()'s comment. Does not use
        // _ssthresh/_in_recovery/HyStart++ at all; cwnd is set directly
        // from the bandwidth-delay-product estimate on every ACK instead
        // of grown incrementally.
        bbrLiteUpdateCwnd(packet_size);
        return;
    }

    // If this packet was sent during recovery, don't grow cwnd
    if (_in_recovery && pn <= _recovery_start_pn) {
        return;
    }
    _in_recovery = false;

    // Slow start: cwnd grows by bytes acked (or, under HyStart++
    // Conservative Slow Start, by a fraction of that — see
    // hystartOnRttSample()).
    if (_cwnd < _ssthresh) {
        if (_hystart_css_active) {
            _cwnd += packet_size / HYSTART_CSS_GROWTH_DIVISOR;
        } else {
            _cwnd += packet_size;
        }
    } else {
        // Congestion avoidance: cwnd grows by ~1 MTU per RTT (RFC 9002
        // Appendix B.5). Uses the actual confirmed usable datagram size
        // (_max_udp_payload — 1472 until DPLPMTUD confirms a larger path
        // MTU) rather than a hardcoded 1472, so cwnd growth doesn't stay
        // capped at the Ethernet-safe baseline on paths where PMTU
        // discovery has since raised it (e.g. jumbo frames).
        // Approximation: cwnd += MTU * packet_size / cwnd
        _cwnd += (_max_udp_payload * packet_size) / _cwnd;
    }
}

// BBR-lite bandwidth/RTT estimation and cwnd sizing — see
// setCongestionControlAlgorithm()'s comment for scope/limitations.
void quic::bbrLiteUpdateCwnd(size_t acked_bytes) {
    auto now = std::chrono::steady_clock::now();

    // Delivery-rate sample: bytes acked since the last sample, divided by
    // the elapsed wall-clock time. Skipped for the very first sample
    // (nothing to measure an interval against yet) and for a zero/negative
    // interval (defensive — steady_clock is monotonic, but back-to-back
    // ACKs processed in the same batch can legitimately measure ~0s).
    if (_bbr_last_delivery_time.time_since_epoch().count() != 0) {
        double dt = std::chrono::duration<double>(now - _bbr_last_delivery_time).count();
        if (dt > 0.0) {
            double bw_sample = static_cast<double>(acked_bytes) / dt; // bytes/sec
            _bbr_bw_samples[_bbr_bw_sample_idx] = bw_sample;
            _bbr_bw_sample_idx = (_bbr_bw_sample_idx + 1) % BBR_BW_WINDOW;
        }
    }
    _bbr_last_delivery_time = now;

    // Running minimum RTT — see the class-level comment on why this never
    // re-adapts upward (no periodic PROBE_RTT in this simplified mode).
    if (_latest_rtt > 0.0 && (_bbr_min_rtt < 0.0 || _latest_rtt < _bbr_min_rtt)) {
        _bbr_min_rtt = _latest_rtt;
    }

    double max_bw = 0.0;
    for (double s : _bbr_bw_samples) max_bw = std::max(max_bw, s);

    if (max_bw > 0.0 && _bbr_min_rtt > 0.0) {
        uint64_t bdp = static_cast<uint64_t>(max_bw * _bbr_min_rtt * BBR_CWND_GAIN);
        _cwnd = std::max(bdp, BBR_MIN_CWND);
    }
    // Else: not enough samples yet — leave cwnd at its current value
    // (the RFC 9002 §7.2 initial window, same starting point as NewReno).
}

void quic::onPacketLostCC(size_t packet_size, uint64_t pn) {
    if (_bytes_in_flight >= packet_size) {
        _bytes_in_flight -= packet_size;
    } else {
        _bytes_in_flight = 0;
    }

    if (_cc_algorithm == CongestionControlAlgorithm::BbrLite) {
        // Deliberately no explicit multiplicative decrease here (unlike
        // NewReno) — matching real BBR's design of not treating isolated
        // loss as a direct congestion signal. The bandwidth estimate in
        // bbrLiteUpdateCwnd() naturally reflects reduced delivery through
        // fewer/slower subsequent ACKs, which is this simplified mode's
        // only feedback path for loss; a full BBR2 additionally caps cwnd
        // explicitly during PROBE_BW on sustained loss, which this mode
        // does not implement (see setCongestionControlAlgorithm()).
        return;
    }

    // Enter recovery if not already in it
    if (!_in_recovery || pn > _recovery_start_pn) {
        _in_recovery = true;
        // _app_pn_send is the *next*, not-yet-assigned PN — the marker
        // must be the last PN actually sent before this recovery episode
        // began, so that onPacketAckedCC()'s `pn <= _recovery_start_pn`
        // exit check correctly treats the very first packet sent *during*
        // recovery (which gets PN == that next value) as eligible to end
        // it, instead of requiring one further packet/ACK first (RFC 9002
        // §7.3.2: recovery ends on an ACK for any packet sent during it).
        _recovery_start_pn = (_app_pn_send > 0) ? (_app_pn_send - 1) : 0;
        // Multiplicative decrease
        _ssthresh = std::max(_cwnd / 2, MIN_CWND);
        _cwnd = _ssthresh;
        // A loss ends slow start outright — any in-progress HyStart++
        // round-tracking (including CSS) is now moot; clear it so stale
        // pre-loss RTT samples can't influence a later slow-start phase
        // (e.g. if a future ssthresh increase ever let cwnd re-enter it).
        hystartReset();
    }
}

void quic::hystartReset() {
    _hystart_css_active = false;
    _hystart_css_rounds_remaining = 0;
    _hystart_window_end = 0;
    _hystart_curr_round_min_rtt = -1.0;
    _hystart_last_round_min_rtt = -1.0;
    _hystart_rtt_sample_count = 0;
}

// HyStart++ (RFC 9438 §4). Called once per RTT sample (see processAckFrame())
// while cwnd is still growing via slow start or CSS; a no-op once ordinary
// congestion avoidance has taken over. Tracks a per-round minimum RTT and,
// on a sustained increase versus the previous round, switches from
// exponential slow-start growth to Conservative Slow Start (a fraction of
// the growth, applied in onPacketAckedCC()) for a bounded number of rounds
// before either resuming normal slow start (the increase was transient) or
// exiting into congestion avoidance for good (it wasn't).
void quic::hystartOnRttSample(double rtt_sample, uint64_t acked_pn) {
    if (_cwnd >= _ssthresh && !_hystart_css_active) return;

    if (_hystart_window_end == 0) {
        // First sample of a fresh round (slow start just began, or this
        // follows a hystartReset()).
        _hystart_window_end = _app_pn_send;
    }

    if (_hystart_curr_round_min_rtt < 0.0 || rtt_sample < _hystart_curr_round_min_rtt) {
        _hystart_curr_round_min_rtt = rtt_sample;
    }
    ++_hystart_rtt_sample_count;

    if (acked_pn < _hystart_window_end) {
        return; // still within the current round
    }

    // Round boundary: this ACK covers a packet sent at/after the round's
    // start — evaluate the round just completed, then start the next one.
    bool have_enough_samples = _hystart_rtt_sample_count >= HYSTART_N_RTT_SAMPLE;
    double curr_min = _hystart_curr_round_min_rtt;

    if (!_hystart_css_active) {
        if (have_enough_samples && _hystart_last_round_min_rtt >= 0.0) {
            double thresh = std::max(HYSTART_MIN_RTT_THRESH,
                                      std::min(HYSTART_MAX_RTT_THRESH, _hystart_last_round_min_rtt / 8.0));
            if (curr_min >= _hystart_last_round_min_rtt + thresh) {
                // Sustained RTT increase — likely queue buildup ahead of
                // loss. Enter Conservative Slow Start instead of continuing
                // to double cwnd every round.
                _hystart_css_active = true;
                _hystart_css_rounds_remaining = HYSTART_CSS_ROUNDS;
                _ssthresh = _cwnd;
            }
        }
    } else {
        double thresh = std::max(HYSTART_MIN_RTT_THRESH,
                                  std::min(HYSTART_MAX_RTT_THRESH, _hystart_last_round_min_rtt / 8.0));
        bool still_elevated = have_enough_samples && curr_min >= _hystart_last_round_min_rtt + thresh;

        if (!still_elevated) {
            // Resolved (or inconclusive) — treat as transient, resume
            // ordinary exponential slow start.
            _hystart_css_active = false;
        } else if (--_hystart_css_rounds_remaining <= 0) {
            // Elevated for the whole CSS window — exit slow start for good.
            _cwnd = _ssthresh;
            _hystart_css_active = false;
        }
        // else: still elevated but CSS rounds remain — keep growing conservatively.
    }

    _hystart_last_round_min_rtt = curr_min;
    _hystart_curr_round_min_rtt = -1.0;
    _hystart_rtt_sample_count = 0;
    _hystart_window_end = _app_pn_send;
}

// ============================================================================
// Path MTU Discovery (DPLPMTUD, RFC 8899 / RFC 9000 §14.3-14.4)
// ============================================================================

// Send a PATH_CHALLENGE padded with PADDING frames to an exact wire size,
// and record it as the outstanding probe. header_overhead mirrors the
// formula sendData()/sendStreamData() use for stream-data chunking: flags
// (1) + DCID + packet number (4) + AEAD tag (16).
void quic::pmtudSendProbe(uint64_t size) {
    const size_t header_overhead = 1 + _remote_cid.size() + 4 + 16;
    const size_t path_challenge_len = 9;  // frame type (1) + data (8)
    if (size <= header_overhead + path_challenge_len) return;

    fillRandomBytes(_pmtud_probe_data.data(), _pmtud_probe_data.size());

    std::vector<uint8_t> frame;
    frame.reserve(size - header_overhead);
    frame.push_back(0x1a);  // PATH_CHALLENGE
    frame.insert(frame.end(), _pmtud_probe_data.begin(), _pmtud_probe_data.end());
    frame.resize(size - header_overhead, 0x00);  // PADDING (type 0x00) fills the rest

    std::vector<uint8_t> packet = buildShortHeaderPacket(frame);
    applyHeaderProtection(packet, EncryptionLevel::Application, _app_keys.send);
    sendPacket(packet.data(), packet.size());  // best-effort, like the PING keepalive below

    _pmtud_probe_size = size;
    _pmtud_probe_sent = std::chrono::steady_clock::now();
}

// A PATH_RESPONSE matched our outstanding probe: the probed size is
// confirmed usable, so raise the confirmed send size and widen the
// search's lower bound.
void quic::pmtudOnProbeAcked() {
    _pmtud_lo = _pmtud_probe_size;
    _max_udp_payload = _pmtud_probe_size;
    _pmtud_probe_size = 0;
    _pmtud_attempts = 0;
}

// Called once per pumpNetwork() iteration (alongside the PING keepalive
// check) to drive the probe state machine: retry/time out an outstanding
// probe, or start the next one. Binary search between the largest
// confirmed-working size and the smallest size ruled out so far, bounded
// above by both our own configured ceiling and the peer's advertised
// max_udp_payload_size.
void quic::pmtudTick() {
    if (_pmtud_probe_size != 0) {
        double pto = _rtt_initialized
            ? (_srtt + std::max(4.0 * _rttvar, 0.001) + 0.025)
            : _rto;
        double elapsed = std::chrono::duration<double>(
            std::chrono::steady_clock::now() - _pmtud_probe_sent).count();
        if (elapsed < pto * 2.0) return;  // still within the probe timeout

        if (++_pmtud_attempts < PMTUD_MAX_ATTEMPTS) {
            pmtudSendProbe(_pmtud_probe_size);  // retry, same size
            return;
        }

        // Unconfirmed after repeated attempts — rule this size (and
        // everything larger) out, then fall through to try smaller.
        _pmtud_ruled_out = std::min(_pmtud_ruled_out, _pmtud_probe_size);
        _pmtud_probe_size = 0;
        _pmtud_attempts = 0;
    }

    if (_pmtud_lo == 0) _pmtud_lo = _max_udp_payload;  // seed from today's confirmed size

    uint64_t hi = std::min({_pmtud_ceiling, _pmtud_peer_ceiling, _pmtud_ruled_out - 1});
    if (hi <= _pmtud_lo || hi - _pmtud_lo < PMTUD_CONVERGE)
        return;  // converged, or nothing above the current baseline to explore

    pmtudSendProbe(_pmtud_lo + (hi - _pmtud_lo + 1) / 2);
}

// ============================================================================
// Variable-length Integer Encoding/Decoding (RFC 9000 Section 16)
// ============================================================================

size_t quic::encodeVarInt(uint64_t value, uint8_t* out) {
    // Branchless length-class derivation (mirrors decodeVarInt's bit-shift
    // approach below): each comparison is unconditional and summed instead
    // of a short-circuiting if/else-if chain, so classification itself
    // never depends on branch prediction guessing right for whatever
    // distribution of values a given call site happens to produce (e.g.
    // monotonically growing stream offsets crossing a threshold).
    unsigned length_class = (value > 63) + (value > 16383) + (value > 1073741823);
    switch (length_class) {
        case 0:
            out[0] = static_cast<uint8_t>(value);
            return 1;
        case 1:
            out[0] = static_cast<uint8_t>(0x40 | (value >> 8));
            out[1] = static_cast<uint8_t>(value & 0xFF);
            return 2;
        case 2:
            out[0] = static_cast<uint8_t>(0x80 | (value >> 24));
            out[1] = static_cast<uint8_t>((value >> 16) & 0xFF);
            out[2] = static_cast<uint8_t>((value >> 8) & 0xFF);
            out[3] = static_cast<uint8_t>(value & 0xFF);
            return 4;
        default:
            out[0] = static_cast<uint8_t>(0xC0 | (value >> 56));
            out[1] = static_cast<uint8_t>((value >> 48) & 0xFF);
            out[2] = static_cast<uint8_t>((value >> 40) & 0xFF);
            out[3] = static_cast<uint8_t>((value >> 32) & 0xFF);
            out[4] = static_cast<uint8_t>((value >> 24) & 0xFF);
            out[5] = static_cast<uint8_t>((value >> 16) & 0xFF);
            out[6] = static_cast<uint8_t>((value >> 8) & 0xFF);
            out[7] = static_cast<uint8_t>(value & 0xFF);
            return 8;
    }
}

uint64_t quic::decodeVarInt(const uint8_t* data, size_t remaining, size_t& bytes_read) {
    if (remaining == 0) {
        bytes_read = 0;
        return 0;
    }

    // Prefix's top 2 bits select the encoded length: 1, 2, 4, or 8 bytes.
    const size_t encoded_length = size_t{1} << (data[0] >> 6);
    if (encoded_length > remaining) {
        // Truncated — never read the bytes that aren't there. bytes_read
        // == 0 is an unambiguous failure signal (a real varint always
        // consumes at least 1 byte), which callers MUST check for. As a
        // second line of defense, every frame/field parsing loop in this
        // file also carries an explicit progress guard (offset must
        // advance every iteration or parsing aborts) — see processFrame(),
        // processCryptoFrame(), processStreamFrame(), processAckFrame() —
        // so even a call site that forgets to check bytes_read can't spin
        // forever or read past the buffer; it just aborts one level up.
        bytes_read = 0;
        return 0;
    }

    uint64_t value = data[0] & 0x3F;
    switch (encoded_length) {
        case 1:
            bytes_read = 1;
            return value;
        case 2:
            bytes_read = 2;
            return (value << 8) | data[1];
        case 4:
            bytes_read = 4;
            return (value << 24) | (static_cast<uint64_t>(data[1]) << 16) |
                   (static_cast<uint64_t>(data[2]) << 8) | data[3];
        default: // 8
            bytes_read = 8;
            return (value << 56) | (static_cast<uint64_t>(data[1]) << 48) |
                   (static_cast<uint64_t>(data[2]) << 40) | (static_cast<uint64_t>(data[3]) << 32) |
                   (static_cast<uint64_t>(data[4]) << 24) | (static_cast<uint64_t>(data[5]) << 16) |
                   (static_cast<uint64_t>(data[6]) << 8) | data[7];
    }
}

size_t quic::varIntSize(uint64_t value) {
    if (value <= 63) return 1;
    if (value <= 16383) return 2;
    if (value <= 1073741823) return 4;
    return 8;
}

// ============================================================================
// Packet Number Decoding (RFC 9000 Section 17.1)
// ============================================================================

uint64_t quic::decodePacketNumber(uint32_t truncated_pn, size_t pn_len, uint64_t largest_pn) {
    uint64_t expected_pn = largest_pn + 1;
    uint64_t pn_win = 1ULL << (pn_len * 8);
    uint64_t pn_hwin = pn_win / 2;
    uint64_t pn_mask = pn_win - 1;
    
    // The candidate packet number
    uint64_t candidate_pn = (expected_pn & ~pn_mask) | truncated_pn;
    
    // Be careful with underflow - check if expected_pn >= pn_hwin before subtracting
    if (expected_pn >= pn_hwin && 
        candidate_pn <= expected_pn - pn_hwin && 
        candidate_pn < (1ULL << 62) - pn_win) {
        return candidate_pn + pn_win;
    }
    if (candidate_pn > expected_pn + pn_hwin && candidate_pn >= pn_win) {
        return candidate_pn - pn_win;
    }
    return candidate_pn;
}

// ============================================================================
// Retry / NEW_TOKEN address-validation tokens (RFC 9000 §8.1)
// ============================================================================

namespace {

// Serializes a peer address into the fixed 1+N+2 byte form the token
// plaintext uses (addr_len byte, raw address bytes, big-endian port).
// Returns false for anything other than AF_INET/AF_INET6 (nothing else can
// reach this code — recvBatchAddr only ever hands back one of the two).
bool serializePeerAddr(const sockaddr_storage& addr, std::vector<uint8_t>& out) {
    if (addr.ss_family == AF_INET) {
        const auto* a4 = reinterpret_cast<const struct sockaddr_in*>(&addr);
        out.push_back(4);
        const uint8_t* ip = reinterpret_cast<const uint8_t*>(&a4->sin_addr);
        out.insert(out.end(), ip, ip + 4);
        uint16_t port = ntohs(a4->sin_port);
        out.push_back(static_cast<uint8_t>(port >> 8));
        out.push_back(static_cast<uint8_t>(port));
        return true;
    } else if (addr.ss_family == AF_INET6) {
        const auto* a6 = reinterpret_cast<const struct sockaddr_in6*>(&addr);
        out.push_back(16);
        const uint8_t* ip = reinterpret_cast<const uint8_t*>(&a6->sin6_addr);
        out.insert(out.end(), ip, ip + 16);
        uint16_t port = ntohs(a6->sin6_port);
        out.push_back(static_cast<uint8_t>(port >> 8));
        out.push_back(static_cast<uint8_t>(port));
        return true;
    }
    return false;
}

// Constant-time byte compare — avoids a timing oracle on tag verification.
bool constantTimeEqual(const uint8_t* a, const uint8_t* b, size_t len) {
    uint8_t diff = 0;
    for (size_t i = 0; i < len; ++i) diff |= static_cast<uint8_t>(a[i] ^ b[i]);
    return diff == 0;
}

} // namespace

std::array<uint8_t, 16> quic::computeRetryIntegrityTag(
    const std::vector<uint8_t>& original_dcid,
    const uint8_t* header_and_token, size_t header_and_token_len) {
    // RFC 9001 §5.8: fixed, public (not secret) AES-128-GCM key/nonce —
    // this authenticates packet *format* against accidental corruption,
    // not origin against a malicious peer (anyone can compute this tag for
    // any content; real protection is the token itself plus the transport
    // parameter cross-check, both keyed off the server's private secret).
    static const uint8_t RETRY_KEY[16] = {
        0xbe, 0x0c, 0x69, 0x0b, 0x9f, 0x66, 0x57, 0x5a,
        0x1d, 0x76, 0x6b, 0x54, 0xe3, 0x68, 0xc8, 0x4e };
    static const uint8_t RETRY_NONCE[12] = {
        0x46, 0x15, 0x99, 0xd3, 0x5d, 0x63, 0x2b, 0xf2,
        0x23, 0x98, 0x25, 0xbb };

    std::vector<uint8_t> aad;
    aad.reserve(1 + original_dcid.size() + header_and_token_len);
    aad.push_back(static_cast<uint8_t>(original_dcid.size()));
    aad.insert(aad.end(), original_dcid.begin(), original_dcid.end());
    aad.insert(aad.end(), header_and_token, header_and_token + header_and_token_len);

    aes128 retry_aead(std::vector<uint8_t>(RETRY_KEY, RETRY_KEY + 16));
    std::array<uint8_t, 16> tag{};
    // Empty plaintext: this degenerates to a pure GMAC over the AAD, which
    // is exactly what §5.8 specifies (confirmed safe — the CTR loop and
    // GHASH's ct-update both no-op cleanly when len==0).
    retry_aead.aes_gcm_encrypt(RETRY_NONCE, aad.data(), aad.size(),
                               nullptr, 0, nullptr, tag.data());
    return tag;
}

std::vector<uint8_t> quic::generateToken(bool is_retry, const std::vector<uint8_t>& original_dcid,
                                          const sockaddr_storage& peer_addr, socklen_t /*peer_len*/) const {
    quic* listener = _parent ? _parent : const_cast<quic*>(this);

    // Plaintext: type(1) + issued_at(8) + addr(1+4-or-16) + port(2) + [odcid(1+len) if retry]
    std::vector<uint8_t> plaintext;
    plaintext.push_back(is_retry ? 0 : 1);
    uint64_t now = static_cast<uint64_t>(std::chrono::duration_cast<std::chrono::seconds>(
        std::chrono::system_clock::now().time_since_epoch()).count());
    for (int i = 7; i >= 0; --i) plaintext.push_back(static_cast<uint8_t>(now >> (8 * i)));
    if (!serializePeerAddr(peer_addr, plaintext)) {
        return {}; // unsupported address family — no token to issue
    }
    if (is_retry) {
        plaintext.push_back(static_cast<uint8_t>(original_dcid.size()));
        plaintext.insert(plaintext.end(), original_dcid.begin(), original_dcid.end());
    }

    // AEAD key derived from the listener's per-process secret — same HKDF
    // helper (and cipher-suite constant, 0x1301 = SHA-256-based) already
    // used throughout this file for QUIC key derivation.
    std::vector<uint8_t> token_key = netplus::tls::hkdf_expand_label(
        listener->_token_secret, "quic token", {}, 16, 0x1301);

    uint8_t nonce[12];
    fillRandomBytes(nonce, sizeof(nonce));

    aes128 token_aead(token_key);
    std::vector<uint8_t> out(12 + plaintext.size() + 16);
    std::memcpy(out.data(), nonce, 12);
    uint8_t* ct = out.data() + 12;
    uint8_t* tag = ct + plaintext.size();
    token_aead.aes_gcm_encrypt(nonce, nullptr, 0, plaintext.data(), plaintext.size(), ct, tag);
    return out;
}

quic::ParsedToken quic::validateToken(const std::vector<uint8_t>& token,
                                       const sockaddr_storage& peer_addr, socklen_t /*peer_len*/) const {
    ParsedToken result;

    // Minimum: 12-byte nonce + 16-byte tag + smallest possible plaintext
    // (type + issued_at + addr_len + 4-byte IPv4 + port = 16 bytes).
    if (token.size() < 12 + 16 + 16) return result;

    quic* listener = _parent ? _parent : const_cast<quic*>(this);
    std::vector<uint8_t> token_key = netplus::tls::hkdf_expand_label(
        listener->_token_secret, "quic token", {}, 16, 0x1301);

    const uint8_t* nonce = token.data();
    const uint8_t* ct = token.data() + 12;
    size_t ct_len = token.size() - 12 - 16;
    const uint8_t* tag = token.data() + token.size() - 16;

    std::vector<uint8_t> plaintext(ct_len);
    aes128 token_aead(token_key);
    if (!token_aead.aes_gcm_decrypt(nonce, nullptr, 0, ct, ct_len, tag, plaintext.data())) {
        return result; // tampered/wrong-key token
    }

    size_t off = 0;
    if (off + 1 > plaintext.size()) return result;
    uint8_t type = plaintext[off++];
    if (type > 1) return result;

    if (off + 8 > plaintext.size()) return result;
    uint64_t issued_at = 0;
    for (int i = 0; i < 8; ++i) issued_at = (issued_at << 8) | plaintext[off++];

    if (off + 1 > plaintext.size()) return result;
    uint8_t addr_len = plaintext[off++];
    if (addr_len != 4 && addr_len != 16) return result;
    if (off + addr_len + 2 > plaintext.size()) return result;
    const uint8_t* tok_addr = &plaintext[off];
    off += addr_len;
    uint16_t tok_port = static_cast<uint16_t>((plaintext[off] << 8) | plaintext[off + 1]);
    off += 2;

    std::vector<uint8_t> odcid;
    if (type == 0) {
        if (off + 1 > plaintext.size()) return result;
        uint8_t odcid_len = plaintext[off++];
        if (off + odcid_len > plaintext.size()) return result;
        odcid.assign(plaintext.begin() + off, plaintext.begin() + off + odcid_len);
        off += odcid_len;
    }

    // Bind to the requesting address — a token minted for one client must
    // never validate for a different one.
    std::vector<uint8_t> actual_addr;
    if (!serializePeerAddr(peer_addr, actual_addr)) return result;
    if (actual_addr.size() != static_cast<size_t>(1 + addr_len + 2)) return result;
    if (!constantTimeEqual(actual_addr.data() + 1, tok_addr, addr_len)) return result;
    uint16_t actual_port = static_cast<uint16_t>((actual_addr[1 + addr_len] << 8) | actual_addr[1 + addr_len + 1]);
    if (actual_port != tok_port) return result;

    // Expiry: Retry tokens must be used almost immediately (the round trip
    // should be near-instant); NEW_TOKEN-issued tokens are meant to survive
    // until a later, separate connection attempt, so get a much longer
    // window. Small forward tolerance for clock skew between processes.
    uint64_t now = static_cast<uint64_t>(std::chrono::duration_cast<std::chrono::seconds>(
        std::chrono::system_clock::now().time_since_epoch()).count());
    const uint64_t kClockSkewTolerance = 5;
    const uint64_t kRetryWindow = 10;
    const uint64_t kNewTokenWindow = 24 * 60 * 60;
    if (issued_at > now + kClockSkewTolerance) return result;
    uint64_t age = now - issued_at;
    if (type == 0 && age > kRetryWindow) return result;
    if (type == 1 && age > kNewTokenWindow) return result;

    result.valid = true;
    result.is_retry = (type == 0);
    result.original_dcid = std::move(odcid);
    return result;
}

// ============================================================================
// Session Tickets (RFC 8446 §4.6.1) — self-encrypted resumption-PSK carrier
// ============================================================================
//
// Same AEAD wire format as generateToken()/validateToken() above (12-byte
// random nonce + AES-128-GCM ciphertext + 16-byte tag), sealed under
// _ticket_secret instead of _token_secret. Plaintext layout: ticket_id(16) +
// issued_at(8) + lifetime_secs(4) + cipher_suite(2) + max_early_data_size(4)
// + psk_len(1) + psk.

std::vector<uint8_t> quic::generateSessionTicket(const std::vector<uint8_t>& ticket_id,
                                                  uint16_t cipher_suite,
                                                  const std::vector<uint8_t>& psk,
                                                  uint32_t lifetime_secs,
                                                  uint32_t max_early_data_size) const {
    quic* listener = _parent ? _parent : const_cast<quic*>(this);

    std::vector<uint8_t> plaintext;
    plaintext.insert(plaintext.end(), ticket_id.begin(), ticket_id.end());
    uint64_t now = static_cast<uint64_t>(std::chrono::duration_cast<std::chrono::seconds>(
        std::chrono::system_clock::now().time_since_epoch()).count());
    for (int i = 7; i >= 0; --i) plaintext.push_back(static_cast<uint8_t>(now >> (8 * i)));
    for (int i = 3; i >= 0; --i) plaintext.push_back(static_cast<uint8_t>(lifetime_secs >> (8 * i)));
    plaintext.push_back(static_cast<uint8_t>(cipher_suite >> 8));
    plaintext.push_back(static_cast<uint8_t>(cipher_suite));
    for (int i = 3; i >= 0; --i) plaintext.push_back(static_cast<uint8_t>(max_early_data_size >> (8 * i)));
    plaintext.push_back(static_cast<uint8_t>(psk.size()));
    plaintext.insert(plaintext.end(), psk.begin(), psk.end());

    std::vector<uint8_t> ticket_key = netplus::tls::hkdf_expand_label(
        listener->_ticket_secret, "quic ticket", {}, 16, 0x1301);

    uint8_t nonce[12];
    fillRandomBytes(nonce, sizeof(nonce));

    aes128 ticket_aead(ticket_key);
    std::vector<uint8_t> out(12 + plaintext.size() + 16);
    std::memcpy(out.data(), nonce, 12);
    uint8_t* ct = out.data() + 12;
    uint8_t* tag = ct + plaintext.size();
    ticket_aead.aes_gcm_encrypt(nonce, nullptr, 0, plaintext.data(), plaintext.size(), ct, tag);
    return out;
}

quic::ParsedTicket quic::validateSessionTicket(const std::vector<uint8_t>& ticket) const {
    ParsedTicket result;

    // Minimum plaintext: 16 (id) + 8 (issued_at) + 4 (lifetime) + 2 (cipher)
    // + 4 (max_early) + 1 (psk_len) + 32 (smallest possible PSK) = 67.
    if (ticket.size() < 12 + 16 + 67) return result;

    quic* listener = _parent ? _parent : const_cast<quic*>(this);
    std::vector<uint8_t> ticket_key = netplus::tls::hkdf_expand_label(
        listener->_ticket_secret, "quic ticket", {}, 16, 0x1301);

    const uint8_t* nonce = ticket.data();
    const uint8_t* ct = ticket.data() + 12;
    size_t ct_len = ticket.size() - 12 - 16;
    const uint8_t* tag = ticket.data() + ticket.size() - 16;

    std::vector<uint8_t> plaintext(ct_len);
    aes128 ticket_aead(ticket_key);
    if (!ticket_aead.aes_gcm_decrypt(nonce, nullptr, 0, ct, ct_len, tag, plaintext.data())) {
        return result; // tampered/wrong-key ticket
    }

    size_t off = 0;
    if (off + 16 > plaintext.size()) return result;
    std::vector<uint8_t> ticket_id(plaintext.begin(), plaintext.begin() + 16);
    off += 16;

    if (off + 8 > plaintext.size()) return result;
    uint64_t issued_at = 0;
    for (int i = 0; i < 8; ++i) issued_at = (issued_at << 8) | plaintext[off++];

    if (off + 4 > plaintext.size()) return result;
    uint32_t lifetime_secs = 0;
    for (int i = 0; i < 4; ++i) lifetime_secs = (lifetime_secs << 8) | plaintext[off++];

    if (off + 2 > plaintext.size()) return result;
    uint16_t cipher_suite = (static_cast<uint16_t>(plaintext[off]) << 8) | plaintext[off + 1];
    off += 2;

    if (off + 4 > plaintext.size()) return result;
    uint32_t max_early_data_size = 0;
    for (int i = 0; i < 4; ++i) max_early_data_size = (max_early_data_size << 8) | plaintext[off++];

    if (off + 1 > plaintext.size()) return result;
    uint8_t psk_len = plaintext[off++];
    if (off + psk_len > plaintext.size()) return result;
    std::vector<uint8_t> psk(plaintext.begin() + off, plaintext.begin() + off + psk_len);
    off += psk_len;

    uint64_t now = static_cast<uint64_t>(std::chrono::duration_cast<std::chrono::seconds>(
        std::chrono::system_clock::now().time_since_epoch()).count());
    const uint64_t kClockSkewTolerance = 5;
    if (issued_at > now + kClockSkewTolerance) return result;
    uint64_t age = now - issued_at;
    if (age > lifetime_secs) return result;

    result.valid = true;
    result.ticket_id = std::move(ticket_id);
    result.cipher_suite = cipher_suite;
    result.psk = std::move(psk);
    result.max_early_data_size = max_early_data_size;
    return result;
}

// ============================================================================
// Certificate Configuration
// ============================================================================

void quic::setCertificates(const std::map<std::string, ssl::CertificateBundle>& certs) {
    std::lock_guard<std::recursive_mutex> lock(quic_mtx());
    _cert_map = certs;
}

// ============================================================================
// Crypto Key Derivation - Initial Keys (RFC 9001 Section 5.2)
// ============================================================================

void quic::deriveInitialKeys(const std::vector<uint8_t>& dcid, bool is_server) {
    // QUIC v1 initial salt (RFC 9001)
    static const uint8_t QUIC_V1_INITIAL_SALT[] = {
        0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3,
        0x4d, 0x17, 0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad,
        0xcc, 0xbb, 0x7f, 0x0a
    };
    
    // Use TLS class HKDF methods with AES-128-GCM-SHA256 (0x1301)
    std::vector<uint8_t> salt(QUIC_V1_INITIAL_SALT, 
                               QUIC_V1_INITIAL_SALT + sizeof(QUIC_V1_INITIAL_SALT));
    std::vector<uint8_t> initial_secret = netplus::tls::hkdf_extract(salt, dcid, 0x1301);
    
    // Derive client initial secret using TLS HKDF expand
    std::vector<uint8_t> client_initial_secret = netplus::tls::hkdf_expand_label(
        initial_secret, "client in", {}, 32, 0x1301);
    
    // Derive server initial secret
    std::vector<uint8_t> server_initial_secret = netplus::tls::hkdf_expand_label(
        initial_secret, "server in", {}, 32, 0x1301);
    
    // Derive client keys using QUIC-specific labels
    std::vector<uint8_t> client_key = netplus::tls::hkdf_expand_label(
        client_initial_secret, "quic key", {}, 16, 0x1301);
    std::vector<uint8_t> client_iv = netplus::tls::hkdf_expand_label(
        client_initial_secret, "quic iv", {}, 12, 0x1301);
    std::vector<uint8_t> client_hp = netplus::tls::hkdf_expand_label(
        client_initial_secret, "quic hp", {}, 16, 0x1301);
    
    // Derive server keys
    std::vector<uint8_t> server_key = netplus::tls::hkdf_expand_label(
        server_initial_secret, "quic key", {}, 16, 0x1301);
    std::vector<uint8_t> server_iv = netplus::tls::hkdf_expand_label(
        server_initial_secret, "quic iv", {}, 12, 0x1301);
    std::vector<uint8_t> server_hp = netplus::tls::hkdf_expand_label(
        server_initial_secret, "quic hp", {}, 16, 0x1301);
    
    // Fill the consolidated send/recv key material for this level.
    auto& send = _initial_keys.send;
    auto& recv = _initial_keys.recv;
    std::memcpy(send.key, is_server ? server_key.data() : client_key.data(), 16);
    std::memcpy(send.iv,  is_server ? server_iv.data()  : client_iv.data(),  12);
    std::memcpy(send.hp,  is_server ? server_hp.data()  : client_hp.data(),  16);
    std::memcpy(recv.key, is_server ? client_key.data() : server_key.data(), 16);
    std::memcpy(recv.iv,  is_server ? client_iv.data()  : server_iv.data(),  12);
    std::memcpy(recv.hp,  is_server ? client_hp.data()  : server_hp.data(),  16);

    // Create AES instances for initial encryption level
    send.aes_gcm = std::make_unique<aes128>(is_server ? server_key : client_key);
    recv.aes_gcm = std::make_unique<aes128>(is_server ? client_key : server_key);

    // Cache AES instances for header protection (ECB mode, reused across packets)
    send.aes_hp = std::make_unique<aes128>(is_server ? server_hp : client_hp);
    recv.aes_hp = std::make_unique<aes128>(is_server ? client_hp : server_hp);

    if (getenv("QUIC_TRACE")) {
        auto hex = [](const std::vector<uint8_t>& v) {
            std::string s;
            for (uint8_t b : v) { char t[3]; snprintf(t, 3, "%02x", b); s += t; }
            return s;
        };
        QUIC_DBG("deriveInitialKeys: is_server=%d dcid=%s client_key=%s client_iv=%s client_hp=%s",
                 (int)is_server, hex(dcid).c_str(), hex(client_key).c_str(),
                 hex(client_iv).c_str(), hex(client_hp).c_str());
    }
}

// ============================================================================
// Crypto Key Derivation - Handshake Keys
// ============================================================================

void quic::deriveHandshakeKeys(const std::vector<uint8_t>& shared_secret) {
    
    // Determine key/hash sizes based on selected cipher
    const bool use_sha384 = (_selected_cipher == 0x1302);  // TLS_AES_256_GCM_SHA384
    const size_t hash_len = use_sha384 ? 48 : 32;
    const size_t key_len = use_sha384 ? 32 : 16;  // AES-256 uses 32-byte keys
    const size_t hp_len = use_sha384 ? 32 : 16;   // HP key length matches AEAD key size
    const uint16_t cipher_suite = use_sha384 ? 0x1302 : 0x1301;
    
    
    // Compute transcript hash using appropriate hash function
    std::vector<uint8_t> transcript_hash = use_sha384 
        ? sha384_hash(_tls_transcript)
        : sha256_hash(_tls_transcript);
    
    // Compute empty hash
    std::vector<uint8_t> empty_hash = use_sha384
        ? sha384_hash({})
        : sha256_hash({});
    
    // Use TLS class HKDF methods for key derivation
    std::vector<uint8_t> zero_key(hash_len, 0);
    std::vector<uint8_t> early_secret = netplus::tls::hkdf_extract({}, zero_key, cipher_suite);
    std::vector<uint8_t> derived_secret = netplus::tls::hkdf_expand_label(
        early_secret, "derived", empty_hash, hash_len, cipher_suite);
    std::vector<uint8_t> handshake_secret = netplus::tls::hkdf_extract(
        derived_secret, shared_secret, cipher_suite);
    std::vector<uint8_t> c_hs_traffic = netplus::tls::hkdf_expand_label(
        handshake_secret, "c hs traffic", transcript_hash, hash_len, cipher_suite);
    std::vector<uint8_t> s_hs_traffic = netplus::tls::hkdf_expand_label(
        handshake_secret, "s hs traffic", transcript_hash, hash_len, cipher_suite);
    
    // Derive QUIC-specific keys from traffic secrets
    std::vector<uint8_t> client_key = netplus::tls::hkdf_expand_label(
        c_hs_traffic, "quic key", {}, key_len, cipher_suite);
    std::vector<uint8_t> client_iv = netplus::tls::hkdf_expand_label(
        c_hs_traffic, "quic iv", {}, 12, cipher_suite);
    std::vector<uint8_t> client_hp = netplus::tls::hkdf_expand_label(
        c_hs_traffic, "quic hp", {}, hp_len, cipher_suite);
    std::vector<uint8_t> server_key = netplus::tls::hkdf_expand_label(
        s_hs_traffic, "quic key", {}, key_len, cipher_suite);
    std::vector<uint8_t> server_iv = netplus::tls::hkdf_expand_label(
        s_hs_traffic, "quic iv", {}, 12, cipher_suite);
    std::vector<uint8_t> server_hp = netplus::tls::hkdf_expand_label(
        s_hs_traffic, "quic hp", {}, hp_len, cipher_suite);
    
    // Store traffic secrets for Finished key derivation
    _c_hs_traffic_secret = c_hs_traffic;
    _s_hs_traffic_secret = s_hs_traffic;
    
    // Fill the consolidated send/recv key material for this level.
    auto& send = _hs_keys.send;
    auto& recv = _hs_keys.recv;
    std::memcpy(send.key, _is_server ? server_key.data() : client_key.data(), key_len);
    std::memcpy(send.iv,  _is_server ? server_iv.data()  : client_iv.data(),  12);
    std::memset(send.hp, 0, sizeof(send.hp));
    std::memcpy(send.hp,  _is_server ? server_hp.data()  : client_hp.data(),  hp_len);
    std::memcpy(recv.key, _is_server ? client_key.data() : server_key.data(), key_len);
    std::memcpy(recv.iv,  _is_server ? client_iv.data()  : server_iv.data(),  12);
    std::memset(recv.hp, 0, sizeof(recv.hp));
    std::memcpy(recv.hp,  _is_server ? client_hp.data()  : server_hp.data(),  hp_len);

    // Create AES instances for handshake encryption level
    if (use_sha384) {
        send.aes_gcm = std::make_unique<aes256>(_is_server ? server_key : client_key);
        recv.aes_gcm = std::make_unique<aes256>(_is_server ? client_key : server_key);
        send.aes_hp  = std::make_unique<aes256>(_is_server ? server_hp  : client_hp);
        recv.aes_hp  = std::make_unique<aes256>(_is_server ? client_hp  : server_hp);
    } else {
        send.aes_gcm = std::make_unique<aes128>(_is_server ? server_key : client_key);
        recv.aes_gcm = std::make_unique<aes128>(_is_server ? client_key : server_key);
        send.aes_hp  = std::make_unique<aes128>(_is_server ? server_hp  : client_hp);
        recv.aes_hp  = std::make_unique<aes128>(_is_server ? client_hp  : server_hp);
    }
}

// ============================================================================
// Crypto Key Derivation - Application Keys
// ============================================================================

void quic::deriveApplicationKeys() {
    // Determine key/hash sizes based on selected cipher
    const bool use_sha384 = (_selected_cipher == 0x1302);  // TLS_AES_256_GCM_SHA384
    const size_t hash_len = use_sha384 ? 48 : 32;
    const size_t key_len = use_sha384 ? 32 : 16;  // AES-256 uses 32-byte keys
    const size_t hp_len = use_sha384 ? 32 : 16;   // HP key length matches AEAD key size
    const uint16_t cipher_suite = use_sha384 ? 0x1302 : 0x1301;
    
    
    // Compute transcript hash using appropriate hash function
    std::vector<uint8_t> transcript_hash = use_sha384 
        ? sha384_hash(_tls_transcript)
        : sha256_hash(_tls_transcript);
    
    // Compute empty hash
    std::vector<uint8_t> empty_hash = use_sha384
        ? sha384_hash({})
        : sha256_hash({});
    
    // Use TLS class HKDF methods for key derivation
    std::vector<uint8_t> zero_key(hash_len, 0);
    std::vector<uint8_t> early_secret = netplus::tls::hkdf_extract({}, zero_key, cipher_suite);
    std::vector<uint8_t> derived_secret = netplus::tls::hkdf_expand_label(
        early_secret, "derived", empty_hash, hash_len, cipher_suite);
    std::vector<uint8_t> handshake_secret = netplus::tls::hkdf_extract(
        derived_secret, _ecdhe_shared, cipher_suite);
    std::vector<uint8_t> derived_hs = netplus::tls::hkdf_expand_label(
        handshake_secret, "derived", empty_hash, hash_len, cipher_suite);
    std::vector<uint8_t> master_secret = netplus::tls::hkdf_extract(
        derived_hs, zero_key, cipher_suite);
    // Retained for resumption_master_secret (session tickets) — see
    // processFinished()'s server branch.
    _master_secret = master_secret;
    std::vector<uint8_t> c_app_traffic = netplus::tls::hkdf_expand_label(
        master_secret, "c ap traffic", transcript_hash, hash_len, cipher_suite);
    std::vector<uint8_t> s_app_traffic = netplus::tls::hkdf_expand_label(
        master_secret, "s ap traffic", transcript_hash, hash_len, cipher_suite);
    
    // Derive QUIC-specific keys from traffic secrets
    std::vector<uint8_t> client_key = netplus::tls::hkdf_expand_label(
        c_app_traffic, "quic key", {}, key_len, cipher_suite);
    std::vector<uint8_t> client_iv = netplus::tls::hkdf_expand_label(
        c_app_traffic, "quic iv", {}, 12, cipher_suite);
    std::vector<uint8_t> client_hp = netplus::tls::hkdf_expand_label(
        c_app_traffic, "quic hp", {}, hp_len, cipher_suite);
    std::vector<uint8_t> server_key = netplus::tls::hkdf_expand_label(
        s_app_traffic, "quic key", {}, key_len, cipher_suite);
    std::vector<uint8_t> server_iv = netplus::tls::hkdf_expand_label(
        s_app_traffic, "quic iv", {}, 12, cipher_suite);
    std::vector<uint8_t> server_hp = netplus::tls::hkdf_expand_label(
        s_app_traffic, "quic hp", {}, hp_len, cipher_suite);
    
    
    // Fill the consolidated send/recv key material for this level.
    auto& send = _app_keys.send;
    auto& recv = _app_keys.recv;
    std::memcpy(send.key, _is_server ? server_key.data() : client_key.data(), key_len);
    std::memcpy(send.iv,  _is_server ? server_iv.data()  : client_iv.data(),  12);
    std::memset(send.hp, 0, sizeof(send.hp));
    std::memcpy(send.hp,  _is_server ? server_hp.data()  : client_hp.data(),  hp_len);
    std::memcpy(recv.key, _is_server ? client_key.data() : server_key.data(), key_len);
    std::memcpy(recv.iv,  _is_server ? client_iv.data()  : server_iv.data(),  12);
    std::memset(recv.hp, 0, sizeof(recv.hp));
    std::memcpy(recv.hp,  _is_server ? client_hp.data()  : server_hp.data(),  hp_len);

    // Create AES instances for application encryption level
    if (use_sha384) {
        send.aes_gcm = std::make_unique<aes256>(_is_server ? server_key : client_key);
        recv.aes_gcm = std::make_unique<aes256>(_is_server ? client_key : server_key);
        send.aes_hp  = std::make_unique<aes256>(_is_server ? server_hp  : client_hp);
        recv.aes_hp  = std::make_unique<aes256>(_is_server ? client_hp  : server_hp);
    } else {
        send.aes_gcm = std::make_unique<aes128>(_is_server ? server_key : client_key);
        recv.aes_gcm = std::make_unique<aes128>(_is_server ? client_key : server_key);
        send.aes_hp  = std::make_unique<aes128>(_is_server ? server_hp  : client_hp);
        recv.aes_hp  = std::make_unique<aes128>(_is_server ? client_hp  : server_hp);
    }
}

// ============================================================================
// Crypto Key Derivation - Early Data (0-RTT) Keys (RFC 8446 7.1 / RFC 9001 4.6.1)
// ============================================================================
//
// Unlike deriveInitialKeys()/deriveHandshakeKeys()/deriveApplicationKeys(),
// only ONE direction is ever populated per call — the client calls this with
// is_send=true right after building its ClientHello (to encrypt the 0-RTT
// packets it's about to send), and the server calls it with is_send=false
// right after accepting a client's PSK+early_data offer in
// processClientHello() (to decrypt the 0-RTT packets that follow). There is
// no "other direction" to fill: a server never sends 0-RTT, and a client
// never receives it.
//
// `psk` is the ticket's resumption PSK; the transcript hash is taken over
// whatever `_tls_transcript` holds right now, which every call site
// guarantees is exactly the ClientHello bytes (RFC 8446: early_traffic =
// Derive-Secret(early_secret, "c e traffic", ClientHello) — the client's
// ClientHello only, never anything from the server).
void quic::deriveEarlyDataKeys(const std::vector<uint8_t>& psk, bool is_send) {
    const bool use_sha384 = (_early_data_cipher_suite == 0x1302);
    const size_t hash_len = use_sha384 ? 48 : 32;
    const size_t key_len = use_sha384 ? 32 : 16;
    const size_t hp_len = use_sha384 ? 32 : 16;
    const uint16_t cipher_suite = _early_data_cipher_suite;

    std::vector<uint8_t> transcript_hash = use_sha384
        ? sha384_hash(_tls_transcript)
        : sha256_hash(_tls_transcript);

    std::vector<uint8_t> early_secret = netplus::tls::hkdf_extract({}, psk, cipher_suite);
    std::vector<uint8_t> early_traffic = netplus::tls::hkdf_expand_label(
        early_secret, "c e traffic", transcript_hash, hash_len, cipher_suite);

    std::vector<uint8_t> key = netplus::tls::hkdf_expand_label(
        early_traffic, "quic key", {}, key_len, cipher_suite);
    std::vector<uint8_t> iv = netplus::tls::hkdf_expand_label(
        early_traffic, "quic iv", {}, 12, cipher_suite);
    std::vector<uint8_t> hp = netplus::tls::hkdf_expand_label(
        early_traffic, "quic hp", {}, hp_len, cipher_suite);

    LevelKeyMaterial& mat = is_send ? _early_keys.send : _early_keys.recv;
    std::memcpy(mat.key, key.data(), key_len);
    std::memcpy(mat.iv, iv.data(), 12);
    std::memset(mat.hp, 0, sizeof(mat.hp));
    std::memcpy(mat.hp, hp.data(), hp_len);

    if (use_sha384) {
        mat.aes_gcm = std::make_unique<aes256>(key);
        mat.aes_hp  = std::make_unique<aes256>(hp);
    } else {
        mat.aes_gcm = std::make_unique<aes128>(key);
        mat.aes_hp  = std::make_unique<aes128>(hp);
    }

    if (getenv("QUIC_TRACE")) {
        QUIC_DBG("deriveEarlyDataKeys: is_send=%d cipher=0x%04x", (int)is_send, cipher_suite);
    }
}

// ============================================================================
// Header Protection (RFC 9001 Section 5.4)
// ============================================================================

void quic::applyHeaderProtection(std::vector<uint8_t>& packet, EncryptionLevel level, LevelKeyMaterial& mat) {
    if (packet.size() < 20) return;  // Need at least sample size
    
    // Find sample position (4 bytes after packet number)
    // For long header: header + pn_length
    // For short header: 1 + dcid_len + pn_length
    
    bool is_long = (packet[0] & 0x80) != 0;
    size_t pn_offset;
    
    if (is_long) {
        // Long header: skip version (4), DCID len (1), DCID, SCID len (1), SCID
        // Then token length (varint) for Initial, then length (varint), then PN
        size_t offset = 5;  // After version
        uint8_t dcid_len = packet[offset++];
        offset += dcid_len;
        uint8_t scid_len = packet[offset++];
        offset += scid_len;
        
        if (offset > packet.size()) return;

        // Skip token for Initial packets
        uint8_t packet_type = (packet[0] & 0x30) >> 4;
        if (packet_type == 0x00) {  // Initial
            size_t tok_bytes;
            uint64_t tok_len = decodeVarInt(&packet[offset], packet.size() - offset, tok_bytes);
            if (tok_bytes == 0) return;
            offset += tok_bytes;
            if (tok_len > packet.size() - offset) return;
            offset += tok_len;
        }

        // Skip length
        if (offset > packet.size()) return;
        size_t len_bytes;
        decodeVarInt(&packet[offset], packet.size() - offset, len_bytes);
        if (len_bytes == 0) return;
        offset += len_bytes;

        pn_offset = offset;
    } else {
        // Short header: 1 byte + DCID
        pn_offset = 1 + _remote_cid.size();
    }
    
    // Get PN length BEFORE applying mask (for protection application)
    size_t pn_len = (packet[0] & 0x03) + 1;
    
    // Sample starts 4 bytes after PN
    size_t sample_offset = pn_offset + 4;
    if (sample_offset + 16 > packet.size()) return;
    
    // Extract sample to get mask
    const uint8_t* sample = packet.data() + sample_offset;
    
    // HP cipher is already role/level-resolved by the caller's choice of
    // `mat` (e.g. _app_keys.send) — no more branching on force_aes128/
    // _handshake_complete/_is_server here.
    aes* cached_hp = mat.aes_hp.get();

    uint8_t mask[16];
    if (cached_hp) {
        cached_hp->encrypt_ecb(sample, mask);
    } else {
        // Fallback: create temporary instance
        const bool use_aes256 = (level != EncryptionLevel::Initial) && (_selected_cipher == 0x1302);
        if (use_aes256) {
            aes256 hp_aes(std::vector<uint8_t>(mat.hp, mat.hp + 32));
            hp_aes.encrypt_ecb(sample, mask);
        } else {
            aes128 hp_aes(std::vector<uint8_t>(mat.hp, mat.hp + 16));
            hp_aes.encrypt_ecb(sample, mask);
        }
    }

    // Apply mask to first byte
    if (is_long) {
        packet[0] ^= (mask[0] & 0x0F);
    } else {
        packet[0] ^= (mask[0] & 0x1F);
    }

    // Apply mask to packet number bytes
    for (size_t i = 0; i < pn_len; ++i) {
        packet[pn_offset + i] ^= mask[1 + i];
    }
}

// ============================================================================
// Packet Protection (AES-GCM - supports both AES-128 and AES-256)
// ============================================================================

void quic::protectPacketInto(const std::vector<uint8_t>& header,
                              const std::vector<uint8_t>& payload,
                              uint64_t packet_number,
                              EncryptionLevel level, LevelKeyMaterial& mat,
                              std::vector<uint8_t>& out) {
    // Build nonce: IV XOR packet_number (left-padded to 12 bytes)
    uint8_t nonce[12];
    std::memcpy(nonce, mat.iv, 12);
    for (int i = 0; i < 8; ++i) {
        nonce[11 - i] ^= static_cast<uint8_t>((packet_number >> (8 * i)) & 0xFF);
    }

    // Initial packets always use AES-128, others use the negotiated cipher
    const bool use_aes256 = (level != EncryptionLevel::Initial) && (_selected_cipher == 0x1302);

    // header + ciphertext + 16-byte tag. `out`'s capacity is reused across
    // calls when the caller passes a persistent scratch buffer (see
    // sendShortHeaderControlPacket()) instead of a fresh local vector.
    const size_t total = header.size() + payload.size() + 16;
    out.resize(total);

    // Copy header
    std::memcpy(out.data(), header.data(), header.size());

    // Encrypt payload directly into the output buffer (after header)
    uint8_t* ct_dst = out.data() + header.size();
    uint8_t* tag_dst = ct_dst + payload.size();

    // Cached AES instance is already role/level-resolved via `mat` — no
    // more pointer-identity comparison against a key array to guess it.
    aes* cached_aes = mat.aes_gcm.get();

    if (cached_aes) {
        cached_aes->aes_gcm_encrypt(nonce, header.data(), header.size(),
                                    payload.data(), payload.size(),
                                    ct_dst, tag_dst);
    } else if (use_aes256) {
        aes256 aes_gcm(std::vector<uint8_t>(mat.key, mat.key + 32));
        aes_gcm.aes_gcm_encrypt(nonce, header.data(), header.size(),
                                payload.data(), payload.size(),
                                ct_dst, tag_dst);
    } else {
        aes128 aes_gcm(std::vector<uint8_t>(mat.key, mat.key + 16));
        aes_gcm.aes_gcm_encrypt(nonce, header.data(), header.size(),
                                payload.data(), payload.size(),
                                ct_dst, tag_dst);
    }
}

std::vector<uint8_t> quic::protectPacket(const std::vector<uint8_t>& header,
                                          const std::vector<uint8_t>& payload,
                                          uint64_t packet_number,
                                          EncryptionLevel level, LevelKeyMaterial& mat) {
    std::vector<uint8_t> result;
    protectPacketInto(header, payload, packet_number, level, mat, result);
    return result;
}

bool quic::unprotectPacket(const uint8_t* data, size_t len,
                            std::vector<uint8_t>& header,
                            std::vector<uint8_t>& payload,
                            uint64_t& packet_number,
                            EncryptionLevel level, LevelKeyMaterial& mat,
                            size_t pn_offset_hint) {
    if (len < 20) {
        return false;  // Minimum packet size
    }

    // Inline header protection removal without copying the full packet.
    // We only need to modify the first byte and up to 4 PN bytes.
    // Use a small stack buffer for the header portion.

    bool is_long = (data[0] & 0x80) != 0;
    size_t pn_offset;

    if (is_long) {
        if (pn_offset_hint != SIZE_MAX && pn_offset_hint <= len) {
            // Caller (processInitialPacket/processHandshakePacket) already
            // parsed this same long header once — reuse its result instead
            // of walking DCID/SCID/token/length again (F08). Everything
            // below (sample_offset/header_len/ciphertext_len bounds checks)
            // still validates independently of where pn_offset came from,
            // so a wrong hint fails the AEAD tag rather than reading OOB.
            pn_offset = pn_offset_hint;
        } else {
            size_t offset = 5;
            if (offset >= len) return false;
            uint8_t dcid_len = data[offset++];
            if (offset + dcid_len >= len) return false;
            offset += dcid_len;
            if (offset >= len) return false;
            uint8_t scid_len = data[offset++];
            if (offset + scid_len >= len) return false;
            offset += scid_len;

            uint8_t pkt_type = (data[0] & 0x30) >> 4;
            if (pkt_type == 0x00) { // Initial - skip token
                size_t tok_bytes;
                uint64_t tok_len = decodeVarInt(&data[offset], len - offset, tok_bytes);
                if (tok_bytes == 0) return false;
                offset += tok_bytes;
                if (tok_len > len - offset) return false;
                offset += tok_len;
            }
            if (offset > len) return false;
            size_t len_bytes;
            decodeVarInt(&data[offset], len - offset, len_bytes);
            if (len_bytes == 0) return false;
            offset += len_bytes;
            pn_offset = offset;
        }
    } else {
        pn_offset = 1 + _local_cid.size();
    }
    
    // HP sample starts 4 bytes after PN offset
    size_t sample_offset = pn_offset + 4;
    if (sample_offset + 16 > len) return false;
    
    // Get HP mask — cipher already role/level-resolved via `mat`
    aes* cached_hp = mat.aes_hp.get();

    uint8_t mask[16];
    if (cached_hp) {
        cached_hp->encrypt_ecb(data + sample_offset, mask);
    } else {
        const bool use_aes256 = (level != EncryptionLevel::Initial) && (_selected_cipher == 0x1302);
        if (use_aes256) {
            aes256 hp_aes(std::vector<uint8_t>(mat.hp, mat.hp + 32));
            hp_aes.encrypt_ecb(data + sample_offset, mask);
        } else {
            aes128 hp_aes(std::vector<uint8_t>(mat.hp, mat.hp + 16));
            hp_aes.encrypt_ecb(data + sample_offset, mask);
        }
    }
    
    // Unmask first byte to get PN length
    uint8_t first_byte = data[0];
    if (is_long) {
        first_byte ^= (mask[0] & 0x0F);
    } else {
        first_byte ^= (mask[0] & 0x1F);
    }
    size_t pn_len = (first_byte & 0x03) + 1;
    
    // Unmask PN bytes
    uint8_t pn_bytes[4];
    for (size_t i = 0; i < pn_len; ++i)
        pn_bytes[i] = data[pn_offset + i] ^ mask[1 + i];
    
    // Compute header length
    size_t header_len;
    if (is_long) {
        header_len = pn_offset + pn_len;
    } else {
        header_len = 1 + _local_cid.size() + pn_len;
    }
    
    // Build the unprotected header for AAD (copy only the header, not full packet)
    header.resize(header_len);
    std::memcpy(header.data(), data, header_len);
    header[0] = first_byte;
    for (size_t i = 0; i < pn_len; ++i)
        header[header_len - pn_len + i] = pn_bytes[i];
    
    // Extract packet number
    uint32_t truncated_pn = 0;
    for (size_t i = 0; i < pn_len; ++i)
        truncated_pn = (truncated_pn << 8) | pn_bytes[i];
    
    // Decode full packet number
    uint64_t largest_pn;
    if (!is_long) {
        largest_pn = (_app_pn_recv > 0 ? _app_pn_recv - 1 : 0);
    } else {
        uint8_t pkt_type = (first_byte & 0x30) >> 4;
        if (pkt_type == 0x02) {
            largest_pn = (_handshake_pn_recv > 0 ? _handshake_pn_recv - 1 : 0);
        } else if (pkt_type == 0x01) {
            // 0-RTT shares the Application packet-number space with 1-RTT
            // (RFC 9000 §12.3) — not a space of its own.
            largest_pn = (_app_pn_recv > 0 ? _app_pn_recv - 1 : 0);
        } else {
            largest_pn = (_initial_pn_recv > 0 ? _initial_pn_recv - 1 : 0);
        }
    }
    packet_number = decodePacketNumber(truncated_pn, pn_len, largest_pn);
    QUIC_DBG("unprotectPacket: is_long=%d truncated_pn=%u pn_len=%zu largest_pn=%lu -> decoded_pn=%lu",
             (int)is_long, truncated_pn, pn_len, (unsigned long)largest_pn, (unsigned long)packet_number);
    
    // Build nonce
    uint8_t nonce[12];
    std::memcpy(nonce, mat.iv, 12);
    for (int i = 0; i < 8; ++i)
        nonce[11 - i] ^= static_cast<uint8_t>((packet_number >> (8 * i)) & 0xFF);

    // Decrypt
    size_t ciphertext_len = len - header_len - 16;
    if (ciphertext_len > len || len < header_len + 16) {
        return false;
    }

    const bool use_aes256 = (level != EncryptionLevel::Initial) && (_selected_cipher == 0x1302);

    payload.resize(ciphertext_len);
    const uint8_t* ct = data + header_len;
    const uint8_t* tag = data + len - 16;

    // Cached AES instance is already role/level-resolved via `mat`.
    aes* cached_aes = mat.aes_gcm.get();

    if (cached_aes) {
        return cached_aes->aes_gcm_decrypt(nonce, header.data(), header.size(),
                                           ct, ciphertext_len, tag, payload.data());
    } else if (use_aes256) {
        aes256 aes_gcm(std::vector<uint8_t>(mat.key, mat.key + 32));
        return aes_gcm.aes_gcm_decrypt(nonce, header.data(), header.size(),
                                       ct, ciphertext_len, tag, payload.data());
    } else {
        aes128 aes_gcm(std::vector<uint8_t>(mat.key, mat.key + 16));
        return aes_gcm.aes_gcm_decrypt(nonce, header.data(), header.size(),
                                       ct, ciphertext_len, tag, payload.data());
    }
}

// ============================================================================
// Socket Interface: pushReceivedData (Push raw UDP data for processing)
// ============================================================================

void quic::pushReceivedData(const uint8_t* data, size_t len) {
    std::lock_guard<std::recursive_mutex> lock(quic_mtx());
    _recv_buffer.insert(_recv_buffer.end(), data, data + len);
}

// ============================================================================
// Socket Interface: handshake_after_accept (Continue QUIC handshake)
// ============================================================================

void quic::handshake_after_accept() {
    std::vector<PendingDispatch> dispatches;
    {
    std::lock_guard<std::recursive_mutex> lock(quic_mtx());
    
    
    // === HANDSHAKE TIMEOUT CHECK (SERIAL) ===
    // This runs every time handshake_after_accept() is called by the event loop
    // It catches timeout even if no packets arrive
    if (_incomplete_ch_detected && !_handshake_timeout_triggered && !_handshake_complete) {
        auto now = std::chrono::steady_clock::now();
        auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
            now - _handshake_start_time).count();
        
        if (elapsed_ms > 2000) {  // 2 second timeout for fragmented messages
            _handshake_timeout_triggered = true;
            _conn_state.store(ConnectionState::Closed);
        }
    }
    
    // Process any buffered data
    if (!_recv_buffer.empty()) {
        std::vector<uint8_t> packet = std::move(_recv_buffer);
        _recv_buffer.clear();
        processIncomingPacket(packet.data(), packet.size());
    }
    
    // Try to receive more data if handshake not complete
    if (!_handshake_complete && _Socket >= 0) {
        buffer recv_buf(65535);
        try {
            size_t recv_len = udp::recvData(recv_buf, MSG_DONTWAIT);
            if (recv_len > 0) {
                processIncomingPacket(reinterpret_cast<const uint8_t*>(recv_buf.data.buf),
                                     recv_len);
            }
        } catch (NetException&) {
            // recvData throws on EAGAIN/EWOULDBLOCK or errors
        }
        {
            
            // If no data on child socket and parent exists, check if parent has data for us
            if (_parent != nullptr) {
                // Note: Parent will route packets to us via _child_connections in its accept()
                // We just need the event loop to call accept() on the parent
            }
        }
        
        // Handshake retransmission: if server sent handshake flight but
        // no completion after HS_RETRANSMIT_INTERVAL, resend the flight.
        if (_is_server && !_hs_flight_packets.empty()) {
            auto now = std::chrono::steady_clock::now();
            auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
                now - _hs_last_send).count();
            int timeout = 200 * (1 << std::min(_hs_retransmit_count, 4));  // 200, 400, 800, 1600, 3200
            if (elapsed > timeout) {
                _hs_retransmit_count++;
                _hs_last_send = now;
                for (auto& pkt : _hs_flight_packets) {
                    sendPacket(pkt.data(), pkt.size());
                }
            }
        }
    }
    
    // PING keepalive: send PING frame if connection is established but idle
    if (_handshake_complete &&
        _conn_state.load() == ConnectionState::Connected &&
        _Socket >= 0) {
        auto now = std::chrono::steady_clock::now();
        auto idle_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
            now - _last_activity).count();
        if (idle_ms >= KEEPALIVE_INTERVAL_MS) {
            // PING frame is just type 0x01, no payload
            std::vector<uint8_t> ping_frame = {0x01};
            std::vector<uint8_t> packet = buildShortHeaderPacket(ping_frame);
            applyHeaderProtection(packet, EncryptionLevel::Application, _app_keys.send);
            sendPacket(packet.data(), packet.size());
            _last_activity = now;
        }

        pmtudTick();
    }

    // Collect pending dispatches before releasing lock. Re-reserving right
    // after the move (which leaves _pending_dispatches at capacity 0)
    // trades one allocation now, sized for the whole next batch, for what
    // would otherwise be several reallocations later as push_back() grows
    // it 1->2->4->... from scratch on the next round of incremental
    // dispatches (F49) — using this batch's size as the hint since batch
    // sizes are typically steady under sustained load.
    size_t dispatch_batch_hint = _pending_dispatches.size();
    dispatches = std::move(_pending_dispatches);
    _pending_dispatches.clear();
    _pending_dispatches.reserve(dispatch_batch_hint);
    } // Release _quic_mutex

    // Dispatch stream callbacks outside the lock
    if (!dispatches.empty() && _stream_callback) {
        for (auto& pd : dispatches) {
            _stream_callback(this, pd.stream_id, pd.data, pd.fin);
        }
    }
}

// ============================================================================
// Off-thread stream-callback dispatch (see declaration comment in socket.h)
// ============================================================================

void quic::scheduleDispatches(std::shared_ptr<quic> self,
                               std::vector<PendingDispatch> dispatches) {
    if (dispatches.empty() || !self->_stream_callback) return;

    if (!self->_dispatch_promoted.load()) {
        // Fast path: run inline on the calling thread, exactly like a
        // direct call — this is the common case (small/fast callbacks)
        // and must not pay any queuing or thread-hop cost. Timed so a
        // callback that turns out to be slow promotes this connection to
        // the async path for its *next* dispatch — this one has already
        // paid the cost of blocking inline, nothing to be done for it
        // retroactively.
        auto t0 = std::chrono::steady_clock::now();
        for (auto& pd : dispatches) {
            self->_stream_callback(self.get(), pd.stream_id, pd.data, pd.fin);
        }
        if (std::chrono::steady_clock::now() - t0 > kSlowDispatchThreshold) {
            self->_dispatch_promoted.store(true);
        }
        return;
    }

    bool need_to_start;
    {
        std::lock_guard<std::mutex> lk(self->_dispatch_queue_mutex);
        self->_dispatch_queue.push_back(std::move(dispatches));
        // exchange(true) returns the previous value: if it was already
        // true, a runner is already draining _dispatch_queue and will pick
        // up this batch itself — starting a second one would let two
        // threads race on the same connection's callbacks.
        need_to_start = !self->_dispatch_running.exchange(true);
    }
    if (need_to_start) {
        quicDispatchPool().submit([self]() {
            self->runDispatchQueue(self);
        });
    }
}

void quic::runDispatchQueue(std::shared_ptr<quic> self) {
    for (;;) {
        std::vector<PendingDispatch> batch;
        {
            std::lock_guard<std::mutex> lk(_dispatch_queue_mutex);
            if (_dispatch_queue.empty()) {
                _dispatch_running.store(false);
                return;
            }
            batch = std::move(_dispatch_queue.front());
            _dispatch_queue.pop_front();
        }
        for (auto& pd : batch) {
            _stream_callback(this, pd.stream_id, pd.data, pd.fin);
        }
    }
    // `self` (kept alive across the whole loop, including while the queue
    // was briefly empty between batches being enqueued and us checking)
    // goes out of scope here, dropping this function's reference.
}

// ============================================================================
// Retry Packet (RFC 9000 §17.2.5 / §8.1.2) — server side
// ============================================================================

void quic::sendRetryPacket(const std::vector<uint8_t>& client_dcid,
                            const std::vector<uint8_t>& client_scid,
                            sockaddr_storage peer_addr, socklen_t peer_len) {
    std::vector<uint8_t> token = generateToken(true, client_dcid, peer_addr, peer_len);
    if (token.empty()) return; // unsupported peer address family

    // Fresh CID for the client to use as DCID on its retried Initial —
    // stateless: we don't need to remember this anywhere (see _retry_scid's
    // comment in socket.h for how the server recovers it later, from the
    // retried Initial's own DCID field).
    std::vector<uint8_t> retry_scid;
    generateConnectionId(retry_scid, 8);

    std::vector<uint8_t> packet;
    packet.push_back(0xF0); // long header, fixed bit, type=Retry(0b11), unused nibble=0
    packet.push_back(static_cast<uint8_t>(_version >> 24));
    packet.push_back(static_cast<uint8_t>(_version >> 16));
    packet.push_back(static_cast<uint8_t>(_version >> 8));
    packet.push_back(static_cast<uint8_t>(_version));
    packet.push_back(static_cast<uint8_t>(client_scid.size())); // DCID = client's SCID
    packet.insert(packet.end(), client_scid.begin(), client_scid.end());
    packet.push_back(static_cast<uint8_t>(retry_scid.size()));  // SCID = our fresh CID
    packet.insert(packet.end(), retry_scid.begin(), retry_scid.end());
    packet.insert(packet.end(), token.begin(), token.end());    // Retry Token

    auto tag = computeRetryIntegrityTag(client_dcid, packet.data(), packet.size());
    packet.insert(packet.end(), tag.begin(), tag.end());

    ssize_t sent = sendBatch({{packet.data(), packet.size()}}, &peer_addr, peer_len);
    QUIC_DBG("sendRetryPacket: client_dcid_len=%zu retry_scid_len=%zu packet_len=%zu sent=%zd errno=%d",
             client_dcid.size(), retry_scid.size(), packet.size(), sent, sent < 0 ? errno : 0);
}

void quic::sendVersionNegotiation(const std::vector<uint8_t>& client_dcid,
                                   const std::vector<uint8_t>& client_scid,
                                   sockaddr_storage peer_addr, socklen_t peer_len) {
    std::vector<uint8_t> packet;

    // First byte: Header Form bit (long header) MUST be 1; the remaining 7
    // bits are unused and MAY be set to any value (RFC 9000 §17.2.1) — using
    // random bits here so clients can't assume a fixed pattern.
    uint8_t first_byte;
    fillRandomBytes(&first_byte, 1);
    packet.push_back(first_byte | 0x80);

    // Version = 0x00000000 is what marks this as a Version Negotiation
    // packet rather than a regular long-header one.
    packet.push_back(0);
    packet.push_back(0);
    packet.push_back(0);
    packet.push_back(0);

    // DCID/SCID are the triggering packet's SCID/DCID, swapped (RFC 9000
    // §17.2.1) — same convention sendRetryPacket uses above.
    packet.push_back(static_cast<uint8_t>(client_scid.size()));
    packet.insert(packet.end(), client_scid.begin(), client_scid.end());
    packet.push_back(static_cast<uint8_t>(client_dcid.size()));
    packet.insert(packet.end(), client_dcid.begin(), client_dcid.end());

    // Supported Version list, plus a reserved/greased version (RFC 9000
    // §6.3 SHOULD) so clients exercise their unknown-version handling.
    auto pushVersion = [&packet](uint32_t v) {
        packet.push_back(static_cast<uint8_t>(v >> 24));
        packet.push_back(static_cast<uint8_t>(v >> 16));
        packet.push_back(static_cast<uint8_t>(v >> 8));
        packet.push_back(static_cast<uint8_t>(v));
    };
    pushVersion(QUIC_VERSION_1);
    pushVersion(QUIC_VERSION_2);
    pushVersion(0x1a2a3a4a); // reserved version pattern, RFC 9000 §15.3

    ssize_t sent = sendBatch({{packet.data(), packet.size()}}, &peer_addr, peer_len);
    QUIC_DBG("sendVersionNegotiation: client_dcid_len=%zu client_scid_len=%zu packet_len=%zu sent=%zd errno=%d",
             client_dcid.size(), client_scid.size(), packet.size(), sent, sent < 0 ? errno : 0);
}

// ============================================================================
// Socket Interface: accept (Server-side connection acceptance)
// ============================================================================

void quic::accept(std::unique_ptr<socket>& csock, bool nonblock) {
    QUIC_DBG("accept: ENTER _Socket=%d", _Socket);

    // Purge stale (Closed) connections from the registry. Extract the
    // shared_ptr(s) out of the map under _registry_mutex, but don't let
    // them actually be destroyed until after the lock is released:
    // to_destroy is declared before reg_lock, so — per C++'s reverse
    // destruction order — reg_lock (and the lock it holds) is released
    // before to_destroy's shared_ptrs drop and potentially run ~quic().
    // Doing this the other way around (destroying while still holding
    // _registry_mutex) would be a nested lock of a non-recursive mutex
    // the same thread already holds if ~quic() ever needed the lock too
    // (it deliberately doesn't — see its comment).
    {
        auto purge_now = std::chrono::steady_clock::now();
        if (purge_now - _last_registry_purge >= _registry_purge_interval) {
            _last_registry_purge = purge_now;
            std::vector<std::shared_ptr<quic>> to_destroy;
            std::unique_lock<netplus::WriterPreferringSharedMutex> reg_lock(_registry_mutex);
            for (auto it = _child_connections.begin(); it != _child_connections.end(); ) {
                if (it->second && it->second->_conn_state.load() == ConnectionState::Closed) {
                    to_destroy.push_back(std::move(it->second));
                    it = _child_connections.erase(it);
                } else {
                    ++it;
                }
            }
        }
    }

    // QUIC accept is different from TCP - we receive Initial packets on the
    // listening socket and create new quic instances for each connection.

    // Check if socket is valid
    if (_Socket < 0) {
        csock = nullptr;
        return;
    }

    // Drain up to 512 datagrams per call.  On high-throughput links (esp.
    // localhost) the client may burst many QUIC packets between epoll_wait
    // cycles.  If we only read one datagram per cycle the kernel UDP
    // receive buffer overflows, the client detects packet loss and its
    // congestion window collapses.  Draining here keeps up with the
    // sender.  For packets belonging to *existing* connections we process
    // and ACK immediately, then loop back for the next datagram.  A new
    // Initial packet (new connection) exits the loop so the caller can
    // register it.
    //
    // Note on concurrency: today the QUIC listener fd is still
    // EPOLLONESHOT'd (EV_ONESHOT'd on kqueue) — see epoll.cpp/kqueue.cpp's
    // listener registration — so in practice only one thread runs this
    // drain loop for a given listener at a time; an EPOLLEXCLUSIVE/no-
    // ONESHOT variant was tried and reverted after a measured 10x
    // regression (see epoll.cpp). The loop is nonetheless written to be
    // safe if that ever changes (e.g. a future per-thread listener socket
    // via SO_REUSEPORT sharding): recvBatchAddr()/recvmmsg on the same fd
    // from separate threads is safe — the kernel hands out disjoint
    // datagrams, never duplicates — and every connection this function
    // touches is reached through _registry_mutex (briefly, for
    // lookup/insert/erase only) plus that connection's own quic_mtx() (for
    // everything else), so different connections could be processed by
    // different threads without contention.
    static constexpr int MAX_DRAIN = 512;

    // Batch-receive datagrams via udp::recvBatchAddr (recvmmsg on Linux).
    // This dramatically reduces syscall overhead on the server hot path.
    int total_processed = 0;
    std::vector<std::vector<uint8_t>> datagrams;
    std::vector<sockaddr_storage> addrs;

    // Packets for existing connections, grouped by connection, deferred
    // until the end of each recvBatchAddr cycle so a burst of several
    // short-header packets for the same connection is decrypted and
    // processed as one batch (see processApplicationPacketsBatch) instead
    // of one ACK-flush-dispatch round trip per packet. Datagram storage
    // (`datagrams`) stays valid until the next recvBatchAddr call, so this
    // must be flushed before that call and before any early return. Keyed
    // by shared_ptr rather than a raw quic*: this pins each connection
    // alive for the whole batch even if another thread concurrently
    // purges its registry entries out from under it.
    std::map<std::shared_ptr<quic>, std::vector<std::pair<const uint8_t*, size_t>>> pending_app_pkts;
    auto flush_pending_app = [&]() {
        for (auto& [existing, pkts] : pending_app_pkts) {
            // try_lock, not block: this runs on the shared accept/drain
            // loop (see the concurrency note above — normally one thread,
            // but never more than one at a time for a given listener), so
            // blocking here to wait out a connection that's busy elsewhere
            // (e.g. mid-transfer) stalls accepting brand-new connections
            // and processing every OTHER existing connection's traffic
            // too, not just this one. QUIC's own loss detection retransmits
            // whatever we skip, so dropping it now and picking it up on a
            // later drain cycle is safe and far cheaper than blocking.
            std::unique_lock<std::recursive_mutex> child_lock(existing->quic_mtx(), std::try_to_lock);
            if (!child_lock.owns_lock()) continue;
            existing->processApplicationPacketsBatch(pkts);

            if (existing->_handshake_complete &&
                existing->_conn_state.load() == ConnectionState::Connected) {
                for (auto& [sid, limit] : existing->_pending_max_stream_data) {
                    existing->sendMaxStreamData(sid, limit);
                }
                existing->_pending_max_stream_data.clear();
                if (existing->_pending_max_data) {
                    existing->sendMaxData(existing->_pending_max_data_value);
                    existing->_pending_max_data = false;
                }
                existing->pmtudTick();
                // F20: sendMaxStreamData/sendMaxData above only queued their
                // packets (batch=true) — flush now, before this block's own
                // scope ends, so they never sit unsent.
                existing->flushBatch();
            }

            if (!existing->_pending_dispatches.empty()) {
                // See the intra-connection dispatch call sites' comment on
                // why capacity is re-reserved right after the move (F49).
                size_t dispatch_batch_hint = existing->_pending_dispatches.size();
                std::vector<PendingDispatch> dispatches = std::move(existing->_pending_dispatches);
                existing->_pending_dispatches.clear();
                existing->_pending_dispatches.reserve(dispatch_batch_hint);
                child_lock.unlock();
                quic::scheduleDispatches(existing, std::move(dispatches));
                child_lock.lock();
            }
        }
        pending_app_pkts.clear();
    };

    while (total_processed < MAX_DRAIN) {

    int batch_to_read = std::min(64, MAX_DRAIN - total_processed);
    size_t got = recvBatchAddr(datagrams, addrs, batch_to_read);
    if (got == 0) {
        csock = nullptr;
        return;
    }

    for (size_t msg_i = 0; msg_i < datagrams.size(); ++msg_i) {
    total_processed++;
    auto& dgram = datagrams[msg_i];
    sockaddr_storage& peer_addr = addrs[msg_i];
    socklen_t peer_len = sizeof(sockaddr_storage);

    if (dgram.size() < 1) continue;

    QUIC_DBG("accept: recv %zu bytes first_byte=0x%02x _Socket=%d",
             dgram.size(), dgram[0], _Socket);

    // Check if this is a long header packet (Initial, Handshake, etc.)
    if ((dgram[0] & 0x80) == 0) {
        // Short header - this is for an existing connection
        // The DCID is right after the first byte (no length prefix for short headers)
        // We assume 8-byte CID (configurable)
        size_t cid_len = 8;  // Default CID length
        if (dgram.size() < 1 + cid_len) {
            // Malformed — skip and try next datagram
            continue;
        }

        // Look up the connection directly from the datagram bytes — no
        // intermediate std::vector allocation just to build a lookup key.
        // shared_lock: many threads can look up (possibly different)
        // connections concurrently here; we only copy out a shared_ptr
        // ("pin" it alive) and release the lock immediately, before
        // touching anything about the connection itself.
        std::shared_ptr<quic> existing;
        {
            std::shared_lock<netplus::WriterPreferringSharedMutex> reg_lock(_registry_mutex);
            auto it = _child_connections.find(cidKey(&dgram[1], cid_len));
            if (it != _child_connections.end()) existing = it->second;
        }

        if (existing) {
            // Skip stale (Closed/Draining) connections. Not erased here —
            // erasing needs the exclusive lock, and the purge step at the
            // top of the next accept() call (this thread's or another's)
            // will do it; routing already treats it as gone either way.
            if (existing->_conn_state.load() != ConnectionState::Closed &&
                existing->_conn_state.load() != ConnectionState::Draining) {
                pending_app_pkts[existing].emplace_back(dgram.data(), dgram.size());
            }
        }

        continue;
    }

    // Parse long header to extract connection IDs
    if (dgram.size() < 7) {
        continue;  // Malformed — skip
    }

    // Extract version
    uint32_t version = (static_cast<uint32_t>(dgram[1]) << 24) |
                       (static_cast<uint32_t>(dgram[2]) << 16) |
                       (static_cast<uint32_t>(dgram[3]) << 8) |
                       static_cast<uint32_t>(dgram[4]);

    QUIC_DBG("accept: long header version=0x%08x", version);

    // Check for version negotiation (version == 0 means VN)
    if (version == 0) {
        QUIC_DBG("accept: skipping VN packet");
        continue;  // Skip VN packets
    }

    // Parse DCID (Destination Connection ID - this is our CID)
    size_t offset = 5;
    if (offset >= dgram.size()) {
        continue;  // Malformed — skip
    }

    uint8_t dcid_len = dgram[offset++];
    if (dcid_len > 20 || offset + dcid_len > dgram.size()) {
        continue;  // Malformed — skip
    }

    std::vector<uint8_t> dcid(dgram.begin() + offset, dgram.begin() + offset + dcid_len);
    offset += dcid_len;


    // Parse SCID (Source Connection ID - client's CID)
    if (offset >= dgram.size()) {
        continue;  // Malformed — skip
    }

    uint8_t scid_len = dgram[offset++];
    if (scid_len > 20 || offset + scid_len > dgram.size()) {
        continue;  // Malformed — skip
    }

    std::vector<uint8_t> scid(dgram.begin() + offset, dgram.begin() + offset + scid_len);
    offset += scid_len;


    // Check if this is for an existing connection (by original DCID)
    // The DCID in subsequent packets should match our local_cid of an existing connection

    std::shared_ptr<quic> existing;
    {
        std::shared_lock<netplus::WriterPreferringSharedMutex> reg_lock(_registry_mutex);
        auto it = _child_connections.find(cidKey(dcid));
        if (it != _child_connections.end()) existing = it->second;
    }

    if (existing) {
        // Check if existing connection is stale (Closed/Draining)
        // AND this is a new Initial packet - client is reconnecting
        uint8_t pkt_type = (dgram[0] & 0x30) >> 4;
        if (pkt_type == 0x00 &&
            (existing->_conn_state.load() == ConnectionState::Closed ||
             existing->_conn_state.load() == ConnectionState::Draining)) {

            // Remove from routing maps. Same extract-then-unlock-then-let-go
            // discipline as the purge step above, and for the same reason.
            {
                std::vector<std::shared_ptr<quic>> to_destroy;
                std::unique_lock<netplus::WriterPreferringSharedMutex> reg_lock2(_registry_mutex);
                auto it1 = _child_connections.find(cidKey(existing->_local_cid));
                if (it1 != _child_connections.end()) {
                    to_destroy.push_back(std::move(it1->second));
                    _child_connections.erase(it1);
                }
                auto it2 = _child_connections.find(cidKey(existing->_original_dcid));
                if (it2 != _child_connections.end()) {
                    to_destroy.push_back(std::move(it2->second));
                    _child_connections.erase(it2);
                }
            }
            existing.reset();

            // Fall through to create a new connection
        } else {
            // Active connection. try_lock, not block — this long-header
            // packet (a mid-handshake retransmission, typically) is handled
            // inline rather than deferred via pending_app_pkts, so blocking
            // here stalls this accept() call at the current datagram,
            // before it can ever reach later ones in the same batch —
            // including a brand-new connection's Initial packet. A busy
            // connection (e.g. mid-transfer elsewhere) must never be able
            // to stall new connections from being accepted at all. QUIC's
            // own loss detection retransmits whatever we skip here.
            std::unique_lock<std::recursive_mutex> child_lock(existing->quic_mtx(), std::try_to_lock);
            if (!child_lock.owns_lock()) continue;
            existing->processIncomingPacket(dgram.data(), dgram.size());

            // Flush child's pending flow control frames
            if (existing->_handshake_complete &&
                existing->_conn_state.load() == ConnectionState::Connected) {
                for (auto& [sid, limit] : existing->_pending_max_stream_data) {
                    existing->sendMaxStreamData(sid, limit);
                }
                existing->_pending_max_stream_data.clear();
                if (existing->_pending_max_data) {
                    existing->sendMaxData(existing->_pending_max_data_value);
                    existing->_pending_max_data = false;
                }
                existing->pmtudTick();
                // F20: flush what sendMaxStreamData/sendMaxData just queued.
                existing->flushBatch();
            }

            // Dispatch stream callbacks off-thread (see scheduleDispatches)
            if (!existing->_pending_dispatches.empty()) {
                // See the intra-connection dispatch call sites' comment on
                // why capacity is re-reserved right after the move (F49).
                size_t dispatch_batch_hint = existing->_pending_dispatches.size();
                std::vector<PendingDispatch> dispatches = std::move(existing->_pending_dispatches);
                existing->_pending_dispatches.clear();
                existing->_pending_dispatches.reserve(dispatch_batch_hint);
                child_lock.unlock();
                quic::scheduleDispatches(existing, std::move(dispatches));
                child_lock.lock();
            }
            continue;
        }
    }

    // Check packet type - must be Initial (0x00) for new connections
    uint8_t packet_type = (dgram[0] & 0x30) >> 4;
    if (packet_type != 0x00) {
        QUIC_DBG("accept: not Initial pkt_type=%u for unknown CID, dropping", packet_type);
        // Not an Initial packet and no existing connection found - skip
        continue;
    }
    QUIC_DBG("accept: NEW Initial from remote, creating child connection");



    // Verify supported version
    if (version != QUIC_VERSION_1 && version != QUIC_VERSION_2) {
        QUIC_DBG("accept: UNSUPPORTED version 0x%08x, sending Version Negotiation", version);
        sendVersionNegotiation(dcid, scid, peer_addr, peer_len);
        continue;
    }
    QUIC_DBG("accept: version OK, dcid_len=%u scid_len=%u pkt_type=%u",
             dcid_len, scid_len, (dgram[0] & 0x30) >> 4);

    // Parse token (for Initial packets)
    if (offset >= dgram.size()) {
        continue;  // Malformed — skip
    }

    size_t tok_bytes;
    uint64_t token_len = decodeVarInt(&dgram[offset], dgram.size() - offset, tok_bytes);
    if (tok_bytes == 0) {
        continue;  // Malformed/truncated — skip
    }
    offset += tok_bytes;

    if (token_len > dgram.size() - offset) {
        continue;  // Malformed — skip
    }

    std::vector<uint8_t> token(dgram.begin() + offset, dgram.begin() + offset + token_len);
    offset += token_len;

    // RFC 9000 §8.1: require address validation before creating any
    // connection state. No token at all, or one that fails validation
    // (tampered/expired/wrong address — treated the same as absent rather
    // than dropped outright, so a legitimate client with a stale token
    // recovers in one more round trip instead of hanging), gets a Retry
    // and nothing else: no child object, nothing to clean up.
    ParsedToken parsed_token;
    if (!token.empty()) {
        parsed_token = validateToken(token, peer_addr, peer_len);
    }
    QUIC_DBG("accept: token.size()=%zu valid=%d is_retry=%d dcid_len=%zu scid_len=%zu",
             token.size(), (int)parsed_token.valid, (int)parsed_token.is_retry,
             dcid.size(), scid.size());
    if (!parsed_token.valid) {
        // A client's first-flight CRYPTO data can span more than one UDP
        // datagram (e.g. a large post-quantum-hybrid ClientHello), all
        // sharing this same token-less DCID with no registry entry yet to
        // recognize them as "already answered". Sending an independent
        // Retry — with its own random SCID/token — per datagram instead of
        // once per attempt has been observed to leave the client unable to
        // complete the handshake even after accepting one of them. Collapse
        // to a single Retry per DCID within kRetryDedupeWindow.
        auto now = std::chrono::steady_clock::now();
        bool already_retried = false;
        {
            std::unique_lock<netplus::WriterPreferringSharedMutex> reg_lock(_registry_mutex);
            for (auto it = _recent_retries.begin(); it != _recent_retries.end(); ) {
                if (now - it->second > kRetryDedupeWindow) it = _recent_retries.erase(it);
                else ++it;
            }
            auto key = cidKey(dcid);
            auto it = _recent_retries.find(key);
            if (it != _recent_retries.end()) {
                already_retried = true;
            } else {
                _recent_retries[key] = now;
            }
        }
        if (already_retried) {
            QUIC_DBG("accept: duplicate token-less Initial for dcid, Retry already sent, dropping");
            continue;
        }

        sendRetryPacket(dcid, scid, peer_addr, peer_len);
        continue;
    }

    // The client's TRUE original DCID: for a Retry-validated connection,
    // extracted from the token (this packet's own `dcid` is the server's
    // Retry SCID instead). NOT used for key derivation (see
    // deriveInitialKeys' call site below) — this is purely so the server
    // can echo it back via the original_destination_connection_id
    // transport parameter, letting the client detect an off-path
    // attacker's forged Retry (RFC 9000 §7.3). For a NEW_TOKEN-validated
    // connection (no Retry occurred), `dcid` already *is* the client's
    // first Initial's DCID.
    const std::vector<uint8_t>& true_original_dcid =
        parsed_token.is_retry ? parsed_token.original_dcid : dcid;

    // Create new QUIC connection socket for this client. Lock its own
    // mutex immediately, before it's configured or registered — nothing
    // else can be contending for this specific (brand new) mutex yet, and
    // holding it across the registry insertion below closes a race where
    // another thread could find this connection via the registry and
    // start touching its state before we've finished initializing it.
    auto child_ptr = std::make_shared<quic>();
    std::unique_lock<std::recursive_mutex> child_lock(child_ptr->quic_mtx());

    // Configure the new connection
    child_ptr->_is_server = true;
    child_ptr->_conn_state.store(ConnectionState::Handshaking);
    child_ptr->_version = version;
    // RFC 9000: Server sends packets TO the client using the client's SCID as DCID
    child_ptr->_remote_cid = scid;           // Client's SCID becomes our DCID for responses
    child_ptr->_original_dcid = true_original_dcid;  // For the anti-spoofing transport parameter only — NOT key derivation
    if (parsed_token.is_retry) {
        // For the retry_source_connection_id transport parameter — see
        // _retry_scid's comment in socket.h for why `dcid` (this packet's
        // own DCID field) is exactly right here.
        child_ptr->_retry_scid = dcid;
    }
    // A child is only ever created after validateToken() already proved
    // address ownership (see the Retry gate above) — RFC 9000 §8.1 lists
    // "the client returns a valid... token" as a validation trigger in its
    // own right, independent of Retry vs NEW_TOKEN.
    child_ptr->_address_validated = true;
    child_ptr->_cert_map = _cert_map;        // Copy certificate configuration
    child_ptr->_parent = this;               // Link to parent for routing
    child_ptr->_stream_callback = _stream_callback;  // Copy stream callback
    child_ptr->_incremental_stream_dispatch = _incremental_stream_dispatch;
    if (_start_dispatch_promoted) child_ptr->_dispatch_promoted.store(true);
    child_ptr->_pmtud_ceiling = _pmtud_ceiling;       // Copy PMTU discovery ceiling

    // Generate our own connection ID for the client to use
    child_ptr->generateConnectionId(child_ptr->_local_cid, 8);

    // Register atomically: if another thread concurrently created and
    // registered a connection for this same original DCID (e.g. two
    // threads each processing their own copy of the same retransmitted
    // Initial packet from concurrent recvmmsg batches), back off and use
    // theirs — child_ptr is discarded harmlessly, since nothing here has
    // done any expensive crypto work or touched the network for it yet.
    std::shared_ptr<quic> winner;
    {
        std::unique_lock<netplus::WriterPreferringSharedMutex> reg_lock(_registry_mutex);
        auto it = _child_connections.find(cidKey(dcid));
        if (it != _child_connections.end()) {
            winner = it->second;
        } else {
            _child_connections[cidKey(dcid)] = child_ptr;
            _child_connections[cidKey(child_ptr->_local_cid)] = child_ptr;
        }
    }

    if (winner) {
        child_lock.unlock();  // release our (losing) child's lock — done with it
        std::unique_lock<std::recursive_mutex> winner_lock(winner->quic_mtx());
        winner->processIncomingPacket(dgram.data(), dgram.size());

        if (winner->_handshake_complete &&
            winner->_conn_state.load() == ConnectionState::Connected) {
            for (auto& [sid, limit] : winner->_pending_max_stream_data) {
                winner->sendMaxStreamData(sid, limit);
            }
            winner->_pending_max_stream_data.clear();
            if (winner->_pending_max_data) {
                winner->sendMaxData(winner->_pending_max_data_value);
                winner->_pending_max_data = false;
            }
            winner->pmtudTick();
            // F20: flush what sendMaxStreamData/sendMaxData just queued.
            winner->flushBatch();
        }

        if (!winner->_pending_dispatches.empty()) {
            std::vector<PendingDispatch> dispatches = std::move(winner->_pending_dispatches);
            winner->_pending_dispatches.clear();
            winner_lock.unlock();
            quic::scheduleDispatches(winner, std::move(dispatches));
            winner_lock.lock();
        }
        continue;
    }

    // We won the race — genuinely a new connection. child_lock (held since
    // construction) covers everything below.

    // Store peer address
    std::memcpy(&child_ptr->_Addr, &peer_addr, peer_len);
    child_ptr->_AddrLen = peer_len;

    if (peer_addr.ss_family == AF_INET) {
        struct sockaddr_in* addr_in = reinterpret_cast<struct sockaddr_in*>(&peer_addr);
        char ip_str[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &addr_in->sin_addr, ip_str, INET_ADDRSTRLEN);
    } else if (peer_addr.ss_family == AF_INET6) {
        struct sockaddr_in6* addr_in6 = reinterpret_cast<struct sockaddr_in6*>(&peer_addr);
        char ip_str[INET6_ADDRSTRLEN];
        inet_ntop(AF_INET6, &addr_in6->sin6_addr, ip_str, INET6_ADDRSTRLEN);
    } else {
    }

    // QUIC: Child connections share the parent's socket for receiving
    // All packets arrive at the parent and are routed via _child_connections
    // The child uses the parent socket with sendto() for sending
    child_ptr->_Socket = _Socket;  // Share parent socket

    // GSO/GRO were already probed on this fd when the listener was set up
    // (probeGSOGRO() in the constructor); child connections share that same
    // fd but are freshly constructed quic objects, so without copying these
    // flags every child would silently fall back to unbatched sendto/recvfrom.
    child_ptr->_gso_enabled      = _gso_enabled;
    child_ptr->_gro_enabled      = _gro_enabled;
    child_ptr->_gso_segment_size = _gso_segment_size;

    // Set non-blocking if requested (parent should already be non-blocking)
    if (nonblock) {
        child_ptr->setNonBlock();
    }

    // Derive Initial keys from THIS packet's own DCID field (RFC 9001
    // §5.2) — confirmed against a real ngtcp2 client: after a Retry, the
    // client re-derives its Initial secret from the *new* DCID (our
    // Retry's SCID), not the connection's original self-chosen one.
    // true_original_dcid (the ODCID recovered from the token) is a
    // different thing entirely: RFC 9000 §7.3 has the client compare it,
    // via the original_destination_connection_id transport parameter,
    // against what it remembers sending, solely to detect an off-path
    // attacker's forged Retry — it plays no part in key derivation.
    child_ptr->deriveInitialKeys(dcid, true);

    // Store the received Initial packet for processing
    child_ptr->_recv_buffer = dgram;

    // Process the Initial packet to start handshake
    child_ptr->processIncomingPacket(dgram.data(), dgram.size());

    // Flush child's pending flow control frames
    if (child_ptr->_handshake_complete &&
        child_ptr->_conn_state.load() == ConnectionState::Connected) {
        for (auto& [sid, limit] : child_ptr->_pending_max_stream_data) {
            child_ptr->sendMaxStreamData(sid, limit);
        }
        child_ptr->_pending_max_stream_data.clear();
        if (child_ptr->_pending_max_data) {
            child_ptr->sendMaxData(child_ptr->_pending_max_data_value);
            child_ptr->_pending_max_data = false;
        }
        child_ptr->pmtudTick();
        // F20: flush what sendMaxStreamData/sendMaxData just queued.
        child_ptr->flushBatch();
    }

    // Dispatch any pending child callbacks off-thread (see scheduleDispatches)
    if (!child_ptr->_pending_dispatches.empty()) {
        std::vector<PendingDispatch> child_dispatches = std::move(child_ptr->_pending_dispatches);
        child_ptr->_pending_dispatches.clear();
        child_lock.unlock();
        quic::scheduleDispatches(child_ptr, std::move(child_dispatches));
        child_lock.lock();
    }

    // Child connection is owned jointly by its two _child_connections
    // registry entries (shared_ptr) — don't also transfer ownership to
    // csock. QUIC connections share the parent socket and are routed via
    // accept().
    csock = nullptr;
    flush_pending_app();  // don't drop already-scanned packets from this cycle
    return;  // New connection created — exit drain loop

    } // end msg_i loop

    flush_pending_app();

    } // end recvmmsg batch loop

    // Reached MAX_DRAIN limit — will be called again by epoll
    csock = nullptr;
}

// ============================================================================
// Packet Processing: Incoming Packets
// ============================================================================

void quic::processIncomingPacket(const uint8_t* data, size_t len) {
    if (len < 1) return;
    QUIC_DBG("processIncomingPacket: len=%zu first_byte=0x%02x", len, data[0]);

    // Anti-amplification accounting (RFC 9000 §8.1) — see sendPacket()'s
    // enforcement and _address_validated's comment in socket.h. Counts the
    // whole received datagram, matching how sendPacket() counts whole
    // sent datagrams; once validated, this becomes a no-op.
    if (!_address_validated) _bytes_received_unvalidated += len;

    // Process coalesced packets by parsing each one's length and processing
    size_t offset = 0;
    while (offset < len) {
        // Check if we can read enough for a complete packet header
        if (offset >= len) break;
        
        // Check header form bit
        if (data[offset] & 0x80) {
            // Long header packet - parse length to find packet boundary
            size_t pkt_offset = offset + 5;  // After first byte + version
            
            if (pkt_offset + 2 > len) {
                // Can't read DCID/SCID lengths, process what we have as single packet
                processLongHeaderPacket(data + offset, len - offset);
                break;
            }
            
            // Parse DCID length and skip it
            uint8_t dcid_len = data[pkt_offset++];
            if (pkt_offset + dcid_len + 1 > len) {
                // Incomplete header
                processLongHeaderPacket(data + offset, len - offset);
                break;
            }
            pkt_offset += dcid_len;
            
            // Parse SCID length and skip it
            uint8_t scid_len = data[pkt_offset++];
            if (pkt_offset + scid_len > len) {
                // Incomplete header
                processLongHeaderPacket(data + offset, len - offset);
                break;
            }
            pkt_offset += scid_len;

            // Retry packets have a completely different tail layout than
            // every other long-header type: a raw Token (no length prefix)
            // followed by a 16-byte Integrity Tag, running to the end of
            // the datagram — no Length field, no packet number, and never
            // coalesced with anything else. Treating the rest of the loop
            // below's Length-field parsing as applicable to a Retry would
            // misinterpret Token/tag bytes as a bogus length, truncating
            // the packet handed to processLongHeaderPacket().
            uint8_t packet_type = (data[offset] & 0x30) >> 4;
            if (packet_type == 0x03) {  // Retry
                processLongHeaderPacket(data + offset, len - offset);
                break;
            }

            // For Initial packets, skip token
            if (packet_type == 0x00) {  // Initial
                size_t tok_bytes;
                uint64_t tok_len = decodeVarInt(&data[pkt_offset], len - pkt_offset, tok_bytes);
                if (tok_bytes == 0) {
                    processLongHeaderPacket(data + offset, len - offset);
                    break;
                }
                pkt_offset += tok_bytes;
                if (tok_len > len - pkt_offset) {
                    // Incomplete packet
                    processLongHeaderPacket(data + offset, len - offset);
                    break;
                }
                pkt_offset += tok_len;
            }

            // Parse Length field
            if (pkt_offset + 1 > len) {
                // Can't read length
                processLongHeaderPacket(data + offset, len - offset);
                break;
            }

            size_t len_bytes;
            uint64_t payload_len = decodeVarInt(&data[pkt_offset], len - pkt_offset, len_bytes);
            if (len_bytes == 0) {
                processLongHeaderPacket(data + offset, len - offset);
                break;
            }
            pkt_offset += len_bytes;
            if (payload_len > len - pkt_offset) {
                // Incomplete packet, process remaining datagram
                processLongHeaderPacket(data + offset, len - offset);
                break;
            }
            
            // pkt_offset is an ABSOLUTE position in `data` (it started as
            // offset + 5), so subtract offset to get this packet's length
            // relative to `data + offset` — a pre-existing bug here
            // (packet_len computed without subtracting offset) meant every
            // packet after the first in a coalesced datagram (e.g. Initial
            // + Handshake coalesced together, a normal handshake pattern)
            // was handed a length that included the FIRST packet's bytes
            // too, reading past the actual packet — and, for datagrams
            // without enough trailing bytes to cover that inflated length,
            // past the buffer entirely.
            size_t packet_len = pkt_offset + payload_len - offset;
            // pkt_offset (absolute) is exactly where the packet-number
            // field starts, i.e. this packet's pn_offset relative to
            // `data + offset` — hand it down so processInitialPacket/
            // processHandshakePacket/unprotectPacket don't each re-walk the
            // DCID/SCID/token/length fields just parsed above (F08).
            size_t pn_offset_hint = pkt_offset - offset;
            processLongHeaderPacket(data + offset, packet_len, pn_offset_hint);
            offset += packet_len;
        } else {
            // Short header packet - rest of datagram is one packet
            processShortHeaderPacket(data + offset, len - offset);
            break;
        }
    }
    
    // Flush pending ACKs for Initial and Handshake encryption levels
    if (_need_initial_ack) {
        _need_initial_ack = false;
        QUIC_DBG("flush: sending Initial ACK for pn=%lu", (unsigned long)_initial_pn_recv);
        std::vector<uint8_t> ack_frame = buildAckFrame(_initial_pn_recv, 0, _initial_pn_recv_ranges);
        std::vector<uint8_t> packet = buildInitialPacket(ack_frame);
        applyHeaderProtection(packet, EncryptionLevel::Initial, _initial_keys.send);
        sendPacket(packet.data(), packet.size());
    }
    if (_need_handshake_ack) {
        _need_handshake_ack = false;
        QUIC_DBG("flush: sending Handshake ACK for pn=%lu (handshake_complete=%d)",
                 (unsigned long)_handshake_pn_recv, (int)_handshake_complete);
        std::vector<uint8_t> ack_frame = buildAckFrame(_handshake_pn_recv, 0, _handshake_pn_recv_ranges);
        std::vector<uint8_t> packet = buildHandshakePacket(ack_frame);
        applyHeaderProtection(packet, EncryptionLevel::Handshake, _hs_keys.send);
        sendPacket(packet.data(), packet.size());
    }
}

void quic::processLongHeaderPacket(const uint8_t* data, size_t len, size_t pn_offset_hint) {
    if (len < 7) return;

    uint8_t packet_type = (data[0] & 0x30) >> 4;
    QUIC_DBG("processLongHeaderPacket: type=%u len=%zu", packet_type, len);


    switch (packet_type) {
        case 0x00:  // Initial
            processInitialPacket(data, len, pn_offset_hint);
            break;
        case 0x01:  // 0-RTT
            // Only ever meaningful server-side, and only once a PSK has
            // actually been accepted for this connection (processClientHello()
            // derives _early_keys.recv there) — anything else (a stray
            // packet, a client somehow receiving one, a connection that
            // never accepted 0-RTT) is silently dropped, same as any other
            // undecryptable packet.
            if (_is_server && _early_keys.recv.aes_gcm) {
                processZeroRTTPacket(data, len, pn_offset_hint);
            }
            break;
        case 0x02:  // Handshake
            processHandshakePacket(data, len, pn_offset_hint);
            break;
        case 0x03:  // Retry
            processRetryPacket(data, len);
            break;
    }
}

void quic::processRetryPacket(const uint8_t* data, size_t len) {
    QUIC_DBG("processRetryPacket: ENTER len=%zu is_server=%d retry_received=%d handshake_complete=%d state=%d",
             len, (int)_is_server, (int)_retry_received, (int)_handshake_complete, (int)_conn_state.load());
    // Only clients act on a Retry (a server never legitimately receives
    // one), and at most once per connection (RFC 9000 §17.2.5.2) — a
    // second Retry is discarded, not reprocessed.
    if (_is_server || _retry_received) return;
    // Only meaningful while still waiting on the very first Initial's
    // response; one arriving after the handshake has moved past that point
    // is stale/spurious.
    if (_handshake_complete || _conn_state.load() != ConnectionState::Handshaking) return;

    if (len < 7) return;
    size_t offset = 5; // after first byte + version
    if (offset >= len) return;
    uint8_t dcid_len = data[offset++];
    if (dcid_len > len - offset) return;
    // This is our own SCID echoed back as the Retry's DCID (RFC 9000
    // §17.2.5.1) — confirm it's actually addressed to us before trusting
    // anything else in the packet.
    if (dcid_len != _local_cid.size() ||
        !std::equal(_local_cid.begin(), _local_cid.end(), data + offset)) {
        return;
    }
    offset += dcid_len;
    if (offset >= len) return;
    uint8_t scid_len = data[offset++];
    if (scid_len > len - offset) return;
    std::vector<uint8_t> scid(data + offset, data + offset + scid_len);
    offset += scid_len;

    // Whatever remains, minus the trailing 16-byte Integrity Tag, is the
    // Retry Token — Retry has no length prefix on it, unlike Initial.
    if (len < offset + 16) return; // not even room for the tag
    size_t token_len = len - offset - 16;
    std::vector<uint8_t> token(data + offset, data + offset + token_len);

    // Verify the Integrity Tag before trusting anything in this packet.
    auto expected_tag = computeRetryIntegrityTag(_original_dcid, data, len - 16);
    bool tag_ok = std::equal(expected_tag.begin(), expected_tag.end(), data + len - 16);
    QUIC_DBG("processRetryPacket: scid_len=%zu token_len=%zu tag_ok=%d", scid.size(), token_len, (int)tag_ok);
    if (!tag_ok) {
        return; // drop silently — off-path noise or corruption, not fatal
    }

    _retry_received = true;
    _pending_token = token;
    _retry_scid = scid; // for the Phase 3 transport-parameter cross-check
    _remote_cid = scid; // new DCID for the resent Initial

    // Re-derive Initial keys from the new DCID (RFC 9001 §5.2 keys them off
    // whatever DCID is currently in use, not the connection's original
    // self-chosen one — confirmed against a real server: reusing the
    // pre-Retry keys here fails the resent Initial's AEAD tag every time).
    // _original_dcid stays as the pre-Retry value: RFC 9000 §7.3 still
    // needs it later, to detect a forged Retry via the
    // original_destination_connection_id transport parameter.
    deriveInitialKeys(_remote_cid, false);
    std::vector<uint8_t> packet = buildInitialPacket(_initial_crypto_frame);
    applyHeaderProtection(packet, EncryptionLevel::Initial, _initial_keys.send);
    buffer send_buf(reinterpret_cast<const char*>(packet.data()), packet.size());
    udp::sendData(send_buf, 0);

    // This server requires a Retry round trip (accept()'s always-Retry
    // policy for a token-less first Initial), which just changed our DCID —
    // any 0-RTT packets already sent under the old DCID are now
    // unreachable and would otherwise just sit in _sent_packets waiting on
    // their PTO timer, which retransmits only the single oldest unacked
    // packet at a time and backs off exponentially on each subsequent
    // firing (checkLossAndRetransmit()) — recovering a handful of early
    // packets one at a time that way could take multiple seconds. Resend
    // them all immediately instead, right alongside the retried Initial:
    // this is what actually makes offering 0-RTT worthwhile against a
    // server whose default policy always Retries an unrecognized client.
    if (!_sent_packets.empty()) {
        std::vector<SentPacket> early_resend;
        for (auto it = _sent_packets.begin(); it != _sent_packets.end(); ) {
            if (it->second.sent_as_early_data) {
                early_resend.push_back(std::move(it->second));
                it = _sent_packets.erase(it);
            } else {
                ++it;
            }
        }
        for (auto& sp : early_resend) {
            uint8_t frame_type = 0x08 | 0x02; // STREAM, LEN bit
            if (sp.stream_offset > 0) frame_type |= 0x04; // OFF bit
            if (sp.fin) frame_type |= 0x01;
            std::vector<uint8_t> frame;
            frame.reserve(1 + 8 + 8 + 8 + sp.data_length);
            frame.push_back(frame_type);
            uint8_t vb[8]; size_t vl;
            vl = encodeVarInt(sp.stream_id, vb); frame.insert(frame.end(), vb, vb + vl);
            if (sp.stream_offset > 0) {
                vl = encodeVarInt(sp.stream_offset, vb); frame.insert(frame.end(), vb, vb + vl);
            }
            vl = encodeVarInt(sp.data_length, vb); frame.insert(frame.end(), vb, vb + vl);
            frame.insert(frame.end(), sp.stream_data.begin(), sp.stream_data.end());

            uint64_t new_pn = _app_pn_send;
            std::vector<uint8_t> early_packet = buildZeroRTTPacket(frame);
            applyHeaderProtection(early_packet, EncryptionLevel::EarlyData, _early_keys.send);
            sendPacket(early_packet.data(), early_packet.size());
            recordSentPacket(new_pn, sp.stream_id, sp.stream_offset, sp.stream_data.data(),
                              sp.stream_data.size(), sp.fin, early_packet.size(),
                              /*sent_as_early_data=*/true);
        }
    }
}

void quic::processShortHeaderPacket(const uint8_t* data, size_t len) {
    // Short header packets are 1-RTT (application data)
    QUIC_DBG("processShortHeaderPacket: len=%zu", len);
    processApplicationPacket(data, len);
}

void quic::processInitialPacket(const uint8_t* data, size_t len, size_t pn_offset_hint) {
    if (len < 20) return;
    QUIC_DBG("processInitialPacket: len=%zu", len);

    // packet_len equals `len` whenever the caller already sliced `data` to
    // exactly one packet (true whenever it also supplied a valid hint —
    // see processIncomingPacket) — only the fallback path below needs to
    // (re)derive it from a `len` that may still cover trailing coalesced
    // bytes.
    size_t packet_len = len;

    if (pn_offset_hint == SIZE_MAX || pn_offset_hint > len) {
        // Fallback: caller couldn't determine the packet-number offset
        // itself (e.g. a truncated/malformed coalesced-packet edge case in
        // processIncomingPacket) — parse the header ourselves, identical to
        // the original unconditional logic.
        size_t offset = 5;  // After first byte + version
        if (offset >= len) return;
        uint8_t dcid_len = data[offset++];
        if (dcid_len > len - offset) return;
        offset += dcid_len;
        if (offset >= len) return;
        uint8_t scid_len = data[offset++];
        if (scid_len > len - offset) return;
        offset += scid_len;

        // Token length (varint)
        size_t tok_bytes;
        uint64_t tok_len = decodeVarInt(&data[offset], len - offset, tok_bytes);
        if (tok_bytes == 0) return;
        offset += tok_bytes;
        if (tok_len > len - offset) return;
        offset += tok_len;

        // Payload length (varint) - this includes PN + encrypted payload + tag
        if (offset >= len) return;
        size_t len_bytes;
        uint64_t payload_len = decodeVarInt(&data[offset], len - offset, len_bytes);
        if (len_bytes == 0) return;
        offset += len_bytes;
        if (payload_len > len - offset) return;

        // Total packet length = header length (offset) + payload length
        packet_len = offset + payload_len;
    }

    // Decrypt and process Initial packet
    std::vector<uint8_t>& header = _recv_header_scratch;
    std::vector<uint8_t>& payload = _recv_payload_scratch;
    uint64_t pn;

    // Initial packets always use AES-128 - use actual packet_len, not full datagram len
    if (!unprotectPacket(data, packet_len, header, payload, pn, EncryptionLevel::Initial, _initial_keys.recv, pn_offset_hint)) {
        QUIC_DBG("processInitialPacket: decrypt FAILED");
        return;
    }
    QUIC_DBG("processInitialPacket: decrypt OK pn=%lu payload=%zu", (unsigned long)pn, payload.size());
    
    // Update largest received packet number
    if (pn > _initial_pn_recv) {
        _initial_pn_recv = pn;
    }
    
    // Track received PN in ranges for proper ACK encoding
    trackRecvPN(pn, _initial_pn_recv_ranges);
    
    // Set encryption level for frame processing
    _current_enc_level = EncryptionLevel::Initial;
    
    // Process frames in payload. Only request an ACK if at least one
    // ack-eliciting frame was present (RFC 9000 §13.2.1) — otherwise an
    // ACK-only packet from the peer would trigger an ACK back, and so on
    // forever.
    size_t frame_offset = 0;
    while (frame_offset < payload.size()) {
        bool ack_eliciting = false;
        processFrame(payload.data(), payload.size(), frame_offset, ack_eliciting);
        if (ack_eliciting) _need_initial_ack = true;
    }
}

void quic::processHandshakePacket(const uint8_t* data, size_t len, size_t pn_offset_hint) {
    QUIC_DBG("processHandshakePacket: len=%zu", len);

    if (len < 20) return;

    // See processInitialPacket()'s identical comment on packet_len/hint.
    size_t packet_len = len;

    if (pn_offset_hint == SIZE_MAX || pn_offset_hint > len) {
        size_t offset = 5;  // After first byte + version
        if (offset >= len) return;
        uint8_t dcid_len = data[offset++];
        if (dcid_len > len - offset) return;
        offset += dcid_len;
        if (offset >= len) return;
        uint8_t scid_len = data[offset++];
        if (scid_len > len - offset) return;
        offset += scid_len;

        // Length field
        if (offset >= len) return;
        size_t len_bytes;
        uint64_t payload_len = decodeVarInt(&data[offset], len - offset, len_bytes);
        if (len_bytes == 0) return;
        offset += len_bytes;
        if (payload_len > len - offset) return;

        // Total packet length = header length (offset) + payload length
        packet_len = offset + payload_len;
    }

    // Decrypt and process Handshake packet
    std::vector<uint8_t>& header = _recv_header_scratch;
    std::vector<uint8_t>& payload = _recv_payload_scratch;
    uint64_t pn;

    // Handshake packets use the negotiated cipher - use actual packet_len
    if (!unprotectPacket(data, packet_len, header, payload, pn, EncryptionLevel::Handshake, _hs_keys.recv, pn_offset_hint)) {
        QUIC_DBG("processHandshakePacket: decrypt FAILED");
        return;
    }
    QUIC_DBG("processHandshakePacket: decrypt OK pn=%lu payload=%zu", (unsigned long)pn, payload.size());

    // RFC 9000 §8.1: successfully processing a Handshake-space packet is
    // itself a validation trigger, independent of Retry/NEW_TOKEN — only
    // the genuine peer could have derived Handshake keys to produce this.
    _address_validated = true;


    if (pn > _handshake_pn_recv) {
        _handshake_pn_recv = pn;
    }
    
    // Track received PN in ranges for proper ACK encoding
    trackRecvPN(pn, _handshake_pn_recv_ranges);
    
    // Set encryption level for frame processing
    _current_enc_level = EncryptionLevel::Handshake;
    
    // Only request an ACK if at least one ack-eliciting frame was present
    // (RFC 9000 §13.2.1) — see processInitialPacket()'s identical comment.
    size_t hs_offset = 0;
    while (hs_offset < payload.size()) {
        bool ack_eliciting = false;
        processFrame(payload.data(), payload.size(), hs_offset, ack_eliciting);
        if (ack_eliciting) _need_handshake_ack = true;
    }
}

// Server-only: decrypt and process an incoming 0-RTT (early data) packet.
// Same long-header layout as Handshake (no token field) but protected under
// _early_keys.recv, and — critically — sharing the Application
// packet-number space/ACK path with 1-RTT (RFC 9000 §12.3), not a space of
// its own: by the time a 0-RTT packet coalesced after the client's first
// Initial reaches this function, _app_keys.send is already derived (see
// processClientHello() -> sendServerHello() -> sendServerHandshakeFlight()),
// so any resulting ACK just goes out immediately over 1-RTT via the
// existing sendAppAck().
void quic::processZeroRTTPacket(const uint8_t* data, size_t len, size_t pn_offset_hint) {
    QUIC_DBG("processZeroRTTPacket: len=%zu", len);

    if (len < 20) return;

    // See processHandshakePacket()'s identical comment on packet_len/hint.
    size_t packet_len = len;

    if (pn_offset_hint == SIZE_MAX || pn_offset_hint > len) {
        size_t offset = 5;  // After first byte + version
        if (offset >= len) return;
        uint8_t dcid_len = data[offset++];
        if (dcid_len > len - offset) return;
        offset += dcid_len;
        if (offset >= len) return;
        uint8_t scid_len = data[offset++];
        if (scid_len > len - offset) return;
        offset += scid_len;

        // Length field (0-RTT has no token field, same as Handshake)
        if (offset >= len) return;
        size_t len_bytes;
        uint64_t payload_len = decodeVarInt(&data[offset], len - offset, len_bytes);
        if (len_bytes == 0) return;
        offset += len_bytes;
        if (payload_len > len - offset) return;

        packet_len = offset + payload_len;
    }

    std::vector<uint8_t>& header = _recv_header_scratch;
    std::vector<uint8_t>& payload = _recv_payload_scratch;
    uint64_t pn;

    if (!unprotectPacket(data, packet_len, header, payload, pn, EncryptionLevel::EarlyData, _early_keys.recv, pn_offset_hint)) {
        QUIC_DBG("processZeroRTTPacket: decrypt FAILED");
        return;
    }
    QUIC_DBG("processZeroRTTPacket: decrypt OK pn=%lu payload=%zu", (unsigned long)pn, payload.size());

    // Shared Application packet-number space (RFC 9000 §12.3) — not a
    // space of its own.
    if (pn > _app_pn_recv) {
        _app_pn_recv = pn;
    }
    trackRecvPN(pn, _app_pn_recv_ranges);

    _current_enc_level = EncryptionLevel::EarlyData;

    // Same deferred-ACK handling as processApplicationPacket() — see its
    // identical comment on why this is conditioned on ack_eliciting.
    size_t frame_offset = 0;
    while (frame_offset < payload.size()) {
        bool ack_eliciting = false;
        processFrame(payload.data(), payload.size(), frame_offset, ack_eliciting);
        if (ack_eliciting) {
            if (!_app_ack_pending) _app_ack_elicit_since = std::chrono::steady_clock::now();
            _app_ack_pending = true;
        }
    }

    if (_app_ack_pending) {
        sendAppAck();
        _app_ack_pending = false;
    }
}

void quic::processApplicationPacket(const uint8_t* data, size_t len) {
    // Decrypt and process 1-RTT packet
    QUIC_DBG("processApplicationPacket: len=%zu handshake_complete=%d", len, (int)_handshake_complete);
    std::vector<uint8_t>& header = _recv_header_scratch;
    std::vector<uint8_t>& payload = _recv_payload_scratch;
    uint64_t pn;
    
    // Set encryption level for frame processing
    _current_enc_level = EncryptionLevel::Application;

    // Application packets use the negotiated cipher
    if (!unprotectPacket(data, len, header, payload, pn, EncryptionLevel::Application, _app_keys.recv)) {
        QUIC_DBG("processApplicationPacket: decrypt FAILED");
        return;
    }
    QUIC_DBG("processApplicationPacket: decrypt OK pn=%lu payload=%zu", (unsigned long)pn, payload.size());
    
    
    if (pn > _app_pn_recv) {
        _app_pn_recv = pn;
    }

    // Track received packet number in ranges for proper ACK encoding
    trackRecvPN(pn, _app_pn_recv_ranges);

    // Defer ACK — it will be piggybacked on the next outgoing data packet
    // (e.g., echo response). If no data is sent, flush after frame
    // processing. Only set if at least one ack-eliciting frame was present
    // (RFC 9000 §13.2.1) — otherwise an ACK-only packet from the peer would
    // trigger an ACK back, which triggers another, forever.
    size_t offset = 0;
    while (offset < payload.size()) {
        bool ack_eliciting = false;
        processFrame(payload.data(), payload.size(), offset, ack_eliciting);
        if (ack_eliciting) {
            if (!_app_ack_pending) _app_ack_elicit_since = std::chrono::steady_clock::now();
            _app_ack_pending = true;
        }
    }

    // If ACK is still pending (no data was sent in response), send standalone ACK
    if (_app_ack_pending) {
        sendAppAck();
        _app_ack_pending = false;
    }
}

// ============================================================================
// Socket Interface: close (Graceful connection termination)
// ============================================================================

void quic::close() {
    // Each connection (including children) locks its own quic_mtx() — see
    // _quic_mutex's comment in socket.h for why it no longer delegates to
    // the parent.
    std::lock_guard<std::recursive_mutex> lock(quic_mtx());
    
    // Don't close if already closed
    if (_conn_state.load() == ConnectionState::Closed) {
        return;
    }
    
    // If connection is established, send CONNECTION_CLOSE frame
    if (_conn_state.load() == ConnectionState::Connected || 
        _conn_state.load() == ConnectionState::Handshaking) {
        
        // Build CONNECTION_CLOSE frame (error code 0 = NO_ERROR)
        std::vector<uint8_t> close_frame = buildConnectionCloseFrame(0x00, "");
        
        // Send in appropriate packet type based on connection state
        if (_handshake_complete) {
            // Send in 1-RTT packet
            std::vector<uint8_t> packet = buildShortHeaderPacket(close_frame);

            // Apply header protection
            applyHeaderProtection(packet, EncryptionLevel::Application, _app_keys.send);

            // Send the packet
            if (_Socket >= 0) {
                sendPacket(packet.data(), packet.size());
            }
        } else {
            // Send in Initial packet during handshake
            std::vector<uint8_t> packet = buildInitialPacket(close_frame);

            // Apply header protection
            applyHeaderProtection(packet, EncryptionLevel::Initial, _initial_keys.send);
            
            if (_Socket >= 0) {
                sendPacket(packet.data(), packet.size());
            }
        }
        
        // Enter draining state
        _conn_state.store(ConnectionState::Draining);
    }
    
    // Close all streams
    for (auto& [stream_id, stream] : _streams) {
        stream.send_fin = true;
        stream.recv_fin = true;
    }
    _streams.clear();
    
    // Clear send queue
    _send_queue.clear();
    
    // Clear crypto buffers
    _crypto_recv_initial.clear();
    _crypto_recv_handshake.clear();
    _crypto_recv_app.clear();
    _crypto_send_initial.clear();
    _crypto_send_handshake.clear();
    
    // Zero out keys for security and release the cached AES cipher objects
    // (LevelKeys::zero() covers both the key/iv/hp bytes and all 4
    // ciphers per direction — including the header-protection ciphers,
    // which the old per-array memset + 6-cipher-reset block below never
    // released).
    _initial_keys.zero();
    _hs_keys.zero();
    _app_keys.zero();

    // Close underlying UDP socket (only if we own it, not if we're a child)
    // Child connections share the parent's socket and should not close it
    if (_Socket >= 0 && _parent == nullptr) {
#ifdef Windows
        ::closesocket(_Socket);
#else
        ::close(_Socket);
#endif
        _Socket = -1;
    }
    
    _conn_state.store(ConnectionState::Closed);
    _handshake_complete = false;

    // Deliberately does NOT remove this connection from the parent's
    // registry here: doing so could drop the last shared_ptr reference to
    // `this` while still inside this member function (whoever called
    // close() may not itself be holding a reference beyond the registry's
    // own two entries), and the caller may be relying on `this` staying
    // valid until close() returns. accept()'s registry-locked purge step
    // (which runs on every call and checks _conn_state, set above) is the
    // single place connections are removed from the registry — it already
    // extracts the shared_ptr(s) and releases _registry_mutex before
    // letting them go, so destruction never races with (or nests inside)
    // registry mutation. Until that purge runs, routing lookups already
    // skip Closed/Draining connections (see accept()), so leaving the
    // entry in place briefly is harmless.
}

// ============================================================================
// Frame Building: CONNECTION_CLOSE
// ============================================================================

std::vector<uint8_t> quic::buildConnectionCloseFrame(uint64_t error_code, const std::string& reason) {
    std::vector<uint8_t> frame;
    
    // Frame type: CONNECTION_CLOSE (0x1c for QUIC layer, 0x1d for application layer)
    uint8_t type_buf[8];
    size_t type_len = encodeVarInt(static_cast<uint64_t>(FrameType::CONNECTION_CLOSE), type_buf);
    frame.insert(frame.end(), type_buf, type_buf + type_len);
    
    // Error code
    uint8_t err_buf[8];
    size_t err_len = encodeVarInt(error_code, err_buf);
    frame.insert(frame.end(), err_buf, err_buf + err_len);
    
    // Frame type (0 if not applicable)
    uint8_t ft_buf[8];
    size_t ft_len = encodeVarInt(0, ft_buf);
    frame.insert(frame.end(), ft_buf, ft_buf + ft_len);
    
    // Reason phrase length
    uint8_t rlen_buf[8];
    size_t rlen_len = encodeVarInt(reason.size(), rlen_buf);
    frame.insert(frame.end(), rlen_buf, rlen_buf + rlen_len);
    
    // Reason phrase
    if (!reason.empty()) {
        frame.insert(frame.end(), reason.begin(), reason.end());
    }
    
    return frame;
}

// ============================================================================
// Packet Building: Initial Packet
// ============================================================================

std::vector<uint8_t> quic::buildInitialPacket(const std::vector<uint8_t>& payload) {
    std::vector<uint8_t> header;
    
    // First byte: Long header form (1) + Fixed bit (1) + Type (00) + Reserved (00) + PN length (00)
    // 0xC0 = 1100 0000 (long header, fixed bit, Initial type)
    uint8_t first_byte = 0xC0;
    size_t pn_len = 4;  // Use 4-byte packet number for simplicity
    first_byte |= (pn_len - 1);  // PN length encoded as 0-3
    header.push_back(first_byte);
    
    // Version (4 bytes)
    header.push_back(static_cast<uint8_t>(_version >> 24));
    header.push_back(static_cast<uint8_t>(_version >> 16));
    header.push_back(static_cast<uint8_t>(_version >> 8));
    header.push_back(static_cast<uint8_t>(_version));
    
    // DCID length + DCID
    header.push_back(static_cast<uint8_t>(_remote_cid.size()));
    header.insert(header.end(), _remote_cid.begin(), _remote_cid.end());
    
    // SCID length + SCID
    header.push_back(static_cast<uint8_t>(_local_cid.size()));
    header.insert(header.end(), _local_cid.begin(), _local_cid.end());
    
    // Token: empty for a server's own Initial and for a client's very
    // first attempt; non-empty when _pending_token was set via setToken()
    // (a retained NEW_TOKEN from a prior connection) or by
    // processRetryPacket() (echoing what a Retry just gave us).
    uint8_t tok_len_buf[8];
    size_t tok_len_size = encodeVarInt(_pending_token.size(), tok_len_buf);
    header.insert(header.end(), tok_len_buf, tok_len_buf + tok_len_size);
    header.insert(header.end(), _pending_token.begin(), _pending_token.end());
    
    // Calculate total length (PN + payload + AEAD tag)
    size_t total_len = pn_len + payload.size() + 16;  // 16-byte AEAD tag
    
    // Length field (variable-length integer)
    uint8_t len_buf[8];
    size_t len_size = encodeVarInt(total_len, len_buf);
    header.insert(header.end(), len_buf, len_buf + len_size);
    
    // Packet number (4 bytes)
    uint64_t pn = _initial_pn_send++;
    header.push_back(static_cast<uint8_t>(pn >> 24));
    header.push_back(static_cast<uint8_t>(pn >> 16));
    header.push_back(static_cast<uint8_t>(pn >> 8));
    header.push_back(static_cast<uint8_t>(pn));
    
    // Protect packet (encrypt payload with header as AAD)
    // Initial packets always use AES-128 regardless of negotiated cipher
    return protectPacket(header, payload, pn, EncryptionLevel::Initial, _initial_keys.send);
}

// ============================================================================
// Packet Building: Handshake Packet
// ============================================================================

std::vector<uint8_t> quic::buildHandshakePacket(const std::vector<uint8_t>& payload) {
    std::vector<uint8_t> header;
    
    // First byte: Long header + Handshake type (bits 5-4 = 10)
    // Bit 7 = 1 (long form), Bit 6 = 1 (fixed bit), Bits 5-4 = 10 (Handshake)
    // Base byte = 1110 0000 = 0xE0 (before adding packet number length bits)
    uint8_t first_byte = 0xE0;  // 1110 0000 (long header, fixed bit, Handshake type)
    size_t pn_len = 4;
    first_byte |= (pn_len - 1);
    header.push_back(first_byte);
    
    // Version
    header.push_back(static_cast<uint8_t>(_version >> 24));
    header.push_back(static_cast<uint8_t>(_version >> 16));
    header.push_back(static_cast<uint8_t>(_version >> 8));
    header.push_back(static_cast<uint8_t>(_version));
    
    // DCID length + DCID
    header.push_back(static_cast<uint8_t>(_remote_cid.size()));
    header.insert(header.end(), _remote_cid.begin(), _remote_cid.end());
    
    // SCID length + SCID
    header.push_back(static_cast<uint8_t>(_local_cid.size()));
    header.insert(header.end(), _local_cid.begin(), _local_cid.end());
    
    // Length field
    size_t total_len = pn_len + payload.size() + 16;
    uint8_t len_buf[8];
    size_t len_size = encodeVarInt(total_len, len_buf);
    header.insert(header.end(), len_buf, len_buf + len_size);
    
    // Packet number
    uint64_t pn = _handshake_pn_send++;
    header.push_back(static_cast<uint8_t>(pn >> 24));
    header.push_back(static_cast<uint8_t>(pn >> 16));
    header.push_back(static_cast<uint8_t>(pn >> 8));
    header.push_back(static_cast<uint8_t>(pn));
    
    return protectPacket(header, payload, pn, EncryptionLevel::Handshake, _hs_keys.send);
}

// ============================================================================
// Packet Building: 0-RTT (Early Data) Packet — client-only
// ============================================================================

std::vector<uint8_t> quic::buildZeroRTTPacket(const std::vector<uint8_t>& payload) {
    std::vector<uint8_t> header;

    // First byte: Long header + 0-RTT type (bits 5-4 = 01).
    // Bit 7 = 1 (long form), Bit 6 = 1 (fixed bit), Bits 5-4 = 01 (0-RTT).
    // Base byte = 1101 0000 = 0xD0 (before adding packet number length bits).
    uint8_t first_byte = 0xD0;
    size_t pn_len = 4;
    first_byte |= (pn_len - 1);
    header.push_back(first_byte);

    // Version
    header.push_back(static_cast<uint8_t>(_version >> 24));
    header.push_back(static_cast<uint8_t>(_version >> 16));
    header.push_back(static_cast<uint8_t>(_version >> 8));
    header.push_back(static_cast<uint8_t>(_version));

    // DCID length + DCID
    header.push_back(static_cast<uint8_t>(_remote_cid.size()));
    header.insert(header.end(), _remote_cid.begin(), _remote_cid.end());

    // SCID length + SCID
    header.push_back(static_cast<uint8_t>(_local_cid.size()));
    header.insert(header.end(), _local_cid.begin(), _local_cid.end());

    // Length field (0-RTT has no token field, same as Handshake)
    size_t total_len = pn_len + payload.size() + 16;
    uint8_t len_buf[8];
    size_t len_size = encodeVarInt(total_len, len_buf);
    header.insert(header.end(), len_buf, len_buf + len_size);

    // Packet number — shared Application space, NOT a counter of its own
    // (RFC 9000 §12.3: 0-RTT and 1-RTT share the same packet-number space).
    uint64_t pn = _app_pn_send++;
    header.push_back(static_cast<uint8_t>(pn >> 24));
    header.push_back(static_cast<uint8_t>(pn >> 16));
    header.push_back(static_cast<uint8_t>(pn >> 8));
    header.push_back(static_cast<uint8_t>(pn));

    return protectPacket(header, payload, pn, EncryptionLevel::EarlyData, _early_keys.send);
}

// ============================================================================
// Packet Building: Short Header (1-RTT) Packet
// ============================================================================

std::vector<uint8_t> quic::buildShortHeaderPacket(const std::vector<uint8_t>& payload) {
    std::vector<uint8_t> header;
    
    // First byte: Short header form (0) + Fixed bit (1) + Spin bit (0) + Reserved (00) + Key phase (0) + PN length
    uint8_t first_byte = 0x40;  // 0100 0000
    size_t pn_len = 4;
    first_byte |= (pn_len - 1);
    header.push_back(first_byte);
    
    // DCID (no length field in short header)
    header.insert(header.end(), _remote_cid.begin(), _remote_cid.end());
    
    // Packet number
    uint64_t pn = _app_pn_send++;
    header.push_back(static_cast<uint8_t>(pn >> 24));
    header.push_back(static_cast<uint8_t>(pn >> 16));
    header.push_back(static_cast<uint8_t>(pn >> 8));
    header.push_back(static_cast<uint8_t>(pn));
    
    return protectPacket(header, payload, pn, EncryptionLevel::Application, _app_keys.send);
}

// Zero-copy equivalent of buildShortHeaderPacket() + applyHeaderProtection()
// + sendPacket() for control-only frames (ACK, MAX_DATA, MAX_STREAM_DATA,
// DATA_BLOCKED, STREAM_DATA_BLOCKED) — see _ctrl_header_scratch/
// _ctrl_pkt_scratch's declaration in socket.h. Returns sendPacket()'s
// result so callers keep their existing retry-on_failure behavior.
ssize_t quic::sendShortHeaderControlPacket(const std::vector<uint8_t>& frame, bool batch) {
    auto& header = _ctrl_header_scratch;
    header.clear();

    uint8_t first_byte = 0x40;
    size_t pn_len = 4;
    first_byte |= (pn_len - 1);
    header.push_back(first_byte);
    header.insert(header.end(), _remote_cid.begin(), _remote_cid.end());

    uint64_t pn = _app_pn_send++;
    header.push_back(static_cast<uint8_t>(pn >> 24));
    header.push_back(static_cast<uint8_t>(pn >> 16));
    header.push_back(static_cast<uint8_t>(pn >> 8));
    header.push_back(static_cast<uint8_t>(pn));

    protectPacketInto(header, frame, pn, EncryptionLevel::Application, _app_keys.send, _ctrl_pkt_scratch);
    applyHeaderProtection(_ctrl_pkt_scratch, EncryptionLevel::Application, _app_keys.send);
    if (batch) {
        // F20: queue instead of an immediate syscall — caller guarantees a
        // flushBatch() before it returns (see this parameter's comment in
        // socket.h). Report as fully sent: the batch's own partial/failure
        // handling (flushBatch(), quic.cpp) retries on the next flush
        // rather than needing this call's return value.
        batchPacket(_ctrl_pkt_scratch.data(), _ctrl_pkt_scratch.size());
        return static_cast<ssize_t>(_ctrl_pkt_scratch.size());
    }
    return sendPacket(_ctrl_pkt_scratch.data(), _ctrl_pkt_scratch.size());
}

// ============================================================================
// Socket Interface: connect (Client-side connection initiation)
// ============================================================================

void quic::connect(const std::string& addr, int port, bool nonblock) {
    std::lock_guard<std::recursive_mutex> lock(quic_mtx());
    
    // Ensure we're in idle state
    if (_conn_state.load() != ConnectionState::Idle) {
        NetException e;
        e[NetException::Error] << "quic::connect: connection already in progress";
        throw e;
    }
    
    _is_server = false;

    // Delegate address resolution, socket creation and UDP connect to the base class
    udp::connect(addr, port, nonblock);

    // Enlarge the receive/send buffers to match the server ctors' 4MB (see
    // their comment) — without this, the client sits at the OS default
    // (~208KB on Linux) while cwnd's slow start grows unbounded until a
    // burst overflows it, causing genuine kernel-level datagram drops
    // (confirmed via /proc/net/snmp's Udp RcvbufErrors counter: tens of
    // thousands in a single multi-MB transfer) that repeatedly collapse
    // cwnd right back down.
    //
    // This was tried once before and reverted: it reproducibly hung
    // quic_roundtrip_sha256_test. That was never actually caused by the
    // larger buffer itself — it was a pre-existing bug the larger buffer
    // exposed by removing the packet loss that had been accidentally
    // masking it. _app_ack_pending (and its Initial/Handshake-level
    // equivalents) used to be set for every successfully-decrypted packet,
    // regardless of what it contained — violating RFC 9000 §13.2.1 ("a
    // packet containing only ACK frames does not elicit an ACK"). An ACK
    // sent for real data triggered an ACK-of-the-ACK, which triggered
    // another, forever. With the small buffer, that infinite exchange
    // eventually dropped a packet and broke the cycle; with a correctly
    // sized buffer, nothing dropped it, so it livelocked. Now fixed at the
    // source (processFrame()'s ack_eliciting out-param), so the buffer
    // increase is safe to re-apply.
    int rcvbuf = 4 * 1024 * 1024;
    int sndbuf = 4 * 1024 * 1024;
    #ifdef _WIN32
    const char* rcvopt = reinterpret_cast<const char*>(&rcvbuf);
    const char* sndopt = reinterpret_cast<const char*>(&sndbuf);
    #else
    int* rcvopt = &rcvbuf;
    int* sndopt = &sndbuf;
    #endif
    ::setsockopt(_Socket, SOL_SOCKET, SO_RCVBUF, rcvopt, sizeof(rcvbuf));
    ::setsockopt(_Socket, SOL_SOCKET, SO_SNDBUF, sndopt, sizeof(sndbuf));

    probeGSOGRO();

    // Generate random DCID for the server (we don't know their CID yet)
    generateConnectionId(_remote_cid, 8);
    _original_dcid = _remote_cid;  // Pre-Retry value, kept for the anti-spoofing transport-parameter check
    
    // Derive initial keys from the server's (destination) CID
    deriveInitialKeys(_remote_cid, false);
    
    // Generate ECDHE key pair for TLS 1.3 handshake
    generateECDHEKeyPair();
    
    // Build and send ClientHello in Initial packet
    _conn_state.store(ConnectionState::Handshaking);
    
    // Build CRYPTO frame containing ClientHello
    std::vector<uint8_t> client_hello = buildClientHello();
    std::vector<uint8_t> crypto_frame = buildCryptoFrame(client_hello, 0);

    // Pad Initial packet to at least 1200 bytes (RFC 9000 requirement)
    // Add PADDING frames if necessary
    size_t min_payload = 1200 - 100;  // Account for header overhead
    while (crypto_frame.size() < min_payload) {
        crypto_frame.push_back(0x00);  // PADDING frame
    }

    // Retained so processRetryPacket()'s resend (if a Retry arrives) can
    // reuse these exact bytes instead of rebuilding the ClientHello — see
    // _initial_crypto_frame's comment in socket.h.
    _initial_crypto_frame = crypto_frame;

    // Build Initial packet. _pending_token is empty unless the caller
    // supplied one via setToken() before connect() (a token retained from a
    // previous connection's NEW_TOKEN, letting this attempt skip Retry).
    std::vector<uint8_t> packet = buildInitialPacket(crypto_frame);

    // Apply header protection
    applyHeaderProtection(packet, EncryptionLevel::Initial, _initial_keys.send);

    // Send Initial packet
    {
        buffer send_buf(reinterpret_cast<const char*>(packet.data()), packet.size());
        udp::sendData(send_buf, 0);
    }
    
    // If blocking mode, wait for ServerHello and complete handshake
    if (!nonblock) {
        completeHandshake();
    }

    // Switch to non-blocking after handshake: on Windows MSG_DONTWAIT is a
    // no-op (defined as 0), so all recv/send calls must rely on the socket
    // being in non-blocking mode to avoid indefinite stalls.
    setNonBlock();
}

// ============================================================================
// TLS 1.3 Handshake: Generate ECDHE Key Pair
// ============================================================================

void quic::generateECDHEKeyPair() {
    // Generate random private key (32 bytes)
    _ecdhe_private.resize(32);
    fillRandomBytes(_ecdhe_private.data(), 32);
    
    // Compute public key using X25519
    _ecdhe_public.resize(32);
    std::vector<uint8_t> priv_vec(_ecdhe_private.begin(), _ecdhe_private.end());
    scalarmult_curve25519_base(_ecdhe_public, priv_vec);
}

// ============================================================================
// TLS 1.3 Handshake: Build ClientHello
// ============================================================================

std::vector<uint8_t> quic::buildClientHello() {
    // Unwrap a stored session ticket (see processNewSessionTicket()'s
    // comment for the wrapped format: 2-byte cipher_suite + 2-byte psk
    // length + psk + the server's own ticket bytes) to decide whether to
    // offer PSK + early_data on this ClientHello.
    bool offer_early_data = false;
    std::vector<uint8_t> ticket_identity;
    size_t psk_hash_len = 32;
    if (_pending_session_ticket.size() > 4) {
        size_t p = 0;
        uint16_t ticket_cs = (static_cast<uint16_t>(_pending_session_ticket[p]) << 8) | _pending_session_ticket[p + 1];
        p += 2;
        uint16_t psk_len = (static_cast<uint16_t>(_pending_session_ticket[p]) << 8) | _pending_session_ticket[p + 1];
        p += 2;
        if (p + psk_len <= _pending_session_ticket.size()) {
            std::vector<uint8_t> psk(_pending_session_ticket.begin() + p, _pending_session_ticket.begin() + p + psk_len);
            p += psk_len;
            ticket_identity.assign(_pending_session_ticket.begin() + p, _pending_session_ticket.end());
            if (!ticket_identity.empty() && !psk.empty()) {
                _early_data_psk = std::move(psk);
                _early_data_cipher_suite = ticket_cs;
                psk_hash_len = (ticket_cs == 0x1302) ? 48 : 32;
                offer_early_data = true;
            }
        }
    }

    std::vector<uint8_t> hello;

    // Handshake type: ClientHello (0x01)
    hello.push_back(0x01);
    
    // Length placeholder (3 bytes) - will be filled later
    size_t length_pos = hello.size();
    hello.push_back(0x00);
    hello.push_back(0x00);
    hello.push_back(0x00);
    
    // Legacy version: TLS 1.2 (0x0303)
    hello.push_back(0x03);
    hello.push_back(0x03);
    
    // Random (32 bytes)
    _client_random.resize(32);
    fillRandomBytes(_client_random.data(), 32);
    hello.insert(hello.end(), _client_random.begin(), _client_random.end());
    
    // Legacy session ID (empty for QUIC)
    hello.push_back(0x00);
    
    // Cipher suites
    hello.push_back(0x00);
    hello.push_back(0x04);  // 2 cipher suites (4 bytes)
    hello.push_back(0x13);
    hello.push_back(0x01);  // TLS_AES_128_GCM_SHA256
    hello.push_back(0x13);
    hello.push_back(0x02);  // TLS_AES_256_GCM_SHA384
    
    // Legacy compression methods
    hello.push_back(0x01);  // 1 method
    hello.push_back(0x00);  // null compression
    
    // Extensions
    std::vector<uint8_t> extensions;
    
    // Extension: supported_versions (0x002b)
    extensions.push_back(0x00);
    extensions.push_back(0x2b);
    extensions.push_back(0x00);
    extensions.push_back(0x03);  // Length
    extensions.push_back(0x02);  // Versions length
    extensions.push_back(0x03);
    extensions.push_back(0x04);  // TLS 1.3
    
    // Extension: supported_groups (0x000a)
    extensions.push_back(0x00);
    extensions.push_back(0x0a);
    extensions.push_back(0x00);
    extensions.push_back(0x04);  // Length
    extensions.push_back(0x00);
    extensions.push_back(0x02);  // Groups length
    extensions.push_back(0x00);
    extensions.push_back(0x1d);  // x25519
    
    // Extension: signature_algorithms (0x000d)
    extensions.push_back(0x00);
    extensions.push_back(0x0d);
    extensions.push_back(0x00);
    extensions.push_back(0x06);  // Length
    extensions.push_back(0x00);
    extensions.push_back(0x04);  // Algorithms length
    extensions.push_back(0x08);
    extensions.push_back(0x04);  // rsa_pss_rsae_sha256
    extensions.push_back(0x04);
    extensions.push_back(0x03);  // ecdsa_secp256r1_sha256
    
    // Extension: key_share (0x0033)
    extensions.push_back(0x00);
    extensions.push_back(0x33);
    size_t ks_len = 2 + 2 + 2 + 32;  // group + key_len + key
    extensions.push_back(static_cast<uint8_t>(ks_len >> 8));
    extensions.push_back(static_cast<uint8_t>(ks_len));
    extensions.push_back(static_cast<uint8_t>((ks_len - 2) >> 8));
    extensions.push_back(static_cast<uint8_t>(ks_len - 2));
    extensions.push_back(0x00);
    extensions.push_back(0x1d);  // x25519
    extensions.push_back(0x00);
    extensions.push_back(0x20);  // 32 bytes
    extensions.insert(extensions.end(), _ecdhe_public.begin(), _ecdhe_public.end());
    
    // Extension: QUIC transport parameters (0x0039)
    std::vector<uint8_t> transport_params = buildTransportParameters();
    extensions.push_back(0x00);
    extensions.push_back(0x39);
    extensions.push_back(static_cast<uint8_t>(transport_params.size() >> 8));
    extensions.push_back(static_cast<uint8_t>(transport_params.size()));
    extensions.insert(extensions.end(), transport_params.begin(), transport_params.end());

    // Extensions: psk_key_exchange_modes (0x002d) + early_data (0x002a) +
    // pre_shared_key (0x0029) — pre_shared_key MUST be the last extension
    // in the ClientHello (RFC 8446 §4.2.11). The binder is a placeholder
    // here (already its final byte-length, so every length field computed
    // below is already correct) and gets patched with its real value once
    // the whole message is assembled — same reserve-then-patch idiom this
    // function already uses for `length_pos` above.
    size_t psk_binder_len_field_pos_in_extensions = SIZE_MAX;
    size_t binder_value_pos_in_extensions = SIZE_MAX;
    if (offer_early_data) {
        // psk_key_exchange_modes (0x002d): psk_dhe_ke only — QUIC never
        // uses a pure psk_ke mode.
        extensions.push_back(0x00); extensions.push_back(0x2d);
        extensions.push_back(0x00); extensions.push_back(0x02);
        extensions.push_back(0x01); // list length
        extensions.push_back(0x01); // psk_dhe_ke

        // early_data (0x002a): empty — just a request signal.
        extensions.push_back(0x00); extensions.push_back(0x2a);
        extensions.push_back(0x00); extensions.push_back(0x00);

        // pre_shared_key (0x0029)
        extensions.push_back(0x00); extensions.push_back(0x29);
        size_t psk_ext_len_pos = extensions.size();
        extensions.push_back(0x00); extensions.push_back(0x00); // ext length placeholder
        size_t psk_ext_body_start = extensions.size();

        // identities: one PskIdentity { opaque identity<1..2^16-1>; uint32 obfuscated_ticket_age; }
        // obfuscated_ticket_age is sent as 0 — this implementation's server
        // never validates it (validateSessionTicket() checks the ticket's
        // own embedded issued_at/lifetime instead), so there is nothing a
        // real value would gain here.
        size_t identities_len = 2 + ticket_identity.size() + 4;
        extensions.push_back(static_cast<uint8_t>(identities_len >> 8));
        extensions.push_back(static_cast<uint8_t>(identities_len));
        extensions.push_back(static_cast<uint8_t>(ticket_identity.size() >> 8));
        extensions.push_back(static_cast<uint8_t>(ticket_identity.size()));
        extensions.insert(extensions.end(), ticket_identity.begin(), ticket_identity.end());
        extensions.push_back(0x00); extensions.push_back(0x00);
        extensions.push_back(0x00); extensions.push_back(0x00);

        // binders: one PskBinderEntry<32..255>
        psk_binder_len_field_pos_in_extensions = extensions.size();
        size_t binders_len = 1 + psk_hash_len;
        extensions.push_back(static_cast<uint8_t>(binders_len >> 8));
        extensions.push_back(static_cast<uint8_t>(binders_len));
        extensions.push_back(static_cast<uint8_t>(psk_hash_len));
        binder_value_pos_in_extensions = extensions.size();
        extensions.insert(extensions.end(), psk_hash_len, 0x00); // placeholder

        size_t psk_ext_body_len = extensions.size() - psk_ext_body_start;
        extensions[psk_ext_len_pos]     = static_cast<uint8_t>(psk_ext_body_len >> 8);
        extensions[psk_ext_len_pos + 1] = static_cast<uint8_t>(psk_ext_body_len);
    }

    // Extensions length
    hello.push_back(static_cast<uint8_t>(extensions.size() >> 8));
    hello.push_back(static_cast<uint8_t>(extensions.size()));
    hello.insert(hello.end(), extensions.begin(), extensions.end());

    // Fill in length
    size_t msg_len = hello.size() - 4;  // Exclude type and length fields
    hello[length_pos] = static_cast<uint8_t>(msg_len >> 16);
    hello[length_pos + 1] = static_cast<uint8_t>(msg_len >> 8);
    hello[length_pos + 2] = static_cast<uint8_t>(msg_len);

    // Patch in the real PSK binder now that the whole ClientHello (and
    // hence every length field) is final. RFC 8446 §4.2.11.2: the binder
    // is an HMAC over the transcript hash of the ClientHello truncated
    // right before the binders vector's own length field (not the binder
    // bytes' final value, since it doesn't exist yet at that point).
    if (offer_early_data && psk_binder_len_field_pos_in_extensions != SIZE_MAX) {
        size_t ext_start_in_hello = hello.size() - extensions.size();
        size_t truncation_pos_in_hello = ext_start_in_hello + psk_binder_len_field_pos_in_extensions;
        size_t binder_value_pos_in_hello = ext_start_in_hello + binder_value_pos_in_extensions;

        const bool use_sha384 = (psk_hash_len == 48);
        std::vector<uint8_t> truncated(hello.begin(), hello.begin() + truncation_pos_in_hello);
        std::vector<uint8_t> ch_hash = use_sha384 ? sha384_hash(truncated) : sha256_hash(truncated);
        std::vector<uint8_t> empty_hash = use_sha384 ? sha384_hash({}) : sha256_hash({});

        std::vector<uint8_t> early_secret = netplus::tls::hkdf_extract({}, _early_data_psk, _early_data_cipher_suite);
        std::vector<uint8_t> binder_key = netplus::tls::hkdf_expand_label(
            early_secret, "res binder", empty_hash, psk_hash_len, _early_data_cipher_suite);
        std::vector<uint8_t> finished_key = netplus::tls::hkdf_expand_label(
            binder_key, "finished", {}, psk_hash_len, _early_data_cipher_suite);
        std::vector<uint8_t> binder = use_sha384
            ? hmac_sha384(finished_key, ch_hash)
            : hmac_sha256(finished_key, ch_hash);

        std::memcpy(hello.data() + binder_value_pos_in_hello, binder.data(), psk_hash_len);
    }

    // Add to transcript
    _tls_transcript.insert(_tls_transcript.end(), hello.begin(), hello.end());

    // Derive 0-RTT send keys now — _tls_transcript is exactly the
    // ClientHello at this point (RFC 8446: early_traffic =
    // Derive-Secret(early_secret, "c e traffic", ClientHello)).
    if (offer_early_data) {
        _early_data_offered = true;
        deriveEarlyDataKeys(_early_data_psk, /*is_send=*/true);
    }

    return hello;
}

// ============================================================================
// QUIC Transport Parameters
// ============================================================================

std::vector<uint8_t> quic::buildTransportParameters() {
    std::vector<uint8_t> params;
    
    
    // original_destination_connection_id (0x00) - REQUIRED for server
    // This is the DCID from the client's first Initial packet
    if (_is_server && !_original_dcid.empty()) {
        params.push_back(0x00);
        uint8_t odcid_len_buf[8];
        size_t odcid_len_size = encodeVarInt(_original_dcid.size(), odcid_len_buf);
        params.insert(params.end(), odcid_len_buf, odcid_len_buf + odcid_len_size);
        params.insert(params.end(), _original_dcid.begin(), _original_dcid.end());
    }
    
    // initial_source_connection_id (0x0f)
    params.push_back(0x0f);
    uint8_t cid_len_buf[8];
    size_t cid_len_size = encodeVarInt(_local_cid.size(), cid_len_buf);
    params.insert(params.end(), cid_len_buf, cid_len_buf + cid_len_size);
    params.insert(params.end(), _local_cid.begin(), _local_cid.end());

    // retry_source_connection_id (0x10) — RFC 9000 §18.2: MUST be sent
    // whenever this connection was created via a Retry, containing the
    // SCID this server put on that Retry packet (see _retry_scid's
    // comment in socket.h for why it's already sitting there with exactly
    // that value, no extra bookkeeping needed).
    if (_is_server && !_retry_scid.empty()) {
        params.push_back(0x10);
        uint8_t rscid_len_buf[8];
        size_t rscid_len_size = encodeVarInt(_retry_scid.size(), rscid_len_buf);
        params.insert(params.end(), rscid_len_buf, rscid_len_buf + rscid_len_size);
        params.insert(params.end(), _retry_scid.begin(), _retry_scid.end());
    }


    // initial_max_data (0x04)
    params.push_back(0x04);
    uint8_t max_data_buf[8];
    size_t max_data_len = encodeVarInt(_max_data_local, max_data_buf);
    uint8_t max_data_len_buf[8];
    size_t max_data_len_size = encodeVarInt(max_data_len, max_data_len_buf);
    params.insert(params.end(), max_data_len_buf, max_data_len_buf + max_data_len_size);
    params.insert(params.end(), max_data_buf, max_data_buf + max_data_len);
    
    // initial_max_stream_data_bidi_local (0x05)
    params.push_back(0x05);
    uint8_t stream_data_buf[8];
    size_t stream_data_len = encodeVarInt(16777216, stream_data_buf);  // 16MB
    uint8_t stream_data_len_buf[8];
    size_t stream_data_len_size = encodeVarInt(stream_data_len, stream_data_len_buf);
    params.insert(params.end(), stream_data_len_buf, stream_data_len_buf + stream_data_len_size);
    params.insert(params.end(), stream_data_buf, stream_data_buf + stream_data_len);
    
    // initial_max_stream_data_bidi_remote (0x06)
    params.push_back(0x06);
    params.insert(params.end(), stream_data_len_buf, stream_data_len_buf + stream_data_len_size);
    params.insert(params.end(), stream_data_buf, stream_data_buf + stream_data_len);
    
    // initial_max_stream_data_uni (0x07)
    params.push_back(0x07);
    params.insert(params.end(), stream_data_len_buf, stream_data_len_buf + stream_data_len_size);
    params.insert(params.end(), stream_data_buf, stream_data_buf + stream_data_len);
    
    // initial_max_streams_bidi (0x08)
    params.push_back(0x08);
    uint8_t max_streams_buf[8];
    size_t max_streams_len = encodeVarInt(10000000, max_streams_buf);
    uint8_t max_streams_len_buf[8];
    size_t max_streams_len_size = encodeVarInt(max_streams_len, max_streams_len_buf);
    params.insert(params.end(), max_streams_len_buf, max_streams_len_buf + max_streams_len_size);
    params.insert(params.end(), max_streams_buf, max_streams_buf + max_streams_len);
    
    // initial_max_streams_uni (0x09)
    params.push_back(0x09);
    params.insert(params.end(), max_streams_len_buf, max_streams_len_buf + max_streams_len_size);
    params.insert(params.end(), max_streams_buf, max_streams_buf + max_streams_len);
    
    // max_idle_timeout (0x01)
    params.push_back(0x01);
    uint8_t timeout_buf[8];
    size_t timeout_len = encodeVarInt(_max_idle_timeout, timeout_buf);
    uint8_t timeout_len_buf[8];
    size_t timeout_len_size = encodeVarInt(timeout_len, timeout_len_buf);
    params.insert(params.end(), timeout_len_buf, timeout_len_buf + timeout_len_size);
    params.insert(params.end(), timeout_buf, timeout_buf + timeout_len);

    // max_udp_payload_size (0x03) — advertise how large a datagram we're
    // willing to receive. Defaults to 1472 (Ethernet-safe); raised via
    // setPmtuCeiling(), which also lets pmtudTick() search higher.
    params.push_back(0x03);
    uint8_t mups_buf[8];
    size_t mups_len = encodeVarInt(_pmtud_ceiling, mups_buf);
    uint8_t mups_len_buf[8];
    size_t mups_len_size = encodeVarInt(mups_len, mups_len_buf);
    params.insert(params.end(), mups_len_buf, mups_len_buf + mups_len_size);
    params.insert(params.end(), mups_buf, mups_buf + mups_len);

    // ack_delay_exponent (0x0a) — tells the peer how to scale the ACK
    // Delay field in ACK frames *we* send (RFC 9000 §13.2.5, §18.2).
    // Always the RFC default (3); sent explicitly so a decoder relying on
    // this parameter rather than assuming the default stays correct.
    params.push_back(0x0a);
    uint8_t ade_buf[8];
    size_t ade_len = encodeVarInt(_ack_delay_exponent, ade_buf);
    uint8_t ade_len_buf[8];
    size_t ade_len_size = encodeVarInt(ade_len, ade_len_buf);
    params.insert(params.end(), ade_len_buf, ade_len_buf + ade_len_size);
    params.insert(params.end(), ade_buf, ade_buf + ade_len);

    return params;
}

// ============================================================================
// Frame Building: CRYPTO Frame
// ============================================================================

std::vector<uint8_t> quic::buildCryptoFrame(const std::vector<uint8_t>& data, uint64_t offset) {
    std::vector<uint8_t> frame;
    
    // Frame type: CRYPTO (0x06)
    frame.push_back(0x06);
    
    // Offset (variable-length integer)
    uint8_t offset_buf[8];
    size_t offset_len = encodeVarInt(offset, offset_buf);
    frame.insert(frame.end(), offset_buf, offset_buf + offset_len);
    
    // Length (variable-length integer)
    uint8_t len_buf[8];
    size_t len_len = encodeVarInt(data.size(), len_buf);
    frame.insert(frame.end(), len_buf, len_buf + len_len);
    
    // Data
    frame.insert(frame.end(), data.begin(), data.end());
    
    return frame;
}

// ============================================================================
// Blocking Handshake Completion
// ============================================================================

void quic::completeHandshake() {
    // Receive and process packets until handshake is complete or timeout
    const auto start = std::chrono::steady_clock::now();
    const auto timeout = std::chrono::milliseconds(HANDSHAKE_TIMEOUT_MS);
    int recv_attempts = 0;
    int recv_got = 0;

    while (!_handshake_complete) {
        // Bail out if the connection was closed while we were waiting
        if (_conn_state.load() == ConnectionState::Closed ||
            _conn_state.load() == ConnectionState::Draining) {
            NetException e;
            e[NetException::Error] << "quic::completeHandshake: connection closed";
            throw e;
        }

        if (std::chrono::steady_clock::now() - start > timeout) {
            NetException e;
            e[NetException::Error] << "quic::completeHandshake: handshake timed out";
            throw e;
        }

        // Wait for data availability before attempting recv (avoids blocking
        // on Windows where MSG_DONTWAIT is not supported)
        socketwait sw;
        if (!sw.waitRead(*this, 5)) continue; // no data within 5ms, retry

        try {
            buffer recv_buf_wrapper(65535);
            recv_attempts++;
            size_t recv_len = udp::recvData(recv_buf_wrapper, MSG_DONTWAIT);
            if (recv_len > 0) {
                recv_got++;
                const uint8_t* pkt = reinterpret_cast<const uint8_t*>(recv_buf_wrapper.data.buf);
                processIncomingPacket(pkt, recv_len);
            }
        } catch (NetException& e) {
            if (e.getErrorType() != NetException::Note) {
                throw;
            }
        }
    }

    _conn_state.store(ConnectionState::Connected);
}

// ============================================================================
// Socket Interface: recvData (Receive application data)
// ============================================================================

size_t quic::recvData(buffer& data, int flags) {
    std::vector<PendingDispatch> dispatches;
    size_t result = 0;

    {   // Scoped lock — released before invoking stream callbacks
        std::lock_guard<std::recursive_mutex> lock(quic_mtx());

        // Check connection state
        if (_conn_state.load() == ConnectionState::Closed) {
            NetException e;
            e[NetException::Error] << "quic::recvData: connection closed";
            throw e;
        }

        if (_conn_state.load() != ConnectionState::Connected && !_handshake_complete) {
            NetException e;
            e[NetException::Error] << "quic::recvData: connection not established";
            throw e;
        }

        // First, check if we have buffered data from any stream
        uint64_t default_stream = _is_server ? 1 : 0;

        auto check_stream = [&](auto& stream_ref) -> bool {
            if (stream_ref.recv_offset >= stream_ref.recv_contiguous) {
                return false;  // No new contiguous data
            }
            size_t available = static_cast<size_t>(stream_ref.recv_contiguous - stream_ref.recv_offset);
            size_t to_copy = std::min(data.size, available);
            std::memcpy(data.data.buf, stream_ref.recv_buffer.data() + stream_ref.recv_offset, to_copy);
            stream_ref.recv_offset += to_copy;
            data.size = to_copy;
            result = to_copy;
            return true;
        };

        auto it = _streams.find(default_stream);
        if (it != _streams.end() && it->second.recv_offset < it->second.recv_contiguous) {
            check_stream(it->second);
        } else {
            // No buffered data, try to receive from network
            int recv_flags = (flags & MSG_DONTWAIT) ? MSG_DONTWAIT : 0;
            buffer recv_buf_wrapper(65535);
            size_t recv_len;
            try {
                recv_len = udp::recvData(recv_buf_wrapper, recv_flags);
            } catch (NetException&) {
                throw;
            }

            if (recv_len == 0) {
                data.size = 0;
                result = 0;
            } else {
                processIncomingPacket(reinterpret_cast<const uint8_t*>(recv_buf_wrapper.data.buf), recv_len);

                // After processing, check for buffered stream data
                bool found = false;
                it = _streams.find(default_stream);
                if (it != _streams.end() && it->second.recv_offset < it->second.recv_contiguous) {
                    check_stream(it->second);
                    found = true;
                }

                if (!found) {
                    // Check all streams
                    for (auto& [sid, stream] : _streams) {
                        if (stream.recv_offset < stream.recv_contiguous) {
                            check_stream(stream);
                            found = true;
                            break;
                        }
                    }
                }

                if (!found) {
                    data.size = 0;
                    result = 0;
                }
            }
        }

        if (_handshake_complete && _conn_state.load() == ConnectionState::Connected) {
            pmtudTick();
        }

        // Collect pending dispatches before releasing lock (see the other
        // call site's comment on why capacity is re-reserved after the
        // move — F49).
        size_t dispatch_batch_hint = _pending_dispatches.size();
        dispatches = std::move(_pending_dispatches);
        _pending_dispatches.clear();
        _pending_dispatches.reserve(dispatch_batch_hint);

    }   // mutex released

    // Dispatch stream callbacks inline (mutex already released).
    if (!dispatches.empty() && _stream_callback) {
        for (auto& pd : dispatches) {
            _stream_callback(this, pd.stream_id, pd.data, pd.fin);
        }
    }

    return result;
}

// ============================================================================
// Pump Network: read and process QUIC packets without consuming stream data.
// For use by HTTP/3 clients that read specific streams via recvStreamData().
// ============================================================================

void quic::pumpNetwork(int flags) {
    (void)flags;
    std::vector<PendingDispatch> dispatches;

    // Use recvBatch to read multiple datagrams in one syscall
    static constexpr int MAX_PUMP = 64;
    std::vector<std::vector<uint8_t>> datagrams;
    recvBatch(datagrams, MAX_PUMP);

    if (!datagrams.empty()) {
        std::lock_guard<std::recursive_mutex> lock(quic_mtx());

        // Route short-header (1-RTT) datagrams — the entire steady-state
        // traffic once the handshake is done — through the batch path so
        // N packets arriving in this one recvBatch() cycle cost one ACK
        // instead of N (same fix as the server's accept() drain loop).
        // Long-header packets (Initial/Handshake) are rare here (a client
        // only sees them during/around the handshake) and keep using the
        // per-packet path, which also handles coalesced long-header packets.
        std::vector<std::pair<const uint8_t*, size_t>> app_pkts;
        for (auto& dgram : datagrams) {
            if (!dgram.empty() && (dgram[0] & 0x80) == 0) {
                app_pkts.emplace_back(dgram.data(), dgram.size());
            } else {
                processIncomingPacket(dgram.data(), dgram.size());
            }
        }
        if (!app_pkts.empty()) {
            processApplicationPacketsBatch(app_pkts);
        }

        // Flush pending flow control frames so the peer doesn't stall
        if (_handshake_complete && _conn_state.load() == ConnectionState::Connected) {
            for (auto& [sid, limit] : _pending_max_stream_data) {
                sendMaxStreamData(sid, limit);
            }
            _pending_max_stream_data.clear();
            if (_pending_max_data) {
                sendMaxData(_pending_max_data_value);
                _pending_max_data = false;
            }
            // F20: flush what sendMaxStreamData/sendMaxData just queued —
            // still inside this block's lock, before it goes out of scope.
            flushBatch();
        }

        // Collect pending dispatches
        if (!_pending_dispatches.empty()) {
            dispatches.insert(dispatches.end(),
                std::make_move_iterator(_pending_dispatches.begin()),
                std::make_move_iterator(_pending_dispatches.end()));
            _pending_dispatches.clear();
        }
    }

    // Dispatch stream callbacks outside the lock
    if (!dispatches.empty() && _stream_callback) {
        for (auto& pd : dispatches) {
            _stream_callback(this, pd.stream_id, pd.data, pd.fin);
        }
    }

    // Loss detection + PTO probes: retransmit lost/stale packets so the
    // peer can make progress even when the caller is not sending new data.
    // Unlike the checkLossAndRetransmit() calls after processing an ACK
    // frame or a pumpIncoming() batch (which run because something new
    // just happened, so they always run immediately), this call site is
    // purely opportunistic polling — a caller looping on pumpNetwork() can
    // invoke it enormously more often than the RTT/PTO-scale checks inside
    // are useful (measured ~1 call per 3us under load, vs. PTO thresholds
    // of tens of ms at minimum: see the +0.025s term and 0.5s initial _rto
    // above). Throttle only *this* call site so a busy poll loop doesn't
    // pay for a full _sent_packets scan on every iteration; the ACK- and
    // batch-triggered call sites are untouched and still fire promptly.
    {
        std::lock_guard<std::recursive_mutex> lock(quic_mtx());
        auto now = std::chrono::steady_clock::now();
        if (now - _last_pump_loss_scan >= _pump_loss_scan_interval) {
            _last_pump_loss_scan = now;
            checkLossAndRetransmit();
        }
    }

    // Path MTU Discovery: drive the probe state machine unconditionally
    // (not just when datagrams just arrived above) so a lost probe's
    // timeout is still detected on a caller that polls pumpNetwork() in a
    // tight loop without new data ever showing up. handshake_after_accept()
    // carries the equivalent tick for event-loop-driven connections; this
    // is the one that matters for callers (including these tests) that
    // poll a bare, non-event-loop quic object directly.
    if (_handshake_complete && _conn_state.load() == ConnectionState::Connected) {
        std::lock_guard<std::recursive_mutex> lock(quic_mtx());
        pmtudTick();
    }
}

// ============================================================================
// Stream Data Receive (for specific stream)
// ============================================================================

size_t quic::recvStreamData(uint64_t stream_id, uint8_t* data, size_t max_len) {
    std::lock_guard<std::recursive_mutex> lock(quic_mtx());
    
    auto it = _streams.find(stream_id);
    if (it == _streams.end()) {
        return 0;  // Stream doesn't exist
    }
    
    Stream& stream = it->second;
    // Only return contiguous data that hasn't been consumed yet.
    // recv_buffer is indexed by absolute stream offset; recv_offset
    // tracks how far the application has consumed.
    if (stream.recv_offset >= stream.recv_contiguous) {
        return 0;  // No new contiguous data available
    }
    
    // Guard: buffer may have been moved out by pending dispatch
    if (stream.recv_buffer.empty()) {
        return 0;
    }
    
    size_t available = static_cast<size_t>(stream.recv_contiguous - stream.recv_offset);
    size_t to_copy = std::min(max_len, available);
    std::memcpy(data, stream.recv_buffer.data() + stream.recv_offset, to_copy);
    stream.recv_offset += to_copy;
    
    // Flow control: after consuming data, check if we should send
    // MAX_STREAM_DATA to open the receive window for the peer.
    // Trigger when >50% of the advertised window has been consumed.
    uint64_t consumed_from_committed = stream.recv_offset;
    if (consumed_from_committed > stream.max_data_local_committed / 2) {
        uint64_t new_limit = stream.recv_offset + stream.max_data_local;
        if (new_limit > stream.max_data_local_committed) {
            stream.max_data_local_committed = new_limit;
            _pending_max_stream_data[stream_id] = new_limit;
        }
    }
    
    // Connection-level flow control: update MAX_DATA after consumption
    if (_data_recv > _max_data_local_committed / 2) {
        uint64_t new_limit = _data_recv + _max_data_local;
        if (new_limit > _max_data_local_committed) {
            _max_data_local_committed = new_limit;
            _pending_max_data_value = new_limit;
            _pending_max_data = true;
        }
    }
    
    return to_copy;
}

// ============================================================================
// Check if stream has data
// ============================================================================

bool quic::hasStreamData(uint64_t stream_id) const {
    std::lock_guard<std::recursive_mutex> lock(quic_mtx());
    
    auto it = _streams.find(stream_id);
    if (it == _streams.end()) {
        return false;
    }
    return it->second.recv_offset < it->second.recv_contiguous;
}

bool quic::isStreamComplete(uint64_t stream_id) const {
    std::lock_guard<std::recursive_mutex> lock(quic_mtx());

    auto it = _streams.find(stream_id);
    if (it == _streams.end()) {
        return false;
    }
    return it->second.recv_complete;
}

bool quic::isStreamReset(uint64_t stream_id) const {
    std::lock_guard<std::recursive_mutex> lock(quic_mtx());

    auto it = _streams.find(stream_id);
    if (it == _streams.end()) {
        return false;
    }
    return it->second.reset;
}

// ============================================================================
// Frame Processing: Process individual frames
// ============================================================================

// Sends CONNECTION_CLOSE with the given transport error code and drains
// the connection — used whenever frame parsing hits malformed/truncated
// data it can't safely recover from (RFC 9000 §12.4: an endpoint MUST
// treat a frame it can't parse as a connection error, not silently
// continue). Consumes the rest of the packet by setting offset = len so
// the caller's frame-processing loop terminates cleanly afterward.
void quic::abortFrameParsing(size_t len, size_t& offset, uint64_t error_code, const char* reason) {
    std::vector<uint8_t> close_frame = buildConnectionCloseFrame(error_code, reason);

    // Must close using the packet number space we were actually parsing
    // (processFrame() sets _current_enc_level right before the frame loop
    // in each of processInitialPacket/processHandshakePacket/
    // processApplicationPacket). Unconditionally sending this at
    // Application level — as this used to do — encrypts with 1-RTT keys
    // that don't exist yet if the error happened during Initial or
    // Handshake parsing, producing a packet the peer can't decrypt: the
    // connection dies silently instead of closing cleanly (RFC 9000
    // §12.4/§10.2.1: use the encryption level of the packet that caused
    // the error). See close()'s Initial/Application split above for the
    // same pattern.
    std::vector<uint8_t> packet;
    switch (_current_enc_level) {
        case EncryptionLevel::Initial:
            packet = buildInitialPacket(close_frame);
            applyHeaderProtection(packet, EncryptionLevel::Initial, _initial_keys.send);
            break;
        case EncryptionLevel::Handshake:
            packet = buildHandshakePacket(close_frame);
            applyHeaderProtection(packet, EncryptionLevel::Handshake, _hs_keys.send);
            break;
        case EncryptionLevel::Application:
            packet = buildShortHeaderPacket(close_frame);
            applyHeaderProtection(packet, EncryptionLevel::Application, _app_keys.send);
            break;
        case EncryptionLevel::EarlyData:
            // Only ever reached server-side (0-RTT is recv-only there, and
            // the server has no 0-RTT send keys of its own — it never
            // originates 0-RTT packets). CONNECTION_CLOSE (0x1c) is valid
            // in every packet number space (RFC 9000 §12.4 Table 3), so
            // respond over Handshake instead, which is always available by
            // the time a 0-RTT packet can have been processed at all.
            packet = buildHandshakePacket(close_frame);
            applyHeaderProtection(packet, EncryptionLevel::Handshake, _hs_keys.send);
            break;
    }
    sendPacket(packet.data(), packet.size());
    _conn_state.store(ConnectionState::Draining);
    offset = len;
}

void quic::processFrame(const uint8_t* data, size_t len, size_t& offset, bool& ack_eliciting) {
    if (offset >= len) return;

    // Read frame type
    size_t type_bytes;
    uint64_t frame_type = decodeVarInt(&data[offset], len - offset, type_bytes);
    if (type_bytes == 0) {
        abortFrameParsing(len, offset, 0x01 /* FRAME_ENCODING_ERROR */, "truncated frame type");
        return;
    }
    offset += type_bytes;

    // RFC 9000 §13.2: every frame type except ACK/ACK_ECN, CONNECTION_CLOSE,
    // and PADDING is ack-eliciting. Set before any of the parsing below
    // (including the abort/error paths) so callers get a consistent answer
    // for this frame type regardless of how parsing turns out. This is what
    // lets callers honor §13.2.1 ("a packet containing only ACK frames does
    // not elicit an ACK") — without it, an ACK sent for real data triggers
    // an ACK-of-the-ACK, which triggers another, forever.
    ack_eliciting = !(frame_type == 0x00 || frame_type == 0x02 ||
                       frame_type == 0x03 || frame_type == 0x1c ||
                       frame_type == 0x1d);

    if (frame_type != 0x00) {  // Don't log PADDING
        QUIC_DBG("processFrame: type=0x%02lx offset=%zu len=%zu enc=%d",
                 (unsigned long)frame_type, offset, len, (int)_current_enc_level);
    }

    // Bounds-checked varint read local to this frame: on truncation, sends
    // CONNECTION_CLOSE(FRAME_ENCODING_ERROR) and aborts (see
    // abortFrameParsing()) instead of continuing to parse past bad data.
    auto readVarInt = [&](uint64_t& out) -> bool {
        size_t bytes;
        out = decodeVarInt(&data[offset], len - offset, bytes);
        if (bytes == 0) {
            abortFrameParsing(len, offset, 0x01, "truncated frame field");
            return false;
        }
        offset += bytes;
        return true;
    };

    switch (frame_type) {
        case 0x00:  // PADDING
            // Just skip - padding frames are 1 byte each
            break;

        case 0x01:  // PING
            // No payload, just acknowledge
            break;

        case 0x02:  // ACK
            processAckFrame(data, len, offset, false);
            break;
        case 0x03:  // ACK_ECN
            processAckFrame(data, len, offset, true);
            break;

        case 0x06:  // CRYPTO
            processCryptoFrame(data, len, offset);
            break;

        case 0x04:  // RESET_STREAM
            {
                uint64_t sid, app_error, final_size;
                if (!readVarInt(sid) || !readVarInt(app_error) || !readVarInt(final_size)) return;
                auto it = _streams.find(sid);
                if (it != _streams.end()) {
                    it->second.reset = true;
                    it->second.recv_fin = true;
                    it->second.recv_fin_offset = final_size;
                }
                (void)app_error;
                // Deliberately not retired here: the application may still
                // need to observe this via isStreamReset()/hasStreamData()
                // before it's done with the stream. It retires (and, for a
                // peer-initiated stream, grants fresh credit) once the app
                // calls closeStream(), same as any other completed stream.
            }
            break;

        case 0x05:  // STOP_SENDING
            {
                uint64_t sid, app_error;
                if (!readVarInt(sid) || !readVarInt(app_error)) return;
                auto it = _streams.find(sid);
                if (it != _streams.end() && !it->second.send_fin) {
                    // RFC 9000 §3.5: reset our send side in response so the
                    // peer isn't left waiting on a response it already told
                    // us it doesn't want. This is what lets retirement (and
                    // the peer's stream-count credit) happen promptly
                    // instead of only once our send finishes naturally,
                    // which for an abandoned/redundant fetch may be slow or
                    // never happen at all. resetStream() retires this side
                    // itself once both directions are terminal.
                    resetStream(sid, app_error);
                }
            }
            break;

        case 0x08: case 0x09: case 0x0a: case 0x0b:
        case 0x0c: case 0x0d: case 0x0e: case 0x0f:  // STREAM
            processStreamFrame(data, len, offset, static_cast<uint8_t>(frame_type));
            break;

        case 0x10:  // MAX_DATA
            {
                uint64_t max_data;
                if (!readVarInt(max_data)) return;
                _max_data_remote = max_data;
                QUIC_DBG("processFrame: MAX_DATA new_limit=%lu", (unsigned long)_max_data_remote);
            }
            break;

        case 0x11:  // MAX_STREAM_DATA
            {
                uint64_t sid, max_sd;
                if (!readVarInt(sid) || !readVarInt(max_sd)) return;
                auto it = _streams.find(sid);
                if (it != _streams.end()) {
                    if (max_sd > it->second.max_data_remote) {
                        QUIC_DBG("processFrame: MAX_STREAM_DATA sid=%lu new_limit=%lu (was %lu)",
                                 (unsigned long)sid, (unsigned long)max_sd,
                                 (unsigned long)it->second.max_data_remote);
                        it->second.max_data_remote = max_sd;
                    }
                }
            }
            break;

        case 0x12:  // MAX_STREAMS (bidi)
            {
                uint64_t max_streams;
                if (!readVarInt(max_streams)) return;
                if (max_streams > _max_streams_bidi_remote)
                    _max_streams_bidi_remote = max_streams;
            }
            break;
        case 0x13:  // MAX_STREAMS (uni)
            {
                uint64_t max_streams;
                if (!readVarInt(max_streams)) return;
                if (max_streams > _max_streams_uni_remote)
                    _max_streams_uni_remote = max_streams;
            }
            break;

        case 0x14:  // DATA_BLOCKED
            {
                uint64_t blocked_at;
                if (!readVarInt(blocked_at)) return;
                // Peer is blocked — send updated MAX_DATA using the
                // current committed limit (not the initial window).
                // Recalculate in case earlier updates were lost.
                uint64_t new_conn_limit = _data_recv + _max_data_local;
                if (new_conn_limit <= _max_data_local_committed)
                    new_conn_limit = _max_data_local_committed + _max_data_local;
                _max_data_local_committed = new_conn_limit;
                sendMaxData(new_conn_limit);
                (void)blocked_at;
            }
            break;

        case 0x15:  // STREAM_DATA_BLOCKED
            {
                uint64_t sid, blocked_at;
                if (!readVarInt(sid) || !readVarInt(blocked_at)) return;
                // Peer is blocked on this stream — send updated
                // MAX_STREAM_DATA using the current committed limit.
                // Recalculate in case earlier updates were lost.
                auto it = _streams.find(sid);
                if (it != _streams.end()) {
                    uint64_t consumed = it->second.recv_buffer.size();
                    uint64_t new_limit = consumed + it->second.max_data_local;
                    if (new_limit <= it->second.max_data_local_committed)
                        new_limit = it->second.max_data_local_committed + it->second.max_data_local;
                    it->second.max_data_local_committed = new_limit;
                    sendMaxStreamData(sid, new_limit);
                }
                (void)blocked_at;
            }
            break;

        case 0x1c:  // CONNECTION_CLOSE
        case 0x1d:  // CONNECTION_CLOSE (application)
            {
                uint64_t error_code, triggering_frame = 0;
                if (!readVarInt(error_code)) return;

                if (frame_type == 0x1c) {
                    // Frame type that triggered the error
                    if (!readVarInt(triggering_frame)) return;
                }

                // Reason phrase length
                uint64_t reason_len;
                if (!readVarInt(reason_len)) return;
                if (reason_len > len - offset) {
                    abortFrameParsing(len, offset, 0x01, "CONNECTION_CLOSE reason length exceeds packet");
                    return;
                }

                // Read reason phrase for logging
                std::string reason(reinterpret_cast<const char*>(&data[offset]), reason_len);
                offset += reason_len;

                QUIC_DBG("CONNECTION_CLOSE: error=0x%lx frame_type=0x%lx trigger_frame=0x%lx reason='%s'",
                         (unsigned long)error_code, (unsigned long)frame_type,
                         (unsigned long)triggering_frame, reason.c_str());

                // Connection is closing
                _conn_state.store(ConnectionState::Draining);
            }
            break;

        case 0x07:  // NEW_TOKEN
            {
                // RFC 9000 §19.7: only a server ever sends this; a server
                // receiving one from a client is a protocol violation.
                if (_is_server) {
                    abortFrameParsing(len, offset, 0x0A /* PROTOCOL_VIOLATION */,
                                       "server received NEW_TOKEN");
                    return;
                }
                uint64_t token_len;
                if (!readVarInt(token_len)) return;
                if (token_len > len - offset) {
                    abortFrameParsing(len, offset, 0x01, "NEW_TOKEN length exceeds packet");
                    return;
                }
                // Retained for getStoredToken() — an application can pull
                // this out and feed it into a future connection's
                // setToken() to skip Retry on that later attempt.
                _received_new_token.assign(&data[offset], &data[offset] + token_len);
                offset += token_len;
            }
            break;

        case 0x16:  // STREAMS_BLOCKED (bidi)
        case 0x17:  // STREAMS_BLOCKED (uni)
            {
                uint64_t maximum_streams;
                if (!readVarInt(maximum_streams)) return;
            }
            break;

        case 0x18:  // NEW_CONNECTION_ID
            {
                uint64_t seq, retire_prior_to;
                if (!readVarInt(seq) || !readVarInt(retire_prior_to)) return;
                if (offset >= len) {
                    abortFrameParsing(len, offset, 0x01, "truncated NEW_CONNECTION_ID");
                    return;
                }
                uint8_t cid_len = data[offset++];  // connection_id length (1 byte)
                // connection_id + 16-byte stateless_reset_token must both fit.
                if (cid_len > len - offset || 16 > len - offset - cid_len) {
                    abortFrameParsing(len, offset, 0x01, "truncated NEW_CONNECTION_ID");
                    return;
                }
                offset += cid_len;                  // connection_id
                offset += 16;                       // stateless_reset_token
            }
            break;

        case 0x19:  // RETIRE_CONNECTION_ID
            {
                uint64_t seq;
                if (!readVarInt(seq)) return;
            }
            break;

        case 0x1a:  // PATH_CHALLENGE
            {
                if (offset + 8 <= len) {
                    // Respond with PATH_RESPONSE echoing the same 8 bytes
                    std::vector<uint8_t> response;
                    response.push_back(0x1b);  // PATH_RESPONSE frame type
                    response.insert(response.end(), &data[offset], &data[offset + 8]);
                    std::vector<uint8_t> packet = buildShortHeaderPacket(response);
                    applyHeaderProtection(packet, EncryptionLevel::Application, _app_keys.send);
                    sendPacket(packet.data(), packet.size());
                    offset += 8;
                } else {
                    abortFrameParsing(len, offset, 0x01, "truncated PATH_CHALLENGE");
                    return;
                }
            }
            break;

        case 0x1b:  // PATH_RESPONSE
            {
                if (offset + 8 > len) {
                    abortFrameParsing(len, offset, 0x01, "truncated PATH_RESPONSE");
                    return;
                }
                // A match against our own outstanding PMTUD probe (see
                // pmtudSendProbe/pmtudTick) proves a datagram of that
                // exact size traversed the path and was decrypted
                // intact — the confirmation the search is waiting for.
                if (_pmtud_probe_size != 0 &&
                    std::memcmp(&data[offset], _pmtud_probe_data.data(), 8) == 0) {
                    pmtudOnProbeAcked();
                }
                offset += 8;  // 8 bytes of data
            }
            break;

        case 0x1e:  // HANDSHAKE_DONE
            _handshake_complete = true;
            _conn_state.store(ConnectionState::Connected);
            break;

        default:
            // Unknown frame type: QUIC frames have no general
            // self-describing length, so an unrecognized type can't be
            // safely skipped over — RFC 9000 §12.4 requires treating this
            // as a connection error (FRAME_ENCODING_ERROR), not silently
            // consuming the rest of the packet and continuing as if
            // nothing happened.
            abortFrameParsing(len, offset, 0x07 /* FRAME_ENCODING_ERROR */, "unknown frame type");
            return;
    }
}

// ============================================================================
// Frame Processing: CRYPTO Frame
// ============================================================================

void quic::processCryptoFrame(const uint8_t* data, size_t len, size_t& offset) {
    if (offset >= len) return;

    // Offset field
    size_t bytes;
    uint64_t crypto_offset = decodeVarInt(&data[offset], len - offset, bytes);
    if (bytes == 0) {
        abortFrameParsing(len, offset, 0x01, "truncated CRYPTO offset");
        return;
    }
    offset += bytes;

    // Length field
    if (offset >= len) {
        abortFrameParsing(len, offset, 0x01, "truncated CRYPTO length");
        return;
    }
    uint64_t crypto_len = decodeVarInt(&data[offset], len - offset, bytes);
    if (bytes == 0) {
        abortFrameParsing(len, offset, 0x01, "truncated CRYPTO length");
        return;
    }
    offset += bytes;

    if (crypto_len > len - offset) {
        abortFrameParsing(len, offset, 0x01, "CRYPTO data length exceeds packet");
        return;
    }
    
    // Get references to the appropriate reassembly structures
    std::vector<uint8_t>* crypto_buffer = nullptr;
    std::vector<CryptoRange>* crypto_ranges = nullptr;
    uint64_t* crypto_next_offset = nullptr;
    
    switch (_current_enc_level) {
        case EncryptionLevel::Initial:
            crypto_buffer = &_crypto_recv_initial;
            crypto_ranges = &_crypto_ranges_initial;
            crypto_next_offset = &_crypto_offset_initial;
            break;
        case EncryptionLevel::Handshake:
            crypto_buffer = &_crypto_recv_handshake;
            crypto_ranges = &_crypto_ranges_handshake;
            crypto_next_offset = &_crypto_offset_handshake;
            break;
        case EncryptionLevel::Application:
            crypto_buffer = &_crypto_recv_app;
            crypto_ranges = &_crypto_ranges_app;
            crypto_next_offset = &_crypto_offset_app;
            break;
        case EncryptionLevel::EarlyData:
            // CRYPTO frames are never valid in 0-RTT packets (RFC 9000
            // §12.4 Table 3) — leaving these null makes the guard just
            // below drop it, same as any other malformed 0-RTT content.
            break;
    }
    
    if (!crypto_buffer || !crypto_ranges || !crypto_next_offset) return;
    
    // Check if this data is a duplicate or overlapping with existing data
    uint64_t data_end = crypto_offset + crypto_len;
    if (data_end <= *crypto_next_offset) {
        offset += crypto_len;
        return;
    }

    // Fast path: the overwhelmingly common case — this fragment arrives
    // exactly at the expected next offset, with no other out-of-order
    // fragments currently buffered. Append directly instead of paying for
    // a CryptoRange allocation, an O(n) merge-scan that rebuilds the
    // entire ranges list even when there's nothing to merge with, and a
    // full re-sort — all of which the general path below does
    // unconditionally, every single CRYPTO frame.
    if (crypto_ranges->empty() && crypto_offset == *crypto_next_offset) {
        crypto_buffer->insert(crypto_buffer->end(), &data[offset], &data[offset + crypto_len]);
        *crypto_next_offset += crypto_len;
        offset += crypto_len;
        processTLSMessages();
        return;
    }

    // Create a range for this data
    CryptoRange range;
    range.offset = crypto_offset;
    range.length = crypto_len;
    range.data.assign(&data[offset], &data[offset + crypto_len]);
    
    // Insert the range into the list, merging overlapping ranges
    std::vector<CryptoRange> new_ranges;
    
    for (auto& existing : *crypto_ranges) {
        // Check if ranges overlap or are adjacent
        if (range.offset <= existing.offset + existing.length &&
            range.offset + range.length >= existing.offset) {
            // Merge ranges
            uint64_t merge_start = std::min(range.offset, existing.offset);
            uint64_t merge_end = std::max(range.offset + range.length, 
                                         existing.offset + existing.length);
            
            // Build merged data
            std::vector<uint8_t> merged_data;
            if (merge_start == range.offset) {
                merged_data = range.data;
                if (existing.offset + existing.length > range.offset + range.length) {
                    // Append tail from existing
                    size_t tail_start = range.offset + range.length - existing.offset;
                    merged_data.insert(merged_data.end(),
                                      existing.data.begin() + tail_start,
                                      existing.data.end());
                }
            } else {
                merged_data = existing.data;
                if (range.offset + range.length > existing.offset + existing.length) {
                    // Append tail from range
                    size_t tail_start = existing.offset + existing.length - range.offset;
                    merged_data.insert(merged_data.end(),
                                      range.data.begin() + tail_start,
                                      range.data.end());
                }
            }
            
            range.offset = merge_start;
            range.length = merge_end - merge_start;
            range.data = merged_data;
        } else {
            new_ranges.push_back(existing);
        }
    }
    new_ranges.push_back(std::move(range));
    *crypto_ranges = std::move(new_ranges);
    
    // Check if we can assemble contiguous data starting from next_offset
    // First, sort ranges by offset to handle out-of-order arrivals
    std::sort(crypto_ranges->begin(), crypto_ranges->end(),
              [](const CryptoRange& a, const CryptoRange& b) {
                  return a.offset < b.offset;
              });
    
    uint64_t current = *crypto_next_offset;
    for (auto& r : *crypto_ranges) {
        if (r.offset == current) {
            // This range fills the gap, extend buffer
            crypto_buffer->insert(crypto_buffer->end(), r.data.begin(), r.data.end());
            current = r.offset + r.length;
        } else if (r.offset > current) {
            // Gap exists
            break;
        }
    }
    *crypto_next_offset = current;
    
    // Remove assembled ranges
    uint64_t next_offset = *crypto_next_offset;
    crypto_ranges->erase(
        std::remove_if(crypto_ranges->begin(), crypto_ranges->end(),
                       [next_offset](const CryptoRange& r) {
                           return r.offset + r.length <= next_offset;
                       }),
        crypto_ranges->end()
    );
    
    offset += crypto_len;
    
    // Process TLS messages in crypto buffer if we have new contiguous data
    processTLSMessages();
}

// ============================================================================
// Frame Processing: STREAM Frame
// ============================================================================

void quic::processStreamFrame(const uint8_t* data, size_t len, size_t& offset, uint8_t frame_type) {
    if (offset >= len) return;

    // The frame type encodes flags in bits 0-2 — passed in from processFrame(),
    // which already decoded it via decodeVarInt(), instead of re-deriving it
    // here from data[offset-1] (only correct because the type happens to be
    // single-byte-encoded; passing it down avoids relying on that).
    bool has_offset = (frame_type & 0x04) != 0;
    bool has_length = (frame_type & 0x02) != 0;
    bool has_fin = (frame_type & 0x01) != 0;
    
    // Stream ID
    size_t bytes;
    uint64_t stream_id = decodeVarInt(&data[offset], len - offset, bytes);
    if (bytes == 0) {
        abortFrameParsing(len, offset, 0x01, "truncated STREAM id");
        return;
    }
    offset += bytes;

    // Offset (if present)
    uint64_t stream_offset = 0;
    if (has_offset) {
        if (offset >= len) {
            abortFrameParsing(len, offset, 0x01, "truncated STREAM offset");
            return;
        }
        stream_offset = decodeVarInt(&data[offset], len - offset, bytes);
        if (bytes == 0) {
            abortFrameParsing(len, offset, 0x01, "truncated STREAM offset");
            return;
        }
        offset += bytes;
    }

    // Length (if present)
    uint64_t stream_len;
    if (has_length) {
        if (offset >= len) {
            abortFrameParsing(len, offset, 0x01, "truncated STREAM length");
            return;
        }
        stream_len = decodeVarInt(&data[offset], len - offset, bytes);
        if (bytes == 0) {
            abortFrameParsing(len, offset, 0x01, "truncated STREAM length");
            return;
        }
        offset += bytes;
    } else {
        stream_len = len - offset;  // Rest of frame is data
    }

    if (stream_len > len - offset) {
        abortFrameParsing(len, offset, 0x01, "STREAM data length exceeds packet");
        return;
    }

    // Ignore data for streams that were already fully closed (both FIN sent
    // and received).  Retransmitted packets for erased streams would otherwise
    // auto-create a new Stream object, inflating _data_recv and potentially
    // re-dispatching stale data to the application.
    if (isStreamClosed(stream_id)) {
        offset += stream_len;
        return;
    }
    
    // Get or create stream
    auto& stream = _streams[stream_id];
    stream.stream_id = stream_id;

    // True exactly once per stream: the very first STREAM frame seen for
    // it. Tracked via recv_frame_count rather than "recv_buffer empty +
    // recv_offset==0" — with incremental dispatch's buffer trimming (see
    // its comment further below), recv_buffer legitimately goes back to
    // empty mid-stream between dispatched chunks, which would otherwise
    // make this fire again and double-count MAX_STREAMS accounting below.
    bool is_first_frame_for_stream = (stream.recv_frame_count == 0);
    stream.recv_frame_count++;

    // Track peer-initiated stream count for MAX_STREAMS enforcement
    // Peer-initiated streams: client-initiated if we're server, server-initiated if we're client
    bool peer_initiated = (_is_server) ? ((stream_id & 0x01) == 0) : ((stream_id & 0x01) == 1);
    if (peer_initiated && is_first_frame_for_stream) {
        // First data on a new peer-initiated stream
        bool is_bidi = (stream_id % 4 == 0) || (stream_id % 4 == 1);
        if (is_bidi) {
            _peer_streams_bidi_opened++;
            if (_peer_streams_bidi_opened > _max_streams_bidi_local) {
                // STREAM_LIMIT_ERROR: peer exceeded our MAX_STREAMS
                std::vector<uint8_t> close_frame = buildConnectionCloseFrame(0x04, "stream limit exceeded");
                std::vector<uint8_t> packet = buildShortHeaderPacket(close_frame);
                applyHeaderProtection(packet, EncryptionLevel::Application, _app_keys.send);
                sendPacket(packet.data(), packet.size());
                _conn_state.store(ConnectionState::Draining);
                offset += stream_len;
                return;
            }
            // Set per-stream limits for peer-initiated bidi streams
            stream.max_data_remote = _is_server
                ? _peer_initial_max_stream_data_bidi_local
                : _peer_initial_max_stream_data_bidi_remote;
        } else {
            _peer_streams_uni_opened++;
            if (_peer_streams_uni_opened > _max_streams_uni_local) {
                std::vector<uint8_t> close_frame = buildConnectionCloseFrame(0x04, "stream limit exceeded");
                std::vector<uint8_t> packet = buildShortHeaderPacket(close_frame);
                applyHeaderProtection(packet, EncryptionLevel::Application, _app_keys.send);
                sendPacket(packet.data(), packet.size());
                _conn_state.store(ConnectionState::Draining);
                offset += stream_len;
                return;
            }
            stream.max_data_remote = _peer_initial_max_stream_data_uni;
        }
    }
    
    // Compute new unique bytes (exclude retransmitted/overlapping data)
    // so that connection-level flow control only counts each byte once.
    uint64_t new_unique_bytes = 0;
    if (stream_len > 0) {
        uint64_t end_offset = stream_offset + stream_len;
        uint64_t overlap = 0;
        for (const auto& r : stream.recv_ranges) {
            if (r.first >= end_offset) break;
            if (r.second <= stream_offset) continue;
            overlap += std::min(r.second, end_offset) - std::max(r.first, stream_offset);
        }
        new_unique_bytes = stream_len - overlap;
    }
    
    // Enforce receive-side flow control: peer must not exceed our advertised limits
    if (stream_len > 0) {
        uint64_t end_offset = stream_offset + stream_len;
        
        // Stream-level check
        if (end_offset > stream.max_data_local_committed) {
            // FLOW_CONTROL_ERROR (0x03): peer exceeded stream flow control limit
            std::vector<uint8_t> close_frame = buildConnectionCloseFrame(0x03, "stream flow control exceeded");
            std::vector<uint8_t> packet = buildShortHeaderPacket(close_frame);
            applyHeaderProtection(packet, EncryptionLevel::Application, _app_keys.send);
            sendPacket(packet.data(), packet.size());
            _conn_state.store(ConnectionState::Draining);
            offset += stream_len;
            return;
        }
        
        // Connection-level check (only count new unique bytes)
        if (_data_recv + new_unique_bytes > _max_data_local_committed) {
            // FLOW_CONTROL_ERROR (0x03): peer exceeded connection flow control limit
            std::vector<uint8_t> close_frame = buildConnectionCloseFrame(0x03, "connection flow control exceeded");
            std::vector<uint8_t> packet = buildShortHeaderPacket(close_frame);
            applyHeaderProtection(packet, EncryptionLevel::Application, _app_keys.send);
            sendPacket(packet.data(), packet.size());
            _conn_state.store(ConnectionState::Draining);
            offset += stream_len;
            return;
        }
    }
    
    // Store data in stream buffer with proper out-of-order reassembly
    if (stream_len > 0) {
        uint64_t end_offset = stream_offset + stream_len;

        // recv_buffer only ever holds the not-yet-dispatched tail of the
        // stream (see incremental dispatch's trimming further below);
        // recv_base_offset is the absolute stream offset corresponding to
        // recv_buffer[0]. For the (default) non-incremental path,
        // recv_base_offset stays 0 and this is exactly the old absolute
        // indexing. Bytes at or before recv_base_offset were already
        // dispatched (or belong to a duplicate/retransmitted frame) and
        // must not be written — only the portion of this frame at or after
        // recv_base_offset, if any, still needs storing.
        uint64_t write_start = std::max(stream_offset, stream.recv_base_offset);
        if (write_start < end_offset) {
            size_t copy_len = static_cast<size_t>(end_offset - write_start);
            size_t src_off = static_cast<size_t>(offset + (write_start - stream_offset));
            uint64_t rel_write_start = write_start - stream.recv_base_offset;
            uint64_t cur_size = stream.recv_buffer.size();

            if (rel_write_start > cur_size) {
                // Genuine gap (bytes we don't have yet and this frame
                // doesn't cover) — these must be zero-filled since their
                // content is still unknown.
                stream.recv_buffer.resize(rel_write_start, 0);
                cur_size = rel_write_start;
            }

            if (rel_write_start < cur_size) {
                // Overlaps already-sized buffer (retransmit/duplicate) —
                // copy the overlapping prefix in place.
                size_t in_place_len = std::min<size_t>(copy_len, static_cast<size_t>(cur_size - rel_write_start));
                std::memcpy(&stream.recv_buffer[rel_write_start], &data[src_off], in_place_len);
                src_off += in_place_len;
                rel_write_start += in_place_len;
                copy_len -= in_place_len;
            }

            if (copy_len > 0) {
                // Remainder extends past the current buffer end — append
                // it directly (the vector grows by exactly this many
                // elements, each constructed once from source data)
                // instead of zero-filling via resize() and then
                // overwriting via memcpy, which wrote every new byte
                // twice — the common case for in-order delivery, where
                // this is the only branch that ever does anything.
                stream.recv_buffer.insert(stream.recv_buffer.end(),
                                           &data[src_off], &data[src_off + copy_len]);
            }
        }

        // Track received range — merge with adjacent/overlapping ranges.
        //
        // Fast path for the overwhelmingly common case (in-order delivery,
        // no reordering/loss in flight): recv_ranges holds exactly one
        // entry starting at 0, and this frame only extends or overlaps it.
        // The general path below handles this correctly too, but always
        // pays for a fresh insert plus an erase+erase+insert merge cycle
        // (4 red-black-tree node operations) even though nothing here is
        // actually out of order — this path does the same net update
        // (grow the sole [0, recv_contiguous) range) in one erase + one
        // insert instead.
        bool handled_by_fast_path = false;
        if (stream.recv_ranges.size() <= 1) {
            if (stream.recv_ranges.empty()) {
                if (stream_offset == 0) {
                    stream.recv_ranges.insert({0, end_offset});
                    handled_by_fast_path = true;
                }
            } else {
                auto& only = *stream.recv_ranges.begin();
                if (only.first == 0 && stream_offset <= only.second) {
                    uint64_t new_hi = std::max(only.second, end_offset);
                    if (new_hi != only.second) {
                        stream.recv_ranges.erase(stream.recv_ranges.begin());
                        stream.recv_ranges.insert({0, new_hi});
                    }
                    handled_by_fast_path = true;
                }
            }
        }

        if (!handled_by_fast_path) {
            auto new_range = std::make_pair(stream_offset, end_offset);
            auto it = stream.recv_ranges.insert(new_range).first;

            // Merge with predecessor(s) if overlapping/adjacent
            while (it != stream.recv_ranges.begin()) {
                auto prev = std::prev(it);
                if (prev->second >= it->first) {
                    auto merged = std::make_pair(prev->first,
                                                  std::max(prev->second, it->second));
                    stream.recv_ranges.erase(prev);
                    it = stream.recv_ranges.erase(it);
                    it = stream.recv_ranges.insert(merged).first;
                } else {
                    break;
                }
            }
            // Merge with successor(s) if overlapping/adjacent
            while (true) {
                auto next = std::next(it);
                if (next == stream.recv_ranges.end()) break;
                if (it->second >= next->first) {
                    auto merged = std::make_pair(it->first,
                                                  std::max(it->second, next->second));
                    stream.recv_ranges.erase(next);
                    it = stream.recv_ranges.erase(it);
                    it = stream.recv_ranges.insert(merged).first;
                } else {
                    break;
                }
            }
        }


        // Update contiguous byte count
        if (!stream.recv_ranges.empty() && stream.recv_ranges.begin()->first == 0) {
            stream.recv_contiguous = stream.recv_ranges.begin()->second;
        }


    }
    
    offset += stream_len;
    
    // Update connection-level receive counter (only new unique bytes)
    _data_recv += new_unique_bytes;
    
    // Flow control: defer MAX_STREAM_DATA when >50% of window consumed.
    // recv_base_offset + recv_buffer.size() is the highest absolute offset
    // the buffer currently extends to — equivalent to the old plain
    // recv_buffer.size() when recv_base_offset is 0 (always true outside
    // incremental dispatch's trimming), and still correct once trimming
    // has shifted the buffer forward.
    uint64_t consumed = stream.recv_base_offset + stream.recv_buffer.size();
    if (consumed > stream.max_data_local_committed / 2) {
        uint64_t new_limit = consumed + stream.max_data_local;
        stream.max_data_local_committed = new_limit;
        _pending_max_stream_data[stream_id] = new_limit;
    }
    
    // Flow control: defer MAX_DATA when >50% of connection window consumed
    if (_data_recv > _max_data_local_committed / 2) {
        uint64_t new_limit = _data_recv + _max_data_local;
        _max_data_local_committed = new_limit;
        _pending_max_data_value = new_limit;
        _pending_max_data = true;
    }
    
    if (has_fin) {
        stream.recv_fin = true;
        stream.recv_fin_offset = stream_offset + stream_len;
    }
    
    // Check if stream is fully received: FIN seen + all data contiguous from 0
    if (stream.recv_fin && !stream.recv_complete &&
        stream.recv_contiguous >= stream.recv_fin_offset) {
        stream.recv_complete = true;
    }


    // Queue stream dispatch — will be invoked OUTSIDE _quic_mutex
    // so that long-running callbacks (e.g. import) don't block PING ACKs
    // and don't deadlock when the callback calls sendStreamData/flush_out.
    // ALL stream types are deferred via _pending_dispatches.
    if (_is_server && _stream_callback && _incremental_stream_dispatch) {
        // Opt-in path (setIncrementalStreamDispatch): dispatch each newly
        // contiguous chunk as it arrives instead of waiting for the whole
        // message, so a server can start responding while the peer is
        // still sending. fin is true only on the chunk that completes the
        // stream; everything before that is fin=false, mirroring how the
        // client side already reads via recv_contiguous (see recvData()).
        if (stream.recv_contiguous > stream.recv_dispatched_offset) {
            // recv_dispatched_offset always equals recv_base_offset here
            // (the buffer is trimmed to it immediately below, every time),
            // so the chunk always starts at the beginning of recv_buffer.
            uint64_t rel_contiguous = stream.recv_contiguous - stream.recv_base_offset;
            std::vector<uint8_t> chunk(
                stream.recv_buffer.begin(),
                stream.recv_buffer.begin() + rel_contiguous);
            stream.recv_dispatched_offset = stream.recv_contiguous;
            bool chunk_fin = stream.recv_complete;
            if (chunk_fin) {
                stream.recv_dispatched = true;
                stream.recv_buffer.clear();
                stream.recv_buffer.shrink_to_fit();
                stream.recv_ranges.clear();
            } else {
                // F04 fix: drop the now-dispatched prefix so recv_buffer
                // only ever holds undispatched bytes, instead of growing to
                // the stream's full cumulative size and only being freed at
                // FIN — the previous behavior for long-lived incremental
                // streams (e.g. large uploads), which could hold the
                // entire transfer in memory even though the application had
                // already consumed most of it via the callback.
                stream.recv_buffer.erase(stream.recv_buffer.begin(),
                                          stream.recv_buffer.begin() + rel_contiguous);
            }
            stream.recv_base_offset = stream.recv_dispatched_offset;
            _pending_dispatches.push_back({stream_id, std::move(chunk), chunk_fin});
        }
    } else if (_is_server && _stream_callback) {
        bool is_bidi_request = (stream_id % 4 == 0);
        if (is_bidi_request && stream.recv_complete && !stream.recv_dispatched) {
            stream.recv_dispatched = true;
            _pending_dispatches.push_back({stream_id,
                                           std::move(stream.recv_buffer),
                                           true});
            // Free memory — data has been moved to pending dispatch
            stream.recv_buffer.clear();
            stream.recv_buffer.shrink_to_fit();
            stream.recv_ranges.clear();
        } else if (!is_bidi_request && stream.recv_complete && !stream.recv_dispatched) {
            // Unidirectional stream fully received — defer via _pending_dispatches
            // to avoid deadlock (callback may call sendStreamData which locks _quic_mutex).
            // Must check recv_complete (not just recv_fin) to ensure all data is
            // contiguous before dispatching — otherwise gaps from lost/reordered
            // packets would deliver a buffer with zero-filled holes.
            stream.recv_dispatched = true;
            _pending_dispatches.push_back({stream_id,
                                           std::move(stream.recv_buffer),
                                           true});
            stream.recv_buffer.clear();
            stream.recv_buffer.shrink_to_fit();
            stream.recv_ranges.clear();
        }
    }
    // Deliberately not retired here even once has_fin is set: the
    // application (client polling via hasStreamData/recvStreamData, or a
    // server callback not yet dispatched) may still need this stream's
    // buffered data. It retires once the app calls closeStream(), same as
    // any other completed stream.
}

// ============================================================================
// Frame Processing: ACK Frame
// ============================================================================

void quic::processAckFrame(const uint8_t* data, size_t len, size_t& offset, bool is_ecn) {
    if (offset >= len) return;

    size_t bytes;

    // Bounds-checked varint read local to this frame — on truncation,
    // aborts parsing via abortFrameParsing() instead of continuing past
    // bad/missing data (mirrors processFrame's readVarInt lambda).
    auto readVarInt = [&](uint64_t& out) -> bool {
        out = decodeVarInt(&data[offset], len - offset, bytes);
        if (bytes == 0) {
            abortFrameParsing(len, offset, 0x01, "truncated ACK field");
            return false;
        }
        offset += bytes;
        return true;
    };

    // Largest Acknowledged
    uint64_t largest_ack;
    if (!readVarInt(largest_ack)) return;

    // ACK Delay
    uint64_t ack_delay;
    if (!readVarInt(ack_delay)) return;

    // ACK Range Count — capped: this directly bounds the loop below, so an
    // attacker-controlled value here (up to 2^62-1) would otherwise let a
    // single frame drive an unbounded number of loop iterations/allocations
    // even though each individual decodeVarInt call is itself bounds-safe.
    // MAX_ACK_RANGES_PER_FRAME is shared with buildAckFrame()'s own
    // truncation so a peer running this same library never legitimately
    // exceeds it (see the constant's comment in socket.h for why
    // _app_pn_recv_ranges can otherwise grow unboundedly over a long
    // connection's lifetime).
    uint64_t ack_range_count;
    if (!readVarInt(ack_range_count)) return;
    if (ack_range_count > MAX_ACK_RANGES_PER_FRAME) {
        abortFrameParsing(len, offset, 0x01, "too many ACK ranges");
        return;
    }

    // First ACK Range
    uint64_t first_ack_range;
    if (!readVarInt(first_ack_range)) return;
    if (first_ack_range > largest_ack) {
        // RFC 9000 §19.3.1: First ACK Range describes packets below
        // Largest Acknowledged, so it can never exceed it. Silently
        // clamping this to lo=0 (the old behavior) would turn a malformed
        // ACK into one that looks like it acknowledges a huge, mostly
        // bogus range.
        abortFrameParsing(len, offset, 0x01, "malformed ACK: first_ack_range exceeds largest_ack");
        return;
    }

    // Collect all ACKed ranges: [lo, hi] pairs. Bounded by
    // MAX_ACK_RANGES_PER_FRAME (already validated above), so a fixed-size
    // stack array replaces what used to be a heap-allocated vector on this
    // hot per-ACK path.
    struct AckRange { uint64_t lo; uint64_t hi; };
    AckRange acked_ranges[MAX_ACK_RANGES_PER_FRAME + 1];
    size_t acked_count = 0;

    // First range
    uint64_t hi = largest_ack;
    uint64_t lo = largest_ack - first_ack_range;
    acked_ranges[acked_count++] = {lo, hi};

    uint64_t prev_smallest = lo;

    // Additional ranges
    for (uint64_t i = 0; i < ack_range_count; ++i) {
        uint64_t gap, ack_range;
        if (!readVarInt(gap) || !readVarInt(ack_range)) return;

        // RFC 9000: prev_smallest - gap - 2 is the top of the next range
        if (prev_smallest < gap + 2) break; // malformed
        hi = prev_smallest - gap - 2;
        lo = (hi >= ack_range) ? hi - ack_range : 0;
        acked_ranges[acked_count++] = {lo, hi};
        prev_smallest = lo;
    }

    // ACK_ECN (RFC 9000 §19.3.2) carries three additional varints after the
    // ACK Ranges: ECT0 Count, ECT1 Count, ECN-CE Count. These MUST be
    // consumed here regardless of whether this endpoint reacts to ECN yet —
    // otherwise `offset` is left pointing mid-field, and the caller's frame
    // loop misinterprets the remaining bytes as an unrelated frame (typically
    // aborting the connection via the default/malformed-frame path).
    if (is_ecn) {
        uint64_t ect0, ect1, ecn_ce;
        if (!readVarInt(ect0)) return;
        if (!readVarInt(ect1)) return;
        if (!readVarInt(ecn_ce)) return;
        _peer_ect0_count = ect0;
        _peer_ect1_count = ect1;
        _peer_ecn_ce_count = ecn_ce;
    }

    auto now = std::chrono::steady_clock::now();

    // Remove acknowledged packets from _sent_packets and update RTT
    for (size_t ri = 0; ri < acked_count; ++ri) {
        auto& ar = acked_ranges[ri];
        for (auto it = _sent_packets.lower_bound(ar.lo);
             it != _sent_packets.end() && it->first <= ar.hi; ) {
            // Update RTT from the largest newly-acked packet
            if (it->first == largest_ack && it->second.ack_eliciting) {
                double rtt_sample = std::chrono::duration<double>(now - it->second.sent_time).count();
                // RFC 9000 §13.2.5: the wire value is in units of
                // 2^ack_delay_exponent microseconds, using *the peer's own*
                // advertised exponent (see _peer_ack_delay_exponent) — not
                // ours, and not a fixed implicit microsecond unit as before
                // (which silently under-scaled by up to 2^3=8x at the
                // RFC-default exponent whenever the peer reported a
                // nonzero delay).
                double adj_delay = static_cast<double>(ack_delay << _peer_ack_delay_exponent) / 1000000.0;
                if (rtt_sample > adj_delay) rtt_sample -= adj_delay;
                _latest_rtt = rtt_sample;
                hystartOnRttSample(rtt_sample, it->first);
                if (!_rtt_initialized) {
                    _srtt = rtt_sample;
                    _rttvar = rtt_sample / 2.0;
                    _rtt_initialized = true;
                } else {
                    _rttvar = 0.75 * _rttvar + 0.25 * std::abs(_srtt - rtt_sample);
                    _srtt = 0.875 * _srtt + 0.125 * rtt_sample;
                }
                _rto = _srtt + std::max(4.0 * _rttvar, 0.001);
                if (_rto < 0.1) _rto = 0.1;
                if (_rto > 10.0) _rto = 10.0;
            }
            // Congestion control: notify CC about acked packet
            onPacketAckedCC(it->second.sent_size, it->first);
            releaseSentDataBuffer(std::move(it->second.stream_data));
            it = _sent_packets.erase(it);
        }
    }

    if (_largest_acked_pn == UINT64_MAX || largest_ack > _largest_acked_pn) {
        _largest_acked_pn = largest_ack;
    }

    // Trigger loss detection after processing ACK
    checkLossAndRetransmit();
}

// ============================================================================
// Retransmission: Record a sent packet for tracking
// ============================================================================

void quic::recordSentPacket(uint64_t pn, uint64_t stream_id, uint64_t stream_offset,
                            const uint8_t* data, size_t len, bool fin, size_t wire_size,
                            bool sent_as_early_data) {
    SentPacket sp;
    sp.pn = pn;
    sp.sent_time = std::chrono::steady_clock::now();
    sp.stream_id = stream_id;
    sp.stream_offset = stream_offset;
    sp.data_length = len;
    sp.sent_size = wire_size > 0 ? wire_size : len;
    sp.fin = fin;
    sp.ack_eliciting = true;
    sp.sent_as_early_data = sent_as_early_data;
    // Reuse a retired buffer's capacity instead of a fresh heap allocation
    // on every sent packet (see _sent_data_pool's comment in socket.h).
    if (!_sent_data_pool.empty()) {
        sp.stream_data = std::move(_sent_data_pool.back());
        _sent_data_pool.pop_back();
    }
    sp.stream_data.assign(data, data + len);
    _sent_packets[pn] = std::move(sp);
}

// ============================================================================
// Retransmission: Loss detection and retransmit
// ============================================================================

void quic::checkLossAndRetransmit() {
    if (_sent_packets.empty()) return;

    auto now = std::chrono::steady_clock::now();

    // PTO (Probe Timeout) — RFC 9002 §6.2
    // When the largest-acked PN hasn't advanced past our oldest unacked
    // packets (tail loss), retransmit the oldest unacked packet to elicit
    // an ACK and bootstrap the normal loss-detection cycle.
    if (!_sent_packets.empty()) {
        double pto = _rtt_initialized
            ? (_srtt + std::max(4.0 * _rttvar, 0.001) + 0.025)
            : _rto;
        // Exponential back-off: double PTO each consecutive probe
        double pto_thresh = pto * (1 << std::min(_pto_count, 5));
        auto& oldest = _sent_packets.begin()->second;
        double age = std::chrono::duration<double>(now - oldest.sent_time).count();
        if (age >= pto_thresh) {
            // Retransmit the oldest unacked packet as a probe. Moved (not
            // copied) out of the map entry — the entry is erased right
            // below regardless of outcome, so there's nothing to preserve
            // in it, and moving avoids deep-copying stream_data.
            auto it0 = _sent_packets.begin();
            SentPacket sp = std::move(it0->second);
            uint64_t old_pn = it0->first;
            _sent_packets.erase(it0);

            if (sp.data_length > 0 || sp.fin) {
                uint8_t frame_type = 0x08 | 0x02;
                if (sp.stream_offset > 0) frame_type |= 0x04;
                if (sp.fin) frame_type |= 0x01;
                std::vector<uint8_t> frame;
                frame.reserve(1 + 8 + 8 + 8 + sp.data_length);
                frame.push_back(frame_type);
                uint8_t vb[8]; size_t vl;
                vl = encodeVarInt(sp.stream_id, vb); frame.insert(frame.end(), vb, vb + vl);
                if (sp.stream_offset > 0) {
                    vl = encodeVarInt(sp.stream_offset, vb); frame.insert(frame.end(), vb, vb + vl);
                }
                vl = encodeVarInt(sp.data_length, vb); frame.insert(frame.end(), vb, vb + vl);
                frame.insert(frame.end(), sp.stream_data.begin(), sp.stream_data.end());

                // If this was originally sent as 0-RTT and still could be
                // (handshake not yet complete, early data not rejected),
                // retransmit it the same way — otherwise (handshake now
                // complete, or the server never confirmed 0-RTT) fall back
                // to the normal 1-RTT path. RFC 9001 forbids sending new
                // 0-RTT packets once 1-RTT keys are in use, so this must
                // never stay true past that point.
                bool retransmit_as_early = sp.sent_as_early_data && !_handshake_complete && !_early_data_rejected;
                uint64_t new_pn = _app_pn_send;
                std::vector<uint8_t> packet = retransmit_as_early ? buildZeroRTTPacket(frame) : buildShortHeaderPacket(frame);
                applyHeaderProtection(packet,
                                       retransmit_as_early ? EncryptionLevel::EarlyData : EncryptionLevel::Application,
                                       retransmit_as_early ? _early_keys.send : _app_keys.send);
                ssize_t sent = sendPacket(packet.data(), packet.size());
                if (sent < 0) sent = sendPacket(packet.data(), packet.size());
                if (sent > 0) {
                    recordSentPacket(new_pn, sp.stream_id, sp.stream_offset,
                                    sp.stream_data.data(), sp.stream_data.size(),
                                    sp.fin, packet.size(), retransmit_as_early);
                    // PTO firing is not itself a congestion signal (RFC 9002
                    // §6.2) — no cwnd/ssthresh change here. But old_pn's
                    // entry is gone from _sent_packets for good (erased
                    // above, superseded by new_pn), so its bytes must leave
                    // the in-flight count exactly once, and new_pn's bytes
                    // must enter it exactly once via onPacketSentCC() —
                    // otherwise _bytes_in_flight drifts every time a PTO
                    // probe fires, eventually clamping to 0 and disabling
                    // cwndAllowsSend()'s gate entirely.
                    if (_bytes_in_flight >= sp.sent_size) _bytes_in_flight -= sp.sent_size;
                    else _bytes_in_flight = 0;
                    onPacketSentCC(packet.size());
                    ++_pto_count;
                    // recordSentPacket() above already deep-copied
                    // sp.stream_data into new_pn's own pooled buffer —
                    // return this one's capacity to the pool too.
                    releaseSentDataBuffer(std::move(sp.stream_data));
                } else {
                    // Send failed (EAGAIN) — re-queue under original PN
                    // so the data is not permanently lost.  Reset sent_time
                    // to now and increment _pto_count so the next PTO check
                    // doesn't fire immediately (avoids hot spin-loop).
                    sp.sent_time = now;
                    ++_pto_count;
                    _sent_packets[old_pn] = std::move(sp);
                }
                QUIC_DBG("PTO probe: new_pn=%lu age=%.3fs pto=%.3fs",
                         (unsigned long)new_pn, age, pto_thresh);
            }
        }
    }

    // Normal loss detection requires at least one ACK
    if (_largest_acked_pn == UINT64_MAX) return;

    // Reset PTO back-off counter once ACKs are flowing
    _pto_count = 0;

    // Time-based loss threshold (RFC 9002 §6.1.2). Floor raised from 1ms to
    // 5ms: on loopback/same-host connections srtt/rttvar are tiny, but two
    // independently scheduled userspace threads (sender + receiver event
    // loop) routinely see >1ms scheduling jitter under load that is not
    // packet loss. At a 1ms floor that jitter was misclassified as loss,
    // halving cwnd (onPacketLostCC) for packets that were merely late —
    // which forces more send rounds, which gives the false-positive more
    // chances to fire again, compounding into a throughput collapse that
    // gets worse the larger the transfer.
    // RFC 9002 §6.1.2: kTimeThreshold * max(smoothed_rtt, latest_rtt) — not
    // _rttvar, which is a slow-moving variance estimate (0.25 weight per
    // update) and reacts far more sluggishly to a genuine RTT jump (path
    // change, mobile handover) than the actual latest sample does.
    double loss_delay = TIME_THRESHOLD * std::max(_srtt, _latest_rtt);
    if (loss_delay < 0.005) loss_delay = 0.005; // min 5ms
    auto loss_time = std::chrono::duration<double>(loss_delay);

    std::vector<SentPacket> lost;

    for (auto it = _sent_packets.begin(); it != _sent_packets.end(); ) {
        bool is_lost = false;

        // Packet-number-based detection: PN is PACKET_THRESHOLD below largest acked
        if (it->first + PACKET_THRESHOLD <= _largest_acked_pn) {
            is_lost = true;
        }

        // Time-based detection: packet was sent long enough ago
        if (!is_lost) {
            auto elapsed = std::chrono::duration<double>(now - it->second.sent_time);
            if (elapsed.count() >= loss_delay) {
                // Only declare lost if this pn < largest_acked (peer has received later packets)
                if (it->first < _largest_acked_pn) {
                    is_lost = true;
                }
            }
        }

        if (is_lost) {
            // Congestion control: notify CC about lost packet
            onPacketLostCC(it->second.sent_size, it->first);
            lost.push_back(std::move(it->second));
            it = _sent_packets.erase(it);
        } else {
            // Early exit: _sent_packets is ordered by PN, and PN order
            // tracks send order (retransmits always get a fresh, larger PN
            // — see the PTO/loss-retransmit blocks above), so once an
            // entry satisfies neither the PN-threshold nor the time-
            // threshold loss criterion, every later (larger-PN, more-
            // recently-sent) entry is at least as unlikely to qualify.
            // Continuing to scan them is pure wasted work in the
            // overwhelmingly common case of little/no loss.
            break;
        }
    }


    // Retransmit lost stream data with new packet numbers
    size_t retransmit_ok = 0, retransmit_fail = 0;
    for (auto& sp : lost) {
        if (sp.data_length == 0 && !sp.fin) continue;

        // Build STREAM frame for the lost data
        uint8_t frame_type = 0x08 | 0x02; // LEN bit
        if (sp.stream_offset > 0) frame_type |= 0x04; // OFF bit
        if (sp.fin) frame_type |= 0x01;

        std::vector<uint8_t> frame;
        frame.reserve(1 + 8 + 8 + 8 + sp.data_length);
        frame.push_back(frame_type);

        uint8_t varint_buf[8];
        size_t bytes = encodeVarInt(sp.stream_id, varint_buf);
        frame.insert(frame.end(), varint_buf, varint_buf + bytes);

        if (sp.stream_offset > 0) {
            bytes = encodeVarInt(sp.stream_offset, varint_buf);
            frame.insert(frame.end(), varint_buf, varint_buf + bytes);
        }

        bytes = encodeVarInt(sp.data_length, varint_buf);
        frame.insert(frame.end(), varint_buf, varint_buf + bytes);

        frame.insert(frame.end(), sp.stream_data.begin(), sp.stream_data.end());

        // New packet number for retransmit — see the PTO probe's identical
        // comment on retransmit_as_early above for why this can't just
        // always be a 1-RTT packet.
        bool retransmit_as_early = sp.sent_as_early_data && !_handshake_complete && !_early_data_rejected;
        uint64_t new_pn = _app_pn_send; // buildShortHeaderPacket/buildZeroRTTPacket will increment
        std::vector<uint8_t> packet = retransmit_as_early ? buildZeroRTTPacket(frame) : buildShortHeaderPacket(frame);
        applyHeaderProtection(packet,
                               retransmit_as_early ? EncryptionLevel::EarlyData : EncryptionLevel::Application,
                               retransmit_as_early ? _early_keys.send : _app_keys.send);

        ssize_t sent = sendPacket(packet.data(), packet.size());
        if (sent < 0) {
            sent = sendPacket(packet.data(), packet.size());
        }

        if (sent > 0) {
            // Track retransmitted packet under new PN
            recordSentPacket(new_pn, sp.stream_id, sp.stream_offset,
                            sp.stream_data.data(), sp.stream_data.size(), sp.fin, packet.size(),
                            retransmit_as_early);
            // sp's own bytes were already removed from _bytes_in_flight by
            // onPacketLostCC() above (that's the genuine congestion signal —
            // real loss, unlike the PTO path). new_pn is a distinct entry in
            // _sent_packets from here on, so its bytes must be counted via
            // onPacketSentCC() too, or _bytes_in_flight ends up permanently
            // short by packet.size() once new_pn is later acked/lost.
            onPacketSentCC(packet.size());
            ++retransmit_ok;
            // recordSentPacket() above already deep-copied sp.stream_data
            // into new_pn's own pooled buffer — return this one's capacity
            // to the pool too instead of letting `lost`'s destructor free it.
            releaseSentDataBuffer(std::move(sp.stream_data));
        } else {
            // Send failed (EAGAIN / send-buffer full) — re-insert under
            // the original PN so the packet is retried on the next
            // loss-detection cycle instead of being permanently lost.
            size_t saved_size = sp.sent_size;
            _sent_packets[sp.pn] = std::move(sp);
            _bytes_in_flight += saved_size;
            ++retransmit_fail;
        }

        QUIC_DBG("RETRANSMIT: old_pn=%lu new_pn=%lu sid=%lu off=%lu len=%zu fin=%d sent=%zd",
                 (unsigned long)sp.pn, (unsigned long)new_pn,
                 (unsigned long)sp.stream_id, (unsigned long)sp.stream_offset,
                 sp.data_length, (int)sp.fin, sent);
    }

}

// ============================================================================
// Stream Data Send (vector overload for application layer)
// ============================================================================

void quic::sendStreamData(uint64_t stream_id, const std::vector<uint8_t>& data, bool fin) {
    // Retry until all data is sent or connection is lost.
    // The size_t variant may return early when congestion control blocks;
    // we must loop to ensure the full message (including FIN) reaches the peer.
    size_t offset = 0;
    size_t remaining = data.size();
    int stall_count = 0;
    while (remaining > 0) {
        size_t sent = sendStreamData(stream_id, data.data() + offset, remaining, fin);
        if (sent == 0) {
            if (++stall_count > 50) {
                // Congestion control stall exceeded — data was not fully sent.
                // Throw so the caller knows the stream is incomplete.
                NetException exception;
                exception[NetException::Error] << "sendStreamData: congestion stall, "
                    << remaining << " of " << data.size() << " bytes unsent on stream " << stream_id;
                throw exception;
            }
            // Pump incoming to process ACKs and open congestion window
            pumpIncoming();
            continue;
        }
        stall_count = 0;
        offset += sent;
        remaining -= sent;
    }
    // Handle FIN-only (empty data with fin flag)
    if (data.empty() && fin) {
        sendStreamData(stream_id, data.data(), 0, true);
    }
}

// ============================================================================
// Flow Control: Send MAX_STREAM_DATA frame
// ============================================================================

void quic::sendMaxStreamData(uint64_t stream_id, uint64_t max_data) {
    std::vector<uint8_t> frame;
    frame.push_back(0x11);  // MAX_STREAM_DATA frame type
    uint8_t buf[8];
    size_t bytes = encodeVarInt(stream_id, buf);
    frame.insert(frame.end(), buf, buf + bytes);
    bytes = encodeVarInt(max_data, buf);
    frame.insert(frame.end(), buf, buf + bytes);

    // F20: batched — every call site is one of the repeated "flush pending
    // flow control" blocks, each already followed by a flushBatch() call,
    // so this never sits queued past the caller's own return.
    sendShortHeaderControlPacket(frame, /*batch=*/true);
}

// ============================================================================
// Flow Control: Send MAX_DATA frame
// ============================================================================

void quic::sendMaxData(uint64_t max_data) {
    std::vector<uint8_t> frame;
    frame.push_back(0x10);  // MAX_DATA frame type
    uint8_t buf[8];
    size_t bytes = encodeVarInt(max_data, buf);
    frame.insert(frame.end(), buf, buf + bytes);

    // F20: batched — see sendMaxStreamData()'s comment above.
    sendShortHeaderControlPacket(frame, /*batch=*/true);
}

// ============================================================================
// Flow Control: Send DATA_BLOCKED frame (RFC 9000 §19.12)
// ============================================================================

void quic::sendDataBlocked(uint64_t limit) {
    std::vector<uint8_t> frame;
    frame.push_back(0x14);  // DATA_BLOCKED frame type
    uint8_t buf[8];
    size_t bytes = encodeVarInt(limit, buf);
    frame.insert(frame.end(), buf, buf + bytes);

    sendShortHeaderControlPacket(frame);
}

// ============================================================================
// Flow Control: Send STREAM_DATA_BLOCKED frame (RFC 9000 §19.13)
// ============================================================================

void quic::sendStreamDataBlocked(uint64_t stream_id, uint64_t limit) {
    std::vector<uint8_t> frame;
    frame.push_back(0x15);  // STREAM_DATA_BLOCKED frame type
    uint8_t buf[8];
    size_t bytes = encodeVarInt(stream_id, buf);
    frame.insert(frame.end(), buf, buf + bytes);
    bytes = encodeVarInt(limit, buf);
    frame.insert(frame.end(), buf, buf + bytes);

    sendShortHeaderControlPacket(frame);
}

// ============================================================================
// ACK: Send ACK frame for 1-RTT application packets
// ============================================================================

void quic::sendAppAck() {
    // Real elapsed-time ACK Delay (RFC 9000 §13.2.5) instead of a
    // hardcoded 0, encoded in our own ack_delay_exponent's units — see
    // _app_ack_elicit_since's comment in socket.h.
    double elapsed_sec = std::chrono::duration<double>(
        std::chrono::steady_clock::now() - _app_ack_elicit_since).count();
    if (elapsed_sec < 0) elapsed_sec = 0;
    uint64_t ack_delay_encoded = static_cast<uint64_t>(elapsed_sec * 1000000.0) >> _ack_delay_exponent;

    std::vector<uint8_t> frame = buildAckFrame(_app_pn_recv, ack_delay_encoded, _app_pn_recv_ranges);
    sendShortHeaderControlPacket(frame);
}

// ============================================================================
// TLS Message Processing
// ============================================================================

void quic::processTLSMessages() {
    // Select the correct crypto buffer based on encryption level
    std::vector<uint8_t>* crypto_buffer = nullptr;
    switch (_current_enc_level) {
        case EncryptionLevel::Initial:
            crypto_buffer = &_crypto_recv_initial;
            break;
        case EncryptionLevel::Handshake:
            crypto_buffer = &_crypto_recv_handshake;
            break;
        case EncryptionLevel::Application:
            crypto_buffer = &_crypto_recv_app;
            break;
        case EncryptionLevel::EarlyData:
            break; // never valid — see processCryptoFrame()'s identical case
    }

    if (!crypto_buffer) return;

    // === HANDSHAKE TIMEOUT CHECK (EVENT-DRIVEN) ===
    // Check for incomplete ClientHello (missing fragments due to packet loss)
    // RFC 9001: ClientHello is typically 1528+ bytes
    // Only trigger timeout if we've received enough time (2+ seconds) with incomplete data and no new fragments
    if (_is_server && _current_enc_level == EncryptionLevel::Initial && crypto_buffer->size() > 0) {
        // Check if we have a complete ClientHello
        // ClientHello format: type(1) + length(3) + [body 35+ bytes]
        if (crypto_buffer->size() >= 4) {
            uint8_t msg_type = (*crypto_buffer)[0];
            uint32_t msg_len = (static_cast<uint32_t>((*crypto_buffer)[1]) << 16) |
                              (static_cast<uint32_t>((*crypto_buffer)[2]) << 8) |
                              static_cast<uint32_t>((*crypto_buffer)[3]);
            
            size_t required_bytes = 4 + msg_len;  // type + length + body
            size_t available_bytes = crypto_buffer->size();
            
            // Detect incomplete message: need significant gap (>200 bytes missing is likely packet loss)
            // Don't timeout on small gaps since fragments might still be arriving
            if (msg_type == 0x01 && available_bytes < required_bytes && 
                (required_bytes - available_bytes) > 200) {
                
                if (!_incomplete_ch_detected) {
                    _incomplete_ch_detected = true;
                    _handshake_start_time = std::chrono::steady_clock::now();
                }
                
                // Check if handshake timeout has been exceeded (2 seconds for fragmented messages)
                auto now = std::chrono::steady_clock::now();
                auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
                    now - _handshake_start_time).count();
                
                if (elapsed_ms > 2000 && !_handshake_timeout_triggered) {  // 2 second timeout for fragments
                    _handshake_timeout_triggered = true;
                    _conn_state.store(ConnectionState::Closed);
                    return;  // Exit without processing
                }
                
                // Still waiting for more fragments, don't process yet
                return;
            }
        }
    }
    
    // Process TLS handshake messages from crypto buffer
    
    while (crypto_buffer->size() >= 4) {
        uint8_t msg_type = (*crypto_buffer)[0];
        uint32_t msg_len = (static_cast<uint32_t>((*crypto_buffer)[1]) << 16) |
                          (static_cast<uint32_t>((*crypto_buffer)[2]) << 8) |
                          static_cast<uint32_t>((*crypto_buffer)[3]);
        
        
        if (crypto_buffer->size() < 4 + msg_len) {
            break;  // Wait for more data
        }
        
        // Extract complete message
        std::vector<uint8_t> msg(crypto_buffer->begin(),
                                  crypto_buffer->begin() + 4 + msg_len);
        crypto_buffer->erase(crypto_buffer->begin(),
                              crypto_buffer->begin() + 4 + msg_len);
        
        switch (msg_type) {
            case 0x02:  // ServerHello
                // Add to transcript (client receiving from server)
                _tls_transcript.insert(_tls_transcript.end(), msg.begin(), msg.end());
                processServerHello(msg);
                break;
            case 0x08:  // EncryptedExtensions
                // Add to transcript (client receiving from server)
                _tls_transcript.insert(_tls_transcript.end(), msg.begin(), msg.end());
                // Parse transport parameters (flow control limits, etc.)
                processEncryptedExtensions(msg);
                break;
            case 0x0b:  // Certificate
                // Add to transcript (client receiving from server)
                _tls_transcript.insert(_tls_transcript.end(), msg.begin(), msg.end());
                // Verify server certificate (simplified)
                break;
            case 0x0f:  // CertificateVerify
                // Add to transcript (client receiving from server)
                _tls_transcript.insert(_tls_transcript.end(), msg.begin(), msg.end());
                // Verify signature (simplified)
                break;
            case 0x14:  // Finished
                processFinished(msg);
                // Clear transcript after Finished is processed
                // Transcript is no longer needed after handshake completion
                _tls_transcript.clear();
                break;
            case 0x01:  // ClientHello
                if (_is_server) {
                    // Add to transcript (server receiving from client)
                    _tls_transcript.insert(_tls_transcript.end(), msg.begin(), msg.end());
                    processClientHello(msg);
                }
                break;
            case 0x04:  // NewSessionTicket (post-handshake, Application level)
                if (!_is_server) {
                    // Not part of any transcript hash — issued strictly
                    // after resumption_master_secret is already fixed.
                    processNewSessionTicket(msg);
                }
                break;
            default:
                break;
        }
    }
}

// ============================================================================
// TLS: Process ServerHello
// ============================================================================

void quic::processServerHello(const std::vector<uint8_t>& msg) {
    if (msg.size() < 39) return;  // Minimum ServerHello size
    
    // Note: Message is ALREADY added to transcript in processTLSMessages()
    // Only process the message content here, not the raw bytes
    
    // Skip to extensions
    size_t offset = 4;  // Skip type + length
    offset += 2;  // Legacy version
    
    // Server random
    _server_random.assign(msg.begin() + offset, msg.begin() + offset + 32);
    offset += 32;
    
    // Session ID length + session ID
    uint8_t session_id_len = msg[offset++];
    offset += session_id_len;
    
    // Cipher suite - parse and store the selected cipher
    if (offset + 2 > msg.size()) return;
    _selected_cipher = (static_cast<uint16_t>(msg[offset]) << 8) | msg[offset + 1];
    offset += 2;
    
    // Compression method
    offset += 1;
    
    // Extensions length
    if (offset + 2 > msg.size()) return;
    uint16_t ext_len = (static_cast<uint16_t>(msg[offset]) << 8) | msg[offset + 1];
    offset += 2;
    
    // Parse extensions to find key_share
    size_t ext_end = offset + ext_len;
    while (offset + 4 <= ext_end && offset + 4 <= msg.size()) {
        uint16_t ext_type = (static_cast<uint16_t>(msg[offset]) << 8) | msg[offset + 1];
        uint16_t ext_data_len = (static_cast<uint16_t>(msg[offset + 2]) << 8) | msg[offset + 3];
        offset += 4;
        
        if (ext_type == 0x0033 && ext_data_len >= 36) {  // key_share
            // Named group (2 bytes)
            offset += 2;
            // Key length (2 bytes)
            offset += 2;
            // Client public key (32 bytes for x25519)
            if (offset + 32 <= msg.size()) {
                std::vector<uint8_t> client_pub(msg.begin() + offset, msg.begin() + offset + 32);
                
                
                
                // Compute shared secret
                _ecdhe_shared.resize(32);
                std::vector<uint8_t> priv_vec(_ecdhe_private.begin(), _ecdhe_private.end());
                scalarmult_curve25519(_ecdhe_shared, priv_vec, client_pub);
                
                
                // Derive handshake keys
                deriveHandshakeKeys(_ecdhe_shared);
            }
            break;
        }
        
        offset += ext_data_len;
    }
}

// ============================================================================
// TLS: Parse QUIC Transport Parameters (RFC 9000 §18)
// ============================================================================

void quic::parseTransportParameters(const uint8_t* data, size_t len) {
    // Peer's claimed original_destination_connection_id (0x00) and
    // retry_source_connection_id (0x10) — only meaningful coming from a
    // server, cross-checked against what we actually observed once parsing
    // finishes (RFC 9000 §7.3). initial_source_connection_id (0x0f) is
    // parsed for completeness but not currently cross-checked — that
    // would need correlating against the SCID actually seen on the wire
    // for each role, which isn't otherwise tracked yet; the Retry-specific
    // checks below are what defends against off-path Retry/version
    // injection, which is what this feature is actually for.
    std::vector<uint8_t> peer_original_dcid;
    bool has_peer_original_dcid = false;
    std::vector<uint8_t> peer_retry_scid;
    bool has_peer_retry_scid = false;

    size_t offset = 0;
    while (offset < len) {
        size_t bytes;
        uint64_t param_id = decodeVarInt(&data[offset], len - offset, bytes);
        if (bytes == 0) break;  // truncated — stop, don't misparse trailing bytes
        offset += bytes;

        if (offset >= len) break;
        uint64_t param_len = decodeVarInt(&data[offset], len - offset, bytes);
        if (bytes == 0) break;
        offset += bytes;

        if (param_len > len - offset) break;  // declared length exceeds what's left

        // Inner values are still read with `len - offset` as the bound
        // (not param_len): a value whose own varint encoding claims more
        // bytes than param_len declared must not be allowed to read past
        // the actual buffer end just because param_len said it was
        // shorter — the outer loop always resyncs via `offset += param_len`
        // regardless of what the inner read actually consumed.
        switch (param_id) {
            case 0x04: {  // initial_max_data
                size_t vb;
                uint64_t v = decodeVarInt(&data[offset], len - offset, vb);
                if (vb != 0) _max_data_remote = v;
                break;
            }
            case 0x05: {  // initial_max_stream_data_bidi_local
                size_t vb;
                uint64_t v = decodeVarInt(&data[offset], len - offset, vb);
                if (vb != 0) _peer_initial_max_stream_data_bidi_local = v;
                break;
            }
            case 0x06: {  // initial_max_stream_data_bidi_remote
                size_t vb;
                uint64_t v = decodeVarInt(&data[offset], len - offset, vb);
                if (vb != 0) _peer_initial_max_stream_data_bidi_remote = v;
                break;
            }
            case 0x07: {  // initial_max_stream_data_uni
                size_t vb;
                uint64_t v = decodeVarInt(&data[offset], len - offset, vb);
                if (vb != 0) _peer_initial_max_stream_data_uni = v;
                break;
            }
            case 0x08: {  // initial_max_streams_bidi
                size_t vb;
                uint64_t v = decodeVarInt(&data[offset], len - offset, vb);
                if (vb != 0) _max_streams_bidi_remote = v;
                break;
            }
            case 0x09: {  // initial_max_streams_uni
                size_t vb;
                uint64_t v = decodeVarInt(&data[offset], len - offset, vb);
                if (vb != 0) _max_streams_uni_remote = v;
                break;
            }
            case 0x01: {  // max_idle_timeout
                size_t vb;
                uint64_t peer_timeout = decodeVarInt(&data[offset], len - offset, vb);
                // Use the smaller of our timeout and peer's
                if (vb != 0 && peer_timeout > 0 && peer_timeout < _max_idle_timeout) {
                    _max_idle_timeout = peer_timeout;
                }
                break;
            }
            case 0x03: {  // max_udp_payload_size — the peer's receive
                          // limit: a hard ceiling on what we may ever
                          // send them, but never proof the *path* can
                          // actually carry that size (that's what
                          // pmtudTick()'s probing establishes before
                          // raising _max_udp_payload past the baseline).
                size_t vb;
                uint64_t peer_max_payload = decodeVarInt(&data[offset], len - offset, vb);
                if (vb != 0) {
                    if (peer_max_payload < 1200)
                        peer_max_payload = 1200;  // RFC 9000 §18.2 floor; ignore bogus/hostile values below it
                    _pmtud_peer_ceiling = peer_max_payload;
                    // Only ever shrink the confirmed send size here — never
                    // grow it. Growing requires an actual probe (see
                    // pmtudOnProbeAcked); this just protects a size we may
                    // already be using from exceeding what the peer accepts.
                    if (_pmtud_peer_ceiling < _max_udp_payload)
                        _max_udp_payload = _pmtud_peer_ceiling;
                }
                break;
            }
            case 0x00: {  // original_destination_connection_id
                peer_original_dcid.assign(data + offset, data + offset + param_len);
                has_peer_original_dcid = true;
                break;
            }
            case 0x10: {  // retry_source_connection_id
                peer_retry_scid.assign(data + offset, data + offset + param_len);
                has_peer_retry_scid = true;
                break;
            }
            case 0x0a: {  // ack_delay_exponent — how the peer itself
                          // scales the ACK Delay field on ACK frames *it*
                          // sends us (RFC 9000 §13.2.5, §18.2); applied in
                          // processAckFrame(), never to our own outgoing
                          // ACKs (those use our own _ack_delay_exponent).
                size_t vb;
                uint64_t v = decodeVarInt(&data[offset], len - offset, vb);
                // RFC 9000 §18.2: values above 20 are invalid; ignore and
                // keep the default rather than let a malformed/hostile
                // value produce a nonsensical (2^v) shift later.
                if (vb != 0 && v <= 20) _peer_ack_delay_exponent = v;
                break;
            }
            default:
                // Skip unknown parameters
                break;
        }
        offset += param_len;
    }

    // RFC 9000 §7.3: a client MUST validate these against what it actually
    // observed — an off-path attacker forging a Retry can't also complete
    // the TLS handshake using keys tied to the true original DCID, but this
    // catches a mismatch explicitly and early rather than relying on that
    // alone. Servers don't receive either parameter from a real client
    // (only servers send them), so this only ever runs client-side.
    if (!_is_server) {
        bool mismatch = false;
        if (!has_peer_original_dcid || peer_original_dcid != _original_dcid) {
            mismatch = true;
        }
        if (_retry_received && (!has_peer_retry_scid || peer_retry_scid != _retry_scid)) {
            mismatch = true;
        }
        if (mismatch) {
            std::vector<uint8_t> close_frame = buildConnectionCloseFrame(
                0x08 /* TRANSPORT_PARAMETER_ERROR */, "connection ID transport parameter mismatch");
            std::vector<uint8_t> packet = buildHandshakePacket(close_frame);
            applyHeaderProtection(packet, EncryptionLevel::Handshake, _hs_keys.send);
            sendPacket(packet.data(), packet.size());
            _conn_state.store(ConnectionState::Draining);
        }
    }
}

// ============================================================================
// TLS: Process EncryptedExtensions (Client side - contains transport params)
// ============================================================================

void quic::processEncryptedExtensions(const std::vector<uint8_t>& msg) {
    if (msg.size() < 6) return;  // Type(1) + Length(3) + ExtLen(2) minimum
    
    size_t offset = 4;  // Skip type(1) + length(3)
    
    // Extensions length (2 bytes)
    if (offset + 2 > msg.size()) return;
    uint16_t ext_total_len = (static_cast<uint16_t>(msg[offset]) << 8) | msg[offset + 1];
    offset += 2;
    
    size_t ext_end = offset + ext_total_len;
    if (ext_end > msg.size()) ext_end = msg.size();
    
    while (offset + 4 <= ext_end) {
        uint16_t ext_type = (static_cast<uint16_t>(msg[offset]) << 8) | msg[offset + 1];
        uint16_t ext_data_len = (static_cast<uint16_t>(msg[offset + 2]) << 8) | msg[offset + 3];
        offset += 4;
        
        if (offset + ext_data_len > ext_end) break;
        
        if (ext_type == 0x0039) {  // quic_transport_parameters
            parseTransportParameters(&msg[offset], ext_data_len);
        } else if (ext_type == 0x002a) {  // early_data — server confirming 0-RTT acceptance
            _early_data_accepted = true;
        }

        offset += ext_data_len;
    }
}

// ============================================================================
// TLS: Process ClientHello (Server side)
// ============================================================================

void quic::processClientHello(const std::vector<uint8_t>& msg) {
    if (!_is_server) return;
    if (msg.size() < 39) return;  // Minimum ClientHello size
    QUIC_DBG("processClientHello: msg_size=%zu", msg.size());
    
    
    // Note: Message is ALREADY added to transcript in processTLSMessages()
    // Only process the message content here, not the raw bytes
    
    // Parse ClientHello
    size_t offset = 4;  // Skip type + length
    offset += 2;  // Legacy version
    
    // Client random
    _client_random.assign(msg.begin() + offset, msg.begin() + offset + 32);
    offset += 32;
    
    // Session ID - save for ServerHello echo
    uint8_t session_id_len = msg[offset++];
    if (session_id_len > 0 && offset + session_id_len <= msg.size()) {
        _client_session_id.assign(msg.begin() + offset, msg.begin() + offset + session_id_len);
    } else {
        _client_session_id.clear();
    }
    offset += session_id_len;
    
    // Cipher suites - parse and select best supported
    if (offset + 2 > msg.size()) return;
    uint16_t cipher_suites_len = (static_cast<uint16_t>(msg[offset]) << 8) | msg[offset + 1];
    offset += 2;
    
    // Parse cipher suites and select preferred (prefer AES-256-GCM with SHA-384)
    bool found_aes256 = false;
    bool found_aes128 = false;
    for (size_t i = 0; i < cipher_suites_len && offset + i + 1 < msg.size(); i += 2) {
        uint16_t cs = (static_cast<uint16_t>(msg[offset + i]) << 8) | msg[offset + i + 1];
        if (cs == 0x1302) found_aes256 = true;  // TLS_AES_256_GCM_SHA384
        if (cs == 0x1301) found_aes128 = true;  // TLS_AES_128_GCM_SHA256
    }
    
    // Select cipher suite - prefer AES-256
     if (found_aes256) {
        _selected_cipher = 0x1302;  // TLS_AES_256_GCM_SHA384
    } else if (found_aes128) {
        _selected_cipher = 0x1301;  // TLS_AES_128_GCM_SHA256
    } else {
        return;
    }
    
    offset += cipher_suites_len;
    
    // Compression methods
    if (offset >= msg.size()) return;
    uint8_t comp_len = msg[offset++];
    offset += comp_len;
    
    // Extensions length
    if (offset + 2 > msg.size()) return;
    uint16_t ext_len = (static_cast<uint16_t>(msg[offset]) << 8) | msg[offset + 1];
    offset += 2;
    
    // Parse extensions to find key_share and SNI
    size_t ext_end = offset + ext_len;
    std::vector<uint8_t> client_key_share;

    // 0-RTT offer, parsed alongside the other extensions below (see the
    // accept/reject block right after this loop for what happens with them).
    bool client_requested_early_data = false;
    std::vector<uint8_t> psk_identity;           // the ticket blob the client offered
    std::vector<uint8_t> psk_binder;             // its claimed binder value
    size_t psk_binder_truncation_offset = SIZE_MAX; // offset in `msg` of the binders-vector length field

    while (offset + 4 <= ext_end && offset + 4 <= msg.size()) {
        uint16_t ext_type = (static_cast<uint16_t>(msg[offset]) << 8) | msg[offset + 1];
        uint16_t ext_data_len = (static_cast<uint16_t>(msg[offset + 2]) << 8) | msg[offset + 3];
        offset += 4;
        
        if (offset + ext_data_len > msg.size()) break;
        
        if (ext_type == 0x0010 && ext_data_len > 2) {  // ALPN
            // Parse ALPN list
            size_t alpn_off = offset;
            uint16_t alpn_list_len = (static_cast<uint16_t>(msg[alpn_off]) << 8) | msg[alpn_off + 1];
            alpn_off += 2;
            
            // Parse first protocol in list (we'll use h3 if available)
            size_t alpn_end = alpn_off + alpn_list_len;
            while (alpn_off + 1 <= alpn_end && alpn_off + 1 <= offset + ext_data_len) {
                uint8_t proto_len = msg[alpn_off++];
                if (alpn_off + proto_len <= alpn_end && alpn_off + proto_len <= offset + ext_data_len) {
                    std::string proto(msg.begin() + alpn_off, msg.begin() + alpn_off + proto_len);
                    if (proto == "h3" || proto == "h3-29") {
                        _selected_alpn = proto;
                        break;  // Use first h3 protocol found
                    }
                    alpn_off += proto_len;
                }
            }
        } else if (ext_type == 0x0000 && ext_data_len > 5) {  // SNI
            // Parse SNI to select certificate
            size_t sni_off = offset + 2;  // Skip list length
            if (sni_off + 3 <= offset + ext_data_len) {
                uint8_t name_type = msg[sni_off++];
                uint16_t name_len = (static_cast<uint16_t>(msg[sni_off]) << 8) | msg[sni_off + 1];
                sni_off += 2;
                
                if (name_type == 0x00 && sni_off + name_len <= offset + ext_data_len) {
                    std::string hostname(msg.begin() + sni_off, msg.begin() + sni_off + name_len);
                    
                    // Store the requested hostname for SNI support
                    _requested_hostname = hostname;
                    
                    // Select certificate based on hostname
                    auto it = _cert_map.find(hostname);
                    if (it != _cert_map.end()) {
                        _selected_cert = &it->second;
                    } else if (!_cert_map.empty()) {
                        // Use first certificate as default
                        _selected_cert = &_cert_map.begin()->second;
                    }
                }
            }
        } else if (ext_type == 0x000d && ext_data_len >= 2) {  // signature_algorithms
            size_t sig_off = offset;
            uint16_t sig_list_len = (static_cast<uint16_t>(msg[sig_off]) << 8) | msg[sig_off + 1];
            sig_off += 2;
            size_t sig_end = std::min(sig_off + sig_list_len, offset + ext_data_len);

            _sigalg_rsa_pss_sha256 = false;
            _sigalg_rsa_pss_sha384 = false;

            while (sig_off + 1 < sig_end && sig_off + 1 < msg.size()) {
                uint16_t sig = (static_cast<uint16_t>(msg[sig_off]) << 8) | msg[sig_off + 1];
                if (sig == 0x0804) _sigalg_rsa_pss_sha256 = true;  // rsa_pss_rsae_sha256
                if (sig == 0x0805) _sigalg_rsa_pss_sha384 = true;  // rsa_pss_rsae_sha384
                sig_off += 2;
            }
        } else if (ext_type == 0x0033) {  // key_share
            // Parse client key shares
            if (ext_data_len >= 4) {
                size_t ks_off = offset;
                uint16_t ks_len = (static_cast<uint16_t>(msg[ks_off]) << 8) | msg[ks_off + 1];
                ks_off += 2;
                
                // Parse key share entries
                size_t ks_end = ks_off + ks_len;
                while (ks_off + 4 <= ks_end && ks_off + 4 <= msg.size()) {
                    uint16_t group = (static_cast<uint16_t>(msg[ks_off]) << 8) | msg[ks_off + 1];
                    uint16_t key_len = (static_cast<uint16_t>(msg[ks_off + 2]) << 8) | msg[ks_off + 3];
                    ks_off += 4;
                    
                    if (group == 0x001d && key_len == 32) {  // x25519
                        client_key_share.assign(msg.begin() + ks_off, msg.begin() + ks_off + 32);
                        break;
                    }
                    
                    ks_off += key_len;
                }
            }
        } else if (ext_type == 0x0039) {  // quic_transport_parameters
            parseTransportParameters(&msg[offset], ext_data_len);
        } else if (ext_type == 0x002a) {  // early_data — presence is the whole signal
            client_requested_early_data = true;
        } else if (ext_type == 0x0029 && ext_data_len >= 2) {  // pre_shared_key
            // Only the FIRST offered identity is used — this
            // implementation never offers/accepts more than one (see
            // buildClientHello()).
            size_t psk_off = offset;
            if (psk_off + 2 <= offset + ext_data_len && psk_off + 2 <= msg.size()) {
                uint16_t identities_len = (static_cast<uint16_t>(msg[psk_off]) << 8) | msg[psk_off + 1];
                size_t id_off = psk_off + 2;
                size_t identities_end = id_off + identities_len;
                if (id_off + 2 <= identities_end && id_off + 2 <= msg.size()) {
                    uint16_t identity_len = (static_cast<uint16_t>(msg[id_off]) << 8) | msg[id_off + 1];
                    size_t identity_data_off = id_off + 2;
                    if (identity_data_off + identity_len + 4 <= identities_end &&
                        identity_data_off + identity_len + 4 <= msg.size()) {
                        psk_identity.assign(msg.begin() + identity_data_off,
                                             msg.begin() + identity_data_off + identity_len);

                        // Binders vector starts right after the identities
                        // vector — this offset (not the binder value's own
                        // position) is what the binder HMAC transcript is
                        // truncated at (RFC 8446 §4.2.11.2).
                        size_t binders_len_off = psk_off + 2 + identities_len;
                        if (binders_len_off + 3 <= offset + ext_data_len && binders_len_off + 3 <= msg.size()) {
                            psk_binder_truncation_offset = binders_len_off;
                            uint8_t binder_len = msg[binders_len_off + 2];
                            size_t binder_val_off = binders_len_off + 3;
                            if (binder_val_off + binder_len <= msg.size()) {
                                psk_binder.assign(msg.begin() + binder_val_off,
                                                   msg.begin() + binder_val_off + binder_len);
                            }
                        }
                    }
                }
            }
        }

        offset += ext_data_len;
    }

    // Accept or decline the client's PSK + early_data offer, if any. A
    // rejection at any step below is silent — the handshake just proceeds
    // exactly as if no ticket had been offered (RFC 8446 §4.2.11: a server
    // that doesn't recognize/accept a PSK identity simply omits
    // pre_shared_key from its ServerHello).
    _early_data_accepted = false;
    if (client_requested_early_data && !psk_identity.empty() && !psk_binder.empty() &&
        psk_binder_truncation_offset != SIZE_MAX) {
        ParsedTicket pt = validateSessionTicket(psk_identity);
        // RFC 8446 §4.2.11: a PSK's hash must match the negotiated cipher
        // suite's hash — don't try to reconcile a mismatch, just decline.
        if (pt.valid && pt.cipher_suite == _selected_cipher) {
            const bool use_sha384 = (pt.cipher_suite == 0x1302);
            const size_t hash_len = use_sha384 ? 48 : 32;
            if (psk_binder.size() == hash_len) {
                std::vector<uint8_t> truncated(msg.begin(), msg.begin() + psk_binder_truncation_offset);
                std::vector<uint8_t> ch_hash = use_sha384 ? sha384_hash(truncated) : sha256_hash(truncated);
                std::vector<uint8_t> empty_hash = use_sha384 ? sha384_hash({}) : sha256_hash({});
                std::vector<uint8_t> early_secret = netplus::tls::hkdf_extract({}, pt.psk, pt.cipher_suite);
                std::vector<uint8_t> binder_key = netplus::tls::hkdf_expand_label(
                    early_secret, "res binder", empty_hash, hash_len, pt.cipher_suite);
                std::vector<uint8_t> finished_key = netplus::tls::hkdf_expand_label(
                    binder_key, "finished", {}, hash_len, pt.cipher_suite);
                std::vector<uint8_t> expected_binder = use_sha384
                    ? hmac_sha384(finished_key, ch_hash)
                    : hmac_sha256(finished_key, ch_hash);

                if (constantTimeEqual(expected_binder.data(), psk_binder.data(), hash_len)) {
                    // Anti-replay: this ticket's PSK is single-use for
                    // early data (RFC 8446 §8.1) — check-and-mark under the
                    // listener's registry lock, same bounded-sweep-on-lookup
                    // pattern as _recent_retries.
                    quic* listener = _parent ? _parent : this;
                    std::string id_key(pt.ticket_id.begin(), pt.ticket_id.end());
                    bool already_used = false;
                    {
                        auto now = std::chrono::steady_clock::now();
                        std::unique_lock<netplus::WriterPreferringSharedMutex> reg_lock(listener->_registry_mutex);
                        for (auto it = listener->_used_ticket_ids.begin(); it != listener->_used_ticket_ids.end(); ) {
                            if (now > it->second) it = listener->_used_ticket_ids.erase(it);
                            else ++it;
                        }
                        if (listener->_used_ticket_ids.count(id_key)) {
                            already_used = true;
                        } else {
                            // Bound the entry's lifetime to the ticket's own
                            // lifetime ceiling generateSessionTicket() uses
                            // (see sendNewSessionTicket()) — no need to
                            // remember it any longer than the ticket itself
                            // could ever be presented.
                            listener->_used_ticket_ids[id_key] = now + std::chrono::seconds(3600);
                        }
                    }
                    if (!already_used) {
                        _early_data_psk = pt.psk;
                        _early_data_cipher_suite = pt.cipher_suite;
                        // _tls_transcript == ClientHello exactly at this
                        // point (see processTLSMessages()'s case 0x01).
                        deriveEarlyDataKeys(_early_data_psk, /*is_send=*/false);
                        _early_data_accepted = true;
                    }
                }
            }
        }
    }

    // Generate server ECDHE key pair
    generateECDHEKeyPair();
    
    // Compute shared secret
    if (!client_key_share.empty()) {
        computeSharedSecret(client_key_share);
    }
    
    // Select default certificate if none selected via SNI
    if (_selected_cert == nullptr && !_cert_map.empty()) {
        _selected_cert = &_cert_map.begin()->second;
    }
    
    // Send ServerHello and continue handshake
    sendServerHello();
}

// ============================================================================
// TLS: Process Finished
// ============================================================================

void quic::processFinished(const std::vector<uint8_t>& msg) {
    QUIC_DBG("processFinished: is_server=%d", (int)_is_server);

    const bool use_sha384 = (_selected_cipher == 0x1302);  // TLS_AES_256_GCM_SHA384
    const size_t hash_len = use_sha384 ? 48 : 32;
    const uint16_t cipher_suite = use_sha384 ? 0x1302 : 0x1301;

    // Verify the peer's verify_data BEFORE touching the transcript — it is
    // computed over the transcript *excluding* this Finished message itself
    // (mirrors sendClientFinished()'s send-side ordering, which hashes
    // _tls_transcript before appending its own Finished to it).
    //   finished_key = HKDF-Expand-Label(peer_handshake_traffic_secret, "finished", "", hash_len)
    //   expected     = HMAC(finished_key, Transcript-Hash(_tls_transcript))
    // where peer_handshake_traffic_secret is the *other* side's secret —
    // both _c_hs_traffic_secret and _s_hs_traffic_secret are already
    // populated on both roles by deriveHandshakeKeys().
    bool verified = false;
    if (msg.size() >= 4 + hash_len) {
        const std::vector<uint8_t>& peer_secret = _is_server ? _c_hs_traffic_secret : _s_hs_traffic_secret;
        std::vector<uint8_t> finished_key = netplus::tls::hkdf_expand_label(
            peer_secret, "finished", {}, hash_len, cipher_suite);
        std::vector<uint8_t> transcript_hash = use_sha384
            ? sha384_hash(_tls_transcript)
            : sha256_hash(_tls_transcript);
        std::vector<uint8_t> expected = use_sha384
            ? hmac_sha384(finished_key, transcript_hash)
            : hmac_sha256(finished_key, transcript_hash);
        verified = constantTimeEqual(expected.data(), msg.data() + 4, hash_len);
    }

    if (!verified) {
        QUIC_DBG("processFinished: verify_data MISMATCH — closing connection");
        // RFC 9000 §20.1 CRYPTO_ERROR space: 0x0100 + TLS alert; 20 =
        // bad_record_mac, the closest-fit alert for a failed MAC check.
        std::vector<uint8_t> close_frame = buildConnectionCloseFrame(0x0100 + 20, "bad_finished_mac");
        std::vector<uint8_t> packet = buildHandshakePacket(close_frame);
        applyHeaderProtection(packet, EncryptionLevel::Handshake, _hs_keys.send);
        if (_Socket >= 0) sendPacket(packet.data(), packet.size());
        _conn_state.store(ConnectionState::Closed);
        return;
    }

    // Verified — safe to fold into the transcript now (needed below for
    // resumption_master_secret, which is defined over the transcript
    // through and including the client's Finished).
    _tls_transcript.insert(_tls_transcript.end(), msg.begin(), msg.end());

    if (!_is_server) {
        // Client received server's Finished
        // Derive application keys and send our Finished
        deriveApplicationKeys();

        // Send client Finished — appends the client's own Finished to
        // _tls_transcript before returning.
        sendClientFinished();

        // resumption_master_secret (RFC 8446 §7.1) — the client computes
        // the exact same value the server will, so it can independently
        // re-derive whatever PSK a later NewSessionTicket's ticket_nonce
        // asks for (see processNewSessionTicket()).
        std::vector<uint8_t> client_res_transcript_hash = use_sha384
            ? sha384_hash(_tls_transcript)
            : sha256_hash(_tls_transcript);
        _resumption_master_secret = netplus::tls::hkdf_expand_label(
            _master_secret, "res master", client_res_transcript_hash, hash_len, cipher_suite);

        _handshake_complete = true;
        _conn_state.store(ConnectionState::Connected);

        // 0-RTT was offered but never confirmed by the server (see
        // processEncryptedExtensions()) — stop attempting to send more
        // early data. Any already-sent-but-unacked early bytes still get
        // retransmitted, now at 1-RTT, by the existing loss-detection path
        // (see checkLossAndRetransmit()'s branch on SentPacket::sent_as_early_data).
        if (_early_data_offered && !_early_data_accepted) {
            _early_data_rejected = true;
        }
    } else {
        // Server received client's Finished
        _handshake_complete = true;
        _conn_state.store(ConnectionState::Connected);

        // Handshake complete - no need to retransmit anymore
        _hs_flight_packets.clear();

        // resumption_master_secret (RFC 8446 §7.1), for session tickets —
        // Derive-Secret(master_secret, "res master", transcript through the
        // client's Finished, i.e. _tls_transcript as of right now).
        std::vector<uint8_t> res_transcript_hash = use_sha384
            ? sha384_hash(_tls_transcript)
            : sha256_hash(_tls_transcript);
        _resumption_master_secret = netplus::tls::hkdf_expand_label(
            _master_secret, "res master", res_transcript_hash, hash_len, cipher_suite);

        // Send HANDSHAKE_DONE frame in 1-RTT packet
        sendHandshakeDone();
    }
}

// ============================================================================
// TLS: Send Client Finished
// ============================================================================

void quic::sendClientFinished() {
    // Build Finished message
    std::vector<uint8_t> finished;
    finished.push_back(0x14);  // Finished type
    
    // Compute verify_data using cipher-appropriate hash:
    // finished_key = HKDF-Expand-Label(client_handshake_traffic_secret, "finished", "", hash_len)
    // verify_data = HMAC(finished_key, Transcript-Hash)
    const bool use_sha384 = (_selected_cipher == 0x1302);  // TLS_AES_256_GCM_SHA384
    const size_t hash_len = use_sha384 ? 48 : 32;
    const uint16_t cipher_suite = use_sha384 ? 0x1302 : 0x1301;
    
    // Use TLS class HKDF method
    std::vector<uint8_t> finished_key = netplus::tls::hkdf_expand_label(
        _c_hs_traffic_secret, "finished", {}, hash_len, cipher_suite);
    
    // Compute transcript hash and verify_data
    std::vector<uint8_t> transcript_hash = use_sha384 
        ? sha384_hash(_tls_transcript)
        : sha256_hash(_tls_transcript);
    std::vector<uint8_t> verify_data = use_sha384
        ? hmac_sha384(finished_key, transcript_hash)
        : hmac_sha256(finished_key, transcript_hash);
    
    // Length (3 bytes) - 48 bytes for SHA-384, 32 bytes for SHA-256
    finished.push_back(0x00);
    finished.push_back(0x00);
    finished.push_back(static_cast<uint8_t>(hash_len));
    
    // Add verify_data
    finished.insert(finished.end(), verify_data.begin(), verify_data.end());
    
    // Add to transcript BEFORE sending
    _tls_transcript.insert(_tls_transcript.end(), finished.begin(), finished.end());
    
    // Send in Handshake packet
    std::vector<uint8_t> crypto_frame = buildCryptoFrame(finished, _crypto_send_handshake.size());
    _crypto_send_handshake.insert(_crypto_send_handshake.end(), finished.begin(), finished.end());
    
    std::vector<uint8_t> packet = buildHandshakePacket(crypto_frame);
    applyHeaderProtection(packet, EncryptionLevel::Handshake, _hs_keys.send);
    
    if (_Socket >= 0) {
        sendPacket(packet.data(), packet.size());
    }
}

// ============================================================================
// QUIC: Send HANDSHAKE_DONE frame (Server only)
// ============================================================================

void quic::sendHandshakeDone() {
    if (!_is_server) return;
    QUIC_DBG("sendHandshakeDone: sending HANDSHAKE_DONE");
    
    
    // HANDSHAKE_DONE frame is a single byte: 0x1e
    std::vector<uint8_t> frame;
    frame.push_back(0x1e);  // HANDSHAKE_DONE frame type
    
    // Build and send 1-RTT (short header) packet
    std::vector<uint8_t> packet = buildShortHeaderPacket(frame);
    
    // Apply header protection using server's application HP key
    applyHeaderProtection(packet, EncryptionLevel::Application, _app_keys.send);
    
    
    if (_Socket >= 0) {
        sendPacket(packet.data(), packet.size());
    }

    // Let this client skip Retry on a future reconnect (RFC 9000 §8.1.3) —
    // it already proved address ownership for *this* connection.
    sendNewTokenFrame();

    // Let this client attempt 0-RTT on a future reconnect (RFC 8446 §4.6.1).
    sendNewSessionTicket();

    // If ALPN is HTTP/3, send the mandatory control streams (RFC 9114 §6.2.1)
    if (_selected_alpn == "h3" || _selected_alpn == "h3-29") {
        sendControlStreams();
    }
}

// ============================================================================
// QUIC: Send NEW_TOKEN frame (Server only, RFC 9000 §8.1.3)
// ============================================================================

void quic::sendNewTokenFrame() {
    if (!_is_server) return;

    std::vector<uint8_t> token = generateToken(false, {}, _Addr, _AddrLen);
    if (token.empty()) return; // unsupported peer address family

    std::vector<uint8_t> frame;
    frame.push_back(0x07); // NEW_TOKEN frame type
    uint8_t len_buf[8];
    size_t len_size = encodeVarInt(token.size(), len_buf);
    frame.insert(frame.end(), len_buf, len_buf + len_size);
    frame.insert(frame.end(), token.begin(), token.end());

    std::vector<uint8_t> packet = buildShortHeaderPacket(frame);
    applyHeaderProtection(packet, EncryptionLevel::Application, _app_keys.send);
    if (_Socket >= 0) {
        sendPacket(packet.data(), packet.size());
    }
}

// ============================================================================
// TLS: Send NewSessionTicket (Server only, RFC 8446 §4.6.1 / RFC 9001 §4.6.1)
// ============================================================================

void quic::sendNewSessionTicket() {
    if (!_is_server) return;
    if (_resumption_master_secret.empty()) return; // shouldn't happen post-handshake

    const bool use_sha384 = (_selected_cipher == 0x1302);
    const size_t hash_len = use_sha384 ? 48 : 32;
    const uint16_t cipher_suite = use_sha384 ? 0x1302 : 0x1301;

    // ticket_nonce doubles as generateSessionTicket()'s ticket_id (16 random
    // bytes) — both a PSK-derivation input and the anti-replay lookup key,
    // one fewer field to invent.
    std::vector<uint8_t> ticket_nonce(16);
    fillRandomBytes(ticket_nonce.data(), ticket_nonce.size());

    // PSK = HKDF-Expand-Label(resumption_master_secret, "resumption", ticket_nonce, hash_len)
    std::vector<uint8_t> psk = netplus::tls::hkdf_expand_label(
        _resumption_master_secret, "resumption", ticket_nonce, hash_len, cipher_suite);

    static constexpr uint32_t kTicketLifetimeSecs = 3600;   // 1 hour
    static constexpr uint32_t kMaxEarlyDataSize = 16384;    // 16 KiB

    std::vector<uint8_t> ticket = generateSessionTicket(
        ticket_nonce, cipher_suite, psk, kTicketLifetimeSecs, kMaxEarlyDataSize);
    if (ticket.empty()) return;

    uint32_t ticket_age_add = 0;
    fillRandomBytes(reinterpret_cast<uint8_t*>(&ticket_age_add), sizeof(ticket_age_add));

    std::vector<uint8_t> nst;
    nst.push_back(0x04); // NewSessionTicket
    size_t length_pos = nst.size();
    nst.push_back(0x00); nst.push_back(0x00); nst.push_back(0x00); // length placeholder

    for (int i = 3; i >= 0; --i) nst.push_back(static_cast<uint8_t>(kTicketLifetimeSecs >> (8 * i)));
    for (int i = 3; i >= 0; --i) nst.push_back(static_cast<uint8_t>(ticket_age_add >> (8 * i)));

    // ticket_nonce<0..255>
    nst.push_back(static_cast<uint8_t>(ticket_nonce.size()));
    nst.insert(nst.end(), ticket_nonce.begin(), ticket_nonce.end());

    // ticket<1..2^16-1>
    nst.push_back(static_cast<uint8_t>(ticket.size() >> 8));
    nst.push_back(static_cast<uint8_t>(ticket.size()));
    nst.insert(nst.end(), ticket.begin(), ticket.end());

    // extensions<0..2^16-2>: early_data (0x002a) { uint32 max_early_data_size; }
    std::vector<uint8_t> extensions;
    extensions.push_back(0x00); extensions.push_back(0x2a);
    extensions.push_back(0x00); extensions.push_back(0x04);
    for (int i = 3; i >= 0; --i) extensions.push_back(static_cast<uint8_t>(kMaxEarlyDataSize >> (8 * i)));
    nst.push_back(static_cast<uint8_t>(extensions.size() >> 8));
    nst.push_back(static_cast<uint8_t>(extensions.size()));
    nst.insert(nst.end(), extensions.begin(), extensions.end());

    size_t msg_len = nst.size() - 4;
    nst[length_pos]     = static_cast<uint8_t>(msg_len >> 16);
    nst[length_pos + 1] = static_cast<uint8_t>(msg_len >> 8);
    nst[length_pos + 2] = static_cast<uint8_t>(msg_len);

    // Sent as a CRYPTO frame at the Application level — not part of any
    // transcript hash (issued strictly after resumption_master_secret, and
    // never referenced by any Finished/binder computation).
    std::vector<uint8_t> crypto_frame = buildCryptoFrame(nst, _crypto_send_app.size());
    _crypto_send_app.insert(_crypto_send_app.end(), nst.begin(), nst.end());

    std::vector<uint8_t> packet = buildShortHeaderPacket(crypto_frame);
    applyHeaderProtection(packet, EncryptionLevel::Application, _app_keys.send);
    if (_Socket >= 0) {
        sendPacket(packet.data(), packet.size());
    }
}

// ============================================================================
// TLS: Process NewSessionTicket (Client only)
// ============================================================================

void quic::processNewSessionTicket(const std::vector<uint8_t>& msg) {
    if (_resumption_master_secret.empty()) return; // shouldn't happen post-handshake

    size_t offset = 4; // skip type + length
    if (offset + 4 + 4 + 1 > msg.size()) return;
    offset += 4; // ticket_lifetime — not otherwise used; the ticket blob's
                 // own embedded lifetime (checked in validateSessionTicket())
                 // is authoritative.
    offset += 4; // ticket_age_add — unused; this implementation doesn't try
                 // to obscure ticket age from network observers.

    uint8_t nonce_len = msg[offset++];
    if (offset + nonce_len > msg.size()) return;
    std::vector<uint8_t> ticket_nonce(msg.begin() + offset, msg.begin() + offset + nonce_len);
    offset += nonce_len;

    if (offset + 2 > msg.size()) return;
    uint16_t ticket_len = (static_cast<uint16_t>(msg[offset]) << 8) | msg[offset + 1];
    offset += 2;
    if (offset + ticket_len > msg.size()) return;
    std::vector<uint8_t> ticket(msg.begin() + offset, msg.begin() + offset + ticket_len);
    offset += ticket_len;
    // Extensions (early_data/max_early_data_size) follow but aren't parsed:
    // this implementation doesn't cap client-side 0-RTT sends against it —
    // the server's own accept/reject decision is the real backstop.

    const bool use_sha384 = (_selected_cipher == 0x1302);
    const size_t hash_len = use_sha384 ? 48 : 32;
    const uint16_t cipher_suite = use_sha384 ? 0x1302 : 0x1301;

    std::vector<uint8_t> psk = netplus::tls::hkdf_expand_label(
        _resumption_master_secret, "resumption", ticket_nonce, hash_len, cipher_suite);

    // The application-facing ticket blob wraps the client-derived PSK
    // together with the server's own ticket bytes: unlike the server (whose
    // ticket is self-encrypted, so ANY server instance can recover the PSK
    // from the ticket alone), a client presenting this ticket on a later,
    // separate quic object has no shared state with this connection to
    // re-derive the PSK from — the PSK has to travel with whatever blob the
    // application stores. Format: 2-byte cipher_suite + 2-byte psk length +
    // psk + server ticket bytes. Still opaque to the application — see
    // setSessionTicket()/buildClientHello()'s unwrapping.
    std::vector<uint8_t> wrapped;
    wrapped.push_back(static_cast<uint8_t>(cipher_suite >> 8));
    wrapped.push_back(static_cast<uint8_t>(cipher_suite));
    wrapped.push_back(static_cast<uint8_t>(psk.size() >> 8));
    wrapped.push_back(static_cast<uint8_t>(psk.size()));
    wrapped.insert(wrapped.end(), psk.begin(), psk.end());
    wrapped.insert(wrapped.end(), ticket.begin(), ticket.end());

    _received_session_ticket = std::move(wrapped);
}

// ============================================================================
// HTTP/3: Send mandatory control streams (RFC 9114 §6.2.1)
// ============================================================================
// Base is a no-op: the control/QPACK-encoder/QPACK-decoder stream framing
// is HTTP/3 (application-layer) knowledge, not QUIC transport. libhttppp
// provides the real implementation via an override (Http3QuicSocket).

void quic::sendControlStreams() {
}

// ============================================================================
// TLS 1.3: Build ALPN extension (0x0010) for EncryptedExtensions
// ============================================================================
// Base is a no-op: which protocol string to advertise (and to whom) is
// HTTP-layer policy, not QUIC transport. libhttppp provides the real
// implementation via an override (Http3QuicSocket).

std::vector<uint8_t> quic::buildAlpnExtension(const std::string& /*alpn*/) {
    return {};
}

// ============================================================================
// Socket Interface: sendData (Send application data)
// ============================================================================

size_t quic::sendData(buffer& data, int flags) {
    std::lock_guard<std::recursive_mutex> lock(quic_mtx());
    
    // Check connection state
    if (_conn_state.load() == ConnectionState::Closed) {
        NetException e;
        e[NetException::Error] << "quic::sendData: connection closed";
        throw e;
    }
    
    if (_conn_state.load() != ConnectionState::Connected && !_handshake_complete) {
        NetException e;
        e[NetException::Error] << "quic::sendData: connection not established";
        throw e;
    }
    
    // Get data pointer
#ifdef Windows
    const char* send_ptr = data.data.buf;
#else
    const char* send_ptr = data.ptr ? data.data.ptr : data.data.buf;
#endif
    size_t send_len = data.size;
    
    if (send_len == 0) {
        return 0;
    }
    
    // Check flow control
    if (_data_sent + send_len > _max_data_remote) {
        // Would exceed peer's max_data limit
        send_len = (_max_data_remote > _data_sent) ? 
                   (_max_data_remote - _data_sent) : 0;
        
        if (send_len == 0) {
            NetException e;
            e[NetException::Note] << "quic::sendData: flow control blocked";
            throw e;
        }
    }
    
    // Use default stream (client-initiated bidirectional stream 0)
    uint64_t stream_id = _is_server ? 1 : 0;
    
    // Get or create stream
    auto& stream = _streams[stream_id];
    stream.stream_id = stream_id;
    
    // Check stream-level flow control
    if (stream.send_offset + send_len > stream.max_data_remote) {
        send_len = (stream.max_data_remote > stream.send_offset) ?
                   (stream.max_data_remote - stream.send_offset) : 0;
        
        if (send_len == 0) {
            NetException e;
            e[NetException::Note] << "quic::sendData: stream flow control blocked";
            throw e;
        }
    }
    
    // Build STREAM frames in QUIC datagram chunks
    const size_t header_overhead = 1 + _remote_cid.size() + 4 + 16;
    const size_t frame_hdr_max = 1 + 8 + 8 + 8;
    const size_t max_datagram = _handshake_complete
        ? static_cast<size_t>(_max_udp_payload)
        : 1200;
    const size_t max_chunk = max_datagram - header_overhead - frame_hdr_max;

    size_t sent_total = 0;
    while (sent_total < send_len) {
        size_t chunk = std::min(send_len - sent_total, max_chunk);

        std::vector<uint8_t> chunk_data(send_ptr + sent_total, send_ptr + sent_total + chunk);
        std::vector<uint8_t> stream_frame = buildStreamFrame(
            stream_id, chunk_data, stream.send_offset, false);

        // Build 1-RTT packet
        uint64_t pn_before = _app_pn_send;
        std::vector<uint8_t> packet = buildShortHeaderPacket(stream_frame);

        // Apply header protection
        applyHeaderProtection(packet, EncryptionLevel::Application, _app_keys.send);

        // Send packet
        ssize_t ret = sendPacket(packet.data(), packet.size());
        if (ret < 0) {
            // Non-blocking retry — do not sleep with quic_mtx held.
            // Lost packets will be retransmitted by the loss detection.
            ret = sendPacket(packet.data(), packet.size());
            if (ret < 0) break;
        }

        // Record for retransmission tracking
        recordSentPacket(pn_before, stream_id, stream.send_offset,
                        reinterpret_cast<const uint8_t*>(send_ptr + sent_total), chunk, false);

        stream.send_offset += chunk;
        _data_sent += chunk;
        sent_total += chunk;
    }
    
    return sent_total;
}

// ============================================================================
// 0-RTT Stream Data Send (client-only) — see its declaration in socket.h
// ============================================================================

size_t quic::sendStreamDataEarly(uint64_t stream_id, const uint8_t* data, size_t len, bool fin) {
    if (len == 0 && !fin) return 0;

    auto& stream = _streams[stream_id];
    stream.stream_id = stream_id;

    // Same flow-control clamping sendStreamData() applies — _max_data_remote/
    // stream.max_data_remote still hold their struct-initialized defaults
    // this early (no transport parameters have been received yet on this
    // connection), which is enough to keep this bounded and safe even
    // though it isn't the server's *actual* remembered limit from the
    // previous connection (RFC 9001 §4.6.1's "remembered transport
    // parameters" aren't otherwise tracked by this implementation) — 0-RTT
    // here is meant for a modest first request, not a bulk transfer.
    size_t send_len = len;
    if (_data_sent + send_len > _max_data_remote) {
        send_len = (_max_data_remote > _data_sent) ? (_max_data_remote - _data_sent) : 0;
    }
    if (stream.send_offset + send_len > stream.max_data_remote) {
        send_len = (stream.max_data_remote > stream.send_offset) ? (stream.max_data_remote - stream.send_offset) : 0;
    }
    if (send_len == 0 && !fin) return 0;

    // Conservative fixed chunk size — 0-RTT packets are padded to the same
    // 1200-byte minimum as Initial (RFC 9000 §14.1); comfortable headroom
    // for header/tag/frame overhead rather than computing it exactly, since
    // this path is never throughput-critical.
    const size_t max_chunk = 1000;
    size_t sent_total = 0;
    bool sent_fin = false;

    do {
        size_t chunk = std::min(send_len - sent_total, max_chunk);
        bool is_last = (sent_total + chunk >= send_len);
        bool set_fin = fin && is_last;

        std::vector<uint8_t> chunk_data(data + sent_total, data + sent_total + chunk);
        std::vector<uint8_t> stream_frame = buildStreamFrame(stream_id, chunk_data, stream.send_offset, set_fin);

        uint64_t pn_before = _app_pn_send;
        std::vector<uint8_t> packet = buildZeroRTTPacket(stream_frame);
        applyHeaderProtection(packet, EncryptionLevel::EarlyData, _early_keys.send);

        recordSentPacket(pn_before, stream_id, stream.send_offset, chunk_data.data(), chunk, set_fin,
                          packet.size(), /*sent_as_early_data=*/true);

        if (_Socket >= 0) {
            sendPacket(packet.data(), packet.size());
        }

        stream.send_offset += chunk;
        sent_total += chunk;
        if (set_fin) sent_fin = true;
    } while (sent_total < send_len);

    _data_sent += sent_total;
    if (sent_fin) stream.send_fin = true;

    return sent_total;
}

// ============================================================================
// Stream Data Send (for specific stream)
// ============================================================================

size_t quic::sendStreamData(uint64_t stream_id, const uint8_t* data, size_t len, bool fin) {
    // F30 (performance report): deliberately NOT implemented. The report
    // suggests moving AES-GCM encryption outside the quic_mtx() lock and
    // only holding PN reservation and _sent_packets/cwnd bookkeeping under
    // a short lock, to reduce the measured contention between an
    // application thread sending and the network thread concurrently
    // processing ACKs for the same connection.
    //
    // That would require restructuring PN reservation, encryption, and the
    // actual sendPacket() syscall to run under different lock granularity,
    // while still strictly preserving on-wire PN ordering and
    // _sent_packets consistency. A race slipped in here would, with high
    // probability, NOT show up on this loopback test system (near-zero
    // latency, hardly any real queuing), but could show up exactly in the
    // production scenario with real latency/jitter that this fix is meant
    // to improve (packet reordering, corrupted retransmission bookkeeping).
    // Effort and risk are rated "high"/"high" in the report itself —
    // without multi-core/variable-bandwidth validation outside this
    // sandbox, the risk of a silent, hard-to-reproduce bug in the critical
    // send path (which F31 just finished verifying correct via 3x full
    // test suite + 3x quic_concurrent_test) outweighs the gain. Decision:
    // not implemented, documented transparently instead of forced through
    // at risk.
    std::unique_lock<std::recursive_mutex> lock(quic_mtx());

    // 0-RTT: the handshake hasn't completed, but early data keys are ready
    // and the server hasn't (yet) told us it declined — send via the
    // separate, simpler early-data path instead of falling through to the
    // gate below. See sendStreamDataEarly()'s comment for why this isn't
    // folded into the optimized logic that follows.
    bool early_data_ok = !_is_server && !_handshake_complete && !_early_data_rejected &&
                          _early_keys.send.aes_gcm != nullptr;
    if (early_data_ok) {
        return sendStreamDataEarly(stream_id, data, len, fin);
    }

    // RFC 9001 §4.1.1: a server MAY send 1-RTT packets as soon as it has
    // sent its own Finished — it doesn't need to wait for (or verify) the
    // client's Finished first, i.e. _handshake_complete (which this server
    // only sets upon receiving the *client's* Finished) is stricter than
    // the RFC requires. Without this, a server that accepted 0-RTT could
    // decrypt a client's early data (processZeroRTTPacket() already derives
    // _app_keys by then, via processClientHello() -> sendServerHello() ->
    // sendServerHandshakeFlight()) but could never reply to it early,
    // defeating the entire point of accepting 0-RTT in the first place.
    bool server_app_keys_ready = _is_server && _app_keys.send.aes_gcm != nullptr;
    if (_conn_state.load() != ConnectionState::Connected && !_handshake_complete && !server_app_keys_ready) {
        return 0;
    }

    if (len == 0 && !fin) {
        return 0;
    }

    // Get or create stream
    auto& stream = _streams[stream_id];
    stream.stream_id = stream_id;

    // Check flow control
    size_t send_len = len;
    if (_data_sent + send_len > _max_data_remote) {
        send_len = (_max_data_remote > _data_sent) ?
                   (_max_data_remote - _data_sent) : 0;
    }
    
    if (stream.send_offset + send_len > stream.max_data_remote) {
        send_len = (stream.max_data_remote > stream.send_offset) ?
                   (stream.max_data_remote - stream.send_offset) : 0;
    }
    
    if (send_len == 0 && !fin) {
        return 0;
    }
    
    // Chunk into QUIC datagrams for proper MTU handling
    const size_t cid_len = _remote_cid.size();
    const size_t header_overhead = 1 + cid_len + 4 + 16; // flags + DCID + PN + tag
    const size_t frame_hdr_max = 1 + 8 + 8 + 8;
    const size_t max_datagram = _handshake_complete
        ? static_cast<size_t>(_max_udp_payload)
        : 1200;
    // Reserve space for a piggybacked ACK frame in the first packet. Must
    // reflect the true worst case, not a fixed guess: the piggyback ACK
    // encoder below writes directly into pkt_buf with no further bounds
    // check, so under-reserving here would let it overwrite the space
    // this chunk size assumes is free for stream data. Sized from the
    // actual (capped, per MAX_ACK_RANGES_PER_FRAME) range count that will
    // be encoded: type(1) + largest_ack(8) + delay(8) + range_count(8) +
    // first_range(8) + additional_ranges * (gap(8) + range(8)).
    const size_t ack_reserve = _app_ack_pending
        ? 33 + std::min<uint64_t>(
              _app_pn_recv_ranges.empty() ? 0 : _app_pn_recv_ranges.size() - 1,
              MAX_ACK_RANGES_PER_FRAME) * 16
        : 0;
    const size_t max_chunk = max_datagram - header_overhead - frame_hdr_max - ack_reserve;

    // Pre-fetch keys once outside the loop
    LevelKeyMaterial& send_mat = _app_keys.send;
    const uint8_t* send_key = send_mat.key;
    const uint8_t* send_iv  = send_mat.iv;
    const uint8_t* hp_key   = send_mat.hp;
    aes* cached_aes = send_mat.aes_gcm.get();
    aes* cached_hp  = send_mat.aes_hp.get();

    // Single buffer for packet building — avoid per-packet heap allocations.
    // Size: max_datagram + overhead for header/tag is always < max_datagram + 64.
    // For typical MTU (1472), this uses ~1.5KB of stack. For large MTU (16384),
    // we fall back to a heap buffer allocated once outside the loop.
    static constexpr size_t STACK_BUF_SIZE = 2048;
    uint8_t stack_buf[STACK_BUF_SIZE];
    std::vector<uint8_t> heap_buf;
    uint8_t* pkt_buf = stack_buf;
    uint8_t* pt_buf_base;
    uint8_t pt_stack[STACK_BUF_SIZE];
    
    if (max_datagram + 64 > STACK_BUF_SIZE) {
        heap_buf.resize((max_datagram + 64) * 2);
        pkt_buf = heap_buf.data();
        pt_buf_base = pkt_buf + max_datagram + 64;
    } else {
        pt_buf_base = pt_stack;
    }

    size_t sent_total = 0;
    size_t batch_count = 0;         // packets accumulated since last flush
    // Match BATCH_MAX so _send_batch's own auto-flush (batchPacket(), quic.cpp:300)
    // never fires first — flushing at half that size doubled sendmmsg syscalls
    // for no benefit (cwnd-constrained sends already flush earlier via the
    // congestion-wait path below, so this only changes the already-healthy,
    // not-blocked-on-cwnd case).
    static constexpr size_t BATCH_FLUSH = BATCH_MAX;

    while (sent_total < send_len) {
        size_t chunk = std::min(send_len - sent_total, max_chunk);
        bool is_last = (sent_total + chunk >= send_len);
        bool set_fin = fin && is_last;

        // Congestion control: use estimated actual packet size, not max_datagram
        size_t est_pkt_size = header_overhead + frame_hdr_max + chunk;
        if (_handshake_complete && !cwndAllowsSend(est_pkt_size)) {
            // Flush any batched packets first so the peer can ACK them
            if (batch_count > 0) {
                flushBatch();
                batch_count = 0;
            }
            // Drain incoming to receive ACKs and free cwnd
            // First: spin-pump a few times without syscall (ACK may already be queued)
            bool unblocked = false;
            for (int spin = 0; spin < 8; ++spin) {
                // quic_mtx() is already held throughout this function (see
                // `lock` above) — pumpIncomingLocked() skips the redundant
                // recursive re-lock pumpIncoming() would otherwise do here,
                // up to 8 times per congestion-window stall (F31).
                pumpIncomingLocked();
                if (cwndAllowsSend(est_pkt_size)) { unblocked = true; break; }
            }
            // If still blocked, poll with timeout. Reuse one socketwait
            // (and its epoll fd) across every retry instead of constructing
            // a fresh one per ~1ms attempt — see _cc_socketwait's comment.
            if (!unblocked) {
                if (!_cc_socketwait) _cc_socketwait = std::make_unique<socketwait>();
                // Budget the wait off the connection's own RTO estimate
                // (RFC 9002 §6.2) instead of a flat 100ms. A fixed 100ms
                // cap gave up before the cwnd realistically had time to
                // open under the RFC-9002 initial cwnd, forcing the final
                // chunk of larger transfers onto slow PTO-based
                // retransmission (measured 1.2-1.3s spikes vs 85-250ms
                // baseline). Floor at 100ms to match prior behavior on
                // healthy connections; cap at 2s so a blown-up RTO
                // estimate can't stall the sender indefinitely.
                int cc_wait_budget_ms = static_cast<int>(
                    std::clamp(_rto * 1000.0, 100.0, 2000.0));
                for (int cc_wait = 0; cc_wait < cc_wait_budget_ms; ++cc_wait) {
                    if (_conn_state.load() == ConnectionState::Closed ||
                        _conn_state.load() == ConnectionState::Draining) break;
                    lock.unlock();
                    _cc_socketwait->waitRead(*this, 1);
                    lock.lock();
                    // Re-locked just above — see the spin-loop's identical
                    // comment on pumpIncomingLocked() (F31).
                    pumpIncomingLocked();
                    if (cwndAllowsSend(est_pkt_size)) { unblocked = true; break; }
                }
            }
            if (!unblocked) break;
        }

        // === Build short header directly into pkt_buf ===
        uint8_t first_byte = 0x40 | 0x03; // Short header, 4-byte PN
        pkt_buf[0] = first_byte;
        std::memcpy(pkt_buf + 1, _remote_cid.data(), cid_len);
        size_t hdr_len = 1 + cid_len;

        // Packet number (4 bytes)
        uint64_t pn = _app_pn_send++;
        pkt_buf[hdr_len]     = static_cast<uint8_t>(pn >> 24);
        pkt_buf[hdr_len + 1] = static_cast<uint8_t>(pn >> 16);
        pkt_buf[hdr_len + 2] = static_cast<uint8_t>(pn >> 8);
        pkt_buf[hdr_len + 3] = static_cast<uint8_t>(pn);
        hdr_len += 4;

        // === Build STREAM frame directly after header (this is the plaintext payload) ===
        size_t frame_off = hdr_len;
        uint8_t vbuf[8];
        size_t vlen;

        // Piggyback ACK frame in first packet if we have pending ACKs
        if (_app_ack_pending && sent_total == 0) {
            // Real elapsed-time ACK Delay (RFC 9000 §13.2.5) instead of a
            // hardcoded 0, encoded in our own ack_delay_exponent's units —
            // see _app_ack_elicit_since's comment in socket.h and
            // sendAppAck() (the standalone-ACK path uses the same formula).
            double elapsed_sec = std::chrono::duration<double>(
                std::chrono::steady_clock::now() - _app_ack_elicit_since).count();
            if (elapsed_sec < 0) elapsed_sec = 0;
            uint64_t ack_delay_encoded = static_cast<uint64_t>(elapsed_sec * 1000000.0) >> _ack_delay_exponent;

            // Write ACK frame (type 0x02) inline
            pkt_buf[frame_off++] = 0x02; // ACK frame type
            if (!_app_pn_recv_ranges.empty()) {
                auto it = std::prev(_app_pn_recv_ranges.end());
                uint64_t la = it->second;

                vlen = encodeVarInt(la, vbuf); std::memcpy(pkt_buf + frame_off, vbuf, vlen); frame_off += vlen;
                vlen = encodeVarInt(ack_delay_encoded, vbuf); std::memcpy(pkt_buf + frame_off, vbuf, vlen); frame_off += vlen;
                // Capped the same way as buildAckFrame() — see
                // MAX_ACK_RANGES_PER_FRAME's comment in socket.h. This
                // matters even more here than there: this writes directly
                // into the fixed-size pkt_buf with no further bounds
                // check, so an unbounded range count would be an actual
                // buffer overflow, not just an oversized packet.
                uint64_t rc = std::min<uint64_t>(_app_pn_recv_ranges.size() - 1, MAX_ACK_RANGES_PER_FRAME);
                vlen = encodeVarInt(rc, vbuf); std::memcpy(pkt_buf + frame_off, vbuf, vlen); frame_off += vlen;
                uint64_t fr = it->second - it->first;
                vlen = encodeVarInt(fr, vbuf); std::memcpy(pkt_buf + frame_off, vbuf, vlen); frame_off += vlen;
                uint64_t prev_smallest = it->first;
                for (uint64_t encoded = 0; encoded < rc && it != _app_pn_recv_ranges.begin(); ++encoded) {
                    --it;
                    uint64_t gap = prev_smallest - it->second - 2;
                    vlen = encodeVarInt(gap, vbuf); std::memcpy(pkt_buf + frame_off, vbuf, vlen); frame_off += vlen;
                    uint64_t ar = it->second - it->first;
                    vlen = encodeVarInt(ar, vbuf); std::memcpy(pkt_buf + frame_off, vbuf, vlen); frame_off += vlen;
                    prev_smallest = it->first;
                }
            } else {

                vlen = encodeVarInt(_app_pn_recv, vbuf); std::memcpy(pkt_buf + frame_off, vbuf, vlen); frame_off += vlen;
                vlen = encodeVarInt(ack_delay_encoded, vbuf); std::memcpy(pkt_buf + frame_off, vbuf, vlen); frame_off += vlen;
                vlen = encodeVarInt(0, vbuf); std::memcpy(pkt_buf + frame_off, vbuf, vlen); frame_off += vlen;
                vlen = encodeVarInt(0, vbuf); std::memcpy(pkt_buf + frame_off, vbuf, vlen); frame_off += vlen;
            }
            _app_ack_pending = false;
        }

        uint8_t frame_type = 0x08 | 0x02; // LEN always set
        if (stream.send_offset > 0) frame_type |= 0x04; // OFF bit
        if (set_fin) frame_type |= 0x01;
        pkt_buf[frame_off++] = frame_type;

        vlen = encodeVarInt(stream_id, vbuf);
        std::memcpy(pkt_buf + frame_off, vbuf, vlen);
        frame_off += vlen;

        if (stream.send_offset > 0) {
            vlen = encodeVarInt(stream.send_offset, vbuf);
            std::memcpy(pkt_buf + frame_off, vbuf, vlen);
            frame_off += vlen;
        }

        vlen = encodeVarInt(chunk, vbuf);
        std::memcpy(pkt_buf + frame_off, vbuf, vlen);
        frame_off += vlen;

        // Copy payload data
        std::memcpy(pkt_buf + frame_off, data + sent_total, chunk);
        frame_off += chunk;

        size_t payload_len = frame_off - hdr_len;

        // === AES-GCM encrypt in-place ===
        // Nonce = IV XOR packet_number
        uint8_t nonce[12];
        std::memcpy(nonce, send_iv, 12);
        for (int i = 0; i < 8; ++i)
            nonce[11 - i] ^= static_cast<uint8_t>((pn >> (8 * i)) & 0xFF);

        // Encrypt: plaintext at pkt_buf+hdr_len → ciphertext at same location
        // We need a temporary buffer for the plaintext since encrypt overwrites
        std::memcpy(pt_buf_base, pkt_buf + hdr_len, payload_len);

        uint8_t* ct_dst = pkt_buf + hdr_len;
        uint8_t* tag_dst = ct_dst + payload_len;

        if (cached_aes) {
            cached_aes->aes_gcm_encrypt(nonce, pkt_buf, hdr_len,
                                        pt_buf_base, payload_len,
                                        ct_dst, tag_dst);
        } else {
            aes128 tmp_aes(std::vector<uint8_t>(send_key, send_key + 16));
            tmp_aes.aes_gcm_encrypt(nonce, pkt_buf, hdr_len,
                                    pt_buf_base, payload_len,
                                    ct_dst, tag_dst);
        }

        size_t total_len = hdr_len + payload_len + 16;

        // === Apply header protection in-place ===
        // Sample starts 4 bytes after PN start (= hdr_len, since PN is 4 bytes and ends at hdr_len)
        const uint8_t* sample = pkt_buf + hdr_len; // 4 bytes after PN start
        uint8_t hp_mask[16];
        if (cached_hp) {
            cached_hp->encrypt_ecb(sample, hp_mask);
        } else {
            aes128 tmp_hp(std::vector<uint8_t>(hp_key, hp_key + 16));
            tmp_hp.encrypt_ecb(sample, hp_mask);
        }
        pkt_buf[0] ^= (hp_mask[0] & 0x1F); // Short header mask
        size_t pn_offset = 1 + cid_len;
        for (size_t i = 0; i < 4; ++i)
            pkt_buf[pn_offset + i] ^= hp_mask[1 + i];

        // === Batch for sendmmsg/GSO instead of sending individually ===
        batchPacket(pkt_buf, total_len);
        batch_count++;

        // Record for retransmission tracking (pass wire size for CC accounting)
        recordSentPacket(pn, stream_id, stream.send_offset,
                        data + sent_total, chunk, set_fin, total_len);

        // Congestion control: track sent bytes
        if (_handshake_complete) {
            onPacketSentCC(total_len);
        }

        stream.send_offset += chunk;
        _data_sent += chunk;
        sent_total += chunk;

        // Flush batch periodically via sendmmsg/GSO
        if (batch_count >= BATCH_FLUSH) {
            flushBatch();
            batch_count = 0;

            // Pace: pump incoming every batch to process ACKs/FC updates.
            // quic_mtx() is already held throughout this function (F31).
            pumpIncomingLocked();
            if (_conn_state.load() == ConnectionState::Closed ||
                _conn_state.load() == ConnectionState::Draining) {
                break;
            }
        }
    }

    // Flush remaining batched packets
    if (batch_count > 0) {
        flushBatch();
    }

    // Only mark FIN if we actually sent all requested data (FIN is on the last chunk).
    // If CC broke the loop early, the FIN was never put on the wire.
    if (fin && sent_total >= send_len) {
        // FIN-only: when send_len == 0, the main loop never ran, so we must
        // send an empty STREAM frame with the FIN bit on the wire.
        if (send_len == 0 && !stream.send_fin) {
            uint8_t frame_type = 0x08 | 0x01; // STREAM + FIN
            if (stream.send_offset > 0) frame_type |= 0x04; // OFF bit
            std::vector<uint8_t> fin_frame;
            fin_frame.push_back(frame_type);
            uint8_t vbuf[8];
            size_t vlen = encodeVarInt(stream_id, vbuf);
            fin_frame.insert(fin_frame.end(), vbuf, vbuf + vlen);
            if (stream.send_offset > 0) {
                vlen = encodeVarInt(stream.send_offset, vbuf);
                fin_frame.insert(fin_frame.end(), vbuf, vbuf + vlen);
            }

            uint64_t pn = _app_pn_send;
            std::vector<uint8_t> packet = buildShortHeaderPacket(fin_frame);
            applyHeaderProtection(packet, EncryptionLevel::Application, _app_keys.send);
            sendPacket(packet.data(), packet.size());
            recordSentPacket(pn, stream_id, stream.send_offset, nullptr, 0, true);
        }
        stream.send_fin = true;
        // Auto-cleanup: both sides FIN'd → erase stream & replenish peer's stream budget
        if (stream.recv_fin) {
            bool peer_bidi = _is_server ? (stream_id % 4 == 0) : (stream_id % 4 == 1);
            bool peer_uni  = _is_server ? (stream_id % 4 == 2) : (stream_id % 4 == 3);
            markStreamClosed(stream_id);
            _streams.erase(stream_id);
            if (peer_bidi) {
                _max_streams_bidi_local++;
                std::vector<uint8_t> ms_frame;
                ms_frame.push_back(0x12);
                uint8_t ms_buf[8];
                size_t ms_bytes = encodeVarInt(_max_streams_bidi_local, ms_buf);
                ms_frame.insert(ms_frame.end(), ms_buf, ms_buf + ms_bytes);
                std::vector<uint8_t> ms_pkt = buildShortHeaderPacket(ms_frame);
                applyHeaderProtection(ms_pkt, EncryptionLevel::Application, _app_keys.send);
                sendPacket(ms_pkt.data(), ms_pkt.size());
            } else if (peer_uni) {
                _max_streams_uni_local++;
                std::vector<uint8_t> ms_frame;
                ms_frame.push_back(0x13);
                uint8_t ms_buf[8];
                size_t ms_bytes = encodeVarInt(_max_streams_uni_local, ms_buf);
                ms_frame.insert(ms_frame.end(), ms_buf, ms_buf + ms_bytes);
                std::vector<uint8_t> ms_pkt = buildShortHeaderPacket(ms_frame);
                applyHeaderProtection(ms_pkt, EncryptionLevel::Application, _app_keys.send);
                sendPacket(ms_pkt.data(), ms_pkt.size());
            }
        }
    }

    return sent_total;
}

// ============================================================================
// Frame Building: STREAM Frame
// ============================================================================

std::vector<uint8_t> quic::buildStreamFrame(uint64_t stream_id, 
                                             const std::vector<uint8_t>& data,
                                             uint64_t offset, bool fin) {
    std::vector<uint8_t> frame;
    frame.reserve(1 + 8 + 8 + 8 + data.size());
    
    // Frame type: STREAM (0x08-0x0f)
    // Bits: OFF (0x04) | LEN (0x02) | FIN (0x01)
    uint8_t frame_type = 0x08;
    if (offset > 0) frame_type |= 0x04;  // OFF bit
    frame_type |= 0x02;  // LEN bit (always include length for clarity)
    if (fin) frame_type |= 0x01;  // FIN bit
    frame.push_back(frame_type);
    
    // Stream ID (variable-length integer)
    uint8_t sid_buf[8];
    size_t sid_len = encodeVarInt(stream_id, sid_buf);
    frame.insert(frame.end(), sid_buf, sid_buf + sid_len);
    
    // Offset (if present)
    if (offset > 0) {
        uint8_t off_buf[8];
        size_t off_len = encodeVarInt(offset, off_buf);
        frame.insert(frame.end(), off_buf, off_buf + off_len);
    }
    
    // Length (variable-length integer)
    uint8_t len_buf[8];
    size_t len_len = encodeVarInt(data.size(), len_buf);
    frame.insert(frame.end(), len_buf, len_buf + len_len);
    
    // Data
    frame.insert(frame.end(), data.begin(), data.end());
    
    return frame;
}

// ============================================================================
// Frame Building: ACK Frame
// ============================================================================

void quic::trackRecvPN(uint64_t pn, std::set<std::pair<uint64_t,uint64_t>>& ranges) {
    auto r = std::make_pair(pn, pn);
    auto it = ranges.insert(r).first;
    // Merge with predecessor(s)
    while (it != ranges.begin()) {
        auto prev = std::prev(it);
        if (prev->second + 1 >= it->first) {
            auto merged = std::make_pair(prev->first,
                                          std::max(prev->second, it->second));
            ranges.erase(prev);
            it = ranges.erase(it);
            it = ranges.insert(merged).first;
        } else {
            break;
        }
    }
    // Merge with successor(s)
    while (true) {
        auto next = std::next(it);
        if (next == ranges.end()) break;
        if (it->second + 1 >= next->first) {
            auto merged = std::make_pair(it->first,
                                          std::max(it->second, next->second));
            ranges.erase(next);
            it = ranges.erase(it);
            it = ranges.insert(merged).first;
        } else {
            break;
        }
    }

    // Bound growth: a gap left by a packet this library will never see
    // again (the peer always retransmits lost data under a fresh PN, so
    // the original PN is a permanent hole — see recordSentPacket()/
    // checkLossAndRetransmit()) can never merge away, so on a long
    // connection with sustained loss this set would otherwise grow by one
    // entry per loss for the connection's entire lifetime. buildAckFrame()
    // only ever encodes the newest MAX_ACK_RANGES_PER_FRAME + 1 ranges
    // (see its comment), so anything older than that is already unreachable
    // from any future ACK frame — safe to drop here instead of retaining it
    // forever.
    constexpr size_t kMaxRanges = static_cast<size_t>(MAX_ACK_RANGES_PER_FRAME) + 1;
    while (ranges.size() > kMaxRanges) {
        ranges.erase(ranges.begin());
    }
}

std::vector<uint8_t> quic::buildAckFrame(uint64_t largest_ack, uint64_t ack_delay,
                                          const std::set<std::pair<uint64_t,uint64_t>>& recv_ranges) {
    std::vector<uint8_t> frame;
    uint8_t vbuf[8];
    size_t vlen;

    QUIC_DBG("buildAckFrame: largest_ack=%lu ack_delay=%lu ranges_size=%zu",
             (unsigned long)largest_ack, (unsigned long)ack_delay, recv_ranges.size());

    // Frame type: ACK (0x02)
    frame.push_back(0x02);

    if (!recv_ranges.empty()) {
        // Ranges are sorted ascending. Walk from the highest range down.
        auto it = std::prev(recv_ranges.end());

        // Largest Acknowledged
        uint64_t la = it->second;
        vlen = encodeVarInt(la, vbuf);
        frame.insert(frame.end(), vbuf, vbuf + vlen);

        // ACK Delay
        vlen = encodeVarInt(ack_delay, vbuf);
        frame.insert(frame.end(), vbuf, vbuf + vlen);

        // Count additional ranges (all except the first/highest) — capped
        // at MAX_ACK_RANGES_PER_FRAME, keeping the MOST RECENT ranges (the
        // walk below is already highest-to-lowest) and dropping older
        // ones once the connection has accumulated more gaps than fit in
        // one frame. See MAX_ACK_RANGES_PER_FRAME's comment in socket.h:
        // this must match processAckFrame()'s own cap, or a peer running
        // this same library could eventually send an ACK its own decoder
        // would reject.
        uint64_t range_count = std::min<uint64_t>(recv_ranges.size() - 1, MAX_ACK_RANGES_PER_FRAME);
        vlen = encodeVarInt(range_count, vbuf);
        frame.insert(frame.end(), vbuf, vbuf + vlen);

        // First ACK Range: la - lo of the highest range
        uint64_t first_range = it->second - it->first;
        vlen = encodeVarInt(first_range, vbuf);
        frame.insert(frame.end(), vbuf, vbuf + vlen);

        uint64_t prev_smallest = it->first;

        // Encode remaining ranges from high to low, stopping at range_count
        for (uint64_t encoded = 0; encoded < range_count && it != recv_ranges.begin(); ++encoded) {
            --it;
            uint64_t gap = prev_smallest - it->second - 2;
            vlen = encodeVarInt(gap, vbuf);
            frame.insert(frame.end(), vbuf, vbuf + vlen);

            uint64_t ack_range = it->second - it->first;
            vlen = encodeVarInt(ack_range, vbuf);
            frame.insert(frame.end(), vbuf, vbuf + vlen);

            prev_smallest = it->first;
        }
    } else {
        // No ranges tracked — single-packet ACK
        vlen = encodeVarInt(largest_ack, vbuf);
        frame.insert(frame.end(), vbuf, vbuf + vlen);

        vlen = encodeVarInt(ack_delay, vbuf);
        frame.insert(frame.end(), vbuf, vbuf + vlen);

        // Range count = 0
        frame.push_back(0x00);

        // First ACK Range = 0 (only the largest_ack packet itself)
        frame.push_back(0x00);
    }

    return frame;
}

// ============================================================================
// pumpIncoming: Drain incoming UDP datagrams and process them
//
// Called from long-running stream callbacks (e.g. H3 streaming) to keep
// QUIC flow control alive.  Reads datagrams from the parent's (or own)
// UDP socket and processes ACKs, MAX_DATA, MAX_STREAM_DATA, etc.
// ============================================================================

void quic::pumpIncoming() {
    std::lock_guard<std::recursive_mutex> lock(quic_mtx());
    pumpIncomingLocked();
}

void quic::pumpIncomingLocked() {
    quic* parent = _parent ? _parent : this;

    // Use udp::recvBatch (sendmmsg/recvmmsg + GRO) to read datagrams. Safe
    // to call concurrently with other threads' accept()/pumpIncoming() on
    // the same shared fd — the kernel hands out disjoint datagrams.
    std::vector<std::vector<uint8_t>> datagrams;
    parent->recvBatch(datagrams, 64);

    // Short-header (1-RTT) datagrams are grouped by target connection and
    // processed together via processApplicationPacketsBatch() — one
    // deferred ACK per target per pumpIncoming() call, the same batching
    // pumpNetwork() already does for its own single-connection case —
    // instead of routing each datagram individually through
    // processIncomingPacket(), which ACKs immediately per packet. That
    // per-packet path measured at ~30x more ACK-only sendto() calls than
    // data packets under load (see benchmark_quic diagnostics): this is
    // exactly the path sendStreamData()'s congestion-window-wait spin loop
    // calls repeatedly while blocked, so every datagram it drains paid for
    // its own standalone ACK datagram. Long-header packets (Initial/
    // Handshake, rare here) keep the immediate per-packet path — coalescing
    // them isn't worth the complexity since they aren't the steady-state
    // traffic and don't go through the deferred-ACK mechanism anyway.
    std::vector<std::shared_ptr<quic>> pins; // keep routed children alive for the whole call
    std::unordered_map<quic*, std::vector<std::pair<const uint8_t*, size_t>>> grouped;

    for (auto& dgram : datagrams) {
        if (dgram.empty()) continue;
        const uint8_t* data = dgram.data();
        size_t len = dgram.size();

        // Route to whichever connection this datagram is for — which may
        // be `this`, a sibling child (if `this` is itself a child and the
        // packet is for a different one), or a child of `this` (if `this`
        // is the listener). Registry lookups take only a shared_lock, same
        // as before; only how the datagram is subsequently *processed*
        // changes (grouped below instead of processed inline here) — under
        // the old shared-parent-mutex design one lock covered every
        // connection, so reaching into a sibling/child's state under only
        // `this`'s lock was safe by accident; it no longer is now that each
        // connection has its own mutex.
        bool route_to_self = false;
        std::shared_ptr<quic> target; // pinned alive past the registry lookup

        if (!(data[0] & 0x80)) {
            // Short header — route via DCID
            bool no_children;
            {
                std::shared_lock<netplus::WriterPreferringSharedMutex> reg_lock(parent->_registry_mutex);
                no_children = parent->_child_connections.empty();
            }
            if (!_parent && no_children) {
                route_to_self = true;
            } else {
                static constexpr size_t CID_LEN = 8;
                if (len < 1 + CID_LEN) continue;
                std::shared_lock<netplus::WriterPreferringSharedMutex> reg_lock(parent->_registry_mutex);
                auto it = parent->_child_connections.find(cidKey(data + 1, CID_LEN));
                if (it != parent->_child_connections.end()) target = it->second;
            }

            quic* target_raw = nullptr;
            if (route_to_self) {
                target_raw = this;
            } else if (target) {
                target_raw = target.get();
                pins.push_back(std::move(target));
            } else {
                continue; // no matching connection for this DCID
            }
            grouped[target_raw].emplace_back(data, len);
        } else {
            // Long header (Initial, Handshake, etc.) — unchanged immediate
            // per-packet path. Matches the original routing exactly: only
            // dispatched when a matching child is found in the registry
            // (never falls back to `this`, even for a listener — a brand
            // new client's Initial packet is accept()'s job, not
            // pumpIncoming()'s; silently dropping an unmatched one here is
            // intentional, same as before this refactor).
            if (len >= 7) {
                size_t dcid_len = static_cast<size_t>(data[5]);
                if (6 + dcid_len <= len) {
                    std::shared_lock<netplus::WriterPreferringSharedMutex> reg_lock(parent->_registry_mutex);
                    auto it = parent->_child_connections.find(cidKey(data + 6, dcid_len));
                    if (it != parent->_child_connections.end()) target = it->second;
                }
            }
            if (target) {
                std::lock_guard<std::recursive_mutex> target_lock(target->quic_mtx());
                target->processIncomingPacket(data, len);
            }
        }
    }

    for (auto& [t, pkts] : grouped) {
        if (t == this) {
            // pumpIncomingLocked()'s precondition is that quic_mtx() on
            // `this` is already held by the caller — re-locking it here
            // (as the general t != this branch below does for every other
            // connection's own, genuinely different mutex) would just be
            // two redundant recursive lock/unlock cycles on top of the
            // caller's, wasted every time this runs from
            // sendStreamData()'s congestion-window-wait spin loop (F31).
            // The two sub-blocks below are unreachable for t == this
            // anyway (both explicitly guarded on t != this), so there's
            // nothing else this branch needs to do.
            t->processApplicationPacketsBatch(pkts);
            continue;
        }

        std::unique_lock<std::recursive_mutex> target_lock(t->quic_mtx());
        t->processApplicationPacketsBatch(pkts);
        // The self case's pending flow-control frames are flushed by the
        // unconditional block below (operating on `this`'s own state);
        // this only covers actually-routed child connections, matching the
        // pre-batching code's behavior.
        if (t != this && t->_handshake_complete &&
            t->_conn_state.load() == ConnectionState::Connected) {
            for (auto& [sid, limit] : t->_pending_max_stream_data)
                t->sendMaxStreamData(sid, limit);
            t->_pending_max_stream_data.clear();
            if (t->_pending_max_data) {
                t->sendMaxData(t->_pending_max_data_value);
                t->_pending_max_data = false;
            }
            t->pmtudTick();
            // F20: flush what sendMaxStreamData/sendMaxData just queued.
            t->flushBatch();
        }

        // Dispatch stream callbacks for routed (sibling) connections too —
        // same as accept()'s flush_pending_app. Without this, a sibling
        // connection that reaches recv_complete while its data happens to
        // be routed through THIS call (instead of through its own accept()
        // call) leaves its completed stream sitting in _pending_dispatches
        // forever: the callback — and therefore any echo/response it would
        // trigger — never fires, until some later, unrelated packet
        // happens to route that connection through accept() instead. Only
        // reachable for `t != this`: a self-targeted entry here would need
        // a shared_ptr to `this`, which callers of pumpIncoming() don't
        // supply (quic has no enable_shared_from_this) — not a gap in
        // practice, since `this` is always mid-send when this function
        // runs, and mid-send state doesn't populate its own
        // _pending_dispatches (that only happens from receiving stream
        // data, not from processing ACKs for what was just sent).
        if (t != this && !t->_pending_dispatches.empty()) {
            auto pin_it = std::find_if(pins.begin(), pins.end(),
                [&](const std::shared_ptr<quic>& p) { return p.get() == t; });
            if (pin_it != pins.end()) {
                std::shared_ptr<quic> target_pin = *pin_it;
                // See the other dispatch call sites' comment on why
                // capacity is re-reserved right after the move (F49).
                size_t dispatch_batch_hint = t->_pending_dispatches.size();
                std::vector<PendingDispatch> dispatches = std::move(t->_pending_dispatches);
                t->_pending_dispatches.clear();
                t->_pending_dispatches.reserve(dispatch_batch_hint);
                target_lock.unlock();
                quic::scheduleDispatches(target_pin, std::move(dispatches));
                target_lock.lock();
            }
        }
    }

    // Flush own pending flow control frames, and check for lost packets to
    // retransmit — both operate on `this`'s own already-locked state (see
    // pumpIncomingLocked()'s precondition).
    flushPendingFlowControlAndCheckLoss();
}

void quic::flushPendingFlowControlAndCheckLoss() {
    if (_handshake_complete && _conn_state.load() == ConnectionState::Connected) {
        for (auto& [sid, limit] : _pending_max_stream_data) {
            sendMaxStreamData(sid, limit);
        }
        _pending_max_stream_data.clear();
        if (_pending_max_data) {
            sendMaxData(_pending_max_data_value);
            _pending_max_data = false;
        }

        pmtudTick();
        // F20: flush what sendMaxStreamData/sendMaxData just queued.
        flushBatch();
    }

    // After processing incoming packets (which may contain ACKs),
    // check for lost packets and retransmit.
    checkLossAndRetransmit();
}

// ============================================================================
// Socket Interface: flush_out (Send queued data)
// ============================================================================

void quic::flush_out() {
    std::lock_guard<std::recursive_mutex> lock(quic_mtx());
    
    // Send any queued packets
    while (!_send_queue.empty()) {
        auto& packet = _send_queue.front();
        
        try {
            buffer send_buf(reinterpret_cast<const char*>(packet.data()), packet.size());
            udp::sendData(send_buf, MSG_DONTWAIT);
        } catch (...) {
            // Socket buffer full or error - stop trying
            break;
        }
        
        _send_queue.pop_front();
    }
    
    // Flush batched flow control frames (only when handshake is complete)
    if (_handshake_complete && _conn_state.load() == ConnectionState::Connected) {
        // Send pending MAX_STREAM_DATA frames (coalesced: only latest value per stream)
        for (auto& [sid, limit] : _pending_max_stream_data) {
            sendMaxStreamData(sid, limit);
        }
        _pending_max_stream_data.clear();
        
        // Send pending MAX_DATA frame (coalesced: only latest value)
        if (_pending_max_data) {
            sendMaxData(_pending_max_data_value);
            _pending_max_data = false;
        }
        // F20: flush what sendMaxStreamData/sendMaxData just queued.
        flushBatch();
    }
}

// ============================================================================
// Stream Management: Open Stream
// ============================================================================

uint64_t quic::openStream(bool bidirectional) {
    std::lock_guard<std::recursive_mutex> lock(quic_mtx());
    
    // Check MAX_STREAMS limit before opening
    if (bidirectional) {
        if (_streams_bidi_opened >= _max_streams_bidi_remote) {
            NetException e;
            e[NetException::Error] << "quic::openStream: bidi stream limit reached ("
                                   << _max_streams_bidi_remote << ")";
            throw e;
        }
    } else {
        if (_streams_uni_opened >= _max_streams_uni_remote) {
            NetException e;
            e[NetException::Error] << "quic::openStream: uni stream limit reached ("
                                   << _max_streams_uni_remote << ")";
            throw e;
        }
    }
    
    uint64_t stream_id;
    
    if (bidirectional) {
        // Client-initiated bidi: 0, 4, 8, ...
        // Server-initiated bidi: 1, 5, 9, ...
        stream_id = _next_stream_id_bidi;
        _next_stream_id_bidi += 4;
        _streams_bidi_opened++;
    } else {
        // Client-initiated uni: 2, 6, 10, ...
        // Server-initiated uni: 3, 7, 11, ...
        stream_id = _next_stream_id_uni;
        _next_stream_id_uni += 4;
        _streams_uni_opened++;
    }
    
    // Adjust for client/server
    if (_is_server) {
        stream_id |= 0x01;  // Server-initiated streams have LSB = 1
    }
    
    // Create stream with peer's advertised per-stream limits
    Stream& stream = _streams[stream_id];
    stream.stream_id = stream_id;
    stream.max_data_local = 16777216;
    if (bidirectional) {
        // For streams we initiate, the remote limit is the peer's bidi_remote param
        stream.max_data_remote = _is_server
            ? _peer_initial_max_stream_data_bidi_remote
            : _peer_initial_max_stream_data_bidi_local;
    } else {
        stream.max_data_remote = _peer_initial_max_stream_data_uni;
    }
    stream.max_data_local_committed = stream.max_data_local;
    
    return stream_id;
}

// ============================================================================
// Stream Management: Close Stream
// ============================================================================

void quic::closeStream(uint64_t stream_id) {
    std::lock_guard<std::recursive_mutex> lock(quic_mtx());

    auto it = _streams.find(stream_id);
    if (it == _streams.end()) {
        return;
    }

    Stream& stream = it->second;

    // Send FIN if we haven't already
    if (!stream.send_fin) {
        // Send empty STREAM frame with FIN bit
        std::vector<uint8_t> stream_frame = buildStreamFrame(
            stream_id, {}, stream.send_offset, true);

        std::vector<uint8_t> packet = buildShortHeaderPacket(stream_frame);
        applyHeaderProtection(packet, EncryptionLevel::Application, _app_keys.send);

        if (_Socket >= 0) {
            sendPacket(packet.data(), packet.size());
        }

        stream.send_fin = true;
    }

    // The caller is closing this stream even though the peer's side hasn't
    // finished (no FIN/RESET received yet) — e.g. abandoning a redundant
    // erasure-coded fetch once enough other shards already arrived. Without
    // telling the peer, it has no reason to hurry its send, so its own
    // retirement (and the MAX_STREAMS credit that depends on it) may not
    // happen for a long time, or ever. STOP_SENDING asks it to abort now.
    // Only worth sending if we actually asked the peer for something
    // (send_offset > 0) — a stream opened and closed without ever sending
    // data has nothing in flight to stop, and firing STOP_SENDING for every
    // such no-op stream is needless wire traffic (observed to visibly
    // degrade unrelated traffic on the same connection when done in bulk).
    if (!stream.recv_fin && stream.send_offset > 0) {
        // Already confirmed to exist (it, above) — skip stopSending()'s own
        // existence check and go straight to the wire-building helper.
        sendStopSendingFrame(stream_id, 0x0102);
    }

    retireStreamIfDone(stream_id, it);
}

// If a stream has terminated in both directions, erase it and — for a
// peer-initiated stream — grant the peer fresh stream-count credit.
// Assumes quic_mtx() is already held by the caller (recursive mutex, so
// re-entrant calls from within this function are safe too).
void quic::retireStreamIfDone(uint64_t stream_id) {
    auto it = _streams.find(stream_id);
    if (it == _streams.end()) return;
    retireStreamIfDone(stream_id, it);
}

void quic::retireStreamIfDone(uint64_t stream_id, std::unordered_map<uint64_t, Stream>::iterator it) {
    Stream& stream = it->second;

    // If both sides have sent FIN, remove stream and replenish peer's budget
    if (stream.recv_fin && stream.send_fin) {
        bool peer_bidi = _is_server ? (stream_id % 4 == 0) : (stream_id % 4 == 1);
        bool peer_uni  = _is_server ? (stream_id % 4 == 2) : (stream_id % 4 == 3);
        markStreamClosed(stream_id);
        _streams.erase(it);
        if (peer_bidi) {
            _max_streams_bidi_local++;
            std::vector<uint8_t> ms_frame;
            ms_frame.push_back(0x12);
            uint8_t ms_buf[8];
            size_t ms_bytes = encodeVarInt(_max_streams_bidi_local, ms_buf);
            ms_frame.insert(ms_frame.end(), ms_buf, ms_buf + ms_bytes);
            std::vector<uint8_t> ms_pkt = buildShortHeaderPacket(ms_frame);
            applyHeaderProtection(ms_pkt, EncryptionLevel::Application, _app_keys.send);
            sendPacket(ms_pkt.data(), ms_pkt.size());
        } else if (peer_uni) {
            _max_streams_uni_local++;
            std::vector<uint8_t> ms_frame;
            ms_frame.push_back(0x13);
            uint8_t ms_buf[8];
            size_t ms_bytes = encodeVarInt(_max_streams_uni_local, ms_buf);
            ms_frame.insert(ms_frame.end(), ms_buf, ms_buf + ms_bytes);
            std::vector<uint8_t> ms_pkt = buildShortHeaderPacket(ms_frame);
            applyHeaderProtection(ms_pkt, EncryptionLevel::Application, _app_keys.send);
            sendPacket(ms_pkt.data(), ms_pkt.size());
        }
    }
}

// ============================================================================
// RESET_STREAM: Abort a stream abnormally (RFC 9000 §19.4)
// ============================================================================

void quic::resetStream(uint64_t stream_id, uint64_t error_code) {
    std::lock_guard<std::recursive_mutex> lock(quic_mtx());

    auto it = _streams.find(stream_id);
    uint64_t final_size = (it != _streams.end()) ? it->second.send_offset : 0;

    std::vector<uint8_t> frame;
    frame.push_back(0x04); // RESET_STREAM frame type
    uint8_t buf[8];
    size_t bytes = encodeVarInt(stream_id, buf);
    frame.insert(frame.end(), buf, buf + bytes);
    bytes = encodeVarInt(error_code, buf);
    frame.insert(frame.end(), buf, buf + bytes);
    bytes = encodeVarInt(final_size, buf);
    frame.insert(frame.end(), buf, buf + bytes);

    std::vector<uint8_t> packet = buildShortHeaderPacket(frame);
    applyHeaderProtection(packet, EncryptionLevel::Application, _app_keys.send);
    ssize_t sent = sendPacket(packet.data(), packet.size());
    if (sent < 0) {
        // Best-effort immediate retry — do not sleep here since the
        // caller typically holds quic_mtx().
        sendPacket(packet.data(), packet.size());
    }

    // No further STREAM frames should follow an abort.
    if (it != _streams.end()) {
        it->second.send_fin = true;
        // Already have a valid iterator from the lookup above — pass it
        // directly instead of making retireStreamIfDone() re-find stream_id.
        retireStreamIfDone(stream_id, it);
    } else {
        retireStreamIfDone(stream_id);
    }
}

// ============================================================================
// STOP_SENDING: ask the peer to abort its send side (RFC 9000 §19.5)
// ============================================================================

void quic::stopSending(uint64_t stream_id, uint64_t error_code) {
    std::lock_guard<std::recursive_mutex> lock(quic_mtx());

    if (_streams.find(stream_id) == _streams.end()) return;
    sendStopSendingFrame(stream_id, error_code);
}

void quic::sendStopSendingFrame(uint64_t stream_id, uint64_t error_code) {
    std::vector<uint8_t> frame;
    frame.push_back(0x05); // STOP_SENDING frame type
    uint8_t buf[8];
    size_t bytes = encodeVarInt(stream_id, buf);
    frame.insert(frame.end(), buf, buf + bytes);
    bytes = encodeVarInt(error_code, buf);
    frame.insert(frame.end(), buf, buf + bytes);

    std::vector<uint8_t> packet = buildShortHeaderPacket(frame);
    applyHeaderProtection(packet, EncryptionLevel::Application, _app_keys.send);
    ssize_t sent = sendPacket(packet.data(), packet.size());
    if (sent < 0) {
        // Best-effort immediate retry — do not sleep here since the
        // caller typically holds quic_mtx().
        sendPacket(packet.data(), packet.size());
    }
}

// ============================================================================
// TLS 1.3 Handshake: sendClientHello (wrapper for connect flow)
// ============================================================================

void quic::sendClientHello() {
    // Generate ECDHE key pair if not already done
    if (_ecdhe_public.empty()) {
        generateECDHEKeyPair();
    }
    
    // Build ClientHello TLS message
    std::vector<uint8_t> client_hello = buildClientHello();
    
    // Build CRYPTO frame containing ClientHello
    std::vector<uint8_t> crypto_frame = buildCryptoFrame(client_hello, 0);
    
    // Pad Initial packet to at least 1200 bytes (RFC 9000 requirement)
    size_t min_payload = 1200 - 100;  // Account for header overhead
    while (crypto_frame.size() < min_payload) {
        crypto_frame.push_back(0x00);  // PADDING frame
    }
    
    // Build Initial packet
    std::vector<uint8_t> packet = buildInitialPacket(crypto_frame);
    
    // Apply header protection
    applyHeaderProtection(packet, EncryptionLevel::Initial, _initial_keys.send);

    // Send Initial packet
    if (_Socket >= 0) {
        sendPacket(packet.data(), packet.size());
    }
}

// ============================================================================
// TLS 1.3 Handshake: sendServerHello (Server response to ClientHello)
// ============================================================================

void quic::sendServerHello() {
    if (!_is_server) return;
    QUIC_DBG("sendServerHello: cipher=0x%04x alpn=%s", _selected_cipher, _selected_alpn.c_str());
    
    
    // Generate server ECDHE key pair
    if (_ecdhe_public.empty()) {
        generateECDHEKeyPair();
    }
    
    
    // Build ServerHello message
    std::vector<uint8_t> server_hello;
    
    // Handshake type: ServerHello (0x02)
    server_hello.push_back(0x02);
    
    // Length placeholder (3 bytes)
    size_t length_pos = server_hello.size();
    server_hello.push_back(0x00);
    server_hello.push_back(0x00);
    server_hello.push_back(0x00);
    
    // Legacy version: TLS 1.2 (0x0303)
    server_hello.push_back(0x03);
    server_hello.push_back(0x03);
    
    // Server random (32 bytes)
    _server_random.resize(32);
    fillRandomBytes(_server_random.data(), 32);
    server_hello.insert(server_hello.end(), _server_random.begin(), _server_random.end());
    
    // Legacy session ID echo - must echo the client's session_id
    server_hello.push_back(static_cast<uint8_t>(_client_session_id.size()));
    if (!_client_session_id.empty()) {
        server_hello.insert(server_hello.end(), _client_session_id.begin(), _client_session_id.end());
    }
    
    // Cipher suite: Use the negotiated cipher
    server_hello.push_back(static_cast<uint8_t>((_selected_cipher >> 8) & 0xFF));
    server_hello.push_back(static_cast<uint8_t>(_selected_cipher & 0xFF));
    
    // Legacy compression method
    server_hello.push_back(0x00);
    
    // Extensions
    std::vector<uint8_t> extensions;
    
    // Extension: supported_versions (0x002b) - TLS 1.3
    extensions.push_back(0x00);
    extensions.push_back(0x2b);
    extensions.push_back(0x00);
    extensions.push_back(0x02);  // Length
    extensions.push_back(0x03);
    extensions.push_back(0x04);  // TLS 1.3
    
    // Extension: key_share (0x0033)
    extensions.push_back(0x00);
    extensions.push_back(0x33);
    extensions.push_back(0x00);
    extensions.push_back(0x24);  // Length: 36 bytes
    extensions.push_back(0x00);
    extensions.push_back(0x1d);  // x25519
    extensions.push_back(0x00);
    extensions.push_back(0x20);  // 32 bytes
    extensions.insert(extensions.end(), _ecdhe_public.begin(), _ecdhe_public.end());

    // Extension: pre_shared_key (0x0029) — confirms PSK (and therefore
    // early_data, alongside EncryptedExtensions' own confirmation below)
    // acceptance. selected_identity is always 0: this implementation never
    // offers/accepts more than one PSK identity per ClientHello.
    if (_early_data_accepted) {
        extensions.push_back(0x00); extensions.push_back(0x29);
        extensions.push_back(0x00); extensions.push_back(0x02); // length = 2
        extensions.push_back(0x00); extensions.push_back(0x00); // selected_identity = 0
    }

    // Extensions length
    server_hello.push_back(static_cast<uint8_t>(extensions.size() >> 8));
    server_hello.push_back(static_cast<uint8_t>(extensions.size()));
    server_hello.insert(server_hello.end(), extensions.begin(), extensions.end());
    
    // Fill in length
    size_t msg_len = server_hello.size() - 4;
    server_hello[length_pos] = static_cast<uint8_t>(msg_len >> 16);
    server_hello[length_pos + 1] = static_cast<uint8_t>(msg_len >> 8);
    server_hello[length_pos + 2] = static_cast<uint8_t>(msg_len);
    
    // Add to transcript
    _tls_transcript.insert(_tls_transcript.end(), server_hello.begin(), server_hello.end());
    
    // Build ACK frame for client's Initial packets
    std::vector<uint8_t> ack_frame = buildAckFrame(_initial_pn_recv, 0, _initial_pn_recv_ranges);
    
    // Build CRYPTO frame
    std::vector<uint8_t> crypto_frame = buildCryptoFrame(server_hello, 0);
    
    // Combine ACK + CRYPTO frames
    std::vector<uint8_t> payload;
    payload.insert(payload.end(), ack_frame.begin(), ack_frame.end());
    payload.insert(payload.end(), crypto_frame.begin(), crypto_frame.end());

    // Pad Initial packet to minimum 1200 bytes (RFC 9000)
    // Use the current CID sizes to compute header length and total packet size.
    const size_t pn_len = 4;
    const size_t token_len = 0;
    size_t padding_guard = 0;
    while (padding_guard++ < 4) {
        size_t total_len = pn_len + payload.size() + 16;
        size_t len_varint = varIntSize(total_len);
        size_t header_len = 1 + 4 + 1 + _remote_cid.size() + 1 + _local_cid.size() +
                            varIntSize(token_len) + len_varint + pn_len;
        size_t packet_len = header_len + payload.size() + 16;
        if (packet_len >= 1200) {
            break;
        }
        size_t pad_len = 1200 - packet_len;
        payload.insert(payload.end(), pad_len, 0x00);
    }
    
    // Build Initial packet for ServerHello (with ACK + CRYPTO)
    std::vector<uint8_t> packet = buildInitialPacket(payload);
    
    applyHeaderProtection(packet, EncryptionLevel::Initial, _initial_keys.send);

    // Send Initial packet with ServerHello
    if (_Socket >= 0) {
        ssize_t sent = sendPacket(packet.data(), packet.size());
        (void)sent;
    }
    
    
    // Derive handshake keys from shared secret
    deriveHandshakeKeys(_ecdhe_shared);
    QUIC_DBG("sendServerHello: handshake keys derived, sending flight...");
    
    
    // Send EncryptedExtensions, Certificate, CertificateVerify, Finished
    sendServerHandshakeFlight();
}

// ============================================================================
// TLS 1.3 Server Handshake: Send Encrypted Flight
// ============================================================================

void quic::sendServerHandshakeFlight() {
    QUIC_DBG("sendServerHandshakeFlight: starting");
    std::vector<uint8_t> flight;
    
    // EncryptedExtensions (0x08)
    std::vector<uint8_t> enc_ext;
    enc_ext.push_back(0x08);  // Type
    
    // Build extensions data
    std::vector<uint8_t> ext_data;
    
    // Extension 1: ALPN (0x0010)
    std::vector<uint8_t> alpn_ext = buildAlpnExtension(_selected_alpn);
    ext_data.insert(ext_data.end(), alpn_ext.begin(), alpn_ext.end());
    
    // Extension 2: quic_transport_parameters (0x0039)
    std::vector<uint8_t> transport_params = buildTransportParameters();
    ext_data.push_back(0x00);
    ext_data.push_back(0x39);
    ext_data.push_back(static_cast<uint8_t>(transport_params.size() >> 8));
    ext_data.push_back(static_cast<uint8_t>(transport_params.size()));
    ext_data.insert(ext_data.end(), transport_params.begin(), transport_params.end());

    // Extension 3: early_data (0x002a), empty — confirms 0-RTT acceptance
    // (RFC 8446 §4.2.10). Together with ServerHello's pre_shared_key
    // extension above, this is what a client checks in
    // processEncryptedExtensions() to learn whether its 0-RTT offer was
    // accepted or it needs to fall back to retransmitting at 1-RTT.
    if (_early_data_accepted) {
        ext_data.push_back(0x00); ext_data.push_back(0x2a);
        ext_data.push_back(0x00); ext_data.push_back(0x00); // length = 0
    }

    // EncryptedExtensions message: Type(1) + Length(3) + Extensions_length(2) + Extensions_data
    // Message length = 2 (extensions length field) + extensions data size
    size_t ee_msg_len = 2 + ext_data.size();
    enc_ext.push_back(static_cast<uint8_t>(ee_msg_len >> 16));
    enc_ext.push_back(static_cast<uint8_t>(ee_msg_len >> 8));
    enc_ext.push_back(static_cast<uint8_t>(ee_msg_len));
    
    // Extensions length field (2 bytes, big-endian)
    enc_ext.push_back(static_cast<uint8_t>(ext_data.size() >> 8));
    enc_ext.push_back(static_cast<uint8_t>(ext_data.size()));
    enc_ext.insert(enc_ext.end(), ext_data.begin(), ext_data.end());
    
    flight.insert(flight.end(), enc_ext.begin(), enc_ext.end());
    _tls_transcript.insert(_tls_transcript.end(), enc_ext.begin(), enc_ext.end());
    
    // Certificate (0x0b) - if we have a certificate
    if (_selected_cert != nullptr) {
        std::vector<uint8_t> cert_msg;
        cert_msg.push_back(0x0b);  // Type
        
        // Certificate request context (empty for server)
        std::vector<uint8_t> cert_body;
        cert_body.push_back(0x00);  // Context length = 0
        
        // Build certificate entries: leaf cert + chain certs
        std::vector<uint8_t> cert_entries;
        
        // Leaf certificate entry
        const auto& cert_der = _selected_cert->cert.derData();
        cert_entries.push_back(static_cast<uint8_t>(cert_der.size() >> 16));
        cert_entries.push_back(static_cast<uint8_t>(cert_der.size() >> 8));
        cert_entries.push_back(static_cast<uint8_t>(cert_der.size()));
        cert_entries.insert(cert_entries.end(), cert_der.begin(), cert_der.end());
        cert_entries.push_back(0x00);  // Extensions length (2 bytes)
        cert_entries.push_back(0x00);
        
        // Chain certificates (CA certs from selected bundle)
        for (const auto& chain_cert : _selected_cert->chain) {
            cert_entries.push_back(static_cast<uint8_t>(chain_cert.size() >> 16));
            cert_entries.push_back(static_cast<uint8_t>(chain_cert.size() >> 8));
            cert_entries.push_back(static_cast<uint8_t>(chain_cert.size()));
            cert_entries.insert(cert_entries.end(), chain_cert.begin(), chain_cert.end());
            cert_entries.push_back(0x00);  // Extensions length (2 bytes)
            cert_entries.push_back(0x00);
        }
        
        // Certificate list length (3 bytes) - covers all entries
        cert_body.push_back(static_cast<uint8_t>(cert_entries.size() >> 16));
        cert_body.push_back(static_cast<uint8_t>(cert_entries.size() >> 8));
        cert_body.push_back(static_cast<uint8_t>(cert_entries.size()));
        cert_body.insert(cert_body.end(), cert_entries.begin(), cert_entries.end());
        
        // Certificate message length
        cert_msg.push_back(static_cast<uint8_t>(cert_body.size() >> 16));
        cert_msg.push_back(static_cast<uint8_t>(cert_body.size() >> 8));
        cert_msg.push_back(static_cast<uint8_t>(cert_body.size()));
        cert_msg.insert(cert_msg.end(), cert_body.begin(), cert_body.end());
        
        
        flight.insert(flight.end(), cert_msg.begin(), cert_msg.end());
        _tls_transcript.insert(_tls_transcript.end(), cert_msg.begin(), cert_msg.end());
        
        // CertificateVerify (0x0f)
        std::vector<uint8_t> cert_verify;
        cert_verify.push_back(0x0f);
        
        // Signature algorithm selection based on cipher suite
        const bool prefer_sha384 = (_selected_cipher == 0x1302);
        const bool use_sha384_cv = prefer_sha384 && _sigalg_rsa_pss_sha384;
        std::vector<uint8_t> cv_body;
        
        if (use_sha384_cv) {
            cv_body.push_back(0x08);
            cv_body.push_back(0x05);  // rsa_pss_rsae_sha384
        } else {
            cv_body.push_back(0x08);
            cv_body.push_back(0x04);  // rsa_pss_rsae_sha256
        }
        
        // Build content to sign: 64 spaces + context + 0x00 + transcript hash
        // The transcript hash MUST use the cipher suite's hash function
        std::vector<uint8_t> sign_content;
        sign_content.reserve(64 + 34 + 1 + 64);  // 64 spaces + context + 0x00 + max hash
        sign_content.resize(64, 0x20);  // 64 spaces
        const char* context = "TLS 1.3, server CertificateVerify";
        sign_content.insert(sign_content.end(), context, context + strlen(context));
        sign_content.push_back(0x00);
        
        // Use cipher-appropriate hash for transcript
        std::vector<uint8_t> cv_transcript_hash = use_sha384_cv ? 
            sha384_hash(_tls_transcript) : sha256_hash(_tls_transcript);
        sign_content.insert(sign_content.end(), cv_transcript_hash.begin(), cv_transcript_hash.end());
        
        // Use appropriate RSA-PSS signing based on cipher suite
        std::vector<uint8_t> signature;
        if (use_sha384_cv) {
            signature = rsa_pss_sha384::sign(
                _selected_cert->rsa_key, sign_content);
        } else {
            signature = rsa_pss_sha256::sign(
                _selected_cert->rsa_key, sign_content);
        }
        
        // Signature length and data
        cv_body.push_back(static_cast<uint8_t>(signature.size() >> 8));
        cv_body.push_back(static_cast<uint8_t>(signature.size()));
        cv_body.insert(cv_body.end(), signature.begin(), signature.end());
        
        // CertificateVerify length
        cert_verify.push_back(static_cast<uint8_t>(cv_body.size() >> 16));
        cert_verify.push_back(static_cast<uint8_t>(cv_body.size() >> 8));
        cert_verify.push_back(static_cast<uint8_t>(cv_body.size()));
        cert_verify.insert(cert_verify.end(), cv_body.begin(), cv_body.end());
        
        flight.insert(flight.end(), cert_verify.begin(), cert_verify.end());
        _tls_transcript.insert(_tls_transcript.end(), cert_verify.begin(), cert_verify.end());
    }
    
    // Finished (0x14)
    std::vector<uint8_t> finished;
    finished.push_back(0x14);
    
    // Compute verify_data using cipher-appropriate hash:
    // finished_key = HKDF-Expand-Label(server_handshake_traffic_secret, "finished", "", hash_len)
    // verify_data = HMAC(finished_key, Transcript-Hash)
    const bool use_sha384 = (_selected_cipher == 0x1302);  // TLS_AES_256_GCM_SHA384
    const size_t hash_len = use_sha384 ? 48 : 32;
    const uint16_t cipher_suite = use_sha384 ? 0x1302 : 0x1301;
    
    // Use TLS class HKDF method
    std::vector<uint8_t> finished_key = netplus::tls::hkdf_expand_label(
        _s_hs_traffic_secret, "finished", {}, hash_len, cipher_suite);
    
    // Compute transcript hash and verify_data
    std::vector<uint8_t> transcript_hash = use_sha384 
        ? sha384_hash(_tls_transcript)
        : sha256_hash(_tls_transcript);
    std::vector<uint8_t> verify_data = use_sha384
        ? hmac_sha384(finished_key, transcript_hash)
        : hmac_sha256(finished_key, transcript_hash);
    
    // Length (3 bytes) - 48 bytes for SHA-384, 32 bytes for SHA-256
    finished.push_back(0x00);
    finished.push_back(0x00);
    finished.push_back(static_cast<uint8_t>(hash_len));
    
    // Add verify_data
    finished.insert(finished.end(), verify_data.begin(), verify_data.end());
    
    flight.insert(flight.end(), finished.begin(), finished.end());
    _tls_transcript.insert(_tls_transcript.end(), finished.begin(), finished.end());
    
    // Fragment the flight into multiple Handshake packets, each fitting within
    // 1200-byte UDP datagrams (RFC 9000 §14 - max datagram size before PMTUD).
    // Handshake header overhead: 1 (first byte) + 4 (version) + 1 (dcid len) +
    //   dcid + 1 (scid len) + scid + 2 (length varint, ≤1200) + 4 (PN) = ~13+CIDs
    // CRYPTO frame overhead: 1 (type) + varint(offset) + varint(len) ≈ 3-7 bytes
    // AEAD tag: 16 bytes
    const size_t MAX_DGRAM = 1200;
    const size_t hs_header_len = 1 + 4 + 1 + _remote_cid.size() + 1 + _local_cid.size() + 2 + 4;
    const size_t aead_tag_len = 16;
    // Conservative max CRYPTO frame overhead: type(1) + offset(4) + length(2) = 7
    const size_t crypto_overhead = 7;
    const size_t max_crypto_data = MAX_DGRAM - hs_header_len - aead_tag_len - crypto_overhead;

    size_t flight_offset = 0;
    size_t fragment_num = 0;
    
    // Clear any previous flight storage and prepare for retransmission
    _hs_flight_packets.clear();
    
    while (flight_offset < flight.size()) {
        size_t chunk_size = std::min(max_crypto_data, flight.size() - flight_offset);
        
        std::vector<uint8_t> chunk(flight.begin() + flight_offset,
                                    flight.begin() + flight_offset + chunk_size);
        std::vector<uint8_t> crypto_frame = buildCryptoFrame(chunk, flight_offset);
        std::vector<uint8_t> packet = buildHandshakePacket(crypto_frame);
        
        applyHeaderProtection(packet, EncryptionLevel::Handshake, _hs_keys.send);

        // Store for retransmission before sending
        _hs_flight_packets.push_back(packet);
        
        if (_Socket >= 0) {
            sendPacket(packet.data(), packet.size());
        }
        
        flight_offset += chunk_size;
        fragment_num++;
    }
    
    // Record send time for retransmission timer
    _hs_last_send = std::chrono::steady_clock::now();
    _hs_retransmit_count = 0;
    QUIC_DBG("sendServerHandshakeFlight: sent %zu fragments, flight=%zu bytes\", deriving app keys...",
             fragment_num, flight.size());
    
    // Derive application keys
    deriveApplicationKeys();
    QUIC_DBG("sendServerHandshakeFlight: app keys derived, done");
}

// ============================================================================
// TLS 1.3 Handshake: Compute Shared Secret from Client Key Share
// ============================================================================

void quic::computeSharedSecret(const std::vector<uint8_t>& peer_public_key) {
    if (peer_public_key.size() != 32) return;
    
    _ecdhe_shared.resize(32);
    std::vector<uint8_t> priv_vec(_ecdhe_private.begin(), _ecdhe_private.end());
    scalarmult_curve25519(_ecdhe_shared, priv_vec, peer_public_key);
}

#ifdef Windows
// ============================================================================
// Windows-specific: AcceptEx-based accept (not used for QUIC/UDP)
// ============================================================================

void quic::accept(LPFN_ACCEPTEX lpfnAcceptEx, std::unique_ptr<socket>& csock) {
    // QUIC runs over UDP and does not use AcceptEx
    (void)lpfnAcceptEx;
    (void)csock;
}

void quic::prime_read() {
    DWORD bytesRecv = 0;
    DWORD dwFlags = 0;

    if (!_ReadBuffer)
        _ReadBuffer.reset(new buffer);

    std::memset(&_ReadBuffer->overlapped, 0, sizeof(WSAOVERLAPPED));
    _ReadBuffer->operation = OP_READ;

    int ret = ::WSARecv(_Socket, &_ReadBuffer->data, 1, &bytesRecv, &dwFlags,
        &_ReadBuffer->overlapped, nullptr);

    if (ret == SOCKET_ERROR) {
        int err = ::WSAGetLastError();
        if (err != WSA_IO_PENDING) {
            NetException e;
            e[NetException::Error] << "quic::prime_read failed: " << err;
            throw e;
        }
    }
}
#endif

} // namespace netplus
