import { BaseClient } from "./base_client.js";
import { type ParserMap } from "./communicator.js";
import { fromBinary, PacketBaseSchema, toBinary, type PacketBase } from "./packets/message_common_pb.js";

export interface BrowserWsConnectOptions {
  WebSocketCtor?: typeof WebSocket;
}

export class BrowserWsClient extends BaseClient {
  private readonly ws: WebSocket;
  private readonly handleMessage: (event: MessageEvent) => void;
  private readonly handleClose: () => void;
  private readonly handleError: () => void;
  private parseQueue = Promise.resolve();
  private stagingCount = 0;

  constructor(ws: WebSocket, intervalSec: number, waitSec: number, parserMap: ParserMap) {
    super(intervalSec, waitSec, () => closeBrowserWebSocket(ws));
    this.ws = ws;
    this.ws.binaryType = "arraybuffer";
    this.initBase(parserMap);

    this.handleMessage = (event: MessageEvent) => {
      if (this.stagingCount >= 64) {
        ws.close(1008, "inbound staging buffer overflow");
        void this.onDisconnected();
        return;
      }
      this.stagingCount++;
      this.parseQueue = this.parseQueue.then(async () => {
        try {
          await this.onMessage(event.data);
        } catch {
          // ignore error to keep the chain alive
        } finally {
          this.stagingCount--;
        }
      });
    };
    this.handleClose = () => {
      void this.onDisconnected();
    };
    this.handleError = () => {
      void this.onDisconnected();
    };

    this.ws.addEventListener("message", this.handleMessage);
    this.ws.addEventListener("close", this.handleClose);
    this.ws.addEventListener("error", this.handleError);
  }

  async writePacket(base: PacketBase): Promise<void> {
    const data = toBinary(PacketBaseSchema, base);
    try {
      this.ws.send(data);
    } catch (err) {
      throw err instanceof Error ? err : new Error(String(err));
    }
  }

  private async onMessage(data: unknown): Promise<void> {
    let bytes: Uint8Array;
    try {
      bytes = await toUint8Array(data);
    } catch {
      void this.onDisconnected();
      return;
    }
    try {
      const base = fromBinary(PacketBaseSchema, bytes);
      await this.communicator.enqueueInbound(base.typeName, base.data, base.nonce, base.responseNonce);
      void this.sendHeartbeat();
    } catch {
      void this.onDisconnected();
    }
  }
}

export async function connectBrowserWs(
  url: string,
  intervalSec: number,
  waitSec: number,
  parserMap: ParserMap,
  options?: BrowserWsConnectOptions,
): Promise<BrowserWsClient> {
  const Ctor = options?.WebSocketCtor ?? globalThis.WebSocket;
  if (Ctor === undefined) {
    throw new Error("global WebSocket is not available; provide WebSocketCtor option");
  }
  return new Promise<BrowserWsClient>((resolve, reject) => {
    const ws = new Ctor(url);
    ws.binaryType = "arraybuffer";
    const onOpen = () => {
      cleanup();
      resolve(new BrowserWsClient(ws, intervalSec, waitSec, parserMap));
    };
    const onError = () => {
      cleanup();
      reject(new Error(`failed to connect ${url}`));
    };
    const cleanup = () => {
      ws.removeEventListener("open", onOpen);
      ws.removeEventListener("error", onError);
    };
    ws.addEventListener("open", onOpen);
    ws.addEventListener("error", onError);
  });
}

async function toUint8Array(data: unknown): Promise<Uint8Array> {
  if (data instanceof ArrayBuffer) {
    return new Uint8Array(data);
  }
  if (data instanceof Uint8Array) {
    return data;
  }
  if (typeof Blob !== "undefined" && data instanceof Blob) {
    const buf = await data.arrayBuffer();
    return new Uint8Array(buf);
  }
  throw new Error("unsupported WebSocket frame: text or unknown payload");
}

async function closeBrowserWebSocket(ws: WebSocket): Promise<void> {
  if (ws.readyState === ws.CLOSED) {
    return;
  }
  await new Promise<void>((resolve) => {
    const onDone = () => {
      ws.removeEventListener("close", onDone);
      ws.removeEventListener("error", onDone);
      resolve();
    };
    ws.addEventListener("close", onDone);
    ws.addEventListener("error", onDone);
    if (ws.readyState === ws.CLOSING) {
      return;
    }
    try {
      ws.close(1000, "");
    } catch {
      onDone();
    }
  });
}
