"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.runNpmScript = runNpmScript;
exports.buildApp = buildApp;
const node_fs_1 = require("node:fs");
const promises_1 = require("node:fs/promises");
const node_crypto_1 = require("node:crypto");
const node_path_1 = require("node:path");
const node_child_process_1 = require("node:child_process");
const node_zlib_1 = require("node:zlib");
const BASE_SOURCE_IGNORE_PATTERNS = [
    'node_modules/',
    'dist/',
    'build/',
    'out/',
    '.next/',
    '.turbo/',
    '.cache/',
    '.git/',
    '.idea/',
    '.svelte-kit/',
    '.DS_Store',
    '*.log',
    '*.tmp',
    '*.zip',
    'tmp/',
    'logs/',
    'coverage/',
    '.vercel/',
    '.netlify/',
];
const projectBuildCache = new Map();
const typescriptSourceCache = new Map();
async function runNpmScript(projectDir, script) {
    await (0, promises_1.mkdir)(projectDir, { recursive: true });
    const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
    await new Promise((resolveRun, rejectRun) => {
        const child = (0, node_child_process_1.spawn)(npmCommand, ['run', script], {
            cwd: projectDir,
            stdio: 'inherit',
            env: { ...process.env },
        });
        const settleFromClose = (code) => {
            if (code === 0) {
                resolveRun();
                return;
            }
            rejectRun(new Error(`npm run ${script} failed with exit code ${code ?? 'unknown'}`));
        };
        child.once('error', rejectRun);
        child.once('close', settleFromClose);
    });
}
function readPackageJson(packageJsonPath) {
    const raw = (0, node_fs_1.readFileSync)(packageJsonPath, 'utf8');
    return JSON.parse(raw);
}
async function ensureProjectBuild(task) {
    if (!task.isTypescriptProject || !task.packageJsonPath) {
        return;
    }
    if (!projectBuildCache.has(task.projectDir)) {
        const promise = (async () => {
            const pkg = readPackageJson(task.packageJsonPath);
            const scripts = pkg?.scripts ?? {};
            if (typeof scripts.build !== 'string') {
                throw new Error(`package.json in ${task.projectDir} is missing a "build" script.`);
            }
            await runNpmScript(task.projectDir, 'build');
        })();
        projectBuildCache.set(task.projectDir, promise);
    }
    await projectBuildCache.get(task.projectDir);
}
function globToRegExp(glob) {
    let pattern = '^';
    for (let i = 0; i < glob.length; i += 1) {
        const char = glob[i];
        if (char === '*') {
            const next = glob[i + 1];
            if (next === '*') {
                pattern += '.*';
                i += 1;
            }
            else {
                pattern += '[^/]*';
            }
            continue;
        }
        if (char === '?') {
            pattern += '.';
            continue;
        }
        if ('+.^$|()[]{}'.includes(char) || char === '\\') {
            pattern += `\\${char}`;
            continue;
        }
        pattern += char;
    }
    pattern += '$';
    return new RegExp(pattern);
}
function readIgnorePatterns(projectDir, filename) {
    const filePath = (0, node_path_1.join)(projectDir, filename);
    if (!(0, node_fs_1.existsSync)(filePath)) {
        return [];
    }
    try {
        const content = (0, node_fs_1.readFileSync)(filePath, 'utf8');
        return content.split(/\r?\n/);
    }
    catch {
        return [];
    }
}
function buildIgnoreRules(projectDir) {
    const patterns = [...BASE_SOURCE_IGNORE_PATTERNS];
    patterns.push(...readIgnorePatterns(projectDir, '.playdropignore'));
    patterns.push(...readIgnorePatterns(projectDir, '.gitignore'));
    const rules = [];
    for (const rawPattern of patterns) {
        const trimmed = rawPattern.trim();
        if (!trimmed || trimmed.startsWith('#')) {
            continue;
        }
        const normalized = trimmed.replace(/\\/g, '/');
        if (normalized.endsWith('/')) {
            const segment = normalized.slice(0, -1);
            const regex = globToRegExp(`**/${segment}/**`);
            rules.push({
                matches: (path, isDir) => {
                    const target = isDir ? `${path}/` : path;
                    if (target === `${segment}/`)
                        return true;
                    if (target.startsWith(`${segment}/`))
                        return true;
                    return regex.test(target);
                },
            });
            continue;
        }
        if (!normalized.includes('/') && !normalized.includes('*') && !normalized.includes('?')) {
            const name = normalized;
            rules.push({
                matches: path => {
                    if (path === name)
                        return true;
                    if (path.endsWith(`/${name}`))
                        return true;
                    return path.split('/').includes(name);
                },
            });
            continue;
        }
        if (!normalized.includes('*') && !normalized.includes('?')) {
            const target = normalized;
            rules.push({
                matches: path => path === target || path.startsWith(`${target}/`),
            });
            continue;
        }
        const regex = globToRegExp(normalized);
        rules.push({
            matches: path => regex.test(path),
        });
    }
    return rules;
}
function shouldIgnore(pathFragment, isDir, rules) {
    return rules.some(rule => rule.matches(pathFragment, isDir));
}
function normalizeRelativePath(root, target) {
    const rel = (0, node_path_1.relative)(root, target).split(node_path_1.sep).join('/');
    return rel.replace(/^\/+/, '');
}
function isPathWithinRoot(rootDir, targetPath) {
    const normalizedRoot = (0, node_path_1.resolve)(rootDir);
    const normalizedTarget = (0, node_path_1.resolve)(targetPath);
    if (normalizedTarget === normalizedRoot) {
        return true;
    }
    const rootPrefix = normalizedRoot.endsWith(node_path_1.sep) ? normalizedRoot : `${normalizedRoot}${node_path_1.sep}`;
    return normalizedTarget.startsWith(rootPrefix);
}
function readProjectDirEntries(current) {
    try {
        return (0, node_fs_1.readdirSync)(current, { withFileTypes: true });
    }
    catch {
        return [];
    }
}
function appendIncludedProjectFile(rootDir, includePath, excludedRelativeFiles, fileByRelativePath, results, enforceReservedBundleNames) {
    const normalizedIncludePath = (0, node_path_1.normalize)(includePath).split(node_path_1.sep).join('/');
    if (!normalizedIncludePath || normalizedIncludePath === '.') {
        return;
    }
    if (normalizedIncludePath.startsWith('..')) {
        throw new Error(`[apps][build] Invalid include file path "${includePath}".`);
    }
    if (excludedRelativeFiles.has(normalizedIncludePath)) {
        return;
    }
    if (fileByRelativePath.has(normalizedIncludePath)) {
        return;
    }
    const absolutePath = (0, node_path_1.resolve)(rootDir, normalizedIncludePath);
    if (!isPathWithinRoot(rootDir, absolutePath)) {
        throw new Error(`[apps][build] Included file "${includePath}" escapes the project root.`);
    }
    if (!(0, node_fs_1.existsSync)(absolutePath)) {
        return;
    }
    const stats = (0, node_fs_1.statSync)(absolutePath);
    if (!stats.isFile()) {
        return;
    }
    if (enforceReservedBundleNames && isReservedBundlePath(normalizedIncludePath)) {
        throw new Error(`[apps][build] Reserved bundle filename "${normalizedIncludePath}" is not allowed. Use dedicated source/ecs/server upload fields.`);
    }
    const record = {
        absolutePath,
        relativePath: normalizedIncludePath,
        mtime: stats.mtime,
    };
    results.push(record);
    fileByRelativePath.set(record.relativePath, record);
}
// eslint-disable-next-line complexity -- Source archive collection needs one pass to enforce ignore/include/exclude rules deterministically.
function collectProjectFiles(rootDir, rules, options) {
    const includeRelativeFiles = options?.includeRelativeFiles ?? [];
    const excludeRelativeFiles = new Set(Array.from(options?.excludeRelativeFiles ?? [])
        .map((filePath) => (0, node_path_1.normalize)(filePath).split(node_path_1.sep).join('/'))
        .filter((filePath) => filePath.length > 0 && filePath !== '.'));
    const enforceReservedBundleNames = options?.enforceReservedBundleNames ?? false;
    const results = [];
    const fileByRelativePath = new Map();
    const stack = [rootDir];
    const visited = new Set();
    while (stack.length > 0) {
        const current = stack.pop();
        if (visited.has(current))
            continue;
        visited.add(current);
        const dirEntries = readProjectDirEntries(current);
        for (const entry of dirEntries) {
            const absolutePath = (0, node_path_1.join)(current, entry.name);
            const relativePath = normalizeRelativePath(rootDir, absolutePath);
            if (!relativePath)
                continue;
            const isDir = entry.isDirectory();
            if (!isDir && enforceReservedBundleNames && isReservedBundlePath(relativePath)) {
                throw new Error(`[apps][build] Reserved bundle filename "${relativePath}" is not allowed. Use dedicated source/ecs/server upload fields.`);
            }
            const ignoreTarget = isDir ? `${relativePath}/` : relativePath;
            if (shouldIgnore(ignoreTarget, isDir, rules))
                continue;
            if (isDir) {
                stack.push(absolutePath);
                continue;
            }
            if (!entry.isFile())
                continue;
            let stats;
            try {
                stats = (0, node_fs_1.statSync)(absolutePath);
            }
            catch {
                continue;
            }
            if (!stats.isFile())
                continue;
            if (excludeRelativeFiles.has(relativePath))
                continue;
            const record = {
                absolutePath,
                relativePath: relativePath.split('/').join('/'),
                mtime: stats.mtime,
            };
            results.push(record);
            fileByRelativePath.set(record.relativePath, record);
        }
    }
    for (const includePath of includeRelativeFiles) {
        appendIncludedProjectFile(rootDir, includePath, excludeRelativeFiles, fileByRelativePath, results, enforceReservedBundleNames);
    }
    results.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
    return results;
}
// Files that are uploaded separately and should not be included in the bundle zip.
const RESERVED_BUNDLE_FILES = new Set(['source.zip', 'ecs.json', 'server.js']);
function isReservedBundlePath(relativePath) {
    const normalized = relativePath.replace(/\\/g, '/');
    const basenameValue = normalized.split('/').pop()?.toLowerCase() || '';
    return RESERVED_BUNDLE_FILES.has(basenameValue);
}
function resolveRuntimeRoot(task) {
    return (0, node_path_1.resolve)((0, node_path_1.dirname)(task.filePath));
}
function collectSeparatelyUploadedSourceFiles(task) {
    const excludedFiles = new Set();
    const addFile = (filePath) => {
        if (!filePath) {
            return;
        }
        const absolutePath = (0, node_path_1.resolve)(filePath);
        if (!isPathWithinRoot(task.projectDir, absolutePath)) {
            return;
        }
        const relativePath = normalizeRelativePath(task.projectDir, absolutePath);
        if (!relativePath || relativePath.startsWith('..')) {
            return;
        }
        excludedFiles.add(relativePath);
    };
    addFile(task.listing?.iconPath);
    addFile(task.listing?.heroPortraitPath);
    addFile(task.listing?.heroLandscapePath);
    task.listing?.screenshotPortraitPaths?.forEach(addFile);
    task.listing?.screenshotLandscapePaths?.forEach(addFile);
    task.listing?.videoPortraitPaths?.forEach(addFile);
    task.listing?.videoLandscapePaths?.forEach(addFile);
    task.achievements?.forEach((definition) => addFile(definition.iconPath));
    return Array.from(excludedFiles).sort((left, right) => left.localeCompare(right));
}
function resolveBundleEntryPoint(task, runtimeRoot) {
    const entryPoint = normalizeRelativePath(runtimeRoot, task.filePath);
    if (!entryPoint || entryPoint.startsWith('..')) {
        throw new Error(`[apps][build] App entry file for ${task.name} must remain inside runtime root ${runtimeRoot}.`);
    }
    return entryPoint;
}
/**
 * Collect files for the bundle zip.
 * Collect files for the bundle zip from the runtime root (dirname(entry file)).
 */
