/**
 * Soniox Speech-to-Text adapter for Patter (TypeScript).
 *
 * Pure WebSocket client for the Soniox real-time STT API. Accumulates
 * `is_final` tokens and flushes them on `<end>`/`<fin>` endpoint tokens,
 * mirroring the Python `SonioxSTT` adapter.
 */

import WebSocket from 'ws';
import { getLogger } from '../logger';

const SONIOX_WS_URL = 'wss://stt-rt.soniox.com/transcribe-websocket';

/**
 * Known Soniox real-time STT models.
 *
 * `STT_RT_V5` is the current default (GA 2026-06-16). It is fully
 * API-compatible with v4 — same WebSocket URL, in-band `api_key`, request
 * params and token-stream shape — so adopting it is a pure model-id swap.
 * Older ids stay reachable for back-compat (Soniox auto-routes retired
 * v4/v3 ids to v5 server-side).
 */
export const SonioxModel = {
  STT_RT_V5: 'stt-rt-v5',
  STT_RT_V4: 'stt-rt-v4',
  STT_RT_V3: 'stt-rt-v3',
  STT_RT_V2: 'stt-rt-v2',
} as const;
export type SonioxModel = (typeof SonioxModel)[keyof typeof SonioxModel];

/** Audio formats accepted by Soniox real-time API. */
export const SonioxAudioFormat = {
  PCM_S16LE: 'pcm_s16le',
} as const;
export type SonioxAudioFormat = (typeof SonioxAudioFormat)[keyof typeof SonioxAudioFormat];

/** Common PCM sample rates for Soniox streaming input. */
export const SonioxSampleRate = {
  HZ_8000: 8000,
  HZ_16000: 16000,
  HZ_24000: 24000,
} as const;
export type SonioxSampleRate = (typeof SonioxSampleRate)[keyof typeof SonioxSampleRate];

/** Soniox real-time client message `type` values. */
export const SonioxClientFrame = {
  KEEPALIVE: 'keepalive',
} as const;
export type SonioxClientFrame = (typeof SonioxClientFrame)[keyof typeof SonioxClientFrame];

/** Soniox token markers that signal a speech-segment endpoint. */
export const SonioxEndpointToken = {
  END: '<end>',
  FIN: '<fin>',
} as const;
export type SonioxEndpointToken = (typeof SonioxEndpointToken)[keyof typeof SonioxEndpointToken];

const KEEPALIVE_MESSAGE = JSON.stringify({ type: SonioxClientFrame.KEEPALIVE });
const END_TOKEN: string = SonioxEndpointToken.END;
const FINALIZED_TOKEN: string = SonioxEndpointToken.FIN;
const KEEPALIVE_INTERVAL_MS = 5000;

/** Patter-normalised transcript event emitted by {@link SonioxSTT}. */
export interface Transcript {
  readonly text: string;
  readonly isFinal: boolean;
  readonly confidence: number;
}

type TranscriptCallback = (transcript: Transcript) => void;

interface SonioxToken {
  text?: string;
  is_final?: boolean;
  confidence?: number;
  [key: string]: unknown;
}

interface SonioxMessage {
  tokens?: SonioxToken[];
  finished?: boolean;
  error_code?: unknown;
  error_message?: string;
}

function isEndToken(token: SonioxToken): boolean {
  return token.text === END_TOKEN || token.text === FINALIZED_TOKEN;
}

/** Accumulates Soniox token text + rolling confidence. */
class TokenAccumulator {
  text = '';
  private confSum = 0;
  private confCount = 0;

  update(token: SonioxToken): void {
    if (token.text) {
      this.text += token.text;
    }
    if (typeof token.confidence === 'number') {
      this.confSum += token.confidence;
      this.confCount += 1;
    }
  }

  get confidence(): number {
    return this.confCount === 0 ? 0 : this.confSum / this.confCount;
  }

  reset(): void {
    this.text = '';
    this.confSum = 0;
    this.confCount = 0;
  }

  get raw(): { sum: number; count: number } {
    return { sum: this.confSum, count: this.confCount };
  }
}

/** Constructor options for {@link SonioxSTT}. */
export interface SonioxSTTOptions {
  readonly model?: SonioxModel | string;
  readonly languageHints?: readonly string[];
  readonly languageHintsStrict?: boolean;
  readonly sampleRate?: SonioxSampleRate | number;
  readonly numChannels?: number;
  readonly enableSpeakerDiarization?: boolean;
  readonly enableLanguageIdentification?: boolean;
  readonly maxEndpointDelayMs?: number;
  /**
   * Optional v5 endpoint control in `[-1.0, 1.0]` (Soniox default `0.0`).
   * Higher = more/sooner endpoints, lower = wait longer. Omitted from the
   * config when undefined so default behaviour is unchanged.
   */
  readonly endpointSensitivity?: number;
  /**
   * Optional v5 latency-reduction aggressiveness, one of `0 | 1 | 2 | 3`
   * (Soniox default `0`). Omitted from the config when undefined.
   */
  readonly endpointLatencyAdjustmentLevel?: number;
  readonly clientReferenceId?: string;
  readonly baseUrl?: string;
}

