#!/usr/bin/env node

import apps from './apps.js';
import appstore from './appstore.js';
import assert from 'node:assert';
import AuditSource from './auditsource.js';
import backupSites from './backupsites.js';
import backuptask from './backuptask.js';
import BoxError from './boxerror.js';
import community from './community.js';
import constants from './constants.js';
import logger from './logger.js';
import df from './df.js';
import dns from './dns.js';
import docker from './docker.js';
import ejs from 'ejs';
import fs from 'node:fs';
import { promises as fsPromises } from 'node:fs';
import iputils from './iputils.js';
import manifestFormat from '@cloudron/manifest-format';
import os from 'node:os';
import path from 'node:path';
import paths from './paths.js';
import promiseRetry from './promise-retry.js';
import reverseProxy from './reverseproxy.js';
import safe from 'safetydance';
import services from './services.js';
import shellModule from './shell.js';
import _ from './underscore.js';

const { log } = logger('apptask');
const shell = shellModule('apptask');

const LOGROTATE_CONFIG_EJS = fs.readFileSync(import.meta.dirname + '/logrotate.ejs', { encoding: 'utf8' }),
    CONFIGURE_LOGROTATE_CMD = path.join(import.meta.dirname, 'scripts/configurelogrotate.sh');

function makeTaskError(error, app) {
    assert(error instanceof BoxError);
    assert.strictEqual(typeof app, 'object');

    return {
        message: error.message,
        reason: error.reason,
        taskId: app.taskId,
        installationState: app.installationState
    };
}

// updates the app object and the database
async function updateApp(app, values) {
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof values, 'object');

    await apps.update(app.id, values);

    for (const value in values) {
        app[value] = values[value];
    }
}

async function allocateContainerIp(app) {
    assert.strictEqual(typeof app, 'object');

    if (app.manifest.id === constants.PROXY_APP_APPSTORE_ID) return;

    await promiseRetry({ times: 10, interval: 0, log }, async function () {
        const iprange = iputils.intFromIp(constants.APPS_IPv4_END) - iputils.intFromIp(constants.APPS_IPv4_START);
        const rnd = Math.floor(Math.random() * iprange);
        const containerIp = iputils.ipFromInt(iputils.intFromIp(constants.APPS_IPv4_START) + rnd);
        await updateApp(app, { containerIp });
    });
}

async function createAppDir(app) {
    assert.strictEqual(typeof app, 'object');

    // we have to create app dir regardless of localstorage addon. this dir is used as a temp space for backup dumps
    const appDir = path.join(paths.APPS_DATA_DIR, app.id);
    const [error] = await safe(fs.promises.mkdir(appDir, { recursive: true }));
    if (error) throw new BoxError(BoxError.FS_ERROR, `Error creating directory: ${error.message}`);
}

async function deleteAppDir(app, options) {
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof options, 'object');

    const appDataDir = path.join(paths.APPS_DATA_DIR, app.id);

    // resolve any symlinked data dir
    const stat = safe.fs.lstatSync(appDataDir);
    if (!stat) return;

    const resolvedAppDataDir = stat.isSymbolicLink() ? safe.fs.readlinkSync(appDataDir) : appDataDir;

    log(`deleteAppDir - removing files in ${resolvedAppDataDir}`);

    if (safe.fs.existsSync(resolvedAppDataDir)) {
        const entries = safe.fs.readdirSync(resolvedAppDataDir);
        if (!entries) throw new BoxError(BoxError.FS_ERROR, `Error listing ${resolvedAppDataDir}: ${safe.error.message}`);

        // remove only files. any directories inside app dir are currently volumes managed by the addons
            // besides, we cannot delete those dirs anyway because of perms
        for (const entry of entries) {
            const fullPath = path.join(resolvedAppDataDir, entry);
            const entryStat = safe.fs.statSync(fullPath);
            if (entryStat && !entryStat.isDirectory()) {
                safe.fs.unlinkSync(fullPath);
                log(`deleteAppDir - ${fullPath} ${safe.error?.message || ''}`);
            }
        }
    }

    // if this fails, it's probably because the localstorage/redis addons have not cleaned up properly
    if (options.removeDirectory) {
        if (stat.isSymbolicLink()) {
            if (!safe.fs.unlinkSync(appDataDir)) {
                if (safe.error.code !== 'ENOENT') throw new BoxError(BoxError.FS_ERROR, `Error unlinking dir ${appDataDir} : ${safe.error.message}`);
            }
        } else {
            if (!safe.fs.rmSync(appDataDir, { recursive: true })) {
                if (safe.error.code !== 'ENOENT') throw new BoxError(BoxError.FS_ERROR, `Error removing dir ${appDataDir} : ${safe.error.message}`);
            }
        }
    }
}

