/**
 * Minion Realtime Client
 *
 * Browser-side JS/TS class that wraps the full WebRTC lifecycle for
 * connecting to OpenAI's Realtime API via an ephemeral token from the
 * Minion gateway.
 *
 * This module has zero Node.js dependencies — it uses only browser APIs
 * (RTCPeerConnection, MediaStream, fetch, AudioContext).
 *
 * Usage:
 *   const client = new MinionRealtimeClient({
 *     tokenUrl: "https://your-gateway/voice/webrtc/token",
 *     gatewayToken: "your-gateway-token", // optional auth
 *   });
 *   client.on("transcript", ({ speaker, text }) => console.log(`${speaker}: ${text}`));
 *   await client.connect();
 *   // ... user speaks, model responds via audio ...
 *   client.disconnect();
 */

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export type ConnectionStatus = "disconnected" | "connecting" | "connected";

export type RealtimeEvent =
  | { type: "connected" }
  | { type: "disconnected"; reason?: string }
  | { type: "transcript"; speaker: "user" | "bot"; text: string }
  | { type: "speech_start" }
  | { type: "speech_stop" }
  | { type: "error"; error: Error }
  | { type: "audio_level"; level: number };

export type RealtimeEventType = RealtimeEvent["type"];

export type EventCallback<T extends RealtimeEventType> = (
  event: Extract<RealtimeEvent, { type: T }>,
) => void;

export interface MinionRealtimeClientOptions {
  /** URL of the gateway's WebRTC token endpoint */
  tokenUrl: string;
  /** Gateway authentication token (sent as Bearer in Authorization header) */
  gatewayToken?: string;
  /** Audio constraints for getUserMedia */
  audioConstraints?: MediaTrackConstraints;
  /** Auto-reconnect on disconnect (default: false) */
  autoReconnect?: boolean;
  /** Max reconnect attempts (default: 3) */
  maxReconnectAttempts?: number;
}

interface EphemeralTokenResponse {
  token: string;
  expiresAt: number;
  sessionId: string;
  model?: string;
}

// ---------------------------------------------------------------------------
// Client
// ---------------------------------------------------------------------------

export class MinionRealtimeClient {
  private options: Required<
    Pick<MinionRealtimeClientOptions, "tokenUrl" | "autoReconnect" | "maxReconnectAttempts">
  > &
    MinionRealtimeClientOptions;

  private pc: RTCPeerConnection | null = null;
  private dataChannel: RTCDataChannel | null = null;
  private localStream: MediaStream | null = null;
  private remoteAudio: HTMLAudioElement | null = null;
  private _status: ConnectionStatus = "disconnected";
  private _isMuted = false;
  private reconnectAttempts = 0;

  // Event listeners
  private listeners = new Map<RealtimeEventType, Set<EventCallback<RealtimeEventType>>>();

  constructor(options: MinionRealtimeClientOptions) {
    this.options = {
      ...options,
      autoReconnect: options.autoReconnect ?? false,
      maxReconnectAttempts: options.maxReconnectAttempts ?? 3,
    };
  }

  // -------------------------------------------------------------------------
  // Public API
  // -------------------------------------------------------------------------

