import type { AudioTrack, Participant, Room } from 'livekit-client';
import { ParticipantEvent, RoomEvent, Track } from 'livekit-client';
import { useEffect, useRef, useState } from 'react';

import { getAudioSettings, subscribeAudioSettings } from './audioSettings';

// Real-time speaking ring driven by Web Audio API AnalyserNodes directly on
// each participant's audio MediaStreamTrack. LiveKit's own `audioLevel` +
// `isSpeaking` are updated by a background monitor (default ~1s) — too
// laggy. AnalyserNodes give us raw PCM at browser frame rate, so the ring
// lights up within one animation frame of actual speech.
//
// Hold time of 250ms prevents flicker between words / short pauses.
// RMS threshold is user-tunable via audioSettings.voiceThreshold so soft
// speakers / noisy rooms can dial in their own sensitivity.
const POLL_MS = 50;
const HOLD_MS = 250;
const FFT_SIZE = 256;

interface Probe {
  ctx: AudioContext;
  analyser: AnalyserNode;
  source: MediaStreamAudioSourceNode;
  buf: Uint8Array;
  // Cached MediaStreamTrack reference — if a publication swaps tracks
  // (mute/unmute, device switch) we rebuild the node chain.
  trackId: string;
}

function makeProbe(track: MediaStreamTrack): Probe | null {
  try {
    const ctx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
    const stream = new MediaStream([track]);
    const source = ctx.createMediaStreamSource(stream);
    const analyser = ctx.createAnalyser();
    analyser.fftSize = FFT_SIZE;
    analyser.smoothingTimeConstant = 0.2;
    source.connect(analyser);
    return {
      ctx,
      analyser,
      source,
      buf: new Uint8Array(analyser.frequencyBinCount),
      trackId: track.id,
    };
  } catch {
    return null;
  }
}

function destroyProbe(probe: Probe): void {
  try {
    probe.source.disconnect();
  } catch {
    /* ignore */
  }
  try {
    void probe.ctx.close();
  } catch {
    /* ignore */
  }
}

function sampleRms(probe: Probe): number {
  // `getByteFrequencyData` types expect `Uint8Array<ArrayBuffer>` (no
  // SharedArrayBuffer). Our `probe.buf` satisfies that at runtime; the cast
  // sidesteps the TS lib signature quirk.
  probe.analyser.getByteFrequencyData(probe.buf as Uint8Array<ArrayBuffer>);
  let sum = 0;
  for (let i = 0; i < probe.buf.length; i++) sum += probe.buf[i]!;
  return sum / (probe.buf.length * 255);
}

function firstAudioTrack(p: Participant): AudioTrack | null {
  const pubs = p.audioTrackPublications;
  for (const pub of pubs.values()) {
    if (pub.kind === Track.Kind.Audio && pub.track) {
      return pub.track as AudioTrack;
    }
  }
  return null;
}

export function useActiveSpeakers(room: Room | null): Set<string> {
  const [ids, setIds] = useState<Set<string>>(() => new Set());
  const thresholdRef = useRef<number>(getAudioSettings().voiceThreshold);

  // Live-subscribe so the slider in settings takes effect without a call
  // restart. Reading via ref keeps the tick-loop branch-free.
  useEffect(() => {
    return subscribeAudioSettings((s) => {
      thresholdRef.current = s.voiceThreshold;
    });
  }, []);

  useEffect(() => {
    if (!room) {
      setIds(new Set());
      return;
    }

    const probes = new Map<string, Probe>();
    const lastActive = new Map<string, number>();

    const syncProbe = (p: Participant): void => {
      if (!p.identity) return;
      const track = firstAudioTrack(p);
      const mst = track?.mediaStreamTrack ?? null;
      const existing = probes.get(p.identity);
      if (!mst) {
        if (existing) {
          destroyProbe(existing);
          probes.delete(p.identity);
        }
        return;
      }
      if (existing && existing.trackId === mst.id) return;
      if (existing) destroyProbe(existing);
      const probe = makeProbe(mst);
      if (probe) probes.set(p.identity, probe);
    };

    const bind = (p: Participant): void => {
      p.on(ParticipantEvent.TrackPublished, () => syncProbe(p));
      p.on(ParticipantEvent.TrackUnpublished, () => syncProbe(p));
      p.on(ParticipantEvent.TrackSubscribed, () => syncProbe(p));
      p.on(ParticipantEvent.TrackUnsubscribed, () => syncProbe(p));
      p.on(ParticipantEvent.TrackMuted, () => syncProbe(p));
      p.on(ParticipantEvent.TrackUnmuted, () => syncProbe(p));
      syncProbe(p);
    };

    bind(room.localParticipant);
    room.remoteParticipants.forEach(bind);

    const onConnected = (p: Participant) => bind(p);
    const onDisconnected = (p: Participant) => {
      if (!p.identity) return;
      const probe = probes.get(p.identity);
      if (probe) {
        destroyProbe(probe);
        probes.delete(p.identity);
      }
      lastActive.delete(p.identity);
    };
    room.on(RoomEvent.ParticipantConnected, onConnected);
    room.on(RoomEvent.ParticipantDisconnected, onDisconnected);

    const tick = () => {
      // Defensively resync every tick — covers the gap where local mic
      // finishes publishing between effect mount and the first
      // TrackPublished event, and handles event misses on Tauri WebView.
      syncProbe(room.localParticipant);
      room.remoteParticipants.forEach(syncProbe);

      const now = Date.now();
      const threshold = thresholdRef.current;
      for (const [id, probe] of probes) {
        if (sampleRms(probe) > threshold) lastActive.set(id, now);
      }
      const next = new Set<string>();
      for (const [id, t] of lastActive) {
        if (now - t <= HOLD_MS) next.add(id);
      }
      setIds((prev) => {
        if (prev.size === next.size && Array.from(prev).every((v) => next.has(v))) {
          return prev;
        }
        return next;
      });
    };

    const interval = window.setInterval(tick, POLL_MS);

    return () => {
      window.clearInterval(interval);
      room.off(RoomEvent.ParticipantConnected, onConnected);
      room.off(RoomEvent.ParticipantDisconnected, onDisconnected);
      for (const probe of probes.values()) destroyProbe(probe);
      probes.clear();
    };
  }, [room]);

  return ids;
}
