import { randomUUID } from "node:crypto";
import { createWriteStream } from "node:fs";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { z } from "zod";
import { resolveAttachmentFileName } from "@superblocksteam/library-shared/types";
import { detectAttachmentArchiveType, } from "@superblocksteam/shared";
import { getLogger, getPrefixedLogger } from "../../../../util/logger.js";
import { AttachmentsStore } from "../../../attachments/index.js";
import { getCanonicalAttachmentUrl } from "../../../attachments/uploaded-content-part.js";
import { getActiveContext } from "../../../llm/context-v2/utils/index.js";
import { createByteCapTransform, extractGzipArchive, extractTgzArchive, extractZipArchive, MAX_ARCHIVE_EXTRACTED_BYTES, sanitizeFileName, } from "../../../util/archive-extractors.js";
import { getScopedToken } from "../../../util/scoped-token-utils.js";
import { createToolFactory, PermissionLevel, ToolCategory, ToolExecutionError, } from "../types.js";
const DOWNLOAD_SCRATCH_ROOT = path.join(".superblocks", "scratch", "attachments");
const MAX_ATTACHMENT_IDS = 20;
/**
 * Cap on the compressed-on-disk size of a single downloaded attachment. Even
 * before decompression, a malicious signed URL could stream an arbitrarily
 * large body and fill the scratch volume, so we bound it at 2x the decompressed
 * cap. This is generous enough for legitimate zip/tgz uploads but bounds the
 * blast radius of a single bad attachment.
 */
