// Logical resource governance for realtime ASR and background translation.
//
// The governor intentionally does not own a model or a worker.  It gives the
// caller one bounded translation queue, keeps ASR ahead of automatic work, and
// exposes the overload recovery policy so a Worker bridge can remain local and
// content-free.
import { ProviderError } from "./providers.js";
const AUTO_TRANSLATION_START_LAG_MS = 300;
const DEGRADED_LAG_MS = 500;
const CLEAR_QUEUE_LAG_MS = 1_000;
const RESET_LAG_MS = 2_000;
const CLEAR_QUEUE_AFTER_MS = 3_000;
const RESET_WINDOW_MS = 15_000;
const RESET_COOLDOWN_MS = 2_000;
export class ResourceGovernor {
    options;
    clock;
    maxPending;
    queue = [];
    active;
    drainScheduled = false;
    disposed = false;
    audioLag = 0;
    asrUrgent = false;
    currentState = "normal";
    highLagSince;
    queueClearedForHighLag = false;
    lastResetAt;
    resetTimes = [];
    constructor(options = {}) {
        this.options = options;
        this.clock = options.now ?? (() => Date.now());
        this.maxPending = Math.max(1, Math.floor(options.maxPendingTranslations ?? 1));
    }
    get state() {
        return this.currentState;
    }
    get audioLagMs() {
        return this.audioLag;
    }
    canStartAutomatic() {
        return !this.disposed && this.currentState !== "overloaded" && !this.asrUrgent && this.audioLag < AUTO_TRANSLATION_START_LAG_MS;
    }
    setAsrUrgent(urgent) {
        this.asrUrgent = urgent;
        if (!urgent)
            this.scheduleDrain();
    }
    pending(priority) {
        return priority ? this.queue.filter((job) => job.priority === priority).length : this.queue.length;
    }
    activePriority() {
        return this.active?.priority;
    }
    enqueue(job) {
        if (this.disposed)
            return Promise.reject(new Error("resource_governor_disposed"));
        if (job.signal?.aborted)
            return Promise.reject(new ProviderError("cancelled"));
        return new Promise((resolve, reject) => {
            const queued = job;
            queued.resolve = resolve;
            queued.reject = reject;
            const controller = job.signal ? undefined : new AbortController();
            const signal = job.signal ?? controller.signal;
            if (signal.aborted) {
                reject(new ProviderError("cancelled"));
                return;
            }
            const onAbort = () => {
                const index = this.queue.indexOf(queued);
                if (index < 0)
                    return;
                this.queue.splice(index, 1);
                queued.removeAbortListener?.();
                reject(new ProviderError("cancelled"));
            };
            signal.addEventListener("abort", onAbort, { once: true });
            queued.removeAbortListener = () => signal.removeEventListener("abort", onAbort);
            // Keep the internal controller alive by attaching it to the job object;
            // it is only needed to provide a real AbortSignal to a job without one.
            if (controller)
                queued.controller = controller;
            this.makeRoom(queued);
            if (queued.settled === true)
                return;
            this.queue.push(queued);
            this.scheduleDrain();
        });
    }
    /** Alias used by callers that view the governor as a scheduler. */
    run(job) {
        return this.enqueue(job);
    }
    /** Alias used by tests and adapters that call the operation schedule(). */
    schedule(job) {
        return this.enqueue(job);
    }
    updateMetrics(metrics, observedAt = this.clock()) {
        return this.updateAudioLag(metrics.audioLagMs, observedAt);
    }
    updateAudioLag(value, observedAt = this.clock()) {
        const at = Number.isFinite(observedAt) ? observedAt : this.clock();
        this.audioLag = Math.max(0, Number.isFinite(value) ? value : 0);
        let droppedAutomatic = false;
        let resetRequested = false;
        let overloaded = this.currentState === "overloaded";
        if (this.audioLag > CLEAR_QUEUE_LAG_MS) {
            if (this.highLagSince === undefined)
                this.highLagSince = at;
            if (!this.queueClearedForHighLag && at - this.highLagSince >= CLEAR_QUEUE_AFTER_MS) {
                droppedAutomatic = this.clearAutomatic();
                this.queueClearedForHighLag = true;
            }
        }
        else {
            this.highLagSince = undefined;
            this.queueClearedForHighLag = false;
        }
        this.pruneResetTimes(at);
        if (this.audioLag <= DEGRADED_LAG_MS)
            this.resetTimes.length = 0;
        if (!overloaded && this.audioLag > RESET_LAG_MS && (this.lastResetAt === undefined || at - this.lastResetAt >= RESET_COOLDOWN_MS)) {
            this.lastResetAt = at;
            this.resetTimes.push(at);
            resetRequested = true;
            this.options.onReset?.();
            this.pruneResetTimes(at);
            if (this.resetTimes.length >= 3) {
                overloaded = true;
                this.setState("overloaded");
                this.clearAutomatic();
                this.options.onOverloaded?.();
            }
        }
        if (!overloaded) {
            this.setState(this.audioLag >= DEGRADED_LAG_MS ? "degraded" : this.audioLag >= AUTO_TRANSLATION_START_LAG_MS ? "throttled" : "normal");
        }
        this.scheduleDrain();
        return { state: this.currentState, audioLagMs: this.audioLag, droppedAutomatic, resetRequested, overloaded: this.currentState === "overloaded" };
    }
    /** Drop only waiting automatic work; a running translation is not interrupted. */
    clearAutomatic() {
        let dropped = false;
        for (let index = this.queue.length - 1; index >= 0; index--) {
            const job = this.queue[index];
            if (job.priority !== "automatic")
                continue;
            this.queue.splice(index, 1);
            job.removeAbortListener?.();
            job.reject(new ProviderError("translator_overloaded"));
            dropped = true;
        }
        return dropped;
    }
    /** Stop accepting new work and reject only queued work. */
    reset() {
        this.clearQueued(new ProviderError("cancelled"));
        this.audioLag = 0;
        this.asrUrgent = false;
        this.highLagSince = undefined;
        this.queueClearedForHighLag = false;
        this.lastResetAt = undefined;
        this.resetTimes.length = 0;
        if (!this.disposed)
            this.setState("normal");
        this.scheduleDrain();
    }
    dispose() {
        this.disposed = true;
        this.clearQueued(new Error("resource_governor_disposed"));
    }
    makeRoom(incoming) {
        if (this.queue.length < this.maxPending)
            return;
        const automatic = this.queue.findIndex((job) => job.priority === "automatic");
        const removeIndex = incoming.priority === "retry" ? (automatic >= 0 ? automatic : 0) : automatic;
        if (removeIndex < 0) {
            incoming.settled = true;
            incoming.removeAbortListener?.();
            incoming.reject(new ProviderError("translator_overloaded"));
            return;
        }
        const [dropped] = this.queue.splice(removeIndex, 1);
        dropped?.removeAbortListener?.();
        dropped?.reject(new ProviderError("translator_overloaded"));
    }
    scheduleDrain() {
        if (this.drainScheduled || this.disposed)
            return;
        this.drainScheduled = true;
        queueMicrotask(() => {
            this.drainScheduled = false;
            void this.drain();
        });
    }
    async drain() {
        if (this.active || this.disposed)
            return;
        const index = this.queue.findIndex((job) => job.priority === "retry" || this.canStartAutomatic());
        if (index < 0)
            return;
        const job = this.queue.splice(index, 1)[0];
        if (!job)
            return;
        job.removeAbortListener?.();
        this.active = job;
        try {
            const controller = job.controller;
            const signal = job.signal ?? controller?.signal ?? new AbortController().signal;
            if (signal.aborted)
                throw new ProviderError("cancelled");
            job.resolve(await job.run(signal));
        }
        catch (error) {
            job.reject(error);
        }
        finally {
            this.active = undefined;
            this.scheduleDrain();
        }
    }
    clearQueued(error) {
        for (const job of this.queue.splice(0)) {
            job.removeAbortListener?.();
            job.reject(error);
        }
    }
    pruneResetTimes(at) {
        while (this.resetTimes.length && at - this.resetTimes[0] > RESET_WINDOW_MS)
            this.resetTimes.shift();
    }
    setState(state) {
        if (state === this.currentState)
            return;
        this.currentState = state;
        this.options.onStateChange?.(state);
    }
}
