// Procedural one-shots. Every sound is synthesized from noise + oscillators —
// no samples ship with the game, so the whole SFX set costs zero bytes of
// download and every parameter is tweakable from here.
//
// These connect to TechnoEngine's sfxBus, which bypasses the location lowpass.
// That is deliberate: the music muffles when you are at the door, but the stamp
// under your own hand does not. The contrast is the joke.

export type SfxName =
  | 'stampSlam'
  | 'stampInk'
  | 'clickerClunk'
  | 'doorBang'
  | 'radioStatic'
  | 'radioChirp'
  | 'phoneBuzz'
  | 'ropeUnhook'
  | 'denyCrowdOoh'
  | 'typeTick';

/** Seconds of white noise generated once and shared by every noise voice. */
const NOISE_SECONDS = 2;

/**
 * Read heads into the shared noise buffer. Successive hits start at different
 * offsets so a run of stamps does not sound like the same recording twice —
 * a deterministic stand-in for the Math.random the house rules forbid.
 */
const NOISE_OFFSETS = [0, 0.31, 0.77, 0.13, 0.94, 0.52, 1.19, 0.68] as const;

/** Chain jingle: link strike times (s) and gains. Uneven on purpose. */
const ROPE_PINGS = [
  { at: 0, gain: 0.3, hz: 5200 },
  { at: 0.026, gain: 0.19, hz: 6400 },
  { at: 0.058, gain: 0.27, hz: 4600 },
  { at: 0.081, gain: 0.14, hz: 7100 },
  { at: 0.117, gain: 0.21, hz: 5800 },
  { at: 0.149, gain: 0.11, hz: 6900 },
] as const;

/** Gain steps across the static burst — flat noise reads as tape hiss, not radio. */
const STATIC_CRACKLE = [0.34, 0.12, 0.41, 0.2, 0.5, 0.16, 0.29, 0.38, 0.09, 0.22] as const;

/** WebAudio refuses an exponential ramp to zero; this is close enough to silence. */
const SILENT = 0.0001;

export class Sfx {
  private readonly ctx: AudioContext;
  private readonly dest: AudioNode;
  private readonly noiseBuffer: AudioBuffer;

  /** Sources still playing — destroy() has to be able to cut them mid-flight. */
  private readonly live = new Set<AudioScheduledSourceNode>();

  private offsetCursor = 0;

  private rain: { src: AudioBufferSourceNode; lfo: OscillatorNode; gain: GainNode } | null = null;

  constructor(ctx: AudioContext, destination: AudioNode) {
    this.ctx = ctx;
    this.dest = destination;
    this.noiseBuffer = makeNoise(ctx);
  }

  play(name: SfxName): void {
    switch (name) {
      case 'stampSlam':
        return this.stampSlam();
      case 'stampInk':
        return this.stampInk();
      case 'clickerClunk':
        return this.clickerClunk();
      case 'doorBang':
        return this.doorBang();
      case 'radioStatic':
        return this.radioStatic();
      case 'radioChirp':
        return this.radioChirp();
      case 'phoneBuzz':
        return this.phoneBuzz();
      case 'ropeUnhook':
        return this.ropeUnhook();
      case 'denyCrowdOoh':
        return this.denyCrowdOoh();
      case 'typeTick':
        return this.typeTick();
    }
  }

  /**
   * The signature sound. Two layers that must land on the same sample: a body
   * thump you feel and a paper crack you hear. Drop the crack and it turns into
   * a kick drum; drop the thump and the stamp weighs nothing.
   */
  stampSlam(): void {
    const t = this.ctx.currentTime;

    const thump = this.ctx.createOscillator();
    thump.type = 'sine';
    thump.frequency.setValueAtTime(170, t);
    thump.frequency.exponentialRampToValueAtTime(52, t + 0.09);
    this.voice(thump, [this.env(t, 0.001, 0.01, 0.26, 0.95)], t, 0.3);

    // Bandpassed rather than open noise: a wide burst sounds like a cymbal, a
    // narrow one at 2.6k sounds like a page taking a hit.
    const snap = this.noiseSource();
    this.voice(
      snap,
      [this.band(2600, 1.1), this.env(t, 0.0006, 0.004, 0.07, 0.55)],
      t,
      0.09,
    );

    // A hair of top end, delayed a touch, is the wooden handle bottoming out.
    const knock = this.noiseSource();
    this.voice(
      knock,
      [this.band(620, 2.4), this.env(t + 0.008, 0.001, 0.004, 0.05, 0.3)],
      t + 0.008,
      0.06,
    );
  }

