// Shared Web Audio graph that sits between the raw microphone MediaStream
// and LiveKit's published track. Mixes live mic with on-demand soundboard
// buffers so both paths reach the peer through a single published track,
// and lets us locally monitor soundboard output without feedback from the
// mic path.
//
// Graph:
//   rawMicSource ──► micGain ──┐
//                              ├─► destinationNode ─► publishedTrack
//   sbBufferSources ─► sbGain ─┤
//                              └─► monitorGain ─► ctx.destination (local hear,
//                                                 soundboard only)
//
// Lifetime:
//   createMicPipeline(rawTrack) — builds graph + AudioContext
//   pipeline.outputTrack         — pass to `localParticipant.publishTrack`
//   pipeline.setMicGain(0..1)    — mute / PTT
//   pipeline.setSoundboardGain(..) / setMonitorGain(..) — sb master + local hear
//   pipeline.playBuffer(buffer, opts) — returns a handle so callers can stop
//   pipeline.stopAll(buffers?)   — kill every active sb source (or only one id)
//   pipeline.replaceMicTrack(newTrack) — hot-swap on device change
//   pipeline.destroy()           — close ctx, stop owned tracks

export interface PlayBufferOpts {
  /** Per-source gain 0..1, multiplied by sb master. */
  gain?: number;
  /** Stable id — calling playBuffer with the same id stops the previous one
   *  first (single-fire mode). Omit for overlap mode. */
  id?: string;
  /** Fired when the buffer ends naturally (not when stopped manually). */
  onEnded?: () => void;
}

export interface PlayHandle {
  id: string | null;
  stop(): void;
}

export interface MicPipeline {
  readonly outputTrack: MediaStreamTrack;
  setMicGain(value: number): void;
  setSoundboardGain(value: number): void;
  setMonitorGain(value: number): void;
  replaceMicTrack(newTrack: MediaStreamTrack): void;
  playBuffer(buffer: AudioBuffer, opts?: PlayBufferOpts): PlayHandle;
  stopAll(id?: string): void;
  destroy(): void;
}

interface ActiveSource {
  id: string | null;
  node: AudioBufferSourceNode;
  gain: GainNode;
}

// Clamp helper — avoid letting callers pass NaN or out-of-range values.
function clamp01(v: number): number {
  if (!Number.isFinite(v)) return 0;
  if (v < 0) return 0;
  if (v > 1) return 1;
  return v;
}

export function createMicPipeline(rawTrack: MediaStreamTrack): MicPipeline {
  const AudioCtx =
    window.AudioContext ??
    (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
  if (!AudioCtx) {
    throw new Error('AudioContext unavailable');
  }

  const ctx = new AudioCtx();

  const micGain = ctx.createGain();
  micGain.gain.value = 1;

  const sbGain = ctx.createGain();
  sbGain.gain.value = 1;

  const monitorGain = ctx.createGain();
  monitorGain.gain.value = 1;

  const dest = ctx.createMediaStreamDestination();

  // Mic path → published only.
  micGain.connect(dest);

  // Soundboard path → published + local monitor.
  sbGain.connect(dest);
  sbGain.connect(monitorGain);
  monitorGain.connect(ctx.destination);

  let currentRawTrack: MediaStreamTrack = rawTrack;
  let micSource: MediaStreamAudioSourceNode = buildMicSource(ctx, rawTrack, micGain);

  const active = new Set<ActiveSource>();
  let destroyed = false;

  function buildMicSource(
    c: AudioContext,
    t: MediaStreamTrack,
    target: AudioNode,
  ): MediaStreamAudioSourceNode {
    const stream = new MediaStream([t]);
    const node = c.createMediaStreamSource(stream);
    node.connect(target);
    return node;
  }

  const outputTrack = dest.stream.getAudioTracks()[0];
  if (!outputTrack) {
    throw new Error('MediaStreamAudioDestinationNode produced no audio track');
  }

  return {
    outputTrack,

    setMicGain(value: number) {
      if (destroyed) return;
      const v = clamp01(value);
      micGain.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
    },

    setSoundboardGain(value: number) {
      if (destroyed) return;
      const v = clamp01(value);
      sbGain.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
    },

    setMonitorGain(value: number) {
      if (destroyed) return;
      const v = clamp01(value);
      monitorGain.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
    },

    replaceMicTrack(newTrack: MediaStreamTrack) {
      if (destroyed) return;
      // Tear down the old MediaStreamSourceNode and stop the raw track we
      // owned. Caller passes ownership of `newTrack` to the pipeline.
      try {
        micSource.disconnect();
      } catch {
        /* ignore */
      }
      if (currentRawTrack !== newTrack) {
        try {
          currentRawTrack.stop();
        } catch {
          /* ignore */
        }
      }
      currentRawTrack = newTrack;
      micSource = buildMicSource(ctx, newTrack, micGain);
    },

    playBuffer(buffer: AudioBuffer, opts: PlayBufferOpts = {}): PlayHandle {
      if (destroyed) {
        return { id: opts.id ?? null, stop: () => undefined };
      }

      // Single-fire: stop previous instance of the same id so holding a
      // hotkey doesn't stack a dozen overlapping plays.
      if (opts.id) {
        for (const entry of active) {
          if (entry.id === opts.id) stopActive(entry);
        }
      }

      const node = ctx.createBufferSource();
      node.buffer = buffer;

      const g = ctx.createGain();
      g.gain.value = clamp01(opts.gain ?? 1);

      node.connect(g);
      g.connect(sbGain);

      const entry: ActiveSource = { id: opts.id ?? null, node, gain: g };
      active.add(entry);

      node.onended = () => {
        if (!active.has(entry)) return;
        try {
          node.disconnect();
        } catch {
          /* ignore */
        }
        try {
          g.disconnect();
        } catch {
          /* ignore */
        }
        active.delete(entry);
        opts.onEnded?.();
      };

      try {
        node.start();
      } catch {
        active.delete(entry);
      }

      return {
        id: entry.id,
        stop: () => stopActive(entry),
      };
    },

    stopAll(id?: string) {
      for (const entry of Array.from(active)) {
        if (id !== undefined && entry.id !== id) continue;
        stopActive(entry);
      }
    },

    destroy() {
      if (destroyed) return;
      destroyed = true;
      for (const entry of Array.from(active)) stopActive(entry);
      try {
        micSource.disconnect();
      } catch {
        /* ignore */
      }
      try {
        micGain.disconnect();
      } catch {
        /* ignore */
      }
      try {
        sbGain.disconnect();
      } catch {
        /* ignore */
      }
      try {
        monitorGain.disconnect();
      } catch {
        /* ignore */
      }
      try {
        currentRawTrack.stop();
      } catch {
        /* ignore */
      }
      try {
        outputTrack.stop();
      } catch {
        /* ignore */
      }
      void ctx.close().catch(() => {
        /* ignore */
      });
    },
  };

  function stopActive(entry: ActiveSource): void {
    try {
      entry.node.onended = null;
      entry.node.stop();
    } catch {
      /* already ended */
    }
    try {
      entry.node.disconnect();
    } catch {
      /* ignore */
    }
    try {
      entry.gain.disconnect();
    } catch {
      /* ignore */
    }
    active.delete(entry);
  }
}