async function removeLogrotateConfig(app) {
    assert.strictEqual(typeof app, 'object');

    const [error] = await safe(shell.sudo([ CONFIGURE_LOGROTATE_CMD, 'remove', app.id ], {}));
    if (error) throw new BoxError(BoxError.LOGROTATE_ERROR, `Error removing logrotate config: ${error.message}`);
}

async function deleteContainers(app, options) {
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof options, 'object');

    log('deleteContainer: deleting app containers (app, scheduler)');

    // remove configs that rely on container id
    await removeLogrotateConfig(app);
    await docker.stopContainers(app.id);
    await docker.deleteContainers(app.id, options);
    await updateApp(app, { containerId: null });
}

async function cleanupLogs(app) {
    assert.strictEqual(typeof app, 'object');

    // note that redis container logs are cleaned up by the addon
    const [error] = await safe(fs.promises.rm(path.join(paths.LOG_DIR, app.id), { force: true, recursive: true }));
    if (error) log('cleanupLogs: cannot cleanup logs: %o', error);
}

async function verifyManifest(manifest) {
    assert.strictEqual(typeof manifest, 'object');

    let error = manifestFormat.parse(manifest);
    if (error) throw new BoxError(BoxError.BAD_FIELD, `Manifest error: ${error.message}`);

    error = await apps.checkManifest(manifest);
    if (error) throw new BoxError(BoxError.CONFLICT, `Manifest constraint check failed: ${error.message}`);
}

async function downloadIcon(app) {
    assert.strictEqual(typeof app, 'object');

    let packageIcon = null;
    if (app.versionsUrl && app.manifest.iconUrl) {
        log(`downloadIcon: Downloading community icon ${app.manifest.iconUrl}`);
        packageIcon = await community.downloadIcon(app.manifest);
    } else if (app.appStoreId) {
        log(`downloadIcon: Downloading icon of ${app.appStoreId}@${app.manifest.version}`);
        packageIcon = await appstore.downloadIcon(app.appStoreId, app.manifest.version);
    }

    await updateApp(app, { packageIcon });
}

async function downloadImage(manifest) {
    assert.strictEqual(typeof manifest, 'object');

    // skip for relay app
    if (manifest.id === constants.PROXY_APP_APPSTORE_ID) return;

    // only download non-local images, local ones are built locally
    if (manifest.dockerImage.indexOf('local/') === 0) return;

    const info = await docker.info();
    const [dfError, diskUsage] = await safe(df.file(info.DockerRootDir));
    if (dfError) throw new BoxError(BoxError.FS_ERROR, `Error getting file system info: ${dfError.message}`);

    if (diskUsage.available < (1024*1024*1024)) throw new BoxError(BoxError.DOCKER_ERROR, `Not enough disk space to pull docker image. available: ${diskUsage.available}`);

    await docker.downloadImage(manifest);
}

async function buildLocalImage(app) {
    assert.strictEqual(typeof app, 'object');

    // TODO some precondition checks like downloadImage maybe

    const sourceFilePath = path.join(paths.APPS_DATA_DIR, app.id, 'source.tar.gz');

    // if we have a newly uploaded source archive, use that
    const uploadedSourceArchiveFilePath = `${paths.SOURCE_ARCHIVES_DIR}/${app.id}.tar.gz`;
    if (fs.existsSync(uploadedSourceArchiveFilePath)) {
        const [renameError] = await safe(fsPromises.rename(uploadedSourceArchiveFilePath, sourceFilePath));
        if (renameError) {
            if (renameError.code === 'EXDEV') {
                // Cross-device rename not permitted, so copy and remove
                let [error] = await safe(fsPromises.copyFile(uploadedSourceArchiveFilePath, sourceFilePath));
                if (error) throw new BoxError(BoxError.FS_ERROR, error);

                [error] = await safe(fsPromises.unlink(uploadedSourceArchiveFilePath));
                if (error) throw new BoxError(BoxError.FS_ERROR, error);
            } else {
                throw new BoxError(BoxError.FS_ERROR, renameError);
            }
        }
    }

    await docker.buildImage(app.manifest.dockerImage, sourceFilePath);
}

