// sound.jsx — interface sound design, fully synthesized via Web Audio (no asset
// files). The sonic palette matches the bitonal/mechanical visual language:
// short, dry, percussive ticks and two-tone confirms — like the membrane-button
// blips of a rowing monitor, not soft UI chimes. Strictly functional feedback so
// hover/press read unmistakably, including audibly.
//
// AudioContext is created lazily on the first user gesture (browser autoplay
// policy). All SFX are no-ops until then and while muted.
//
// Exports to window: Sfx

const Sfx = (() => {
  let ctx = null;
  let master = null;
  let enabled = true;
  let volume = 0.5;
  let lastHover = 0;

  // create the AudioContext. Must run INSIDE a real user-gesture handler so it is
  // never born "suspended" and left waiting on a pending resume — that stuck state
  // was the long, variable delay (sometimes ~20s) before audio "got going".
  function ensure() {
    if (ctx) return ctx;
    try {
      ctx = new (window.AudioContext || window.webkitAudioContext)();
      master = ctx.createGain();
      master.gain.value = volume;
      master.connect(ctx.destination);
    } catch (e) { ctx = null; }
    return ctx;
  }
  // first real gesture creates AND resumes the context, synchronously, so the
  // browser honours it at once. Left live (not once) so it self-heals if the tab
  // or OS auto-suspends the context later.
  function unlock() {
    const c = ensure();
    if (c && c.state === "suspended") { try { c.resume(); } catch (e) {} }
  }
  ["pointerdown", "keydown", "touchstart"].forEach((ev) =>
    window.addEventListener(ev, unlock, { once: false, passive: true }));

  // a dry percussive tick: short noise/blip through a band-ish envelope
  function blip(freq, dur, type = "square", gain = 1, sweep = 0, when = 0) {
    // only play once the context is actually running — never schedule into a
    // suspended context (that queues sounds that fire late in a burst).
    if (!enabled || !ctx || ctx.state !== "running" || !master) return;
    try {
      const t0 = ctx.currentTime + Math.max(0, when);
      const osc = ctx.createOscillator();
      const g = ctx.createGain();
      osc.type = type;
      osc.frequency.setValueAtTime(freq, t0);
      if (sweep) osc.frequency.exponentialRampToValueAtTime(Math.max(40, freq + sweep), t0 + dur);
      g.gain.setValueAtTime(0.0001, t0);
      g.gain.exponentialRampToValueAtTime(gain, t0 + 0.004);
      g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
      osc.connect(g); g.connect(master);
      osc.start(t0); osc.stop(t0 + dur + 0.02);
    } catch (e) {}
  }
  // filtered noise burst — a "ka" click body for presses
  function noise(dur, cutoff, gain = 0.5, when = 0) {
    if (!enabled || !ctx || ctx.state !== "running" || !master) return;
    try {
      const t0 = ctx.currentTime + Math.max(0, when);
      const n = Math.floor(ctx.sampleRate * dur);
      const buf = ctx.createBuffer(1, n, ctx.sampleRate);
      const data = buf.getChannelData(0);
      for (let i = 0; i < n; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / n);
      const src = ctx.createBufferSource(); src.buffer = buf;
      const f = ctx.createBiquadFilter(); f.type = "bandpass"; f.frequency.value = cutoff; f.Q.value = 0.8;
      const g = ctx.createGain();
      g.gain.setValueAtTime(gain, t0);
      g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
      src.connect(f); f.connect(g); g.connect(master);
      src.start(t0); src.stop(t0 + dur + 0.02);
    } catch (e) {}
  }

  return {
    setEnabled(v) { enabled = v; },
    setVolume(v) { volume = v; if (master) master.gain.value = v; },
    isEnabled() { return enabled; },

    // soft high tick — hover (rate-limited so sweeping a list doesn't machine-gun)
    hover() {
      const now = performance.now();
      if (now - lastHover < 45) return;
      lastHover = now;
      blip(2100, 0.025, "square", 0.10);
    },
    // firm membrane click — generic press
    tap() { noise(0.028, 1700, 0.35); blip(820, 0.03, "square", 0.16); },
    // two-tone up — affirmative select / confirm (scheduled on the audio clock,
    // not setTimeout — main-thread jank was making the 2nd tone fire late)
    select() { blip(760, 0.05, "square", 0.18); blip(1180, 0.06, "square", 0.16, 0, 0.042); },
    // two-tone down — back / stop / dismiss
    back() { blip(680, 0.05, "square", 0.16); blip(440, 0.07, "square", 0.16, 0, 0.046); },
    // rising 3-note — start workout
    start() { [600, 840, 1180].forEach((f, i) => blip(f, 0.07, "square", 0.17, 0, i * 0.07)); },
    // dry blip — tab / segment / metric cycle
    tick() { blip(1500, 0.022, "square", 0.13); noise(0.016, 2400, 0.12); },
    // subtle low pulse — stroke catch (used live)
    stroke() { blip(150, 0.06, "sine", 0.12, 60); },
    // mechanical latch — a dry safe-dial / lock click (additive; title page unaffected)
    latch() { noise(0.038, 820, 0.42); blip(210, 0.055, "triangle", 0.13, -70); },
    // two-stage mechanical unlock — 'grik' (dry catch) then 'gruk' (the bolt seats
    // home, lower + heavier). Scheduled on the audio clock; additive.
    unlock() {
      noise(0.04, 1500, 0.45); blip(300, 0.05, "triangle", 0.12, -110);
      noise(0.065, 650, 0.6, 0.115); blip(130, 0.1, "triangle", 0.22, -35, 0.115);
    },
    // flat denial — dull double-thud, pitch falling (procedural; additive)
    deny() { noise(0.07, 340, 0.5); blip(120, 0.09, "square", 0.16, -20); blip(96, 0.11, "square", 0.15, -12, 0.09); },
  };
})();

window.Sfx = Sfx;
