/**
 * THE BAND — the four things living on the dial (round 5 order 6).
 *
 * Schema is LANE-DATA's (`data/stations.json`); this file is pure synthesis. Every station is
 * a continuous voice built once at unlock, whose OUTPUT GAIN is driven by the tuner's lock
 * strength. Stations self-schedule with a lookahead so nothing allocates per frame except
 * genuinely one-shot notes.
 *
 * Each station also decides what THE SCREEN says: when a talking station is locked it writes
 * the subtitle bus, and the sky captions it.
 */
import type { Era } from '../screen/eras';
import { envGain, hash01, noiseSource } from './graph';

export interface StationDef {
  id: string;
  band: 'AM' | 'FM' | 'SW';
  freq: number;
  name: string;
  format?: string;
  flavor?: string;
  music?: {
    bpm: number; scale: string; steps: number; mutateEveryBars: number;
    seed: number; voices: string[]; eraFlavor?: Record<string, string>;
  };
  chyron?: string[];
  numbers?: {
    preamble: string; preambleRepeats: number; groupSize: number; groupsPerMessage: number;
    secondsPerGroup: number; plantedGroups: string[]; plantEveryMessages: number;
    decoyPool: string[];
  };
  ghost?: { everyMinutes: number; reversed: boolean; line: string };
}

export interface LiveStation {
  def: StationDef;
  /** station audio lands here; the tuner drives this node's gain from the lock strength */
  out: GainNode;
  start(t0: number): void;
  /** called every frame. `lock` 0..1 — only a locked station schedules and subtitles. */
  update(now: number, lock: number, era: Era): void;
  /** the line the sky should caption right now, or '' */
  subtitle(): string;
}

const LOOKAHEAD = 0.25; // seconds of scheduling runway