async function updateChecklist(app, newChecks, acknowledged = false) {
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof newChecks, 'object');
    assert.strictEqual(typeof acknowledged, 'boolean');

    // add new checklist items depending on sso state
    const checklist = app.checklist || {};
    for (const k in newChecks) {
        if (app.checklist[k]) continue;

        const item = {
            acknowledged: acknowledged,
            sso: newChecks[k].sso,
            appVersion: app.version,
            message: newChecks[k].message,
            changedAt: 0,
            changedBy: null, // username from audit log
        };

        if (typeof item.sso === 'undefined') checklist[k] = item;
        else if (item.sso && app.sso) checklist[k] = item;
        else if (!item.sso && !app.sso) checklist[k] = item;
    }

    await updateApp(app, { checklist });
}

async function startApp(app) {
    log('startApp: starting container');

    if (app.runState === apps.RSTATE_STOPPED) return;

    // skip for relay app
    if (app.manifest.id === constants.PROXY_APP_APPSTORE_ID) return;

    await docker.startContainer(app.id);
}

async function startCommand(app, args, progressCallback) {
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof args, 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    await progressCallback({ percent: 10, message: 'Starting app services' });
    await services.startAppServices(app);

    if (app.manifest.id !== constants.PROXY_APP_APPSTORE_ID) {
        await progressCallback({ percent: 40, message: 'Starting container' });
        await docker.startContainer(app.id);
    }

    // stopped apps do not renew certs. currently, we don't do DNS to not overwrite existing user settings
    await progressCallback({ percent: 70, message: 'Configuring reverse proxy' });
    await reverseProxy.configureApp(app, AuditSource.APPTASK);

    await progressCallback({ percent: 100, message: 'Done' });
    await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
}

async function stopCommand(app, args, progressCallback) {
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof args, 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    // we don't delete the containers. app containers are created with the unless-stopped restart policy. there is no danger of apps getting restarted on reboot
    await progressCallback({ percent: 20, message: 'Stopping container' });
    await reverseProxy.unconfigureApp(app); // removing nginx configs also means that we can auto-cleanup old certs since they are not referenced
    await docker.stopContainers(app.id);

    await progressCallback({ percent: 50, message: 'Stopping app services' });
    await services.stopAppServices(app);

    await progressCallback({ percent: 100, message: 'Done' });
    await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
}

async function restartCommand(app, args, progressCallback) {
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof args, 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    if (app.manifest.id !== constants.PROXY_APP_APPSTORE_ID) {
        await progressCallback({ percent: 10, message: 'Starting app services' });
        await services.startAppServices(app);

        await progressCallback({ percent: 30, message: 'Restarting container' });
        await docker.restartContainer(app.id);
    }

    // stopped apps do not renew certs. currently, we don't do DNS to not overwrite existing user settings
    await progressCallback({ percent: 70, message: 'Configuring reverse proxy' });
    await reverseProxy.configureApp(app, AuditSource.APPTASK);

    await progressCallback({ percent: 100, message: 'Done' });
    await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
}

// this command can also be called when the app is stopped. do not touch services
async function uninstallCommand(app, args, progressCallback) {
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof args, 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    await progressCallback({ percent: 20, message: 'Deleting container' });
    await reverseProxy.unconfigureApp(app);
    await deleteContainers(app, {});

    await progressCallback({ percent: 30, message: 'Teardown addons' });
    // if install/clone/restore/import failed early (e.g. invalid image), services may not have started
    await services.teardownAddons(app, app.manifest.addons, { ignoreError: true });
    await services.teardownPersistentDirs(app);

    await progressCallback({ percent: 40, message: 'Cleanup file manager' });

    await progressCallback({ percent: 50, message: 'Deleting app data directory' });
    await deleteAppDir(app, { removeDirectory: true });

    await progressCallback({ percent: 60, message: 'Deleting image' });
    await docker.deleteImage(app.manifest.dockerImage);
    await safe(fsPromises.unlink(`${paths.SOURCE_ARCHIVES_DIR}/${app.id}.tar.gz`));

    await progressCallback({ percent: 70, message: 'Unregistering domains' });
    await dns.unregisterLocations([ { subdomain: app.subdomain, domain: app.domain } ].concat(app.secondaryDomains).concat(app.redirectDomains).concat(app.aliasDomains), progressCallback);

    await progressCallback({ percent: 90, message: 'Cleanup logs' });
    await cleanupLogs(app);

    await progressCallback({ percent: 95, message: 'Remove app from database' });
    await apps.del(app.id);
}

