// English streaming partial stabilization.
//
// Sherpa returns a cumulative transcript for the current online stream.  The
// prefix that has already been published must therefore be tracked separately
// from the still-changing tail.  This class never edits a published prefix:
// every committed value is returned as a new stable fragment and the caller is
// responsible for assigning it a new cue id. A word boundary is not a
// translation boundary: translating one committed word at a time makes an
// otherwise fluent English sentence unreadable in Chinese. We therefore wait
// for sentence punctuation or the recognizer's natural endpoint.
const DEFAULT_MIN_OBSERVATIONS = 3;
const DEFAULT_STABLE_DURATION_MS = 800;
const SENTENCE_PUNCTUATION = new Set(Array.from(".!?。！？…"));
export class PartialStabilizer {
    minObservations;
    stableDurationMs;
    clock;
    prefixes = new Map();
    committed = "";
    constructor(options = {}) {
        this.minObservations = Math.max(1, Math.floor(options.minObservations ?? DEFAULT_MIN_OBSERVATIONS));
        this.stableDurationMs = Math.max(0, options.stableDurationMs ?? DEFAULT_STABLE_DURATION_MS);
        this.clock = options.now ?? (() => Date.now());
    }
    get committedText() {
        return this.committed;
    }
    get committedPrefix() {
        return this.committed;
    }
    /**
     * Observe one cumulative ASR partial.  `observedAt` is injectable so the
     * stabilization rule can be tested without sleeping and remains independent
     * from audio timestamps supplied by the capture layer.
     */
    observe(text, startMs, endMs, observedAt = this.clock()) {
        const normalized = normalize(text);
        if (!normalized)
            return this.result("", undefined, startMs, endMs);
        const compatible = this.isCompatible(normalized);
        if (!compatible) {
            // A correction that no longer contains the published prefix is not
            // allowed to rewrite history.  Keep it visible as an active revision,
            // but wait for endpoint before emitting a new stable fragment.
            this.prefixes.clear();
            return this.result(normalized, undefined, startMs, endMs);
        }
        const at = Number.isFinite(observedAt) ? observedAt : this.clock();
        this.updatePrefixStates(normalized, at);
        const candidate = this.eligiblePrefix(at);
        let stableText;
        if (candidate) {
            const fragment = normalize(normalized.slice(this.committed.length, candidate.length));
            if (fragment) {
                this.committed = join(this.committed, fragment);
                stableText = fragment;
                for (const prefix of this.prefixes.keys()) {
                    if (prefix.length <= candidate.length)
                        this.prefixes.delete(prefix);
                }
            }
        }
        return this.result(this.remainder(normalized), stableText, startMs, endMs);
    }
    /** Alias kept explicit for engine adapters that call their input operation push(). */
    push(text, startMs, endMs, observedAt = this.clock()) {
        return this.observe(text, startMs, endMs, observedAt);
    }
    update(text, startMs, endMs, observedAt = this.clock()) {
        return this.observe(text, startMs, endMs, observedAt);
    }
    /**
     * Endpoint is the only place where an unfinished final word is allowed to
     * become stable.  The returned text is still a new fragment after the
     * already-committed prefix.
     */
    endpoint(text, startMs, endMs) {
        const normalized = normalize(text);
        const stableText = normalized && this.isCompatible(normalized) ? this.remainder(normalized) : normalized;
        const result = this.result("", stableText || undefined, startMs, endMs);
        this.reset();
        return result;
    }
    /** Alias for engines that call their endpoint operation flush(). */
    flush(text, startMs, endMs) {
        return this.endpoint(text, startMs, endMs);
    }
    reset() {
        this.prefixes.clear();
        this.committed = "";
    }
    isCompatible(text) {
        return !this.committed || text === this.committed || text.startsWith(`${this.committed} `) || text.startsWith(this.committed);
    }
    remainder(text) {
        if (!this.committed)
            return text;
        if (!text.startsWith(this.committed))
            return text;
        return text.slice(this.committed.length).trimStart();
    }
    updatePrefixStates(text, observedAt) {
        // A prefix is continuous only while every new partial still starts with
        // it.  Drop a state as soon as ASR revises before that prefix.
        for (const [prefix, state] of this.prefixes) {
            if (!text.startsWith(prefix))
                this.prefixes.delete(prefix);
            else {
                state.observations += 1;
                state.lastObservedAt = observedAt;
            }
        }
        for (const prefix of sentencePrefixes(text)) {
            if (prefix.length <= this.committed.length || !this.isCompatible(prefix))
                continue;
            if (this.prefixes.has(prefix))
                continue;
            this.prefixes.set(prefix, { observations: 1, firstObservedAt: observedAt, lastObservedAt: observedAt });
        }
    }
    eligiblePrefix(observedAt) {
        let selected;
        for (const [prefix, state] of this.prefixes) {
            if (state.observations < this.minObservations)
                continue;
            if (observedAt - state.firstObservedAt < this.stableDurationMs)
                continue;
            if (!selected || prefix.length > selected.length)
                selected = prefix;
        }
        return selected;
    }
    result(partialText, stableText, startMs, endMs) {
        return { partialText, ...(stableText ? { stableText, stableFragment: stableText, stablePrefix: stableText } : {}), startMs, endMs };
    }
}
function sentencePrefixes(text) {
    const prefixes = [];
    for (let index = 0; index < text.length; index++) {
        const character = text[index];
        if (SENTENCE_PUNCTUATION.has(character))
            prefixes.push(text.slice(0, index + 1).trim());
    }
    return [...new Set(prefixes)];
}
function normalize(value) {
    return value.trim().replace(/\s+/gu, " ");
}
function join(left, right) {
    return left ? `${left} ${right}`.trim() : right;
}
