import type { EventBus } from '../core/EventBus';
import { SeededRNG } from '../core/SeededRNG';
import { type ArcScales, arcAt } from './arc';
import {
  type AudioLocation,
  cutoffFor,
  gainFor,
  sweepMsFor,
} from './filterCurve';
import {
  DEFAULT_MIX,
  MIX_RANGES,
  type TrackMix,
  VOICE_NAMES,
  type VoiceName,
  clampTo,
  cloneMix,
  voiceGain,
} from './mix';
import {
  type BeatGrid,
  barOf,
  beatIntervalMs,
  beatTimeMs,
  dueBeats,
  isDownbeat,
  patternStep,
  subdivisions,
} from './scheduling';

const DEFAULT_BPM = 128;
const DEFAULT_LOOKAHEAD_MS = 100;
const TIMER_MS = 25;

/**
 * Beat 0 sits this far past the resume instant. Scheduling into the past is not
 * an error — the node just fires immediately — but then the beat *sounds* later
 * than the `audioTimeMs` we promised for it. The toilet rhythm game scores
 * against that number, so the first beat has to be as honest as the rest.
 */
const START_LEAD_MS = 60;

const SILENT = 0.0001; // exponential ramps cannot reach 0

const BASS_STEPS = 32; // 2 bars of 16ths

/** Seconds to the top of the bass filter sweep. Shape, not level — not tunable. */
const BASS_SWEEP_S = 0.03;

/**
 * The bass tail settles a touch above the sweep floor: `filterLoHz` is where the
 * note *starts*, and resting exactly there makes the release sound like a gate.
 * Expressed as a ratio so retuning the floor carries the rest point with it.
 */
const BASS_TAIL_RATIO = 200 / 180;

/** Fraction of the pad cycle spent fading in; the rest fades out. */
const PAD_RISE = 0.4;

/** A slider drag on `master` should not zipper, but should not lag either. */
const KNOB_RAMP_S = 0.03;

/** Mix paths the engine rounds at use-time, so they must be stored rounded too. */
const INTEGER_PATHS = new Set(['pad.bars']);

/**
 * Semitones from the bass root, one entry per 16th. Nothing lands on a downbeat
 * — the kick owns that slot, and leaving it empty is what makes the bassline
 * roll instead of thud.
 */
const BASS_PATTERN: readonly (number | null)[] = [
  null, null, 0, 0,
  null, 0, null, 0,
  null, null, 3, 3,
  null, 0, null, 12,
  null, null, 0, 0,
  null, 0, null, 0,
  null, null, 5, 3,
  null, 0, 2, null,
];

/** A minor, voiced wide and low. Detuning the pair is the whole character. */
const PAD_VOICES: ReadonlyArray<{ hz: number; detune: number; type: OscillatorType }> = [
  { hz: 110, detune: -7, type: 'sawtooth' },
  { hz: 110, detune: 7, type: 'sawtooth' },
  { hz: 164.81, detune: 0, type: 'triangle' },
];

/** Widened once so a dotted path can be looked up without a cast at each site. */
const RANGES: Record<string, readonly [number, number] | undefined> = MIX_RANGES;

const GROUP_NAMES: readonly VoiceName[] = VOICE_NAMES;

interface MixLens {
  read: () => number;
  write: (v: number) => void;
  range: readonly [number, number];
}

/**
 * The engine currently owning the AudioContext, or null.
 *
 * The tuning desk has to reach the engine during a real night, when the scene
 * that constructed it belongs to another lane and there is no route to it. A
 * module variable is the smallest thing that solves that — deliberately not a
 * window global, which would make it part of the page's public surface.
 */
let activeEngine: TechnoEngine | null = null;

export const getActiveEngine = (): TechnoEngine | null => activeEngine;

export interface TechnoEngineOptions {
  bpm?: number;
  lookaheadMs?: number;
}

/**
 * The looping techno track and the beat authority in one. Replaces
 * core/StubBeatClock — same `beat:tick` contract, but the beats now come off the
 * AudioContext clock instead of a frame counter, so they are actually true.
 *
 * Everything is synthesized; there are no samples to load and nothing to
 * preload. All scheduling happens on a 25ms timer looking `lookaheadMs` into the
 * future. That timer is throttled on a blurred tab exactly as rAF is, so the
 * look-ahead alone does not save us; what does is `resync()`, which drops the
 * span that elapsed while we were asleep instead of dumping it into the graph.
 *
 * Every level and shape comes off a live `TrackMix` scaled by the night arc, so
 * the track can be retuned and auditioned mid-playback. Nothing is read at
 * construction that is not re-read on the next beat.
 */