async function addLogrotateConfig(app) {
    assert.strictEqual(typeof app, 'object');

    const result = await docker.inspect(app.containerId);

    const runVolume = result.Mounts.find(function (mount) { return mount.Destination === '/run'; });
    if (!runVolume) throw new BoxError(BoxError.DOCKER_ERROR, 'App does not have /run mounted');

    // logrotate configs can have arbitrary commands, so the config files must be owned by root
    const logrotateConf = ejs.render(LOGROTATE_CONFIG_EJS, { volumePath: runVolume.Source, appId: app.id });
    const tmpFilePath = path.join(os.tmpdir(), app.id + '.logrotate');

    safe.fs.writeFileSync(tmpFilePath, logrotateConf);
    if (safe.error) throw new BoxError(BoxError.FS_ERROR, `Error writing logrotate config: ${safe.error.message}`);

    const [error] = await safe(shell.sudo([ CONFIGURE_LOGROTATE_CMD, 'add', app.id, tmpFilePath ], {}));
    if (error) throw new BoxError(BoxError.LOGROTATE_ERROR, `Error adding logrotate config: ${error.message}`);
}

async function createContainer(app) {
    assert.strictEqual(typeof app, 'object');
    assert(!app.containerId); // otherwise, it will trigger volumeFrom

    if (app.manifest.id === constants.PROXY_APP_APPSTORE_ID) return;

    log('createContainer: creating container');

    const container = await docker.createContainer(app);

    await updateApp(app, { containerId: container.id });

    // re-generate configs that rely on container id
    await addLogrotateConfig(app);
}

async function installCommand(app, args, progressCallback) {
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof args, 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    // restoreConfig is one of null (install) OR { backupId } (restore) OR  { remotePath, backupSite } (import) OR  { inPlace } (import)
    const { restoreConfig, overwriteDns, skipDnsSetup, oldManifest } = args;

    // this protects against the theoretical possibility of an app being marked for install/restore from
    // a previous version of box code
    await verifyManifest(app.manifest);

    // teardown for re-installs
    await progressCallback({ percent: 10, message: 'Deleting old containers' });
    await reverseProxy.unconfigureApp(app);
    await deleteContainers(app, { managedOnly: true });

    // when restoring, app does not require these addons anymore. remove carefully to preserve the db passwords
    if (oldManifest) {
        const addonsToRemove = _.omit(oldManifest.addons, Object.keys(app.manifest.addons));
        await services.teardownAddons(app, addonsToRemove, {});
    }

    if (!restoreConfig || restoreConfig.remotePath || restoreConfig.backupId) { // install/import/restore but not in-place import should delete data dir
        await deleteAppDir(app, { removeDirectory: false }); // do not remove any symlinked appdata dir
    }

    if (oldManifest && oldManifest.dockerImage !== app.manifest.dockerImage) {
        await docker.deleteImage(oldManifest.dockerImage);
    }

    // allocating container ip here, lets the users "repair" an app if allocation fails at apps.add time
    await allocateContainerIp(app);

    await progressCallback({ percent: 15, message: 'Downloading icon' });
    await downloadIcon(app);

    await progressCallback({ percent: 18, message: 'Updating checklist' });
    await updateChecklist(app, app.manifest.checklist || {}, restoreConfig ? true : false);

    if (!skipDnsSetup) {
        await progressCallback({ percent: 20, message: 'Registering subdomains' });

        await dns.registerLocations([ { subdomain: app.subdomain, domain: app.domain }].concat(app.secondaryDomains).concat(app.redirectDomains).concat(app.aliasDomains), { overwriteDns }, progressCallback);
    }

    await progressCallback({ percent: 30, message: 'Downloading image' });
    await downloadImage(app.manifest);

    await progressCallback({ percent: 55, message: 'Setting up app' });
    await createAppDir(app);

    if (!restoreConfig) { // install
        await progressCallback({ percent: 60, message: 'Setting up addons' });
        await services.setupAddons(app, app.manifest.addons);
        await services.setupPersistentDirs(app);
    } else if (app.installationState === apps.ISTATE_PENDING_IMPORT && restoreConfig.inPlace) { // in-place import
        await progressCallback({ percent: 60, message: 'Importing addons in-place' });
        await services.setupAddons(app, app.manifest.addons);
        await services.setupPersistentDirs(app);
        await services.clearAddons(app, _.omit(app.manifest.addons, ['localstorage']));
        await apps.loadConfig(app);
        await services.restoreAddons(app, app.manifest.addons);
        await services.runRestoreCommand(app);
    } else if ((app.installationState === apps.ISTATE_PENDING_IMPORT || app.installationState === apps.ISTATE_PENDING_RESTORE) && restoreConfig.remotePath) { // app import or app restore during full box restore
        await progressCallback({ percent: 65, message: 'Downloading backup and restoring addons' });
        await services.setupAddons(app, app.manifest.addons);
        await services.setupPersistentDirs(app);
        await services.clearAddons(app, app.manifest.addons);
        const backupSite = restoreConfig.backupSite;
        await backupSites.storageApi(backupSite).setup(backupSite.config);
        await backuptask.downloadApp(app, restoreConfig, (progress) => { progressCallback({ percent: 75, message: progress.message }); });
        await apps.loadConfig(app);
        await backupSites.storageApi(backupSite).teardown(backupSite.config);
        await progressCallback({ percent: 75, message: 'Restoring addons' });
        await services.restoreAddons(app, app.manifest.addons);
        await services.runRestoreCommand(app);
    } else { // clone and restore
        await progressCallback({ percent: 65, message: 'Downloading backup and restoring addons' });
        await services.setupAddons(app, app.manifest.addons);
        await services.setupPersistentDirs(app);
        await services.clearAddons(app, app.manifest.addons);
        await backuptask.downloadApp(app, restoreConfig, (progress) => { progressCallback({ percent: 65, message: progress.message }); });
        if (app.installationState === apps.ISTATE_PENDING_CLONE) {
            const customIcon = safe.fs.readFileSync(path.join(paths.APPS_DATA_DIR, app.id + '/icon.png'));
            if (customIcon) await updateApp(app, { icon: customIcon });
        }
        await progressCallback({ percent: 70, message: 'Restoring addons' });
        await services.restoreAddons(app, app.manifest.addons);
        await services.runRestoreCommand(app);
    }

    // now we have the local package tarball, so lets build
    if (app.manifest.dockerImage.indexOf('local/') === 0) {
        await progressCallback({ percent: 75, message: 'Building image' });
        await buildLocalImage(app);
    }

    await progressCallback({ percent: 80, message: 'Creating container' });
    await createContainer(app);

    await startApp(app);

    if (!skipDnsSetup) {
        await progressCallback({ percent: 85, message: 'Waiting for DNS propagation' });
        await dns.waitForLocations([ { subdomain: app.subdomain, domain: app.domain }].concat(app.secondaryDomains).concat(app.redirectDomains).concat(app.aliasDomains), progressCallback);
    }

    await progressCallback({ percent: 95, message: 'Configuring reverse proxy' });
    await reverseProxy.configureApp(app, AuditSource.APPTASK);

    await progressCallback({ percent: 100, message: 'Done' });
    await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
}

