/**
 * The tuner (round 5 order 6).
 *
 * A real dial: you sweep through noise and stations swim up out of it. Lock is a gaussian on
 * the distance between the dial and each carrier, so stations fade in and out rather than
 * snapping — hunting for a station is the point, not an obstacle.
 *
 * Inter-station noise is BAND-APPROPRIATE, because that's what makes each band feel like a
 * different physical place:
 *   AM — brown crackle. Heavy, atmospheric, full of distant weather.
 *   FM — white hiss. Bright, flat, modern, boring in a clean way.
 *   SW — warble and heterodyne whistles that beat against nearby carriers. Alive and hostile.
 */
import type { Era } from '../screen/eras';
import { glide, noiseSource } from './graph';
import { setSubtitle } from './subtitleBus';
import {
  amPropaganda, deadAir, fmMusic, swNumbers, type LiveStation, type StationDef,
} from './stations';

export type Band = 'AM' | 'FM' | 'SW';

/** Dial travel per band, in that band's own units (MHz for FM, kHz for AM/SW). */
export const BAND_RANGE: Record<Band, [number, number]> = {
  AM: [520, 1710],
  FM: [87.5, 108.0],
  SW: [5900, 7400],
};

/**
 * How wide a station's capture is, in band units. Tuned so a station occupies a comfortable
 * fraction of the dial: wide enough to find by hand, narrow enough that finding it feels like
 * something. SW is deliberately the fiddliest.
 */
const LOCK_WIDTH: Record<Band, number> = { AM: 9, FM: 0.32, SW: 6 };

export interface RadioState {
  band: Band;
  frequency: number;
  volume: number;
  muted: boolean;
  /** id of the station currently locked, or null when you're between stations */
  locked: string | null;
  /** 0..1 strength of that lock */
  lock: number;
  unlocked: boolean;
}

/** The narrow control surface LANE-UI's tuner dock drives. */
export interface Radio {
  setBand(b: Band): void;
  setFrequency(f: number): void;
  setVolume(v: number): void;
  setMuted(m: boolean): void;
  getState(): RadioState;
  subscribe(fn: (s: RadioState) => void): () => void;
  /** every station on the dial, for the UI to draw tick marks */
  listStations(): { id: string; name: string; band: Band; freq: number }[];
}

export interface RadioEngine extends Radio {
  /** open THE BAND — called once the optical fossil is found */
  unlock(t0: number): void;
  isUnlocked(): boolean;
  update(now: number, era: Era): void;
  /** master output for the whole radio, so ambience can duck under it */
  out: GainNode;
}

const gaussian = (d: number, w: number) => Math.exp(-(d * d) / (2 * w * w));

