// The beds under the night: rain outside, bodies inside, and the kebab shop's
// fluoro tube humming to itself while both happen.
//
// ROUTING. Everything here lands on the engine's sfxBus, never the musicBus.
// The music lowpass models "the track heard through a wall" — that is a
// statement about the PA being on the other side of a door. Rain is not on the
// other side of anything; it is falling on your head, so filtering it with the
// music would put the weather indoors with the speakers. What makes rain
// door-only is its own gain envelope tracking `audio:location`, not a cutoff.
// The crowd bed is the same argument run backwards: it is the room you are
// standing in when you are inside, and a wall's worth of muffling belongs on
// the PA, not on the people. Location is a mix decision here, not a filter one.

import type { EventBus } from '../core/EventBus';
import { SeededRNG } from '../core/SeededRNG';
import type { Sfx } from './Sfx';

export interface AmbienceOptions {
  rain?: boolean;
  crowd?: boolean;
  kebab?: boolean;
}

type Bed = 'rain' | 'crowd' | 'kebab';
type Location = 'door' | 'floor';

/** Seconds of looped noise behind the crowd bed. Long enough to hide the seam. */
const NOISE_SECONDS = 4;

/** Location crossfade. Sits between the 600/900ms filter sweeps either way, so
 *  stepping through the door reads as one movement rather than two events. */
const LOC_FADE_S = 0.7;

/** Headcount swell. Slow enough to be a swell, fast enough to feel caused. */
const COUNT_FADE_S = 0.4;

/** Enable/disable toggles are a tuning affordance, not a game beat — keep short. */
const TOGGLE_FADE_S = 0.25;

/** Peak crowd gain, at a room so full it cannot get louder. Well under the kick. */
const CROWD_PEAK = 0.09;

/**
 * Saturation constant for the headcount curve. At k=14 the first dozen people
 * buy most of the loudness and the hundredth buys almost none — which is how a
 * room actually behaves, and the opposite of what a linear map does.
 */
const CROWD_K = 14;

/** Crowd bed at the door: the room leaking past you, not silence. */
const CROWD_DOOR_DUCK = 0.15;

/** The fluoro tube. Barely there on purpose — it is set dressing, not a voice. */
const KEBAB_LEVEL = 0.014;

export class Ambience {
  private readonly bus: EventBus;
  private readonly ctx: AudioContext;
  private readonly dest: AudioNode;
  private readonly sfx: Sfx;

  private readonly offs: Array<() => void> = [];

  private readonly enabled: Record<Bed, boolean>;

  private loc: Location = 'door';
  private running = false;
  private count = 0;

  private crowd: CrowdBed | null = null;
  private kebab: KebabBed | null = null;

  /** Built on first start() and kept: restarting shouldn't re-allocate 4s of noise. */
  private noise: AudioBuffer | null = null;

  constructor(
    bus: EventBus,
    ctx: AudioContext,
    destination: AudioNode,
    sfx: Sfx,
    opts: AmbienceOptions = {},
  ) {
    this.bus = bus;
    this.ctx = ctx;
    this.dest = destination;
    this.sfx = sfx;
    this.enabled = {
      rain: opts.rain ?? true,
      crowd: opts.crowd ?? true,
      // The kebab shop is the moral centre of the thing (design doc §5). It gets
      // a sound whether or not anyone is listening for it.
      kebab: opts.kebab ?? true,
    };

    this.offs.push(
      this.bus.on('audio:location', ({ location }) => {
        if (location === this.loc) return; // door -> door must not restack a fade
        this.loc = location;
        this.applyLocation();
      }),
    );

    // Headcount is derived, not published: NightState.capacity.inside is in no
    // event payload and we may not add one. So we count the truth log — an
    // admission puts a body in the room, an ejection takes one out.
    //
    // Deliberately NOT door:clicker. The clicker is the player's *claimed*
    // count, and the gap between it and reality is a game mechanic. Driving the
    // crowd bed off it would make the room sound like the lie instead of the
    // room, and would hand the player a way to hear their own miscount.
    this.offs.push(
      this.bus.on('door:verdict', ({ verdict }) => {
        if (verdict === 'admit') this.setInsideCount(this.count + 1);
      }),
    );
    this.offs.push(
      this.bus.on('floor:ejection', () => this.setInsideCount(this.count - 1)),
    );
  }

