import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { VoiceCallTransport } from "@/core/net/voice-call-transport";

class FakeWebSocket {
  static readonly CONNECTING = 0;
  static readonly OPEN = 1;
  static readonly CLOSED = 3;
  readyState = FakeWebSocket.CONNECTING;
  binaryType = "blob";
  sent: unknown[] = [];
  onopen: (() => void) | null = null;
  onmessage: ((event: { data: unknown }) => void) | null = null;
  onclose: ((event: { code: number; reason: string; wasClean: boolean }) => void) | null = null;
  onerror: (() => void) | null = null;

  constructor(readonly url: string) {}
  send(data: unknown) { this.sent.push(data); }
  close() { this.readyState = FakeWebSocket.CLOSED; }
  open() { this.readyState = FakeWebSocket.OPEN; this.onopen?.(); }
  receive(data: unknown) { this.onmessage?.({ data }); }
}

describe("VoiceCallTransport", () => {
  let sockets: FakeWebSocket[];

  beforeEach(() => {
    sockets = [];
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  function create() {
    return new VoiceCallTransport("wss://api.example/ws", "secret-token", {
      webSocketFactory: (url) => {
        const socket = new FakeWebSocket(url);
        sockets.push(socket);
        return socket as unknown as WebSocket;
      },
    });
  }

  it("uploads 100-200ms chunks as binary frames instead of base64 JSON", () => {
    const transport = create();
    transport.connect();
    sockets[0].open();

    transport.startCall("elio", "turn-1");
    transport.startAudio("call-1", "turn-1", "audio/webm;codecs=opus", 48000);
    const frame = new Uint8Array([1, 2, 3]).buffer;
    transport.sendAudioChunk(frame);
    transport.endAudio("call-1", "turn-1");

    expect(JSON.parse(sockets[0].sent[0] as string)).toMatchObject({
      type: "call_start",
      protocolVersion: 2,
      characterId: "elio",
      turnId: "turn-1",
    });
    expect(JSON.parse(sockets[0].sent[1] as string)).toMatchObject({
      type: "call_audio_start",
      encoding: "opus",
    });
    expect(sockets[0].sent[2]).toBe(frame);
    expect(JSON.parse(sockets[0].sent[3] as string).type).toBe("call_audio_end");
  });

  it("associates the next binary payload with its JSON audio metadata", () => {
    const transport = create();
    const received = vi.fn();
    transport.onAudioChunk = received;
    transport.connect();
    sockets[0].open();
    sockets[0].receive(JSON.stringify({
      type: "call_audio_chunk_start",
      protocolVersion: 2,
      callId: "call-1",
      turnId: "turn-1",
      index: 0,
      mimeType: "audio/mpeg",
      byteLength: 3,
    }));
    const bytes = new Uint8Array([4, 5, 6]).buffer;
    sockets[0].receive(bytes);

    expect(received).toHaveBeenCalledWith(
      expect.objectContaining({ turnId: "turn-1", index: 0, mimeType: "audio/mpeg" }),
      bytes,
    );
  });

  it("surfaces billing fields without treating a normal charge as an error", () => {
    const transport = create();
    const billing = vi.fn();
    transport.onBillingStatus = billing;
    transport.connect();
    sockets[0].open();
    sockets[0].receive(JSON.stringify({
      type: "call_billing_status",
      status: "settled",
      creditsCharged: 2,
      creditBalance: 8,
      requiredCredits: 2,
      shortfallCredits: 0,
      canContinue: true,
      nextTurnId: "turn-2",
    }));

    expect(billing).toHaveBeenCalledWith(expect.objectContaining({
      creditsCharged: 2,
      creditBalance: 8,
      canContinue: true,
      nextTurnId: "turn-2",
    }));
  });

  it("starts a reconnect attempt within the two-second release gate", () => {
    vi.useFakeTimers();
    const transport = create();
    const reconnecting = vi.fn();
    transport.onReconnecting = reconnecting;
    transport.connect();
    sockets[0].open();
    sockets[0].onclose?.({ code: 1006, reason: "network", wasClean: false });

    expect(reconnecting).toHaveBeenCalledWith(400);
    vi.advanceTimersByTime(400);
    expect(sockets).toHaveLength(2);
    sockets[1].open();
    expect(JSON.parse(sockets[1].sent[0] as string)).toMatchObject({
      type: "call_client_metric",
      name: "reconnectLatency",
      milliseconds: 400,
    });
    transport.disconnect();
  });
});
