/* AudioEngine.jsx — Web Audio A/B player for the sample×preset matrix.
   Plays two synced sources (dry + processed) and crossfades between them.
   If a processed file is missing, synthesizes a placeholder tone. */

const { useReducer, useEffect: _aeUE } = React;

class AudioEngine {
  constructor() {
    this.ctx = null;
    this._cache = {};      // url → AudioBuffer
    this.currentSample = null;
    this.currentPreset = null;
    this.isPlaying = false;
    this.isWet = true;
    this.listeners = new Set();
    this.position = 0;
    this.startTime = 0;
    this.rafId = null;
    this.drySource = null;
    this.wetSource = null;
    this.dryGain = null;
    this.wetGain = null;
    this.outNode = null;
    this._playGen = 0;
    this._duration = 0;
  }

  async ensureCtx() {
    if (!this.ctx) this.ctx = new (window.AudioContext || window.webkitAudioContext)();
    if (this.ctx.state === 'suspended') await this.ctx.resume();
  }

  async _loadUrl(url) {
    if (!url) return null;
    if (this._cache[url]) return this._cache[url];
    try {
      const res = await fetch(url);
      const arr = await res.arrayBuffer();
      const buf = await this.ctx.decodeAudioData(arr);
      this._cache[url] = buf;
      return buf;
    } catch (e) {
      console.warn('[AudioEngine] failed to load', url, e);
      return null;
    }
  }

  _makePlaceholder(seconds = 8) {
    const sr = this.ctx.sampleRate;
    const buf = this.ctx.createBuffer(2, seconds * sr, sr);
    for (let ch = 0; ch < 2; ch++) {
      const d = buf.getChannelData(ch);
      for (let i = 0; i < d.length; i++) {
        const t = i / sr;
        const f = 180 + 40 * Math.sin(t * 2);
        const env = Math.max(0, Math.sin(t * 0.8)) * Math.exp(-((t % 2) - 0.2) * 1.2);
        d[i] = 0.15 * env * (Math.sin(2 * Math.PI * f * t) + 0.4 * (Math.random() * 2 - 1));
      }
    }
    return buf;
  }

  _teardown() {
    for (const src of [this.drySource, this.wetSource]) {
      if (src) { try { src.stop(); } catch(e){} try { src.disconnect(); } catch(e){} }
    }
    for (const n of [this.dryGain, this.wetGain, this.outNode]) {
      if (n) { try { n.disconnect(); } catch(e){} }
    }
    this.drySource = this.wetSource = null;
    this.dryGain = this.wetGain = this.outNode = null;
  }

  async play(sampleId, preset, fromPos = null) {
    this._playGen++;
    const gen = this._playGen;
    await this.ensureCtx();

    const dryUrl = getDryUrl(sampleId);
    const wetUrl = getAudioUrl(sampleId, preset.file);

    // Load both in parallel
    const [dryBuf, wetBuf] = await Promise.all([
      dryUrl ? this._loadUrl(dryUrl) : null,
      wetUrl ? this._loadUrl(wetUrl) : null,
    ]);

    // Stale check
    if (gen !== this._playGen) return;

    // Stop previous
    this.stop(true);

    const ctx = this.ctx;
    const dry = dryBuf || this._makePlaceholder();
    const wet = wetBuf || dry; // fallback: use dry as wet (no difference heard)

    // Build dual-source crossfade
    const dryGain = ctx.createGain();
    const wetGain = ctx.createGain();
    const out = ctx.createGain();
    dryGain.connect(out); wetGain.connect(out); out.connect(ctx.destination);

    const wantWet = this.isWet;
    dryGain.gain.value = wantWet ? 0 : 1;
    wetGain.gain.value = wantWet ? 1 : 0;

    const drySrc = ctx.createBufferSource();
    drySrc.buffer = dry; drySrc.loop = true; drySrc.connect(dryGain);
    const wetSrc = ctx.createBufferSource();
    wetSrc.buffer = wet; wetSrc.loop = true; wetSrc.connect(wetGain);

    const bufLen = Math.min(dry.duration, wet.duration);
    const offset = (fromPos != null ? fromPos : this.position) % bufLen;
    const t0 = ctx.currentTime + 0.01;
    drySrc.start(t0, offset);
    wetSrc.start(t0, offset);

    this.drySource = drySrc;
    this.wetSource = wetSrc;
    this.dryGain = dryGain;
    this.wetGain = wetGain;
    this.outNode = out;
    this._duration = bufLen;

    this.currentSample = sampleId;
    this.currentPreset = preset.id;
    this.isPlaying = true;
    this.startTime = ctx.currentTime - offset;
    this._tick();
    this.emit();
  }

  togglePlay(sampleId, preset) {
    if (this.isPlaying && this.currentSample === sampleId && this.currentPreset === preset.id) {
      this.stop();
    } else {
      this.play(sampleId, preset);
    }
  }

  /* Change the armed sample+preset without starting playback.
     If playing, hot-swap from the current position.
     If paused/stopped, just update refs so the next Play uses them. */
  switchTo(sampleId, preset) {
    if (this.isPlaying) {
      const pos = this.position || 0;
      this.play(sampleId, preset, pos);
    } else {
      this.currentSample = sampleId;
      this.currentPreset = preset.id;
      this.emit();
    }
  }

  stop(silent = false) {
    this._teardown();
    this.isPlaying = false;
    if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; }
    if (!silent) this.emit();
  }

  seek(pos) {
    this.position = pos;
    if (this.isPlaying) {
      // Find current preset object
      const p = MV_PRESETS.find(pr => pr.id === this.currentPreset);
      if (p) this.play(this.currentSample, p, pos);
    } else {
      this.emit();
    }
  }

  _tick = () => {
    if (!this.isPlaying) return;
    if (this._duration > 0) {
      this.position = (this.ctx.currentTime - this.startTime) % this._duration;
    }
    this.emit();
    this.rafId = requestAnimationFrame(this._tick);
  }

  _applyCrossfade() {
    if (!this.dryGain || !this.wetGain) return;
    const t = this.ctx.currentTime;
    const ramp = 0.06;
    this.dryGain.gain.cancelScheduledValues(t);
    this.wetGain.gain.cancelScheduledValues(t);
    this.dryGain.gain.setTargetAtTime(this.isWet ? 0 : 1, t, ramp);
    this.wetGain.gain.setTargetAtTime(this.isWet ? 1 : 0, t, ramp);
  }

  setWet(wet) {
    if (this.isWet === wet) return;
    this.isWet = wet;
    this._applyCrossfade();
    this.emit();
  }

  /** True when the current sample+preset combo has a real pre-rendered stem */
  hasRealWet(sampleId, presetId) {
    const preset = MV_PRESETS.find(p => p.id === presetId);
    return preset && preset.real && preset.real[sampleId];
  }

  get duration() { return this._duration; }

  onChange(cb) { this.listeners.add(cb); return () => this.listeners.delete(cb); }
  emit() { for (const cb of this.listeners) cb(this); }
}

const engineInstance = new AudioEngine();

const useEngine = () => {
  const [, force] = useReducer(x => x + 1, 0);
  _aeUE(() => engineInstance.onChange(force), []);
  return engineInstance;
};

Object.assign(window, { AudioEngine, audioEngine: engineInstance, useEngine });