/** Streaming STT adapter for Soniox's real-time WebSocket API. */
export class SonioxSTT {
  /** Stable pricing/dashboard key — read by stream-handler/metrics. */
  static readonly providerKey = 'soniox';
  private ws: WebSocket | null = null;
  private readonly callbacks = new Set<TranscriptCallback>();
  private final = new TokenAccumulator();
  private lastInterimText = '';
  private keepaliveTimer: ReturnType<typeof setInterval> | null = null;

  private readonly apiKey: string;
  private readonly model: string;
  private readonly languageHints?: readonly string[];
  private readonly languageHintsStrict: boolean;
  private readonly sampleRate: number;
  private readonly numChannels: number;
  private readonly enableSpeakerDiarization: boolean;
  private readonly enableLanguageIdentification: boolean;
  private readonly maxEndpointDelayMs: number;
  private readonly endpointSensitivity?: number;
  private readonly endpointLatencyAdjustmentLevel?: number;
  private readonly clientReferenceId?: string;
  private readonly baseUrl: string;

  /** Construction args replayed by clone(). */
  private readonly patterCtorArgs: unknown[];

  constructor(apiKey: string, options: SonioxSTTOptions = {}) {
    this.patterCtorArgs = [apiKey, options];
    if (!apiKey) {
      throw new Error('Soniox apiKey is required');
    }
    const maxEndpointDelayMs = options.maxEndpointDelayMs ?? 500;
    if (maxEndpointDelayMs < 500 || maxEndpointDelayMs > 3000) {
      throw new Error('maxEndpointDelayMs must be between 500 and 3000');
    }
    const { endpointSensitivity, endpointLatencyAdjustmentLevel } = options;
    if (
      endpointSensitivity !== undefined &&
      (endpointSensitivity < -1.0 || endpointSensitivity > 1.0)
    ) {
      throw new Error('endpointSensitivity must be between -1.0 and 1.0');
    }
    if (
      endpointLatencyAdjustmentLevel !== undefined &&
      ![0, 1, 2, 3].includes(endpointLatencyAdjustmentLevel)
    ) {
      throw new Error('endpointLatencyAdjustmentLevel must be 0, 1, 2, or 3');
    }

    this.apiKey = apiKey;
    this.model = options.model ?? SonioxModel.STT_RT_V5;
    this.languageHints = options.languageHints;
    this.languageHintsStrict = options.languageHintsStrict ?? false;
    this.sampleRate = options.sampleRate ?? SonioxSampleRate.HZ_16000;
    this.numChannels = options.numChannels ?? 1;
    this.enableSpeakerDiarization = options.enableSpeakerDiarization ?? false;
    this.enableLanguageIdentification = options.enableLanguageIdentification ?? true;
    this.maxEndpointDelayMs = maxEndpointDelayMs;
    this.endpointSensitivity = endpointSensitivity;
    this.endpointLatencyAdjustmentLevel = endpointLatencyAdjustmentLevel;
    this.clientReferenceId = options.clientReferenceId;
    this.baseUrl = options.baseUrl ?? SONIOX_WS_URL;
  }

  /** Factory for Twilio-style 8 kHz linear PCM. */
  static forTwilio(apiKey: string, languageHints?: string[]): SonioxSTT {
    return new SonioxSTT(apiKey, {
      sampleRate: SonioxSampleRate.HZ_8000,
      languageHints,
    });
  }

  private buildConfig(): Record<string, unknown> {
    const config: Record<string, unknown> = {
      api_key: this.apiKey,
      model: this.model,
      audio_format: 'pcm_s16le',
      num_channels: this.numChannels,
      sample_rate: this.sampleRate,
      enable_endpoint_detection: true,
      enable_speaker_diarization: this.enableSpeakerDiarization,
      enable_language_identification: this.enableLanguageIdentification,
      max_endpoint_delay_ms: this.maxEndpointDelayMs,
    };
    // v5 endpoint controls — opt-in; omitted when unset so the wire config
    // stays byte-identical to the pre-v5 default.
    if (this.endpointSensitivity !== undefined) {
      config.endpoint_sensitivity = this.endpointSensitivity;
    }
    if (this.endpointLatencyAdjustmentLevel !== undefined) {
      config.endpoint_latency_adjustment_level = this.endpointLatencyAdjustmentLevel;
    }
    if (this.languageHints) {
      config.language_hints = this.languageHints;
      config.language_hints_strict = this.languageHintsStrict;
    }
    if (this.clientReferenceId) {
      config.client_reference_id = this.clientReferenceId;
    }
    return config;
  }

  /** Open the streaming WebSocket and send the initial config payload. */

  /**
   * Fresh adapter built with this instance's construction arguments —
   * called per call by the stream handler so concurrent calls never share
   * connection state (sockets/queues; cross-call transcript bleed).
   */
  clone(): this {
    const ctor = this.constructor as new (...args: unknown[]) => this;
    return new ctor(...this.patterCtorArgs);
  }