function collectBundleFiles(task) {
    const runtimeRoot = resolveRuntimeRoot(task);
    const entryPoint = resolveBundleEntryPoint(task, runtimeRoot);
    const rules = buildIgnoreRules(task.projectDir);
    const entries = collectProjectFiles(runtimeRoot, rules, {
        includeRelativeFiles: [entryPoint],
        enforceReservedBundleNames: true,
    });
    return { entries, entryPoint };
}
function createBundleArchive(task) {
    const { entries, entryPoint } = collectBundleFiles(task);
    const buffer = createZipArchive(entries);
    const hash = (0, node_crypto_1.createHash)('sha256').update(buffer).digest('hex');
    return { buffer, hash, entryPoint };
}
const CRC32_TABLE = (() => {
    const table = new Uint32Array(256);
    for (let i = 0; i < 256; i += 1) {
        let crc = i;
        for (let j = 0; j < 8; j += 1) {
            if ((crc & 1) !== 0) {
                crc = (crc >>> 1) ^ 0xedb88320;
            }
            else {
                crc >>>= 1;
            }
        }
        table[i] = crc >>> 0;
    }
    return table;
})();
const ZIP_REGULAR_FILE_MODE = 0o100644;
function crc32(buffer) {
    let crc = 0 ^ -1;
    for (let i = 0; i < buffer.length; i += 1) {
        crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ buffer[i]) & 0xff];
    }
    return (crc ^ -1) >>> 0;
}
function toDosDateTime(date) {
    const year = Math.max(Math.min(date.getFullYear(), 2107), 1980);
    const month = date.getMonth() + 1;
    const day = date.getDate();
    const hours = date.getHours();
    const minutes = date.getMinutes();
    const seconds = Math.floor(date.getSeconds() / 2);
    const dosDate = ((year - 1980) << 9) | (month << 5) | day;
    const dosTime = (hours << 11) | (minutes << 5) | seconds;
    return { dosDate, dosTime };
}
function createZipArchive(entries) {
    const chunks = [];
    const centralRecords = [];
    let offset = 0;
    for (const entry of entries) {
        const data = (0, node_fs_1.readFileSync)(entry.absolutePath);
        const compressed = (0, node_zlib_1.deflateRawSync)(data);
        const crc = crc32(data);
        const { dosDate, dosTime } = toDosDateTime(entry.mtime);
        const fileNameBuffer = Buffer.from(entry.relativePath, 'utf8');
        const localHeader = Buffer.alloc(30);
        let cursor = 0;
        localHeader.writeUInt32LE(0x04034b50, cursor);
        cursor += 4;
        localHeader.writeUInt16LE(20, cursor);
        cursor += 2;
        localHeader.writeUInt16LE(0x0800, cursor);
        cursor += 2;
        localHeader.writeUInt16LE(8, cursor);
        cursor += 2;
        localHeader.writeUInt16LE(dosTime, cursor);
        cursor += 2;
        localHeader.writeUInt16LE(dosDate, cursor);
        cursor += 2;
        localHeader.writeUInt32LE(crc, cursor);
        cursor += 4;
        localHeader.writeUInt32LE(compressed.length, cursor);
        cursor += 4;
        localHeader.writeUInt32LE(data.length, cursor);
        cursor += 4;
        localHeader.writeUInt16LE(fileNameBuffer.length, cursor);
        cursor += 2;
        localHeader.writeUInt16LE(0, cursor);
        chunks.push(localHeader, fileNameBuffer, compressed);
        const centralHeader = Buffer.alloc(46);
        cursor = 0;
        centralHeader.writeUInt32LE(0x02014b50, cursor);
        cursor += 4;
        centralHeader.writeUInt16LE(0x031e, cursor);
        cursor += 2;
        centralHeader.writeUInt16LE(20, cursor);
        cursor += 2;
        centralHeader.writeUInt16LE(0x0800, cursor);
        cursor += 2;
        centralHeader.writeUInt16LE(8, cursor);
        cursor += 2;
        centralHeader.writeUInt16LE(dosTime, cursor);
        cursor += 2;
        centralHeader.writeUInt16LE(dosDate, cursor);
        cursor += 2;
        centralHeader.writeUInt32LE(crc, cursor);
        cursor += 4;
        centralHeader.writeUInt32LE(compressed.length, cursor);
        cursor += 4;
        centralHeader.writeUInt32LE(data.length, cursor);
        cursor += 4;
        centralHeader.writeUInt16LE(fileNameBuffer.length, cursor);
        cursor += 2;
        centralHeader.writeUInt16LE(0, cursor);
        cursor += 2;
        centralHeader.writeUInt16LE(0, cursor);
        cursor += 2;
        centralHeader.writeUInt16LE(0, cursor);
        cursor += 2;
        centralHeader.writeUInt16LE(0, cursor);
        cursor += 2;
        centralHeader.writeUInt32LE((ZIP_REGULAR_FILE_MODE << 16) >>> 0, cursor);
        cursor += 4;
        centralHeader.writeUInt32LE(offset, cursor);
        cursor += 4;
        centralRecords.push(Buffer.concat([centralHeader, fileNameBuffer]));
        offset += localHeader.length + fileNameBuffer.length + compressed.length;
    }
    const centralDirectory = Buffer.concat(centralRecords);
    const centralSize = centralDirectory.length;
    const centralOffset = offset;
    chunks.push(centralDirectory);
    const eocd = Buffer.alloc(22);
    let cursor = 0;
    eocd.writeUInt32LE(0x06054b50, cursor);
    cursor += 4;
    eocd.writeUInt16LE(0, cursor);
    cursor += 2;
    eocd.writeUInt16LE(0, cursor);
    cursor += 2;
    const entryCount = entries.length;
    eocd.writeUInt16LE(entryCount, cursor);
    cursor += 2;
    eocd.writeUInt16LE(entryCount, cursor);
    cursor += 2;
    eocd.writeUInt32LE(centralSize, cursor);
    cursor += 4;
    eocd.writeUInt32LE(centralOffset, cursor);
    cursor += 4;
    eocd.writeUInt16LE(0, cursor);
    chunks.push(eocd);
    return Buffer.concat(chunks);
}
function createSourceArchive(task) {
    const rules = buildIgnoreRules(task.projectDir);
    const includeFiles = ['README.md', 'AGENTS.md'];
    const excludedFiles = collectSeparatelyUploadedSourceFiles(task);
    if (task.catalogueAbsolutePath && (0, node_fs_1.existsSync)(task.catalogueAbsolutePath)) {
        const relativeCataloguePath = normalizeRelativePath(task.projectDir, task.catalogueAbsolutePath) || 'catalogue.json';
        includeFiles.push(relativeCataloguePath);
    }
    if (task.isTypescriptProject) {
        const cacheKey = [task.projectDir, ...excludedFiles].join('\u0000');
        const cached = typescriptSourceCache.get(cacheKey);
        if (cached) {
            return cached;
        }
        const entries = collectProjectFiles(task.projectDir, rules, {
            includeRelativeFiles: includeFiles,
            excludeRelativeFiles: excludedFiles,
        });
        const buffer = createZipArchive(entries);
        const hash = (0, node_crypto_1.createHash)('sha256').update(buffer).digest('hex');
        const record = { buffer, hash };
        typescriptSourceCache.set(cacheKey, record);
        return record;
    }
    const entries = collectProjectFiles(task.projectDir, rules, {
        includeRelativeFiles: includeFiles,
        excludeRelativeFiles: excludedFiles,
    });
    const buffer = createZipArchive(entries);
    const hash = (0, node_crypto_1.createHash)('sha256').update(buffer).digest('hex');
    return { buffer, hash };
}
async function buildApp(task) {
    await ensureProjectBuild(task);
    if (!(0, node_fs_1.existsSync)(task.filePath) || !(0, node_fs_1.statSync)(task.filePath).isFile()) {
        const locationHint = task.cataloguePath || 'catalogue.json';
        const bundlePath = task.relativeFile || (0, node_path_1.basename)(task.filePath);
        const buildAdvice = task.isTypescriptProject
            ? 'Run `npm run build` in the project directory and confirm the bundle path.'
            : 'Confirm the catalogue entry points to an existing HTML file.';
        throw new Error([
            `Bundle not found for ${task.name}: ${bundlePath}`,
            `Check the "file" value in ${locationHint}.`,
            buildAdvice,
        ].join(' '));
    }
    // Create bundle zip for versioned upload
    const bundleArchive = createBundleArchive(task);
    const bundleFileName = `${task.name}-bundle.zip`;
    const bundleBlobPart = Uint8Array.from(bundleArchive.buffer);
    const bundleFile = new File([bundleBlobPart], bundleFileName, { type: 'application/zip' });
    const bundleHash = bundleArchive.hash;
    const sourceArchive = createSourceArchive(task);
    const sourceFileName = `${task.name}-source.zip`;
    const sourceBlobPart = Uint8Array.from(sourceArchive.buffer);
    const sourceFile = new File([sourceBlobPart], sourceFileName, { type: 'application/zip' });
    const metadata = {};
    if (task.displayName)
        metadata.displayName = task.displayName;
    if (typeof task.description === 'string')
        metadata.description = task.description;
    if (task.type)
        metadata.type = task.type;
    if (task.emoji !== undefined)
        metadata.emoji = task.emoji;
    if (task.color !== undefined)
        metadata.color = task.color;
    if (task.surfaceTargets && task.surfaceTargets.length > 0) {
        metadata.surfaceTargets = task.surfaceTargets;
    }
    metadata.entryPoint = bundleArchive.entryPoint;
    // Build ecs file if path is defined
    let ecsFile;
    let ecsHash;
    if (task.ecsPath) {
        const ecsBuffer = (0, node_fs_1.readFileSync)(task.ecsPath);
        ecsHash = (0, node_crypto_1.createHash)('sha256').update(ecsBuffer).digest('hex');
        const ecsBlobPart = Uint8Array.from(ecsBuffer);
        ecsFile = new File([ecsBlobPart], 'ecs.json', { type: 'application/json' });
    }
    // Build server file if path is defined
    let serverFile;
    let serverHash;
    if (task.serverPath) {
        const serverBuffer = (0, node_fs_1.readFileSync)(task.serverPath);
        serverHash = (0, node_crypto_1.createHash)('sha256').update(serverBuffer).digest('hex');
        const serverBlobPart = Uint8Array.from(serverBuffer);
        serverFile = new File([serverBlobPart], 'server.js', { type: 'application/javascript' });
    }
    return {
        bundleFile,
        bundleHash,
        sourceFile,
        sourceHash: sourceArchive.hash,
        metadata,
        ecsFile,
        ecsHash,
        serverFile,
        serverHash,
    };
}
