import { createSocket } from "node:dgram";
import { assert } from "../../../../core/assert.js";
import { Transport } from "../Transport.js";

/**
 * Transport adapter over Node's `dgram` (UDP) socket.
 *
 * Designed for one-to-one peer relationships: each adapter instance talks to
 * exactly one remote endpoint. For a server with many clients, create one
 * adapter per client (typically after observing the first packet from each
 * client's source `address:port`).
 *
 * The adapter binds locally on construction. To connect to a known remote
 * (client pattern), pass `remote: { address, port }`. To accept inbound from
 * any source (server pattern), leave `remote` unset and call
 * {@link set_remote} after the first inbound packet identifies the peer.
 *
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */
export class NodeUDPTransport extends Transport {
    #remote;
    #stats;
    #on_message;
    #on_close;
    #on_socket_error;
    #disconnect_fired;


    /**
     * @param {{
     *   bind_address?: string,
     *   bind_port?: number,
     *   remote?: { address: string, port: number },
     * }} [options]
     */
    constructor({ bind_address = '0.0.0.0', bind_port = 0, remote = null } = {}) {
        super();
        assert.isString(bind_address, 'bind_address');
        assert.isNonNegativeInteger(bind_port, 'bind_port');

        /** @type {import('node:dgram').Socket} */
        this.socket = createSocket('udp4');

        /**
         * Remote `{address, port}` to send to. May be null until the first inbound
         * packet identifies the peer (server pattern). Sending before this is set throws.
         * @type {{address: string, port: number}|null}
         * @private
         */
        this.#remote = remote;

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

        // A socket-level error and a subsequent close are the same failure to
        // higher layers — fire `onDisconnect` once.
        /** @private */
        this.#disconnect_fired = false;

        /** @private */
        this.#on_message = (msg, rinfo) => {
            // If we don't have a remote yet, latch onto the first sender (server pattern).
            if (this.#remote === null) {
                this.#remote = { address: rinfo.address, port: rinfo.port };
            }
            this.#stats.bytes_in += msg.length;
            this.#stats.packets_in += 1;
            // Buffer extends Uint8Array; safe to pass directly.
            this.onReceive.send2(msg, msg.length);
        };

        /** @private */
        this.#on_close = () => {
            if (this.#disconnect_fired) return;
            this.#disconnect_fired = true;
            this.onDisconnect.send1('socket_closed');
        };

        /** @private */
        this.#on_socket_error = (err) => {
            if (this.#disconnect_fired) return;
            this.#disconnect_fired = true;
            this.onDisconnect.send1(err && err.message ? err.message : 'socket_error');
        };

        this.socket.on('message', this.#on_message);
        this.socket.on('close', this.#on_close);
        // A persistent error listener is required — without one, Node throws
        // 'error' as an uncaught exception (`Error [ERR_UNHANDLED_ERROR]`).
        // The transient one wired up in `connect()` is removed once bind
        // succeeds, leaving runtime errors unguarded otherwise.
        this.socket.on('error', this.#on_socket_error);
        this.socket.bind(bind_port, bind_address);
    }

    /**
     * Set or update the remote endpoint that {@link send} targets.
     * @param {string} address
     * @param {number} port
     */
    set_remote(address, port) {
        assert.isString(address, 'address');
        assert.isNonNegativeInteger(port, 'port');
        this.#remote = { address, port };
    }

    /**
     * Returns the locally bound `{address, port}`, useful for "what port did I
     * end up on?" when constructed with `bind_port: 0`.
     * @returns {{address: string, port: number}|null}
     */
    get_bound_address() {
        try {
            return this.socket.address();
        } catch (e) {
            return null; // socket not yet bound or already closed
        }
    }

    /**
     * @param {Uint8Array} bytes
     * @param {number} length
     */
    send(bytes, length) {
        assert.isNonNegativeInteger(length, 'length');
        if (this.#remote === null) {
            throw new Error("NodeUDPTransport.send: no remote set; pass `remote` to constructor or call set_remote()");
        }
        if (length === 0) return;
        // dgram.send accepts a typed array; subarray to bound the length.
        const payload = (length === bytes.byteLength) ? bytes : bytes.subarray(0, length);
        this.socket.send(payload, this.#remote.port, this.#remote.address);
        this.#stats.bytes_out += length;
        this.#stats.packets_out += 1;
    }

    /**
     * Block until the socket is bound and ready. Useful in tests; production
     * code typically doesn't need to await this (sending before bind is fine —
     * dgram queues internally).
     * @returns {Promise<void>}
     */
    connect() {
        return new Promise((resolve, reject) => {
            if (this.socket.address && this.#try_bound()) {
                resolve();
                return;
            }
            const on_listening = () => {
                this.socket.off('error', on_bind_error);
                resolve();
            };
            // Bind-phase error handler is separate from the persistent
            // runtime handler wired up in the constructor: bind failures
            // should reject the connect() promise rather than fire
            // `onDisconnect` on a transport that never came up.
            const on_bind_error = (err) => {
                this.socket.off('listening', on_listening);
                // Latch so the persistent runtime handler doesn't also
                // fire `onDisconnect` for the same bind failure.
                this.#disconnect_fired = true;
                reject(err);
            };
            this.socket.once('listening', on_listening);
            this.socket.once('error', on_bind_error);
        });
    }

    /**
     * Close the underlying socket. After this, calls to {@link send} will fail.
     *
     * Locally-initiated disconnect: the socket's `close` event would
     * otherwise fire `onDisconnect` afterward, which surprises callers
     * who explicitly tore down the transport. Suppressed via the latch.
     */
    disconnect() {
        this.#disconnect_fired = true;
        try {
            this.socket.off('message', this.#on_message);
            this.socket.off('close', this.#on_close);
            this.socket.off('error', this.#on_socket_error);
            this.socket.close();
        } catch (e) {
            // socket may already be 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,
        };
    }

    /** @private */
    #try_bound() {
        try { this.socket.address(); return true; }
        catch { return false; }
    }
}
