import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import ignore from "ignore";
import { getConfigDir, getSessionFile } from "../config/index.js";
import { buildBackupTosKey, uploadBackup } from "./backup-uploader.js";
import { resolveTosCredentials } from "../tos-credentials.js";
import { logger } from "../logger.js";
import { getClaudeProjectSlug, getClaudeTranscriptDirForProfileDir, getReferencedClaudeTranscriptIds, } from "../utils/claude-transcripts.js";
const execFileAsync = promisify(execFile);
const CLOUDFLARED_DIR = path.join(os.homedir(), ".cloudflared");
const CLOUDFLARED_BACKUP_FILES = ["cert.pem", "config.yml"];
const GH_CLI_DIR = path.join(os.homedir(), ".config", "gh");
const GH_CLI_TOKEN_FILE = "oauth-token.txt";
const GLOBAL_BACKUP_IGNORE_FILE = ".visionclaw-backupignore";
const LOCAL_BACKUP_IGNORE_FILE = ".backupignore";
const MAX_IGNORED_PATH_LOG_LINES = 50;
const UPLOAD_PROGRESS_LOG_INTERVAL_MS = 5_000;
function formatBytes(bytes) {
    if (bytes < 1024)
        return `${bytes} B`;
    const units = ["KB", "MB", "GB", "TB"];
    let value = bytes / 1024;
    let unitIndex = 0;
    while (value >= 1024 && unitIndex < units.length - 1) {
        value /= 1024;
        unitIndex += 1;
    }
    return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
}
function formatDuration(ms) {
    if (ms < 1_000)
        return `${Math.max(0, Math.round(ms))}ms`;
    const totalSeconds = Math.max(1, Math.round(ms / 1_000));
    const minutes = Math.floor(totalSeconds / 60);
    const seconds = totalSeconds % 60;
    if (minutes === 0)
        return `${seconds}s`;
    return `${minutes}m ${seconds}s`;
}
function getDirectorySize(dirPath) {
    if (!fs.existsSync(dirPath))
        return 0;
    const stat = fs.lstatSync(dirPath);
    if (stat.isFile())
        return stat.size;
    if (!stat.isDirectory())
        return 0;
    let size = 0;
    for (const entry of fs.readdirSync(dirPath)) {
        size += getDirectorySize(path.join(dirPath, entry));
    }
    return size;
}
function formatSizeBreakdownLines(parts) {
    const totalBytes = parts.reduce((sum, part) => sum + part.bytes, 0);
    return parts
        .map((part) => {
        const percent = totalBytes > 0 ? (part.bytes / totalBytes) * 100 : 0;
        return `  - ${part.label}: ${formatBytes(part.bytes)} (${percent.toFixed(1)}%)`;
    })
        .join("\n");
}
function getHomeDir() {
    return process.env.VISIONCLAW_TEST_HOME_DIR ?? os.homedir();
}
function getDesktopDir() {
    return process.env.VISIONCLAW_TEST_DESKTOP_DIR ?? path.join(getHomeDir(), "Desktop");
}
function formatTimestamp() {
    const now = new Date();
    const pad = (n) => String(n).padStart(2, "0");
    return (`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
        `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`);
}
function toPosixPath(value) {
    return value.split(path.sep).join("/");
}
function ensureDirectoryCandidatePath(relativePath) {
    const posixPath = toPosixPath(relativePath);
    return posixPath.endsWith("/") ? posixPath : `${posixPath}/`;
}
function countIgnoreRules(contents) {
    return contents
        .split(/\r?\n/)
        .map((line) => line.trim())
        .filter((line) => line.length > 0 && !line.startsWith("#"))
        .length;
}
function loadIgnoreFile(filePath) {
    if (!fs.existsSync(filePath))
        return undefined;
    const contents = fs.readFileSync(filePath, "utf-8");
    const createIgnore = ignore;
    const matcher = createIgnore();
    matcher.add(contents);
    return {
        filePath,
        matcher,
        ruleCount: countIgnoreRules(contents),
    };
}
function getSafeRelativePath(root, absPath) {
    const relative = path.relative(root, absPath);
    if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
        return undefined;
    }
    return toPosixPath(relative);
}
function getHomeRelativePath(absPath) {
    return getSafeRelativePath(getHomeDir(), absPath);
}
function isIgnoredBy(matcher, relativePath, isDirectory) {
    if (!matcher || !relativePath || relativePath.length === 0)
        return false;
    if (matcher.matcher.ignores(relativePath))
        return true;
    return isDirectory && matcher.matcher.ignores(ensureDirectoryCandidatePath(relativePath));
}
function shouldIgnoreBackupPath(context, absPath, isDirectory) {
    const sourceRelativePosixPath = getSafeRelativePath(context.sourceRoot, absPath);
    if (context.sourceName === "profile" &&
        isDirectory &&
        (sourceRelativePosixPath === "log" || sourceRelativePosixPath === "logs")) {
        return "built-in";
    }
    const homeRelativePath = getHomeRelativePath(absPath);
    if (homeRelativePath && isIgnoredBy(context.globalIgnore, homeRelativePath, isDirectory)) {
        return "global";
    }
    if (isIgnoredBy(context.localIgnore, sourceRelativePosixPath, isDirectory)) {
        return "local";
    }
    return undefined;
}
function formatIgnoreReason(reason) {
    if (reason === "global")
        return `global ${GLOBAL_BACKUP_IGNORE_FILE}`;
    if (reason === "local")
        return `local ${LOCAL_BACKUP_IGNORE_FILE}`;
    return "built-in";
}
function logLoadedIgnoreFiles(globalIgnore, contexts) {
    if (globalIgnore) {
        logger.system(`Loaded global backup ignore file:\n  ${globalIgnore.filePath}\n  Rules: ${globalIgnore.ruleCount}`);
    }
    for (const context of contexts) {
        if (!context.localIgnore)
            continue;
        logger.system(`Loaded backup ignore file for ${context.sourceName}:\n  ${context.localIgnore.filePath}\n  Rules: ${context.localIgnore.ruleCount}`);
    }
}
function logIgnoredPathsBySource(paths) {
    const sourceNames = ["profile", "transcripts", "desktop", "cloudflared", "gh"];
    for (const sourceName of sourceNames) {
        const entries = paths.filter((entry) => entry.sourceName === sourceName);
        if (entries.length === 0)
            continue;
        const visibleEntries = entries.slice(0, MAX_IGNORED_PATH_LOG_LINES);
        const lines = visibleEntries.map((entry) => `  - ${entry.path} (${formatIgnoreReason(entry.reason)})`);
        const remainingCount = entries.length - visibleEntries.length;
        if (remainingCount > 0) {
            lines.push(`  ... and ${remainingCount} more ignored path(s)`);
        }
        logger.system(`Ignored ${sourceName} paths:\n${lines.join("\n")}`);
    }
}
function snapshotProfileTree(sourceDir, stagingDir, ignoreContext) {
    const skippedPaths = [];
    const ignoredPaths = [];
    const copyEntry = (sourcePath, targetPath, relativePath) => {
        const stat = fs.lstatSync(sourcePath);
        const isDirectory = stat.isDirectory();
        const isFile = stat.isFile();
        if (relativePath) {
            const ignoreReason = shouldIgnoreBackupPath(ignoreContext, sourcePath, isDirectory);
            if (ignoreReason) {
                ignoredPaths.push({
                    sourceName: "profile",
                    path: isDirectory ? ensureDirectoryCandidatePath(relativePath) : toPosixPath(relativePath),
                    reason: ignoreReason,
                });
                return;
            }
        }
        if (isDirectory) {
            fs.mkdirSync(targetPath, { recursive: true });
            for (const entry of fs.readdirSync(sourcePath)) {
                copyEntry(path.join(sourcePath, entry), path.join(targetPath, entry), relativePath ? path.join(relativePath, entry) : entry);
            }
            return;
        }
        if (isFile) {
            fs.mkdirSync(path.dirname(targetPath), { recursive: true });
            fs.copyFileSync(sourcePath, targetPath);
            return;
        }
        if (stat.isSymbolicLink()) {
            skippedPaths.push(sourcePath);
            return;
        }
        skippedPaths.push(sourcePath);
    };
    copyEntry(sourceDir, stagingDir, "");
    return { skippedPaths, ignoredPaths };
}
function logSkippedPathsBySource(paths, maxBytes) {
    const sourceNames = ["desktop"];
    for (const sourceName of sourceNames) {
        const entries = paths.filter((entry) => entry.sourceName === sourceName);
        if (entries.length === 0)
            continue;
        const visibleEntries = entries.slice(0, MAX_IGNORED_PATH_LOG_LINES);
        const lines = visibleEntries.map((entry) => {
            const size = entry.bytes === undefined ? "" : ` (${formatBytes(entry.bytes)})`;
            if (entry.reason === "unsupported")
                return `  - ${entry.path} (unsupported file type)`;
            if (entry.reason === "read-error")
                return `  - ${entry.path} (could not read)`;
            if (entry.reason === "copy-error")
                return `  - ${entry.path} (could not copy${entry.bytes === undefined ? "" : `, ${formatBytes(entry.bytes)}`})`;
            return `  - ${entry.path}${size}`;
        });
        const remainingCount = entries.length - visibleEntries.length;
        if (remainingCount > 0) {
            lines.push(`  ... and ${remainingCount} more skipped path(s)`);
        }
        logger.system(`Skipped ${sourceName} paths due to ${formatBytes(maxBytes)} limit:\n${lines.join("\n")}`);
    }
}
function snapshotDesktop(sourceDir, stagingDir, ignoreContext, maxBytes) {
    const skippedPaths = [];
    const ignoredPaths = [];
    const candidates = [];
    if (!fs.existsSync(sourceDir)) {
        return { copiedCount: 0, copiedBytes: 0, skippedPaths, ignoredPaths };
    }
    const collectEntry = (sourcePath, targetPath, relativePath) => {
        let stat;
        try {
            stat = fs.lstatSync(sourcePath);
        }
        catch {
            skippedPaths.push({
                sourceName: "desktop",
                path: toPosixPath(relativePath || path.basename(sourcePath)),
                reason: "read-error",
            });
            return;
        }
        const isDirectory = stat.isDirectory();
        const isFile = stat.isFile();
        if (relativePath) {
            const ignoreReason = shouldIgnoreBackupPath(ignoreContext, sourcePath, isDirectory);
            if (ignoreReason) {
                ignoredPaths.push({
                    sourceName: "desktop",
                    path: isDirectory ? ensureDirectoryCandidatePath(relativePath) : toPosixPath(relativePath),
                    reason: ignoreReason,
                });
                return;
            }
        }
        if (isDirectory) {
            let entries;
            try {
                entries = fs.readdirSync(sourcePath);
            }
            catch {
                skippedPaths.push({
                    sourceName: "desktop",
                    path: ensureDirectoryCandidatePath(relativePath || path.basename(sourcePath)),
                    reason: "read-error",
                });
                return;
            }
            for (const entry of entries) {
                collectEntry(path.join(sourcePath, entry), path.join(targetPath, entry), relativePath ? path.join(relativePath, entry) : entry);
            }
            return;
        }
        if (isFile) {
            candidates.push({
                sourcePath,
                targetPath,
                relativePath: toPosixPath(relativePath),
                bytes: stat.size,
            });
            return;
        }
        skippedPaths.push({
            sourceName: "desktop",
            path: toPosixPath(relativePath),
            reason: "unsupported",
        });
    };
    collectEntry(sourceDir, stagingDir, "");
    let copiedBytes = 0;
    let copiedCount = 0;
    for (const candidate of candidates.sort((a, b) => a.bytes - b.bytes || a.relativePath.localeCompare(b.relativePath))) {
        if (copiedBytes + candidate.bytes > maxBytes) {
            skippedPaths.push({
                sourceName: "desktop",
                path: candidate.relativePath,
                bytes: candidate.bytes,
                reason: "size-budget",
            });
            continue;
        }
        try {
            fs.mkdirSync(path.dirname(candidate.targetPath), { recursive: true });
            fs.copyFileSync(candidate.sourcePath, candidate.targetPath);
        }
        catch {
            skippedPaths.push({
                sourceName: "desktop",
                path: candidate.relativePath,
                bytes: candidate.bytes,
                reason: "copy-error",
            });
            continue;
        }
        copiedBytes += candidate.bytes;
        copiedCount += 1;
    }
    return { copiedCount, copiedBytes, skippedPaths, ignoredPaths };
}
function snapshotClaudeTranscripts(sourceDir, stagingDir, transcriptIds, ignoreContext) {
    const skippedPaths = [];
    const ignoredPaths = [];
    let copiedCount = 0;
    if (!fs.existsSync(sourceDir) || transcriptIds.length === 0) {
        return { copiedCount, missingIds: [], skippedPaths, ignoredPaths };
    }
    fs.mkdirSync(stagingDir, { recursive: true });
    const missingIds = [];
    for (const transcriptId of transcriptIds) {
        const entry = `${transcriptId}.jsonl`;
        const sourcePath = path.join(sourceDir, entry);
        if (!fs.existsSync(sourcePath)) {
            missingIds.push(transcriptId);
            continue;
        }
        const stat = fs.lstatSync(sourcePath);
        if (!stat.isFile()) {
            if (!stat.isFile()) {
                skippedPaths.push(sourcePath);
            }
            continue;
        }
        const ignoreReason = shouldIgnoreBackupPath(ignoreContext, sourcePath, false);
        if (ignoreReason) {
            ignoredPaths.push({
                sourceName: "transcripts",
                path: entry,
                reason: ignoreReason,
            });
            continue;
        }
        fs.copyFileSync(sourcePath, path.join(stagingDir, entry));
        copiedCount += 1;
    }
    return { copiedCount, missingIds, skippedPaths, ignoredPaths };
}
function snapshotCloudflared(stagingDir, ignoreContext) {
    if (!fs.existsSync(CLOUDFLARED_DIR)) {
        return { copiedCount: 0, ignoredPaths: [] };
    }
    let copiedCount = 0;
    const ignoredPaths = [];
    const filesToCopy = [...CLOUDFLARED_BACKUP_FILES];
    // Also grab tunnel credential JSON files (<uuid>.json)
    try {
        for (const entry of fs.readdirSync(CLOUDFLARED_DIR)) {
            if (entry.endsWith(".json") &&
                /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$/i.test(entry)) {
                filesToCopy.push(entry);
            }
        }
    }
    catch {
        return { copiedCount: 0, ignoredPaths };
    }
    for (const fileName of filesToCopy) {
        const sourcePath = path.join(CLOUDFLARED_DIR, fileName);
        if (!fs.existsSync(sourcePath))
            continue;
        const stat = fs.lstatSync(sourcePath);
        if (!stat.isFile())
            continue;
        const ignoreReason = shouldIgnoreBackupPath(ignoreContext, sourcePath, false);
        if (ignoreReason) {
            ignoredPaths.push({
                sourceName: "cloudflared",
                path: fileName,
                reason: ignoreReason,
            });
            continue;
        }
        fs.mkdirSync(stagingDir, { recursive: true });
        fs.copyFileSync(sourcePath, path.join(stagingDir, fileName));
        copiedCount += 1;
    }
    return { copiedCount, ignoredPaths };
}
async function snapshotGhCli(stagingDir, ignoreContext) {
    if (!fs.existsSync(GH_CLI_DIR)) {
        return { copiedCount: 0, hasToken: false, ignoredPaths: [] };
    }
    let copiedCount = 0;
    const ignoredPaths = [];
    try {
        for (const entry of fs.readdirSync(GH_CLI_DIR)) {
            const sourcePath = path.join(GH_CLI_DIR, entry);
            if (!fs.lstatSync(sourcePath).isFile())
                continue;
            const ignoreReason = shouldIgnoreBackupPath(ignoreContext, sourcePath, false);
            if (ignoreReason) {
                ignoredPaths.push({
                    sourceName: "gh",
                    path: entry,
                    reason: ignoreReason,
                });
                continue;
            }
            fs.mkdirSync(stagingDir, { recursive: true });
            fs.copyFileSync(sourcePath, path.join(stagingDir, entry));
            copiedCount += 1;
        }
    }
    catch {
        return { copiedCount: 0, hasToken: false, ignoredPaths };
    }
    // Export the OAuth token from the system keychain.
    // `gh auth token` prints the active token to stdout.
    let hasToken = false;
    try {
        const result = await execFileAsync("gh", ["auth", "token"], {
            timeout: 10_000,
        });
        const stdout = typeof result === "string" ? result : result.stdout;
        const token = stdout.trim();
        const tokenSourcePath = path.join(GH_CLI_DIR, GH_CLI_TOKEN_FILE);
        const tokenIgnoreReason = shouldIgnoreBackupPath(ignoreContext, tokenSourcePath, false);
        if (token && !tokenIgnoreReason) {
            fs.mkdirSync(stagingDir, { recursive: true });
            fs.writeFileSync(path.join(stagingDir, GH_CLI_TOKEN_FILE), token, {
                mode: 0o600,
            });
            hasToken = true;
        }
        else if (token && tokenIgnoreReason) {
            ignoredPaths.push({
                sourceName: "gh",
                path: GH_CLI_TOKEN_FILE,
                reason: tokenIgnoreReason,
            });
        }
    }
    catch {
        // gh CLI not installed or not authenticated — skip token export
    }
    return { copiedCount, hasToken, ignoredPaths };
}
export async function runBackup(config, ownerEmail, options = {}) {
    const zipName = `visionclaw-backup-${formatTimestamp()}.zip`;
    const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vc-backup-"));
    const zipPath = path.join(tmpDir, zipName);
    const stagingRoot = path.join(tmpDir, "staging");
    try {
        const profileDir = getConfigDir();
        if (!fs.existsSync(profileDir)) {
            throw new Error(`Profile directory not found: ${profileDir}`);
        }
        const profileDirName = path.basename(profileDir);
        const globalIgnore = loadIgnoreFile(path.join(getHomeDir(), GLOBAL_BACKUP_IGNORE_FILE));
        const stagedProfileDir = path.join(stagingRoot, ".visionclaw", "profiles", profileDirName);
        const transcriptDir = getClaudeTranscriptDirForProfileDir(profileDir);
        const transcriptIds = getReferencedClaudeTranscriptIds(getSessionFile());
        const desktopDir = getDesktopDir();
        const profileIgnoreContext = {
            sourceName: "profile",
            sourceRoot: profileDir,
            globalIgnore,
            localIgnore: loadIgnoreFile(path.join(profileDir, LOCAL_BACKUP_IGNORE_FILE)),
        };
        const transcriptIgnoreContext = {
            sourceName: "transcripts",
            sourceRoot: transcriptDir,
            globalIgnore,
            localIgnore: loadIgnoreFile(path.join(transcriptDir, LOCAL_BACKUP_IGNORE_FILE)),
        };
        const desktopIgnoreContext = {
            sourceName: "desktop",
            sourceRoot: desktopDir,
            globalIgnore,
            localIgnore: loadIgnoreFile(path.join(desktopDir, LOCAL_BACKUP_IGNORE_FILE)),
        };
        const cloudflaredIgnoreContext = {
            sourceName: "cloudflared",
            sourceRoot: CLOUDFLARED_DIR,
            globalIgnore,
            localIgnore: loadIgnoreFile(path.join(CLOUDFLARED_DIR, LOCAL_BACKUP_IGNORE_FILE)),
        };
        const ghIgnoreContext = {
            sourceName: "gh",
            sourceRoot: GH_CLI_DIR,
            globalIgnore,
            localIgnore: loadIgnoreFile(path.join(GH_CLI_DIR, LOCAL_BACKUP_IGNORE_FILE)),
        };
        const ignoreContexts = [profileIgnoreContext, transcriptIgnoreContext, desktopIgnoreContext, cloudflaredIgnoreContext, ghIgnoreContext];
        logLoadedIgnoreFiles(globalIgnore, ignoreContexts);
        const stagedTranscriptDir = path.join(stagingRoot, ".claude", "projects", getClaudeProjectSlug(profileDir));
        const snapshot = snapshotProfileTree(profileDir, stagedProfileDir, profileIgnoreContext);
        const transcripts = snapshotClaudeTranscripts(transcriptDir, stagedTranscriptDir, transcriptIds, transcriptIgnoreContext);
        const stagedDesktopDir = path.join(stagingRoot, ".visionclaw", "user-data", "Desktop");
        const desktop = config.backupDesktopEnabled
            ? snapshotDesktop(desktopDir, stagedDesktopDir, desktopIgnoreContext, config.backupDesktopMaxBytes)
            : { copiedCount: 0, copiedBytes: 0, skippedPaths: [], ignoredPaths: [] };
        const stagedCloudflaredDir = path.join(stagingRoot, ".cloudflared");
        const cloudflared = snapshotCloudflared(stagedCloudflaredDir, cloudflaredIgnoreContext);
        const stagedGhCliDir = path.join(stagingRoot, ".config", "gh");
        const ghCli = await snapshotGhCli(stagedGhCliDir, ghIgnoreContext);
        logIgnoredPathsBySource([
            ...snapshot.ignoredPaths,
            ...transcripts.ignoredPaths,
            ...desktop.ignoredPaths,
            ...cloudflared.ignoredPaths,
            ...ghCli.ignoredPaths,
        ]);
        logSkippedPathsBySource(desktop.skippedPaths, config.backupDesktopMaxBytes);
        const sizeBreakdown = [
            {
                label: "profile",
                bytes: getDirectorySize(stagedProfileDir),
            },
            {
                label: "transcripts",
                bytes: getDirectorySize(stagedTranscriptDir),
            },
            {
                label: "desktop",
                bytes: getDirectorySize(stagedDesktopDir),
            },
            {
                label: "cloudflared",
                bytes: getDirectorySize(stagedCloudflaredDir),
            },
            {
                label: "gh",
                bytes: getDirectorySize(stagedGhCliDir),
            },
        ];
        const stagedSizeBytes = sizeBreakdown.reduce((sum, part) => sum + part.bytes, 0);
        logger.system(`Staging backup data in: ${stagingRoot}`);
        logger.system(`Backup staging size: ${formatBytes(stagedSizeBytes)}\n${formatSizeBreakdownLines(sizeBreakdown)}`);
        logger.system(`Creating backup zip: ${zipPath} (.visionclaw/profiles/${profileDirName})`);
        if (snapshot.skippedPaths.length > 0) {
            logger.warn(`Skipping ${snapshot.skippedPaths.length} symlink or unsupported runtime entr${snapshot.skippedPaths.length === 1 ? "y" : "ies"} during backup.`);
        }
        if (transcripts.copiedCount > 0) {
            logger.system(`Including ${transcripts.copiedCount} Claude transcript file${transcripts.copiedCount === 1 ? "" : "s"} in backup.`);
        }
        if (transcripts.missingIds.length > 0) {
            logger.warn(`Missing ${transcripts.missingIds.length} transcript file${transcripts.missingIds.length === 1 ? "" : "s"} referenced by session.json.`);
        }
        if (transcripts.skippedPaths.length > 0) {
            logger.warn(`Skipping ${transcripts.skippedPaths.length} unsupported entr${transcripts.skippedPaths.length === 1 ? "y" : "ies"} in Claude transcript storage.`);
        }
        if (config.backupDesktopEnabled && fs.existsSync(desktopDir)) {
            logger.system(`Including ${desktop.copiedCount} Desktop file${desktop.copiedCount === 1 ? "" : "s"} in backup (${formatBytes(desktop.copiedBytes)}/${formatBytes(config.backupDesktopMaxBytes)} limit).`);
        }
        if (cloudflared.copiedCount > 0) {
            logger.system(`Including ${cloudflared.copiedCount} cloudflared config file${cloudflared.copiedCount === 1 ? "" : "s"} in backup.`);
        }
        if (ghCli.copiedCount > 0) {
            logger.system(`Including ${ghCli.copiedCount} GitHub CLI config file${ghCli.copiedCount === 1 ? "" : "s"}${ghCli.hasToken ? " (with OAuth token)" : ""} in backup.`);
        }
        const zipInputs = [".visionclaw"];
        if (fs.existsSync(path.join(stagingRoot, ".claude"))) {
            zipInputs.push(".claude");
        }
        if (fs.existsSync(path.join(stagingRoot, ".cloudflared"))) {
            zipInputs.push(".cloudflared");
        }
        if (fs.existsSync(path.join(stagingRoot, ".config"))) {
            zipInputs.push(".config");
        }
        // Run zip from the staging directory so the archive keeps the expected
        // ~/.visionclaw/profiles/<profile> layout without touching live sockets.
        const zipStartMs = Date.now();
        await execFileAsync("zip", ["-rq", zipPath, ...zipInputs], {
            cwd: stagingRoot,
            timeout: 10 * 60 * 1000,
            maxBuffer: 10 * 1024 * 1024,
        });
        const zipDurationMs = Date.now() - zipStartMs;
        const zipSizeBytes = fs.statSync(zipPath).size;
        logger.system(`Backup zip created: ${zipPath} (${formatBytes(zipSizeBytes)}, compressed in ${formatDuration(zipDurationMs)}).`);
        const provider = options.provider ?? config.backupProvider;
        const uploadStartMs = Date.now();
        let lastUploadProgressLogMs = 0;
        const handleProgress = (uploadedBytes) => {
            const nowMs = Date.now();
            if (uploadedBytes < zipSizeBytes &&
                nowMs - lastUploadProgressLogMs < UPLOAD_PROGRESS_LOG_INTERVAL_MS) {
                return;
            }
            lastUploadProgressLogMs = nowMs;
            const elapsedMs = Math.max(1, nowMs - uploadStartMs);
            const uploadedRatio = zipSizeBytes > 0 ? uploadedBytes / zipSizeBytes : 1;
            const bytesPerSecond = uploadedBytes / (elapsedMs / 1_000);
            const remainingBytes = Math.max(0, zipSizeBytes - uploadedBytes);
            const etaMs = bytesPerSecond > 0 ? (remainingBytes / bytesPerSecond) * 1_000 : 0;
            logger.system(`Upload progress: ${formatBytes(Math.min(uploadedBytes, zipSizeBytes))}/${formatBytes(zipSizeBytes)} (${Math.min(100, uploadedRatio * 100).toFixed(1)}%), ${formatBytes(bytesPerSecond)}/s, ETA ${formatDuration(etaMs)}.`);
        };
        let result;
        if (provider === "tos") {
            const tosCredentials = resolveTosCredentials(config);
            if (!tosCredentials) {
                throw new Error("TOS credentials not available. They should be provided via the onboarding service heartbeat or local config.");
            }
            const tosKey = buildBackupTosKey(config, zipName);
            logger.system(`Uploading backup to TOS bucket ${config.backupTosBucket} key ${tosKey} (${formatBytes(zipSizeBytes)}; estimated time will update with measured speed)...`);
            result = await uploadBackup({
                provider: "tos",
                localPath: zipPath,
                name: zipName,
                contentType: "application/zip",
                onProgress: (progress) => handleProgress(progress.uploadedBytes),
                tos: {
                    accessKeyId: tosCredentials.accessKeyId,
                    accessKeySecret: tosCredentials.accessKeySecret,
                    bucket: config.backupTosBucket,
                    region: config.backupTosRegion,
                    endpoint: config.backupTosEndpoint,
                    publicEndpoint: config.backupTosPublicEndpoint,
                    signedUrlExpiresSeconds: options.signedUrlExpiresSeconds ?? config.backupTosSignedUrlExpiresSeconds,
                    key: tosKey,
                },
            });
        }
        else {
            logger.system(`Uploading backup to Google Drive (${formatBytes(zipSizeBytes)}; estimated time will update with measured speed)...`);
            result = await uploadBackup({
                provider: "gdrive",
                config,
                ownerEmail,
                localPath: zipPath,
                name: zipName,
                contentType: "application/zip",
                onProgress: (progress) => handleProgress(progress.uploadedBytes),
            });
        }
        const uploadDurationMs = Date.now() - uploadStartMs;
        const averageUploadSpeed = zipSizeBytes / Math.max(1, uploadDurationMs / 1_000);
        logger.system(`Backup uploaded to ${provider === "tos" ? "TOS" : "Google Drive"} in ${formatDuration(uploadDurationMs)} at ${formatBytes(averageUploadSpeed)}/s: ${result.location}`);
        if (result.signedUrl && result.expiresAt) {
            logger.system(`Signed download URL expires at ${result.expiresAt}: ${result.signedUrl}`);
        }
        return result;
    }
    finally {
        fs.rmSync(tmpDir, { recursive: true, force: true });
    }
}
//# sourceMappingURL=backup.js.map