  get insideCount(): number {
    return this.count;
  }

  /** Lets a demo drive the room without staging a hundred real admissions. */
  setInsideCount(n: number): void {
    const next = Math.max(0, Math.floor(n));
    if (next === this.count) return;
    this.count = next;
    this.crowd?.setLevel(this.crowdTarget(), COUNT_FADE_S);
  }

  start(): void {
    if (this.running) return;
    this.running = true;

    this.noise ??= noiseBuffer(this.ctx);
    this.crowd = new CrowdBed(this.ctx, this.dest, this.noise);
    this.kebab = new KebabBed(this.ctx, this.dest);

    // Snap rather than fade on the first frame: there is no previous location to
    // cross from, and a 700ms swell out of nothing sounds like a mistake.
    this.crowd.setLevel(this.crowdTarget(), 0);
    this.crowd.setLocation(this.crowdDuck(), 0);
    this.kebab.setLevel(this.kebabTarget(), 0);
    this.kebab.setLocation(this.kebabDuck(), 0);
    this.applyRain();
  }

  stop(): void {
    if (!this.running) return;
    this.running = false;

    this.crowd?.stop();
    this.crowd = null;
    this.kebab?.stop();
    this.kebab = null;
    this.sfx.stopRain();
  }

  setEnabled(bed: Bed, on: boolean): void {
    if (this.enabled[bed] === on) return;
    this.enabled[bed] = on;
    if (!this.running) return;

    switch (bed) {
      case 'rain':
        return this.applyRain();
      case 'crowd':
        return this.crowd?.setLevel(this.crowdTarget(), TOGGLE_FADE_S);
      case 'kebab':
        return this.kebab?.setLevel(this.kebabTarget(), TOGGLE_FADE_S);
    }
  }

  destroy(): void {
    this.stop();
    for (const off of this.offs) off();
    this.offs.length = 0;
  }

  // --- policy ---------------------------------------------------------------

  private applyLocation(): void {
    this.crowd?.setLocation(this.crowdDuck(), LOC_FADE_S);
    this.kebab?.setLocation(this.kebabDuck(), LOC_FADE_S);
    this.applyRain();
  }

  /**
   * Rain is delegated to Sfx, which already owns a rain bed wired to this same
   * bus; a second one would just be the first one twice as loud. Both calls are
   * idempotent, so repeated door -> door is a no-op there as well as here.
   *
   * The `running` guard is what the crowd and kebab beds get for free from their
   * null refs. Without it a location change after stop() — the subscription
   * outlives stop(), only destroy() drains it — would start rain under a room
   * with no crowd and no kebab in it, and nothing would take it back down.
   */
  private applyRain(): void {
    if (!this.running) return;
    if (this.enabled.rain && this.loc === 'door') this.sfx.startRain();
    else this.sfx.stopRain();
  }

  private crowdTarget(): number {
    if (!this.enabled.crowd) return 0;
    return CROWD_PEAK * (1 - Math.exp(-this.count / CROWD_K));
  }

  private crowdDuck(): number {
    return this.loc === 'floor' ? 1 : CROWD_DOOR_DUCK;
  }

  private kebabTarget(): number {
    return this.enabled.kebab ? KEBAB_LEVEL : 0;
  }

  private kebabDuck(): number {
    return this.loc === 'door' ? 1 : 0;
  }
}

/**
 * Bodies and talking. A wide bandpass around 500Hz is the whole trick: above
 * ~1kHz noise turns into hiss and below ~250Hz it turns into traffic, and the
 * band between them is where a room full of people sits.
 */