export function createRadio(
  ctx: BaseAudioContext,
  defs: StationDef[],
  isRealSeam: (x: number, y: number) => boolean,
): RadioEngine {
  const out = ctx.createGain();
  out.gain.value = 0; // silent until the fossil is dug up

  const stations: LiveStation[] = defs.map((d) => {
    if (d.music) return fmMusic(ctx, d);
    if (d.chyron) return amPropaganda(ctx, d);
    if (d.numbers) return swNumbers(ctx, d, isRealSeam);
    return deadAir(ctx, d);
  });
  for (const s of stations) s.out.connect(out);

  // ---- the three inter-station noise beds. All three exist; only the current band is audible.
  const hiss = noiseSource(ctx, 'white'); // FM
  const hissF = ctx.createBiquadFilter();
  hissF.type = 'highpass';
  hissF.frequency.value = 1200;
  const hissG = ctx.createGain();
  hissG.gain.value = 0;
  hiss.connect(hissF).connect(hissG).connect(out);

  const crackle = noiseSource(ctx, 'brown'); // AM
  const crackleF = ctx.createBiquadFilter();
  crackleF.type = 'lowpass';
  crackleF.frequency.value = 3200;
  const crackleG = ctx.createGain();
  crackleG.gain.value = 0;
  crackle.connect(crackleF).connect(crackleG).connect(out);

  const warble = noiseSource(ctx, 'white'); // SW bed
  const warbleF = ctx.createBiquadFilter();
  warbleF.type = 'bandpass';
  warbleF.frequency.value = 1500;
  warbleF.Q.value = 1.2;
  const warbleG = ctx.createGain();
  warbleG.gain.value = 0;
  // the bandpass centre wanders — that slow swimming quality of a shortwave band at night
  const warbleLfo = ctx.createOscillator();
  warbleLfo.frequency.value = 0.13;
  const warbleLfoDepth = ctx.createGain();
  warbleLfoDepth.gain.value = 600;
  warbleLfo.connect(warbleLfoDepth).connect(warbleF.frequency);
  warble.connect(warbleF).connect(warbleG).connect(out);

  // Heterodyne: the whistle you get when your dial is NEAR a carrier but not on it. Pitch is
  // the beat frequency, so it swoops down to nothing as you zero in — the sound of hunting.
  const het = ctx.createOscillator();
  het.type = 'sine';
  het.frequency.value = 1000;
  const hetG = ctx.createGain();
  hetG.gain.value = 0;
  het.connect(hetG).connect(out);

  let band: Band = 'FM';
  let frequency = 88.1;
  let volume = 0.7;
  let muted = false;
  let unlocked = false;
  let started = false;
  let locked: string | null = null;
  let lockStrength = 0;
  const subs = new Set<(s: RadioState) => void>();

  function state(): RadioState {
    return { band, frequency, volume, muted, locked, lock: +lockStrength.toFixed(3), unlocked };
  }
  function publish() {
    const s = state();
    for (const fn of subs) fn(s);
  }

  function applyMaster() {
    glide(out.gain, unlocked && !muted ? volume : 0, ctx, 0.08);
  }

  return {
    out,

    unlock(t0: number) {
      if (unlocked) return;
      unlocked = true;
      if (!started) {
        started = true;
        for (const s of stations) s.start(t0);
        hiss.start(t0);
        crackle.start(t0);
        warble.start(t0);
        warbleLfo.start(t0);
        het.start(t0);
      }
      applyMaster();
      publish();
    },
    isUnlocked: () => unlocked,

    setBand(b) {
      if (b === band) return;
      band = b;
      // land somewhere sensible in the new band rather than an out-of-range number
      const [lo, hi] = BAND_RANGE[b];
      frequency = Math.min(hi, Math.max(lo, frequency));
      const near = stations.find((s) => s.def.band === b);
      if (near && (frequency < lo || frequency > hi)) frequency = near.def.freq;
      publish();
    },
    setFrequency(f) {
      const [lo, hi] = BAND_RANGE[band];
      frequency = Math.min(hi, Math.max(lo, f));
      publish();
    },
    setVolume(v) {
      volume = Math.min(1, Math.max(0, v));
      applyMaster();
      publish();
    },
    setMuted(m) {
      muted = m;
      applyMaster();
      publish();
    },
    getState: state,
    subscribe(fn) {
      subs.add(fn);
      fn(state());
      return () => subs.delete(fn);
    },
    listStations: () =>
      stations.map((s) => ({ id: s.def.id, name: s.def.name, band: s.def.band, freq: s.def.freq })),

    update(now, era) {
      if (!unlocked) return;

      const w = LOCK_WIDTH[band];
      let best: LiveStation | null = null;
      let bestLock = 0;
      let nearestDist = Infinity;

      for (const s of stations) {
        const onBand = s.def.band === band;
        const d = Math.abs(s.def.freq - frequency);
        const lock = onBand ? gaussian(d, w) : 0;
        if (onBand && d < nearestDist) nearestDist = d;
        // Below the audible floor a station is genuinely off — no CPU spent scheduling it.
        glide(s.out.gain, lock < 0.02 ? 0 : lock, ctx, 0.06);
        s.update(now, lock, era);
        if (lock > bestLock) { bestLock = lock; best = s; }
      }

      const changed = (best?.def.id ?? null) !== locked;
      locked = best && bestLock > 0.02 ? best.def.id : null;
      lockStrength = bestLock;

      // Static fills whatever the stations leave behind.
      const staticAmt = 1 - Math.min(1, bestLock);
      glide(hissG.gain, band === 'FM' ? 0.05 * staticAmt : 0, ctx, 0.1);
      glide(crackleG.gain, band === 'AM' ? 0.11 * staticAmt : 0, ctx, 0.1);
      glide(warbleG.gain, band === 'SW' ? 0.08 * staticAmt : 0, ctx, 0.1);

      // Heterodyne only on SW, only while close-but-not-locked, pitch = how far off you are.
      if (band === 'SW' && nearestDist < w * 3 && bestLock < 0.85) {
        const beat = Math.min(2400, 40 + (nearestDist / (w * 3)) * 2400);
        glide(het.frequency, beat, ctx, 0.08);
        glide(hetG.gain, 0.05 * (1 - bestLock), ctx, 0.1);
      } else {
        glide(hetG.gain, 0, ctx, 0.15);
      }

      // The sky captions whatever is currently talking.
      setSubtitle(best && bestLock > 0.35 ? best.subtitle() : '');
      if (changed) publish();
    },
  };
}