// configure is called for an infra update and repair to re-create container, reverseproxy config. it's all "local"
async function recreateCommand(app, args, progressCallback) {
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof args, 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    await progressCallback({ percent: 10, message: 'Deleting old container' });
    await deleteContainers(app, { managedOnly: true });

    await progressCallback({ percent: 50, message: 'Creating container' });
    await createContainer(app);

    await startApp(app);

    await progressCallback({ percent: 100, message: 'Done' });
    await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
}

async function changeLocationCommand(app, args, progressCallback) {
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof args, 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    const oldConfig = args.oldConfig;
    const locationChanged = oldConfig.fqdn !== app.fqdn;
    const skipDnsSetup = args.skipDnsSetup;
    const overwriteDns = args.overwriteDns;

    await progressCallback({ percent: 10, message: 'Unregistering old domains' });
    await reverseProxy.unconfigureApp(app);
    await deleteContainers(app, { managedOnly: true });

    // unregister old domains
    let obsoleteDomains = [];

    if (oldConfig.secondaryDomains) {
        obsoleteDomains = obsoleteDomains.concat(oldConfig.secondaryDomains.filter(function (o) {
            return !app.secondaryDomains.some(function (n) { return n.subdomain === o.subdomain && n.domain === o.domain; });
        }));
    }

    if (oldConfig.redirectDomains) {
        obsoleteDomains = obsoleteDomains.concat(oldConfig.redirectDomains.filter(function (o) {
            return !app.redirectDomains.some(function (n) { return n.subdomain === o.subdomain && n.domain === o.domain; });
        }));
    }

    if (oldConfig.aliasDomains) {
        obsoleteDomains = obsoleteDomains.concat(oldConfig.aliasDomains.filter(function (o) {
            return !app.aliasDomains.some(function (n) { return n.subdomain === o.subdomain && n.domain === o.domain; });
        }));
    }

    if (locationChanged) obsoleteDomains.push({ subdomain: oldConfig.subdomain, domain: oldConfig.domain });

    if (obsoleteDomains.length !== 0) await dns.unregisterLocations(obsoleteDomains, progressCallback);

    // setup dns
    if (!skipDnsSetup) {
        await progressCallback({ percent: 30, message: 'Registering subdomains' });
        await dns.registerLocations([ { subdomain: app.subdomain, domain: app.domain }].concat(app.secondaryDomains).concat(app.redirectDomains).concat(app.aliasDomains), { overwriteDns }, progressCallback);
    }

    // re-setup addons since they rely on the app's fqdn (e.g oauth)
    await progressCallback({ percent: 50, message: 'Setting up addons' });
    await services.setupAddons(app, app.manifest.addons);

    await progressCallback({ percent: 60, message: 'Creating container' });
    await createContainer(app);

    await startApp(app);

    if (!skipDnsSetup) {
        await progressCallback({ percent: 80, message: 'Waiting for DNS propagation' });
        await dns.waitForLocations([ { subdomain: app.subdomain, domain: app.domain }].concat(app.secondaryDomains).concat(app.redirectDomains).concat(app.aliasDomains), progressCallback);
    }

    await progressCallback({ percent: 90, message: 'Configuring reverse proxy' });
    await reverseProxy.configureApp(app, AuditSource.APPTASK);

    await progressCallback({ percent: 100, message: 'Done' });
    await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
}

