/**
 * Browser TTS Provider
 *
 * Text-to-speech provider using the browser's Web Speech API.
 * Works in all modern browsers that support SpeechSynthesis.
 *
 * Part of PIE Assessment Toolkit.
 */
import { segmentSentences as segmentTextToSentences } from "./text-segmentation.js";
const normalizeLanguageCode = (value) => String(value || "")
    .trim()
    .toLowerCase();
const browserLanguage = () => {
    const navigatorLanguage = typeof navigator !== "undefined"
        ? navigator.language || navigator.languages?.[0]
        : "";
    return normalizeLanguageCode(navigatorLanguage || "en-US");
};
const findBrowserVoice = (voices, preferredName) => {
    if (preferredName) {
        const explicit = voices.find((voice) => voice.name === preferredName);
        if (explicit)
            return explicit;
    }
    const language = browserLanguage();
    const languagePrefix = language.split("-")[0] || "en";
    const matchesLanguage = (voice) => {
        const voiceLanguage = normalizeLanguageCode(voice.lang);
        return voiceLanguage === language || voiceLanguage.startsWith(`${languagePrefix}-`);
    };
    const ranked = [
        (voice) => voice.localService && matchesLanguage(voice),
        (voice) => voice.default && matchesLanguage(voice),
        (voice) => matchesLanguage(voice),
        (voice) => voice.localService,
        (voice) => voice.default,
    ];
    for (const predicate of ranked) {
        const voice = voices.find(predicate);
        if (voice)
            return voice;
    }
    return voices[0] || null;
};
const shouldAssignBrowserVoice = (voice) => !voice.default;
/**
 * Browser TTS Provider
 *
 * Stateless factory for creating browser-based TTS implementations.
 */
export class BrowserTTSProvider {
    providerId = "browser";
    providerName = "Browser Speech Synthesis";
    version = "1.0.0";
    async initialize(config) {
        // SSR guard
        if (typeof window === "undefined") {
            throw new Error("BrowserTTSProvider requires browser environment");
        }
        // Check browser support
        if (!("speechSynthesis" in window)) {
            throw new Error("Browser does not support Speech Synthesis API");
        }
        return new BrowserTTSProviderImpl(config);
    }
    supportsFeature(feature) {
        // Browser supports all TTS features
        return true;
    }
    getCapabilities() {
        return {
            supportsPause: true,
            supportsResume: true,
            supportsWordBoundary: true,
            supportsVoiceSelection: true,
            supportsRateControl: true,
            supportsPitchControl: true,
            // The Web Speech API voices plain text only; SSML tags are read aloud.
            supportsSSML: false,
        };
    }
    destroy() {
        // No cleanup needed for browser provider
    }
}
/**
 * Browser TTS Provider Implementation
 *
 * Handles actual speech synthesis using the Web Speech API.
 */