  /** Damp, not percussive — the soft attack is what stops it reading as a click. */
  stampInk(): void {
    const t = this.ctx.currentTime;

    const lp = this.ctx.createBiquadFilter();
    lp.type = 'lowpass';
    lp.Q.value = 6;
    lp.frequency.setValueAtTime(2000, t);
    lp.frequency.exponentialRampToValueAtTime(210, t + 0.13);

    const src = this.noiseSource();
    src.playbackRate.value = 0.7;
    this.voice(src, [lp, this.env(t, 0.018, 0.02, 0.16, 0.32)], t, 0.22);
  }

  /** Two clicks; the ear hears the pair as one mechanism, not two events. */
  clickerClunk(): void {
    const t = this.ctx.currentTime;
    this.click(t, 2100, 0.5);
    this.click(t + 0.035, 1720, 0.28);
  }

  /**
   * The toilet rhythm game cues off this through a closed door and a techno
   * mix, so it is the loudest thing in the set by a clear margin.
   */
  doorBang(): void {
    const t = this.ctx.currentTime;

    const thud = this.ctx.createOscillator();
    thud.type = 'sine';
    thud.frequency.setValueAtTime(120, t);
    thud.frequency.exponentialRampToValueAtTime(36, t + 0.14);
    this.voice(thud, [this.env(t, 0.001, 0.02, 0.4, 1.1)], t, 0.46);

    const slap = this.noiseSource();
    this.voice(slap, [this.band(880, 0.8), this.env(t, 0.001, 0.01, 0.13, 0.7)], t, 0.16);

    // High-Q noise band == a struck panel resonating. Cheaper than modelling it.
    const ring = this.noiseSource();
    this.voice(ring, [this.band(370, 18), this.env(t, 0.002, 0.03, 0.42, 0.5)], t, 0.5);
  }

  radioStatic(): void {
    const t = this.ctx.currentTime;
    const dur = 0.35;

    const g = this.ctx.createGain();
    g.gain.setValueAtTime(SILENT, t);
    // Stepped, not ramped: hard jumps between levels are what crackle sounds like.
    for (let i = 0; i < STATIC_CRACKLE.length; i++) {
      const level = STATIC_CRACKLE[i] ?? 0.2;
      g.gain.setValueAtTime(level * 0.6, t + (i / STATIC_CRACKLE.length) * dur);
    }
    g.gain.setValueAtTime(0.18, t + dur * 0.94);
    g.gain.exponentialRampToValueAtTime(SILENT, t + dur);

    const src = this.noiseSource();
    this.voice(src, [this.band(1800, 0.9), g], t, dur);
  }

  /** The squelch tail you get when someone lets go of the PTT. */
  radioChirp(): void {
    const t = this.ctx.currentTime;

    const tone = this.ctx.createOscillator();
    tone.type = 'sawtooth';
    tone.frequency.setValueAtTime(2400, t);
    tone.frequency.exponentialRampToValueAtTime(680, t + 0.075);
    this.voice(tone, [this.band(1600, 1.6), this.env(t, 0.002, 0.02, 0.07, 0.22)], t, 0.11);

    const tick = this.noiseSource();
    this.voice(
      tick,
      [this.band(3200, 1.2), this.env(t + 0.078, 0.001, 0.004, 0.03, 0.2)],
      t + 0.078,
      0.04,
    );
  }

  /**
   * Phone face-down on the bar. The 28Hz amplitude modulation is the whole
   * effect — an unmodulated 55Hz tone is a bass note, the same tone chopped at
   * 28Hz is a plastic case rattling on laminate.
   */
  phoneBuzz(): void {
    const t = this.ctx.currentTime;
    this.buzzBurst(t, 0.15);
    this.buzzBurst(t + 0.24, 0.15);
  }

