import { createHash, randomUUID } from "node:crypto";
import { createReadStream, promises as fs } from "node:fs";
import { isAbsolute, join, resolve as resolvePath } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
export const DEFAULT_MEDIA_CACHE_MAX_MB = 2048;
export const DEFAULT_MEDIA_CACHE_TTL_DAYS = 7;
export const DEFAULT_MEDIA_CACHE_VERIFY = "size";
const INDEX_VERSION = 1;
const INDEX_FILENAME = "index.json";
const INDEX_LOCK_RETRY_MS = 25;
const INDEX_LOCK_HEARTBEAT_MS = 30_000;
const INDEX_LOCK_STALE_MS = 5 * 60_000;
const ensureDir = async (path) => {
    await fs.mkdir(path, { recursive: true });
};
export function resolveMediaCachePath({ env, cachePath, }) {
    const raw = cachePath?.trim();
    const home = env.HOME?.trim() || env.USERPROFILE?.trim() || null;
    if (raw) {
        if (raw.startsWith("~/") && home) {
            return resolvePath(join(home, raw.slice(2)));
        }
        return isAbsolute(raw) ? raw : home ? resolvePath(join(home, raw)) : resolvePath(raw);
    }
    if (!home)
        return null;
    return join(home, ".summarize", "cache", "media");
}
const hashKey = (value) => {
    return createHash("sha256").update(value).digest("hex");
};
const hashFile = async (filePath) => {
    return new Promise((resolve, reject) => {
        const hash = createHash("sha256");
        const stream = createReadStream(filePath);
        stream.on("data", (chunk) => hash.update(chunk));
        stream.on("error", reject);
        stream.on("end", () => resolve(hash.digest("hex")));
    });
};
const resolveExtension = (filename, mediaType) => {
    const safeName = filename?.trim() ?? "";
    const match = safeName.match(/\.([a-z0-9]{2,5})$/i);
    if (match)
        return `.${match[1].toLowerCase()}`;
    if (!mediaType)
        return ".bin";
    const normalized = mediaType.toLowerCase();
    if (normalized.includes("audio/mpeg"))
        return ".mp3";
    if (normalized.includes("audio/mp4"))
        return ".m4a";
    if (normalized.includes("audio/ogg"))
        return ".ogg";
    if (normalized.includes("audio/wav"))
        return ".wav";
    if (normalized.includes("audio/flac"))
        return ".flac";
    if (normalized.includes("video/mp4"))
        return ".mp4";
    if (normalized.includes("video/webm"))
        return ".webm";
    if (normalized.includes("application/vnd.apple.mpegurl") ||
        normalized.includes("application/x-mpegurl"))
        return ".m3u8";
    return ".bin";
};
const readIndex = async (indexPath) => {
    try {
        const raw = await fs.readFile(indexPath, "utf8");
        const parsed = JSON.parse(raw);
        if (parsed &&
            parsed.version === INDEX_VERSION &&
            parsed.entries &&
            typeof parsed.entries === "object") {
            return {
                version: INDEX_VERSION,
                entries: parsed.entries,
            };
        }
    }
    catch {
        // ignore
    }
    return { version: INDEX_VERSION, entries: {} };
};
function isNodeErrorWithCode(error, code) {
    return error instanceof Error && "code" in error && error.code === code;
}
async function readLockInfo(lockPath) {
    return fs.lstat(lockPath).catch((error) => {
        if (isNodeErrorWithCode(error, "ENOENT"))
            return null;
        throw error;
    });
}
async function acquireIndexLock(indexPath) {
    const lockPath = `${indexPath}.lock`;
    while (true) {
        try {
            const handle = await fs.open(lockPath, "wx", 0o600);
            const identity = await handle.stat();
            try {
                await handle.writeFile(`${process.pid}\n`, "utf8");
            }
            catch (error) {
                await handle.close().catch(() => { });
                await fs.unlink(lockPath).catch(() => { });
                throw error;
            }
            const heartbeat = setInterval(() => {
                const now = new Date();
                void handle.utimes(now, now).catch(() => { });
            }, INDEX_LOCK_HEARTBEAT_MS);
            heartbeat.unref();
            return async () => {
                clearInterval(heartbeat);
                await handle.close();
                const current = await readLockInfo(lockPath);
                if (current && current.dev === identity.dev && current.ino === identity.ino) {
                    await fs.unlink(lockPath);
                }
            };
        }
        catch (error) {
            if (!isNodeErrorWithCode(error, "EEXIST"))
                throw error;
            const info = await readLockInfo(lockPath);
            if (info?.isFile() && Date.now() - info.mtimeMs > INDEX_LOCK_STALE_MS) {
                await fs.unlink(lockPath).catch((unlinkError) => {
                    if (!isNodeErrorWithCode(unlinkError, "ENOENT"))
                        throw unlinkError;
                });
                continue;
            }
            await delay(INDEX_LOCK_RETRY_MS);
        }
    }
}
const writeIndex = async (indexPath, index) => {
    const tmpPath = `${indexPath}.tmp-${process.pid}-${randomUUID()}`;
    try {
        await fs.writeFile(tmpPath, JSON.stringify(index));
        await fs.rename(tmpPath, indexPath);
    }
    finally {
        await fs.rm(tmpPath, { force: true });
    }
};
const removeEntry = async (cacheDir, index, key, entry) => {
    delete index.entries[key];
    const path = join(cacheDir, entry.fileName);
    await fs.rm(path, { force: true });
};
const shouldCacheUrl = (url) => {
    return /^https?:/i.test(url.trim());
};
const normalizeEntry = (cacheDir, entry) => {
    return {
        url: entry.url,
        filePath: join(cacheDir, entry.fileName),
        sizeBytes: entry.sizeBytes,
        sha256: entry.sha256,
        mediaType: entry.mediaType,
        filename: entry.filename,
        createdAtMs: entry.createdAtMs,
        lastAccessAtMs: entry.lastAccessAtMs,
        expiresAtMs: entry.expiresAtMs,
    };
};
const moveFile = async (fromPath, toPath) => {
    if (fromPath === toPath)
        return;
    await fs.rm(toPath, { force: true });
    try {
        await fs.rename(fromPath, toPath);
    }
    catch (error) {
        const code = error?.code;
        if (code !== "EXDEV")
            throw error;
        await fs.copyFile(fromPath, toPath);
        await fs.rm(fromPath, { force: true });
    }
};
export async function createMediaCache({ path, maxBytes, ttlMs, verify = DEFAULT_MEDIA_CACHE_VERIFY, }) {
    const cacheDir = path;
    const indexPath = join(cacheDir, INDEX_FILENAME);
    await ensureDir(cacheDir);
    const withIndexLock = async (fn) => {
        const release = await acquireIndexLock(indexPath);
        try {
            return await fn();
        }
        finally {
            await release();
        }
    };
    const pruneExpired = async (index, now) => {
        let pruned = false;
        const entries = Object.entries(index.entries);
        for (const [key, entry] of entries) {
            if (entry.expiresAtMs != null && entry.expiresAtMs <= now) {
                await removeEntry(cacheDir, index, key, entry);
                pruned = true;
            }
        }
        return pruned;
    };
    const computeSizeBytes = async (entry) => {
        try {
            const stat = await fs.stat(join(cacheDir, entry.fileName));
            return stat.size;
        }
        catch {
            return null;
        }
    };
    const enforceMaxBytes = async (index) => {
        if (!Number.isFinite(maxBytes) || maxBytes <= 0)
            return;
        const entries = Object.entries(index.entries);
        if (entries.length === 0)
            return;
        let totalBytes = 0;
        for (const [, entry] of entries) {
            if (typeof entry.sizeBytes !== "number") {
                entry.sizeBytes = await computeSizeBytes(entry);
            }
            if (typeof entry.sizeBytes === "number") {
                totalBytes += entry.sizeBytes;
            }
        }
        if (totalBytes <= maxBytes)
            return;
        const sorted = entries
            .map(([key, entry]) => ({ key, entry }))
            .sort((a, b) => a.entry.lastAccessAtMs - b.entry.lastAccessAtMs);
        for (const { key, entry } of sorted) {
            if (totalBytes <= maxBytes)
                break;
            const size = typeof entry.sizeBytes === "number" ? entry.sizeBytes : 0;
            await removeEntry(cacheDir, index, key, entry);
            totalBytes -= size;
        }
    };
    const get = async ({ url }) => {
        if (!shouldCacheUrl(url))
            return null;
        return await withIndexLock(async () => {
            const now = Date.now();
            const index = await readIndex(indexPath);
            const pruned = await pruneExpired(index, now);
            const key = hashKey(url);
            const entry = index.entries[key];
            if (!entry) {
                if (pruned)
                    await writeIndex(indexPath, index);
                return null;
            }
            const filePath = join(cacheDir, entry.fileName);
            let stat = null;
            try {
                stat = await fs.stat(filePath);
            }
            catch {
                await removeEntry(cacheDir, index, key, entry);
                await writeIndex(indexPath, index);
                return null;
            }
            if (verify === "size" && typeof entry.sizeBytes === "number") {
                if (stat.size !== entry.sizeBytes) {
                    await removeEntry(cacheDir, index, key, entry);
                    await writeIndex(indexPath, index);
                    return null;
                }
            }
            if (verify === "hash") {
                const hash = await hashFile(filePath);
                if (entry.sha256 && entry.sha256 !== hash) {
                    await removeEntry(cacheDir, index, key, entry);
                    await writeIndex(indexPath, index);
                    return null;
                }
                entry.sha256 = hash;
            }
            if (typeof entry.sizeBytes !== "number" || entry.sizeBytes !== stat.size) {
                entry.sizeBytes = stat.size;
            }
            entry.lastAccessAtMs = now;
            index.entries[key] = entry;
            await writeIndex(indexPath, index);
            return normalizeEntry(cacheDir, entry);
        });
    };
    const put = async ({ url, filePath, mediaType = null, filename = null, }) => {
        if (!shouldCacheUrl(url))
            return null;
        const sourceStat = await fs.stat(filePath);
        if (Number.isFinite(maxBytes) && maxBytes > 0 && sourceStat.size > maxBytes) {
            return null;
        }
        const key = hashKey(url);
        const ext = resolveExtension(filename, mediaType);
        const fileName = `${key}${ext}`;
        const stagingPath = join(cacheDir, `.${key}.incoming-${process.pid}-${randomUUID()}`);
        await moveFile(filePath, stagingPath);
        try {
            const sha256 = verify === "hash" ? await hashFile(stagingPath) : null;
            return await withIndexLock(async () => {
                const now = Date.now();
                const index = await readIndex(indexPath);
                await pruneExpired(index, now);
                await moveFile(stagingPath, join(cacheDir, fileName));
                const expiresAtMs = ttlMs > 0 ? now + ttlMs : null;
                const entry = {
                    url,
                    fileName,
                    sizeBytes: sourceStat.size,
                    sha256,
                    mediaType,
                    filename,
                    createdAtMs: now,
                    lastAccessAtMs: now,
                    expiresAtMs,
                };
                index.entries[key] = entry;
                await enforceMaxBytes(index);
                const finalEntry = index.entries[key];
                await writeIndex(indexPath, index);
                return finalEntry ? normalizeEntry(cacheDir, finalEntry) : null;
            });
        }
        finally {
            await fs.rm(stagingPath, { force: true });
        }
    };
    return { get, put };
}
//# sourceMappingURL=media-cache.js.map