/**
 * OpenAI Realtime S2S Session
 *
 * Core WebSocket session for speech-to-speech conversations via the OpenAI
 * Realtime API. Handles bidirectional audio streaming, tool/function calling,
 * VAD-based turn detection, and barge-in.
 *
 * This is the abstract engine that any voice-enabled channel can use.
 * Channels provide audio input and consume audio output — this class
 * handles all Realtime API protocol details.
 */

import WebSocket from "ws";
import type {
  RealtimeClientEvent,
  RealtimeS2SSessionConfig,
  RealtimeS2SSessionEvents,
  RealtimeSessionConfig,
} from "./types.js";

const DEFAULT_MODEL = "gpt-4o-realtime-preview";
const DEFAULT_VOICE = "verse";
const DEFAULT_AUDIO_FORMAT = "g711_ulaw";
const DEFAULT_TRANSCRIPTION_MODEL = "gpt-4o-transcribe";
const MAX_RECONNECT_ATTEMPTS = 5;
const RECONNECT_DELAY_MS = 1000;
const CONNECT_TIMEOUT_MS = 15000;

export class RealtimeS2SSession {
  private ws: WebSocket | null = null;
  private connected = false;
  private closed = false;
  private reconnectAttempts = 0;
  private pendingResponseTranscript = "";

  private readonly config: Required<
    Pick<
      RealtimeS2SSessionConfig,
      "apiKey" | "model" | "voice" | "inputAudioFormat" | "outputAudioFormat"
    >
  > &
    RealtimeS2SSessionConfig;
  private readonly events: RealtimeS2SSessionEvents;

  constructor(config: RealtimeS2SSessionConfig, events: RealtimeS2SSessionEvents) {
    this.config = {
      ...config,
      model: config.model || DEFAULT_MODEL,
      voice: config.voice || DEFAULT_VOICE,
      inputAudioFormat: config.inputAudioFormat || DEFAULT_AUDIO_FORMAT,
      outputAudioFormat: config.outputAudioFormat || DEFAULT_AUDIO_FORMAT,
    };
    this.events = events;
  }

  /**
   * Connect to the OpenAI Realtime API and configure the session.
   */
  async connect(): Promise<void> {
    this.closed = false;
    this.reconnectAttempts = 0;
    return this.doConnect();
  }

  /**
   * Send audio data to the Realtime API.
   * @param base64Audio Base64-encoded audio in the configured input format
   */
  sendAudio(base64Audio: string): void {
    if (!this.connected) return;
    this.sendEvent({
      type: "input_audio_buffer.append",
      audio: base64Audio,
    });
  }

  /**
   * Commit the audio buffer (signal end of audio input).
   * Use when turn detection is manual (not server_vad).
   */
  commitAudio(): void {
    if (!this.connected) return;
    this.sendEvent({ type: "input_audio_buffer.commit" });
  }

  /**
   * Clear the input audio buffer.
   */
  clearAudioBuffer(): void {
    if (!this.connected) return;
    this.sendEvent({ type: "input_audio_buffer.clear" });
  }

  /**
   * Update the session configuration (voice, instructions, tools, etc.).
   */
  updateSession(sessionUpdate: Partial<RealtimeSessionConfig>): void {
    if (!this.connected) return;
    this.sendEvent({
      type: "session.update",
      session: sessionUpdate,
    });
  }

  /**
   * Cancel the current in-progress response (for barge-in).
   */
  cancelResponse(): void {
    if (!this.connected) return;
    this.sendEvent({ type: "response.cancel" });
  }

  /**
   * Close the session and disconnect.
   */
  close(): void {
    this.closed = true;
    if (this.ws) {
      this.ws.close();
      this.ws = null;
    }
    this.connected = false;
  }

  /**
   * Check if the session is currently connected.
   */
  isConnected(): boolean {
    return this.connected;
  }

  // ---------------------------------------------------------------------------
  // Private: Connection
  // ---------------------------------------------------------------------------

  private async doConnect(): Promise<void> {
    return new Promise((resolve, reject) => {
      const url = `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(this.config.model)}`;

      this.ws = new WebSocket(url, {
        headers: {
          Authorization: `Bearer ${this.config.apiKey}`,
          "OpenAI-Beta": "realtime=v1",
        },
      });

      this.ws.on("open", () => {
        console.log("[RealtimeS2S] WebSocket connected");
        this.connected = true;
        this.reconnectAttempts = 0;
        this.configureSession();
        resolve();
      });

      this.ws.on("message", (data: Buffer) => {
        try {
          const event = JSON.parse(data.toString()) as Record<string, unknown>;
          this.handleEvent(event);
        } catch (e) {
          console.error("[RealtimeS2S] Failed to parse event:", e);
        }
      });

      this.ws.on("error", (error) => {
        console.error("[RealtimeS2S] WebSocket error:", error);
        if (!this.connected) {
          reject(error);
        }
      });

      this.ws.on("close", (code, reason) => {
        console.log(
          `[RealtimeS2S] WebSocket closed (code: ${code}, reason: ${reason?.toString() || "none"})`,
        );
        this.connected = false;

        if (!this.closed) {
          void this.attemptReconnect();
        }
      });

      setTimeout(() => {
        if (!this.connected) {
          reject(new Error("Realtime S2S connection timeout"));
        }
      }, CONNECT_TIMEOUT_MS);
    });
  }

