import { assert } from "../../../../core/assert.js";
import { seededRandom } from "../../../../core/math/random/seededRandom.js";
import { Transport } from "../Transport.js";

/**
 * In-process Transport pair that models real network conditions.
 *
 * Each `send` samples loss probability against the live `config`; survivors
 * are queued for delivery at `now + latency + jitter` and {@link tick} drains
 * anything past its deadline. Reorder happens naturally when jitter is high
 * relative to send rate.
 *
 * Sister to {@link LoopbackTransport}. Loopback is for unit tests where you
 * want deterministic, manual `deliver_all` control. SimulatedTransport is for
 * prototypes and stress tests where you want realistic-feeling behaviour
 * driven by wall-clock time.
 *
 * Pair two instances with {@link bind_pair}; each becomes the other's delivery
 * target. The `config` object is mutable at runtime — sliders in dev tools
 * can poke at it freely.
 *
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */
export class SimulatedTransport extends Transport {
    #stats;
    #forced_drops_remaining;
    #random;


    /**
     * @param {{
     *   latency_ms?: number,
     *   jitter_ms?: number,
     *   loss_pct?: number,
     *   clock?: function(): number,
     * }} [options]
     *
     * `clock` returns the current "now" in ms — used both to schedule
     * `deliver_at_ms` inside {@link send} and as the default for {@link tick}.
     * Defaults to `Date.now`, which is right for tests and any caller that
     * uses real wall time. Pair-bound transports should share the same clock
     * source (typically a single sim-time variable) so packets sent on one
     * side become deliverable at the matching moment on the other.
     */
    constructor({
                    latency_ms = 60,
                    jitter_ms = 15,
                    loss_pct = 2,
                    clock = Date.now,
                    random_seed = 1337
                } = {}) {
        super();
        assert.isNumber(latency_ms, 'latency_ms');
        assert.isNumber(jitter_ms, 'jitter_ms');
        assert.isNumber(loss_pct, 'loss_pct');
        assert.isFunction(clock, 'clock');

        /**
         * Peer transport; null until {@link bind_pair} is called.
         * @type {SimulatedTransport|null}
         */
        this.peer = null;

        /**
         * Inbound queue, sorted ascending by `deliver_at_ms`.
         * Each entry: `{ deliver_at_ms: number, bytes: Uint8Array }`.
         * @type {{deliver_at_ms: number, bytes: Uint8Array}[]}
         */
        this.in_queue = [];

        /**
         * Live-tunable simulated link conditions. Mutate these freely; values
         * take effect on the next `send`.
         */
        this.config = { latency_ms, jitter_ms, loss_pct };

        /**
         * Stats. Includes `packets_dropped` (a SimulatedTransport-specific count
         * not in the base `getStats()` shape).
         * @private
         */
        this.#stats = {
            bytes_in: 0, bytes_out: 0,
            packets_in: 0, packets_out: 0,
            packets_dropped: 0,
        };

        /**
         * Counter for deterministic forced losses scheduled by {@link force_drop_next}.
         * Each `send` decrements this and drops the packet if non-zero, regardless of
         * the configured `loss_pct`. Used by demos / repro tests where you want to
         * trigger a precise burst of loss.
         * @private @type {number}
         */
        this.#forced_drops_remaining = 0;


        /**
         *
         * @type {function(): number}
         * @private
         */
        this.#random = seededRandom(random_seed);

