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

/**
 * Forwards action records between peers.
 *
 * **Send path** ({@link pack_for_peer}): walk the local action log over a
 * frame range, filter records by scope, and write a packet that contains
 * only the action portion of each record (no prior state — receivers don't
 * need it; their executors capture their own prior state).
 *
 * **Receive path** ({@link unpack_from_peer}): parse the packet, deserialize
 * each action via the registry, and run it through the local executor. The
 * executor logs it into the receiver's action log just like locally-originated
 * actions, capturing the receiver's prior state for its own rewind purposes.
 *
 * The Replicator is transport-agnostic: it produces and consumes
 * `BinaryBuffer`s. The orchestrator hands the produced buffer to a transport
 * and feeds the inbound buffer in the other direction.
 *
 * Wire format:
 * ```
 *   loop while bytes remain:
 *     varint:  frame_number
 *     varint:  action_count
 *     loop action_count times:
 *       uint8:   action_type_id
 *       uint32:  action_payload_len
 *       bytes:   action_payload
 * ```
 *
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */
export class Replicator {
    #record_bounds;


    /**
     * @param {{
     *   action_log: ActionLog,
     *   action_registry: SimActionRegistry,
     *   executor: SimActionExecutor,
     *   slot_table: ReplicationSlotTable,
     *   scope_filter: { is_entity_in_scope(peer_id: number, network_id: number): boolean },
     * }} options
     */
    constructor({ action_log, action_registry, executor, slot_table, scope_filter }) {
        /**
         * @type {ActionLog}
         */
        this.action_log = action_log;

        /**
         * @type {SimActionRegistry}
         */
        this.action_registry = action_registry;

        /**
         * @type {SimActionExecutor}
         */
        this.executor = executor;

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

        /**
         * Receives `network_id` (peer-shared) — NOT local `entity_id`. Replicator
         * translates via `slot_table.network_for(entity_id)` before calling.
         * @type {{ is_entity_in_scope(peer_id: number, network_id: number): boolean }}
         */
        this.scope_filter = scope_filter;

        /**
         * Pre-allocated scratch for record positions in the current pack pass.
         * Each slot holds (record_start, record_end) interleaved.
         * Grown on demand.
         * @type {Int32Array}
         * @private
         */
        this.#record_bounds = new Int32Array(64 * 2);

        /**
         * Fired after each per-frame action group is fully applied via
         * {@link unpack_from_peer}. Args: `(peer_id, frame_number)`.
         *
         * Subscribe to drive interpolation buffers, replay logs, diagnostics,
         * or anything else that needs to know "frame N from peer P has just
         * been applied to the local world."
         *
         * Fires in frame-ascending order within a single packet (the order
         * the wire format encodes them in).
         *
         * @type {Signal}
         */
        this.onFrameApplied = new Signal();

        /**
         * Optional deferral hook. When non-null, {@link unpack_from_peer}
         * does NOT touch the action log or executor — it parses the packet
         * and calls this function once per action with:
         *
         *   `(peer_id, frame_number, action_type_id, in_buffer, payload_offset, payload_len)`
         *
         * The buffer is shared scratch; consumers must copy the payload bytes
         * if they need them to outlive the call. The orchestrator is then
         * responsible for executing the buffered actions in whatever order
         * the netcode model requires (e.g. rewind to oldest frame, stable-
         * sort by sender, replay forward).
         *
         * Leave null for "execute on arrival" semantics (the default and
         * what the loopback/single-orchestrator tests rely on).
         *
         * @type {((peer_id: number, frame_number: number, action_type_id: number, in_buffer: BinaryBuffer, payload_offset: number, payload_len: number) => void) | null}
         */
        this.on_pending_action = null;
    }

