// Sample-accurate gapless playback engine (Web Audio).
//
// The HTML5 <audio> element cannot play tracks back-to-back without a gap: advancing swaps the element's
// `src`, which re-fetches + re-buffers the next file. For a continuous DJ mix (already silence-trimmed at
// the edges) that gap is audible. This engine instead decodes each track to an AudioBuffer and schedules
// the NEXT track's AudioBufferSourceNode to `start()` at the precise AudioContext time the current track
// ends — so the seam is sample-accurate, zero gap.
//
// Cost of that guarantee: it must download + decode the WHOLE next file to PCM ahead of time (a ~10-min
// track ≈ 200 MB in memory), so we cache only a few decoded buffers. Startup / manual-skip therefore has
// a decode latency (surfaced via onLoadingChange); auto-advance is pre-decoded so it's instant.

export type EngineTrack = { key: string; url: string };

export type EngineCallbacks = {
  /** The engine advanced to a new track on its OWN (natural boundary) — mirror it into UI state. */
  onIndex?: (index: number) => void;
  /** Current position + duration (seconds). Fires ~per animation frame while playing, and on load/seek. */
  onTime?: (positionSec: number, durationSec: number) => void;
  /** Playback stopped because the queue ran out (host should reflect paused state). */
  onEndOfQueue?: () => void;
  /** True while the CURRENT track is being fetched/decoded (startup / skip / seek). */
  onLoadingChange?: (loading: boolean) => void;
};

const CACHE_MAX = 3; // decoded buffers kept (current + next + a little headroom); bounds memory

export class GaplessEngine {
  private ctx: AudioContext | null = null;
  private gain: GainNode | null = null;
  private volume = 1;

  private buffers = new Map<string, AudioBuffer>(); // url → decoded PCM (insertion-ordered LRU)
  private inflight = new Map<string, Promise<AudioBuffer | null>>();

  private queue: EngineTrack[] = [];
  private index = 0;

  private cur: AudioBufferSourceNode | null = null;
  private nxt: AudioBufferSourceNode | null = null;
  // EVERY source node this engine has started and that has not yet ended. `cur`/`nxt` are the two it is
  // reasoning about; this is the set it is responsible for silencing. They diverged once — a duplicate
  // begin() left a source playing that nothing held a reference to, so nothing could ever stop it — and
  // an orphan in Web Audio is unstoppable and inaudible to the code. Add here, stop from here.
  private live = new Set<AudioBufferSourceNode>();
  private curBaseTime = 0; // ctx time that corresponds to position 0 of the current track
  private curDuration = 0;
  private nextStartAt = 0; // ctx time the scheduled `nxt` will begin (= current track's end)
  private pendingOffset = 0; // where the current track should (re)start from — seek/resume offset
  private started = false; // has the current track's source actually been started?
  private playing = false;
  private gen = 0; // bumped on any disruptive change; stale async/onended callbacks check it and bail
  // The generation a begin() is currently in flight for, or -1. `gen` alone cannot express this: it marks
  // a change, and two begin() calls for the SAME generation are exactly the case that must be refused.
  private beginGen = -1;
  private raf = 0;

  private cb: EngineCallbacks;

  constructor(cb: EngineCallbacks) {
    this.cb = cb;
  }

  private ensureCtx(): AudioContext {
    if (!this.ctx) {
      this.ctx = new AudioContext();
      this.gain = this.ctx.createGain();
      this.gain.gain.value = this.volume;
      this.gain.connect(this.ctx.destination);
    }
    return this.ctx;
  }

  /** Resume the context from a user gesture (autoplay policy). Safe to call repeatedly. */
  unlock(): void {
    const ctx = this.ensureCtx();
    if (ctx.state === 'suspended') void ctx.resume();
  }

  setVolume(v: number): void {
    this.volume = v;
    if (this.gain) this.gain.gain.value = v;
  }

  // ── Decoding (fetch full file → PCM), deduped + LRU-capped ──
  private decode(url: string): Promise<AudioBuffer | null> {
    const cached = this.buffers.get(url);
    if (cached) return Promise.resolve(cached);
    const existing = this.inflight.get(url);
    if (existing) return existing;
    const ctx = this.ensureCtx();
    const p = (async () => {
      try {
        const res = await fetch(url);
        if (!res.ok) return null;
        const bytes = await res.arrayBuffer();
        const decoded = await ctx.decodeAudioData(bytes);
        this.buffers.set(url, decoded);
        this.evict();
        return decoded;
      } catch {
        return null;
      } finally {
        this.inflight.delete(url);
      }
    })();
    this.inflight.set(url, p);
    return p;
  }