class CrowdBed {
  private readonly src: AudioBufferSourceNode;
  private readonly lfos: OscillatorNode[] = [];
  private readonly chain: AudioNode[] = [];
  private readonly level: GainNode;
  private readonly loc: GainNode;
  private readonly ctx: AudioContext;

  constructor(ctx: AudioContext, dest: AudioNode, buffer: AudioBuffer) {
    this.ctx = ctx;

    this.src = ctx.createBufferSource();
    this.src.buffer = buffer;
    this.src.loop = true;
    // Slowed a little: it drags the noise's spectral centre down and costs nothing.
    this.src.playbackRate.value = 0.85;

    const band = ctx.createBiquadFilter();
    band.type = 'bandpass';
    band.frequency.value = 500;
    band.Q.value = 0.75; // ~300-800Hz at -3dB

    // Second pass shaves the fizz the bandpass's gentle skirt lets through.
    const lp = ctx.createBiquadFilter();
    lp.type = 'lowpass';
    lp.frequency.value = 1100;

    // Two slow LFOs on a multiplying node, not on the level itself — swelling a
    // gain of 0 would push it negative, and an empty room has to be silent.
    const wobble = ctx.createGain();
    wobble.gain.value = 1;
    this.wobbler(wobble, 0.19, 0.12);
    this.wobbler(wobble, 0.07, 0.07);

    this.level = ctx.createGain();
    this.level.gain.value = 0;

    this.loc = ctx.createGain();
    this.loc.gain.value = 0;

    this.chain.push(band, lp, wobble, this.level, this.loc);
    this.src.connect(band).connect(lp).connect(wobble).connect(this.level).connect(this.loc);
    this.loc.connect(dest);
    this.src.start();
  }

  setLevel(to: number, durS: number): void {
    ramp(this.ctx, this.level.gain, to, durS);
  }

  setLocation(to: number, durS: number): void {
    ramp(this.ctx, this.loc.gain, to, durS);
  }

  stop(): void {
    stopBed(this.ctx, this.loc, this.src, this.lfos, this.chain);
  }

  private wobbler(target: GainNode, hz: number, depth: number): void {
    const lfo = this.ctx.createOscillator();
    lfo.type = 'sine';
    lfo.frequency.value = hz;
    const amount = this.ctx.createGain();
    amount.gain.value = depth;
    lfo.connect(amount).connect(target.gain);
    lfo.start();
    this.lfos.push(lfo);
    this.chain.push(amount);
  }
}

/**
 * The kebab shop's fluoro tube, heard from the footpath. 100Hz because a tube
 * buzzes at twice mains frequency; the 50Hz underneath is the ballast doing the
 * work. Mono and completely dry — it is six metres away through an open
 * shopfront, and reverb would put it in a cathedral.
 */
class KebabBed {
  private readonly oscs: OscillatorNode[] = [];
  private readonly chain: AudioNode[] = [];
  private readonly level: GainNode;
  private readonly loc: GainNode;
  private readonly ctx: AudioContext;

  constructor(ctx: AudioContext, dest: AudioNode) {
    this.ctx = ctx;

    const sum = ctx.createGain();
    sum.gain.value = 1;

    // Fundamental, mains harmonic, and a thin 200Hz that keeps it from reading
    // as a clean test tone.
    this.partial(100, 1, sum);
    this.partial(50, 0.45, sum);
    this.partial(200, 0.18, sum);

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

    // A dying tube breathes. 0.6Hz at 8% is under conscious notice and is the
    // difference between "hum" and "sine wave someone left running".
    const flicker = ctx.createGain();
    flicker.gain.value = 1;
    const lfo = ctx.createOscillator();
    lfo.type = 'sine';
    lfo.frequency.value = 0.6;
    const depth = ctx.createGain();
    depth.gain.value = 0.08;
    lfo.connect(depth).connect(flicker.gain);
    lfo.start();
    this.oscs.push(lfo);

    this.level = ctx.createGain();
    this.level.gain.value = 0;

    this.loc = ctx.createGain();
    this.loc.gain.value = 0;

    this.chain.push(sum, lp, depth, flicker, this.level, this.loc);
    sum.connect(lp).connect(flicker).connect(this.level).connect(this.loc);
    this.loc.connect(dest);
  }

