import { assert } from "../../../core/assert.js";
import { BinaryBuffer } from "../../../core/binary/BinaryBuffer.js";
import Signal from "../../../core/events/signal/Signal.js";
import { Replicator } from "../replication/Replicator.js";
import { AlwaysRelevantScope } from "../replication/ScopeFilter.js";
import { ActionLog } from "../sim/ActionLog.js";
import { ReplicatedComponentRegistry } from "../sim/ReplicatedComponentRegistry.js";
import { SimActionExecutor } from "../sim/SimActionExecutor.js";
import { SimActionRegistry } from "../sim/SimActionRegistry.js";
import { snapshotter_emit } from "../sim/Snapshotter.js";
import { Baseline } from "../state/Baseline.js";
import { ChangedEntitySet } from "../state/ChangedEntitySet.js";
import { MutationLedger } from "../state/MutationLedger.js";
import { ReplicationSlotTable } from "../state/ReplicationSlotTable.js";
import { Channel } from "../transport/Channel.js";
import { resend_fragment_chunk, send_fragmented } from "../transport/fragments/fragment_send.js";
import { FragmentAssembler } from "../transport/fragments/FragmentAssembler.js";
import { FragmentRetention } from "../transport/fragments/FragmentRetention.js";
import { MAX_CHANNEL_PAYLOAD_BYTES } from "../transport/fragments/packet_size.js";
import { ReliableCommandPipeline } from "../transport/ReliableCommandPipeline.js";

// DISCONNECT packets carry a short UTF-8 reason label. The encoder/decoder
// pair are stateless after construction; sharing module-level singletons
// avoids allocating a fresh pair on every send/receive of this packet type.
const DISCONNECT_TEXT_ENCODER = new TextEncoder();
const DISCONNECT_TEXT_DECODER = new TextDecoder();

/**
 * Wire packet types. The first byte of every NetworkPeer-routed payload picks
 * the dispatch path. New types can be added without breaking the action stream
 * because old peers will just see an unknown type and drop the packet.
 *
 * @readonly
 * @enum {number}
 */
export const NetworkPacketType = {
    ACTION_STREAM: 0,
    RECOVERY_REQUEST: 1,
    STATE_BURST: 2,
    /**
     * Server → client authoritative state for one entity, tagged with the
     * server frame the state represents. Used by client-side prediction to
     * trigger rewind+replay reconciliation: "your entity should have looked
     * like this at frame F."
     */
    AUTH_STATE: 3,
    /**
     * One chunk of a fragmented logical payload. Used transparently by the
     * peer when an outgoing message exceeds the channel's single-packet
     * capacity. The fragment header (uint16 message_id, uint8 chunk_index,
     * uint8 total_chunks) follows this type byte; the chunk payload follows
     * that. The reassembled bytes are re-dispatched through this same
     * switch (so any of the other packet types can be carried fragmented).
     */
    FRAGMENT: 4,
    /**
     * Reliable command. Carries a uintVar logical_seq followed by a
     * caller-defined payload. Delivered at-least-once with sender-side
     * retransmit and receiver-side dedup via {@link ReliableCommandPipeline}.
     * Used for chat, level transitions, lobby state — anything where
     * loss-tolerance of the action-stream model isn't acceptable.
     */
    RELIABLE_COMMAND: 5,
    /**
     * Selective-retransmit request from the receiver of a fragmented
     * message. Wire layout (after the leading type byte):
     *
     *   uint16  message_id (LE)
     *   uint8   missing_count
     *   uint8   missing_indices[missing_count]
     *
     * The sender's {@link FragmentRetention} looks up the retained
     * source bytes and re-emits the listed chunks via
     * {@link resend_fragment_chunk}. Receiver drives NACK timing
     * via {@link FragmentAssembler#service}.
     */
    NACK_FRAGMENT: 6,
    /**
     * Server → client time-dilation feedback. Wire layout:
     *   int16  buffer_depth (LE)
     *
     * `buffer_depth = max_received_frame - current_sim_frame` from the
     * server's POV. Client feeds it to {@link TimeDilation} and dilates
     * its tick clock to keep the buffer near target.
     */
    TIME_DILATION: 7,
    /**
     * Server → newly-connected peer initial-state snapshot. Wire layout:
     *   16 bytes  session_token  (UUID v1, identifies this peer-session
     *                             for later RESUME_HELLO authentication)
     *   varint    frame_number   (server sim_frame this snapshot represents)
     *   bytes     payload        (Snapshotter wire format)
     *
     * Typically exceeds MTU; fragment-send handles chunking.
     */
    INITIAL_SYNC: 8,
    /**
     * Client → server reconnection request. Sent after a transport rebuild
     * to claim continuity with a prior session record. Wire layout:
     *   uintVar   local_peer_id
     *   uintVar   last_acked_frame
     *   16 bytes  session_token   (from a prior INITIAL_SYNC)
     */
    RESUME_HELLO: 9,
    /**
     * Server → client: resume accepted. Empty payload — the action
     * stream will resume from `last_acked + 1` on the next host tick.
     */
    RESUME_ACCEPT: 10,
    /**
     * Server → client: resume rejected. Wire layout:
     *   uint8 reason_code  (see {@link ResumeRejectReason})
     *
     * A fresh INITIAL_SYNC typically follows on the next host tick;
     * the client drops unconfirmed predictions and accepts the resync.
     */
    RESUME_REJECT: 11,
    /**
     * Either direction. Wire layout:
     *   uint8   reason_label_length
     *   bytes   reason_label   (UTF-8, opaque to engine)
     *
     * Client → server: lets the host free state immediately instead of
     * waiting for the grace timer.
     * Server → client: the host called `drop_peer(peer_id, reason)`;
     * the client surfaces `onConnectionPermanentlyLost(reason)` and
     * does NOT attempt reconnect.
     */
    DISCONNECT: 12,
};