export class TechnoEngine {
  readonly ctx: AudioContext;
  readonly master: GainNode;
  readonly musicBus: GainNode;
  readonly sfxBus: GainNode;
  readonly bpm: number;

  private readonly filter: BiquadFilterNode;
  private readonly noiseBuffer: AudioBuffer;
  private readonly lookaheadMs: number;
  private readonly grid: BeatGrid;
  private readonly unsubs: Array<() => void> = [];
  /**
   * Long-lived handles so stop() can silence what is already on the schedule.
   * Keyed by source, valued by the rest of that voice's chain, because stop()
   * pre-empts `onended` and would otherwise strand those nodes on the bus.
   */
  private readonly voices = new Map<AudioScheduledSourceNode, AudioNode[]>();

  private mixState: TrackMix = cloneMix(DEFAULT_MIX);
  private clockMinute = 0;
  private pinnedMinute: number | null = null;
  /** Cached so the scheduler is not re-interpolating the arc ~20 times a second. */
  private scales: ArcScales = arcAt(0);

  private timer: ReturnType<typeof setInterval> | null = null;
  private nextBeat = 0;
  private loc: AudioLocation = 'door';
  private override: number | null = null;
  private unlockedFlag = false;
  private unlocking: Promise<void> | null = null;

  constructor(
    private readonly bus: EventBus,
    opts: TechnoEngineOptions = {},
  ) {
    this.bpm = opts.bpm ?? DEFAULT_BPM;
    this.lookaheadMs = opts.lookaheadMs ?? DEFAULT_LOOKAHEAD_MS;
    this.ctx = new AudioContext();
    this.grid = { bpm: this.bpm, originMs: 0 };

    this.master = this.ctx.createGain();
    this.master.gain.value = this.mixState.master;
    this.master.connect(this.ctx.destination);

    this.filter = this.ctx.createBiquadFilter();
    this.filter.type = 'lowpass';
    this.filter.frequency.value = cutoffFor(this.loc);
    this.filter.Q.value = this.qFor(this.loc);
    this.filter.connect(this.master);

    this.musicBus = this.ctx.createGain();
    this.musicBus.gain.value = gainFor(this.loc);
    this.musicBus.connect(this.filter);

    // Deliberately NOT through the filter: a stamp must sound like a stamp even
    // when the music is buried behind a door.
    this.sfxBus = this.ctx.createGain();
    this.sfxBus.connect(this.master);

    this.noiseBuffer = this.buildNoise();

    this.unsubs.push(
      this.bus.on('audio:location', ({ location }) => this.setLocation(location)),
      this.bus.on('clock:tick', ({ clockMin }) => {
        this.clockMinute = clockMin;
        if (this.pinnedMinute === null) this.scales = arcAt(clockMin);
      }),
    );

    // eslint-disable-next-line @typescript-eslint/no-this-alias -- the registry is the point
    activeEngine = this;
  }

  get running(): boolean {
    return this.timer !== null;
  }

  get unlocked(): boolean {
    return this.unlockedFlag;
  }

  get cutoffHz(): number {
    return this.filter.frequency.value;
  }

  get location(): AudioLocation {
    return this.loc;
  }

  // --- mix ------------------------------------------------------------------

  get mix(): Readonly<TrackMix> {
    return this.mixState;
  }

  /**
   * Set one knob by dotted path: `'master'`, `'levels.<voice>'`,
   * `'<voice>.<field>'`, `'doorQ'`, `'floorQ'`.
   *
   * An unknown path is inert rather than fatal — the tuning desk is generated
   * from a table, and a typo there should cost one dead slider, not the night.
   */
  setMixValue(path: string, value: number): void {
    const lens = this.lens(path);
    if (!lens) return;
    // Quantise here rather than at the slider: what we store is what formatMix
    // dumps, so a fractional bar count would export a value nobody ever heard.
    // The engine owns the invariant because it is the thing that rounds at use.
    const v = clampTo(INTEGER_PATHS.has(path) ? Math.round(value) : value, lens.range);
    lens.write(v);

    // Levels and shapes land on the next scheduled note (<500ms, inaudible as
    // lag). These two are continuous, so they have to move now.
    if (path === 'master') this.ramp(this.master.gain, v, KNOB_RAMP_S, false);
    if (path === this.loc + 'Q' && this.override === null) {
      this.ramp(this.filter.Q, v, KNOB_RAMP_S, false);
    }
  }