  private configureSession(): void {
    const sessionConfig: Partial<RealtimeSessionConfig> = {
      modalities: ["audio", "text"],
      voice: this.config.voice,
      input_audio_format: this.config.inputAudioFormat,
      output_audio_format: this.config.outputAudioFormat,
      input_audio_transcription: {
        model: this.config.inputTranscriptionModel || DEFAULT_TRANSCRIPTION_MODEL,
      },
    };

    if (this.config.instructions) {
      sessionConfig.instructions = this.config.instructions;
    }

    if (this.config.tools && this.config.tools.length > 0) {
      sessionConfig.tools = this.config.tools;
    }

    if (this.config.turnDetection) {
      sessionConfig.turn_detection = {
        type: "server_vad",
        ...this.config.turnDetection,
      };
    } else {
      sessionConfig.turn_detection = {
        type: "server_vad",
        threshold: 0.5,
        prefix_padding_ms: 300,
        silence_duration_ms: 800,
      };
    }

    if (this.config.temperature !== undefined) {
      sessionConfig.temperature = this.config.temperature;
    }

    this.sendEvent({ type: "session.update", session: sessionConfig });
  }

  // ---------------------------------------------------------------------------
  // Private: Reconnection
  // ---------------------------------------------------------------------------

  private async attemptReconnect(): Promise<void> {
    if (this.closed) return;

    if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
      console.error(`[RealtimeS2S] Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached`);
      this.events.onError?.(new Error("Max reconnect attempts reached"));
      return;
    }

    this.reconnectAttempts++;
    const delay = RECONNECT_DELAY_MS * 2 ** (this.reconnectAttempts - 1);
    console.log(
      `[RealtimeS2S] Reconnecting ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} in ${delay}ms...`,
    );

    await new Promise((r) => setTimeout(r, delay));
    if (this.closed) return;

    try {
      await this.doConnect();
      console.log("[RealtimeS2S] Reconnected successfully");
    } catch (error) {
      console.error("[RealtimeS2S] Reconnect failed:", error);
    }
  }

  // ---------------------------------------------------------------------------
  // Private: Event Handling
  // ---------------------------------------------------------------------------

  private handleEvent(event: Record<string, unknown>): void {
    const type = event.type as string;

    switch (type) {
      case "session.created":
        console.log("[RealtimeS2S] Session created");
        this.events.onSessionCreated?.();
        break;

      case "session.updated":
        console.log("[RealtimeS2S] Session updated");
        break;

      case "input_audio_buffer.speech_started":
        console.log("[RealtimeS2S] Speech started (VAD)");
        this.events.onSpeechStarted?.();
        // Auto barge-in: cancel any in-progress response
        this.cancelResponse();
        break;

      case "input_audio_buffer.speech_stopped":
        console.log("[RealtimeS2S] Speech stopped (VAD)");
        this.events.onSpeechStopped?.();
        break;

      case "input_audio_buffer.committed":
        break;

      case "conversation.item.input_audio_transcription.completed": {
        const transcript = event.transcript as string | undefined;
        if (transcript) {
          this.events.onInputTranscript?.(transcript);
        }
        break;
      }

      case "conversation.item.input_audio_transcription.failed":
        console.warn("[RealtimeS2S] Input transcription failed:", event.error);
        break;

      case "response.created": {
        this.pendingResponseTranscript = "";
        const response = event.response as { id: string } | undefined;
        if (response?.id) this.events.onResponseStarted?.(response.id);
        break;
      }

      case "response.done": {
        const response = event.response as { id: string } | undefined;
        if (response?.id) this.events.onResponseDone?.(response.id);
        break;
      }

      case "response.audio.delta": {
        const delta = event.delta as string;
        if (delta) this.events.onAudioDelta?.(delta);
        break;
      }

      case "response.audio.done":
        break;

      case "response.audio_transcript.delta": {
        const delta = event.delta as string;
        if (delta) {
          this.pendingResponseTranscript += delta;
          this.events.onTranscriptDelta?.(delta);
        }
        break;
      }

      case "response.audio_transcript.done": {
        const transcript = event.transcript as string;
        if (transcript) this.events.onTranscriptDone?.(transcript);
        this.pendingResponseTranscript = "";
        break;
      }

      case "response.function_call_arguments.done": {
        const callId = event.call_id as string;
        const name = event.name as string;
        const args = event.arguments as string;
        if (callId && name) {
          void this.handleToolCall(callId, name, args ?? "{}");
        }
        break;
      }

      case "error": {
        const err = event.error as { type?: string; message?: string; code?: string } | undefined;
        console.error("[RealtimeS2S] API error:", err);
        this.events.onError?.(
          new Error(
            `${err?.type ?? "unknown"}: ${err?.message ?? "unknown error"} (${err?.code ?? "?"})`,
          ),
        );
        break;
      }

      default:
        // Ignore unhandled event types (delta events for args, etc.)
        break;
    }
  }

  private async handleToolCall(callId: string, name: string, argsJson: string): Promise<void> {
    if (!this.events.onToolCall) {
      console.warn(`[RealtimeS2S] Tool call received but no handler: ${name}`);
      this.submitToolResult(callId, JSON.stringify({ error: "Tool handler not configured" }));
      return;
    }

    try {
      const result = await this.events.onToolCall(callId, name, argsJson);
      this.submitToolResult(callId, result);
    } catch (error) {
      console.error(`[RealtimeS2S] Tool call failed: ${name}`, error);
      this.submitToolResult(
        callId,
        JSON.stringify({ error: error instanceof Error ? error.message : String(error) }),
      );
    }
  }

  private submitToolResult(callId: string, output: string): void {
    // Send the tool result
    this.sendEvent({
      type: "conversation.item.create",
      item: {
        type: "function_call_output",
        call_id: callId,
        output,
      },
    });

    // Trigger the model to continue generating a response
    this.sendEvent({ type: "response.create" });
  }

  // ---------------------------------------------------------------------------
  // Private: Transport
  // ---------------------------------------------------------------------------

  private sendEvent(event: RealtimeClientEvent): void {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(event));
    }
  }
}
