// WebSocket transport to the relay server (hbbr:21119).
//
// On connect, send RequestRelay with the uuid we agreed with the peer
// during rendezvous. After hbbr pairs us with the peer (matched by uuid)
// every subsequent binary frame is one message between us and the peer
// — first the unencrypted secure handshake, then secretbox-encrypted
// per-session messages.
//
// This module knows nothing about encryption. session.ts wraps it.

import { hbb } from "../proto/generated.js";

type Frame = Uint8Array;

export interface RelayOptions {
  wsUrl: string;
  peerId: string;
  uuid: string;
  licenceKey: string;
  /** Connect timeout in ms — applies only to the initial RequestRelay roundtrip. */
  connectTimeoutMs?: number;
}

/**
 * Plain duplex stream wrapping a WebSocket. Outbound = `send(bytes)`;
 * inbound = `await next()`. Closed when `close()` is called or the
 * underlying WS errors.
 */
export class Relay {
  private ws!: WebSocket;
  private readBuf: Frame[] = [];
  private waiters: Array<(f: Frame | null) => void> = [];
  private closed = false;
  private closeError: Error | null = null;

  /**
   * Open the WS, send RequestRelay, resolve once the WS is open and our
   * RequestRelay has been queued. The peer's response is delivered via
   * subsequent `next()` calls.
   */
  static async connect(opts: RelayOptions): Promise<Relay> {
    const r = new Relay();
    await r._open(opts);
    return r;
  }

  private async _open(opts: RelayOptions): Promise<void> {
    const timeoutMs = opts.connectTimeoutMs ?? 10_000;
    return new Promise((resolve, reject) => {
      this.ws = new WebSocket(opts.wsUrl);
      this.ws.binaryType = "arraybuffer";

      const timer = setTimeout(() => {
        this._fail(new Error(`relay: connect timeout after ${timeoutMs}ms`));
        reject(new Error(`relay: connect timeout after ${timeoutMs}ms`));
      }, timeoutMs);

      this.ws.onopen = () => {
        clearTimeout(timer);
        const msg = hbb.RendezvousMessage.create({
          request_relay: hbb.RequestRelay.create({
            id: opts.peerId,
            uuid: opts.uuid,
            licence_key: opts.licenceKey,
            conn_type: hbb.ConnType.DEFAULT_CONN,
            // Note: `secure: true` is not set here — secure handshake is
            // negotiated AFTER the relay pairs us, via the Message{public_key}
            // frame the peer sends/expects. The `secure` flag in the
            // *rendezvous-side* RequestRelay was the hint to the peer.
          }),
        });
        const bytes = hbb.RendezvousMessage.encode(msg).finish();
        this.ws.send(bytes);
        resolve();
      };

      this.ws.onmessage = (ev: MessageEvent) => {
        const buf = new Uint8Array(ev.data as ArrayBuffer);
        // Wake one waiter or queue.
        const w = this.waiters.shift();
        if (w) w(buf);
        else this.readBuf.push(buf);
      };

      this.ws.onerror = () => {
        clearTimeout(timer);
        this._fail(new Error(`relay: WebSocket error on ${opts.wsUrl}`));
        reject(new Error(`relay: WebSocket error on ${opts.wsUrl}`));
      };

      this.ws.onclose = (ev: CloseEvent) => {
        clearTimeout(timer);
        this._fail(new Error(`relay: socket closed (code=${ev.code} reason=${ev.reason || "n/a"})`));
      };
    });
  }

  /** Read the next frame from the relay. Resolves null after close. */
  next(): Promise<Frame | null> {
    if (this.readBuf.length > 0) {
      return Promise.resolve(this.readBuf.shift()!);
    }
    if (this.closed) {
      return Promise.resolve(null);
    }
    return new Promise((resolve, reject) => {
      this.waiters.push((f) => {
        if (this.closeError && f === null) reject(this.closeError);
        else resolve(f);
      });
    });
  }

  /** Send a binary frame on the relay. */
  send(bytes: Uint8Array): void {
    if (this.closed) throw new Error("relay: send on closed connection");
    this.ws.send(bytes);
  }

  /** Close the underlying WS and wake any pending readers. */
  close(): void {
    this._fail(new Error("relay: closed by client"));
  }

  private _fail(err: Error): void {
    if (this.closed) return;
    this.closed = true;
    this.closeError = err;
    try { this.ws.close(); } catch { /* ignore */ }
    while (this.waiters.length > 0) {
      const w = this.waiters.shift()!;
      w(null);
    }
  }
}
