import { createHash } from "node:crypto";
import * as fs from "node:fs/promises";
import path from "node:path";
import { writeFileAtomically } from "./atomic-write.js";
import { hasOwnErrorCode } from "./errors.js";
import { assertNoSymlinkSegments, assertSafeRelPath, MEMORY_CACHE_DIR_RELPATH, MEMORY_INGEST_CACHE_DIR_RELPATH } from "./paths.js";
export function computeIngestKey(input) {
    const hash = createHash("sha256");
    hash.update(input.sourceBytes);
    hash.update("\0");
    hash.update(input.indexMdBytes);
    hash.update("\0");
    hash.update(input.promptTemplateVersion);
    hash.update("\0");
    hash.update(input.agentId);
    return hash.digest("hex");
}
export async function readCacheEntry(root, key) {
    const safeKey = assertSafeRelPath(key);
    await assertIngestCachePathIsNotSymlink(root);
    const cachePath = path.join(root, MEMORY_INGEST_CACHE_DIR_RELPATH, `${safeKey}.json`);
    let raw;
    try {
        raw = await fs.readFile(cachePath, "utf8");
    }
    catch (error) {
        if (isMissing(error)) {
            return null;
        }
        throw error;
    }
    try {
        return parseCacheEntry(JSON.parse(raw), key);
    }
    catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        console.warn(`Ignoring ingest cache entry "${key}": ${message}`);
        return null;
    }
}
export async function writeCacheEntry(root, entry) {
    const key = assertSafeRelPath(entry.key);
    await assertIngestCachePathIsNotSymlink(root);
    await fs.mkdir(path.join(root, MEMORY_INGEST_CACHE_DIR_RELPATH), { recursive: true });
    await assertIngestCachePathIsNotSymlink(root);
    await writeFileAtomically(path.join(root, MEMORY_INGEST_CACHE_DIR_RELPATH, `${key}.json`), `${JSON.stringify(entry)}\n`);
}
export async function cacheStatus(root) {
    await assertIngestCachePathIsNotSymlink(root);
    const ingestDir = path.join(root, MEMORY_INGEST_CACHE_DIR_RELPATH);
    const fileNames = await readCacheFileNames(ingestDir);
    const sizes = await Promise.all(fileNames.map(async (fileName) => (await fs.stat(path.join(ingestDir, fileName))).size));
    return {
        entries: fileNames.length,
        bytes: sizes.reduce((total, size) => total + size, 0)
    };
}
export async function clearCache(root, opts = {}) {
    await assertIngestCachePathIsNotSymlink(root);
    const ingestDir = path.join(root, MEMORY_INGEST_CACHE_DIR_RELPATH);
    const cacheDir = path.join(root, MEMORY_CACHE_DIR_RELPATH);
    const fileNames = await readCacheFileNames(ingestDir);
    if (fileNames.length === 0) {
        if (opts.olderThanMs === undefined) {
            await fs.rm(cacheDir, { recursive: true, force: true });
        }
        return { removed: 0 };
    }
    if (opts.olderThanMs === undefined) {
        await fs.rm(cacheDir, { recursive: true, force: true });
        return { removed: fileNames.length };
    }
    const cutoff = Date.now() - opts.olderThanMs;
    const expiredEntries = [];
    for (const fileName of fileNames) {
        const key = fileName.slice(0, -".json".length);
        const entry = await readCacheEntry(root, key);
        if (entry === null || Date.parse(entry.ingestedAt) > cutoff) {
            continue;
        }
        const filePath = path.join(ingestDir, fileName);
        expiredEntries.push({ filePath, content: await fs.readFile(filePath, "utf8") });
    }
    try {
        for (const entry of expiredEntries) {
            await fs.rm(entry.filePath, { force: true });
        }
    }
    catch (error) {
        await Promise.all(expiredEntries.map((entry) => writeFileAtomically(entry.filePath, entry.content).catch(() => undefined)));
        throw error;
    }
    await removeEmptyDirectory(ingestDir);
    await removeEmptyDirectory(cacheDir);
    return { removed: expiredEntries.length };
}
async function assertIngestCachePathIsNotSymlink(root) {
    await assertNoSymlinkSegments(root, MEMORY_INGEST_CACHE_DIR_RELPATH);
}
function parseCacheEntry(value, requestedKey) {
    const object = expectRecord(value);
    const key = expectString(getOwnEntry(object, "key"), "key");
    if (key !== requestedKey) {
        throw new Error(`Cache entry key "${key}" does not match requested key "${requestedKey}".`);
    }
    return {
        key,
        ingestedAt: expectString(getOwnEntry(object, "ingestedAt"), "ingestedAt"),
        sourceLabel: expectString(getOwnEntry(object, "sourceLabel"), "sourceLabel"),
        diff: parseMemoryDiff(getOwnEntry(object, "diff")),
        exitCode: expectNonNegativeInteger(getOwnEntry(object, "exitCode"), "exitCode"),
        durationMs: expectNonNegativeInteger(getOwnEntry(object, "durationMs"), "durationMs"),
        memoryTokens: expectNonNegativeInteger(getOwnEntry(object, "memoryTokens"), "memoryTokens"),
        sourceTokens: expectNonNegativeInteger(getOwnEntry(object, "sourceTokens"), "sourceTokens"),
        promptTemplateVersion: expectString(getOwnEntry(object, "promptTemplateVersion"), "promptTemplateVersion"),
        agentId: expectString(getOwnEntry(object, "agentId"), "agentId")
    };
}
function parseMemoryDiff(value) {
    const object = expectRecord(value);
    return {
        created: expectStringArray(getOwnEntry(object, "created"), "diff.created"),
        updated: expectStringArray(getOwnEntry(object, "updated"), "diff.updated"),
        deleted: expectStringArray(getOwnEntry(object, "deleted"), "diff.deleted")
    };
}
function expectRecord(value) {
    if (typeof value !== "object" || value === null || Array.isArray(value)) {
        throw new Error("Expected a JSON object.");
    }
    return value;
}
function expectString(value, field) {
    if (typeof value !== "string") {
        throw new Error(`Expected string at "${field}".`);
    }
    return value;
}
function expectNonNegativeInteger(value, field) {
    if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
        throw new Error(`Expected non-negative integer at "${field}".`);
    }
    return value;
}
function expectStringArray(value, field) {
    if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
        throw new Error(`Expected string[] at "${field}".`);
    }
    return value;
}
function getOwnEntry(record, key) {
    return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined;
}
async function readCacheFileNames(ingestDir) {
    try {
        return (await fs.readdir(ingestDir))
            .filter((fileName) => path.posix.extname(fileName).toLowerCase() === ".json")
            .sort((left, right) => left.localeCompare(right));
    }
    catch (error) {
        if (isMissing(error)) {
            return [];
        }
        throw error;
    }
}
async function removeEmptyDirectory(directoryPath) {
    try {
        const remainingEntries = await fs.readdir(directoryPath);
        if (remainingEntries.length === 0) {
            await fs.rmdir(directoryPath);
        }
    }
    catch (error) {
        if (isMissing(error)) {
            return;
        }
        throw error;
    }
}
function isMissing(error) {
    return hasOwnErrorCode(error, "ENOENT");
}