async function changeServicesCommand(app, args, progressCallback) {
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof args, 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    await progressCallback({ percent: 10, message: 'Deleting old containers' });
    await deleteContainers(app, { managedOnly: true });

    const unusedAddons = {};
    if (app.manifest.addons?.turn && !app.enableTurn) unusedAddons.turn = app.manifest.addons.turn;
    if (app.manifest.addons?.sendmail && !app.enableMailbox) unusedAddons.sendmail = app.manifest.addons.sendmail;
    if (app.manifest.addons?.recvmail && !app.enableInbox) unusedAddons.recvmail = app.manifest.addons.recvmail;
    if (app.manifest.addons?.redis && !app.enableRedis) unusedAddons.redis = app.manifest.addons.redis;

    await progressCallback({ percent: 20, message: 'Removing unused addons' });
    await services.teardownAddons(app, unusedAddons, {});

    await progressCallback({ percent: 40, message: 'Setting up addons' });
    await services.setupAddons(app, app.manifest.addons);

    await progressCallback({ percent: 60, message: 'Creating container' });
    await createContainer(app);

    await startApp(app);

    await progressCallback({ percent: 100, message: 'Done' });
    await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
}

async function migrateDataDirCommand(app, args, progressCallback) {
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof args, 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    const { newStorageVolumeId, newStorageVolumePrefix } = args;
    assert(newStorageVolumeId === null || typeof newStorageVolumeId === 'string');
    assert(newStorageVolumePrefix === null || typeof newStorageVolumePrefix === 'string');

    await progressCallback({ percent: 10, message: 'Deleting old containers' });
    await deleteContainers(app, { managedOnly: true });

    if (app.manifest.addons?.localstorage) {
        await progressCallback({ percent: 40, message: 'Moving data dir' });
        await services.moveDataDir(app, newStorageVolumeId, newStorageVolumePrefix);
        await updateApp(app, { storageVolumeId: newStorageVolumeId, storageVolumePrefix: newStorageVolumePrefix });
    }

    await progressCallback({ percent: 90, message: 'Creating container' });
    await createContainer(app);

    await startApp(app);

    await progressCallback({ percent: 100, message: 'Done' });
    await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
}

async function configureCommand(app, args, progressCallback) {
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof args, 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    await progressCallback({ percent: 10, message: 'Deleting old containers' });
    await reverseProxy.unconfigureApp(app);
    await deleteContainers(app, { managedOnly: true });

    await progressCallback({ percent: 20, message: 'Downloading icon' });
    await downloadIcon(app);

    await progressCallback({ percent: 40, message: 'Downloading image' });
    await downloadImage(app.manifest);

    await progressCallback({ percent: 45, message: 'Ensuring app data directory' });
    await createAppDir(app);

    // re-setup addons since they rely on the app's fqdn (e.g oauth)
    await progressCallback({ percent: 50, message: 'Setting up addons' });
    await services.setupAddons(app, app.manifest.addons);

    await progressCallback({ percent: 60, message: 'Creating container' });
    await createContainer(app);

    await startApp(app);

    await progressCallback({ percent: 90, message: 'Configuring reverse proxy' });
    await reverseProxy.configureApp(app, AuditSource.APPTASK);

    await progressCallback({ percent: 100, message: 'Done' });
    await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
}

