"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.uploadApp = uploadApp;
const node_crypto_1 = require("node:crypto");
const types_1 = require("@playdrop/types");
const config_1 = require("@playdrop/config");
const node_fs_1 = require("node:fs");
const node_path_1 = require("node:path");
const http_1 = require("../http");
const launchCheck_1 = require("./launchCheck");
const EXTENSION_TO_MIME = {
    '.png': 'image/png',
    '.jpg': 'image/jpeg',
    '.jpeg': 'image/jpeg',
    '.webp': 'image/webp',
    '.gif': 'image/gif',
    '.mp4': 'video/mp4',
    '.webm': 'video/webm',
    '.mp3': 'audio/mpeg',
    '.wav': 'audio/wav',
    '.ogg': 'audio/ogg',
    '.json': 'application/json',
    '.gltf': 'model/gltf+json',
    '.glb': 'model/gltf-binary',
    '.vox': 'application/octet-stream',
};
function isPngSignature(content) {
    if (content.length < 8) {
        return false;
    }
    return (content[0] === 0x89
        && content[1] === 0x50
        && content[2] === 0x4e
        && content[3] === 0x47
        && content[4] === 0x0d
        && content[5] === 0x0a
        && content[6] === 0x1a
        && content[7] === 0x0a);
}
function isMp4Signature(content) {
    if (content.length < 12) {
        return false;
    }
    return content.toString('ascii', 4, 8) === 'ftyp';
}
function createUploadValidationError(code, message) {
    const error = new Error(`${code}: ${message}`);
    error.code = code;
    return error;
}
function formatLimitBytes(maxBytes) {
    if (maxBytes >= 1024 * 1024) {
        return `${maxBytes / 1024 / 1024}MB`;
    }
    return `${maxBytes / 1024}KB`;
}
function createFileFromPath(filePath, mimeType, maxBytes, sizeCode, typeCode, label, validator) {
    const content = (0, node_fs_1.readFileSync)(filePath);
    if (content.length > maxBytes) {
        throw createUploadValidationError(sizeCode, `${label} must be under ${formatLimitBytes(maxBytes)}. Received ${content.length} bytes from ${filePath}.`);
    }
    if (validator && !validator(content)) {
        throw createUploadValidationError(typeCode, `${label} must match ${mimeType}. Invalid file signature at ${filePath}.`);
    }
    const name = (0, node_path_1.basename)(filePath);
    return {
        file: new File([content], name, { type: mimeType }),
        size: content.length,
    };
}
async function bufferFromFile(file) {
    return Buffer.from(await file.arrayBuffer());
}
function computeSha256Hex(buffer) {
    return (0, node_crypto_1.createHash)('sha256').update(buffer).digest('hex');
}
function resolveMimeType(filename, fallback = 'application/octet-stream') {
    const type = EXTENSION_TO_MIME[(0, node_path_1.extname)(filename).toLowerCase()];
    return type ?? fallback;
}
async function buildPreparedSessionFile(fileKey, file) {
    const buffer = await bufferFromFile(file);
    return {
        fileKey,
        file,
        filename: file.name,
        contentType: file.type || resolveMimeType(file.name),
        sizeBytes: buffer.length,
        sha256: computeSha256Hex(buffer),
    };
}
function createGenericFileFromPath(filePath) {
    const content = (0, node_fs_1.readFileSync)(filePath);
    const name = (0, node_path_1.basename)(filePath);
    return new File([content], name, { type: resolveMimeType(name) });
}
async function prepareOwnedAssetUpload(task) {
    const files = await Promise.all(Object.entries(task.filePaths).map(async ([role, filePath]) => {
        const file = createGenericFileFromPath(filePath);
        const buffer = await bufferFromFile(file);
        return {
            role,
            file,
            filename: file.name,
            contentType: file.type || resolveMimeType(file.name),
            sizeBytes: buffer.length,
            sha256: computeSha256Hex(buffer),
        };
    }));
    return {
        uploadKey: task.name,
        runtimeKey: task.runtimeKey,
        name: task.name,
        displayName: task.displayName,
        description: task.description,
        category: task.category,
        subcategory: task.subcategory,
        format: task.format || (0, node_path_1.extname)(files[0]?.filename || '').replace(/^\./, '').toUpperCase() || 'GLB',
        visibility: task.visibility,
        license: task.license,
        assetSpec: task.assetSpec,
        tags: task.tags,
        shopListed: task.shopListed,
        shopPriceCredits: task.shopPriceCredits,
        files,
    };
}
// eslint-disable-next-line complexity -- Source archive failures need distinct guidance when publish-only media is already excluded.
function ensurePreparedArtifactSize(file, maxBytes, code, label, task) {
    if (!file) {
        return 0;
    }
    if (file.size > maxBytes) {
        if (code === 'source_too_large' && task) {
            const hasSeparatePublishMedia = Boolean(task.listing?.iconPath
                || task.listing?.heroPortraitPath
                || task.listing?.heroLandscapePath
                || task.listing?.screenshotPortraitPaths?.length
                || task.listing?.screenshotLandscapePaths?.length
                || task.listing?.videoPortraitPaths?.length
                || task.listing?.videoLandscapePaths?.length
                || task.achievements?.length);
            const suffix = hasSeparatePublishMedia
                ? ' Listing and achievement media are uploaded separately and excluded from source.zip. The remaining source archive is still too large.'
                : '';
            throw createUploadValidationError(code, `${label} must be under ${formatLimitBytes(maxBytes)}. Received ${file.size} bytes.${suffix}`);
        }
        throw createUploadValidationError(code, `${label} must be under ${formatLimitBytes(maxBytes)}. Received ${file.size} bytes.`);
    }
    return file.size;
}
function accumulateUploadBytes(taskName, currentTotal, nextBytes) {
    const nextTotal = currentTotal + nextBytes;
    if (nextTotal > config_1.MAX_VERSION_TOTAL_BYTES) {
        throw createUploadValidationError('upload_too_large', `Total upload for "${taskName}" exceeds ${formatLimitBytes(config_1.MAX_VERSION_TOTAL_BYTES)}. Received ${nextTotal} bytes.`);
    }
    return nextTotal;
}
function pushPreparedFile(taskName, currentTotal, prepared) {
    return accumulateUploadBytes(taskName, currentTotal, prepared.size);
}
function pushPreparedArtifact(task, currentTotal, file, maxBytes, code, label) {
    const bytes = ensurePreparedArtifactSize(file, maxBytes, code, label, task);
    return accumulateUploadBytes(task.name, currentTotal, bytes);
}
async function uploadApp(client, task, artifacts, options) {
    // Version is required - no legacy fallback
    if (!task.version) {
        throw new Error(`Missing "version" field in catalogue.json for app "${task.name}".\n` +
            `Add a version field like "version": "1.0.0" to upload your app.`);
    }
    return uploadAppVersion(client, task, artifacts, options);
}
/**
 * Upload an app version using the versioned API.
 */
