/**
 * Web Audio engine for HUD sound effects.
 *
 * Uses AudioBufferSourceNode for low-latency, overlapping playback (HTMLAudio
 * can't overlap the same source and stalls when triggered rapidly). Adds a
 * small pitch jitter on each play so repeated sounds don't feel robotic — the
 * trick games use to keep keypress chirps from grating.
 */

import { logError } from '@/lib/errors';

type EngineOptions = {
  /** Master volume 0..1 applied on top of per-call volume. */
  masterVolume: number;
  /** When false, all play() calls are no-ops. */
  enabled: boolean;
};

type PlayOptions = {
  /** 0..1, multiplied with master volume. */
  volume?: number;
  /** ±fraction of playbackRate jitter; 0.05 = ±5%. */
  pitchVariance?: number;
  /** Skip if the same sound was played within this many ms. Prevents audible doubling on rapid triggers. */
  throttleMs?: number;
};

class SoundEffectsEngine {
  private ctx: AudioContext | null = null;
  private masterGain: GainNode | null = null;
  private limiter: DynamicsCompressorNode | null = null;
  private buffers = new Map<string, AudioBuffer>();
  private loading = new Map<string, Promise<AudioBuffer | null>>();
  private lastPlayedAt = new Map<string, number>();
  private options: EngineOptions = { masterVolume: 0.02, enabled: true };

  setOptions(next: Partial<EngineOptions>) {
    this.options = { ...this.options, ...next };
    if (this.masterGain) {
      this.masterGain.gain.value = this.options.masterVolume;
    }
  }

  /** Lazy AudioContext init. Browsers (and Electron in some configs) start it suspended until a user gesture. */
  private getContext(): AudioContext | null {
    if (this.ctx) return this.ctx;
    try {
      const Ctor =
        window.AudioContext ??
        (window as unknown as { webkitAudioContext?: typeof AudioContext })
          .webkitAudioContext;
      if (!Ctor) return null;
      this.ctx = new Ctor();
      this.masterGain = this.ctx.createGain();
      this.masterGain.gain.value = this.options.masterVolume;

      // Brickwall limiter: caps peaks regardless of system volume so the
      // click stays controlled when the OS volume is cranked. Threshold sets
      // the ceiling; high ratio + fast attack make it a hard limiter.
      this.limiter = this.ctx.createDynamicsCompressor();
      this.limiter.threshold.value = -24; // dB ceiling for peaks
      this.limiter.knee.value = 0;
      this.limiter.ratio.value = 20;
      this.limiter.attack.value = 0.001;
      this.limiter.release.value = 0.08;

      this.masterGain.connect(this.limiter);
      this.limiter.connect(this.ctx.destination);
      return this.ctx;
    } catch (err) {
      logError(err, { scope: 'soundEffects.createContext' });
      return null;
    }
  }

  /**
   * Preload a sound by name from a URL. Safe to call multiple times — caches.
   * Missing/failed loads resolve to null and the sound silently no-ops on play.
   */
  preload(name: string, url: string): Promise<AudioBuffer | null> {
    if (this.buffers.has(name)) {
      return Promise.resolve(this.buffers.get(name) ?? null);
    }
    const existing = this.loading.get(name);
    if (existing) return existing;

    const ctx = this.getContext();
    if (!ctx) return Promise.resolve(null);

    const promise = fetch(url)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status} loading ${url}`);
        return res.arrayBuffer();
      })
      .then((data) => ctx.decodeAudioData(data))
      .then((buffer) => {
        this.buffers.set(name, buffer);
        return buffer;
      })
      .catch((err) => {
        logError(err, { scope: 'soundEffects.preload', name });
        return null;
      });

    this.loading.set(name, promise);
    return promise;
  }

  play(name: string, opts: PlayOptions = {}) {
    if (!this.options.enabled) return;

    const buffer = this.buffers.get(name);
    if (!buffer) return; // silently skip if not loaded

    const ctx = this.ctx;
    const master = this.masterGain;
    if (!ctx || !master) return;

    // Resume suspended context (autoplay policies). Resume is async but
    // start(0) is queued correctly once the context resumes.
    if (ctx.state === 'suspended') {
      void ctx
        .resume()
        .catch((err) => logError(err, { scope: 'soundEffects.resume' }));
    }

    const throttleMs = opts.throttleMs ?? 15;
    const now = ctx.currentTime * 1000;
    const last = this.lastPlayedAt.get(name) ?? -Infinity;
    if (now - last < throttleMs) return;
    this.lastPlayedAt.set(name, now);

    const source = ctx.createBufferSource();
    source.buffer = buffer;

    const variance = opts.pitchVariance ?? 0.04;
    if (variance > 0) {
      // Centered around 1.0; e.g. variance 0.04 → 0.96..1.04
      const jitter = 1 + (Math.random() * 2 - 1) * variance;
      source.playbackRate.value = jitter;
    }

    const gain = ctx.createGain();
    gain.gain.value = opts.volume ?? 1;

    source.connect(gain).connect(master);
    source.start(0);
  }
}

export const soundEffects = new SoundEffectsEngine();
export type { PlayOptions };
