import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatContext } from "../llm/chat_context.js";
import { initializeLogger } from "../log.js";
import {
  AudioRecognition
} from "./audio_recognition.js";
function createHooks() {
  return {
    onInterruption: vi.fn(),
    onStartOfSpeech: vi.fn(),
    onVADInferenceDone: vi.fn(),
    onEndOfSpeech: vi.fn(),
    onInterimTranscript: vi.fn(),
    onFinalTranscript: vi.fn(),
    onPreemptiveGeneration: vi.fn(),
    onAgentBackchannelOpportunity: vi.fn(),
    retrieveChatCtx: () => ChatContext.empty(),
    onEndOfTurn: vi.fn(async () => true)
  };
}
function createRecognition(overrides = {}) {
  return new AudioRecognition({
    recognitionHooks: createHooks(),
    minEndpointingDelay: 0,
    maxEndpointingDelay: 0,
    ...overrides
  });
}
function emitOverlapSpeechEvent(ar, ev) {
  ar.onOverlapSpeechEvent(ev);
}
function overlapSpeechEvent(isInterruption) {
  return {
    type: "overlapping_speech",
    detectedAt: Date.now(),
    isInterruption,
    totalDurationInS: 0,
    predictionDurationInS: 0,
    detectionDelayInS: 0,
    probability: isInterruption ? 1 : 0,
    numRequests: 1
  };
}
describe("AudioRecognition backchannel boundary", () => {
  initializeLogger({ pretty: false, level: "silent" });
  beforeEach(() => {
    vi.useFakeTimers();
  });
  afterEach(() => {
    vi.useRealTimers();
  });
  describe("option normalization", () => {
    it("accepts a single number and applies it to the start side", async () => {
      const ar = createRecognition({ backchannelBoundary: 250 });
      const cb = vi.fn();
      await ar.onStartOfAgentSpeech(Date.now());
      ar.backchannelBoundaryCallback = cb;
      expect(ar.backchannelBoundaryActive).toBe(true);
      vi.advanceTimersByTime(249);
      expect(cb).not.toHaveBeenCalled();
      vi.advanceTimersByTime(1);
      expect(cb).toHaveBeenCalledTimes(1);
      expect(ar.backchannelBoundaryActive).toBe(false);
      await ar.close();
    });
    it("accepts a [start, end] tuple and uses the start value for the start timer", async () => {
      const ar = createRecognition({ backchannelBoundary: [100, 999] });
      const cb = vi.fn();
      await ar.onStartOfAgentSpeech(Date.now());
      ar.backchannelBoundaryCallback = cb;
      vi.advanceTimersByTime(99);
      expect(cb).not.toHaveBeenCalled();
      vi.advanceTimersByTime(1);
      expect(cb).toHaveBeenCalledTimes(1);
      await ar.close();
    });
    it("disables both sides cleanly when set to null", async () => {
      const ar = createRecognition({ backchannelBoundary: null });
      const cb = vi.fn();
      await ar.onStartOfAgentSpeech(Date.now());
      ar.backchannelBoundaryCallback = cb;
      expect(ar.backchannelBoundaryActive).toBe(false);
      vi.advanceTimersByTime(6e4);
      expect(cb).not.toHaveBeenCalled();
      await ar.close();
    });
    it("disables both sides cleanly when undefined", async () => {
      const ar = createRecognition();
      await ar.onStartOfAgentSpeech(Date.now());
      expect(ar.backchannelBoundaryActive).toBe(false);
      await ar.close();
    });
    it("does not arm the start timer when start cooldown is 0", async () => {
      const ar = createRecognition({ backchannelBoundary: [0, 500] });
      const cb = vi.fn();
      await ar.onStartOfAgentSpeech(Date.now());
      ar.backchannelBoundaryCallback = cb;
      expect(ar.backchannelBoundaryActive).toBe(false);
      vi.advanceTimersByTime(1e3);
      expect(cb).not.toHaveBeenCalled();
      await ar.close();
    });
    it("throws on a negative single number", () => {
      expect(() => createRecognition({ backchannelBoundary: -1 })).toThrow(/non-negative number/);
    });
    it("throws on a tuple with a negative element", () => {
      expect(() => createRecognition({ backchannelBoundary: [100, -1] })).toThrow(
        /non-negative numbers/
      );
    });
  });
  describe("lifecycle", () => {
    it("re-arms the start cooldown when onStartOfAgentSpeech is called again", async () => {
      const ar = createRecognition({ backchannelBoundary: 200 });
      const cb1 = vi.fn();
      const cb2 = vi.fn();
      await ar.onStartOfAgentSpeech(Date.now());
      ar.backchannelBoundaryCallback = cb1;
      expect(ar.backchannelBoundaryActive).toBe(true);
      vi.advanceTimersByTime(50);
      await ar.onStartOfAgentSpeech(Date.now());
      ar.backchannelBoundaryCallback = cb2;
      expect(ar.backchannelBoundaryActive).toBe(true);
      vi.advanceTimersByTime(199);
      expect(cb1).not.toHaveBeenCalled();
      expect(cb2).not.toHaveBeenCalled();
      vi.advanceTimersByTime(1);
      expect(cb1).not.toHaveBeenCalled();
      expect(cb2).toHaveBeenCalledTimes(1);
      await ar.close();
    });
    it("cancelBackchannelBoundary clears both the timer and the callback", async () => {
      const ar = createRecognition({ backchannelBoundary: 500 });
      const cb = vi.fn();
      await ar.onStartOfAgentSpeech(Date.now());
      ar.backchannelBoundaryCallback = cb;
      expect(ar.backchannelBoundaryActive).toBe(true);
      ar.cancelBackchannelBoundary();
      expect(ar.backchannelBoundaryActive).toBe(false);
      expect(ar.backchannelBoundaryCallback).toBeUndefined();
      vi.advanceTimersByTime(1e3);
      expect(cb).not.toHaveBeenCalled();
      await ar.close();
    });
    it("onEndOfAgentSpeech cancels a pending start-boundary timer", async () => {
      const ar = createRecognition({ backchannelBoundary: [1e3, 500] });
      const cb = vi.fn();
      await ar.onStartOfAgentSpeech(Date.now());
      ar.backchannelBoundaryCallback = cb;
      expect(ar.backchannelBoundaryActive).toBe(true);
      await ar.onEndOfAgentSpeech(Date.now() + 1e3);
      expect(ar.backchannelBoundaryActive).toBe(false);
      vi.advanceTimersByTime(2e3);
      expect(cb).not.toHaveBeenCalled();
      await ar.close();
    });
    it("disableInterruptionDetection() cancels a pending boundary timer", async () => {
      const ar = createRecognition({ backchannelBoundary: 1e3 });
      const cb = vi.fn();
      await ar.onStartOfAgentSpeech(Date.now());
      ar.backchannelBoundaryCallback = cb;
      expect(ar.backchannelBoundaryActive).toBe(true);
      await ar.disableInterruptionDetection();
      expect(ar.backchannelBoundaryActive).toBe(false);
      vi.advanceTimersByTime(2e3);
      expect(cb).not.toHaveBeenCalled();
      await ar.close();
    });
    it("close() cancels a pending boundary timer (no leak on shutdown)", async () => {
      const ar = createRecognition({ backchannelBoundary: 1e3 });
      const cb = vi.fn();
      await ar.onStartOfAgentSpeech(Date.now());
      ar.backchannelBoundaryCallback = cb;
      expect(ar.backchannelBoundaryActive).toBe(true);
      await ar.close();
      expect(ar.backchannelBoundaryActive).toBe(false);
      vi.advanceTimersByTime(2e3);
      expect(cb).not.toHaveBeenCalled();
    });
  });
  describe("overlap events", () => {
    it("suppresses backchannels during the boundary without blocking interruptions", async () => {
      const hooks = createHooks();
      const ar = new AudioRecognition({
        recognitionHooks: hooks,
        minEndpointingDelay: 0,
        maxEndpointingDelay: 0,
        backchannelBoundary: [50, 0]
      });
      await ar.onStartOfAgentSpeech(Date.now());
      emitOverlapSpeechEvent(ar, overlapSpeechEvent(false));
      expect(hooks.onInterruption).not.toHaveBeenCalled();
      emitOverlapSpeechEvent(ar, overlapSpeechEvent(true));
      expect(hooks.onInterruption).toHaveBeenCalledTimes(1);
      vi.advanceTimersByTime(50);
      emitOverlapSpeechEvent(ar, overlapSpeechEvent(false));
      emitOverlapSpeechEvent(ar, overlapSpeechEvent(true));
      expect(hooks.onInterruption).toHaveBeenCalledTimes(2);
      await ar.close();
    });
  });
});
//# sourceMappingURL=audio_recognition_backchannel.test.js.map