// Native-loopback renderer sink. Pairs with
// electron/modules/audio-loopback.ts and the @chatapp/audio-loopback-native
// addon: receives interleaved f32 stereo PCM chunks at 48kHz over IPC,
// pushes them into an AudioWorklet ring buffer, and exposes the result
// as a real `MediaStreamTrack` that LiveKit can publish as a
// ScreenShareAudio publication.
//
// The WASAPI pipeline excludes our own process tree, so peers in a
// video call don't hear themselves echoed back when the user shares
// system audio. That's the whole reason we route around Chromium's
// 'loopback' source on Windows.
//
// Strategy: AudioWorklet + MediaStreamAudioDestinationNode is the
// portable path that works in every Electron Chromium build we ship
// against. MediaStreamTrackGenerator (a newer alternative) was
// considered but is gated behind a flag in stable Chromium and isn't
// reliable across Electron versions, so we don't take that branch.

interface AudioLoopbackChunkPayload {
  captureId: number;
  samples: Float32Array;
}

export interface LoopbackTrackHandle {
  /** The audio MediaStreamTrack to publish via LiveKit. */
  track: MediaStreamTrack;
  /** Teardown — stops the addon-side capture, disconnects the audio
   *  graph, ends the track. Idempotent. */
  stop: () => Promise<void>;
}

export class LoopbackAudioUnavailable extends Error {
  constructor(reason: string) {
    super('loopback audio unavailable: ' + reason);
    this.name = 'LoopbackAudioUnavailable';
  }
}

// AudioWorklet processor source. Identical ring-buffer shape to the
// Tauri renderer: a pair of per-channel ring buffers that the main
// thread fills as samples arrive; `process()` drains into the output
// quantum. Underrun emits silence (a glitch is better than a freeze
// for LiveKit's Opus encoder). Hard cap at 300ms keeps a stalled-then-
// resumed pipeline from playing back minutes of stale audio.
const WORKLET_SOURCE = `
class LoopbackAudioProcessor extends AudioWorkletProcessor {
  constructor() {
    super();
    this.bufferSize = 48000 * 0.3 | 0;
    this.targetFrames = 48000 * 0.08 | 0;
    this.bufL = new Float32Array(this.bufferSize);
    this.bufR = new Float32Array(this.bufferSize);
    this.writePos = 0;
    this.readPos = 0;
    this.available = 0;
    this.port.onmessage = (e) => {
      const { left, right } = e.data;
      const len = left.length;
      for (let i = 0; i < len; i++) {
        this.bufL[this.writePos] = left[i];
        this.bufR[this.writePos] = right[i];
        this.writePos = (this.writePos + 1) % this.bufferSize;
        if (this.available < this.bufferSize) {
          this.available++;
        } else {
          this.readPos = (this.readPos + 1) % this.bufferSize;
        }
      }
      if (this.available > this.targetFrames * 3) {
        const drop = this.available - this.targetFrames;
        this.readPos = (this.readPos + drop) % this.bufferSize;
        this.available -= drop;
      }
    };
  }
  process(_inputs, outputs) {
    const output = outputs[0];
    if (!output || output.length === 0) return true;
    const out0 = output[0];
    const out1 = output[1] || output[0];
    const n = out0.length;
    for (let i = 0; i < n; i++) {
      if (this.available > 0) {
        out0[i] = this.bufL[this.readPos];
        if (out1 !== out0) out1[i] = this.bufR[this.readPos];
        this.readPos = (this.readPos + 1) % this.bufferSize;
        this.available--;
      } else {
        out0[i] = 0;
        if (out1 !== out0) out1[i] = 0;
      }
    }
    return true;
  }
}
registerProcessor('loopback-audio-processor', LoopbackAudioProcessor);
`;

let workletModuleUrl: string | null = null;
function getWorkletModuleUrl(): string {
  if (workletModuleUrl) return workletModuleUrl;
  const blob = new Blob([WORKLET_SOURCE], { type: 'application/javascript' });
  workletModuleUrl = URL.createObjectURL(blob);
  return workletModuleUrl;
}

export interface StartLoopbackTrackOptions {
  /** When set, the capture targets only the picked window's process
   *  tree (INCLUDE_TARGET_PROCESS_TREE) — Discord-parity behaviour for
   *  window-shares. The HWND is the decimal handle parsed from
   *  desktopCapturer's `window:<HWND>:0` source id. When unset, falls
   *  back to the EXCLUDE-self path that captures the whole OS mixer
   *  minus our own PID tree (default for full-screen shares). */
  windowHwnd?: number;
}

/**
 * Start a native WASAPI process-loopback capture and surface the
 * result as a `MediaStreamTrack`. Throws `LoopbackAudioUnavailable` if
 * the platform / build doesn't ship the addon — callers fall through
 * to the cross-platform getUserMedia path.
 *
 * Pass `{ windowHwnd }` to capture only that window's app audio
 * (window-share path). Omit to capture the whole OS mixer minus our
 * own PID tree (full-screen-share path).
 */
