import Recorder from 'opus-recorder';
import encoderPath from 'opus-recorder/dist/encoderWorker.min.js?url';

// In-browser voice-message recorder. Produces real OGG/Opus (so it bridges to
// Telegram as a genuine voice note — MediaRecorder's webm/opus would NOT) plus
// a waveform sampled live off an AnalyserNode. Encode-only: we drop element-web's
// Safari WAV-decode fallback and AudioWorklet metering. See
// docs/plans/voice_messages.md §1 (D1, D2, D3) and §3 (Phase 2).

export type VoiceRecordingResult = {
  blob: Blob;
  // recorded length in milliseconds
  durationMs: number;
  // MSC1767 waveform: integers 0..1024
  waveform: number[];
};

// Live amplitude sampling cadence (~12.5 Hz). Enough resolution for a readable
// waveform without flooding the array on long recordings.
const SAMPLE_INTERVAL_MS = 80;
// Number of buckets in the emitted waveform.
const WAVEFORM_POINTS = 100;

const getAudioContext = (): AudioContext => {
  const Ctx =
    window.AudioContext ||
    (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
  return new Ctx();
};

// Resample arbitrary-length 0..1 amplitudes into `points` averaged buckets,
// then scale to integers 0..1024 for the MSC1767 waveform.
const buildWaveform = (amplitudes: number[], points: number): number[] => {
  if (amplitudes.length === 0) return new Array(points).fill(0);
  const out: number[] = [];
  const bucket = amplitudes.length / points;
  for (let i = 0; i < points; i += 1) {
    const start = Math.floor(i * bucket);
    const end = Math.max(start + 1, Math.floor((i + 1) * bucket));
    let sum = 0;
    let count = 0;
    for (let j = start; j < end && j < amplitudes.length; j += 1) {
      sum += amplitudes[j];
      count += 1;
    }
    const avg = count > 0 ? sum / count : 0;
    out.push(Math.min(1024, Math.round(avg * 1024)));
  }
  return out;
};

export class VoiceRecording {
  private recorder: Recorder | null = null;

  // Set by cancel() so a start() still awaiting getUserMedia / worklet init
  // releases anything it acquires late instead of leaking the mic.
  private cancelled = false;

  private stream: MediaStream | null = null;

  private audioContext: AudioContext | null = null;

  private source: MediaStreamAudioSourceNode | null = null;

  private analyser: AnalyserNode | null = null;

  private amplitudes: number[] = [];

  private sampleTimer: number | undefined;

  private startedAt = 0;

  private chunks: Uint8Array[] = [];

  private onAmplitude: ((level: number) => void) | null = null;

  static isSupported(): boolean {
    return (
      typeof navigator !== 'undefined' &&
      !!navigator.mediaDevices?.getUserMedia &&
      // opus-recorder needs WebAssembly — mirror its own gate so the mic button
      // is hidden (not failed-into-error) on a WASM-less engine.
      typeof WebAssembly !== 'undefined' &&
      (typeof window.AudioContext !== 'undefined' ||
        typeof (window as unknown as { webkitAudioContext?: unknown }).webkitAudioContext !==
          'undefined')
    );
  }

  async start(onAmplitude?: (level: number) => void): Promise<void> {
    this.onAmplitude = onAmplitude ?? null;
    this.cancelled = false;
    try {
      await this.startInternal();
    } catch (err) {
      // If we acquired the mic before a later step failed (e.g. the encoder
      // worklet didn't load), release it so the mic never stays on.
      this.cleanup();
      throw err;
    }
  }

  private async startInternal(): Promise<void> {
    this.stream = await navigator.mediaDevices.getUserMedia({
      audio: {
        channelCount: 1,
        echoCancellation: true,
        noiseSuppression: true,
        autoGainControl: true,
      },
    });
    // Torn down (Close / room switch) while getUserMedia was resolving — release
    // the just-acquired mic instead of leaking it.
    if (this.cancelled) {
      this.cleanup();
      return;
    }

    this.audioContext = getAudioContext();
    // Started from a click (mic button) so the gesture lets us resume a
    // context that some engines spawn suspended.
    if (this.audioContext.state === 'suspended') {
      await this.audioContext.resume();
    }
    if (this.cancelled) {
      this.cleanup();
      return;
    }
    this.source = this.audioContext.createMediaStreamSource(this.stream);
    this.analyser = this.audioContext.createAnalyser();
    this.analyser.fftSize = 1024;
    this.source.connect(this.analyser);

    this.recorder = new Recorder({
      encoderPath,
      sourceNode: this.source,
      encoderSampleRate: 48000,
      numberOfChannels: 1,
      encoderApplication: 2048, // VOIP / voice
      encoderBitRate: 24000,
      encoderFrameSize: 20,
      maxFramesPerPage: 40,
      resampleQuality: 3,
      streamPages: false,
    });

    this.chunks = [];
    this.amplitudes = [];
    this.recorder.ondataavailable = (data) => {
      this.chunks.push(new Uint8Array(data));
    };

    await this.recorder.start();
    if (this.cancelled) {
      this.cleanup();
      return;
    }
    this.startedAt = performance.now();

    const buf = new Uint8Array(this.analyser.fftSize);
    this.sampleTimer = window.setInterval(() => {
      if (!this.analyser) return;
      this.analyser.getByteTimeDomainData(buf);
      let peak = 0;
      for (let i = 0; i < buf.length; i += 1) {
        peak = Math.max(peak, Math.abs(buf[i] - 128));
      }
      const level = peak / 128;
      this.amplitudes.push(level);
      this.onAmplitude?.(level);
    }, SAMPLE_INTERVAL_MS);
  }

  async stop(): Promise<VoiceRecordingResult> {
    this.clearTimer();
    const rec = this.recorder;
    const durationMs = Math.max(0, Math.round(performance.now() - this.startedAt));
    if (!rec) {
      this.cleanup();
      return { blob: this.buildBlob(), durationMs, waveform: [] };
    }
    // opus-recorder delivers the final ogg pages via ondataavailable then calls
    // onstop; its stop() promise resolves on the same "done". Resolve on
    // whichever of {onstop, stop()-settles, a safety timeout} comes first so we
    // ALWAYS fall through to cleanup() and release the mic — even if a callback
    // misfires on some WebView. opus-recorder does NOT own our getUserMedia
    // stream (we pass a sourceNode), so cleanup() is the only thing that stops
    // the mic track.
    await new Promise<void>((resolve) => {
      let done = false;
      const finish = () => {
        if (done) return;
        done = true;
        resolve();
      };
      rec.onstop = finish;
      const timer = window.setTimeout(finish, 1500);
      rec
        .stop()
        .then(finish)
        .catch(finish)
        .finally(() => window.clearTimeout(timer));
    });
    const blob = this.buildBlob();
    const waveform = buildWaveform(this.amplitudes, WAVEFORM_POINTS);
    this.cleanup();
    return { blob, durationMs, waveform };
  }

  cancel(): void {
    // Mark cancelled so an in-flight startInternal() (awaiting getUserMedia /
    // worklet init) releases whatever it acquires late instead of leaking it.
    this.cancelled = true;
    this.clearTimer();
    // Release the mic immediately. opus-recorder's own stop() is fire-and-forget
    // here (it never touches our stream); cleanup() stops the track right away.
    try {
      this.recorder?.stop().catch(() => undefined);
    } catch {
      // already stopped — ignore
    }
    this.chunks = [];
    this.cleanup();
  }

  private buildBlob(): Blob {
    const total = this.chunks.reduce((n, c) => n + c.length, 0);
    const merged = new Uint8Array(total);
    let offset = 0;
    this.chunks.forEach((c) => {
      merged.set(c, offset);
      offset += c.length;
    });
    return new Blob([merged], { type: 'audio/ogg' });
  }

  private clearTimer(): void {
    if (this.sampleTimer !== undefined) {
      window.clearInterval(this.sampleTimer);
      this.sampleTimer = undefined;
    }
  }

  private cleanup(): void {
    this.clearTimer();
    this.source?.disconnect();
    this.analyser?.disconnect();
    this.stream?.getTracks().forEach((track) => track.stop());
    if (this.audioContext && this.audioContext.state !== 'closed') {
      this.audioContext.close().catch(() => undefined);
    }
    this.recorder = null;
    this.source = null;
    this.analyser = null;
    this.stream = null;
    this.audioContext = null;
    this.onAmplitude = null;
  }
}
