/**
 * STT records audio via a MediaRecorder, posts it to whisper-server,
 * and returns the transcription. No concept of modes — the App
 * decides what to do with the result.
 *
 * Recording lifecycle: acquire() requests microphone access (async,
 * may prompt) and creates a fresh MediaRecorder; start() begins
 * capturing (synchronous); stop() resolves the audio blob and
 * releases the stream. A fresh stream is acquired per recording and
 * torn down on every stop() so iOS/WebKit never reuses a capture
 * track that TTS playback or an audio-session interruption has
 * quietly killed.
 */

/**
 * VOICE_MAP maps whisper language names to default Kokoro voices.
 * Used for auto-selecting a voice when the selector is set to "Auto".
 */
export const VOICE_MAP = {
  chinese: 'zf_xiaobei',
  english: 'af_heart',
  french: 'ff_siwis',
  hindi: 'hf_alpha',
  italian: 'if_sara',
  japanese: 'jf_alpha',
  korean: 'kf_sarah',
  portuguese: 'pf_dora',
  spanish: 'ef_dora',
};

export class STT {
  /**
   * @param {Object} options
   * @param {string} options.url - whisper-server base URL
   */
  constructor({ url }) {
    this.url = url;
    this.mediaRecorder = null;
    this.audioChunks = [];
    this._ext = 'webm';
  }

  /**
   * acquire requests microphone access and creates a fresh
   * MediaRecorder. Always starts clean so iOS never reuses a dead
   * capture track. Must be called before start.
   */
  async acquire() {
    this._release();

    const stream = await navigator.mediaDevices.getUserMedia({
      audio: true,
    });

    const [track] = stream.getAudioTracks();
    if (track?.readyState !== 'live') {
      for (const t of stream.getTracks()) t.stop();
      throw new Error('microphone unavailable');
    }

    let mimeType = null;
    for (const candidate of [
      'audio/webm;codecs=opus',
      'audio/webm',
      'audio/mp4;codecs=mp4a.40.2',
      'audio/mp4',
    ]) {
      if (MediaRecorder.isTypeSupported?.(candidate)) {
        mimeType = candidate;
        break;
      }
    }
    this._ext = mimeType?.includes('mp4') ? 'm4a' : 'webm';
    this.mediaRecorder = mimeType
      ? new MediaRecorder(stream, { mimeType })
      : new MediaRecorder(stream);

    this.audioChunks = [];
    this.mediaRecorder.addEventListener('dataavailable', (event) => {
      if (event.data.size > 0) {
        this.audioChunks.push(event.data);
      }
    });
  }

  /**
   * start begins recording. Requires acquire() to have completed.
   */
  start() {
    this.audioChunks = [];
    this.mediaRecorder.start();
  }

  /**
   * stop stops recording, transcribes the audio, and returns the result.
   * @returns {Promise<{text: string, detectedLanguage: string}>}
   */
  async stop() {
    const audioBlob = await this._stopRecording();
    if (audioBlob.size === 0) {
      return { text: '', detectedLanguage: '' };
    }
    return this._transcribe(audioBlob);
  }

  /** @returns {boolean} */
  get isRecording() {
    return this.mediaRecorder?.state === 'recording';
  }

  /**
   * destroy stops tracks and releases the media stream. Called on page
   * teardown.
   */
  destroy() {
    this._release();
  }

  /**
   * _stopRecording stops the MediaRecorder and resolves with the
   * assembled audio blob, releasing the stream. Returns an empty blob
   * if there is no active recording, so it is safe to call before
   * start has run (e.g. if the user released while acquire was still
   * pending).
   * @returns {Promise<Blob>}
   * @private
   */
  _stopRecording() {
    return new Promise((resolve, reject) => {
      const mr = this.mediaRecorder;
      if (!mr || mr.state === 'inactive') {
        this._release();
        resolve(new Blob([]));
        return;
      }

      mr.addEventListener(
        'stop',
        () => {
          const type = mr.mimeType;
          const blob = new Blob(this.audioChunks, { type });
          if (type?.includes('mp4') || type?.includes('m4a')) {
            this._ext = 'm4a';
          }
          this._release();
          resolve(blob);
        },
        { once: true },
      );

      mr.addEventListener(
        'error',
        (event) => {
          this._release();
          reject(event.error);
        },
        { once: true },
      );

      mr.stop();
    });
  }

  /**
   * _release stops all tracks and clears recorder state.
   * @private
   */
  _release() {
    for (const track of this.mediaRecorder?.stream?.getTracks() ?? []) {
      track.stop();
    }
    this.mediaRecorder = null;
    this.audioChunks = [];
  }

  /**
   * _transcribe sends an audio blob to whisper-server and returns the
   * transcription result.
   * @param {Blob} audioBlob
   * @returns {Promise<{text: string, detectedLanguage: string}>}
   * @private
   */
  async _transcribe(audioBlob) {
    if (!this.url) {
      throw new Error('STT URL not configured');
    }

    const formData = new FormData();
    formData.append('file', audioBlob, `audio.${this._ext}`);
    formData.append('response_format', 'verbose_json');

    const res = await fetch(`${this.url}/inference`, {
      method: 'POST',
      body: formData,
    });

    if (!res.ok) {
      const errText = await res.text().catch(() => '');
      throw new Error(`STT error ${res.status}: ${errText}`);
    }

    const data = await res.json();
    const text = (data.text || '').trim();
    const detectedLanguage = data.detected_language || data.language || '';
    return { text, detectedLanguage };
  }
}