async function updateCommand(app, args, progressCallback) {
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof args, 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    const updateConfig = args.updateConfig;

    // app does not want these addons anymore
    // FIXME: this does not handle option changes (like multipleDatabases)
    const unusedAddons = _.omit(app.manifest.addons, Object.keys(updateConfig.manifest.addons));
    const httpPortChanged = app.manifest.httpPort !== updateConfig.manifest.httpPort;
    const proxyAuthChanged = !_.isEqual(app.manifest.addons?.proxyAuth, updateConfig.manifest.addons?.proxyAuth);

    // this protects against the theoretical possibility of an app being marked for update from
    // a previous version of box code
    await progressCallback({ percent: 5, message: 'Verify manifest' });
    await verifyManifest(updateConfig.manifest);

    if (!updateConfig.skipBackup) {
        await progressCallback({ percent: 10, message: 'Backing up app' });
        const sites = await backupSites.listByContentForUpdates(app.id);
        if (sites.length === 0) throw new BoxError(BoxError.BAD_STATE, 'App has no backup site for updates', { backupError: true });

        for (const site of sites) {
            // preserve update backups for 3 weeks
            const [error] = await safe(backuptask.backupApp(app, site, { preserveSecs: 3*7*24*60*60 }, (progress) => {
                progressCallback({ percent: Math.round(10 + (progress.percent || 0) * 10 / 100), message: `Backup - ${progress.message}` });
            }));
            if (error) {
                error.backupError = true;
                throw error;
            }
        }
    }

    await progressCallback({ percent: 20, message: 'Updating checklist' });
    await updateChecklist(app, app.manifest.checklist || {}, true /* new state acked */);

    // download new image before app is stopped. this is so we can reduce downtime
    // and also not remove the 'common' layers when the old image is deleted
    await progressCallback({ percent: 25, message: 'Downloading image' });
    await downloadImage(updateConfig.manifest);

    // note: we cleanup first and then backup. this is done so that the app is not running should backup fail
    // we cannot easily 'recover' from backup failures because we have to revert manfest and portBindings
    await progressCallback({ percent: 35, message: 'Deleting old containers' });
    await deleteContainers(app, { managedOnly: true });
    if (app.manifest.dockerImage !== updateConfig.manifest.dockerImage) await docker.deleteImage(app.manifest.dockerImage);

    // only delete unused addons after backup
    await services.teardownAddons(app, unusedAddons, {});
    if (Object.keys(unusedAddons).includes('localstorage')) await updateApp(app, { storageVolumeId: null, storageVolumePrefix: null }); // lose reference

    // teardown persistent dirs removed in the new manifest
    const newPersistentDirs = updateConfig.manifest.persistentDirs || [];
    const removedPersistentDirs = (app.manifest.persistentDirs || []).filter(d => !newPersistentDirs.includes(d));
    if (removedPersistentDirs.length) {
        await services.teardownPersistentDirs({ ...app, manifest: { persistentDirs: removedPersistentDirs } });
    }

    // free unused ports. this is done after backup, so the app object is not in some inconsistent state should backup fail
    const newTcpPorts = updateConfig.manifest.tcpPorts || {};
    const newUdpPorts = updateConfig.manifest.udpPorts || {};
    const portBindings = { ...app.portBindings };

    for (const portName of Object.keys(portBindings)) {
        if (newTcpPorts[portName] || newUdpPorts[portName]) continue; // port still in use
        delete portBindings[portName];
    }

    // clear aliasDomains if needed based multiDomain change
    const aliasDomains = app.manifest.multiDomain && !updateConfig.manifest.multiDomain ? [] : app.aliasDomains;

    // clear unused secondaryDomains
    const secondaryDomains = [ ...app.secondaryDomains ];
    const newHttpPorts = updateConfig.manifest.httpPorts || {};
    for (let i = secondaryDomains.length-1; i >= 0; i--) {
        const { environmentVariable } = secondaryDomains[i];
        if (environmentVariable in newHttpPorts) continue; // domain still in use
        secondaryDomains.splice(i, 1); // remove domain
    }

    const values = {
        manifest: updateConfig.manifest,
        portBindings,
        // all domains have to be updated together
        subdomain: app.subdomain,
        domain: app.domain,
        aliasDomains,
        secondaryDomains,
        redirectDomains: app.redirectDomains
    };
    if ('memoryLimit' in updateConfig) values.memoryLimit = updateConfig.memoryLimit;
    if ('appStoreId' in updateConfig) values.appStoreId = updateConfig.appStoreId;

    await updateApp(app, values); // switch over to the new config

    await progressCallback({ percent: 45, message: 'Downloading icon' });
    await downloadIcon(app);

    await progressCallback({ percent: 60, message: 'Updating addons' });
    await services.setupAddons(app, updateConfig.manifest.addons);
    await services.setupPersistentDirs(app);

    // now we have the local package tarball, so lets build
    if (app.manifest.dockerImage.indexOf('local/') === 0) {
        await progressCallback({ percent: 65, message: 'Building image' });
        await buildLocalImage(app);
    }

    await progressCallback({ percent: 70, message: 'Creating container' });
    await createContainer(app);

    await startApp(app);

    await progressCallback({ percent: 90, message: 'Configuring reverse proxy' });
    if (proxyAuthChanged || httpPortChanged) {
        await reverseProxy.configureApp(app, AuditSource.APPTASK);
    }

    await progressCallback({ percent: 100, message: 'Done' });
    await updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null, updateInfo: null, updateTime: new Date() });
}