/**
 * Reason codes for {@link NetworkPacketType.RESUME_REJECT}.
 * @readonly @enum {number}
 */
export const ResumeRejectReason = Object.freeze({
    UnknownPeer: 0,
    GraceExpired: 1,
    TokenMismatch: 2,
    PeerIdCollision: 3,
});

/**
 * Per-tick orchestrator that wires the action infrastructure to one or more
 * peers over a Transport.
 *
 * Holds a single world (`EntityComponentDataset`) plus the action machinery
 * (executor, replicator, action log). For each connected peer it owns a
 * `Channel` and a `seq → frame_end` map so that ack notifications advance the
 * `Baseline.last_acked(peer_id)` watermark.
 *
 * Use as both server (many connected peers) and client (one peer = the server).
 * The asymmetry is in *who* connects — the orchestrator itself is symmetric.
 *
 * Lifecycle:
 *   1. `connect_peer(peer_id, transport)` — wire a transport for a peer.
 *   2. Per tick:
 *      - `begin_tick(frame_number)` — open the action log for this frame.
 *      - User code runs game logic, calling `executor.execute(action)` for each
 *        replicated state mutation. Inbound actions from peers also flow into
 *        the executor automatically (via the channel.onPayload wiring).
 *      - `end_tick()` — close the frame and dispatch outgoing packets to every peer.
 *   3. `disconnect_peer(peer_id)` — release the channel + baseline state.
 *
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */
export class NetworkPeer {
    #changed_entities;
    #peers;
    #send_buffer;
    #recv_buffer;
    #fragment_scratch;
    #next_fragment_message_id;
    #tick_open;
    #current_frame;


