// Central registry of Web-Audio pipelines for every remote audio track we're
// playing. Exists because `HTMLMediaElement.volume` caps at 1.0 — to let a
// user boost a quiet peer past 100% we need an explicit GainNode in the
// output chain. Once a track is on the WebAudio path, `audio.muted` / volume
// no longer drive output (the MediaElementSource diverts samples through the
// graph), so all gating — deafen, watch-state, manual mute, per-user volume —
// is collapsed into a single effective-gain value per pipeline.
//
// The registry is a plain module-level Map. Attach/detach lifecycle is owned
// by CallContext's attachTrack/detachTrack helpers; gain recomputation is
// also triggered from CallContext when any state the formula depends on
// changes.

export type RemoteTrackSource = 'microphone' | 'screenshare';

export interface RemoteAudioPipeline {
  /** LiveKit track sid — stable identifier for this track's lifetime. */
  trackSid: string;
  participantId: string;
  trackSource: RemoteTrackSource;
  audio: HTMLAudioElement;
  ctx: AudioContext;
  source: MediaElementAudioSourceNode;
  gain: GainNode;
}

const pipelines = new Map<string, RemoteAudioPipeline>();

// Build the WebAudio chain for an already-attached HTMLAudioElement. Returns
// null when WebAudio isn't available (older browsers) — callers should fall
// back to `audio.volume` in that case.
export function createPipeline(
  audio: HTMLAudioElement,
  info: { trackSid: string; participantId: string; trackSource: RemoteTrackSource },
): RemoteAudioPipeline | null {
  const AudioCtx =
    window.AudioContext ??
    (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
  if (!AudioCtx) return null;
  try {
    const ctx = new AudioCtx();
    // A fresh AudioContext under Chromium's autoplay policy starts in
    // `suspended` state when no recent user gesture is in scope —
    // attachTrack fires from a LiveKit event, not the call-start click,
    // so we can't rely on the gesture crossing the async boundary. Kick
    // resume() immediately and retry on any statechange so a later
    // suspension (window backgrounding, device change) doesn't leave
    // the remote peer silent permanently.
    const tryResume = () => {
      if (ctx.state === 'suspended') {
        void ctx.resume().catch(() => {
          /* ignore — will retry on next statechange */
        });
      }
    };
    tryResume();
    ctx.addEventListener('statechange', tryResume);
    const source = ctx.createMediaElementSource(audio);
    const gain = ctx.createGain();
    // Start silent; the caller (CallContext) applies the correct effective
    // gain immediately after registering via `setPipelineGain`.
    gain.gain.value = 0;
    source.connect(gain);
    gain.connect(ctx.destination);
    // Do NOT set `audio.muted = true` here. Chromium gates the
    // media-element's internal sample production behind the muted flag,
    // and that gate sits *before* the MediaElementAudioSourceNode tap —
    // a muted element feeds zero samples into the WebAudio graph, which
    // silences the peer even though createMediaElementSource already
    // diverts the element's direct playback path. The diversion itself
    // is sufficient to stop the element from double-playing to the
    // default output; explicit muting is the bug.
    const pipeline: RemoteAudioPipeline = {
      trackSid: info.trackSid,
      participantId: info.participantId,
      trackSource: info.trackSource,
      audio,
      ctx,
      source,
      gain,
    };
    pipelines.set(info.trackSid, pipeline);
    return pipeline;
  } catch (err: unknown) {
    console.warn('createPipeline failed', err);
    return null;
  }
}

export function destroyPipeline(trackSid: string): void {
  const p = pipelines.get(trackSid);
  if (!p) return;
  pipelines.delete(trackSid);
  try {
    p.source.disconnect();
  } catch {
    /* ignore */
  }
  try {
    p.gain.disconnect();
  } catch {
    /* ignore */
  }
  void p.ctx.close().catch(() => {
    /* ignore */
  });
}

export function allPipelines(): IterableIterator<RemoteAudioPipeline> {
  return pipelines.values();
}

export function getPipeline(trackSid: string): RemoteAudioPipeline | undefined {
  return pipelines.get(trackSid);
}

export function pipelinesFor(
  participantId: string,
  source?: RemoteTrackSource,
): RemoteAudioPipeline[] {
  const out: RemoteAudioPipeline[] = [];
  for (const p of pipelines.values()) {
    if (p.participantId !== participantId) continue;
    if (source && p.trackSource !== source) continue;
    out.push(p);
  }
  return out;
}

// Ramp the gain slightly (~20ms) so 0→2x doesn't introduce a click and so
// rapid slider drags stay smooth. ctx.currentTime is the right anchor —
// setValueAtTime jumps abruptly.
export function setPipelineGain(pipeline: RemoteAudioPipeline, value: number): void {
  const v = clampGain(value);
  try {
    pipeline.gain.gain.setTargetAtTime(v, pipeline.ctx.currentTime, 0.02);
  } catch {
    // Some contexts in a closed state throw — just direct-assign as a
    // fallback; worst case the next valid call smooths it out.
    pipeline.gain.gain.value = v;
  }
}

export function clampGain(v: number): number {
  if (!Number.isFinite(v)) return 0;
  if (v < 0) return 0;
  // 2.0 matches Discord's upper bound (200%). Going higher invites clipping
  // since the source is already peaking at 1.0 for most mics.
  if (v > 2) return 2;
  return v;
}

// Route every pipeline's AudioContext to the given sink. Feature-detects
// `AudioContext.setSinkId` (Chrome 115+); older runtimes silently keep the
// default sink, which is the behaviour we had before the WebAudio refactor
// so this is a strict improvement rather than a regression.
export async function setAllPipelinesSinkId(deviceId: string): Promise<void> {
  const maybeId = deviceId.length === 0 ? 'default' : deviceId;
  for (const p of pipelines.values()) {
    const ctxAny = p.ctx as unknown as {
      setSinkId?: (id: string) => Promise<void>;
    };
    if (typeof ctxAny.setSinkId !== 'function') continue;
    try {
      await ctxAny.setSinkId(maybeId);
    } catch (err: unknown) {
      console.warn('AudioContext.setSinkId failed', { trackSid: p.trackSid, err });
    }
  }
}