const MAX_DOWNLOAD_BYTES = 2 * MAX_ARCHIVE_EXTRACTED_BYTES;
export const downloadAttachmentsToolFactory = createToolFactory("downloadAttachments", ({ clark, services, }) => {
    return {
        // Categorized as DEBUG so it is available in PLAN mode: Clark needs
        // to materialize uploaded archives into the private scratch dir in
        // order to inspect their contents before exitPlanMode when an
        // attachment's classification is `reference_only`.
        category: ToolCategory.DEBUG,
        defaultPermission: PermissionLevel.ALLOW,
        // This tool writes downloaded + extracted files into the app scratch
        // directory, so it is not read-only.
        readOnly: false,
        description: `Download uploaded session attachments into a fixed scratch directory.

Use listAttachments first to discover attachment IDs and/or storage keys.

Behavior:
- Downloads uploaded attachments.
- Automatically extracts supported archive formats: zip, tgz, gz.
- Returns local working paths for further file operations (ls/read/glob/etc).`,
        inputSchema: z
            .object({
            attachmentIds: z
                .array(z.number().int().positive())
                .max(MAX_ATTACHMENT_IDS)
                .optional()
                .describe(`Session attachment IDs from listAttachments (max ${MAX_ATTACHMENT_IDS})`),
            storageKeys: z
                .array(z.string().min(1))
                .max(MAX_ATTACHMENT_IDS)
                .optional()
                .describe(`Uploaded attachment storage keys from listAttachments (max ${MAX_ATTACHMENT_IDS})`),
        })
            .refine((input) => (input.attachmentIds?.length ?? 0) > 0 ||
            (input.storageKeys?.length ?? 0) > 0, {
            message: "Provide at least one attachmentId or storageKey",
        }),
        execute: async ({ attachmentIds, storageKeys }) => {
            const logger = getPrefixedLogger("[downloadAttachments]");
            const context = getActiveContext(clark, services);
            if (!context) {
                throw new ToolExecutionError("Context not available", {
                    context: {},
                    input: { attachmentIds, storageKeys },
                    severity: "warn",
                    toolName: "downloadAttachments",
                });
            }
            const uniqueIds = Array.from(new Set(attachmentIds ?? [])).sort((a, b) => a - b);
            const uniqueStorageKeys = Array.from(new Set((storageKeys ?? [])
                .map((storageKey) => storageKey.trim())
                .filter((storageKey) => storageKey.length > 0))).sort();
            const store = new AttachmentsStore();
            const attachmentsById = store.getAttachmentsByIds(context, uniqueIds);
            const attachmentsByStorageKey = store.getUploadedAttachmentsByStorageKeys(context, uniqueStorageKeys);
            const foundIds = new Set(attachmentsById.map((attachment) => attachment.id));
            const foundStorageKeys = new Set(attachmentsByStorageKey
                .map((attachment) => attachment.storageKey?.trim())
                .filter((storageKey) => !!storageKey));
            const mergedAttachmentsById = new Map();
            for (const attachment of attachmentsById) {
                mergedAttachmentsById.set(attachment.id, attachment);
            }
            for (const attachment of attachmentsByStorageKey) {
                mergedAttachmentsById.set(attachment.id, attachment);
            }
            const storedAttachments = Array.from(mergedAttachmentsById.values()).sort((left, right) => left.id - right.id);
            const failures = [];
            const missingIds = uniqueIds.filter((id) => !foundIds.has(id));
            for (const missingId of missingIds) {
                failures.push({
                    attachmentId: missingId,
                    error: "Attachment ID not found in current session",
                });
            }
            const missingStorageKeys = uniqueStorageKeys.filter((storageKey) => !foundStorageKeys.has(storageKey));
            for (const missingStorageKey of missingStorageKeys) {
                failures.push({
                    error: "Attachment storageKey not found in current session",
                    storageKey: missingStorageKey,
                });
            }
            const uploadedAttachments = storedAttachments.filter((attachment) => {
                if (attachment.type === "uploaded") {
                    return true;
                }
                failures.push({
                    attachmentId: attachment.id,
                    error: "Attachment is not an uploaded file reference",
                    fileName: attachment.fileName,
                });
                return false;
            });
            if (uploadedAttachments.length === 0) {
                return {
                    downloads: [],
                    failures,
                    count: 0,
                    rootPath: DOWNLOAD_SCRATCH_ROOT,
                };
            }
            const appScopeAttachments = uploadedAttachments.filter((attachment) => attachment.scopeType !== "org");
            let scopedToken = "";
            if (appScopeAttachments.length > 0) {
                if (!clark.context.jwt) {
                    throw new Error("Cannot resolve app-scoped attachments without a user JWT");
                }
                scopedToken = await getScopedToken({
                    applicationId: services.applicationId,
                    jwt: clark.context.jwt,
                    superblocksBaseUrl: services.superblocksBaseUrl,
                });
            }
            const leaseReferences = uploadedAttachments.map((attachment) => {
                const resolvedFileName = resolveAttachmentFileName({
                    fileName: attachment.fileName,
                    mediaType: attachment.mediaType,
                    type: "uploaded",
                });
                const scopeType = attachment.scopeType === "org" ? "org" : "app";
                return {
                    applicationId: scopeType === "app"
                        ? (attachment.applicationId ?? services.applicationId)
                        : undefined,
                    fileName: resolvedFileName,
                    mediaType: attachment.mediaType,
                    scopeType,
                    storageKey: attachment.storageKey,
                    url: getCanonicalAttachmentUrl(attachment.url),
                };
            });
            const leaseResponse = await services.rpcClient.call(async (client) => {
                return await client.call.v1.ai.resolveAttachmentLeases({
                    attachments: leaseReferences,
                    scopedToken,
                    superblocksBaseUrl: services.superblocksBaseUrl,
                });
            });
            const resolvedAttachments = leaseResponse?.data?.attachments;
            if (!Array.isArray(resolvedAttachments)) {
                throw new ToolExecutionError("Download failed. Attachment lease response missing attachments array.", {
                    context: {
                        attachmentCount: leaseReferences.length,
                    },
                    input: { attachmentIds, storageKeys },
                    severity: "error",
                    toolName: "downloadAttachments",
                });
            }
            const resolvedByUrl = new Map(resolvedAttachments
                .filter((attachment) => typeof attachment?.url === "string" &&
                typeof attachment?.signedUrl === "string")
                .map((attachment) => [
                getCanonicalAttachmentUrl(attachment.url),
                attachment,
            ]));
            const runId = `${Date.now()}-${randomUUID().slice(0, 8)}`;
            const rootRelativePath = path.join(DOWNLOAD_SCRATCH_ROOT, runId);
            const rootAbsolutePath = path.join(services.appShell.appRootDirPath, rootRelativePath);
            await mkdir(rootAbsolutePath, { recursive: true });
            const downloads = [];
            for (const attachment of uploadedAttachments) {
                const canonicalUrl = getCanonicalAttachmentUrl(attachment.url);
                const resolved = resolvedByUrl.get(canonicalUrl);
                const resolvedFileName = resolveAttachmentFileName({
                    fileName: attachment.fileName,
                    mediaType: attachment.mediaType,
                    type: "uploaded",
                });
                if (!resolved) {
                    failures.push({
                        attachmentId: attachment.id,
                        error: "Lease resolution failed",
                        fileName: resolvedFileName,
                    });
                    continue;
                }
                try {
                    const response = await fetch(resolved.signedUrl);
                    if (!response.ok || !response.body) {
                        throw new Error(`Failed to download attachment (${response.status})`);
                    }
                    const safeName = sanitizeFileName(resolvedFileName);
                    const relativeDownloadPath = path.join(rootRelativePath, `${attachment.id}-${safeName}`);
                    const absoluteDownloadPath = path.join(services.appShell.appRootDirPath, relativeDownloadPath);
                    await mkdir(path.dirname(absoluteDownloadPath), {
                        recursive: true,
                    });
                    // Stream the response body directly to disk instead of buffering
                    // the entire file in memory via arrayBuffer(). Large attachments
                    // (zip/tgz up to 200 MiB) would otherwise OOM the vite-plugin process.
                    // Cap the on-disk size so a malicious signed URL cannot stream
                    // arbitrary bytes and fill the scratch volume before decompression
                    // limits get a chance to run.
                    await pipeline(Readable.fromWeb(response.body), createByteCapTransform(MAX_DOWNLOAD_BYTES, `Attachment download exceeds maximum size (${MAX_DOWNLOAD_BYTES} bytes)`), createWriteStream(absoluteDownloadPath));
                    const archiveType = detectAttachmentArchiveType({
                        fileName: resolvedFileName,
                        mimeType: attachment.mediaType,
                    });
                    if (!archiveType) {
                        downloads.push({
                            attachmentId: attachment.id,
                            downloadPath: relativeDownloadPath,
                            extracted: false,
                            fileName: resolvedFileName,
                            mediaType: attachment.mediaType,
                            scopeType: attachment.scopeType === "org" ? "org" : "app",
                            workingPath: relativeDownloadPath,
                        });
                        continue;
                    }
                    const extractionDirRelative = path.join(rootRelativePath, `${attachment.id}-${safeName}-extracted`);
                    const extractionDirAbsolute = path.join(services.appShell.appRootDirPath, extractionDirRelative);
                    await mkdir(extractionDirAbsolute, { recursive: true });
                    let extractedFilesCount = 0;
                    let workingPath = extractionDirRelative;
                    if (archiveType === "zip") {
                        extractedFilesCount = await extractZipArchive({
                            archivePath: absoluteDownloadPath,
                            destinationDir: extractionDirAbsolute,
                        });
                    }
                    else if (archiveType === "tgz") {
                        extractedFilesCount = await extractTgzArchive({
                            archivePath: absoluteDownloadPath,
                            destinationDir: extractionDirAbsolute,
                        });
                    }
                    else {
                        const extracted = await extractGzipArchive({
                            archivePath: absoluteDownloadPath,
                            destinationDir: extractionDirAbsolute,
                            fileName: resolvedFileName,
                        });
                        extractedFilesCount = extracted.extractedCount;
                        workingPath = path.relative(services.appShell.appRootDirPath, extracted.outputPath);
                    }
                    downloads.push({
                        archiveType,
                        attachmentId: attachment.id,
                        downloadPath: relativeDownloadPath,
                        extracted: true,
                        extractedFilesCount,
                        fileName: resolvedFileName,
                        mediaType: attachment.mediaType,
                        scopeType: attachment.scopeType === "org" ? "org" : "app",
                        workingPath,
                    });
                }
                catch (error) {
                    getLogger().warn("[downloadAttachments] Failed to download", {
                        attachmentId: attachment.id,
                        error: error instanceof Error ? error.message : String(error),
                    });
                    failures.push({
                        attachmentId: attachment.id,
                        error: error instanceof Error ? error.message : "Download failed",
                        fileName: resolvedFileName,
                    });
                }
            }
            logger.info(`Downloaded ${downloads.length} attachment(s), ${failures.length} failure(s)`);
            return {
                downloads,
                failures,
                count: downloads.length,
                rootPath: rootRelativePath,
            };
        },
        getSummary: (_input, output) => {
            const count = typeof output === "object" &&
                output &&
                "count" in output &&
                typeof output.count === "number"
                ? output.count
                : 0;
            return {
                output: `${count} attachment${count === 1 ? "" : "s"} downloaded`,
            };
        },
    };
});
//# sourceMappingURL=download-attachments.js.map