// File-wise cache for Phase 2 capability manifests. Unlike the legacy Whisper
// cache, every entry is tied to a capability id and an exact SHA-256 recorded in
// the manifest. The marker is written only after the downloaded response hashes
// correctly, so a completed Cache API entry can be used without probing the network.
const CACHE_NAME = "realtime-subtitles-capabilities-v1";
const SIZE_HEADER = "x-realtime-subtitles-capability-size";
const SHA_HEADER = "x-realtime-subtitles-capability-sha256";
const ID_HEADER = "x-realtime-subtitles-capability-id";
const DEFAULT_UPSTREAM_HOST = "https://huggingface.co/";
const RANGE_THRESHOLD_BYTES = 16 * 1024 * 1024;
const RANGE_SIZE_BYTES = 8 * 1024 * 1024;
export class CapabilityModelStore {
    cacheProvider;
    fetcher;
    remoteHost;
    remoteHosts;
    constructor(cacheProvider = caches, fetcher = fetch, remoteHost = "https://hf-mirror.com/", fallbackHosts = [DEFAULT_UPSTREAM_HOST]) {
        this.cacheProvider = cacheProvider;
        this.fetcher = fetcher;
        this.remoteHost = remoteHost;
        this.remoteHosts = Array.from(new Set([remoteHost, ...fallbackHosts]));
    }
    url(manifest, path) {
        return this.urlFor(this.remoteHost, manifest, path);
    }
    async isReady(manifest) {
        const cache = await this.cacheProvider.open(CACHE_NAME);
        const results = await Promise.all(manifest.files.map(async (file) => this.valid(cache, manifest, file)));
        return results.every(Boolean);
    }
    async download(manifest, signal, onProgress) {
        const cache = await this.cacheProvider.open(CACHE_NAME);
        let completed = 0;
        for (const file of manifest.files) {
            if (signal.aborted)
                throw new DOMException("download_cancelled", "AbortError");
            if (await this.valid(cache, manifest, file)) {
                completed += file.sizeBytes;
                onProgress(file.path, completed, totalBytes(manifest));
                continue;
            }
            const cacheUrl = this.url(manifest, file.path);
            await cache.delete(cacheUrl);
            let lastError;
            for (const [hostIndex, remoteHost] of this.remoteHosts.entries()) {
                if (signal.aborted)
                    throw new DOMException("download_cancelled", "AbortError");
                if (hostIndex > 0)
                    onProgress(file.path, completed, totalBytes(manifest));
                try {
                    const bytes = await downloadFileBytes(this.fetcher, this.urlFor(remoteHost, manifest, file.path), file, completed, totalBytes(manifest), signal, onProgress);
                    if (bytes.byteLength !== file.sizeBytes)
                        throw new Error(`model_file_size_mismatch:${file.path}`);
                    // The DOM typings currently widen Uint8Array's buffer to ArrayBufferLike;
                    // the runtime accepts this byte view directly and avoids another full model copy.
                    const sha256 = await sha256Hex(bytes);
                    if (sha256 !== file.sha256.toLowerCase())
                        throw new Error(`model_file_sha256_mismatch:${file.path}`);
                    const headers = new Headers();
                    headers.set("content-length", String(file.sizeBytes));
                    headers.set(SIZE_HEADER, String(file.sizeBytes));
                    headers.set(SHA_HEADER, file.sha256.toLowerCase());
                    headers.set(ID_HEADER, manifest.id);
                    await cache.put(cacheUrl, new Response(bytes, { status: 200, headers }));
                    lastError = undefined;
                    break;
                }
                catch (error) {
                    if (isAbortError(error))
                        throw error;
                    lastError = normalizeDownloadError(error, file.path);
                }
            }
            if (lastError)
                throw lastError;
            completed += file.sizeBytes;
        }
    }
    async remove(manifest) {
        const cache = await this.cacheProvider.open(CACHE_NAME);
        await Promise.all(manifest.files.map((file) => cache.delete(this.url(manifest, file.path))));
    }
    async fileBytes(manifest, path) {
        const file = manifest.files.find((candidate) => candidate.path === path);
        if (!file)
            throw new Error("capability_file_unknown");
        const cache = await this.cacheProvider.open(CACHE_NAME);
        const response = await cache.match(this.url(manifest, path));
        if (!response || !(await this.validResponse(response, manifest, file)))
            throw new Error("capability_cache_corrupt");
        return new Uint8Array(await response.arrayBuffer());
    }
    async valid(cache, manifest, file) {
        const response = await cache.match(this.url(manifest, file.path));
        return response ? this.validResponse(response, manifest, file) : false;
    }
    async validResponse(response, manifest, file) {
        return response.headers.get(ID_HEADER) === manifest.id
            && response.headers.get(SIZE_HEADER) === String(file.sizeBytes)
            && response.headers.get(SHA_HEADER) === file.sha256.toLowerCase()
            && response.headers.get("content-length") === String(file.sizeBytes);
    }
    urlFor(remoteHost, manifest, path) {
        return `${remoteHost.replace(/\/$/u, "")}/${manifest.modelId}/resolve/${encodeURIComponent(manifest.revision)}/${path}`;
    }
}
async function downloadFileBytes(fetcher, url, file, completed, total, signal, onProgress) {
    if (file.sizeBytes < RANGE_THRESHOLD_BYTES) {
        const response = await fetchResponse(fetcher, url, file.path, signal);
        return readResponseBytes(response, file.path, completed, total, file.sizeBytes, signal, onProgress);
    }
    const bytes = new Uint8Array(file.sizeBytes);
    for (let start = 0; start < file.sizeBytes; start += RANGE_SIZE_BYTES) {
        const end = Math.min(file.sizeBytes - 1, start + RANGE_SIZE_BYTES - 1);
        const response = await fetcher(url, { signal, headers: { Range: `bytes=${start}-${end}` } });
        if (!response.ok)
            throw new Error(`model_file_http_${response.status}:${file.path}`);
        if (response.status === 200) {
            if (start !== 0)
                throw new Error(`model_file_range_unsupported:${file.path}`);
            return readResponseBytes(response, file.path, completed, total, file.sizeBytes, signal, onProgress);
        }
        if (response.status !== 206 || !contentRangeMatches(response.headers.get("content-range"), start, end, file.sizeBytes)) {
            throw new Error(`model_file_range_unsupported:${file.path}`);
        }
        const part = await readResponseBytes(response, file.path, completed + start, total, end - start + 1, signal, onProgress);
        if (part.byteLength !== end - start + 1)
            throw new Error(`model_file_size_mismatch:${file.path}`);
        bytes.set(part, start);
    }
    return bytes;
}
async function fetchResponse(fetcher, url, path, signal) {
    try {
        const response = await fetcher(url, { signal });
        if (!response.ok)
            throw new Error(`model_file_http_${response.status}:${path}`);
        return response;
    }
    catch (error) {
        if (isAbortError(error) || (error instanceof Error && error.message.startsWith("model_file_http_")))
            throw error;
        throw new Error(`model_file_fetch_failed:${path}:${error instanceof Error ? error.message : String(error)}`);
    }
}
async function readResponseBytes(response, path, completed, total, expectedSize, signal, onProgress) {
    if (!response.body) {
        const bytes = new Uint8Array(await response.arrayBuffer());
        onProgress(path, completed + bytes.byteLength, total);
        return bytes;
    }
    const reader = response.body.getReader();
    let bytes = new Uint8Array(expectedSize);
    let loaded = 0;
    try {
        for (;;) {
            if (signal.aborted)
                throw new DOMException("download_cancelled", "AbortError");
            const next = await reader.read();
            if (next.done)
                break;
            if (next.value.byteLength === 0)
                continue;
            if (loaded + next.value.byteLength > bytes.byteLength) {
                const expanded = new Uint8Array(loaded + next.value.byteLength);
                expanded.set(bytes.subarray(0, loaded));
                bytes = expanded;
            }
            bytes.set(next.value, loaded);
            loaded += next.value.byteLength;
            onProgress(path, completed + loaded, total);
        }
    }
    finally {
        reader.releaseLock();
    }
    return bytes.subarray(0, loaded);
}
function contentRangeMatches(value, start, end, total) {
    const match = /^bytes (\d+)-(\d+)\/(\d+)$/u.exec(value ?? "");
    return match !== null && Number(match[1]) === start && Number(match[2]) === end && Number(match[3]) === total;
}
function normalizeDownloadError(error, path) {
    if (error instanceof Error && error.message.startsWith("model_file_"))
        return error;
    return new Error(`model_file_fetch_failed:${path}:${error instanceof Error ? error.message : String(error)}`);
}
function isAbortError(error) {
    return error instanceof DOMException && error.name === "AbortError";
}
export function totalBytes(manifest) {
    return manifest.files.reduce((sum, file) => sum + file.sizeBytes, 0);
}
async function sha256Hex(bytes) {
    const digest = await crypto.subtle.digest("SHA-256", bytes);
    return Array.from(new Uint8Array(digest), (part) => part.toString(16).padStart(2, "0")).join("");
}
