/**
 * Transport abstraction for communicating with a quicproquo server.
 *
 * The native qpq server speaks Cap'n Proto RPC over QUIC/TCP + Noise_XX.
 * Browsers cannot open raw TCP sockets, so this SDK assumes a WebSocket
 * bridge proxy that translates JSON-over-WebSocket to capnp RPC.
 *
 * The transport layer handles:
 *  - Binary/text message framing over WebSocket
 *  - Request/response correlation via incrementing IDs
 *  - Automatic reconnection with exponential back-off
 */

import type {
  ConnectOptions,
  RpcRequest,
  RpcResponse,
  TransportEvent,
} from "./types.js";

// ---------------------------------------------------------------------------
// Transport interface
// ---------------------------------------------------------------------------

export interface Transport {
  /** Send an RPC request and wait for the correlated response. */
  call(method: string, params: Record<string, unknown>): Promise<RpcResponse>;
  /** Register a listener for transport-level events. */
  on(handler: (event: TransportEvent) => void): void;
  /** True when the underlying connection is open. */
  readonly connected: boolean;
  /** Gracefully close the transport. */
  close(): void;
}

// ---------------------------------------------------------------------------
// WebSocketTransport
// ---------------------------------------------------------------------------

const DEFAULT_CONNECT_TIMEOUT = 5_000;
const DEFAULT_REQUEST_TIMEOUT = 10_000;
const MAX_RECONNECT_DELAY = 30_000;

interface PendingCall {
  resolve: (r: RpcResponse) => void;
  reject: (e: Error) => void;
  timer: ReturnType<typeof setTimeout>;
}

export class WebSocketTransport implements Transport {
  private ws: WebSocket | null = null;
  private nextId = 1;
  private pending = new Map<number, PendingCall>();
  private handlers: Array<(event: TransportEvent) => void> = [];
  private _connected = false;
  private reconnectDelay = 1_000;
  private shouldReconnect = true;
  private readonly opts: ConnectOptions;

  private constructor(opts: ConnectOptions) {
    this.opts = opts;
  }

  get connected(): boolean {
    return this._connected;
  }

  /** Create and connect a WebSocketTransport. Resolves when the socket opens. */
  static connect(opts: ConnectOptions): Promise<WebSocketTransport> {
    const transport = new WebSocketTransport(opts);
    return transport.doConnect();
  }

  on(handler: (event: TransportEvent) => void): void {
    this.handlers.push(handler);
  }

  async call(
    method: string,
    params: Record<string, unknown>,
  ): Promise<RpcResponse> {
    if (!this._connected || !this.ws) {
      throw new Error("Transport is not connected");
    }

    const id = this.nextId++;
    const request: RpcRequest = { id, method, params };

    return new Promise<RpcResponse>((resolve, reject) => {
      const timeout = this.opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT;
      const timer = setTimeout(() => {
        this.pending.delete(id);
        reject(new Error(`RPC timeout for ${method} (id=${id})`));
      }, timeout);

      this.pending.set(id, { resolve, reject, timer });
      this.ws!.send(JSON.stringify(request));
    });
  }

  close(): void {
    this.shouldReconnect = false;
    if (this.ws) {
      this.ws.close(1000, "client closing");
      this.ws = null;
    }
    this.rejectAll("Transport closed");
  }

  // -----------------------------------------------------------------------
  // Internal
  // -----------------------------------------------------------------------

  private doConnect(): Promise<WebSocketTransport> {
    return new Promise<WebSocketTransport>((resolve, reject) => {
      const timeout = this.opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT;
      const timer = setTimeout(() => {
        reject(new Error(`Connection timeout after ${timeout}ms`));
        if (this.ws) {
          this.ws.close();
          this.ws = null;
        }
      }, timeout);

      const ws = new WebSocket(this.opts.addr);
      ws.binaryType = "arraybuffer";
      this.ws = ws;

      ws.addEventListener("open", () => {
        clearTimeout(timer);
        this._connected = true;
        this.reconnectDelay = 1_000;
        this.emit({ type: "open" });
        resolve(this);
      });

      ws.addEventListener("close", (ev) => {
        clearTimeout(timer);
        this._connected = false;
        this.emit({ type: "close", code: ev.code, reason: ev.reason });
        this.rejectAll(`WebSocket closed: ${ev.code} ${ev.reason}`);
        this.maybeReconnect();
      });

      ws.addEventListener("error", () => {
        const err = new Error("WebSocket error");
        this.emit({ type: "error", error: err });
      });

      ws.addEventListener("message", (ev) => {
        this.handleMessage(ev.data);
      });
    });
  }

  private handleMessage(raw: unknown): void {
    // Binary frames are forwarded as-is
    if (raw instanceof ArrayBuffer) {
      this.emit({ type: "message", data: new Uint8Array(raw) });
      return;
    }

    // Text frames are parsed as JSON RPC responses
    if (typeof raw === "string") {
      let resp: RpcResponse;
      try {
        resp = JSON.parse(raw) as RpcResponse;
      } catch {
        return; // ignore malformed
      }
      const pending = this.pending.get(resp.id);
      if (pending) {
        clearTimeout(pending.timer);
        this.pending.delete(resp.id);
        pending.resolve(resp);
      }
    }
  }

  private emit(event: TransportEvent): void {
    for (const h of this.handlers) {
      h(event);
    }
  }

  private rejectAll(reason: string): void {
    for (const [id, pending] of this.pending) {
      clearTimeout(pending.timer);
      pending.reject(new Error(reason));
      this.pending.delete(id);
    }
  }

  private maybeReconnect(): void {
    if (!this.shouldReconnect) return;
    const delay = Math.min(this.reconnectDelay, MAX_RECONNECT_DELAY);
    this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY);
    setTimeout(() => {
      if (this.shouldReconnect) {
        this.doConnect().catch(() => {
          /* reconnect attempt failed, will retry via close handler */
        });
      }
    }, delay);
  }
}