export async function startLoopbackTrack(
  opts?: StartLoopbackTrackOptions,
): Promise<LoopbackTrackHandle> {
  if (typeof window === 'undefined' || !window.electronAPI?.audioLoopback) {
    throw new LoopbackAudioUnavailable('not an electron runtime with audio-loopback bridge');
  }
  const AudioCtor: typeof AudioContext | undefined =
    typeof window !== 'undefined'
      ? (window.AudioContext ??
          (window as unknown as { webkitAudioContext?: typeof AudioContext })
            .webkitAudioContext)
      : undefined;
  if (!AudioCtor) {
    throw new LoopbackAudioUnavailable('WebAudio unavailable');
  }

  // Pin the context to 48kHz so the worklet's input rate matches the
  // addon's output rate. If the OS forces a different rate the
  // constructor throws on some browsers; we surface as Unavailable so
  // the caller can fall back.
  let ctx: AudioContext;
  try {
    ctx = new AudioCtor({ sampleRate: 48000, latencyHint: 'interactive' });
  } catch (err: unknown) {
    throw new LoopbackAudioUnavailable(
      err instanceof Error ? err.message : String(err),
    );
  }

  try {
    await ctx.audioWorklet.addModule(getWorkletModuleUrl());
  } catch (err: unknown) {
    await ctx.close().catch(() => undefined);
    throw new LoopbackAudioUnavailable(
      'audioWorklet load failed: ' +
        (err instanceof Error ? err.message : String(err)),
    );
  }

  const node = new AudioWorkletNode(ctx, 'loopback-audio-processor', {
    numberOfInputs: 0,
    numberOfOutputs: 1,
    outputChannelCount: [2],
  });
  const dest = ctx.createMediaStreamDestination();
  node.connect(dest);

  // Kick the AudioContext out of `suspended` before any samples arrive
  // — the share is triggered from a user click so autoplay policy
  // allows this. An un-resumed context would buffer everything the
  // addon produces until the context eventually runs, giving seconds
  // of initial latency.
  if (ctx.state !== 'running') {
    try {
      await ctx.resume();
    } catch (err: unknown) {
      console.warn('loopback ctx.resume failed', err);
    }
  }

  // Subscribe BEFORE starting the capture so we don't drop any of the
  // initial chunks that race the start() promise.
  let captureIdResolved: number | null = null;
  const unsubscribe = window.electronAPI.audioLoopback.onChunk(
    (payload: AudioLoopbackChunkPayload) => {
      if (captureIdResolved !== null && payload.captureId !== captureIdResolved) {
        return;
      }
      const interleaved = payload.samples;
      const frames = interleaved.length >> 1;
      if (frames === 0) return;
      const left = new Float32Array(frames);
      const right = new Float32Array(frames);
      for (let i = 0; i < frames; i++) {
        left[i] = interleaved[i * 2] ?? 0;
        right[i] = interleaved[i * 2 + 1] ?? 0;
      }
      node.port.postMessage({ left, right }, [left.buffer, right.buffer]);
    },
  );

  let startResult: { captureId: number };
  try {
    if (typeof opts?.windowHwnd === 'number' && Number.isFinite(opts.windowHwnd)) {
      // INCLUDE_TARGET_PROCESS_TREE — capture only the picked window's
      // app. Falls back via the catch below if the addon predates the
      // startForWindow surface (older .node binary).
      const startForWindow = window.electronAPI.audioLoopback.startForWindow;
      if (typeof startForWindow !== 'function') {
        throw new LoopbackAudioUnavailable(
          'audioLoopback.startForWindow not exposed — preload + native addon need rebuild',
        );
      }
      startResult = await startForWindow(opts.windowHwnd);
    } else {
      startResult = await window.electronAPI.audioLoopback.start();
    }
  } catch (err: unknown) {
    unsubscribe();
    node.disconnect();
    await ctx.close().catch(() => undefined);
    throw new LoopbackAudioUnavailable(
      err instanceof Error ? err.message : String(err),
    );
  }
  captureIdResolved = startResult.captureId;

  const tracks = dest.stream.getAudioTracks();
  const track = tracks[0];
  if (!track) {
    unsubscribe();
    node.disconnect();
    await ctx.close().catch(() => undefined);
    await window.electronAPI.audioLoopback
      .stop(startResult.captureId)
      .catch(() => undefined);
    throw new LoopbackAudioUnavailable('MediaStreamDestination produced no track');
  }

  let stopped = false;
  const stop = async (): Promise<void> => {
    if (stopped) return;
    stopped = true;
    unsubscribe();
    try {
      await window.electronAPI.audioLoopback.stop(startResult.captureId);
    } catch (err: unknown) {
      console.warn('audioLoopback.stop failed', err);
    }
    try {
      node.disconnect();
    } catch {
      /* already disconnected */
    }
    try {
      track.stop();
    } catch {
      /* already stopped */
    }
    await ctx.close().catch(() => undefined);
  };

  return { track, stop };
}