    /**
     * Pack actions from frames in `[start_frame, end_frame]` (inclusive) into
     * `out_buffer` for a given peer, applying the scope filter.
     *
     * Writes nothing if no in-scope actions are found.
     *
     * @param {number} peer_id
     * @param {number} start_frame inclusive; if no frame in range is in the log, that frame is skipped
     * @param {number} end_frame inclusive
     * @param {BinaryBuffer} out_buffer
     */
    pack_for_peer(peer_id, start_frame, end_frame, out_buffer) {
        assert.isNonNegativeInteger(peer_id, 'peer_id');
        assert.isNonNegativeInteger(start_frame, 'start_frame');
        assert.isNonNegativeInteger(end_frame, 'end_frame');

        for (let frame = start_frame; frame <= end_frame; frame++) {
            if (!this.action_log.has_frame(frame)) continue;

            const buffer = this.action_log.buffer_for(frame);
            const end = this.action_log.write_end_for(frame);

            // First pass: walk records, capture (start, end) for those in scope.
            let in_scope_count = 0;
            while (buffer.position < end) {
                const record_start = buffer.position;
                const in_scope = this.#scan_record_in_scope(buffer, peer_id);
                const record_end = buffer.position;

                if (in_scope) {
                    if ((in_scope_count + 1) * 2 > this.#record_bounds.length) {
                        this.#grow_record_bounds();
                    }
                    this.#record_bounds[in_scope_count * 2] = record_start;
                    this.#record_bounds[in_scope_count * 2 + 1] = record_end;
                    in_scope_count++;
                }
            }

            if (in_scope_count === 0) continue;

            // Second pass: write the frame header + each in-scope action's payload only.
            out_buffer.writeUintVar(frame);
            out_buffer.writeUintVar(in_scope_count);

            for (let i = 0; i < in_scope_count; i++) {
                const rs = this.#record_bounds[i * 2];
                buffer.position = rs;
                this.#copy_action_to(buffer, out_buffer);
            }
        }
    }

    /**
     * Parse and apply actions from `in_buffer` into the local world via the executor.
     * Each frame in the packet opens a new entry in the local action log under the
     * sender's frame number, ensuring the rewind/replay machinery sees consistent
     * frame indexing across peers.
     *
     * @param {number} peer_id
     * @param {BinaryBuffer} in_buffer
     * @param {number} in_buffer_end byte position to stop reading at
     */
    unpack_from_peer(peer_id, in_buffer, in_buffer_end) {
        assert.isNonNegativeInteger(peer_id, 'peer_id');
        assert.isNonNegativeInteger(in_buffer_end, 'in_buffer_end');

        // Deferral path: parse only, hand each action off to the orchestrator's
        // pending-input log. The action log and executor are not touched here —
        // the orchestrator decides when (and in what order) to apply actions
        // (e.g. rewind to oldest pending frame and replay forward with stable-
        // sort tie-breaking by sender, the way rollback netcode demands).
        if (this.on_pending_action !== null) {
            while (in_buffer.position < in_buffer_end) {
                const frame_number = in_buffer.readUintVar();
                const action_count = in_buffer.readUintVar();
                for (let i = 0; i < action_count; i++) {
                    const action_type_id = in_buffer.readUint8();
                    const action_payload_len = in_buffer.readUint32();
                    this.on_pending_action(
                        peer_id, frame_number, action_type_id,
                        in_buffer, in_buffer.position, action_payload_len,
                    );
                    in_buffer.position += action_payload_len;
                }
            }
            return;
        }

        const registry = this.action_registry;

        while (in_buffer.position < in_buffer_end) {
            const frame_number = in_buffer.readUintVar();
            const action_count = in_buffer.readUintVar();

            this.action_log.begin_frame(frame_number);
            try {
                for (let i = 0; i < action_count; i++) {
                    const action_type_id = in_buffer.readUint8();
                    const action_payload_len = in_buffer.readUint32();

                    const klass = registry.klass_for(action_type_id);
                    if (klass === undefined) {
                        // Unknown action — skip the payload to stay aligned and continue.
                        in_buffer.position += action_payload_len;
                        continue;
                    }

                    const action = registry.acquire(klass);
                    try {
                        action.deserialize(in_buffer);
                        // Sender of the action is the peer we received it from
                        // (the wire format strips sender_id for security).
                        this.executor.execute(action, peer_id);
                    } finally {
                        registry.release(action);
                    }
                }
            } finally {
                this.action_log.end_frame();
            }

            // Fire AFTER end_frame so handlers see consistent state and can
            // safely query the action log for the just-applied frame.
            this.onFrameApplied.send2(peer_id, frame_number);
        }
    }