  /**
   * Connect to the Realtime API via WebRTC.
   * 1. Fetches ephemeral token from the gateway
   * 2. Creates RTCPeerConnection
   * 3. Gets user microphone
   * 4. Creates SDP offer and sends to OpenAI
   * 5. Applies SDP answer to complete handshake
   */
  async connect(): Promise<void> {
    if (this._status !== "disconnected") {
      throw new Error(`Cannot connect: status is ${this._status}`);
    }

    this.setStatus("connecting");
    this.reconnectAttempts = 0;

    try {
      // 1. Get ephemeral token
      const tokenResponse = await this.fetchToken();

      // 2. Create peer connection
      this.pc = new RTCPeerConnection({
        iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
      });

      // 3. Set up remote audio playback
      this.setupRemoteAudio();

      // 4. Get microphone and add track
      this.localStream = await navigator.mediaDevices.getUserMedia({
        audio: this.options.audioConstraints ?? {
          echoCancellation: true,
          noiseSuppression: true,
          autoGainControl: true,
        },
      });

      for (const track of this.localStream.getAudioTracks()) {
        this.pc.addTrack(track, this.localStream);
      }

      // 5. Create data channel for events
      this.dataChannel = this.pc.createDataChannel("oai-events");
      this.setupDataChannel();

      // 6. Create and set local description (SDP offer)
      const offer = await this.pc.createOffer();
      await this.pc.setLocalDescription(offer);

      // 7. Send SDP offer to OpenAI's Realtime API
      const model = tokenResponse.model ?? "gpt-4o-realtime-preview";
      const sdpResponse = await fetch(
        `https://api.openai.com/v1/realtime?model=${encodeURIComponent(model)}`,
        {
          method: "POST",
          headers: {
            Authorization: `Bearer ${tokenResponse.token}`,
            "Content-Type": "application/sdp",
          },
          body: offer.sdp,
        },
      );

      if (!sdpResponse.ok) {
        throw new Error(`SDP exchange failed: ${sdpResponse.status}`);
      }

      // 8. Apply the SDP answer
      const sdpAnswer = await sdpResponse.text();
      await this.pc.setRemoteDescription({
        type: "answer",
        sdp: sdpAnswer,
      });

      // 9. Monitor connection state
      this.pc.onconnectionstatechange = () => {
        if (this.pc?.connectionState === "connected") {
          this.setStatus("connected");
          this.emit({ type: "connected" });
        } else if (
          this.pc?.connectionState === "disconnected" ||
          this.pc?.connectionState === "failed"
        ) {
          this.handleDisconnect(`WebRTC ${this.pc.connectionState}`);
        }
      };
    } catch (error) {
      this.cleanup();
      this.setStatus("disconnected");
      const err = error instanceof Error ? error : new Error(String(error));
      this.emit({ type: "error", error: err });
      throw err;
    }
  }

  /**
   * Disconnect from the Realtime API.
   */
  disconnect(): void {
    this.cleanup();
    this.setStatus("disconnected");
    this.emit({ type: "disconnected", reason: "user" });
  }

  /**
   * Mute the microphone.
   */
  mute(): void {
    if (this.localStream) {
      for (const track of this.localStream.getAudioTracks()) {
        track.enabled = false;
      }
    }
    this._isMuted = true;
  }

  /**
   * Unmute the microphone.
   */
  unmute(): void {
    if (this.localStream) {
      for (const track of this.localStream.getAudioTracks()) {
        track.enabled = true;
      }
    }
    this._isMuted = false;
  }

  /**
   * Switch to a different audio input device.
   */
  async setInputDevice(deviceId: string): Promise<void> {
    if (!this.pc) return;

    // Get new stream with specified device
    const newStream = await navigator.mediaDevices.getUserMedia({
      audio: {
        ...(this.options.audioConstraints ?? {}),
        deviceId: { exact: deviceId },
      },
    });

    // Replace the audio track on the peer connection
    const sender = this.pc.getSenders().find((s) => s.track?.kind === "audio");
    if (sender && newStream.getAudioTracks()[0]) {
      await sender.replaceTrack(newStream.getAudioTracks()[0]);
    }

    // Stop old tracks
    if (this.localStream) {
      for (const track of this.localStream.getAudioTracks()) {
        track.stop();
      }
    }

    this.localStream = newStream;

    // Preserve mute state
    if (this._isMuted) {
      this.mute();
    }
  }