  /** NaN for an unknown path — a wrong-but-plausible 0 would read as a real value. */
  getMixValue(path: string): number {
    return this.lens(path)?.read() ?? Number.NaN;
  }

  setVoiceEnabled(voice: VoiceName, on: boolean): void {
    this.mixState.enabled[voice] = on;
  }

  resetMix(): void {
    this.mixState = cloneMix(DEFAULT_MIX);
    this.ramp(this.master.gain, this.mixState.master, KNOB_RAMP_S, false);
    if (this.override === null) {
      this.ramp(this.filter.Q, this.qFor(this.loc), KNOB_RAMP_S, false);
    }
  }

  // --- night arc ------------------------------------------------------------

  /**
   * Pin the arc to a minute for the ear pass — this is how 1 AM gets auditioned
   * at 9 PM. null hands the arc back to `clock:tick`.
   */
  setArcMinute(min: number | null): void {
    this.pinnedMinute = min;
    this.scales = arcAt(this.arcMinute);
  }

  get arcMinute(): number {
    return this.pinnedMinute ?? this.clockMinute;
  }

  get arcScales(): ArcScales {
    return { ...this.scales };
  }

  get arcPinned(): boolean {
    return this.pinnedMinute !== null;
  }

  /**
   * Resume after a real user gesture. Idempotent, and safe to call concurrently
   * — overlapping callers await the same resume rather than racing the origin.
   */
  async unlock(): Promise<void> {
    if (this.unlockedFlag) return;
    if (this.unlocking) return this.unlocking;

    this.unlocking = (async () => {
      if (this.ctx.state === 'suspended') await this.ctx.resume();
      const atMs = this.ctx.currentTime * 1000;
      // Re-origin the grid: beat 0 is now, not whenever this object was built.
      this.grid.originMs = atMs + START_LEAD_MS;
      this.nextBeat = 0;
      this.unlockedFlag = true;
      // Review ruling: atMs carries the ANCHORED beat-0 origin, not the resume
      // instant — consumers may treat beat 0 as landing exactly at atMs.
      this.bus.emit('audio:unlocked', { atMs: this.grid.originMs });
    })();

    try {
      await this.unlocking;
    } finally {
      this.unlocking = null;
    }
  }

  /**
   * Start the scheduler. Starting before `unlock()` is legal but silent: the
   * timer spins and emits nothing until the gesture lands. Callers therefore
   * never have to sequence start/unlock.
   *
   * Restarting after a stop needs no re-anchoring here: nothing is scheduled
   * outside `tick()`, and its first wake resyncs past the gap.
   */
  start(): void {
    if (this.timer !== null) return;
    this.timer = setInterval(() => this.tick(), TIMER_MS);
  }

  stop(): void {
    if (this.timer !== null) {
      clearInterval(this.timer);
      this.timer = null;
    }
    for (const [src, chain] of [...this.voices]) {
      src.onended = null;
      try {
        src.stop();
      } catch {
        /* already finished — nothing to silence */
      }
      this.release(src, chain);
    }
    this.voices.clear();
  }

  /** Ramp to the location's cutoff/gain. A pinned override wins over both. */
  setLocation(location: AudioLocation, immediate = false): void {
    this.loc = location;
    if (this.override !== null) return;

    const durS = immediate ? 0 : sweepMsFor(location) / 1000;
    this.ramp(this.filter.frequency, cutoffFor(location), durS, true);
    this.ramp(this.musicBus.gain, gainFor(location), durS, false);
    this.ramp(this.filter.Q, this.qFor(location), durS, false);
  }

