"use client";

import { useState, useRef, useCallback, useEffect } from "react";

export type VoiceState = "idle" | "listening" | "thinking" | "speaking";

// Fallback defaults — used when no voice_recording_config has arrived from admin console yet
const DEFAULTS = {
  minRecordMs:        1200,
  silenceDuration:    2000,
  maxRecordMs:        25000,
  calibrationMs:      800,
  noiseMarginDefault: 12,
  noiseMarginHigh:    28,
  bargeInFrames:      1,
  levelFps:           15,
  bargeInNoiseMargin: 8,
} as const;

export type VoiceTtsConfig = {
  rate?: number;
  pitch?: number;
  volume?: number;
  voiceURI?: string;
  voiceName?: string;
  lang?: string;
  genderHint?: "male" | "female";
  ignoreBackgroundNoise?: boolean;
  allowInterrupt?: boolean;
};

export type VoiceRecordingConfig = {
  minRecordMs?: number;
  silenceDuration?: number;
  maxRecordMs?: number;
  calibrationMs?: number;
  noiseMarginDefault?: number;
  noiseMarginHigh?: number;
  bargeInFrames?: number;
  levelFps?: number;
  bargeInNoiseMargin?: number;
};

export function useVoiceRecording({
  onAudioReady,
  ttsConfig,
  recordingConfig,
}: {
  onAudioReady: (b64: string) => void;
  ttsConfig?: VoiceTtsConfig | null;
  recordingConfig?: VoiceRecordingConfig | null;
}) {
  const [voiceState, setVoiceState] = useState<VoiceState>("idle");
  const [audioLevel, setAudioLevel] = useState(0);
  const [micError, setMicError] = useState<string | null>(null);

  const activeRef   = useRef(false);
  // When true, voice_response_audio chunks and voice_response_done are accepted.
  // Set to true only when fresh audio is sent to the backend.
  // Set to false on interrupt/stop so stale chunks from an old pipeline cannot
  // stop the new recording or replay unwanted audio.
  const acceptingResponseRef = useRef(false);
  // Tracks whether the session has been explicitly stopped so speakText can
  // distinguish "not started yet" (greeting allowed) from "already stopped" (block).
  type SessionPhase = "pre" | "active" | "stopped";
  const sessionPhaseRef = useRef<SessionPhase>("pre");
  const stateRef    = useRef<VoiceState>("idle");
  const recorderRef = useRef<MediaRecorder | null>(null);
  // When true the current recording should be discarded (bot is speaking)
  const muteRef      = useRef(false);
  const onReadyRef         = useRef(onAudioReady);
  const ttsConfigRef       = useRef(ttsConfig);
  const recordingConfigRef = useRef(recordingConfig);
  const lastLevelTs  = useRef(0);
  // Cached voice list — populated on mount and kept fresh via voiceschanged
  const voicesRef    = useRef<SpeechSynthesisVoice[]>([]);
  // Forward-ref so async timeouts always call the latest version of startListening
  const startListeningRef  = useRef<() => Promise<void>>(async () => {});
  // Consecutive above-threshold frames during TTS — for barge-in detection
  const bargeInCountRef    = useRef(0);
  const interruptAndListenRef = useRef<() => void>(() => {});
  // Expose the active speakText finish() so interruptAndListen can clean up timers + streams
  const finishSpeakRef = useRef<(() => void) | null>(null);
  // Track the currently playing HTML5 Audio element so interruptAndListen can stop it
  const currentAudioRef = useRef<HTMLAudioElement | null>(null);

  useEffect(() => { onReadyRef.current = onAudioReady; }, [onAudioReady]);
  useEffect(() => { ttsConfigRef.current = ttsConfig; }, [ttsConfig]);
  useEffect(() => { recordingConfigRef.current = recordingConfig; }, [recordingConfig]);

  // Pre-load voices so they're ready before the first speakText call
  useEffect(() => {
    if (typeof window === "undefined" || !window.speechSynthesis) return;
    const load = () => { voicesRef.current = window.speechSynthesis.getVoices(); };
    load();
    window.speechSynthesis.addEventListener("voiceschanged", load);
    return () => window.speechSynthesis.removeEventListener("voiceschanged", load);
  }, []);

  const setState = useCallback((s: VoiceState) => {
    stateRef.current = s;
    setVoiceState(s);
    if (s !== "listening") setAudioLevel(0);
  }, []);

  // ── Core recording loop ─────────────────────────────────────────────────

  const startListening = useCallback(async () => {
    if (!activeRef.current) return;
    // Don't re-enter if already recording, mid-transcription, or already setting up mic
    if (recorderRef.current?.state === "recording") return;
    if (stateRef.current === "thinking") return;
    if (stateRef.current === "listening") return;

    setState("listening"); // set BEFORE getUserMedia — prevents race entries

    let stream: MediaStream;
    try {
      // Disable all browser audio processing so Gemini STT receives clean, unprocessed audio.
      // echoCancellation/noiseSuppression/autoGainControl all distort speech and cause
      // transcription errors ("Hello" → "Hallo", words cut off, etc.).
      stream = await navigator.mediaDevices.getUserMedia({
        audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
        video: false,
      });
    } catch {
      // Some browsers/devices ignore the constraints and only accept { audio: true }.
      // Fall back so the user isn't left with a broken mic.
      try {
        stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
      } catch (err) {
        console.warn("[voice] Mic access denied:", err);
        activeRef.current = false;
        setState("idle");
        setMicError("Microphone access denied. Please allow microphone access and try again.");
        return;
      }
    }

    if (!activeRef.current || (stateRef.current as VoiceState) !== "listening") {
      stream.getTracks().forEach(t => t.stop());
      return;
    }

    // AudioContext for real-time RMS analysis
    const AudioCtxClass = window.AudioContext || (window as any).webkitAudioContext;
    const audioCtx = new AudioCtxClass() as AudioContext;
    const analyser = audioCtx.createAnalyser();
    analyser.fftSize = 512;
    audioCtx.createMediaStreamSource(stream).connect(analyser);

    const mimeType = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg", "audio/mp4", ""]
      .find(t => !t || MediaRecorder.isTypeSupported(t)) ?? "";

    const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
    recorderRef.current = recorder;
    muteRef.current = false; // reset suppress flag for this recording

    const chunks: Blob[] = [];
    recorder.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); };

    recorder.onstop = () => {
      stream.getTracks().forEach(t => t.stop());
      audioCtx.close().catch(() => {});
      recorderRef.current = null;

      // Discard if voice was stopped externally or this was a muted stop (for TTS)
      if (!activeRef.current || !chunks.length || muteRef.current) {
        muteRef.current = false;
        return;
      }

      const blob = new Blob(chunks, { type: chunks[0]?.type || "audio/webm" });
      const reader = new FileReader();
      reader.onloadend = () => {
        const b64 = (reader.result as string).split(",")[1];
        if (b64 && activeRef.current) {
          setState("thinking");
          acceptingResponseRef.current = true; // ready to receive the response for this audio
          onReadyRef.current(b64);
        }
      };
      reader.readAsDataURL(blob);
    };

    recorder.start(100);

    // ── Adaptive silence detection ──────────────────────────────────────
    const buf = new Uint8Array(analyser.frequencyBinCount);
    let noiseFloor = 0;
    let noiseSamples = 0;
    let silenceStart: number | null = null;
    const t0 = Date.now();

    const tick = () => {
      if (!activeRef.current || recorder.state !== "recording") return;
      const cfg = recordingConfigRef.current;
      const maxRecordMs     = cfg?.maxRecordMs        ?? DEFAULTS.maxRecordMs;
      const levelFps        = cfg?.levelFps           ?? DEFAULTS.levelFps;
      const calibrationMs   = cfg?.calibrationMs      ?? DEFAULTS.calibrationMs;
      const minRecordMs     = cfg?.minRecordMs        ?? DEFAULTS.minRecordMs;
      const silenceDuration = cfg?.silenceDuration    ?? DEFAULTS.silenceDuration;
      const noiseMarginDef  = cfg?.noiseMarginDefault ?? DEFAULTS.noiseMarginDefault;
      const noiseMarginHigh = cfg?.noiseMarginHigh    ?? DEFAULTS.noiseMarginHigh;

      if (Date.now() - t0 > maxRecordMs) { recorder.stop(); return; }

      analyser.getByteTimeDomainData(buf);
      let sq = 0;
      for (let i = 0; i < buf.length; i++) sq += (buf[i] - 128) ** 2;
      const rms = Math.sqrt(sq / buf.length);

      // Publish audio level for UI (throttled to levelFps)
      const now = Date.now();
      if (now - lastLevelTs.current > 1000 / levelFps) {
        setAudioLevel(Math.min(1, rms / 40));
        lastLevelTs.current = now;
      }

      const elapsed = Date.now() - t0;

      // Calibration phase — learn the room's noise floor
      if (elapsed < calibrationMs) {
        noiseFloor = (noiseFloor * noiseSamples + rms) / (noiseSamples + 1);
        noiseSamples++;
        requestAnimationFrame(tick);
        return;
      }

      // Adaptive threshold — raised when ignoreBackgroundNoise is enabled
      const margin    = ttsConfigRef.current?.ignoreBackgroundNoise ? noiseMarginHigh : noiseMarginDef;
      const threshold = Math.min(38, Math.max(14, noiseFloor + margin));

      if (rms < threshold) {
        if (silenceStart === null) silenceStart = Date.now();
        else if (elapsed > minRecordMs && Date.now() - silenceStart > silenceDuration) {
          recorder.stop();
          return;
        }
      } else {
        silenceStart = null;
      }

      requestAnimationFrame(tick);
    };

    requestAnimationFrame(tick);
  }, [setState]);

  useEffect(() => { startListeningRef.current = startListening; }, [startListening]);

  // ── Thinking-state watchdog ─────────────────────────────────────────────
  // If backend never responds after sending audio, recover after 30s
  useEffect(() => {
    if (voiceState !== "thinking") return;
    const watchdog = setTimeout(() => {
      if (stateRef.current === "thinking" && activeRef.current) {
        setState("idle");
        startListeningRef.current();
      }
    }, 12000);
    return () => clearTimeout(watchdog);
  }, [voiceState, setState]);

  // ── Public API ──────────────────────────────────────────────────────────

  /** User taps "send now" — stop recording and dispatch audio immediately. */
  const stopAndSend = useCallback(() => {
    if (!activeRef.current) return;
    if (recorderRef.current?.state === "recording") {
      muteRef.current = false; // allow this audio to be sent
      recorderRef.current.stop();
    }
  }, []);

  /**
   * Called by the socket hook when bot response text arrives.
   * Speaks via browser TTS then automatically restarts listening (the main loop).
   */
  const speakText = useCallback((text: string) => {
    // "stopped" = voice mode was explicitly ended — block any late TTS responses.
    // "pre"     = session not yet started — allow the greeting to play.
    // "active"  = normal operation — allow all bot responses.
    if (sessionPhaseRef.current === "stopped") return;

    // Stop any active recording silently — we don't want to capture TTS audio
    if (recorderRef.current?.state === "recording") {
      muteRef.current = true;
      try { recorderRef.current.stop(); } catch {}
    }

    if (!text.trim()) {
      if (activeRef.current) setTimeout(() => startListeningRef.current(), 500);
      return;
    }

    setState("speaking");

    if (typeof window === "undefined" || !window.speechSynthesis) {
      setTimeout(() => startListeningRef.current(), 500);
      return;
    }

    // Cancel any previous utterance cleanly
    window.speechSynthesis.cancel();

    // ── Barge-in cleanup refs ────────────────────────────────────────────
    bargeInCountRef.current = 0;
    let bargeInStream: MediaStream | null = null;
    let bargeInCtx: AudioContext | null   = null;
    let bargeInRaf = 0;

    const stopBargeIn = () => {
      cancelAnimationFrame(bargeInRaf);
      bargeInStream?.getTracks().forEach(t => t.stop());
      if (bargeInCtx) { bargeInCtx.close().catch(() => {}); bargeInCtx = null; }
      bargeInStream = null;
      bargeInCountRef.current = 0;
    };

    // ── done guard — prevents double-resume from onend + fallback ────────
    let done = false;
    let fallbackTimer: ReturnType<typeof setTimeout>;
    let keepAlive: ReturnType<typeof setInterval>;
    let startedCheckTimer: ReturnType<typeof setTimeout>;

    const finish = () => {
      if (done) return;
      done = true;
      finishSpeakRef.current = null;
      clearTimeout(fallbackTimer);
      clearTimeout(startedCheckTimer);
      clearInterval(keepAlive);
      stopBargeIn();
      if (activeRef.current) setTimeout(() => startListeningRef.current(), 600);
    };
    // Expose so interruptAndListen can trigger cleanup from outside this closure
    finishSpeakRef.current = finish;

    // Chrome: cancel() then immediately speak() silently swallows the utterance.
    // A 70 ms gap is enough for the engine to reset cleanly.
    setTimeout(() => {
      if (!activeRef.current || done) return;

      const utt = new SpeechSynthesisUtterance(text);
      const cfg = ttsConfigRef.current;

      utt.rate   = cfg?.rate   ?? 1.0;
      utt.pitch  = cfg?.pitch  ?? 1.0;
      utt.volume = cfg?.volume ?? 1.0;

      // Use cached voices — fall back to live list (important on first call)
      const voices = voicesRef.current.length
        ? voicesRef.current
        : window.speechSynthesis.getVoices();

      const savedName   = (cfg?.voiceName ?? "").toLowerCase();
      const genderHint  = cfg?.genderHint;
      const wantsMale   = genderHint === "male"   || (savedName.includes("male") && !savedName.includes("female"));
      const wantsFemale = genderHint === "female" || savedName.includes("female");

      const voice =
        (cfg?.voiceName ? voices.find(v => v.name === cfg.voiceName) : null) ||
        (cfg?.voiceName ? voices.find(v => v.name.toLowerCase() === savedName) : null) ||
        (cfg?.voiceURI  ? voices.find(v => v.voiceURI === cfg.voiceURI) : null) ||
        (cfg?.lang && cfg.lang !== "en" && wantsMale
          ? voices.find(v => v.lang === cfg.lang && v.name.toLowerCase().includes("male") && !v.name.toLowerCase().includes("female"))
          : null) ||
        (cfg?.lang && cfg.lang !== "en" && wantsFemale
          ? voices.find(v => v.lang === cfg.lang && v.name.toLowerCase().includes("female"))
          : null) ||
        (cfg?.lang && cfg.lang !== "en" ? voices.find(v => v.lang === cfg.lang) : null) ||
        null;

      if (voice) { utt.voice = voice; utt.lang = voice.lang; }

      // Fallback timer: estimate speech duration + 1.5s buffer
      const wordCount   = text.trim().split(/\s+/).length;
      const estimatedMs = Math.max(2500, (wordCount / 2.5) * 1000);

      fallbackTimer = setTimeout(() => {
        window.speechSynthesis.cancel();
        finish();
      }, estimatedMs + 1500);

      // Chrome pauses TTS when tab loses focus — poll every 250ms (was 8000ms)
      keepAlive = setInterval(() => {
        if (!window.speechSynthesis.speaking) { clearInterval(keepAlive); return; }
        if (window.speechSynthesis.paused) window.speechSynthesis.resume();
      }, 250);

      // "interrupted" means we cancelled it ourselves — not a real error, don't restart
      utt.onerror = (e) => {
        if ((e as any).error === "interrupted") return;
        finish();
      };
      utt.onend = finish;

      window.speechSynthesis.speak(utt);

      // If speechSynthesis hasn't started within 1.5s, Chrome silently swallowed it — retry once
      startedCheckTimer = setTimeout(() => {
        if (done) return;
        if (!window.speechSynthesis.speaking && !window.speechSynthesis.pending) {
          window.speechSynthesis.cancel();
          setTimeout(() => {
            if (!done && activeRef.current) window.speechSynthesis.speak(utt);
          }, 80);
        }
      }, 1500);

      // ── Barge-in: always monitor mic during TTS ─────────────────────────
      // allowInterrupt controls sensitivity: ON = normal threshold, OFF = stricter
      // threshold so accidental triggers are rare but deliberate speech still works.
      navigator.mediaDevices.getUserMedia({
        audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
        video: false,
      })
        .then((stream) => {
          if (stateRef.current !== "speaking" || done) { stream.getTracks().forEach(t => t.stop()); return; }
          bargeInStream = stream;
          const AudioCtxClass = window.AudioContext || (window as any).webkitAudioContext;
          bargeInCtx = new AudioCtxClass() as AudioContext;
          const bargeAnalyser = bargeInCtx.createAnalyser();
          bargeAnalyser.fftSize = 512;
          bargeInCtx.createMediaStreamSource(stream).connect(bargeAnalyser);
          const bargeBuffer = new Uint8Array(bargeAnalyser.frequencyBinCount);

          // Calibration phase: measure ambient RMS (TTS + room) for 300ms so threshold
          // adapts to the actual audio leaking into the mic, not a fixed baseline of 8.
          const BARGE_CALIBRATION_MS = 300;
          let bargeBaseline   = 8;
          let bargeCalibrated = false;
          const bargeSamples: number[] = [];
          const bargeStart = Date.now();

          const bargeInTick = () => {
            if (stateRef.current !== "speaking" || !activeRef.current || done) { stopBargeIn(); return; }
            bargeAnalyser.getByteTimeDomainData(bargeBuffer);
            let sq = 0;
            for (let i = 0; i < bargeBuffer.length; i++) sq += (bargeBuffer[i] - 128) ** 2;
            const rms  = Math.sqrt(sq / bargeBuffer.length);
            const elapsed = Date.now() - bargeStart;

            if (!bargeCalibrated) {
              if (elapsed < BARGE_CALIBRATION_MS) {
                bargeSamples.push(rms);
              } else {
                bargeBaseline   = bargeSamples.length
                  ? bargeSamples.reduce((a, b) => a + b, 0) / bargeSamples.length
                  : 8;
                bargeCalibrated = true;
              }
              bargeInRaf = requestAnimationFrame(bargeInTick);
              return;
            }

            const ttsCfg    = ttsConfigRef.current;
            const rcfg      = recordingConfigRef.current;
            const marginDef = rcfg?.bargeInNoiseMargin ?? rcfg?.noiseMarginDefault ?? DEFAULTS.bargeInNoiseMargin;
            const marginHi  = rcfg?.noiseMarginHigh ?? DEFAULTS.noiseMarginHigh;
            // allowInterrupt takes priority: if explicitly ON, always use easy threshold.
            // ignoreBackgroundNoise only raises threshold when allowInterrupt is not explicitly true.
            const margin    = (ttsCfg?.allowInterrupt === true)  ? marginDef
                            : (ttsCfg?.allowInterrupt === false) ? marginHi
                            : ttsCfg?.ignoreBackgroundNoise      ? marginHi
                            : marginDef;
            const threshold = Math.min(30, Math.max(12, bargeBaseline + margin));
            const frames    = rcfg?.bargeInFrames ?? DEFAULTS.bargeInFrames;

            if (rms > threshold) {
              bargeInCountRef.current += 1;
              if (bargeInCountRef.current >= frames) {
                stopBargeIn();
                interruptAndListenRef.current();
                return;
              }
            } else {
              bargeInCountRef.current = 0;
            }
            bargeInRaf = requestAnimationFrame(bargeInTick);
          };
          bargeInRaf = requestAnimationFrame(bargeInTick);
        })
        .catch(() => {});
    }, 70); // 70ms cancel→speak gap
  }, [setState]);

  const startConversation = useCallback(() => {
    activeRef.current = true;
    sessionPhaseRef.current = "active";
    setMicError(null);
    startListening();
  }, [startListening]);

  const stopConversation = useCallback(() => {
    activeRef.current = false;
    sessionPhaseRef.current = "stopped";
    acceptingResponseRef.current = false;
    muteRef.current = true;
    // Stop any playing backend audio
    if (currentAudioRef.current) {
      currentAudioRef.current.pause();
      currentAudioRef.current.src = "";
      currentAudioRef.current = null;
    }
    audioQueueRef.current = [];
    isQueuePlayingRef.current = false;
    queueDoneRef.current = false;
    if (recorderRef.current?.state === "recording") {
      try { recorderRef.current.stop(); } catch {}
    }
    if (typeof window !== "undefined") window.speechSynthesis?.cancel();
    setState("idle");
  }, [setState]);

  const interruptAndListen = useCallback(() => {
    if (!activeRef.current) return;
    // Discard stale chunks from the pipeline we just interrupted
    acceptingResponseRef.current = false;
    // Stop any playing HTML5 backend audio and clear the queue
    if (currentAudioRef.current) {
      currentAudioRef.current.pause();
      currentAudioRef.current.src = "";
      currentAudioRef.current = null;
    }
    audioQueueRef.current = [];
    isQueuePlayingRef.current = false;
    queueDoneRef.current = false;
    // Clean up browser TTS and any speakText timers
    finishSpeakRef.current?.();
    finishSpeakRef.current = null;
    if (typeof window !== "undefined") window.speechSynthesis?.cancel();
    // Show "listening" visually immediately, but reset stateRef to "idle" so
    // startListening's re-entry guard (stateRef === "listening") doesn't block mic setup.
    setVoiceState("listening");
    setAudioLevel(0);
    stateRef.current = "idle";
    setTimeout(() => startListeningRef.current(), 200);
  }, []);

  // Keep the ref in sync so the barge-in closure always calls the latest version
  useEffect(() => { interruptAndListenRef.current = interruptAndListen; }, [interruptAndListen]);

  // ── Barge-in monitoring for backend audio (HTML5 Audio) ────────────────────
  // Whenever William is speaking (greeting or response chunks), monitor the mic.
  // If the user speaks loudly enough for N consecutive frames, interrupt playback
  // and restart listening — same behaviour as speakText's barge-in, now for audio/.
  useEffect(() => {
    if (voiceState !== "speaking" || !activeRef.current) return;

    let stopped = false;
    let bargeInStream: MediaStream | null = null;
    let bargeInCtx: AudioContext | null = null;
    let bargeInRaf = 0;
    let bargeInCount = 0;

    const stopBargeIn = () => {
      stopped = true;
      cancelAnimationFrame(bargeInRaf);
      bargeInStream?.getTracks().forEach(t => t.stop());
      if (bargeInCtx) { bargeInCtx.close().catch(() => {}); bargeInCtx = null; }
      bargeInStream = null;
    };

    navigator.mediaDevices.getUserMedia({
      audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
      video: false,
    }).then(stream => {
      if (stopped) { stream.getTracks().forEach(t => t.stop()); return; }
      bargeInStream = stream;
      const AudioCtxClass = window.AudioContext || (window as any).webkitAudioContext;
      bargeInCtx = new AudioCtxClass() as AudioContext;
      const bargeAnalyser = bargeInCtx.createAnalyser();
      bargeAnalyser.fftSize = 512;
      bargeInCtx.createMediaStreamSource(stream).connect(bargeAnalyser);
      const bargeBuffer = new Uint8Array(bargeAnalyser.frequencyBinCount);

      // Calibrate noise floor (TTS playback leaks into mic — adapt to real ambient level)
      const BARGE_CALIBRATION_MS = 300;
      let bargeBaseline = 8;
      let bargeCalibrated = false;
      const bargeSamples: number[] = [];
      const bargeStart = Date.now();

      const bargeInTick = () => {
        if (stopped || !activeRef.current) { stopBargeIn(); return; }
        bargeAnalyser.getByteTimeDomainData(bargeBuffer);
        let sq = 0;
        for (let i = 0; i < bargeBuffer.length; i++) sq += (bargeBuffer[i] - 128) ** 2;
        const rms = Math.sqrt(sq / bargeBuffer.length);
        const elapsed = Date.now() - bargeStart;

        if (!bargeCalibrated) {
          if (elapsed < BARGE_CALIBRATION_MS) {
            bargeSamples.push(rms);
          } else {
            bargeBaseline = bargeSamples.length
              ? Math.min(15, bargeSamples.reduce((a, b) => a + b, 0) / bargeSamples.length)
              : 8;
            bargeCalibrated = true;
          }
          bargeInRaf = requestAnimationFrame(bargeInTick);
          return;
        }

        const ttsCfg = ttsConfigRef.current;
        const rcfg   = recordingConfigRef.current;
        const marginDef = rcfg?.bargeInNoiseMargin ?? rcfg?.noiseMarginDefault ?? DEFAULTS.bargeInNoiseMargin;
        const marginHi  = rcfg?.noiseMarginHigh ?? DEFAULTS.noiseMarginHigh;
        // Barge-in is ON by default. Only raise threshold when explicitly disabled.
        const allowInt  = ttsCfg?.allowInterrupt ?? true;
        const margin    = (!allowInt || ttsCfg?.ignoreBackgroundNoise) ? marginHi : marginDef;
        const threshold = Math.min(30, Math.max(12, bargeBaseline + margin));
        const frames    = rcfg?.bargeInFrames ?? DEFAULTS.bargeInFrames;

        if (rms > threshold) {
          bargeInCount++;
          if (bargeInCount >= frames) {
            stopBargeIn();
            interruptAndListenRef.current();
            return;
          }
        } else {
          bargeInCount = 0;
        }
        bargeInRaf = requestAnimationFrame(bargeInTick);
      };
      bargeInRaf = requestAnimationFrame(bargeInTick);
    }).catch(() => {});

    return () => { stopBargeIn(); };
  }, [voiceState]);

  const playGreetingAudio = useCallback((audioB64: string, format: string = "wav") => {
    if (sessionPhaseRef.current === "stopped") return;

    // Stop any active recording so the greeting isn't captured
    if (recorderRef.current?.state === "recording") {
      muteRef.current = true;
      try { recorderRef.current.stop(); } catch {}
    }

    if (!audioB64) {
      if (activeRef.current) setTimeout(() => startListeningRef.current(), 500);
      return;
    }

    setState("speaking");

    let done = false;
    const finish = () => {
      if (done) return;
      done = true;
      currentAudioRef.current = null;
      if (activeRef.current) setTimeout(() => startListeningRef.current(), 600);
    };

    try {
      const mime = format === "mp3" ? "audio/mpeg" : `audio/${format}`;
      const audio = new Audio(`data:${mime};base64,${audioB64}`);
      currentAudioRef.current = audio;
      audio.onended = finish;
      audio.onerror = finish;
      audio.play().catch(finish);
    } catch {
      finish();
    }
  }, [setState]);

  // ── Bot response audio queue ───────────────────────────────────────────────
  // Backend sends one chunk per sentence. We queue them and play in order.
  // Listening only restarts after voice_response_done AND the queue drains.
  const audioQueueRef     = useRef<{ b64: string; fmt: string }[]>([]);
  const isQueuePlayingRef = useRef(false);
  const queueDoneRef      = useRef(false);

  const playNextInQueueRef = useRef<() => void>(() => {});
  // Keep the ref current on every render so closures always call the latest version
  useEffect(() => {
    playNextInQueueRef.current = () => {
      const next = audioQueueRef.current.shift();
      if (!next) {
        isQueuePlayingRef.current = false;
        if (queueDoneRef.current && activeRef.current) {
          queueDoneRef.current = false;
          setTimeout(() => startListeningRef.current(), 600);
        }
        return;
      }
      isQueuePlayingRef.current = true;
      setState("speaking");
      try {
        const mime = next.fmt === "mp3" ? "audio/mpeg" : `audio/${next.fmt}`;
        const audio = new Audio(`data:${mime};base64,${next.b64}`);
        currentAudioRef.current = audio;
        audio.onended = () => { currentAudioRef.current = null; playNextInQueueRef.current(); };
        audio.onerror = () => { currentAudioRef.current = null; playNextInQueueRef.current(); };
        audio.play().catch(() => { currentAudioRef.current = null; playNextInQueueRef.current(); });
      } catch {
        currentAudioRef.current = null;
        playNextInQueueRef.current();
      }
    };
  });

  const playResponseChunk = useCallback((audioB64: string, format: string = "wav") => {
    // Discard chunks from a pipeline the user already interrupted
    if (!acceptingResponseRef.current || sessionPhaseRef.current === "stopped" || !audioB64) return;
    if (recorderRef.current?.state === "recording") {
      muteRef.current = true;
      try { recorderRef.current.stop(); } catch {}
    }
    audioQueueRef.current.push({ b64: audioB64, fmt: format });
    if (!isQueuePlayingRef.current) playNextInQueueRef.current();
  }, []);

  const onVoiceResponseDone = useCallback(() => {
    // Ignore done signals from a pipeline the user already interrupted
    if (!acceptingResponseRef.current) return;
    queueDoneRef.current = true;
    // If queue already drained (or TTS failed with no chunks), restart listening now.
    // Must also clear "thinking" state — startListening guards against re-entry
    // when state is "thinking", which would lock the session if no audio arrived.
    if (!isQueuePlayingRef.current && audioQueueRef.current.length === 0 && activeRef.current) {
      queueDoneRef.current = false;
      if (stateRef.current === "thinking") setState("idle");
      setTimeout(() => startListeningRef.current(), 600);
    }
  }, [setState]);

  return {
    voiceState,
    audioLevel,
    micError,
    startConversation,
    stopConversation,
    playGreetingAudio,
    playResponseChunk,
    onVoiceResponseDone,
    stopAndSend,
    interruptAndListen,
  };
}
