import { assert } from "../../../../core/assert.js";
import {
    FRAGMENT_RETENTION_MAX_AGE_MS,
    FRAGMENT_RETENTION_MAX_MESSAGES,
    FRAGMENT_RETENTION_MAX_RETRIES,
} from "./packet_size.js";

/**
 * Per-peer sender-side retention buffer for fragmented message bytes,
 * used to satisfy NACK retransmits from the peer's {@link FragmentAssembler}.
 *
 * Lifecycle:
 *   1. {@link retain} — called on each fragmented send; copies the source
 *      bytes into the retention so they're available to retransmit. If
 *      the per-peer slot cap is hit, the oldest retained entry is FIFO-
 *      evicted.
 *   2. {@link consume_nack} — looks up retained bytes for a NACK target
 *      and bumps the retry counter. Returns `null` if the entry is gone
 *      (aged out, evicted, or out of retry budget — in which case the
 *      entry is dropped here too).
 *   3. {@link service} — call once per tick to age out entries older
 *      than `max_age_ms`.
 *   4. {@link clear} — drop everything; call on peer disconnect.
 *
 * Memory cost: each retained entry holds a fresh copy of its source
 * payload (the upstream send buffer is a reused scratch). With the
 * default 16 slots × up-to-64 KiB cap on the receiver, worst case is
 * 1 MiB per peer; steady-state is empty because steady traffic fits in
 * one packet and bypasses fragmentation.
 *
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */
export class FragmentRetention {
    #entries;
    #order;


    /**
     * @param {{
     *   max_messages?: number,
     *   max_age_ms?: number,
     *   max_retries?: number,
     * }} [options]
     *   `max_messages` caps simultaneously-retained messages; oldest is
     *   FIFO-evicted on overflow. `max_age_ms` is the per-entry TTL.
     *   `max_retries` caps how many NACK retransmit rounds a single
     *   retained message can satisfy before being dropped.
     */
    constructor({
        max_messages = FRAGMENT_RETENTION_MAX_MESSAGES,
        max_age_ms = FRAGMENT_RETENTION_MAX_AGE_MS,
        max_retries = FRAGMENT_RETENTION_MAX_RETRIES,
    } = {}) {
        assert.isPositiveInteger(max_messages, 'max_messages');
        assert.isPositiveInteger(max_age_ms, 'max_age_ms');
        assert.isNonNegativeInteger(max_retries, 'max_retries');

        /** @type {number} @readonly */
        this.max_messages = max_messages;
        /** @type {number} @readonly */
        this.max_age_ms = max_age_ms;
        /** @type {number} @readonly */
        this.max_retries = max_retries;

        /**
         * message_id → retained entry. `payload` is a fresh copy
         * (independent of the caller's send buffer).
         * @type {Map<number, {payload: Uint8Array, length: number, sent_at: number, retries: number}>}
         * @private
         */
        this.#entries = new Map();

        /**
         * FIFO of message_ids in insertion order, for eviction on
         * overflow and for monotonic age-out.
         * @type {number[]}
         * @private
         */
        this.#order = [];
    }

    /**
     * Copy `payload[0..length)` into retention under `message_id`.
     * Evicts the oldest retained entry if `max_messages` is exceeded.
     *
     * If a retained entry already exists for `message_id` (only legitimate
     * cause: uint16 wrap), the old entry is dropped first so the FIFO
     * position and retry budget restart from the new send.
     *
     * @param {number} message_id
     * @param {Uint8Array} payload  source bytes (caller may overwrite after return)
     * @param {number} length       number of bytes from `payload[0]` to copy
     * @param {number} now_ms       monotonic timestamp for age-out
     */
    retain(message_id, payload, length, now_ms) {
        assert.isNonNegativeInteger(message_id, 'message_id');
        assert.isNonNegativeInteger(length, 'length');
        assert.isNumber(now_ms, 'now_ms');

        if (this.#entries.has(message_id)) {
            this.#delete(message_id);
        }
        if (this.#entries.size >= this.max_messages) {
            const oldest = this.#order.shift();
            if (oldest !== undefined) this.#entries.delete(oldest);
        }
        const copy = new Uint8Array(length);
        copy.set(payload.subarray(0, length));
        this.#entries.set(message_id, {
            payload: copy,
            length,
            sent_at: now_ms,
            retries: 0,
        });
        this.#order.push(message_id);
    }

    /**
     * Mark a NACK round for `message_id` and return the retained entry
     * so the caller can re-emit the missing chunks. Returns `null` if
     * no entry exists, or if the per-message retry budget is now
     * exhausted (in which case the entry is dropped).
     *
     * @param {number} message_id
     * @returns {{payload: Uint8Array, length: number}|null}
     */
    consume_nack(message_id) {
        assert.isNonNegativeInteger(message_id, 'message_id');
        const entry = this.#entries.get(message_id);
        if (entry === undefined) return null;
        entry.retries++;
        if (entry.retries > this.max_retries) {
            this.#delete(message_id);
            return null;
        }
        return entry;
    }

    /**
     * Per-tick maintenance: evict any entry older than `max_age_ms`.
     *
     * The FIFO is in strict insertion order, so we can stop at the first
     * still-fresh entry rather than scanning the whole map.
     *
     * @param {number} now_ms
     */
    service(now_ms) {
        assert.isNumber(now_ms, 'now_ms');
        while (this.#order.length > 0) {
            const id = this.#order[0];
            const entry = this.#entries.get(id);
            if (entry === undefined) {
                this.#order.shift();
                continue;
            }
            if (now_ms - entry.sent_at < this.max_age_ms) break;
            this.#order.shift();
            this.#entries.delete(id);
        }
    }

    /**
     * Number of currently-retained messages.
     * @returns {number}
     */
    size() {
        return this.#entries.size;
    }

    /**
     * Whether `message_id` is currently retained.
     * @param {number} message_id
     * @returns {boolean}
     */
    has(message_id) {
        return this.#entries.has(message_id);
    }

    /**
     * Drop every retained entry. Use on peer disconnect.
     */
    clear() {
        this.#entries.clear();
        this.#order.length = 0;
    }

    /** @private */
    #delete(message_id) {
        this.#entries.delete(message_id);
        const idx = this.#order.indexOf(message_id);
        if (idx >= 0) this.#order.splice(idx, 1);
    }
}