  async connect(): Promise<void> {
    // Reset the accumulator so reconnection after close() does not carry
    // stale final.text across streams.
    this.final.reset();
    this.ws = new WebSocket(this.baseUrl);

    await new Promise<void>((resolve, reject) => {
      const timer = setTimeout(() => reject(new Error('Soniox connect timeout')), 10000);
      this.ws!.once('open', () => {
        clearTimeout(timer);
        resolve();
      });
      this.ws!.once('error', (err) => {
        clearTimeout(timer);
        reject(err);
      });
    });

    // Send the initial configuration payload as a JSON text frame.
    this.ws.send(JSON.stringify(this.buildConfig()));

    this.ws.on('message', (raw) => this.handleMessage(raw.toString()));
    this.ws.on('close', () => this.clearKeepalive());
    this.ws.on('error', (err) => {
      getLogger().error(`SonioxSTT WebSocket error: ${String(err)}`);
    });

    this.keepaliveTimer = setInterval(() => {
      if (this.ws && this.ws.readyState === WebSocket.OPEN) {
        try {
          this.ws.send(KEEPALIVE_MESSAGE);
        } catch {
          // ignore
        }
      }
    }, KEEPALIVE_INTERVAL_MS);
  }

  private clearKeepalive(): void {
    if (this.keepaliveTimer) {
      clearInterval(this.keepaliveTimer);
      this.keepaliveTimer = null;
    }
  }

  private handleMessage(raw: string): void {
    let content: SonioxMessage;
    try {
      content = JSON.parse(raw) as SonioxMessage;
    } catch {
      return;
    }

    if (content.error_code || content.error_message) {
      getLogger().error(
        `SonioxSTT error ${String(content.error_code)}: ${String(content.error_message)}`,
      );
    }

    const tokens = content.tokens ?? [];
    const nonFinal = new TokenAccumulator();
    let emittedFinalThisMsg = false;

    for (const token of tokens) {
      if (token.is_final) {
        if (isEndToken(token)) {
          if (this.final.text) {
            this.emit({
              text: this.final.text.trim(),
              isFinal: true,
              confidence: this.final.confidence,
            });
            this.final.reset();
            emittedFinalThisMsg = true;
          }
        } else {
          this.final.update(token);
        }
      } else {
        nonFinal.update(token);
      }
    }

    if (!emittedFinalThisMsg) {
      const text = (this.final.text + nonFinal.text).trim();
      // Skip re-emitting an unchanged interim: keepalive/progress frames
      // with no new tokens re-emitted the identical text, re-triggering
      // downstream partial-transcript/barge-in checks. Mirrors Python.
      if (text && text !== this.lastInterimText) {
        this.lastInterimText = text;
        const { sum: fSum, count: fCount } = this.final.raw;
        const { sum: nSum, count: nCount } = nonFinal.raw;
        const total = fCount + nCount;
        const confidence = total > 0 ? (fSum + nSum) / total : 0;
        this.emit({ text, isFinal: false, confidence });
      }
    }

    if (content.finished && this.final.text) {
      this.emit({
        text: this.final.text.trim(),
        isFinal: true,
        confidence: this.final.confidence,
      });
      this.final.reset();
    }
  }

  private emit(transcript: Transcript): void {
    for (const cb of this.callbacks) {
      // The registered callback is async (stream-handler's handleTranscript)
      // — a bare call left its rejection unhandled, which kills the Node
      // process. Contain both sync throws and async rejections.
      try {
        Promise.resolve(cb(transcript)).catch((err) =>
          getLogger().error(`STT transcript callback failed: ${String(err)}`),
        );
      } catch (err) {
        getLogger().error(`STT transcript callback threw: ${String(err)}`);
      }
    }
  }

  /** Send a binary PCM16-LE audio chunk to Soniox for transcription. */
  sendAudio(audio: Buffer): void {
    if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
    if (audio.length === 0) return;
    this.ws.send(audio);
  }

  /**
   * Ask Soniox to finalize buffered audio immediately. The pipeline's VAD
   * ``speech_end`` fast-path duck-types ``stt.finalize`` — without this
   * every Soniox turn waited out the full endpointing delay. Mirrors Python.
   */
  finalize(): void {
    if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
    try {
      this.ws.send(JSON.stringify({ type: 'finalize' }));
    } catch (err) {
      getLogger().debug(`Soniox finalize failed: ${String(err)}`);
    }
  }

  /** Register a transcript listener. */
  onTranscript(callback: TranscriptCallback): void {
    this.callbacks.add(callback);
  }

  /** Unregister a previously registered transcript listener. */
  offTranscript(callback: TranscriptCallback): void {
    this.callbacks.delete(callback);
  }

  /** Send the empty-frame stream terminator and close the WebSocket. */
  close(): void {
    this.clearKeepalive();
    if (this.ws) {
      try {
        // Soniox terminates the stream on an empty binary frame.
        this.ws.send(Buffer.alloc(0));
      } catch {
        // ignore
      }
      try {
        this.ws.close();
      } catch {
        // ignore
      }
      this.ws = null;
    }
  }
}