    /**
     * Walk a single record from the buffer's current position, advancing past it.
     * Returns true if any of the record's affected entities is in scope for `peer_id`,
     * or if the record has no affected entities (event-style action — always sent).
     *
     * @param {BinaryBuffer} buffer
     * @param {number} peer_id
     * @returns {boolean}
     * @private
     */
    #scan_record_in_scope(buffer, peer_id) {
        const prior_count = buffer.readUintVar();

        let any_in_scope = false;
        for (let i = 0; i < prior_count; i++) {
            const entity_id = buffer.readUintVar();
            // Translate local entity_id to network_id for the scope filter, which
            // operates on peer-shared identifiers. If `network_for` returns -1,
            // the entity is not currently networked on this sender — either it
            // was never replicated (local-only) or its slot was freed. In both
            // cases the receiver cannot reconstruct the reference (no slot maps
            // to it), so we skip rather than send a packet the receiver can't
            // safely apply.
            const network_id = this.slot_table.network_for(entity_id);
            if (network_id >= 0 && this.scope_filter.is_entity_in_scope(peer_id, network_id)) {
                any_in_scope = true;
            }
            buffer.readUint8();             // component_type_id
            const payload_len = buffer.readUint32();
            buffer.position += payload_len; // skip prior payload
        }

        // Skip the action payload too.
        buffer.readUint8();                 // action_type_id
        buffer.readUint8();                 // sender_id (local-only metadata, not sent)
        const action_payload_len = buffer.readUint32();
        buffer.position += action_payload_len;

        // Event-style actions (no affected components) are always in scope.
        return prior_count === 0 ? true : any_in_scope;
    }

    /**
     * Buffer's current position is the start of an action record. Skip the
     * prior_state section, then copy the action portion (type_id + len + payload)
     * to `out_buffer`. Buffer position advances to the end of the record.
     *
     * @param {BinaryBuffer} buffer source action-log frame
     * @param {BinaryBuffer} out_buffer destination packet
     * @private
     */
    #copy_action_to(buffer, out_buffer) {
        // Skip prior_state.
        const prior_count = buffer.readUintVar();
        for (let i = 0; i < prior_count; i++) {
            buffer.readUintVar();           // entity_id
            buffer.readUint8();             // component_type_id
            const len = buffer.readUint32();
            buffer.position += len;         // skip prior payload
        }

        // Copy action portion: type_id + len + payload. The local-only
        // sender_id byte is STRIPPED here — over-the-wire format stays
        // (type_id, len, payload) and the receiver derives sender from
        // the packet's peer_id. Including sender_id on the wire would
        // let a hostile peer impersonate other peers' actions.
        const action_type_id = buffer.readUint8();
        buffer.readUint8(); // sender_id — discarded, not sent on the wire
        const action_payload_len = buffer.readUint32();

        out_buffer.writeUint8(action_type_id);
        out_buffer.writeUint32(action_payload_len);
        out_buffer.writeBytes(buffer.raw_bytes, buffer.position, action_payload_len);
        buffer.position += action_payload_len;
    }

    /**
     * @private
     */
    #grow_record_bounds() {
        const old = this.#record_bounds;
        const next = new Int32Array(old.length * 2);
        next.set(old);
        this.#record_bounds = next;
    }
}
