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

/**
 * Transport adapter over a `WebSocket`.
 *
 * **Not** the right choice for game state — WebSocket runs over TCP, which
 * means head-of-line blocking under packet loss. Use {@link WebRTCDataChannelTransport}
 * for game state. WebSocket is appropriate for lobby/matchmaking, chat, replay
 * uploads, and other use cases where ordering and reliability matter more
 * than tail latency.
 *
 * Duck-typed against the standard browser `WebSocket` interface so tests can
 * pass a mock. The expected interface:
 *   - `binaryType` (writable; we set it to `'arraybuffer'`)
 *   - `addEventListener(type, handler)` for `'message'`, `'close'`, `'error'`, `'open'`
 *   - `send(ArrayBufferView | ArrayBuffer)`
 *   - `close()`
 *   - `readyState`
 *
 * Either pass an already-constructed WebSocket via `socket`, or pass a `url`
 * and let the adapter construct one (browser only — `WebSocket` must be available).
 *
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */
export class WebSocketTransport extends Transport {
    #stats;
    #on_message;
    #on_close;
    #disconnect_fired;


    /**
     * @param {{ socket?: WebSocket, url?: string }} options
     */
    constructor({ socket, url } = {}) {
        super();
        if (socket === undefined && typeof url !== "string") {
            throw new Error("WebSocketTransport: either `socket` or `url` is required");
        }

        // WebSocket sits on TCP — every message is delivered in order
        // with no loss until the socket closes.
        this.reliable = true;
        this.ordered = true;

        /** @type {WebSocket} */
        this.socket = socket !== undefined ? socket : new WebSocket(url);

        assert.ok(typeof this.socket.send === 'function', 'socket must implement send()');

        this.socket.binaryType = 'arraybuffer';

        /** @private */
        this.#stats = { bytes_in: 0, bytes_out: 0, packets_in: 0, packets_out: 0 };

        // Latch so that local disconnect() and the subsequent socket
        // 'close' event don't both fire `onDisconnect`.
        /** @private */
        this.#disconnect_fired = false;

        /** @private */
        this.#on_message = (event) => {
            // event.data is ArrayBuffer (binaryType set above) for binary frames,
            // or string for text frames. We treat strings as text payloads converted to UTF-8 bytes.
            let bytes;
            if (event.data instanceof ArrayBuffer) {
                bytes = new Uint8Array(event.data);
            } else if (typeof event.data === "string") {
                bytes = new TextEncoder().encode(event.data);
            } else {
                // Some browsers wrap as Blob if binaryType is misset; ignore.
                return;
            }
            this.#stats.bytes_in += bytes.length;
            this.#stats.packets_in += 1;
            this.onReceive.send2(bytes, bytes.length);
        };

        // Forward socket close into the `onDisconnect` Signal so higher
        // layers (e.g. `NetworkSession`'s reconnect ladder) can react.
        // `'close'` covers both clean and abrupt closures; some
        // implementations fire `'error'` first and then `'close'`, so we
        // only fire ours on close. The latch additionally suppresses the
        // echo when callers locally disconnect.
        /** @private */
        this.#on_close = (ev) => {
            if (this.#disconnect_fired) return;
            this.#disconnect_fired = true;
            const reason = ev && ev.reason ? ev.reason : `code:${ev && ev.code}`;
            this.onDisconnect.send1(reason);
        };

        this.socket.addEventListener('message', this.#on_message);
        this.socket.addEventListener('close', this.#on_close);
    }

    /**
     * @param {Uint8Array} bytes
     * @param {number} length
     */
    send(bytes, length) {
        assert.isNonNegativeInteger(length, 'length');
        if (length === 0) return;
        const payload = (length === bytes.byteLength) ? bytes : bytes.subarray(0, length);
        this.socket.send(payload);
        this.#stats.bytes_out += length;
        this.#stats.packets_out += 1;
    }

    /**
     * Resolve when the socket is OPEN. If already open, resolves immediately.
     * Useful when constructed with a `url` and the caller wants to await readiness.
     *
     * Rejects on either `'error'` or `'close'` — some failure modes (server
     * rejects the handshake with a clean close, network drops mid-handshake)
     * fire `'close'` without `'error'`; listening on only one would hang the
     * promise forever.
     *
     * @returns {Promise<void>}
     */
    connect() {
        const OPEN = 1; // WebSocket.OPEN
        if (this.socket.readyState === OPEN) return Promise.resolve();

        return new Promise((resolve, reject) => {
            const cleanup = () => {
                this.socket.removeEventListener('open', on_open);
                this.socket.removeEventListener('error', on_error);
                this.socket.removeEventListener('close', on_close);
            };
            const on_open = () => {
                cleanup();
                resolve();
            };
            const on_error = (err) => {
                cleanup();
                reject(err);
            };
            const on_close = (ev) => {
                cleanup();
                reject(new Error(`WebSocketTransport.connect: socket closed before opening (code=${ev && ev.code})`));
            };
            this.socket.addEventListener('open', on_open);
            this.socket.addEventListener('error', on_error);
            this.socket.addEventListener('close', on_close);
        });
    }

    /**
     * Close the socket. Subsequent sends throw via the underlying WebSocket.
     *
     * Locally-initiated disconnect: latched so the socket's own subsequent
     * `close` event doesn't fire `onDisconnect` again — callers who
     * explicitly tore down don't expect a notification echo.
     */
    disconnect() {
        this.#disconnect_fired = true;
        try {
            this.socket.removeEventListener('message', this.#on_message);
            this.socket.removeEventListener('close', this.#on_close);
            this.socket.close();
        } catch (e) {
            // Already closed; ignore.
        }
    }

    /**
     * @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,
        };
    }
}
