/**
 * THE SPEAKER — LANE-SCREEN's second organ.
 *
 * HOUSE LAW: every sound is synthesized with Web Audio. No asset files, ever.
 *
 * Three layers, gated by discovery:
 *   1. DIEGETIC MINIMUM (always on)  — placement, fax, brownout, scram, ratification.
 *      Nothing else exists before the optical fossil is found. The silence is the tease.
 *   2. THE BAND (on relicFound)      — the tuner and its four stations.
 *   3. WORLD AMBIENCE (post-discovery) — the factory's own hum, ducked under the radio.
 *
 * The AudioContext is created lazily on the first user gesture (browsers require it) and the
 * whole graph is built once at that moment. After that the per-frame path only writes
 * AudioParams — no allocation except genuinely one-shot notes.
 */
import type { AudioFX, GameData, SimEvent, SimSnapshot } from '../contracts';
import { ERA_ORDER, highestEra, type Era } from '../screen/eras';
import { createMasterChain } from './graph';
import { createAmbience, type Ambience } from './ambience';
import { createRadio, type Radio, type RadioEngine } from './radio';
import type { StationDef } from './stations';
import {
  brownoutHit, brownoutRecover, faxChatter, klaxon, ratifySting, tapeChunk, thunk,
} from './sfx';

import stationData from '../../data/stations.json';
import seamData from '../../data/seams.json';

const STATIONS = (stationData as { stations: StationDef[] }).stations;

/**
 * The real seams, so the NUMBERS station's seeded filler can never accidentally broadcast a
 * true coordinate (LANE-DATA's rule: only the planted groups are real).
 */
const SEAM_RECTS = (seamData as { seams: { rect: { x: number; y: number; w: number; h: number } }[] })
  .seams.map((s) => s.rect);

function isRealSeam(x: number, y: number): boolean {
  for (const r of SEAM_RECTS) {
    if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) return true;
  }
  return false;
}

/** Sounds that must not machine-gun when the sim emits a burst in one tick. */
const MIN_GAP = { thunk: 0.03, chunk: 0.05 };

/** The live radio handle for getRadio(). Module-level — set by build(), which runs on the
 * first user gesture — so the control surface exists in PRODUCTION, not just behind the
 * DEV debug object (orchestrator round-5 integration fix: the dock was silent in prod). */
let liveRadio: Radio | null = null;

export function createAudioFX(): AudioFX {
  let ctx: BaseAudioContext | null = null;
  let master: ReturnType<typeof createMasterChain> | null = null;
  let radio: RadioEngine | null = null;
  let ambience: Ambience | null = null;

  const eras = new Map<string, Era>();
  let era: Era = ERA_ORDER[0];
  let discovered = false;
  let lastThunk = 0;
  let lastChunk = 0;
  let variant = 0;

  function now(): number {
    return ctx ? ctx.currentTime : 0;
  }

  function openTheBand() {
    if (discovered) return;
    discovered = true;
    if (!ctx || !radio || !ambience || !master) return;
    radio.unlock(now());
    ambience.start(now());
    // The fossil is a severed audio bus reconnecting: the world audibly comes online.
    ratifySting(ctx, master.input, now() + 0.02);
  }

  function handle(e: SimEvent) {
    if (!ctx || !master) return;
    const dest = master.input;
    const t = now() + 0.005; // a hair in the future: scheduling in the past clicks
    switch (e.kind) {
      case 'placed':
        if (t - lastThunk >= MIN_GAP.thunk) {
          thunk(ctx, dest, t, variant++);
          lastThunk = t;
        }
        break;
      case 'brownout':
        if (e.on) brownoutHit(ctx, dest, t);
        else brownoutRecover(ctx, dest, t);
        break;
      case 'scram':
        if (e.on) klaxon(ctx, dest, t);
        break;
      case 'researched':
        ratifySting(ctx, dest, t);
        break;
      case 'commissionDone':
        faxChatter(ctx, dest, t, 18);
        break;
      case 'shipped':
        // Layer 3: the tape chunk belongs to the discovered world.
        if (discovered && t - lastChunk >= MIN_GAP.chunk) {
          tapeChunk(ctx, dest, t, variant++);
          lastChunk = t;
        }
        break;
      case 'relicFound':
        openTheBand();
        break;
      default:
        break;
    }
  }

  function build() {
    if (ctx) return;
    const AC: typeof AudioContext =
      (window as any).AudioContext ?? (window as any).webkitAudioContext;
    if (!AC) {
      console.warn('[audio] no Web Audio support — THE SPEAKER stays silent');
      return;
    }
    ctx = new AC();
    master = createMasterChain(ctx);
    radio = createRadio(ctx, STATIONS, isRealSeam);
    liveRadio = radio as Radio;
    ambience = createAmbience(ctx);
    radio.out.connect(master.input);
    ambience.out.connect(master.input);

    // The fax announces itself the moment the world has ears — Layer 1 proof of life.
    faxChatter(ctx, master.input, now() + 0.15, 10);

    // If the relic was already found (loaded save, or dug before the first click), the band
    // should be open the instant audio exists.
    if (discovered) {
      discovered = false;
      openTheBand();
    }

    if (import.meta.env.DEV) {
      (window as any).__fktryAudio = {
        ctx,
        radio: radio as Radio,
        openTheBand,
        isDiscovered: () => discovered,
        stations: STATIONS,
        play: (name: string) => {
          if (!ctx || !master) return;
          const d = master.input;
          const t = now() + 0.02;
          ({ thunk, fax: faxChatter, brownout: brownoutHit, recover: brownoutRecover,
             klaxon, ratify: ratifySting, chunk: tapeChunk } as any)[name]?.(ctx, d, t);
        },
      };
    }
  }

  return {
    init(data: GameData) {
      for (const t of data.tech) eras.set(t.id, t.era);
    },

    unlock() {
      build();
      if (ctx && 'resume' in ctx) void (ctx as AudioContext).resume();
    },

    onEvents(events: SimEvent[]) {
      if (!ctx) {
        // Audio doesn't exist yet (no gesture). Anything that happened is simply not heard —
        // we never queue a burst to fire on unlock. But discovery must not be lost.
        for (const e of events) if (e.kind === 'relicFound') discovered = true;
        return;
      }
      for (const e of events) handle(e);
    },

    frame(timeMs: number, snap?: SimSnapshot) {
      if (!ctx || !radio || !ambience) return;
      const t = now();

      if (snap?.research) era = highestEra(snap.research.unlocked, (x) => eras.get(x));

      // Discovery can also arrive via the snapshot (e.g. a loaded save), not just the event.
      if (!discovered && snap?.relics?.some((r) => r.found)) openTheBand();

      radio.update(t, era);

      if (discovered && snap) {
        const { gen, draw } = snap.bandwidth;
        const load = draw <= 0 ? 0 : Math.min(1, draw / Math.max(1, Math.max(gen, draw)));
        let hot = 0;
        let n = 0;
        for (const e of snap.entities) if (e.heat > 0.01) { hot += e.heat; n++; }
        const fever = n ? hot / n : 0;
        ambience.update(load, fever, radio.getState().lock);
      }
    },
  };
}

/**
 * THE SPEAKER's control surface, for LANE-UI's tuner dock.
 *
 * SANCTIONED CROSS-LANE IMPORT: LANE-UI may `import { getRadio } from '../audio'` and drive
 * the dial. Returns null before the first user gesture builds the graph. The object is
 * deliberately narrow — the UI can tune, set volume and subscribe, and can do nothing else to
 * the audio graph.
 */
export function getRadio(): Radio | null {
  return liveRadio;
}