// eslint-disable-next-line complexity -- Upload assembly mirrors the versioned multipart contract in one place.
async function uploadAppVersion(client, task, artifacts, options) {
    const creatorUsername = typeof options?.creatorUsername === 'string' ? options.creatorUsername.trim() : '';
    if (!creatorUsername) {
        throw new Error(`Missing creator username for app "${task.name}" upload.`);
    }
    let totalUploadBytes = 0;
    const achievements = task.achievements ?? [];
    const leaderboards = task.leaderboards ?? [];
    const listing = task.listing;
    const preparedSessionFiles = [];
    const preparedOwnedAssets = await Promise.all(task.ownedAssets.map((ownedAsset) => prepareOwnedAssetUpload(ownedAsset)));
    for (const ownedAsset of preparedOwnedAssets) {
        for (const file of ownedAsset.files) {
            totalUploadBytes = accumulateUploadBytes(task.name, totalUploadBytes, file.sizeBytes);
        }
    }
    for (const definition of achievements) {
        const prepared = createFileFromPath(definition.iconPath, 'image/png', config_1.MAX_VERSION_ICON_BYTES, 'achievement_icon_too_large', 'invalid_achievement_icon_content_type', `Achievement icon "${definition.key}"`, isPngSignature);
        totalUploadBytes = pushPreparedFile(task.name, totalUploadBytes, prepared);
        preparedSessionFiles.push(await buildPreparedSessionFile(`achievementIcon:${definition.key}`, prepared.file));
    }
    if (task.hostingMode === 'EXTERNAL') {
        // no hosted files
    }
    else if (artifacts) {
        totalUploadBytes = pushPreparedArtifact(task, totalUploadBytes, artifacts.bundleFile, config_1.MAX_VERSION_BUNDLE_BYTES, 'bundle_too_large', 'Bundle');
        totalUploadBytes = pushPreparedArtifact(task, totalUploadBytes, artifacts.sourceFile, config_1.MAX_APP_SOURCE_BYTES, 'source_too_large', 'Source archive');
        preparedSessionFiles.push(await buildPreparedSessionFile('bundle', artifacts.bundleFile));
        preparedSessionFiles.push(await buildPreparedSessionFile('source', artifacts.sourceFile));
        if (!options?.skipEcs) {
            if (artifacts.ecsFile) {
                totalUploadBytes = pushPreparedArtifact(task, totalUploadBytes, artifacts.ecsFile, config_1.MAX_APP_ECS_BYTES, 'ecs_too_large', 'ECS config');
                preparedSessionFiles.push(await buildPreparedSessionFile('ecs', artifacts.ecsFile));
            }
            if (artifacts.serverFile) {
                totalUploadBytes = pushPreparedArtifact(task, totalUploadBytes, artifacts.serverFile, config_1.MAX_APP_SERVER_BYTES, 'server_too_large', 'Server.js');
                preparedSessionFiles.push(await buildPreparedSessionFile('server', artifacts.serverFile));
            }
        }
    }
    if (listing?.iconPath) {
        const prepared = createFileFromPath(listing.iconPath, 'image/png', config_1.MAX_VERSION_ICON_BYTES, 'icon_too_large', 'invalid_icon_content_type', 'Icon', isPngSignature);
        totalUploadBytes = pushPreparedFile(task.name, totalUploadBytes, prepared);
        preparedSessionFiles.push(await buildPreparedSessionFile('icon', prepared.file));
    }
    if (listing?.heroPortraitPath) {
        const prepared = createFileFromPath(listing.heroPortraitPath, 'image/png', config_1.MAX_VERSION_HERO_BYTES, 'hero_portrait_too_large', 'invalid_hero_portrait_content_type', 'Hero portrait', isPngSignature);
        totalUploadBytes = pushPreparedFile(task.name, totalUploadBytes, prepared);
        preparedSessionFiles.push(await buildPreparedSessionFile('heroPortrait', prepared.file));
    }
    if (listing?.heroLandscapePath) {
        const prepared = createFileFromPath(listing.heroLandscapePath, 'image/png', config_1.MAX_VERSION_HERO_BYTES, 'hero_landscape_too_large', 'invalid_hero_landscape_content_type', 'Hero landscape', isPngSignature);
        totalUploadBytes = pushPreparedFile(task.name, totalUploadBytes, prepared);
        preparedSessionFiles.push(await buildPreparedSessionFile('heroLandscape', prepared.file));
    }
    for (let index = 0; index < (listing?.screenshotPortraitPaths ?? []).length; index += 1) {
        const filePath = listing.screenshotPortraitPaths[index];
        const prepared = createFileFromPath(filePath, 'image/png', config_1.MAX_VERSION_SCREENSHOT_BYTES, 'screenshot_portrait_too_large', 'invalid_screenshot_portrait_content_type', `Portrait screenshot ${index + 1}`, isPngSignature);
        totalUploadBytes = pushPreparedFile(task.name, totalUploadBytes, prepared);
        preparedSessionFiles.push(await buildPreparedSessionFile(`screenshotsPortrait:${index}`, prepared.file));
    }
    for (let index = 0; index < (listing?.screenshotLandscapePaths ?? []).length; index += 1) {
        const filePath = listing.screenshotLandscapePaths[index];
        const prepared = createFileFromPath(filePath, 'image/png', config_1.MAX_VERSION_SCREENSHOT_BYTES, 'screenshot_landscape_too_large', 'invalid_screenshot_landscape_content_type', `Landscape screenshot ${index + 1}`, isPngSignature);
        totalUploadBytes = pushPreparedFile(task.name, totalUploadBytes, prepared);
        preparedSessionFiles.push(await buildPreparedSessionFile(`screenshotsLandscape:${index}`, prepared.file));
    }
    for (let index = 0; index < (listing?.videoPortraitPaths ?? []).length; index += 1) {
        const filePath = listing.videoPortraitPaths[index];
        const prepared = createFileFromPath(filePath, 'video/mp4', config_1.MAX_VERSION_VIDEO_BYTES, 'video_portrait_too_large', 'invalid_video_portrait_content_type', `Portrait video ${index + 1}`, isMp4Signature);
        totalUploadBytes = pushPreparedFile(task.name, totalUploadBytes, prepared);
        preparedSessionFiles.push(await buildPreparedSessionFile(`videosPortrait:${index}`, prepared.file));
    }
    for (let index = 0; index < (listing?.videoLandscapePaths ?? []).length; index += 1) {
        const filePath = listing.videoLandscapePaths[index];
        const prepared = createFileFromPath(filePath, 'video/mp4', config_1.MAX_VERSION_VIDEO_BYTES, 'video_landscape_too_large', 'invalid_video_landscape_content_type', `Landscape video ${index + 1}`, isMp4Signature);
        totalUploadBytes = pushPreparedFile(task.name, totalUploadBytes, prepared);
        preparedSessionFiles.push(await buildPreparedSessionFile(`videosLandscape:${index}`, prepared.file));
    }
    const initializeRequest = {
        version: task.version,
        releaseNotes: task.releaseNotes,
        visibility: task.versionVisibility,
        license: task.license,
        remixRef: task.remix,
        usesAssets: task.uses.assets.map((entry) => ({
            ref: entry.ref,
            ...(entry.runtimeKey ? { runtimeKey: entry.runtimeKey } : {}),
        })),
        usesPacks: task.uses.packs,
        tags: task.tags,
        clearTags: options?.clearTags,
        surfaceTargets: task.surfaceTargets,
        assetSpecSupport: (task.assetSpecSupport ?? []).map((row) => ({
            assetSpecRef: row.assetSpec,
            versionRange: row.versionRange,
            capabilities: row.capabilities,
        })),
        entryPoint: artifacts?.metadata?.entryPoint ?? undefined,
        authMode: task.authMode,
        controllerMode: task.controllerMode,
        previewable: task.previewable,
        skipReview: options?.skipReview,
        displayName: task.displayName,
        description: task.description,
        type: task.type,
        emoji: task.emoji ?? undefined,
        color: task.color ?? undefined,
        achievements: achievements.map((definition) => ({
            key: definition.key,
            displayName: definition.displayName,
            description: definition.description,
            kind: definition.kind,
            ...(definition.hidden ? { hidden: true } : {}),
            ...(definition.maxProgress !== undefined ? { maxProgress: definition.maxProgress } : {}),
            icon: definition.icon,
            status: definition.status,
        })),
        leaderboards: leaderboards.map((definition) => ({
            key: definition.key,
            displayName: definition.displayName,
            description: definition.description,
            scoreType: definition.scoreType,
            sort: definition.sort,
            ...(definition.unitLabel ? { unitLabel: definition.unitLabel } : {}),
            status: definition.status,
        })),
        externalUrl: task.hostingMode === 'EXTERNAL' ? task.externalUrl : undefined,
        files: preparedSessionFiles.map((file) => ({
            fileKey: file.fileKey,
            filename: file.filename,
            contentType: file.contentType,
            sizeBytes: file.sizeBytes,
            sha256: file.sha256,
        })),
        ownedAssets: preparedOwnedAssets.map((ownedAsset) => ({
            uploadKey: ownedAsset.uploadKey,
            runtimeKey: ownedAsset.runtimeKey,
            name: ownedAsset.name,
            displayName: ownedAsset.displayName,
            description: ownedAsset.description,
            category: ownedAsset.category,
            subcategory: ownedAsset.subcategory,
            format: ownedAsset.format,
            assetSpec: ownedAsset.assetSpec,
            visibility: ownedAsset.visibility,
            license: ownedAsset.license,
            tags: ownedAsset.tags,
            shopListed: ownedAsset.shopListed,
            shopPriceCredits: ownedAsset.shopPriceCredits,
            files: ownedAsset.files.map((file) => ({
                role: file.role,
                filename: file.filename,
                contentType: file.contentType,
                sizeBytes: file.sizeBytes,
                sha256: file.sha256,
            })),
        })),
    };
    let sessionId = null;
    try {
        const initialized = await client.initializeAppUpload(creatorUsername, task.name, initializeRequest);
        sessionId = initialized.session.id;
        if (initialized.status !== 'finalized') {
            const uploadedFileKeys = new Set(initialized.session.uploadedFileKeys);
            const uploadedOwnedAssetKeys = new Set(initialized.session.uploadedOwnedAssetKeys);
            for (const file of preparedSessionFiles) {
                if (uploadedFileKeys.has(file.fileKey)) {
                    continue;
                }
                await client.uploadAppSessionFile(creatorUsername, task.name, sessionId, file.fileKey, {
                    file: file.file,
                    filename: file.filename,
                });
            }
            for (const ownedAsset of preparedOwnedAssets) {
                if (uploadedOwnedAssetKeys.has(ownedAsset.uploadKey)) {
                    continue;
                }
                await client.uploadAppSessionOwnedAsset(creatorUsername, task.name, sessionId, ownedAsset.uploadKey, {
                    files: ownedAsset.files.map((file) => ({
                        fieldName: file.role,
                        file: file.file,
                        filename: file.filename,
                    })),
                });
            }
            if (task.hostingMode !== 'EXTERNAL'
                && !task.externalUrl
                && options?.runStagedUploadLaunchCheck) {
                const launchCheck = await (0, launchCheck_1.runUploadedHostedLaunchCheck)({
                    client,
                    creatorUsername,
                    task,
                    sessionId,
                    timeoutMs: options.launchCheckTimeoutMs,
                    token: options?.token ?? null,
                    currentUser: options?.user ?? null,
                });
                if (launchCheck.status !== 'PASSED') {
                    throw new Error((0, launchCheck_1.formatHostedLaunchCheckFailure)(task.name, launchCheck, 'staged'));
                }
            }
        }
        const finalized = initialized.status === 'finalized'
            ? initialized.finalized
            : await client.finalizeAppUpload(creatorUsername, task.name, sessionId);
        if (!finalized) {
            throw new Error(`Upload failed for ${task.name}: missing finalized app upload payload`);
        }
        const version = initialized.status === 'finalized'
            ? finalized?.version
            : finalized.version;
        const versionNodeId = initialized.status === 'finalized'
            ? finalized?.versionNodeId
            : finalized.versionNodeId;
        return {
            versionCreated: true,
            version: version?.version,
            versionId: typeof version?.id === 'number' ? version.id : undefined,
            versionNodeId: typeof versionNodeId === 'string' ? versionNodeId : undefined,
        };
    }
    catch (unknownError) {
        if (sessionId) {
            try {
                await client.abortAppUpload(creatorUsername, task.name, sessionId);
            }
            catch {
                // keep original error
            }
        }
        if (unknownError instanceof types_1.UnsupportedClientError) {
            (0, http_1.handleUnsupportedError)(unknownError, 'Upload app version');
        }
        if (unknownError instanceof http_1.CLIUnsupportedClientError) {
            throw unknownError;
        }
        if (unknownError instanceof types_1.ApiError) {
            const detail = unknownError.message?.trim();
            if (unknownError.code === 'version_limit_reached') {
                const appRef = `${creatorUsername}/app/${task.name}`;
                throw new Error(`${detail || `App "${task.name}" has reached the maximum version count.`}\n`
                    + `Review versions: playdrop versions browse ${appRef}\n`
                    + `Delete one old version: playdrop creations apps versions delete ${task.name} <version>`);
            }
            if (unknownError.status === 409 && unknownError.code === 'version_exists') {
                throw new Error(`Version ${task.version} already exists for app "${task.name}".\n` +
                    `Hint: Update "version" in catalogue.json to a new version (e.g., bump to the next patch).`);
            }
            const statusText = `status ${unknownError.status}`;
            const errorCode = typeof unknownError.code === 'string' && unknownError.code.trim().length > 0
                ? unknownError.code.trim()
                : '';
            const annotatedMessage = detail
                ? `${errorCode ? `${errorCode}: ` : ''}${detail}`
                : `${statusText}${errorCode ? ` (${errorCode})` : ''}`;
            throw new Error(`Upload failed for ${task.name}: ${annotatedMessage}`);
        }
        throw unknownError;
    }
}