class BrowserTTSProviderImpl {
    utterance = null;
    config = null;
    _isPlaying = false;
    _isPaused = false;
    speakRunId = 0;
    constructor(config) {
        this.config = config;
    }
    async speak(text) {
        if (!this.config) {
            throw new Error("TTS not initialized");
        }
        // Invalidate any in-flight run and cancel current utterance.
        this.stop();
        const runId = this.speakRunId;
        const chunks = this.splitIntoChunks(text);
        for (const chunk of chunks) {
            if (runId !== this.speakRunId) {
                break;
            }
            const shouldContinue = await this.speakChunk(chunk.text, chunk.offset, runId);
            if (!shouldContinue) {
                break;
            }
        }
    }
    async speakSegments(segments) {
        if (!this.config) {
            throw new Error("TTS not initialized");
        }
        this.stop();
        const runId = this.speakRunId;
        for (const segment of segments) {
            if (runId !== this.speakRunId)
                break;
            const chunks = this.splitIntoChunks(segment.text);
            for (const chunk of chunks) {
                if (runId !== this.speakRunId)
                    break;
                const shouldContinue = await this.speakChunk(chunk.text, segment.startOffset + chunk.offset, runId);
                if (!shouldContinue)
                    break;
            }
            const pauseMsAfter = Math.max(0, Number(segment.pauseMsAfter || 0));
            if (pauseMsAfter > 0 && runId === this.speakRunId) {
                const shouldContinue = await this.waitForPause(pauseMsAfter, runId);
                if (!shouldContinue)
                    break;
            }
        }
    }
    async waitForPause(pauseMs, runId) {
        await new Promise((resolve) => setTimeout(resolve, pauseMs));
        return runId === this.speakRunId;
    }
    splitIntoChunks(text) {
        const MAX_CHUNK_LENGTH = 260;
        if (text.length <= MAX_CHUNK_LENGTH) {
            return [{ text, offset: 0 }];
        }
        const sentences = this.segmentSentences(text);
        const chunks = [];
        let currentText = "";
        let currentOffset = 0;
        for (const sentence of sentences) {
            const trimmed = sentence.text.trim();
            if (!trimmed)
                continue;
            const firstNonWhitespace = sentence.text.search(/\S/);
            const trimmedStart = sentence.offset + (firstNonWhitespace === -1 ? 0 : firstNonWhitespace);
            if (!currentText) {
                currentText = trimmed;
                currentOffset = trimmedStart;
                continue;
            }
            const candidate = `${currentText} ${trimmed}`;
            if (candidate.length <= MAX_CHUNK_LENGTH) {
                currentText = candidate;
            }
            else {
                chunks.push({ text: currentText, offset: currentOffset });
                currentText = trimmed;
                currentOffset = trimmedStart;
            }
        }
        if (currentText) {
            chunks.push({ text: currentText, offset: currentOffset });
        }
        return chunks.length ? chunks : [{ text, offset: 0 }];
    }
    getHighlightMode() {
        const providerOptions = (this.config?.providerOptions || {});
        return providerOptions.highlightMode === "word" ? "word" : "sentence";
    }
    getSegmentationPolicy() {
        const providerOptions = (this.config?.providerOptions || {});
        const segmenter = (providerOptions.segmenter || {});
        const mode = segmenter.mode;
        const useSegmenter = mode !== "regexOnly";
        const locale = typeof segmenter.locale === "string" && segmenter.locale.trim().length > 0
            ? segmenter.locale
            : typeof providerOptions.locale === "string" &&
                providerOptions.locale.trim().length > 0
                ? providerOptions.locale
                : undefined;
        return {
            useSentenceSegmenter: useSegmenter,
            useWordSegmenter: useSegmenter,
            locale,
        };
    }
    segmentSentences(text) {
        const policy = this.getSegmentationPolicy();
        return segmentTextToSentences(text, {
            locale: policy.locale,
            useSentenceSegmenter: policy.useSentenceSegmenter,
        });
    }
    inferWordLength(text, index) {
        const safeIndex = Math.max(0, Math.min(index, Math.max(0, text.length - 1)));
        const slice = text.slice(safeIndex);
        const match = slice.match(/^\s*([^\s]+)/);
        return match?.[1]?.length || 1;
    }
    async speakChunk(chunkText, chunkOffset, runId) {
        return new Promise((resolve, reject) => {
            if (runId !== this.speakRunId) {
                resolve(false);
                return;
            }
            const utterance = new SpeechSynthesisUtterance(chunkText);
            this.utterance = utterance;
            // Apply config
            const voice = findBrowserVoice(speechSynthesis.getVoices(), this.config?.voice);
            if (voice && shouldAssignBrowserVoice(voice))
                utterance.voice = voice;
            if (this.config?.rate)
                utterance.rate = this.config.rate;
            if (this.config?.pitch)
                utterance.pitch = this.config.pitch;
            const clearOwnedUtterance = () => {
                if (this.utterance === utterance) {
                    this.utterance = null;
                }
            };
            utterance.onstart = () => {
                if (runId !== this.speakRunId)
                    return;
                this._isPlaying = true;
                this._isPaused = false;
            };
            utterance.onend = () => {
                clearOwnedUtterance();
                if (runId !== this.speakRunId) {
                    resolve(false);
                    return;
                }
                this._isPlaying = false;
                this._isPaused = false;
                resolve(true);
            };
            utterance.onerror = (event) => {
                clearOwnedUtterance();
                if (runId !== this.speakRunId) {
                    resolve(false);
                    return;
                }
                this._isPlaying = false;
                this._isPaused = false;
                if (event.error === "interrupted" || event.error === "canceled") {
                    resolve(false);
                    return;
                }
                reject(new Error(`Speech synthesis error: ${event.error}`));
            };
            utterance.onpause = () => {
                if (runId !== this.speakRunId)
                    return;
                this._isPaused = true;
            };
            utterance.onresume = () => {
                if (runId !== this.speakRunId)
                    return;
                this._isPaused = false;
            };
            utterance.onboundary = (event) => {
                if (runId !== this.speakRunId)
                    return;
                console.log("[BrowserProvider] Boundary event:", event.name, "charIndex:", event.charIndex, "charLength:", event.charLength);
                if (event.name !== "word" || !this.onWordBoundary)
                    return;
                if (this.getHighlightMode() === "sentence") {
                    return;
                }
                const charIndex = Math.max(0, Math.min(event.charIndex, Math.max(0, chunkText.length - 1)));
                const reportedLength = Number(event.charLength || 0);
                const inferredLength = this.inferWordLength(chunkText, charIndex);
                const wordLength = Number.isFinite(reportedLength) &&
                    reportedLength > 0 &&
                    reportedLength <= 80 &&
                    charIndex + reportedLength <= chunkText.length
                    ? reportedLength
                    : inferredLength;
                const word = chunkText
                    .substring(charIndex, Math.min(chunkText.length, charIndex + wordLength))
                    .trim();
                const absoluteBoundaryStart = chunkOffset + charIndex;
                console.log("[BrowserProvider] Calling onWordBoundary with word:", word, "at position:", absoluteBoundaryStart);
                this.onWordBoundary(word, absoluteBoundaryStart, wordLength);
            };
            speechSynthesis.speak(utterance);
        });
    }
    pause() {
        if (this._isPlaying && !this._isPaused) {
            speechSynthesis.pause();
        }
    }
    resume() {
        if (this._isPlaying && this._isPaused) {
            speechSynthesis.resume();
        }
    }
    stop() {
        this.speakRunId += 1;
        if (this._isPlaying || this.utterance) {
            speechSynthesis.cancel();
        }
        this.utterance = null;
        this._isPlaying = false;
        this._isPaused = false;
    }
    isPlaying() {
        return this._isPlaying && !this._isPaused;
    }
    isPaused() {
        return this._isPaused;
    }
    /**
     * Update settings dynamically (rate, pitch, voice)
     * Changes take effect on the next speak() call
     */
    updateSettings(settings) {
        if (!this.config) {
            this.config = {};
        }
        // Update config with new settings
        if (settings.rate !== undefined) {
            this.config.rate = settings.rate;
        }
        if (settings.pitch !== undefined) {
            this.config.pitch = settings.pitch;
        }
        if (settings.voice !== undefined) {
            this.config.voice = settings.voice;
        }
        if (settings.providerOptions !== undefined) {
            this.config.providerOptions = {
                ...(this.config.providerOptions || {}),
                ...(settings.providerOptions || {}),
            };
        }
    }
    onWordBoundary;
}
//# sourceMappingURL=browser-provider.js.map