  private evict(): void {
    while (this.buffers.size > CACHE_MAX) {
      const keep = new Set([this.queue[this.index]?.url, this.queue[this.index + 1]?.url]);
      let removed = false;
      for (const k of this.buffers.keys()) {
        if (!keep.has(k)) {
          this.buffers.delete(k);
          removed = true;
          break;
        }
      }
      if (!removed) break; // everything left is current/next — stop
    }
  }

  // ── Public control surface ──

  /** Load a queue and either start it (autoplay) or prepare it paused at `seekTo`. */
  load(queue: EngineTrack[], index: number, autoplay: boolean, seekTo = 0): void {
    this.gen++;
    this.stopSources();
    this.queue = queue;
    this.index = queue.length ? Math.max(0, Math.min(index, queue.length - 1)) : 0;
    this.playing = autoplay && queue.length > 0;
    this.started = false;
    this.pendingOffset = Math.max(0, seekTo);
    this.curDuration = 0;
    this.curBaseTime = 0;
    this.startTicker();
    if (!queue.length) return;
    if (autoplay) void this.begin(this.gen);
    else void this.prepare(this.gen);
  }

  /** Manual jump to an arbitrary index (prev / next button / track click). A small decode hitch is fine. */
  skipTo(index: number): void {
    this.gen++;
    this.stopSources();
    this.index = Math.max(0, Math.min(index, this.queue.length - 1));
    this.pendingOffset = 0;
    this.started = false;
    this.curDuration = 0;
    if (this.playing) void this.begin(this.gen);
    else void this.prepare(this.gen);
  }

  seek(sec: number): void {
    if (!this.queue[this.index]) return;
    this.gen++;
    this.stopSources();
    this.pendingOffset = Math.max(0, this.curDuration ? Math.min(sec, this.curDuration) : sec);
    this.started = false;
    if (this.playing) {
      void this.begin(this.gen);
    } else {
      this.cb.onTime?.(this.pendingOffset, this.curDuration);
      void this.prepare(this.gen);
    }
  }

  play(): void {
    this.playing = true;
    const ctx = this.ensureCtx();
    if (!this.started) void this.begin(this.gen);
    else if (ctx.state === 'suspended') void ctx.resume();
  }

  pause(): void {
    this.playing = false;
    if (this.ctx && this.ctx.state === 'running') void this.ctx.suspend();
  }

  destroy(): void {
    this.gen++;
    if (this.raf) cancelAnimationFrame(this.raf);
    this.raf = 0;
    this.stopSources();
    this.buffers.clear();
    this.inflight.clear();
    if (this.ctx) {
      void this.ctx.close();
      this.ctx = null;
      this.gain = null;
    }
  }

  // ── Internals ──

  /** Decode the current track without starting it — paused/prepared (restore, or seek while paused). */
  private async prepare(gen: number): Promise<void> {
    const t = this.queue[this.index];
    if (!t) return;
    this.cb.onLoadingChange?.(true);
    const buf = await this.decode(t.url);
    this.cb.onLoadingChange?.(false);
    if (gen !== this.gen || !buf) return;
    this.curDuration = buf.duration;
    this.cb.onTime?.(Math.min(this.pendingOffset, buf.duration), buf.duration);
    void this.preloadNext(gen);
  }

  /**
   * Start the current track at `pendingOffset` (decoding first if needed).
   *
   * Re-entrant calls for the same generation are REFUSED, and that guard is the whole reason this method
   * is not simply idempotent-by-gen. `started` only becomes true at the very end, after a fetch and a
   * full decode — seconds, for a long track. Anything that calls begin() in that window sees
   * `started === false` and starts a second, parallel decode of the same track, and both finish and both
   * call startBuffer(): two sources of the same audio playing at once, only one of them in `cur`.
   *
   * That was not a rare race. Starting a queue from a paused player fired it every single time: the host
   * commits a new queue and `playing: true` together, its queue effect calls load(autoplay) → begin, and
   * its playing effect then calls play() → begin again, same generation. The audible result compounds —
   * the untracked twin keeps its own onended, so at the track boundary advance() runs twice, the index
   * jumps two tracks and a second source is promoted while the first is still sounding.
   */
  private async begin(gen: number): Promise<void> {
    if (this.beginGen === gen) return;
    this.beginGen = gen;
    try {
      const ctx = this.ensureCtx();
      if (ctx.state === 'suspended') await ctx.resume();
      if (gen !== this.gen) return;
      const t = this.queue[this.index];
      if (!t) return;
      this.cb.onLoadingChange?.(true);
      const buf = await this.decode(t.url);
      this.cb.onLoadingChange?.(false);
      if (gen !== this.gen || !buf) return;
      this.startBuffer(buf, this.pendingOffset, gen);
      void this.preloadNext(gen);
    } finally {
      // Released even on the bail paths: a decode that fails must not leave this generation permanently
      // unable to start, or a failed track would wedge the player until something bumped `gen`.
      if (this.beginGen === gen) this.beginGen = -1;
    }
  }

