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

/**
 * Append-only per-tick log of "which entities mutated this tick", recorded as
 * compact varint sequences in a single backing buffer.
 *
 * Used by the server to answer the recovery query: *"which entities changed
 * between tick X and tick Y?"* — the client asks this when it detects loss
 * beyond the ActionLog retry window, so it can request the current state of
 * just those entities instead of a full re-init.
 *
 * Keeps one global log (NOT per-client). Entry header per tick is small
 * (`{tick, offset, length}` = ~16 bytes); the per-id payload is one varint
 * each. At 60 Hz with 1k mutations/sec this is ~60 KB/min uncompacted.
 *
 * Trim: caller invokes {@link drop_through} periodically to release old
 * ticks. The backing buffer is re-packed in place via `Uint8Array.copyWithin`
 * so memory stays bounded. Beyond the retained window, the recovery answer
 * is "you're too far behind — full re-init."
 *
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */
export class MutationLedger {
    #buffer;
    #entries;


    /**
     * @param {{ initial_buffer_size?: number }} [options]
     */
    constructor({ initial_buffer_size = 4096 } = {}) {
        /**
         * @private
         * @type {BinaryBuffer}
         */
        this.#buffer = new BinaryBuffer();
        this.#buffer.setCapacity(initial_buffer_size);
        this.#buffer.position = 0;

        /**
         * Per-tick metadata. Sorted ascending by `tick` (we only append in
         * monotonic-tick order, so this invariant holds naturally).
         * @private
         * @type {{tick: number, offset: number, length: number}[]}
         */
        this.#entries = [];
    }

    /**
     * Record the contents of `changed_set` as the mutations for `tick_number`.
     * Tick numbers must be monotonically non-decreasing across calls; recording
     * the same tick twice appends a second entry (which is fine — range
     * queries will union them).
     *
     * @param {number} tick_number
     * @param {ChangedEntitySet} changed_set
     */
    record_tick(tick_number, changed_set) {
        assert.isNonNegativeInteger(tick_number, 'tick_number');

        // Idle ticks (no mutations) are a no-op: storing a zero-length entry
        // would just waste an array slot and force range queries to iterate it.
        if (changed_set.size() === 0) return;

        const offset = this.#buffer.position;
        changed_set.compact_into(this.#buffer);
        const length = this.#buffer.position - offset;

        this.#entries.push({ tick: tick_number, offset, length });
    }

    /**
     * Read every network ID recorded in any tick within `[start_tick, end_tick]`
     * (inclusive on both ends) and add it to `output_set`. Duplicate IDs across
     * ticks are deduped naturally by the set.
     *
     * @param {ChangedEntitySet} output_set caller-owned destination; not cleared
     * @param {number} start_tick
     * @param {number} end_tick
     */
    entities_changed_in_range(output_set, start_tick, end_tick) {
        assert.isNonNegativeInteger(start_tick, 'start_tick');
        assert.isNonNegativeInteger(end_tick, 'end_tick');

        const buf = this.#buffer;
        const saved_pos = buf.position;
        try {
            const entries = this.#entries;
            for (let i = 0; i < entries.length; i++) {
                const e = entries[i];
                if (e.tick < start_tick) continue;
                if (e.tick > end_tick) break; // entries are sorted
                const end = e.offset + e.length;
                buf.position = e.offset;
                while (buf.position < end) {
                    output_set.add(buf.readUintVar());
                }
            }
        } finally {
            buf.position = saved_pos;
        }
    }

    /**
     * Drop all entries whose tick is `<=` `tick_number` and reclaim their
     * buffer space. After this call, queries below `tick_number` return nothing.
     *
     * @param {number} tick_number
     */
    drop_through(tick_number) {
        assert.isInteger(tick_number, 'tick_number');

        const entries = this.#entries;
        // Find the index of the first entry to keep.
        let keep_from = 0;
        while (keep_from < entries.length && entries[keep_from].tick <= tick_number) keep_from++;

        if (keep_from === 0) return; // nothing to drop

        if (keep_from === entries.length) {
            // Drop everything.
            entries.length = 0;
            this.#buffer.position = 0;
            return;
        }

        // Shift the buffer's retained tail down to offset 0.
        const shift = entries[keep_from].offset;
        const tail_end = this.#buffer.position;
        // copyWithin(target, start, end) — safe for overlapping sources.
        this.#buffer.raw_bytes.copyWithin(0, shift, tail_end);
        this.#buffer.position = tail_end - shift;

        // Drop the dead head entries; rebase offsets of the survivors.
        entries.splice(0, keep_from);
        for (let i = 0; i < entries.length; i++) {
            entries[i].offset -= shift;
        }
    }

    /**
     * Earliest tick still recorded, or -1 if empty.
     * @returns {number}
     */
    earliest_recorded_tick() {
        return this.#entries.length === 0 ? -1 : this.#entries[0].tick;
    }

    /**
     * Most recent tick recorded, or -1 if empty.
     * @returns {number}
     */
    latest_recorded_tick() {
        return this.#entries.length === 0 ? -1 : this.#entries[this.#entries.length - 1].tick;
    }

    /**
     * Number of recorded ticks (not network IDs).
     * @returns {number}
     */
    size() {
        return this.#entries.length;
    }

    /**
     * Bytes currently held in the backing buffer (after any drops).
     * @returns {number}
     */
    byte_size() {
        return this.#buffer.position;
    }

    /**
     * Drop everything.
     */
    clear() {
        this.#entries.length = 0;
        this.#buffer.position = 0;
    }
}
