import { assert } from "../../../core/assert.js";
import { BinaryBuffer } from "../../../core/binary/BinaryBuffer.js";
import Signal from "../../../core/events/signal/Signal.js";

/**
 * Reliable, at-least-once delivery layer over a {@link Channel}.
 *
 * Used for messages that must arrive: chat, lobby/room state changes,
 * level transitions, kick / disconnect notifications. Distinct from the
 * action-stream traffic the rest of the netcode uses, which is UDP-style
 * best-effort with per-frame back-fill compensating for losses.
 *
 * Wire layout (after the {@link Channel}'s 8-byte header and the
 * `packet_type` byte that {@link NetworkPeer}'s dispatcher consumes):
 *
 *   uintVar logical_seq    sender-assigned, monotonically increasing
 *   bytes   command_payload
 *
 * Reliability mechanism: each outgoing command goes out as one
 * Channel.send call. The returned channel-level seq is tracked in
 * `__unacked`. When the Channel later fires:
 *   - `onPacketAcked(seq)`: drop the entry — the command was delivered
 *     (the receiver de-duplicated by logical_seq, so a "duplicate" from
 *     a previous retransmit is harmless).
 *   - `onPacketLost(seq)`: the Channel's 33-packet ack window aged out
 *     this seq without seeing it acked. Re-send the same logical
 *     command on a fresh channel seq.
 *
 * Receiver side: a sliding-window de-dupe of recent `logical_seq` values.
 * Anything seen within the last `max_received_history` is silently
 * dropped; first-time arrivals fire `onCommand` with the payload bytes.
 *
 * Ordering: NOT guaranteed. Commands may arrive in any order. If you
 * need in-order delivery (level transitions before subsequent chat),
 * key payloads with a counter and reorder in the application handler.
 *
 * Lifecycle constraints:
 *   - `max_unacked` caps outstanding commands. Send throws when full
 *     (signals real backpressure — receiver gone or path is broken).
 *   - `max_received_history` caps the de-dupe set. A retransmit older
 *     than this window will be re-delivered as a duplicate to the
 *     application; not a problem in practice because the sender's
 *     `max_unacked` keeps the inflight set bounded too.
 *
 * Caveats:
 *   - Loss detection relies on the Channel's seq window, which advances
 *     when subsequent packets are sent. Under sustained no-traffic
 *     conditions a single lost reliable command may stay unacked
 *     indefinitely — by convention the action-stream's per-tick traffic
 *     keeps the window moving even when the application is otherwise
 *     idle. Real production code may want a timer-based retransmit
 *     fallback layered on top of this.
 *
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */
export class ReliableCommandPipeline {
    #next_send_seq;
    #unacked;
    #received_seqs;
    #received_order;
    #send_scratch;
    #bound_acked;
    #bound_lost;


