import { ParticipantKind } from "@livekit/rtc-node";
import { describe, expect, it, vi } from "vitest";
import {
  BaseStreamingTurnDetector,
  BaseStreamingTurnDetectorStream,
  MIN_SILENCE_DURATION_MS
} from "../inference/eot/base.js";
import { ChatContext } from "../llm/chat_context.js";
import { initializeLogger } from "../log.js";
import { Future } from "../utils.js";
import { VADEventType } from "../vad.js";
import {
  AudioRecognition
} from "./audio_recognition.js";
initializeLogger({ pretty: false, level: "silent" });
function makeHooks() {
  return {
    onInterruption: vi.fn(),
    onStartOfSpeech: vi.fn(),
    onVADInferenceDone: vi.fn(),
    onEndOfSpeech: vi.fn(),
    onInterimTranscript: vi.fn(),
    onFinalTranscript: vi.fn(),
    onEotPrediction: vi.fn(),
    onAgentBackchannelOpportunity: vi.fn(),
    onPreemptiveGeneration: vi.fn(),
    onUserTurnExceeded: vi.fn(),
    retrieveChatCtx: () => ChatContext.empty(),
    onEndOfTurn: vi.fn(async () => false)
    // don't commit by default
  };
}
function makeRecognition(opts = {}) {
  const hooks = makeHooks();
  const full = {
    recognitionHooks: hooks,
    stt: void 0,
    vad: void 0,
    interruptionDetection: void 0,
    turnDetectionMode: "vad",
    minEndpointingDelay: 10,
    maxEndpointingDelay: 500,
    getLinkedParticipant: () => ({ sid: "p1", identity: "bob", kind: ParticipantKind.AGENT }),
    ...opts
  };
  const recognition = new AudioRecognition(full);
  return { recognition, internals: recognition, hooks };
}
function makeAudioStream() {
  const stream = Object.create(BaseStreamingTurnDetectorStream.prototype);
  stream.supportsLanguage = vi.fn(async () => true);
  stream.unlikelyThreshold = vi.fn(async () => 0.5);
  stream.backchannelThreshold = vi.fn(async () => void 0);
  stream.predict = vi.fn(() => new Future());
  stream.cancelInference = vi.fn();
  stream.flush = vi.fn();
  return stream;
}
function makeAudioDetector(stream) {
  const detector = Object.create(BaseStreamingTurnDetector.prototype);
  detector.stream = vi.fn(() => stream);
  return detector;
}
function resolvedPrediction(probability, opts = {}) {
  const event = {
    type: "eot_prediction",
    endOfTurnProbability: probability,
    lastSpeakingTimeMs: 0,
    inferenceDuration: opts.inferenceDuration,
    detectionDelay: opts.detectionDelay,
    backchannelProbability: opts.backchannelProbability
  };
  const fut = new Future();
  fut.resolve(event);
  return { fut, event };
}
function predictMock(stream) {
  return stream.predict;
}
function cancelInferenceMock(stream) {
  return stream.cancelInference;
}
function inferenceDone(rawAccumulatedSpeech, rawAccumulatedSilence = 0) {
  return {
    type: VADEventType.INFERENCE_DONE,
    samplesIndex: 0,
    timestamp: 0,
    speechDuration: 0,
    silenceDuration: 0,
    frames: [],
    probability: 0,
    inferenceDuration: 0,
    speaking: false,
    rawAccumulatedSilence,
    rawAccumulatedSpeech
  };
}
function startOfSpeech() {
  return {
    type: VADEventType.START_OF_SPEECH,
    samplesIndex: 0,
    timestamp: 0,
    speechDuration: 500,
    silenceDuration: 0,
    frames: [],
    probability: 0,
    inferenceDuration: 0,
    speaking: true,
    rawAccumulatedSilence: 0,
    rawAccumulatedSpeech: 500
  };
}
function endOfSpeech() {
  return {
    type: VADEventType.END_OF_SPEECH,
    samplesIndex: 0,
    timestamp: 0,
    speechDuration: 0,
    silenceDuration: 300,
    frames: [],
    probability: 0,
    inferenceDuration: 0,
    speaking: false,
    rawAccumulatedSilence: 300,
    rawAccumulatedSpeech: 0
  };
}
function flush() {
  return new Promise((resolve) => setImmediate(resolve));
}
function runScriptedVad(internals) {
  let resolveNext = null;
  const buffered = [];
  let closed = false;
  const stream = {
    updateInputStream(_s) {
    },
    detachInputStream() {
    },
    close() {
      closed = true;
      if (resolveNext) {
        resolveNext({ done: true, value: void 0 });
        resolveNext = null;
      }
    },
    [Symbol.asyncIterator]() {
      return {
        next() {
          if (buffered.length > 0) {
            return Promise.resolve({ done: false, value: buffered.shift() });
          }
          if (closed) {
            return Promise.resolve({ done: true, value: void 0 });
          }
          return new Promise((res) => {
            resolveNext = res;
          });
        }
      };
    }
  };
  const vad = { stream: () => stream };
  const controller = new AbortController();
  const task = internals.createVadTask(vad, controller.signal);
  return {
    async feed(ev) {
      if (resolveNext) {
        const res = resolveNext;
        resolveNext = null;
        res({ done: false, value: ev });
      } else {
        buffered.push(ev);
      }
      await flush();
      await flush();
    },
    async stop() {
      controller.abort();
      await task.catch(() => {
      });
    }
  };
}
describe("TestSpeakingGuardRace", () => {
  it("cancels the in-flight bounce when speaking starts during endpointing", async () => {
    const { internals, hooks } = makeRecognition();
    const stream = makeAudioStream();
    internals.turnDetectorStream = stream;
    internals.turnDetector = makeAudioDetector(stream);
    internals.turnDetectorPredictionFut = resolvedPrediction(0.2).fut;
    internals.runEOUDetection(ChatContext.empty(), "vad");
    await new Promise((r) => setTimeout(r, 50));
    internals.userSpeakingEvent.set();
    expect(internals.bounceEOUTask).toBeDefined();
    await internals.bounceEOUTask.result.catch(() => {
    });
    expect(hooks.onEndOfTurn).not.toHaveBeenCalled();
  });
  it("short-circuits without spawning the bounce when already speaking", async () => {
    const { internals, hooks } = makeRecognition();
    const stream = makeAudioStream();
    internals.turnDetectorStream = stream;
    internals.turnDetector = makeAudioDetector(stream);
    internals.speaking = true;
    internals.runEOUDetection(ChatContext.empty(), "vad");
    expect(internals.bounceEOUTask).toBeDefined();
    await internals.bounceEOUTask.result.catch(() => {
    });
    expect(hooks.onEndOfTurn).not.toHaveBeenCalled();
    expect(predictMock(stream).mock.calls.length).toBe(0);
  });
});
describe("TestEotPredictionDedup", () => {
  it("emits onEotPrediction once across vad then stt triggers", async () => {
    var _a;
    const { internals, hooks } = makeRecognition();
    const { fut, event } = resolvedPrediction(0.2, { inferenceDuration: 50, detectionDelay: 100 });
    const stream = makeAudioStream();
    internals.turnDetectorStream = stream;
    internals.turnDetector = makeAudioDetector(stream);
    internals.turnDetectorPredictionFut = fut;
    internals.runEOUDetection(ChatContext.empty(), "vad");
    await flush();
    await flush();
    expect(hooks.onEotPrediction).toHaveBeenCalledTimes(1);
    internals.runEOUDetection(ChatContext.empty(), "stt");
    await flush();
    await flush();
    expect(hooks.onEotPrediction).toHaveBeenCalledTimes(1);
    expect(internals.lastEmittedEotPrediction).toBe(event);
    await ((_a = internals.bounceEOUTask) == null ? void 0 : _a.cancelAndWait().catch(() => {
    }));
  });
  it("emits on every bounce for a text-based detector", async () => {
    var _a;
    const { internals, hooks } = makeRecognition();
    const textDetector = {
      model: "fake",
      provider: "fake",
      supportsLanguage: vi.fn(async () => true),
      unlikelyThreshold: vi.fn(async () => 0.5),
      predictEndOfTurn: vi.fn(async () => 0.2)
    };
    internals.turnDetector = textDetector;
    internals.turnDetectorStream = void 0;
    internals.audioTranscript = "hello there";
    internals.runEOUDetection(ChatContext.empty(), "vad");
    await flush();
    await flush();
    expect(hooks.onEotPrediction).toHaveBeenCalledTimes(1);
    internals.runEOUDetection(ChatContext.empty(), "stt");
    await flush();
    await flush();
    expect(hooks.onEotPrediction).toHaveBeenCalledTimes(2);
    await ((_a = internals.bounceEOUTask) == null ? void 0 : _a.cancelAndWait().catch(() => {
    }));
  });
  it("clearUserTurn resets the dedup guard so the next turn emits again", () => {
    const { internals } = makeRecognition();
    internals.lastEmittedEotPrediction = resolvedPrediction(0.2).event;
    internals.audioInterimTranscript = "";
    internals.audioPreflightTranscript = "";
    internals.sttRequestIds = [];
    internals.clearUserTurn();
    expect(internals.lastEmittedEotPrediction).toBeUndefined();
  });
});
describe("TestBackchannelOpportunityEmit", () => {
  async function drive(internals) {
    var _a;
    internals.runEOUDetection(ChatContext.empty(), "vad");
    await flush();
    await flush();
    await ((_a = internals.bounceEOUTask) == null ? void 0 : _a.cancelAndWait().catch(() => {
    }));
  }
  it("emits with eot context when the turn continues", async () => {
    const { internals, hooks } = makeRecognition();
    const stream = makeAudioStream();
    stream.backchannelThreshold = vi.fn(async () => 0.5);
    internals.turnDetectorStream = stream;
    internals.turnDetector = makeAudioDetector(stream);
    internals.turnDetectorPredictionFut = resolvedPrediction(0.2, {
      backchannelProbability: 0.8
    }).fut;
    await drive(internals);
    expect(hooks.onAgentBackchannelOpportunity).toHaveBeenCalledTimes(1);
    const ev = hooks.onAgentBackchannelOpportunity.mock.calls[0][0];
    expect(ev.probability).toBeCloseTo(0.8);
    expect(ev.threshold).toBeCloseTo(0.5);
    expect(ev.endOfTurnProbability).toBeCloseTo(0.2);
    expect(ev.endOfTurnThreshold).toBeCloseTo(0.5);
  });
  it("emits once across vad then stt triggers (shares the eot dedupe)", async () => {
    var _a;
    const { internals, hooks } = makeRecognition();
    const stream = makeAudioStream();
    stream.backchannelThreshold = vi.fn(async () => 0.5);
    internals.turnDetectorStream = stream;
    internals.turnDetector = makeAudioDetector(stream);
    internals.turnDetectorPredictionFut = resolvedPrediction(0.2, {
      backchannelProbability: 0.8
    }).fut;
    internals.runEOUDetection(ChatContext.empty(), "vad");
    await flush();
    await flush();
    expect(hooks.onAgentBackchannelOpportunity).toHaveBeenCalledTimes(1);
    internals.runEOUDetection(ChatContext.empty(), "stt");
    await flush();
    await flush();
    expect(hooks.onAgentBackchannelOpportunity).toHaveBeenCalledTimes(1);
    await ((_a = internals.bounceEOUTask) == null ? void 0 : _a.cancelAndWait().catch(() => {
    }));
  });
  it("emits with eot context when the turn ends", async () => {
    const { internals, hooks } = makeRecognition();
    const stream = makeAudioStream();
    stream.backchannelThreshold = vi.fn(async () => 0.5);
    internals.turnDetectorStream = stream;
    internals.turnDetector = makeAudioDetector(stream);
    internals.turnDetectorPredictionFut = resolvedPrediction(0.9, {
      backchannelProbability: 0.8
    }).fut;
    await drive(internals);
    expect(hooks.onAgentBackchannelOpportunity).toHaveBeenCalledTimes(1);
    const ev = hooks.onAgentBackchannelOpportunity.mock.calls[0][0];
    expect(ev.endOfTurnProbability).toBeCloseTo(0.9);
    expect(ev.endOfTurnThreshold).toBeCloseTo(0.5);
  });
  it("does not emit below threshold", async () => {
    const { internals, hooks } = makeRecognition();
    const stream = makeAudioStream();
    stream.backchannelThreshold = vi.fn(async () => 0.7);
    internals.turnDetectorStream = stream;
    internals.turnDetector = makeAudioDetector(stream);
    internals.turnDetectorPredictionFut = resolvedPrediction(0.2, {
      backchannelProbability: 0.4
    }).fut;
    await drive(internals);
    expect(hooks.onAgentBackchannelOpportunity).not.toHaveBeenCalled();
  });
  it("does not emit when backchannel is disabled", async () => {
    const { internals, hooks } = makeRecognition();
    const stream = makeAudioStream();
    internals.turnDetectorStream = stream;
    internals.turnDetector = makeAudioDetector(stream);
    internals.turnDetectorPredictionFut = resolvedPrediction(0.2, {
      backchannelProbability: 0.9
    }).fut;
    await drive(internals);
    expect(hooks.onAgentBackchannelOpportunity).not.toHaveBeenCalled();
  });
  it("does not emit for a text-based detector", async () => {
    const { internals, hooks } = makeRecognition();
    const textDetector = {
      model: "fake",
      provider: "fake",
      supportsLanguage: vi.fn(async () => true),
      unlikelyThreshold: vi.fn(async () => 0.5),
      predictEndOfTurn: vi.fn(async () => 0.2)
    };
    internals.turnDetector = textDetector;
    internals.turnDetectorStream = void 0;
    internals.audioTranscript = "hello there";
    await drive(internals);
    expect(hooks.onAgentBackchannelOpportunity).not.toHaveBeenCalled();
  });
});
describe("TestPredictionFutureLifecycle", () => {
  it("silence tick starts a request once", async () => {
    const { internals } = makeRecognition();
    const stream = makeAudioStream();
    internals.turnDetectorStream = stream;
    internals.turnDetector = makeAudioDetector(stream);
    internals.speaking = true;
    const vad = runScriptedVad(internals);
    try {
      await vad.feed(inferenceDone(0, 300));
      await vad.feed(inferenceDone(0, 400));
      expect(predictMock(stream).mock.calls.length).toBe(1);
      expect(internals.turnDetectorPredictionFut).toBeDefined();
    } finally {
      await vad.stop();
    }
  });
  it("resumed speech without SOS rearms the next pause", async () => {
    const { internals } = makeRecognition();
    const stream = makeAudioStream();
    internals.turnDetectorStream = stream;
    internals.turnDetector = makeAudioDetector(stream);
    internals.speaking = true;
    const vad = runScriptedVad(internals);
    try {
      await vad.feed(inferenceDone(0, 300));
      const firstFut = internals.turnDetectorPredictionFut;
      expect(firstFut).toBeDefined();
      firstFut.resolve(resolvedPrediction(0.1).event);
      await vad.feed(inferenceDone(1, 0));
      expect(cancelInferenceMock(stream)).toHaveBeenCalledTimes(1);
      expect(cancelInferenceMock(stream)).toHaveBeenCalledWith();
      expect(internals.turnDetectorPredictionFut).toBeUndefined();
      await vad.feed(inferenceDone(0, 300));
      expect(predictMock(stream).mock.calls.length).toBe(2);
      expect(internals.turnDetectorPredictionFut).toBeDefined();
      expect(internals.turnDetectorPredictionFut).not.toBe(firstFut);
    } finally {
      await vad.stop();
    }
  });
  it("silence tick starts a request even while the agent is speaking", async () => {
    const { internals } = makeRecognition();
    const stream = makeAudioStream();
    internals.turnDetectorStream = stream;
    internals.turnDetector = makeAudioDetector(stream);
    internals.speaking = true;
    internals.isAgentSpeaking = true;
    const vad = runScriptedVad(internals);
    try {
      await vad.feed(inferenceDone(0, 300));
      expect(predictMock(stream).mock.calls.length).toBe(1);
      expect(internals.turnDetectorPredictionFut).toBeDefined();
    } finally {
      await vad.stop();
    }
  });
  it("EOS consumes the silence-tick request without predicting", async () => {
    const { internals, hooks } = makeRecognition();
    const stream = makeAudioStream();
    internals.turnDetectorStream = stream;
    internals.turnDetector = makeAudioDetector(stream);
    internals.speaking = true;
    const { fut } = resolvedPrediction(0.9);
    internals.turnDetectorPredictionFut = fut;
    const vad = runScriptedVad(internals);
    try {
      await vad.feed(endOfSpeech());
      expect(predictMock(stream).mock.calls.length).toBe(0);
      expect(internals.turnDetectorPredictionFut).toBe(fut);
      expect(internals.bounceEOUTask).toBeDefined();
      await internals.bounceEOUTask.result.catch(() => {
      });
      expect(hooks.onEotPrediction).toHaveBeenCalledTimes(1);
    } finally {
      await vad.stop();
    }
  });
  it("SOS tears down the request and rearms", async () => {
    const { internals } = makeRecognition();
    const stream = makeAudioStream();
    internals.turnDetectorStream = stream;
    internals.turnDetector = makeAudioDetector(stream);
    internals.turnDetectorPredictionFut = new Future();
    internals.turnDetectorFlushed = true;
    const vad = runScriptedVad(internals);
    try {
      await vad.feed(startOfSpeech());
      expect(cancelInferenceMock(stream)).toHaveBeenCalledTimes(1);
      expect(cancelInferenceMock(stream)).toHaveBeenCalledWith();
      expect(internals.turnDetectorPredictionFut).toBeUndefined();
      expect(internals.turnDetectorFlushed).toBe(false);
    } finally {
      await vad.stop();
    }
  });
  it("EOS never starts a request", async () => {
    const { internals } = makeRecognition();
    const stream = makeAudioStream();
    internals.turnDetectorStream = stream;
    internals.turnDetector = makeAudioDetector(stream);
    const vad = runScriptedVad(internals);
    try {
      await vad.feed(endOfSpeech());
      expect(predictMock(stream).mock.calls.length).toBe(0);
      expect(internals.turnDetectorPredictionFut).toBeUndefined();
      const { fut } = resolvedPrediction(0.9);
      internals.turnDetectorPredictionFut = fut;
      await vad.feed(endOfSpeech());
      expect(predictMock(stream).mock.calls.length).toBe(0);
      expect(internals.turnDetectorPredictionFut).toBe(fut);
    } finally {
      await vad.stop();
    }
  });
  it("late stt final after flush short-circuits and warns once", async () => {
    const { internals, hooks } = makeRecognition();
    const stream = makeAudioStream();
    internals.turnDetectorStream = stream;
    internals.turnDetector = makeAudioDetector(stream);
    internals.turnDetectorFlushed = true;
    for (let i = 0; i < 2; i++) {
      internals.runEOUDetection(ChatContext.empty(), "stt");
      expect(internals.bounceEOUTask).toBeDefined();
      await internals.bounceEOUTask.result.catch(() => {
      });
    }
    expect(predictMock(stream).mock.calls.length).toBe(0);
    expect(hooks.onEotPrediction).not.toHaveBeenCalled();
    expect(internals.turnDetectorLatePredictionWarned).toBe(true);
  });
  it("predict timeout signals fallback and drops the future", async () => {
    const { internals, hooks } = makeRecognition();
    const stream = makeAudioStream();
    internals.turnDetectorStream = stream;
    internals.turnDetector = makeAudioDetector(stream);
    internals.turnDetectorPredictionFut = new Future();
    internals.runEOUDetection(ChatContext.empty(), "vad");
    expect(internals.bounceEOUTask).toBeDefined();
    await internals.bounceEOUTask.result.catch(() => {
    });
    expect(cancelInferenceMock(stream)).toHaveBeenCalledTimes(1);
    expect(cancelInferenceMock(stream)).toHaveBeenCalledWith({ timedOut: true });
    expect(internals.turnDetectorPredictionFut).toBeUndefined();
    expect(hooks.onEotPrediction).not.toHaveBeenCalled();
    expect(stream.unlikelyThreshold).not.toHaveBeenCalled();
    expect(hooks.onEndOfTurn).toHaveBeenCalledTimes(1);
  });
  it("commit flushes the stream and marks the turn flushed", async () => {
    const { internals, hooks } = makeRecognition();
    hooks.onEndOfTurn.mockResolvedValue(true);
    const stream = makeAudioStream();
    internals.turnDetectorStream = stream;
    internals.turnDetector = makeAudioDetector(stream);
    internals.turnDetectorPredictionFut = resolvedPrediction(0.9).fut;
    internals.runEOUDetection(ChatContext.empty(), "vad");
    expect(internals.bounceEOUTask).toBeDefined();
    await internals.bounceEOUTask.result.catch(() => {
    });
    expect(stream.flush).toHaveBeenCalledWith("turn committed");
    expect(internals.turnDetectorPredictionFut).toBeUndefined();
    expect(internals.turnDetectorFlushed).toBe(true);
  });
});
describe("TestVadMinSilenceRequirement", () => {
  const requiredMs = MIN_SILENCE_DURATION_MS + 50;
  const fakeVad = (minSilenceDuration) => ({ minSilenceDuration });
  it("raises when min silence is too low for an audio detector", () => {
    const { internals } = makeRecognition();
    internals.vad = fakeVad(requiredMs - 1);
    internals.turnDetector = makeAudioDetector(makeAudioStream());
    expect(() => internals.checkVadSilenceRequirement()).toThrow(/minSilenceDuration/);
  });
  it("passes when min silence is adequate", () => {
    const { internals } = makeRecognition();
    internals.vad = fakeVad(requiredMs + 250);
    internals.turnDetector = makeAudioDetector(makeAudioStream());
    expect(() => internals.checkVadSilenceRequirement()).not.toThrow();
  });
  it("skips validation for a non-audio detector", () => {
    const { internals } = makeRecognition();
    internals.vad = fakeVad(requiredMs - 1);
    internals.turnDetector = { model: "x", provider: "x" };
    expect(() => internals.checkVadSilenceRequirement()).not.toThrow();
  });
  it("skips validation when there is no VAD", () => {
    const { internals } = makeRecognition();
    internals.vad = void 0;
    internals.turnDetector = makeAudioDetector(makeAudioStream());
    expect(() => internals.checkVadSilenceRequirement()).not.toThrow();
  });
  it("skips validation when the VAD exposes no min-silence knob", () => {
    const { internals } = makeRecognition();
    internals.vad = fakeVad(null);
    internals.turnDetector = makeAudioDetector(makeAudioStream());
    expect(() => internals.checkVadSilenceRequirement()).not.toThrow();
  });
  it("updateTurnDetector validates the pairing before building a stream", () => {
    const { internals } = makeRecognition();
    internals.vad = fakeVad(requiredMs - 1);
    const stream = makeAudioStream();
    const detector = makeAudioDetector(stream);
    expect(() => internals.updateTurnDetector(detector)).toThrow(/minSilenceDuration/);
    expect(internals.turnDetectorStream).toBeUndefined();
    expect(detector.stream.mock.calls.length).toBe(0);
  });
});
//# sourceMappingURL=audio_recognition_turn_detection.test.js.map