  private startBuffer(buf: AudioBuffer, offset: number, gen: number): void {
    const ctx = this.ensureCtx();
    const src = ctx.createBufferSource();
    src.buffer = buf;
    src.connect(this.gain!);
    const startAt = ctx.currentTime;
    const off = Math.min(Math.max(0, offset), buf.duration);
    src.start(startAt, off);
    this.live.add(src);
    src.onended = () => this.onSourceEnded(src, gen);
    this.cur = src;
    this.curDuration = buf.duration;
    this.curBaseTime = startAt - off; // position = ctx.currentTime - curBaseTime
    this.started = true;
    this.playing = true;
  }

  /** Decode index+1 and schedule it to begin exactly when the current track ends. */
  private async preloadNext(gen: number): Promise<void> {
    if (this.nxt) {
      const stale = this.nxt;
      this.live.delete(stale);
      try {
        stale.onended = null;
        stale.stop();
      } catch {
        /* not started */
      }
      try {
        stale.disconnect();
      } catch {
        /* already disconnected */
      }
      this.nxt = null;
    }
    const forIndex = this.index;
    const nextTrack = this.queue[forIndex + 1];
    if (!nextTrack || !this.started) return;
    const buf = await this.decode(nextTrack.url);
    if (gen !== this.gen || this.index !== forIndex || !buf) return;
    const ctx = this.ensureCtx();
    const boundary = this.curBaseTime + this.curDuration;
    if (boundary <= ctx.currentTime) return; // already past — advance() will start it fresh
    const src = ctx.createBufferSource();
    src.buffer = buf;
    src.connect(this.gain!);
    src.start(boundary, 0);
    this.live.add(src);
    src.onended = () => this.onSourceEnded(src, gen);
    this.nxt = src;
    this.nextStartAt = boundary;
  }

  /**
   * A source finished. Only the one the engine considers CURRENT may drive the queue forward.
   *
   * Without the identity check, any source that outlives its bookkeeping still advances the queue when it
   * ends — so one stray node does not just play unwanted audio, it desynchronises the index for
   * everything after it. `cur` is the single source of truth for "what is playing"; ending anything else
   * is bookkeeping, not an event.
   */
  private onSourceEnded(src: AudioBufferSourceNode, gen: number): void {
    this.live.delete(src);
    if (gen !== this.gen || this.cur !== src) return;
    this.advance();
  }

  /** Fired at a track boundary (its source ended): move to the next track. */
  private advance(): void {
    if (this.index + 1 >= this.queue.length) {
      this.playing = false;
      this.started = false;
      this.cur = null;
      this.cb.onEndOfQueue?.();
      return;
    }
    this.index++;
    const promoted = this.nxt;
    this.nxt = null;
    if (promoted?.buffer) {
      // The next source was scheduled to start at the exact boundary — it's already playing seamlessly.
      this.cur = promoted;
      this.curDuration = promoted.buffer.duration;
      this.curBaseTime = this.nextStartAt;
      this.started = true;
      this.cb.onIndex?.(this.index);
      void this.preloadNext(this.gen);
    } else {
      // Next wasn't decoded in time (rare) — start it now, accepting a tiny gap this once.
      this.cb.onIndex?.(this.index);
      this.pendingOffset = 0;
      this.started = false;
      void this.begin(this.gen);
    }
  }

  /** Silence everything this engine has started. Iterates `live`, not just cur/nxt — see its declaration. */
  private stopSources(): void {
    for (const s of this.live) {
      try {
        s.onended = null;
        s.stop();
      } catch {
        /* not started */
      }
      try {
        s.disconnect();
      } catch {
        /* already disconnected */
      }
    }
    this.live.clear();
    this.cur = null;
    this.nxt = null;
  }

  private startTicker(): void {
    if (this.raf) return;
    const tick = () => {
      this.raf = requestAnimationFrame(tick);
      if (!this.ctx || !this.started || !this.playing) return;
      const pos = this.ctx.currentTime - this.curBaseTime;
      this.cb.onTime?.(Math.max(0, Math.min(pos, this.curDuration)), this.curDuration);
    };
    this.raf = requestAnimationFrame(tick);
  }
}