  /** Non-null pins the cutoff and suppresses location sweeps; null hands it back. */
  setCutoffOverride(hz: number | null): void {
    this.override = hz;
    if (hz === null) {
      this.setLocation(this.loc);
      return;
    }
    this.ramp(this.filter.frequency, this.clampHz(hz), 0, true);
  }

  destroy(): void {
    this.stop();
    for (const off of this.unsubs) off();
    this.unsubs.length = 0;
    if (activeEngine === this) activeEngine = null;
    void this.ctx.close().catch(() => undefined);
  }

  // --- scheduling -----------------------------------------------------------

  private tick(): void {
    if (!this.unlockedFlag) return;

    const nowMs = this.ctx.currentTime * 1000;
    this.resync(nowMs);
    const due = dueBeats(this.grid, this.nextBeat, nowMs, this.lookaheadMs);
    if (due.length === 0) return;

    for (const { beatIndex, timeMs } of due) {
      this.scheduleBeat(beatIndex, timeMs);
      // The SCHEDULED time, not the current one. Consumers time their input
      // against this, so a convenient lie here is a broken rhythm game.
      this.bus.emit('beat:tick', { beatIndex, audioTimeMs: timeMs });
      if (isDownbeat(beatIndex)) {
        this.bus.emit('beat:bar', { barIndex: barOf(beatIndex), beatIndex, audioTimeMs: timeMs });
      }
    }

    const last = due[due.length - 1];
    if (last) this.nextBeat = last.beatIndex + 1;
  }

  /**
   * Skip a stale span rather than catch up on it. A throttled timer (hidden tab)
   * or a long stop/start gap leaves the cursor minutes behind the audio clock;
   * playing those beats would start every voice on the same sample — a clipped
   * blast, not music — and `beat:tick` would hand the rhythm game timestamps it
   * could only score as misses. The grace is one beat: ordinary wakeup jitter
   * must not trigger a jump.
   *
   * Landing on a downbeat rather than the nearest beat keeps bar phase, so the
   * resync reads as a cut instead of the track lurching sideways.
   */
  private resync(nowMs: number): void {
    const interval = beatIntervalMs(this.bpm);
    if (beatTimeMs(this.grid, this.nextBeat) >= nowMs - interval) return;
    const firstFuture = Math.ceil((nowMs - this.grid.originMs) / interval);
    this.nextBeat = Math.ceil(firstFuture / 4) * 4;
  }

  /**
   * Effective gain for a voice right now: level, mute and arc folded together.
   * Shared with the tuning desk via mix.ts so the number on screen is the number
   * the scheduler uses.
   */
  private gainOf(voice: VoiceName): number {
    return voiceGain(this.mixState, voice, this.scales[voice]);
  }

  private scheduleBeat(beatIndex: number, timeMs: number): void {
    const sixteenths = subdivisions(this.grid, beatIndex, 4);
    const mix = this.mixState;

    // Silent voices are skipped, not scheduled quietly: an exponential ramp to
    // zero throws, and at 9 PM the arc mutes the bass outright — no reason to
    // build and tear down eight nodes a second for something nobody can hear.
    const kickGain = this.gainOf('kick');
    if (kickGain > SILENT) this.kick(timeMs / 1000, kickGain);

    const hatGain = this.gainOf('hat');
    const offbeat = sixteenths[2]; // the 8th between beats
    if (hatGain > SILENT && offbeat !== undefined) this.hat(offbeat / 1000, hatGain);

    const bassGain = this.gainOf('bass');
    if (bassGain > SILENT) {
      for (let s = 0; s < sixteenths.length; s++) {
        const at = sixteenths[s];
        const semi = BASS_PATTERN[patternStep(beatIndex, s, BASS_STEPS)];
        if (at === undefined || semi === undefined || semi === null) continue;
        this.bass(at / 1000, mix.bass.rootHz * Math.pow(2, semi / 12), bassGain);
      }
    }

    const padGain = this.gainOf('pad');
    const padBars = this.padBars();
    if (padGain > SILENT && isDownbeat(beatIndex) && barOf(beatIndex) % padBars === 0) {
      this.pad(timeMs / 1000, padGain, padBars);
    }
  }

  /** The pad's cycle length doubles as its retrigger interval, so it must be a whole bar count. */
  private padBars(): number {
    return Math.max(1, Math.round(this.mixState.pad.bars));
  }

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