  /**
   * Register an event listener.
   */
  on<T extends RealtimeEventType>(event: T, callback: EventCallback<T>): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(callback as unknown as EventCallback<RealtimeEventType>);
  }

  /**
   * Remove an event listener.
   */
  off<T extends RealtimeEventType>(event: T, callback: EventCallback<T>): void {
    this.listeners.get(event)?.delete(callback as unknown as EventCallback<RealtimeEventType>);
  }

  /**
   * Current connection status.
   */
  get status(): ConnectionStatus {
    return this._status;
  }

  /**
   * Whether the microphone is muted.
   */
  get isMuted(): boolean {
    return this._isMuted;
  }

  // -------------------------------------------------------------------------
  // Private
  // -------------------------------------------------------------------------

  private async fetchToken(): Promise<EphemeralTokenResponse> {
    const headers: Record<string, string> = {
      "Content-Type": "application/json",
    };

    if (this.options.gatewayToken) {
      headers.Authorization = `Bearer ${this.options.gatewayToken}`;
    }

    const response = await fetch(this.options.tokenUrl, {
      method: "POST",
      headers,
    });

    if (!response.ok) {
      throw new Error(`Token request failed: ${response.status}`);
    }

    return (await response.json()) as EphemeralTokenResponse;
  }

  private setupRemoteAudio(): void {
    if (!this.pc) return;

    this.pc.ontrack = (event) => {
      if (event.streams[0]) {
        if (!this.remoteAudio) {
          this.remoteAudio = new Audio();
          this.remoteAudio.autoplay = true;
        }
        this.remoteAudio.srcObject = event.streams[0];
      }
    };
  }

  private setupDataChannel(): void {
    if (!this.dataChannel) return;

    this.dataChannel.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data as string) as {
          type: string;
          transcript?: string;
          delta?: string;
          [key: string]: unknown;
        };

        switch (data.type) {
          case "input_audio_buffer.speech_started":
            this.emit({ type: "speech_start" });
            break;

          case "input_audio_buffer.speech_stopped":
            this.emit({ type: "speech_stop" });
            break;

          case "conversation.item.input_audio_transcription.completed":
            if (data.transcript) {
              this.emit({
                type: "transcript",
                speaker: "user",
                text: data.transcript,
              });
            }
            break;

          case "response.audio_transcript.done":
            if (data.transcript) {
              this.emit({
                type: "transcript",
                speaker: "bot",
                text: data.transcript as string,
              });
            }
            break;

          case "error":
            this.emit({
              type: "error",
              error: new Error((data.error as { message?: string })?.message ?? "Unknown error"),
            });
            break;
        }
      } catch {
        // Ignore malformed data channel messages
      }
    };
  }

  private handleDisconnect(reason: string): void {
    this.cleanup();
    this.setStatus("disconnected");
    this.emit({ type: "disconnected", reason });

    if (this.options.autoReconnect && this.reconnectAttempts < this.options.maxReconnectAttempts) {
      this.reconnectAttempts++;
      const delay = 1000 * 2 ** (this.reconnectAttempts - 1);
      setTimeout(() => {
        if (this._status === "disconnected") {
          void this.connect().catch(() => {
            // Reconnect failed, will try again if under max attempts
          });
        }
      }, delay);
    }
  }

  private cleanup(): void {
    if (this.localStream) {
      for (const track of this.localStream.getTracks()) {
        track.stop();
      }
      this.localStream = null;
    }

    if (this.dataChannel) {
      this.dataChannel.close();
      this.dataChannel = null;
    }

    if (this.pc) {
      this.pc.close();
      this.pc = null;
    }

    if (this.remoteAudio) {
      this.remoteAudio.srcObject = null;
      this.remoteAudio = null;
    }
  }

  private setStatus(status: ConnectionStatus): void {
    this._status = status;
  }

  private emit(event: RealtimeEvent): void {
    const callbacks = this.listeners.get(event.type);
    if (callbacks) {
      for (const cb of callbacks) {
        try {
          cb(event as never);
        } catch (e) {
          console.error("[MinionRealtimeClient] Event handler error:", e);
        }
      }
    }
  }
}