  ropeUnhook(): void {
    const t = this.ctx.currentTime;
    for (const ping of ROPE_PINGS) {
      const at = t + ping.at;
      const hp = this.ctx.createBiquadFilter();
      hp.type = 'highpass';
      hp.frequency.value = ping.hz;
      const src = this.noiseSource();
      this.voice(src, [hp, this.env(at, 0.001, 0.003, 0.05, ping.gain)], at, 0.06);
    }
  }

  /**
   * Six people at the rope who just watched someone get knocked back. Two
   * bandpasses sweeping upward together fake the vowel moving "oo" -> "ooh";
   * the slow swell keeps it a murmur instead of a boo.
   */
  denyCrowdOoh(): void {
    const t = this.ctx.currentTime;
    const dur = 0.62;

    const swell = this.ctx.createGain();
    swell.gain.setValueAtTime(SILENT, t);
    swell.gain.linearRampToValueAtTime(0.3, t + 0.24);
    swell.gain.setValueAtTime(0.3, t + 0.34);
    swell.gain.exponentialRampToValueAtTime(SILENT, t + dur);

    const formant = (base: number, top: number, level: number): AudioNode[] => {
      const f = this.ctx.createBiquadFilter();
      f.type = 'bandpass';
      f.Q.value = 5;
      f.frequency.setValueAtTime(base, t);
      f.frequency.linearRampToValueAtTime(top, t + 0.5);
      const trim = this.ctx.createGain();
      trim.gain.value = level;
      return [f, trim];
    };

    const low = this.noiseSource();
    low.playbackRate.value = 0.5;
    // The lower formant owns the shared swell node, so it is disconnected with
    // the rest of that chain on `ended` — both branches stop at the same time.
    this.voice(low, [...formant(360, 560, 1), swell], t, dur);

    const high = this.noiseSource();
    high.playbackRate.value = 0.5;
    this.voiceInto(high, formant(860, 1180, 0.45), swell, t, dur);
  }

  /**
   * Fires once per typed character, so restraint is the entire spec. Anything
   * louder or longer than this becomes unbearable by the third line of dialogue.
   */
  typeTick(): void {
    const t = this.ctx.currentTime;
    const src = this.noiseSource();
    this.voice(src, [this.band(3400, 2.2), this.env(t, 0.0005, 0.002, 0.012, 0.045)], t, 0.016);
  }

  /** Idempotent — the door scene may call this on every re-entry. */
  startRain(): void {
    if (this.rain) return;
    const t = this.ctx.currentTime;

    const src = this.ctx.createBufferSource();
    src.buffer = this.noiseBuffer;
    src.loop = true;

    const lp = this.ctx.createBiquadFilter();
    lp.type = 'lowpass';
    lp.frequency.value = 3200;

    const gain = this.ctx.createGain();
    gain.gain.setValueAtTime(SILENT, t);
    gain.gain.linearRampToValueAtTime(0.1, t + 1.2);

    // A slow tremolo stops the bed reading as a static hiss the ear tunes out.
    const lfo = this.ctx.createOscillator();
    lfo.type = 'sine';
    lfo.frequency.value = 0.13;
    const lfoDepth = this.ctx.createGain();
    lfoDepth.gain.value = 0.028;
    lfo.connect(lfoDepth).connect(gain.gain);

    src.connect(lp).connect(gain).connect(this.dest);
    src.start(t);
    lfo.start(t);
    this.rain = { src, lfo, gain };
  }

  /** Fades rather than cuts — a hard stop on a noise bed is audible as a click. */
  stopRain(): void {
    const rain = this.rain;
    if (!rain) return;
    this.rain = null;

    const t = this.ctx.currentTime;
    const fade = 0.9;
    rain.gain.gain.cancelScheduledValues(t);
    rain.gain.gain.setValueAtTime(Math.max(rain.gain.gain.value, SILENT), t);
    rain.gain.gain.exponentialRampToValueAtTime(SILENT, t + fade);

    rain.src.onended = () => {
      rain.src.disconnect();
      rain.gain.disconnect();
    };
    rain.src.stop(t + fade);
    rain.lfo.stop(t + fade);
    rain.lfo.onended = () => rain.lfo.disconnect();
  }