  setLevel(to: number, durS: number): void {
    ramp(this.ctx, this.level.gain, to, durS);
  }

  setLocation(to: number, durS: number): void {
    ramp(this.ctx, this.loc.gain, to, durS);
  }

  stop(): void {
    stopBed(this.ctx, this.loc, null, this.oscs, this.chain);
  }

  private partial(hz: number, gain: number, sum: GainNode): void {
    const osc = this.ctx.createOscillator();
    osc.type = 'sine';
    osc.frequency.value = hz;
    const g = this.ctx.createGain();
    g.gain.value = gain;
    osc.connect(g).connect(sum);
    osc.start();
    this.oscs.push(osc);
    this.chain.push(g);
  }
}

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

/**
 * Linear throughout, unlike the engine's sweeps: these targets legitimately
 * reach 0 (empty room, bed disabled) and an exponential ramp to zero throws.
 * Re-anchoring at the live value first keeps an interrupted fade continuous —
 * players do walk in and straight back out.
 *
 * Read `param.value` BEFORE cancelling, same as `TechnoEngine.ramp()`.
 * `cancelScheduledValues(now)` removes every event at or after `now`, including
 * the in-flight ramp's end event, and the spec does not say what the getter
 * reads afterwards: Blink returns the last rendered value, but a UA computing it
 * from the surviving timeline returns the ramp's origin. Reading first pins the
 * mid-ramp value the player is actually hearing, so a crowd bed 350ms into a
 * 0.15 -> 1 door->floor swell fades from ~0.57 rather than jumping down to 0.15.
 *
 * `cancelAndHoldAtTime` holds that value at `now` instead of discarding it,
 * which is the same thing done properly — but Firefox has never shipped it, so
 * feature-detect and fall back.
 */
function ramp(ctx: AudioContext, param: AudioParam, to: number, durS: number): void {
  const now = ctx.currentTime;
  const from = param.value;
  if (typeof param.cancelAndHoldAtTime === 'function') param.cancelAndHoldAtTime(now);
  else param.cancelScheduledValues(now);
  param.setValueAtTime(from, now);
  if (durS <= 0) param.setValueAtTime(to, now);
  else param.linearRampToValueAtTime(to, now + durS);
}

/**
 * Fade out, then stop and disconnect everything after the fade lands. Cutting a
 * running noise bed is audible as a click, and leaving the nodes attached means
 * a six-hour night accumulates every bed it ever started.
 */
function stopBed(
  ctx: AudioContext,
  out: GainNode,
  src: AudioBufferSourceNode | null,
  oscs: readonly OscillatorNode[],
  chain: readonly AudioNode[],
): void {
  const fade = 0.25;
  const at = ctx.currentTime + fade;
  ramp(ctx, out.gain, 0, fade);

  const tail = src ?? oscs[0];
  if (tail) {
    tail.onended = () => {
      for (const n of chain) n.disconnect();
      src?.disconnect();
      for (const o of oscs) o.disconnect();
    };
  }
  src?.stop(at);
  for (const o of oscs) o.stop(at);
}

/** Deterministic white noise — house rule forbids Math.random, and a fixed seed
 *  means the room sounds the same on every run. */
function noiseBuffer(ctx: AudioContext): AudioBuffer {
  const len = Math.floor(ctx.sampleRate * NOISE_SECONDS);
  const buf = ctx.createBuffer(1, len, ctx.sampleRate);
  const data = buf.getChannelData(0);
  const rng = new SeededRNG(0x4b3b).stream('crowd-noise');
  for (let i = 0; i < len; i++) data[i] = rng.next() * 2 - 1;
  return buf;
}
