import { AdaptiveSegmenter } from "./adaptive-segmenter.js";
import { emptyAsrMetrics } from "./asr-metrics.js";
const SAMPLE_RATE = 16_000;
const REQUIRED_VOTES = 2;
const MAX_STARTUP_SEGMENTS = 4;
const MAX_STARTUP_VOICED_MS = 20_000;
const MAX_REPLAY_SEGMENTS = 2;
const MAX_REPLAY_SAMPLES = SAMPLE_RATE * 8;
/** Select a language from script evidence, never relative candidate length. */
export function detectEnglishOrJapanese(english, japanese) {
    const kana = (japanese.match(/[\u3040-\u30ff]/gu) ?? []).length;
    const japaneseLetters = (japanese.match(/[\u3040-\u30ff\u3400-\u9fff]/gu) ?? []).length;
    if (kana >= 2 && japaneseLetters >= 3)
        return "ja";
    const words = english.match(/[A-Za-z]+(?:['-][A-Za-z]+)*/gu) ?? [];
    return words.length >= 2 && kana === 0 ? "en" : undefined;
}
/**
 * Bounded automatic language state machine. Initial selection needs two
 * speech segments.  The chosen recognizer remains locked for the subtitle
 * session: language-specific ASR output is not reliable enough to infer a
 * later language switch without a dedicated LID model.
 */
export class AutoLanguageEngine {
    english;
    japanese;
    backend = "sherpa-wasm";
    mode = "segmented";
    config;
    selected;
    selectedLanguage;
    phase = "probing";
    segmenter = new AdaptiveSegmenter();
    replay = [];
    pendingSegments = [];
    draining = false;
    generation = 0;
    paused = false;
    startupSegments = 0;
    startupVoicedMs = 0;
    vote;
    voteCount = 0;
    englishText = "";
    japaneseText = "";
    constructor(english, japanese) {
        this.english = english;
        this.japanese = japanese;
    }
    async init(config) {
        this.dispose();
        this.config = config;
        this.phase = "probing";
        this.paused = false;
        this.resetEvidence();
        const generation = this.generation;
        await Promise.all([this.english.init(this.candidateConfig("en")), this.japanese.init(this.candidateConfig("ja"))]);
        if (generation !== this.generation || !this.config) {
            this.english.dispose();
            this.japanese.dispose();
        }
    }
    pushAudio(pcm16, startMs) {
        if (!this.config || this.paused || pcm16.length === 0)
            return;
        // The language has already been confirmed for this subtitle session.
        // Keep the realtime recognizer's hot path identical to an explicitly
        // selected language: no Float32 copy, VAD pass, candidate queue, or rival
        // model work may compete with each incoming 100 ms audio frame.
        if (this.selected) {
            this.selected.pushAudio(pcm16, startMs);
            return;
        }
        const audio = new Float32Array(pcm16.length);
        for (let index = 0; index < pcm16.length; index++)
            audio[index] = pcm16[index] / 0x7fff;
        for (const segment of this.segmenter.push(audio, startMs))
            this.enqueue(segment);
    }
    flush(endMs) {
        if (this.selected) {
            this.selected.flush(endMs);
            return;
        }
        const segment = this.segmenter.flush();
        if (segment)
            this.enqueue(segment);
    }
    reset(reason) {
        this.generation += 1;
        this.segmenter.reset();
        this.pendingSegments.length = 0;
        this.replay.length = 0;
        this.resetEvidence();
        this.selected?.reset(reason);
        this.challenger()?.reset(reason);
        if (this.selected) {
            this.phase = "locked";
        }
        else {
            this.phase = "probing";
            this.startupSegments = 0;
            this.startupVoicedMs = 0;
        }
    }
    setPaused(paused) {
        this.paused = paused;
        if (paused) {
            this.segmenter.reset();
            this.pendingSegments.length = 0;
            this.resetEvidence();
            this.selected?.setPaused(true);
            this.challenger()?.setPaused(true);
            return;
        }
        this.selected?.setPaused(false);
        this.challenger()?.setPaused(true);
    }
    idlePause() { this.setPaused(true); }
    resumeFromIdle() { this.setPaused(false); }
    recordDroppedAudio(durationMs) { this.selected?.recordDroppedAudio?.(durationMs); if (!this.selected) {
        this.english.recordDroppedAudio?.(durationMs);
        this.japanese.recordDroppedAudio?.(durationMs);
    } }
    metrics() {
        if (!this.selected) {
            const english = this.english.metrics();
            const japanese = this.japanese.metrics();
            const received = Math.max(english.audioReceivedMs, japanese.audioReceivedMs);
            const processed = Math.max(english.audioProcessedMs, japanese.audioProcessedMs);
            const inferenceMs = english.inferenceMs + japanese.inferenceMs;
            return { ...english, audioReceivedMs: received, audioProcessedMs: processed, inferenceMs, rtf: processed > 0 ? inferenceMs / processed : 0, audioLagMs: Math.max(english.audioLagMs, japanese.audioLagMs), droppedAudioMs: english.droppedAudioMs + japanese.droppedAudioMs, resetCount: english.resetCount + japanese.resetCount };
        }
        const selected = this.selected?.metrics() ?? emptyAsrMetrics();
        return selected;
    }
    dispose() {
        this.generation += 1;
        this.selected?.dispose();
        this.selected = undefined;
        this.selectedLanguage = undefined;
        this.english.dispose();
        this.japanese.dispose();
        this.config = undefined;
        this.segmenter.reset();
        this.replay.length = 0;
        this.pendingSegments.length = 0;
        this.draining = false;
        this.paused = false;
        this.phase = "probing";
        this.resetEvidence();
    }
    candidateConfig(language) {
        return { ...this.config, sourceLanguage: language, onPartial: () => undefined, onStable: (text) => { if (language === "en")
                this.englishText = appendEvidence(this.englishText, text);
            else
                this.japaneseText = appendEvidence(this.japaneseText, text); } };
    }
    selectedConfig(language) {
        const config = this.config;
        return { ...config, sourceLanguage: language,
            onPartial: (text, startMs, endMs) => config.onPartial(text, startMs, endMs),
            onStable: (text, startMs, endMs) => config.onStable(text, startMs, endMs),
        };
    }
    enqueue(segment) { if (this.pendingSegments.length >= MAX_REPLAY_SEGMENTS)
        this.pendingSegments.shift(); this.pendingSegments.push(segment); if (!this.draining)
        void this.drain(); }
    async drain() {
        this.draining = true;
        const generation = this.generation;
        try {
            while (this.pendingSegments.length && generation === this.generation && this.config && !this.paused) {
                const segment = this.pendingSegments.shift();
                if (!this.selected)
                    await this.probe(segment, generation);
                // Once locked, feed live PCM directly to the selected recognizer.  Do
                // not infer a switch from a rival forced-language transcript: that was
                // the source of false English flips in all-Japanese programs.
            }
        }
        finally {
            this.draining = false;
            if (this.pendingSegments.length && generation === this.generation && !this.paused)
                void this.drain();
        }
    }
    async probe(segment, generation) {
        this.startupSegments += 1;
        this.startupVoicedMs += duration(segment);
        this.remember(segment);
        this.englishText = "";
        this.japaneseText = "";
        const pcm = toPcm16(segment.audio);
        this.english.pushAudio(pcm, segment.startMs);
        this.japanese.pushAudio(pcm, segment.startMs);
        this.english.flush(segment.endMs);
        this.japanese.flush(segment.endMs);
        const detected = detectEnglishOrJapanese(this.englishText, this.japaneseText);
        if (this.recordVote(detected) && detected)
            await this.activate(detected, true, generation);
        else if (this.startupSegments >= MAX_STARTUP_SEGMENTS || this.startupVoicedMs >= MAX_STARTUP_VOICED_MS) {
            this.config?.onError({ code: "inference_failed" });
            this.dispose();
        }
    }
    async activate(language, replay, generation) {
        if (generation !== this.generation || !this.config)
            return;
        const chosen = language === "en" ? this.english : this.japanese;
        const rejected = language === "en" ? this.japanese : this.english;
        this.selected?.reset("endpoint");
        rejected.reset("endpoint");
        this.selected = undefined;
        await chosen.init(this.selectedConfig(language));
        if (generation !== this.generation || !this.config)
            return;
        this.selected = chosen;
        this.selectedLanguage = language;
        this.phase = "locked";
        this.resetEvidence();
        this.config.onLanguage?.(language);
        if (replay) {
            for (const frame of this.replay)
                chosen.pushAudio(frame.pcm, frame.startMs);
            this.replay.length = 0;
        }
        rejected.setPaused(true);
    }
    challenger() { if (!this.selectedLanguage)
        return undefined; return this.selectedLanguage === "en" ? this.japanese : this.english; }
    remember(segment) { const frame = { pcm: toPcm16(segment.audio), startMs: segment.startMs, endMs: segment.endMs }; this.replay.push(frame); let samples = this.replay.reduce((total, item) => total + item.pcm.length, 0); while (this.replay.length > MAX_REPLAY_SEGMENTS || samples > MAX_REPLAY_SAMPLES)
        samples -= this.replay.shift().pcm.length; }
    recordVote(next) { if (!next) {
        this.resetEvidence();
        return false;
    } if (this.vote === next)
        this.voteCount += 1;
    else {
        this.vote = next;
        this.voteCount = 1;
    } return this.voteCount >= REQUIRED_VOTES; }
    resetEvidence() { this.vote = undefined; this.voteCount = 0; this.englishText = ""; this.japaneseText = ""; }
}
function duration(segment) { return Math.max(0, segment.endMs - segment.startMs); }
function toPcm16(audio) { const pcm = new Int16Array(audio.length); for (let index = 0; index < audio.length; index++)
    pcm[index] = Math.max(-1, Math.min(1, audio[index])) * 0x7fff; return pcm; }
function appendEvidence(existing, next) { return `${existing} ${next}`.trim().slice(-512); }