  private kick(at: number, peak: number): void {
    const k = this.mixState.kick;
    const osc = this.ctx.createOscillator();
    const gain = this.ctx.createGain();
    osc.type = 'sine';

    // The pitch drop is the beater; the tail is the cone. This is the one voice
    // that has to survive a 250Hz lowpass, so it gets a long body.
    osc.frequency.setValueAtTime(k.startHz, at);
    osc.frequency.exponentialRampToValueAtTime(k.endHz, at + k.dropS);

    // A decay tuned shorter than the attack would schedule the ramps out of
    // order; the desk allows that combination, WebAudio does not.
    const decayS = Math.max(k.attackS + 0.005, k.decayS);
    gain.gain.setValueAtTime(SILENT, at);
    gain.gain.exponentialRampToValueAtTime(peak, at + k.attackS);
    gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS);

    osc.connect(gain).connect(this.musicBus);
    osc.start(at);
    osc.stop(at + decayS + 0.02);
    this.hold(osc, [gain]);
  }

  private hat(at: number, peak: number): void {
    const h = this.mixState.hat;
    const src = this.ctx.createBufferSource();
    src.buffer = this.noiseBuffer;

    const hp = this.ctx.createBiquadFilter();
    hp.type = 'highpass';
    hp.frequency.value = h.hpHz;

    const gain = this.ctx.createGain();
    gain.gain.setValueAtTime(peak, at);
    gain.gain.exponentialRampToValueAtTime(SILENT, at + h.decayS);

    src.connect(hp).connect(gain).connect(this.musicBus);
    // Read far enough to cover the envelope; a longer decay must not be cut off
    // by the buffer running out under it.
    const readS = Math.min(Math.max(0.06, h.decayS + 0.02), this.noiseBuffer.duration);
    // Cheap variety: start the read head somewhere different each hit so the one
    // shared buffer doesn't ring identically 400 times a minute. The span is the
    // buffer minus the read length, so offset + readS lands inside the buffer for
    // any tuning of hat.decayS; a read as long as the buffer leaves no span.
    const span = this.noiseBuffer.duration - readS;
    const offset = span > 0 ? (at * 7.3) % span : 0;
    src.start(at, offset, readS);
    this.hold(src, [hp, gain]);
  }

  private bass(at: number, hz: number, peak: number): void {
    const b = this.mixState.bass;
    const lp = this.ctx.createBiquadFilter();
    lp.type = 'lowpass';
    lp.Q.value = b.q;

    const decayS = Math.max(BASS_SWEEP_S + 0.02, b.decayS);
    // Per-note filter sweep — the "wow" that stops 16ths sounding like a fax.
    lp.frequency.setValueAtTime(b.filterLoHz, at);
    lp.frequency.exponentialRampToValueAtTime(b.filterHiHz, at + BASS_SWEEP_S);
    lp.frequency.exponentialRampToValueAtTime(b.filterLoHz * BASS_TAIL_RATIO, at + decayS - 0.01);

    const gain = this.ctx.createGain();
    gain.gain.setValueAtTime(SILENT, at);
    gain.gain.exponentialRampToValueAtTime(peak, at + 0.006);
    gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS);

    lp.connect(gain).connect(this.musicBus);

    const spread = [-b.detuneCents, b.detuneCents];
    spread.forEach((detune, i) => {
      const osc = this.ctx.createOscillator();
      osc.type = 'sawtooth';
      osc.frequency.value = hz;
      osc.detune.value = detune;
      osc.connect(lp);
      osc.start(at);
      osc.stop(at + decayS + 0.01);
      // Hang the shared chain off the LAST saw by index, not by sign: at zero
      // detune both are 0 and a sign test would strand lp/gain on the bus.
      this.hold(osc, i === spread.length - 1 ? [lp, gain] : []);
    });
  }

  private pad(at: number, peak: number, bars: number): void {
    const cycle = (bars * 4 * beatIntervalMs(this.bpm)) / 1000;

    const lp = this.ctx.createBiquadFilter();
    lp.type = 'lowpass';
    lp.frequency.value = this.mixState.pad.lpHz;

    const gain = this.ctx.createGain();
    gain.gain.setValueAtTime(0, at);
    gain.gain.linearRampToValueAtTime(peak, at + cycle * PAD_RISE);
    gain.gain.linearRampToValueAtTime(0, at + cycle);

    lp.connect(gain).connect(this.musicBus);

    PAD_VOICES.forEach((v, i) => {
      const osc = this.ctx.createOscillator();
      osc.type = v.type;
      osc.frequency.value = v.hz;
      osc.detune.value = v.detune;
      osc.connect(lp);
      osc.start(at);
      osc.stop(at + cycle);
      this.hold(osc, i === PAD_VOICES.length - 1 ? [lp, gain] : []);
    });
  }

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

  private qFor(location: AudioLocation): number {
    return location === 'floor' ? this.mixState.floorQ : this.mixState.doorQ;
  }

  /** Resolve a dotted mix path to a get/set/clamp triple, or null if it is not a knob. */
  private lens(path: string): MixLens | null {
    const m = this.mixState;

    if (path === 'master' || path === 'doorQ' || path === 'floorQ') {
      const range = RANGES[path];
      if (!range) return null;
      const key = path;
      return { read: () => m[key], write: (v) => { m[key] = v; }, range };
    }

    const dot = path.indexOf('.');
    if (dot <= 0) return null;
    const head = path.slice(0, dot);
    const tail = path.slice(dot + 1);

    if (head === 'levels') {
      // MIX_RANGES keys the shared bound as 'level' (singular) — one range for
      // all four faders — while the path is per-voice.
      if (!(VOICE_NAMES as readonly string[]).includes(tail)) return null;
      const v = tail as VoiceName;
      return { read: () => m.levels[v], write: (x) => { m.levels[v] = x; }, range: MIX_RANGES.level };
    }

    if (!(GROUP_NAMES as readonly string[]).includes(head)) return null;
    // The four param groups have no index signature by design — the widening is
    // confined here, behind the hasOwn check below.
    const group = m[head as VoiceName] as unknown as Record<string, number>;
    if (!Object.hasOwn(group, tail)) return null;
    const range = RANGES[path];
    if (!range) return null;
    return { read: () => group[tail] ?? Number.NaN, write: (x) => { group[tail] = x; }, range };
  }

  /**
   * One second of deterministic white noise, built once and re-read per hat.
   * Allocating a buffer per hit would mean ~8 buffers a second for six hours.
   */
  private buildNoise(): AudioBuffer {
    const len = Math.floor(this.ctx.sampleRate);
    const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
    const data = buf.getChannelData(0);
    const rng = new SeededRNG(0x1a7).stream('hat-noise');
    for (let i = 0; i < len; i++) data[i] = rng.next() * 2 - 1;
    return buf;
  }

  /** Keep the node reachable for stop(); let it free itself if it runs out. */
  private hold(src: AudioScheduledSourceNode, chain: AudioNode[]): void {
    this.voices.set(src, chain);
    src.onended = () => this.release(src, chain);
  }

  private release(src: AudioScheduledSourceNode, chain: AudioNode[]): void {
    this.voices.delete(src);
    src.disconnect();
    for (const n of chain) n.disconnect();
  }

  /**
   * Re-anchor at the live value before ramping so a sweep interrupted mid-flight
   * (door -> floor -> door in two seconds, which players absolutely do) continues
   * from where it actually is instead of snapping.
   *
   * Exponential ramps trace exactly `cutoffAt()`'s curve — v0*(v1/v0)^t — so we
   * get cutoffCurve()'s shape without setValueCurveAtTime's rule that a new curve
   * may not overlap a running one. That overlap throws, and re-entrant location
   * changes are the normal case here, not the edge case.
   */
  private ramp(param: AudioParam, to: number, durS: number, exponential: boolean): void {
    const now = this.ctx.currentTime;
    const from = param.value;
    param.cancelScheduledValues(now);
    param.setValueAtTime(from, now);
    if (durS <= 0) {
      param.setValueAtTime(to, now);
    } else if (exponential && from > 0 && to > 0) {
      param.exponentialRampToValueAtTime(to, now + durS);
    } else {
      param.linearRampToValueAtTime(to, now + durS);
    }
  }

  private clampHz(hz: number): number {
    return Math.max(20, Math.min(hz, this.ctx.sampleRate / 2));
  }
}