async function run(appId, args, progressCallback) {
    assert.strictEqual(typeof appId, 'string');
    assert.strictEqual(typeof args, 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    const app = await apps.get(appId);

    log(`run: startTask installationState: ${app.installationState} runState: ${app.runState}`);

    let cmd;

    switch (app.installationState) {
    case apps.ISTATE_PENDING_INSTALL:
    case apps.ISTATE_PENDING_CLONE:
    case apps.ISTATE_PENDING_RESTORE:
    case apps.ISTATE_PENDING_IMPORT:
        cmd = installCommand(app, args, progressCallback);
        break;
    case apps.ISTATE_PENDING_CONFIGURE:
        cmd = configureCommand(app, args, progressCallback);
        break;
    case apps.ISTATE_PENDING_RECREATE_CONTAINER: // mounts/devices/env
    case apps.ISTATE_PENDING_RESIZE: // memory/cpu
    case apps.ISTATE_PENDING_DEBUG:
        cmd = recreateCommand(app, args, progressCallback);
        break;
    case apps.ISTATE_PENDING_LOCATION_CHANGE:
        cmd = changeLocationCommand(app, args, progressCallback);
        break;
    case apps.ISTATE_PENDING_SERVICES_CHANGE:
        cmd = changeServicesCommand(app, args, progressCallback);
        break;
    case apps.ISTATE_PENDING_DATA_DIR_MIGRATION:
        cmd = migrateDataDirCommand(app, args, progressCallback);
        break;
    case apps.ISTATE_PENDING_UNINSTALL:
        cmd = uninstallCommand(app, args, progressCallback);
        break;
    case apps.ISTATE_PENDING_UPDATE:
        cmd = updateCommand(app, args, progressCallback);
        break;
    case apps.ISTATE_PENDING_START:
        cmd = startCommand(app, args, progressCallback);
        break;
    case apps.ISTATE_PENDING_STOP:
        cmd = stopCommand(app, args, progressCallback);
        break;
    case apps.ISTATE_PENDING_RESTART:
        cmd = restartCommand(app, args, progressCallback);
        break;
    case apps.ISTATE_INSTALLED: // can only happen when we have a bug in our code while testing/development
        cmd = updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null });
        break;
    default:
        log('run: apptask launched with invalid command');
        throw new BoxError(BoxError.INTERNAL_ERROR, 'Unknown install command in apptask:' + app.installationState);
    }

    const [error, result] = await safe(cmd); // only some commands like backup return a result
    if (error) {
        log(`run: app error for state ${app.installationState}: %o`, error);

        if (app.installationState === apps.ISTATE_PENDING_UPDATE && error.backupError) {
            log('run: update aborted because backup failed');
            await safe(updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null }, { log }));
        } else {
            await safe(updateApp(app, { installationState: apps.ISTATE_ERROR, error: makeTaskError(error, app) }), { debug: log });
        }

        throw error;
    }

    return result || null;
}

const _createAppDir = createAppDir;

const _deleteAppDir = deleteAppDir;

export default {
    run,
    _createAppDir,
    _deleteAppDir,
    _verifyManifest: verifyManifest,
};