    /**
     * @param {{
     *   world: EntityComponentDataset,
     *   binary_registry: BinarySerializationRegistry,
     *   replicated_components: Function[],
     *   action_classes: Function[],
     *   scope_filter?: { is_entity_in_scope(peer_id: number, network_id: number): boolean },
     *   frame_capacity?: number,
     *   initial_buffer_size?: number,
     *   mutation_ledger?: MutationLedger|null,
     *   changed_set_capacity?: number,
     * }} options
     */
    constructor({
        world,
        binary_registry,
        replicated_components,
        action_classes,
        scope_filter = new AlwaysRelevantScope(),
        frame_capacity = 32,
        initial_buffer_size = 1024,
        mutation_ledger = null,
        changed_set_capacity = 256,
    }) {
        assert.ok(world && typeof world.createEntity === 'function', 'world must be an EntityComponentDataset');
        assert.ok(binary_registry, 'binary_registry required');
        assert.isFunction(replicated_components.forEach, 'replicated_components must be iterable');
        assert.isFunction(action_classes.forEach, 'action_classes must be iterable');

        /** @type {EntityComponentDataset} */
        this.world = world;

        /** @type {ReplicatedComponentRegistry} */
        this.component_registry = new ReplicatedComponentRegistry(binary_registry);
        for (const klass of replicated_components) {
            this.component_registry.register(klass);
        }

        /** @type {SimActionRegistry} */
        this.action_registry = new SimActionRegistry();
        for (const klass of action_classes) {
            this.action_registry.register(klass);
        }

        /** @type {ReplicationSlotTable} */
        this.slot_table = new ReplicationSlotTable();

        /** @type {ActionLog} */
        this.action_log = new ActionLog({ frame_capacity, initial_buffer_size });

        /**
         * Optional per-tick mutation tracker. When a `MutationLedger` is supplied,
         * this peer records each tick's affected network IDs into a fresh
         * `ChangedEntitySet`, then compacts the set into the ledger at `end_tick`.
         * Server-side peers should provide a ledger so they can answer recovery
         * queries. Receive-only client peers don't need one.
         *
         * @type {MutationLedger|null}
         */
        this.mutation_ledger = mutation_ledger;

        /**
         * Per-tick "what mutated this tick" sink, used only when
         * {@link mutation_ledger} is set. Cleared at the start of each tick;
         * compacted into the ledger at end-of-tick.
         * @type {ChangedEntitySet|null}
         * @private
         */
        this.#changed_entities = mutation_ledger === null
            ? null
            : new ChangedEntitySet({ capacity: changed_set_capacity });

        /** @type {SimActionExecutor} */
        this.executor = new SimActionExecutor({
            world,
            action_log: this.action_log,
            action_registry: this.action_registry,
            component_registry: this.component_registry,
            slot_table: this.slot_table,
            changed_entities: this.#changed_entities,
        });

        /** @type {Replicator} */
        this.replicator = new Replicator({
            action_log: this.action_log,
            action_registry: this.action_registry,
            executor: this.executor,
            slot_table: this.slot_table,
            scope_filter,
        });

        /** @type {Baseline} */
        this.baseline = new Baseline();

        /**
         * peer_id → { channel, transport, seq_to_frame_end, on_payload, on_acked }
         * @type {Map<number, { channel: Channel, transport: Object, seq_to_frame_end: Map<number, number>, on_payload: Function, on_acked: Function }>}
         * @private
         */
        this.#peers = new Map();

        /**
         * Pre-allocated send buffer for `end_tick` packing.
         * @type {BinaryBuffer}
         * @private
         */
        this.#send_buffer = new BinaryBuffer();

        /**
         * Reusable scratch buffer for wrapping inbound payloads as BinaryBuffers
         * for the Replicator to consume. Reused per packet (sequential delivery).
         * @type {BinaryBuffer}
         * @private
         */
        this.#recv_buffer = new BinaryBuffer();

        /**
         * Reusable scratch buffer for outgoing fragment packets. Sized for one
         * channel-level packet (MTU minus channel header).
         * @type {Uint8Array}
         * @private
         */
        this.#fragment_scratch = new Uint8Array(MAX_CHANNEL_PAYLOAD_BYTES);

        /**
         * Monotonically-increasing identifier for fragmented messages.
         * Wraps at uint16. The receiver's FragmentAssembler keys reassembly
         * state on this; collisions would only matter if more than 65536
         * messages were in flight simultaneously, which is impossible
         * under any realistic transport pressure.
         * @type {number}
         * @private
         */
        this.#next_fragment_message_id = 0;

        /** @type {boolean} @private */
        this.#tick_open = false;

        /**
         * Fired when an inbound packet is a `RECOVERY_REQUEST` (peer is asking
         * which entities mutated in a tick range so it can request their state).
         * Args: `(peer_id, start_tick, end_tick)`. Subscriber typically responds
         * via {@link send_state_burst_for_range}.
         * @type {Signal}
         */
        this.onRecoveryRequest = new Signal();

        /**
         * Fired when an inbound packet is a `STATE_BURST` carrying current state
         * for a list of entities (response to a recovery request, or push).
         * Args: `(peer_id, buffer, length)` where `buffer` is positioned just
         * past the packet-type prefix and `length` is the byte count of the
         * burst payload (not counting the prefix). Apply via
         * {@link snapshotter_apply_to_existing}.
         * @type {Signal}
         */
        this.onStateBurst = new Signal();

        /**
         * Fired when an inbound packet is an `AUTH_STATE` (server's authoritative
         * state for one entity, tagged with the frame the state represents).
         * Args: `(peer_id, frame_number, network_id, buffer)` where `buffer` is
         * positioned at the start of the entity's state payload. The handler
         * is responsible for deserializing the bytes and triggering whatever
         * reconciliation policy the application wants (snap, rewind+replay).
         * @type {Signal}
         */
        this.onAuthState = new Signal();

        /**
         * Fires on TIME_DILATION packet arrival.
         * Args: `(peer_id, buffer_depth)` — signed int16.
         * @type {Signal}
         */
        this.onTimeDilationFeedback = new Signal();

        /**
         * Fires on INITIAL_SYNC packet arrival.
         * Args: `(peer_id, session_token, frame_number, buffer, payload_end)`.
         * `session_token` is a `Uint8Array(16)` (UUID v1 raw bytes); the
         * buffer is positioned at the start of the Snapshotter payload.
         * @type {Signal}
         */
        this.onInitialSync = new Signal();

        /**
         * Fires on RESUME_HELLO arrival (host-only path in practice).
         * Args: `(peer_id, local_peer_id, last_acked_frame, session_token)`.
         * Subscriber decides whether to accept (call
         * {@link send_resume_accept}) or reject (call
         * {@link send_resume_reject}).
         * @type {Signal}
         */
        this.onResumeHello = new Signal();

        /**
         * Fires on RESUME_ACCEPT arrival (client-only in practice).
         * Args: `(peer_id)`.
         * @type {Signal}
         */
        this.onResumeAccept = new Signal();

        /**
         * Fires on RESUME_REJECT arrival (client-only in practice).
         * Args: `(peer_id, reason_code)` — see {@link ResumeRejectReason}.
         * @type {Signal}
         */
        this.onResumeReject = new Signal();

        /**
         * Fires on DISCONNECT arrival.
         * Args: `(peer_id, reason_label)` — `reason_label` is a string,
         * opaque to the engine.
         * @type {Signal}
         */
        this.onDisconnectPacket = new Signal();

        /**
         * Fired when a reliable command arrives from a peer (delivered via
         * {@link ReliableCommandPipeline}, dedup'd against retransmits).
         *
         * Args: `(peer_id, buffer, payload_offset, payload_length)`. The
         * buffer is the per-peer scratch and may be reused after the handler
         * returns; consumers must copy the payload bytes if they need them
         * to outlive the call.
         *
         * Used for chat, level transitions, lobby/room state — application-
         * level messages that need at-least-once delivery semantics rather
         * than the action stream's best-effort.
         * @type {Signal}
         */
        this.onReliableCommand = new Signal();
    }