    /**
     * @param {{
     *   channel: import("./Channel.js").Channel,
     *   packet_type: number,
     *   max_unacked?: number,
     *   max_received_history?: number,
     *   max_retries?: number,
     * }} options
     */
    constructor({
        channel,
        packet_type,
        max_unacked = 64,
        max_received_history = 64,
        max_retries = 16,
    }) {
        assert.ok(channel && typeof channel.send === 'function', 'channel must be a Channel');
        assert.isNonNegativeInteger(packet_type, 'packet_type');
        assert.ok(packet_type <= 0xFF, 'packet_type must fit in a uint8');
        assert.isPositiveInteger(max_unacked, 'max_unacked');
        assert.isPositiveInteger(max_received_history, 'max_received_history');
        assert.isPositiveInteger(max_retries, 'max_retries');

        /** @type {import("./Channel.js").Channel} */
        this.channel = channel;
        /** @type {number} @readonly */
        this.packet_type = packet_type;
        /** @type {number} @readonly */
        this.max_unacked = max_unacked;
        /** @type {number} @readonly */
        this.max_received_history = max_received_history;
        /**
         * Maximum retransmissions per logical command before it is dropped
         * and {@link onCommandAbandoned} fires. Bounded so a permanently-
         * black-holed peer can't pin retransmit traffic on the channel
         * forever. The default (16) tolerates roughly half a second of
         * sustained loss at typical channel cadences before giving up.
         * @type {number} @readonly
         */
        this.max_retries = max_retries;

        /**
         * Monotonic counter for outgoing logical seqs. Receiver dedups on
         * this value; sender retransmits use the SAME logical_seq with a
         * new channel_seq.
         * @type {number}
         * @private
         */
        this.#next_send_seq = 0;

        /**
         * Outstanding commands keyed by their CURRENT channel_seq. On
         * retransmit, the entry is moved to the new channel_seq.
         * `retries` counts how many times this logical command has been
         * resent; the entry is dropped when it exceeds `max_retries`.
         * @type {Map<number, {logical_seq: number, payload: Uint8Array, retries: number}>}
         * @private
         */
        this.#unacked = new Map();

        /**
         * Logical seqs received recently. Sliding window for dedup.
         * @type {Set<number>}
         * @private
         */
        this.#received_seqs = new Set();

        /**
         * FIFO order of received logical_seqs, for pruning when
         * `__received_seqs` exceeds `max_received_history`.
         * @type {number[]}
         * @private
         */
        this.#received_order = [];

        /**
         * Scratch buffer for assembling outgoing packets.
         * @type {BinaryBuffer}
         * @private
         */
        this.#send_scratch = new BinaryBuffer();
        this.#send_scratch.setCapacity(1024);

        /**
         * Fired when a brand-new logical command arrives (not a duplicate).
         * Args: `(buf, payload_offset, payload_length)`. The buffer is
         * shared scratch; consumers must copy bytes if they need them to
         * outlive the handler.
         * @type {Signal}
         */
        this.onCommand = new Signal();

        /**
         * Fired when a sent command has been retransmitted `max_retries`
         * times without an ack and is being given up on. Args:
         * `(logical_seq)`. Subscribers typically surface this as a "command
         * failed to deliver" event to the application, or treat it as a
         * hard signal to tear down the session.
         * @type {Signal}
         */
        this.onCommandAbandoned = new Signal();

        // Channel reliability notifications.
        this.#bound_acked = (seq) => this.#on_packet_acked(seq);
        this.#bound_lost = (seq) => this.#on_packet_lost(seq);
        channel.onPacketAcked.add(this.#bound_acked);
        channel.onPacketLost.add(this.#bound_lost);
    }

    /**
     * Send a logical command. Returns the assigned `logical_seq` (useful
     * for application-level diagnostics; the receiver dedups internally
     * regardless).
     *
     * @param {Uint8Array} payload
     * @param {number} length
     * @returns {number}
     */
    send(payload, length) {
        assert.isNonNegativeInteger(length, 'length');
        if (this.#unacked.size >= this.max_unacked) {
            throw new Error(
                `ReliableCommandPipeline.send: ${this.#unacked.size} commands unacked ` +
                `(max ${this.max_unacked}); receiver likely disconnected or path saturated`
            );
        }
        const logical_seq = this.#next_send_seq;
        this.#next_send_seq++;

        // Defensive copy: the caller's buffer may be reused after this call.
        const payload_copy = new Uint8Array(length);
        payload_copy.set(payload.subarray(0, length));

        const channel_seq = this.#transmit(logical_seq, payload_copy);
        this.#unacked.set(channel_seq, { logical_seq, payload: payload_copy, retries: 0 });
        return logical_seq;
    }

    /**
     * Called by the orchestrator (typically {@link NetworkPeer}'s
     * dispatcher) when a packet of this pipeline's type arrives. `buf`
     * is positioned just past the packet-type byte.
     *
     * @param {BinaryBuffer} buf
     * @param {number} total_length    full transport-level payload length,
     *                                  including the consumed packet-type byte
     */
    handle_inbound(buf, total_length) {
        const logical_seq = buf.readUintVar();

        if (this.#received_seqs.has(logical_seq)) {
            // Duplicate from a retransmit; ignore.
            return;
        }
        this.#received_seqs.add(logical_seq);
        this.#received_order.push(logical_seq);
        // Prune. Oldest-first.
        while (this.#received_order.length > this.max_received_history) {
            const oldest = this.#received_order.shift();
            this.#received_seqs.delete(oldest);
        }

        const payload_offset = buf.position;
        const payload_length = total_length - payload_offset;
        this.onCommand.send3(buf, payload_offset, payload_length);
    }

    /**
     * Number of commands sent that haven't yet been acked. Useful for
     * backpressure / progress UI.
     * @returns {number}
     */
    unacked_count() {
        return this.#unacked.size;
    }

    /**
     * Drop subscriptions and clear state. Required before discarding a
     * pipeline whose channel will outlive it.
     */
    dispose() {
        this.channel.onPacketAcked.remove(this.#bound_acked);
        this.channel.onPacketLost.remove(this.#bound_lost);
        this.#unacked.clear();
        this.#received_seqs.clear();
        this.#received_order.length = 0;
    }

    /** @private */
    #transmit(logical_seq, payload) {
        const buf = this.#send_scratch;
        // Worst-case capacity: type byte + 10 bytes for varint + payload.
        const needed = 1 + 10 + payload.length;
        if (buf.raw_bytes.length < needed) buf.setCapacity(needed);
        buf.position = 0;
        buf.writeUint8(this.packet_type);
        buf.writeUintVar(logical_seq);
        buf.writeBytes(payload, 0, payload.length);
        return this.channel.send(buf.raw_bytes, buf.position);
    }

    /** @private */
    #on_packet_acked(channel_seq) {
        this.#unacked.delete(channel_seq);
    }

    /** @private */
    #on_packet_lost(channel_seq) {
        const entry = this.#unacked.get(channel_seq);
        if (entry === undefined) return;
        this.#unacked.delete(channel_seq);

        if (entry.retries >= this.max_retries) {
            // Permanently black-holed peer or fundamentally broken path —
            // surface the failure to the application and stop retrying.
            this.onCommandAbandoned.send1(entry.logical_seq);
            return;
        }

        // Retransmit on a fresh channel_seq. Same logical_seq, same payload.
        entry.retries++;
        const new_channel_seq = this.#transmit(entry.logical_seq, entry.payload);
        this.#unacked.set(new_channel_seq, entry);
    }
}