/** Mulberry32 — small, fast, and seedable, so a run's radio is always the same radio. */
function rng(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// ─────────────────────────────────────────────────────────────── 88.1 FKTRY FM

const PENTA_MINOR = [0, 3, 5, 7, 10]; // the scale DATA asked for

/**
 * The factory's own transmitter: a sequencer nobody is operating.
 * Square lead + triangle bass on a seeded 16-step pattern that mutates every 32 bars, so it
 * genuinely never repeats a bar it liked. The era colours the transmission, not the notes —
 * same music, aging equipment.
 */
export function fmMusic(ctx: BaseAudioContext, def: StationDef): LiveStation {
  const m = def.music!;
  const out = ctx.createGain();
  out.gain.value = 0;

  const voiceBus = ctx.createGain();
  voiceBus.gain.value = 0.5;
  // Wow/flutter lives on a delay line: reel-era tape doesn't hold pitch.
  const warble = ctx.createDelay(0.05);
  warble.delayTime.value = 0.004;
  const wowLfo = ctx.createOscillator();
  wowLfo.frequency.value = 0.7;
  const wowDepth = ctx.createGain();
  wowDepth.gain.value = 0; // era sets this
  wowLfo.connect(wowDepth).connect(warble.delayTime);
  voiceBus.connect(warble).connect(out);

  // Era beds: surface crackle (reel) and the 15.7kHz line whine (broadcast).
  const crackle = noiseSource(ctx, 'brown');
  const crackleF = ctx.createBiquadFilter();
  crackleF.type = 'highpass';
  crackleF.frequency.value = 1800;
  const crackleG = ctx.createGain();
  crackleG.gain.value = 0;
  crackle.connect(crackleF).connect(crackleG).connect(out);

  const lineWhine = ctx.createOscillator();
  lineWhine.type = 'sine';
  lineWhine.frequency.value = 15734; // NTSC horizontal line rate, the sound of a warm CRT
  const whineG = ctx.createGain();
  whineG.gain.value = 0;
  lineWhine.connect(whineG).connect(out);

  const spb = 60 / m.bpm / 4; // 16th notes
  let step = 0;
  let bars = 0;
  let nextTime = 0;
  let started = false;
  let pattern: number[] = [];
  let mutations = 0;
  const rand = rng(m.seed);

  function mutate() {
    pattern = Array.from({ length: m.steps }, () => {
      const r = rand();
      if (r < 0.34) return -1; // rest — space is what stops it sounding like a demo
      return PENTA_MINOR[Math.floor(rand() * PENTA_MINOR.length)] + (rand() < 0.25 ? 12 : 0);
    });
    mutations++;
  }
  mutate();

  let eraNow: Era = 'stream';

  function note(semi: number, t: number, isDownbeat: boolean) {
    const root = 110; // A2
    const f = root * Math.pow(2, semi / 12);
    // fortress: wavelet-smooth, no transients. Everything else keeps its edge.
    const attack = eraNow === 'fortress' ? 0.09 : 0.006;
    const decay = eraNow === 'fortress' ? 0.5 : 0.16;

    const lead = ctx.createOscillator();
    lead.type = 'square';
    lead.frequency.value = f * 2;
    const lg = envGain(ctx, t, eraNow === 'stream' ? 0.3 : 0.22, attack, decay);
    const lp = ctx.createBiquadFilter();
    lp.type = 'lowpass';
    lp.frequency.value = eraNow === 'disc' ? 6000 : eraNow === 'reel' ? 2200 : 4000;
    lead.connect(lp).connect(lg).connect(voiceBus);
    lead.start(t);
    lead.stop(t + attack + decay + 0.02);

    if (isDownbeat) {
      const bass = ctx.createOscillator();
      bass.type = 'triangle';
      bass.frequency.value = f / 2;
      const bg = envGain(ctx, t, 0.34, attack, decay * 1.8);
      bass.connect(bg).connect(voiceBus);
      bass.start(t);
      bass.stop(t + attack + decay * 1.8 + 0.02);
    }
  }

  return {
    def,
    out,
    start(t0) {
      wowLfo.start(t0);
      crackle.start(t0);
      lineWhine.start(t0);
      nextTime = t0;
      started = true;
    },
    update(now, lock, era) {
      if (era !== eraNow) {
        eraNow = era;
        // The transmission ages even though the tune doesn't.
        wowDepth.gain.value = era === 'reel' ? 0.0022 : 0;
        crackleG.gain.value = era === 'reel' ? 0.055 : 0;
        whineG.gain.value = era === 'broadcast' ? 0.012 : 0;
      }
      if (!started || lock <= 0.02) {
        // Keep the clock rolling while off-station so the music is a continuous broadcast
        // you tune INTO, not a track that starts when you arrive.
        while (nextTime < now) { nextTime += spb; step = (step + 1) % m.steps; }
        return;
      }
      while (nextTime < now + LOOKAHEAD) {
        const semi = pattern[step];
        if (semi >= 0) note(semi, nextTime, step % 4 === 0);
        nextTime += spb;
        step++;
        if (step >= m.steps) {
          step = 0;
          bars++;
          if (bars % m.mutateEveryBars === 0) mutate();
        }
      }
    },
    subtitle: () => '',
  };
}

// ────────────────────────────────────────────────────────── AM 640 THE STANDARD

/**
 * The Correction, with a transmitter.
 *
 * A ring-modulated mumble: a low carrier multiplied by a formant-swept tone, gated into
 * syllables. It is deliberately just short of intelligible — you can hear that it is speech
 * and not what it says. THE SCREEN tells you what it says. That gap is the whole trick.
 */
export function amPropaganda(ctx: BaseAudioContext, def: StationDef): LiveStation {
  const lines = def.chyron ?? [];
  const out = ctx.createGain();
  out.gain.value = 0;

  // carrier * modulator = ring mod. The modulator is what makes it sound institutional.
  const carrier = ctx.createOscillator();
  carrier.type = 'sawtooth';
  carrier.frequency.value = 118;
  const ring = ctx.createGain();
  ring.gain.value = 0; // driven by the modulator; 0 = silence between syllables
  const modulator = ctx.createOscillator();
  modulator.type = 'sine';
  modulator.frequency.value = 61;
  const modDepth = ctx.createGain();
  modDepth.gain.value = 0.5;
  modulator.connect(modDepth).connect(ring.gain);

  // two formants: a mouth shape that never quite resolves into a vowel
  const f1 = ctx.createBiquadFilter();
  f1.type = 'bandpass';
  f1.frequency.value = 520;
  f1.Q.value = 4;
  const f2 = ctx.createBiquadFilter();
  f2.type = 'bandpass';
  f2.frequency.value = 1180;
  f2.Q.value = 6;
  const syllable = ctx.createGain();
  syllable.gain.value = 0;

  carrier.connect(ring);
  ring.connect(f1).connect(syllable);
  ring.connect(f2).connect(syllable);
  // AM band means AM bandwidth: nothing above ~4.5k survives the transmitter.
  const band = ctx.createBiquadFilter();
  band.type = 'lowpass';
  band.frequency.value = 4500;
  syllable.connect(band).connect(out);

  let lineIdx = 0;
  let nextSyllable = 0;
  let syllablesLeft = 0;
  let lineEndsAt = 0;
  let started = false;
  const rand = rng(6400);

  function beginLine(t: number) {
    lineIdx = (lineIdx + 1) % Math.max(1, lines.length);
    const words = (lines[lineIdx] ?? '').split(/\s+/).length;
    syllablesLeft = Math.max(6, Math.round(words * 1.6));
    lineEndsAt = t + syllablesLeft * 0.17 + 1.4; // + the pause it leaves for effect
    nextSyllable = t + 0.25;
  }

  return {
    def,
    out,
    start(t0) {
      carrier.start(t0);
      modulator.start(t0);
      started = true;
      beginLine(t0);
    },
    update(now, lock) {
      if (!started) return;
      if (now > lineEndsAt) beginLine(now);
      if (lock <= 0.02) return;
      while (nextSyllable < now + LOOKAHEAD && syllablesLeft > 0) {
        const t = nextSyllable;
        const dur = 0.075 + rand() * 0.075;
        // syllable envelope
        syllable.gain.setValueAtTime(0.0001, t);
        syllable.gain.exponentialRampToValueAtTime(0.5, t + 0.02);
        syllable.gain.exponentialRampToValueAtTime(0.0001, t + dur);
        // formant motion per syllable — the mouth moves, the meaning doesn't arrive
        f1.frequency.setValueAtTime(380 + rand() * 320, t);
        f2.frequency.setValueAtTime(950 + rand() * 700, t);
        carrier.frequency.setValueAtTime(104 + rand() * 28, t);
        nextSyllable = t + dur + 0.06 + rand() * 0.05;
        syllablesLeft--;
      }
    },
    // The flagship: the sky captions the radio.
    subtitle: () => lines[lineIdx] ?? '',
  };
}

// ─────────────────────────────────────────────────────────── SW 6955 NUMBERS

/**
 * The unlisted station. Cold formant digits, no affect, no acknowledgement of a listener.
 * Some groups are coordinates (LANE-DATA plants the real ones); the rest are decoys and
 * seeded filler. The chyron shows the digits, so a player with a pencil can map the ground.
 */
export function swNumbers(
  ctx: BaseAudioContext,
  def: StationDef,
  isRealSeam: (x: number, y: number) => boolean,
): LiveStation {
  const n = def.numbers!;
  const out = ctx.createGain();
  out.gain.value = 0;

  // A formant voice: buzz source + three resonances. Vowel-ish, sexless, entirely uninterested.
  const buzz = ctx.createOscillator();
  buzz.type = 'sawtooth';
  buzz.frequency.value = 196;
  const voiceGain = ctx.createGain();
  voiceGain.gain.value = 0;
  const fs = [720, 1240, 2600].map((f) => {
    const b = ctx.createBiquadFilter();
    b.type = 'bandpass';
    b.frequency.value = f;
    b.Q.value = 8;
    return b;
  });
  const sum = ctx.createGain();
  buzz.connect(voiceGain);
  for (const f of fs) voiceGain.connect(f).connect(sum);
  // SW bandwidth is narrow and awful
  const band = ctx.createBiquadFilter();
  band.type = 'bandpass';
  band.frequency.value = 1400;
  band.Q.value = 0.7;
  sum.connect(band).connect(out);

  const rand = rng(69550);
  /** Vowel formants per digit so each numeral has a consistent, learnable shape. */
  const DIGIT_FORMANTS: [number, number, number][] = [
    [520, 1000, 2400], [300, 2200, 2900], [600, 1700, 2500], [500, 1750, 2450],
    [640, 1200, 2300], [430, 2100, 2800], [560, 900, 2350], [400, 1900, 2600],
    [700, 1150, 2400], [350, 2300, 3000],
  ];

  function decodes(group: string): { x: number; y: number } | null {
    if (group.length !== 5) return null;
    const x = Number(group.slice(0, 2)) - 32;
    const y = Number(group.slice(2, 4)) - 32;
    if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
    return { x, y };
  }

  /** Seeded filler that never accidentally gives away real ground. */
  function fillerGroup(): string {
    for (let tries = 0; tries < 24; tries++) {
      const g = Array.from({ length: n.groupSize }, () => Math.floor(rand() * 10)).join('');
      const d = decodes(g);
      if (d && isRealSeam(d.x, d.y)) continue; // never leak a coordinate by accident
      return g;
    }
    return '00000';
  }

  let messages = 0;
  let queue: string[] = [];
  let nextAt = 0;
  let started = false;
  let showing = '';

  function buildMessage() {
    const groups: string[] = [];
    for (let i = 0; i < n.preambleRepeats; i++) groups.push(n.preamble);
    const planted = messages % n.plantEveryMessages === 0;
    for (let i = 0; i < n.groupsPerMessage; i++) {
      if (planted && i === Math.floor(n.groupsPerMessage / 2)) {
        groups.push(n.plantedGroups[(messages / n.plantEveryMessages) % n.plantedGroups.length]);
      } else if (rand() < 0.35) {
        groups.push(n.decoyPool[Math.floor(rand() * n.decoyPool.length)]);
      } else {
        groups.push(fillerGroup());
      }
    }
    messages++;
    queue = groups;
  }

  function speakGroup(group: string, t0: number) {
    const per = n.secondsPerGroup / (n.groupSize + 1);
    for (let i = 0; i < group.length; i++) {
      const d = Number(group[i]);
      const t = t0 + i * per;
      const [a, b, c] = DIGIT_FORMANTS[d] ?? DIGIT_FORMANTS[0];
      fs[0].frequency.setValueAtTime(a, t);
      fs[1].frequency.setValueAtTime(b, t);
      fs[2].frequency.setValueAtTime(c, t);
      buzz.frequency.setValueAtTime(190 + d * 2, t); // barely any melody: no affect
      voiceGain.gain.setValueAtTime(0.0001, t);
      voiceGain.gain.exponentialRampToValueAtTime(0.34, t + 0.03);
      voiceGain.gain.setValueAtTime(0.34, t + per * 0.6);
      voiceGain.gain.exponentialRampToValueAtTime(0.0001, t + per * 0.92);
    }
  }

  return {
    def,
    out,
    start(t0) {
      buzz.start(t0);
      started = true;
      nextAt = t0 + 0.5;
      buildMessage();
    },
    update(now, lock) {
      if (!started) return;
      while (nextAt < now + LOOKAHEAD) {
        if (!queue.length) buildMessage();
        const g = queue.shift()!;
        showing = g;
        if (lock > 0.02) speakGroup(g, nextAt);
        nextAt += n.secondsPerGroup;
      }
    },
    // Digits on the sky — the treasure map, for anyone bothering to write them down.
    subtitle: () => (showing ? showing.split('').join(' ') : ''),
  };
}

// ──────────────────────────────────────────────────────────── 108.0 DEAD AIR

/**
 * The top of the dial. Room tone from a room that should not have any, breathing at the pace
 * of a projector, and once in a very long while a chime played backwards.
 */
export function deadAir(ctx: BaseAudioContext, def: StationDef): LiveStation {
  const out = ctx.createGain();
  out.gain.value = 0;

  const tone = noiseSource(ctx, 'brown');
  const lp = ctx.createBiquadFilter();
  lp.type = 'lowpass';
  lp.frequency.value = 420;
  const toneG = ctx.createGain();
  // Deliberately well under a station: this is a room you strain to hear, not a channel.
  // (Measured at 0.32 it rendered LOUDER than FKTRY FM, which made "dead air" the loudest
  // thing on the dial — exactly backwards.)
  toneG.gain.value = 0.1;
  tone.connect(lp).connect(toneG).connect(out);

  // the breathing: a slow swell at roughly a projector's pace
  const breath = ctx.createOscillator();
  breath.type = 'sine';
  breath.frequency.value = 0.21;
  const breathDepth = ctx.createGain();
  breathDepth.gain.value = 0.05;
  breath.connect(breathDepth).connect(toneG.gain);

  let started = false;
  let nextGhost = 0;
  const ghostEvery = (def.ghost?.everyMinutes ?? 10) * 60;

  /** A reversed chime: swell up out of nothing, then stop dead. Wrong-way-round on purpose. */
  function ghost(t0: number) {
    const dur = 2.2;
    const g = ctx.createGain();
    g.gain.setValueAtTime(0.0001, t0);
    g.gain.exponentialRampToValueAtTime(0.28, t0 + dur); // the swell
    g.gain.setValueAtTime(0.0001, t0 + dur + 0.01); // the cut
    g.connect(out);
    [523.25, 783.99, 1046.5].forEach((f, i) => {
      const o = ctx.createOscillator();
      o.type = 'sine';
      o.frequency.value = f * (1 + i * 0.001);
      o.connect(g);
      o.start(t0);
      o.stop(t0 + dur + 0.05);
    });
  }

  return {
    def,
    out,
    start(t0) {
      tone.start(t0);
      breath.start(t0);
      started = true;
      // seeded, so the ghost always arrives at the same moment in a given run
      nextGhost = t0 + ghostEvery * (0.35 + hash01(1080) * 0.4);
    },
    update(now, lock) {
      if (!started || lock <= 0.02) return;
      if (now >= nextGhost) {
        ghost(now + 0.05);
        nextGhost = now + ghostEvery;
      }
    },
    subtitle: () => '',
  };
}