    /**
     * Wire `transport` to the peer identified by `peer_id`. Creates a Channel
     * over the transport and connects ack/payload notifications to the local
     * Baseline / Replicator.
     *
     * @param {number} peer_id
     * @param {{ send(bytes: Uint8Array, length: number): void, onReceive: any }} transport
     */
    connect_peer(peer_id, transport) {
        assert.isNonNegativeInteger(peer_id, 'peer_id');
        if (this.#peers.has(peer_id)) {
            throw new Error(`NetworkPeer.connect_peer: peer ${peer_id} already connected`);
        }

        const channel = new Channel({ transport });
        const seq_to_frame_end = new Map();
        const fragment_assembler = new FragmentAssembler();
        const fragment_retention = new FragmentRetention();
        const reliable_pipeline = new ReliableCommandPipeline({
            channel,
            packet_type: NetworkPacketType.RELIABLE_COMMAND,
        });

        // Closure captured once at connect time so the per-tick service
        // call doesn't allocate. Builds a NACK_FRAGMENT packet inside
        // the orchestrator's shared __send_buffer and ships it over the
        // peer's channel. `indices[0..count)` is a reused internal
        // scratch from the assembler — only valid for the call.
        const on_nack = (message_id, indices, count) => {
            this.#send_buffer.position = 0;
            this.#send_buffer.writeUint8(NetworkPacketType.NACK_FRAGMENT);
            this.#send_buffer.writeUint16LE(message_id);
            this.#send_buffer.writeUint8(count);
            for (let i = 0; i < count; i++) {
                this.#send_buffer.writeUint8(indices[i]);
            }
            channel.send(this.#send_buffer.raw_bytes, this.#send_buffer.position);
        };
        // Forward pipeline deliveries to the user-facing signal, attributing
        // them to the originating peer.
        reliable_pipeline.onCommand.add((buf, payload_offset, payload_length) => {
            this.onReliableCommand.send4(peer_id, buf, payload_offset, payload_length);
        });

        // Recursive dispatch helper. Used for both incoming transport bytes
        // AND for reassembled FRAGMENT payloads (so the same switch handles
        // both transparent and fragmented delivery of any packet type).
        const dispatch = (bytes, length) => {
            if (length < 1) {
                // malformed: no type byte
                return;
            }

            this.#recv_buffer.position = 0;
            this.#recv_buffer.writeBytes(bytes, 0, length);
            this.#recv_buffer.position = 0;

            const packet_type = this.#recv_buffer.readUint8();

            switch (packet_type) {
                case NetworkPacketType.ACTION_STREAM:
                    // The Replicator opens a frame on the action log per packet
                    // (one per sender frame).
                    this.replicator.unpack_from_peer(peer_id, this.#recv_buffer, length);
                    break;
                case NetworkPacketType.RECOVERY_REQUEST: {
                    const start_tick = this.#recv_buffer.readUintVar();
                    const end_tick = this.#recv_buffer.readUintVar();
                    this.onRecoveryRequest.send3(peer_id, start_tick, end_tick);
                    break;
                }
                case NetworkPacketType.STATE_BURST:
                    this.onStateBurst.send3(peer_id, this.#recv_buffer, length);
                    break;
                case NetworkPacketType.AUTH_STATE: {
                    const frame_number = this.#recv_buffer.readUintVar();
                    const network_id = this.#recv_buffer.readUintVar();
                    this.onAuthState.send4(peer_id, frame_number, network_id, this.#recv_buffer);
                    break;
                }
                case NetworkPacketType.FRAGMENT: {
                    // Fragment header layout (after the leading type byte):
                    //   uint16 message_id (LE), uint8 chunk_index, uint8 total_chunks
                    const message_id = this.#recv_buffer.readUint16LE();
                    const chunk_index = this.#recv_buffer.readUint8();
                    const total_chunks = this.#recv_buffer.readUint8();
                    const chunk_offset = this.#recv_buffer.position;
                    const chunk_length = length - chunk_offset;
                    const reassembled = fragment_assembler.receive(
                        message_id, chunk_index, total_chunks,
                        bytes, chunk_offset, chunk_length,
                    );
                    if (reassembled !== null) {
                        // Defence-in-depth: don't allow nested fragmentation
                        // (would indicate a sender bug; ignore silently).
                        if (reassembled[0] !== NetworkPacketType.FRAGMENT) {
                            dispatch(reassembled, reassembled.length);
                        }
                    }
                    break;
                }
                case NetworkPacketType.NACK_FRAGMENT: {
                    // Receiver is asking for specific chunks of a
                    // previously-sent fragmented message.
                    //   uint16 message_id (LE), uint8 missing_count,
                    //   uint8 missing_indices[missing_count]
                    const message_id = this.#recv_buffer.readUint16LE();
                    const missing_count = this.#recv_buffer.readUint8();
                    const entry = fragment_retention.consume_nack(message_id);
                    if (entry === null) {
                        // Either we never retained this id (already evicted /
                        // never sent), or the retry budget for it is exhausted.
                        // Either way: nothing to retransmit. Drop silently.
                        break;
                    }
                    for (let i = 0; i < missing_count; i++) {
                        const chunk_index = this.#recv_buffer.readUint8();
                        resend_fragment_chunk(
                            entry.payload, entry.length, message_id, chunk_index,
                            NetworkPacketType.FRAGMENT, this.#fragment_scratch,
                            (chunk_bytes, chunk_length) => channel.send(chunk_bytes, chunk_length),
                        );
                    }
                    break;
                }
                case NetworkPacketType.RELIABLE_COMMAND:
                    // Pipeline reads logical_seq, dedups, fires its onCommand
                    // (which we forward to the user's onReliableCommand above).
                    reliable_pipeline.handle_inbound(this.#recv_buffer, length);
                    break;
                case NetworkPacketType.TIME_DILATION: {
                    const depth = this.#recv_buffer.readInt16();
                    this.onTimeDilationFeedback.send2(peer_id, depth);
                    break;
                }
                case NetworkPacketType.INITIAL_SYNC: {
                    const session_token = new Uint8Array(16);
                    this.#recv_buffer.readBytes(session_token, 0, 16);
                    const frame_number = this.#recv_buffer.readUintVar();
                    this.onInitialSync.send5(peer_id, session_token, frame_number, this.#recv_buffer, length);
                    break;
                }
                case NetworkPacketType.RESUME_HELLO: {
                    const local_peer_id = this.#recv_buffer.readUintVar();
                    const last_acked_frame = this.#recv_buffer.readUintVar();
                    const session_token = new Uint8Array(16);
                    this.#recv_buffer.readBytes(session_token, 0, 16);
                    this.onResumeHello.send4(peer_id, local_peer_id, last_acked_frame, session_token);
                    break;
                }
                case NetworkPacketType.RESUME_ACCEPT: {
                    this.onResumeAccept.send1(peer_id);
                    break;
                }
                case NetworkPacketType.RESUME_REJECT: {
                    const reason_code = this.#recv_buffer.readUint8();
                    this.onResumeReject.send2(peer_id, reason_code);
                    break;
                }
                case NetworkPacketType.DISCONNECT: {
                    const label_length = this.#recv_buffer.readUint8();
                    let label = '';
                    if (label_length > 0) {
                        const label_bytes = new Uint8Array(label_length);
                        this.#recv_buffer.readBytes(label_bytes, 0, label_length);
                        label = DISCONNECT_TEXT_DECODER.decode(label_bytes);
                    }
                    this.onDisconnectPacket.send2(peer_id, label);
                    break;
                }
                default:
                    // Unknown type — drop. Forward-compat for newer peers.
                    break;
            }
        };

        // Ack: advance Baseline to the highest acked frame_end for this peer.
        const on_acked = (seq) => {
            const frame_end = seq_to_frame_end.get(seq);
            if (frame_end !== undefined) {
                this.baseline.set_acked(peer_id, frame_end);
                seq_to_frame_end.delete(seq);
            }
        };

        channel.onPayload.add(dispatch);
        channel.onPacketAcked.add(on_acked);

        this.#peers.set(peer_id, {
            channel, transport, seq_to_frame_end,
            on_payload: dispatch, on_acked, on_nack,
            fragment_assembler, fragment_retention,
            reliable_pipeline,
        });
    }

    /**
     * Unwire and forget a peer.
     * @param {number} peer_id
     */
    disconnect_peer(peer_id) {
        assert.isNonNegativeInteger(peer_id, 'peer_id');
        const conn = this.#peers.get(peer_id);
        if (conn === undefined) return;

        conn.channel.onPayload.remove(conn.on_payload);
        conn.channel.onPacketAcked.remove(conn.on_acked);
        // Drop the channel's subscription on the transport. Required for the
        // common case of reusing the same transport on a subsequent reconnect:
        // without this, the orphaned channel keeps consuming inbound packets
        // and corrupts ack state in parallel with whatever channel replaces it.
        conn.channel.dispose();

        // Drop any in-flight fragment reassemblies for this peer — a fresh
        // connection on the same peer_id should start clean.
        conn.fragment_assembler.clear();
        // Same for the sender-side retention buffer: nothing about the
        // previous connection's retransmit history should leak across.
        conn.fragment_retention.clear();

        // Detach the reliable pipeline (drops its channel subscriptions and
        // any unacked / received state).
        conn.reliable_pipeline.dispose();

        this.#peers.delete(peer_id);
        this.baseline.forget(peer_id);
    }

    /**
     * Whether `peer_id` is currently connected.
     * @param {number} peer_id
     * @returns {boolean}
     */
    is_connected(peer_id) {
        return this.#peers.has(peer_id);
    }

    /**
     * Direct access to a peer's channel (for sending acks-only packets, raw payloads, etc.).
     * @param {number} peer_id
     * @returns {Channel|undefined}
     */
    channel_for(peer_id) {
        const conn = this.#peers.get(peer_id);
        return conn === undefined ? undefined : conn.channel;
    }

    /**
     * Open the action log for `frame_number`. Call before running game logic
     * for the tick. The user's code (or the engine's `EntityManager.simulate`)
     * runs between `begin_tick` and `end_tick`, calling `executor.execute()`
     * for each replicated state mutation.
     *
     * @param {number} frame_number
     */
    begin_tick(frame_number) {
        assert.isNonNegativeInteger(frame_number, 'frame_number');
        if (this.#tick_open) {
            throw new Error(`NetworkPeer.begin_tick: previous tick still open; call end_tick first`);
        }
        // Reset the per-tick changed-set so this tick starts with a blank slate.
        if (this.#changed_entities !== null) this.#changed_entities.clear();
        this.action_log.begin_frame(frame_number);
        this.#tick_open = true;
        this.#current_frame = frame_number;
    }

    /**
     * Close the current tick and send pending action records to every peer.
     * For each peer, packs frames `[Baseline.last_acked(peer) + 1, current_frame]`
     * and sends via the channel; records the seq→frame_end mapping so the next
     * ack from that peer advances the baseline.
     *
     * If a {@link MutationLedger} was supplied at construction, this tick's
     * accumulated changed-entity set is also compacted into the ledger.
     */
    end_tick() {
        if (!this.#tick_open) {
            throw new Error(`NetworkPeer.end_tick: no tick is open; call begin_tick first`);
        }
        this.action_log.end_frame();
        this.#tick_open = false;

        this.flush_outbound(this.#current_frame);

        // Per-tick fragment maintenance: age out sender-side retention
        // and drive NACK emission for any incomplete inbound messages.
        // Runs AFTER the action-stream send loop so the shared
        // __send_buffer is free for the on_nack closure to reuse.
        const now_ms = performance.now();
        for (const [, conn] of this.#peers) {
            conn.fragment_retention.service(now_ms);
            conn.fragment_assembler.service(now_ms, conn.on_nack);
        }
    }

    /**
     * Pack and dispatch the outbound action stream for `current_frame` to
     * every connected peer. Second half of {@link end_tick} — exposed
     * separately so orchestrators that manage action_log frames directly
     * (e.g. {@link ServerAuthoritativeServer}'s rollback flow, which
     * begin_frame/end_frames many frames per tick) can drive the
     * outbound dispatch without going through begin_tick/end_tick.
     *
     * Assumes the action_log frame for `current_frame` is already CLOSED
     * by the caller. Records the tick's accumulated mutations into the
     * MutationLedger (if configured), then packs frames
     * `[last_acked+1, current_frame]` for each peer via
     * `Replicator.pack_for_peer`. Empty packs (no in-scope actions) are
     * not sent. Clears the per-tick changed-entity set after recording.
     *
     * @param {number} current_frame
     */
    flush_outbound(current_frame) {
        assert.isNonNegativeInteger(current_frame, 'current_frame');

        // Record this tick's mutations into the persistent ledger BEFORE sending.
        // (Order doesn't strictly matter for correctness, but keeping the ledger
        // up-to-date as soon as possible reduces edge-case race windows for
        // recovery requests that arrive immediately after.)
        if (this.mutation_ledger !== null && this.#changed_entities !== null) {
            this.mutation_ledger.record_tick(current_frame, this.#changed_entities);
        }

        for (const [peer_id, conn] of this.#peers) {
            const last_acked = this.baseline.last_acked(peer_id);
            // Pack-range start.
            //
            // For peers with at least one acked frame, this is just
            // last_acked+1 — the standard "catch up since you last
            // confirmed receipt."
            //
            // For freshly-connected peers (no ack yet), we cover the
            // action_log's full ring: max(0, current_frame -
            // frame_capacity + 1). Two competing constraints:
            //
            //   - Don't pack `current_frame` only: under any non-zero
            //     latency, A's actions tagged at frame X land on the
            //     server in `action_log[X]`, not `action_log[current]`.
            //     A relay peer (B) whose start is `current` would see
            //     an empty pack every tick → no packet sent → B can't
            //     ack → baseline never advances → bootstrap deadlock.
            //   - Don't pack from frame 0: would waste bandwidth
            //     iterating frames the ring no longer holds. The `has_frame`
            //     guard inside pack_for_peer would skip them, but the
            //     loop bound itself can grow unboundedly with
            //     current_frame.
            //
            // `current_frame - frame_capacity + 1` is the oldest frame
            // the ring could still hold, so we naturally cap at the
            // ring's actual content. Actions referencing entities the
            // peer's slot_table doesn't yet know about silently no-op
            // (Replicator translates via network_for → -1; SimAction
            // apply hits the `if (local < 0) return` guard). For first-
            // connect scenarios the app is still expected to send a
            // state-burst seed; this just makes the action-stream
            // bootstrap work in addition to that.
            const start = last_acked < 0
                ? Math.max(0, current_frame - this.action_log.frame_capacity + 1)
                : last_acked + 1;
            if (start > current_frame) continue;

            this.#send_buffer.position = 0;
            // Packet-type prefix.
            this.#send_buffer.writeUint8(NetworkPacketType.ACTION_STREAM);
            this.replicator.pack_for_peer(peer_id, start, current_frame, this.#send_buffer);
            const length = this.#send_buffer.position;
            // Just the prefix → no in-scope actions to send.
            if (length === 1) continue;

            const seq = this.#send_to_peer(conn, this.#send_buffer.raw_bytes, length);
            conn.seq_to_frame_end.set(seq, current_frame);
        }

        // Reset the per-tick changed-entity set so the next tick starts clean.
        // (begin_tick also clears it; the dual call is harmless.)
        if (this.#changed_entities !== null) this.#changed_entities.clear();
    }

    /**
     * Send a logical payload to a connected peer, fragmenting transparently
     * if it exceeds the channel's single-packet capacity.
     *
     * Returns the seq of the FIRST transport packet emitted. For non-
     * fragmented sends that's the only packet; for fragmented sends, that
     * seq is what callers should record in `seq_to_frame_end` — when it
     * acks, the first fragment arrived, which is a sufficient signal that
     * the message is in flight. Fragments either all arrive (and the
     * receiver reassembles) or the message is lost; the action-stream's
     * back-fill mechanism naturally recovers from the latter on subsequent
     * sends.
     *
     * @param {object} conn  entry from __peers
     * @param {Uint8Array} payload  starts with the packet-type byte
     * @param {number} length
     * @returns {number} seq of the first packet sent
     * @private
     */
    #send_to_peer(conn, payload, length) {
        if (length <= MAX_CHANNEL_PAYLOAD_BYTES) {
            return conn.channel.send(payload, length);
        }
        const message_id = this.#next_fragment_message_id;
        this.#next_fragment_message_id = (this.#next_fragment_message_id + 1) & 0xFFFF;
        // Retain the source bytes so we can satisfy NACK retransmits.
        // Copy happens inside retain(); `payload` is the orchestrator's
        // shared send buffer and will be overwritten by the next call.
        conn.fragment_retention.retain(message_id, payload, length, performance.now());
        let first_seq = -1;
        send_fragmented(
            payload, length, message_id,
            NetworkPacketType.FRAGMENT, this.#fragment_scratch,
            (chunk_bytes, chunk_length) => {
                const seq = conn.channel.send(chunk_bytes, chunk_length);
                if (first_seq < 0) first_seq = seq;
            },
        );
        return first_seq;
    }

    /**
     * Send a recovery request to `peer_id`: "tell me the current state of every
     * entity that mutated between `start_tick` and `end_tick`." The peer's
     * server-side handler ({@link onRecoveryRequest}) is expected to respond by
     * calling {@link send_state_burst_for_range}.
     *
     * Best-effort delivery via the same UDP-style channel — if the request is
     * lost, the client is expected to retry.
     *
     * @param {number} peer_id
     * @param {number} start_tick
     * @param {number} end_tick
     */
    send_recovery_request(peer_id, start_tick, end_tick) {
        assert.isNonNegativeInteger(peer_id, 'peer_id');
        assert.isNonNegativeInteger(start_tick, 'start_tick');
        assert.isNonNegativeInteger(end_tick, 'end_tick');
        const conn = this.#peers.get(peer_id);
        if (conn === undefined) throw new Error(`NetworkPeer.send_recovery_request: peer ${peer_id} not connected`);

        this.#send_buffer.position = 0;
        this.#send_buffer.writeUint8(NetworkPacketType.RECOVERY_REQUEST);
        this.#send_buffer.writeUintVar(start_tick);
        this.#send_buffer.writeUintVar(end_tick);
        this.#send_to_peer(conn, this.#send_buffer.raw_bytes, this.#send_buffer.position);
    }

    /**
     * Server-side recovery response. Looks up which entities mutated in
     * `[start_tick, end_tick]` via the local `MutationLedger` (must be present),
     * then sends the current state of those entities to `peer_id` via a
     * `STATE_BURST` packet. Receiver applies via `snapshotter_apply_to_existing`.
     *
     * If no entities are in the range (or the range is older than the ledger
     * retains), nothing is sent. The requester should treat absence of response
     * after a timeout as "needs full re-init."
     *
     * @param {number} peer_id
     * @param {number} start_tick
     * @param {number} end_tick
     */
    send_state_burst_for_range(peer_id, start_tick, end_tick) {
        assert.isNonNegativeInteger(peer_id, 'peer_id');
        assert.isNonNegativeInteger(start_tick, 'start_tick');
        assert.isNonNegativeInteger(end_tick, 'end_tick');
        if (this.mutation_ledger === null) {
            throw new Error('NetworkPeer.send_state_burst_for_range: requires mutation_ledger to be configured');
        }
        const conn = this.#peers.get(peer_id);
        if (conn === undefined) throw new Error(`NetworkPeer.send_state_burst_for_range: peer ${peer_id} not connected`);

        // Reuse the changed-set as a scratch dedupe surface for the union.
        // It's been cleared at the start of this tick (or hasn't been used yet);
        // it will be cleared again at the start of the next tick.
        const scratch = this.#changed_entities !== null
            ? this.#changed_entities
            : new ChangedEntitySet({ capacity: 1024 });
        // Don't clobber an in-progress tick's accumulated mutations. If a tick
        // is currently open we use a fresh set just for this query.
        const set = this.#tick_open
            ? new ChangedEntitySet({ capacity: 1024 })
            : scratch;
        set.clear();

        this.mutation_ledger.entities_changed_in_range(set, start_tick, end_tick);
        if (set.size() === 0) return;

        this.#send_buffer.position = 0;
        this.#send_buffer.writeUint8(NetworkPacketType.STATE_BURST);
        snapshotter_emit({
            buffer: this.#send_buffer,
            world: this.world,
            slot_table: this.slot_table,
            component_registry: this.component_registry,
            entity_iter: cb => {
                set.for_each(network_id => {
                    const eid = this.slot_table.entity_for(network_id);
                    if (eid >= 0) cb(eid);
                });
            },
        });
        // STATE_BURST is the primary path that can exceed MTU — a recovery
        // response covering many entities easily lands in the multi-KB range.
        // The fragmenting wrapper splits it across packets transparently.
        this.#send_to_peer(conn, this.#send_buffer.raw_bytes, this.#send_buffer.position);
    }

    /**
     * Send the authoritative state of one entity to `peer_id`, tagged with
     * the frame the state represents. Used by client-side prediction: the
     * server emits this every tick (or every N ticks) for client-owned
     * entities; the client compares to its predicted state at the same frame
     * and reconciles via rewind+replay if they differ.
     *
     * The payload is whatever `write_fn(buffer)` writes — typically a
     * `BinaryClassSerializationAdapter.serialize(buffer, component)` call.
     * The receiver's `onAuthState` handler reads the same bytes via the
     * matching deserializer.
     *
     * @param {number} peer_id
     * @param {number} frame_number sender's frame the state corresponds to
     * @param {number} network_id which entity this state is for
     * @param {function(BinaryBuffer): void} write_fn writes the entity's state bytes
     */
    send_auth_state(peer_id, frame_number, network_id, write_fn) {
        assert.isNonNegativeInteger(peer_id, 'peer_id');
        assert.isNonNegativeInteger(frame_number, 'frame_number');
        assert.isNonNegativeInteger(network_id, 'network_id');
        assert.isFunction(write_fn, 'write_fn');
        const conn = this.#peers.get(peer_id);
        if (conn === undefined) throw new Error(`NetworkPeer.send_auth_state: peer ${peer_id} not connected`);

        this.#send_buffer.position = 0;
        this.#send_buffer.writeUint8(NetworkPacketType.AUTH_STATE);
        this.#send_buffer.writeUintVar(frame_number);
        this.#send_buffer.writeUintVar(network_id);
        write_fn(this.#send_buffer);
        this.#send_to_peer(conn, this.#send_buffer.raw_bytes, this.#send_buffer.position);
    }

    /**
     * Send an INITIAL_SYNC packet. `write_fn` receives the send-buffer
     * positioned just past the `(packet_type, session_token, frame_number)`
     * header and writes the Snapshotter payload. Fragments transparently.
     *
     * @param {number} peer_id
     * @param {Uint8Array} session_token 16-byte UUID v1 identifying this peer-session
     * @param {number} frame_number server sim frame this snapshot represents
     * @param {function(BinaryBuffer): void} write_fn
     */
    send_initial_sync(peer_id, session_token, frame_number, write_fn) {
        assert.isNonNegativeInteger(peer_id, 'peer_id');
        assert.ok(session_token instanceof Uint8Array && session_token.length === 16,
            'session_token must be a 16-byte Uint8Array');
        assert.isNonNegativeInteger(frame_number, 'frame_number');
        assert.isFunction(write_fn, 'write_fn');
        const conn = this.#peers.get(peer_id);
        if (conn === undefined) throw new Error(`NetworkPeer.send_initial_sync: peer ${peer_id} not connected`);

        this.#send_buffer.position = 0;
        this.#send_buffer.writeUint8(NetworkPacketType.INITIAL_SYNC);
        this.#send_buffer.writeBytes(session_token, 0, 16);
        this.#send_buffer.writeUintVar(frame_number);
        write_fn(this.#send_buffer);
        this.#send_to_peer(conn, this.#send_buffer.raw_bytes, this.#send_buffer.position);
    }

    /**
     * Send a RESUME_HELLO packet to the server. Client-only direction.
     *
     * @param {number} peer_id remote peer (the server)
     * @param {number} local_peer_id the client's own peer id
     * @param {number} last_acked_frame the client's view of last-acked frame
     * @param {Uint8Array} session_token 16-byte token from a prior INITIAL_SYNC
     */
    send_resume_hello(peer_id, local_peer_id, last_acked_frame, session_token) {
        assert.isNonNegativeInteger(peer_id, 'peer_id');
        assert.isNonNegativeInteger(local_peer_id, 'local_peer_id');
        assert.isNonNegativeInteger(last_acked_frame, 'last_acked_frame');
        assert.ok(session_token instanceof Uint8Array && session_token.length === 16,
            'session_token must be a 16-byte Uint8Array');
        const conn = this.#peers.get(peer_id);
        if (conn === undefined) throw new Error(`NetworkPeer.send_resume_hello: peer ${peer_id} not connected`);

        this.#send_buffer.position = 0;
        this.#send_buffer.writeUint8(NetworkPacketType.RESUME_HELLO);
        this.#send_buffer.writeUintVar(local_peer_id);
        this.#send_buffer.writeUintVar(last_acked_frame);
        this.#send_buffer.writeBytes(session_token, 0, 16);
        this.#send_to_peer(conn, this.#send_buffer.raw_bytes, this.#send_buffer.position);
    }

    /**
     * Send a RESUME_ACCEPT packet. Host-only direction.
     * @param {number} peer_id
     */
    send_resume_accept(peer_id) {
        assert.isNonNegativeInteger(peer_id, 'peer_id');
        const conn = this.#peers.get(peer_id);
        if (conn === undefined) throw new Error(`NetworkPeer.send_resume_accept: peer ${peer_id} not connected`);

        this.#send_buffer.position = 0;
        this.#send_buffer.writeUint8(NetworkPacketType.RESUME_ACCEPT);
        this.#send_to_peer(conn, this.#send_buffer.raw_bytes, this.#send_buffer.position);
    }

    /**
     * Send a RESUME_REJECT packet. Host-only direction.
     * @param {number} peer_id
     * @param {number} reason_code one of {@link ResumeRejectReason}
     */
    send_resume_reject(peer_id, reason_code) {
        assert.isNonNegativeInteger(peer_id, 'peer_id');
        assert.isNonNegativeInteger(reason_code, 'reason_code');
        const conn = this.#peers.get(peer_id);
        if (conn === undefined) throw new Error(`NetworkPeer.send_resume_reject: peer ${peer_id} not connected`);

        this.#send_buffer.position = 0;
        this.#send_buffer.writeUint8(NetworkPacketType.RESUME_REJECT);
        this.#send_buffer.writeUint8(reason_code & 0xFF);
        this.#send_to_peer(conn, this.#send_buffer.raw_bytes, this.#send_buffer.position);
    }

    /**
     * Send a DISCONNECT packet. Either direction. `reason_label` is an
     * opaque string (255 byte cap after UTF-8 encoding).
     *
     * @param {number} peer_id
     * @param {string} [reason_label]
     */
    send_disconnect(peer_id, reason_label = '') {
        assert.isNonNegativeInteger(peer_id, 'peer_id');
        assert.isString(reason_label, 'reason_label');
        const conn = this.#peers.get(peer_id);
        if (conn === undefined) throw new Error(`NetworkPeer.send_disconnect: peer ${peer_id} not connected`);

        const encoded = DISCONNECT_TEXT_ENCODER.encode(reason_label);
        const len = Math.min(encoded.length, 255);

        this.#send_buffer.position = 0;
        this.#send_buffer.writeUint8(NetworkPacketType.DISCONNECT);
        this.#send_buffer.writeUint8(len);
        if (len > 0) this.#send_buffer.writeBytes(encoded, 0, len);
        this.#send_to_peer(conn, this.#send_buffer.raw_bytes, this.#send_buffer.position);
    }

    /**
     * Send a TIME_DILATION feedback packet. `buffer_depth` is clamped
     * to the int16 range — saturating is fine since the controller
     * doesn't need sub-int16 precision.
     *
     * @param {number} peer_id
     * @param {number} buffer_depth signed integer; clamped to int16
     */
    send_time_dilation_feedback(peer_id, buffer_depth) {
        assert.isNonNegativeInteger(peer_id, 'peer_id');
        assert.isNumber(buffer_depth, 'buffer_depth');
        const conn = this.#peers.get(peer_id);
        if (conn === undefined) throw new Error(`NetworkPeer.send_time_dilation_feedback: peer ${peer_id} not connected`);

        let depth = buffer_depth | 0;
        if (depth > 32767) depth = 32767;
        else if (depth < -32768) depth = -32768;

        this.#send_buffer.position = 0;
        this.#send_buffer.writeUint8(NetworkPacketType.TIME_DILATION);
        this.#send_buffer.writeInt16(depth);
        this.#send_to_peer(conn, this.#send_buffer.raw_bytes, this.#send_buffer.position);
    }

    /**
     * Send a reliable command to a connected peer. Delivered at-least-once
     * with sender-side retransmit on Channel-detected loss and receiver-
     * side dedup by logical seq. Use for messages where the action-stream
     * model (best-effort, per-frame back-fill) isn't suitable — chat,
     * lobby/room state, level transitions.
     *
     * Returns the logical seq the pipeline assigned (diagnostic only; the
     * receiver dedups internally regardless).
     *
     * @param {number} peer_id
     * @param {Uint8Array} payload
     * @param {number} length
     * @returns {number} logical seq
     */
    send_reliable_command(peer_id, payload, length) {
        assert.isNonNegativeInteger(peer_id, 'peer_id');
        assert.isNonNegativeInteger(length, 'length');
        const conn = this.#peers.get(peer_id);
        if (conn === undefined) {
            throw new Error(`NetworkPeer.send_reliable_command: peer ${peer_id} not connected`);
        }
        return conn.reliable_pipeline.send(payload, length);
    }
}