  destroy(): void {
    this.stopRain();
    for (const src of this.live) {
      try {
        src.stop();
      } catch {
        // Already stopped or never started — nothing to unwind.
      }
      src.disconnect();
    }
    this.live.clear();
  }

  // --- voices -------------------------------------------------------------

  /** One filtered click. Shared by the clicker's two halves. */
  private click(at: number, hz: number, level: number): void {
    const src = this.noiseSource();
    this.voice(src, [this.band(hz, 12), this.env(at, 0.0008, 0.002, 0.028, level)], at, 0.04);
  }

  private buzzBurst(at: number, dur: number): void {
    const carrier = this.ctx.createOscillator();
    carrier.type = 'triangle';
    carrier.frequency.value = 55;

    // Modulator swings the ring gain around 0.5, so the carrier is chopped to
    // near-silence and back 28 times a second.
    const ring = this.ctx.createGain();
    ring.gain.value = 0.5;
    const mod = this.ctx.createOscillator();
    mod.type = 'square';
    mod.frequency.value = 28;
    const modDepth = this.ctx.createGain();
    modDepth.gain.value = 0.5;
    mod.connect(modDepth).connect(ring.gain);
    mod.start(at);
    mod.stop(at + dur);
    mod.onended = () => {
      mod.disconnect();
      modDepth.disconnect();
    };

    this.voice(carrier, [ring, this.env(at, 0.006, dur - 0.03, 0.02, 0.4)], at, dur);
  }

  // --- plumbing -----------------------------------------------------------

  private noiseSource(): AudioBufferSourceNode {
    const src = this.ctx.createBufferSource();
    src.buffer = this.noiseBuffer;
    return src;
  }

  private band(hz: number, q: number): BiquadFilterNode {
    const f = this.ctx.createBiquadFilter();
    f.type = 'bandpass';
    f.frequency.value = hz;
    f.Q.value = q;
    return f;
  }

  /** Attack/hold/decay gain envelope, already scheduled against audio time. */
  private env(at: number, attack: number, hold: number, decay: number, peak: number): GainNode {
    const g = this.ctx.createGain();
    g.gain.setValueAtTime(SILENT, at);
    g.gain.linearRampToValueAtTime(peak, at + attack);
    g.gain.setValueAtTime(peak, at + attack + hold);
    g.gain.exponentialRampToValueAtTime(SILENT, at + attack + hold + decay);
    return g;
  }

  private voice(
    src: AudioScheduledSourceNode,
    chain: readonly AudioNode[],
    at: number,
    dur: number,
  ): void {
    this.voiceInto(src, chain, this.dest, at, dur);
  }

  /**
   * Wires src -> chain -> out, plays it, and disconnects the whole chain on
   * `ended`. Without this a long night leaks a few hundred dead nodes.
   */
  private voiceInto(
    src: AudioScheduledSourceNode,
    chain: readonly AudioNode[],
    out: AudioNode,
    at: number,
    dur: number,
  ): void {
    let node: AudioNode = src;
    for (const n of chain) node = node.connect(n);
    node.connect(out);

    this.live.add(src);
    src.onended = () => {
      this.live.delete(src);
      src.disconnect();
      for (const n of chain) n.disconnect();
    };

    if (src instanceof AudioBufferSourceNode) src.start(at, this.nextOffset(), dur);
    else src.start(at);
    src.stop(at + dur);
  }

  private nextOffset(): number {
    const offset = NOISE_OFFSETS[this.offsetCursor % NOISE_OFFSETS.length] ?? 0;
    this.offsetCursor++;
    return offset;
  }
}

/**
 * White noise from a fixed-seed LCG. Deterministic by house rule, and one
 * buffer for the whole night — regenerating per hit would allocate megabytes.
 */
function makeNoise(ctx: AudioContext): AudioBuffer {
  const length = Math.floor(ctx.sampleRate * NOISE_SECONDS);
  const buffer = ctx.createBuffer(1, length, ctx.sampleRate);
  const data = buffer.getChannelData(0);
  let seed = 0x2f6e2b1;
  for (let i = 0; i < length; i++) {
    seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
    data[i] = (seed / 0xffffffff) * 2 - 1;
  }
  return buffer;
}