        /**
         * Clock source. Returns the current time in ms — read once per
         * {@link send} call to compute `deliver_at_ms`, and used as the default
         * for {@link tick} when no explicit `now_ms` is supplied.
         * @type {function(): number}
         */
        this.clock = clock;
    }

    /**
     * Bind two SimulatedTransports together so they deliver to each other.
     *
     * @param {SimulatedTransport} a
     * @param {SimulatedTransport} b
     */
    static bind_pair(a, b) {
        assert.ok(a instanceof SimulatedTransport, 'a must be a SimulatedTransport');
        assert.ok(b instanceof SimulatedTransport, 'b must be a SimulatedTransport');
        a.peer = b;
        b.peer = a;
    }

    /**
     * @param {Uint8Array} bytes
     * @param {number} length
     */
    send(bytes, length) {
        assert.isNonNegativeInteger(length, 'length');

        this.#stats.bytes_out += length;
        this.#stats.packets_out += 1;

        if (this.peer === null) return;

        // Forced drops trump configured probability. They model a deterministic
        // loss burst — useful for demos and reproducible repros.
        if (this.#forced_drops_remaining > 0) {
            this.#forced_drops_remaining--;
            this.#stats.packets_dropped += 1;
            return;
        }

        if (this.#random() * 100 < this.config.loss_pct) {
            this.#stats.packets_dropped += 1;
            return;
        }

        const now_ms = this.clock();
        const jitter = (this.#random() * 2 - 1) * this.config.jitter_ms;
        const deliver_at_ms = now_ms + this.config.latency_ms + jitter;

        const copy = new Uint8Array(length);
        copy.set(bytes.subarray(0, length));

        // Insertion-sort into peer's queue by delivery time. Reorder happens
        // naturally because the new packet may slot in ahead of an earlier
        // packet that drew a larger jitter sample.
        const q = this.peer.in_queue;
        let i = q.length;
        while (i > 0 && q[i - 1].deliver_at_ms > deliver_at_ms) i--;
        q.splice(i, 0, { deliver_at_ms, bytes: copy });
    }

    /**
     * Deliver every packet whose `deliver_at_ms <= now_ms`. Call once per tick
     * (or whenever you want to advance the simulated link).
     *
     * @param {number} now_ms
     * @returns {number} packets delivered
     */
    tick(now_ms) {
        assert.isNumber(now_ms, 'now_ms');
        let delivered = 0;
        const q = this.in_queue;
        while (q.length > 0 && q[0].deliver_at_ms <= now_ms) {
            const e = q.shift();
            this.#stats.bytes_in += e.bytes.length;
            this.#stats.packets_in += 1;
            this.onReceive.send2(e.bytes, e.bytes.length);
            delivered++;
        }
        return delivered;
    }

    /**
     * Number of packets queued for delivery but not yet released.
     * @returns {number}
     */
    queued_count() {
        return this.in_queue.length;
    }

    /**
     * Number of packets dropped to simulated loss since construction.
     * @returns {number}
     */
    dropped_count() {
        return this.#stats.packets_dropped;
    }

    /**
     * Schedule the next `n` outbound packets to be dropped unconditionally
     * (in addition to any loss from `config.loss_pct`). Useful for demos and
     * reproducible loss-burst tests where you want the recovery path to engage
     * on demand rather than after enough random drops.
     *
     * @param {number} n
     */
    force_drop_next(n) {
        assert.isNonNegativeInteger(n, 'n');
        this.#forced_drops_remaining += n;
    }

    /**
     * Clear the queue and unbind. Subsequent sends become no-ops.
     *
     * Mirrors {@link LoopbackTransport#disconnect}: the local side fires
     * `onDisconnect` so test harnesses can drive reconnect flows
     * deterministically, and the formerly-paired peer is notified so its
     * own listeners observe the link drop. Symmetric whether the
     * disconnect originates here or on the peer.
     */
    disconnect() {
        this.in_queue.length = 0;
        if (this.peer !== null) {
            const p = this.peer;
            this.peer = null;
            if (p.peer === this) {
                p.peer = null;
                p.onDisconnect.send1('peer_disconnected');
            }
        }
        this.onDisconnect.send1('local_disconnect');
    }

    /**
     * @returns {{bytes_in: number, bytes_out: number, packets_in: number, packets_out: number}}
     */
    getStats() {
        return {
            bytes_in: this.#stats.bytes_in,
            bytes_out: this.#stats.bytes_out,
            packets_in: this.#stats.packets_in,
            packets_out: this.#stats.packets_out,
        };
    }
}
