import apps from './apps.js';
import appstore from './appstore.js';
import assert from 'node:assert';
import AuditSource from './auditsource.js';
import BoxError from './boxerror.js';
import backupSites from './backupsites.js';
import backuptask from './backuptask.js';
import community from './community.js';
import constants from './constants.js';
import cron from './cron.js';
import { CronTime } from 'cron';
import crypto from 'node:crypto';
import logger from './logger.js';
import df from './df.js';
import eventlog from './eventlog.js';
import fs from 'node:fs';
import locks from './locks.js';
import notifications from './notifications.js';
import os from 'node:os';
import path from 'node:path';
import paths from './paths.js';
import promiseRetry from './promise-retry.js';
import safe from 'safetydance';
import semver from 'semver';
import settings from './settings.js';
import shellModule from './shell.js';
import tasks from './tasks.js';

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

const AUTOUPDATE_POLICY_NEVER = 'never';
const AUTOUPDATE_POLICY_APPS_ONLY = 'apps_only';
const AUTOUPDATE_POLICY_PLATFORM_AND_APPS = 'platform_and_apps';

const RELEASES_PUBLIC_KEY = path.join(import.meta.dirname, 'releases.gpg');
const UPDATE_CMD = path.join(import.meta.dirname, 'scripts/update.sh');

function validatePolicy(policy) {
    if (policy === AUTOUPDATE_POLICY_NEVER || policy === AUTOUPDATE_POLICY_APPS_ONLY || policy === AUTOUPDATE_POLICY_PLATFORM_AND_APPS) return;

    return new BoxError(BoxError.BAD_FIELD, 'Invalid policy');
}

async function setAutoupdateConfig(config) {
    assert.strictEqual(typeof config, 'object');
    assert.strictEqual(typeof config.schedule, 'string');
    assert.strictEqual(typeof config.policy, 'string');

    const error = validatePolicy(config.policy);
    if (error) throw error;

    const job = safe.safeCall(function () { return new CronTime(config.schedule); });
    if (!job) throw new BoxError(BoxError.BAD_FIELD, 'Invalid schedule pattern');

    await settings.set(settings.AUTOUPDATE_SCHEDULE_KEY, config.schedule);
    await settings.set(settings.AUTOUPDATE_POLICY_KEY, config.policy);
    await cron.handleAutoupdateConfigChanged(config);
}

async function getAutoupdateConfig() {
    const schedule = await settings.get(settings.AUTOUPDATE_SCHEDULE_KEY) || cron.DEFAULT_AUTOUPDATE_PATTERN;
    const policy = await settings.get(settings.AUTOUPDATE_POLICY_KEY) || AUTOUPDATE_POLICY_PLATFORM_AND_APPS;
    return { schedule, policy };
}

async function downloadBoxUrl(url, file) {
    assert.strictEqual(typeof file, 'string');

    // do not assert since it comes from the appstore
    if (typeof url !== 'string')  throw new BoxError(BoxError.EXTERNAL_ERROR, `url cannot be download to ${file} as it is not a string`);

    safe.fs.unlinkSync(file);

    await promiseRetry({ times: 10, interval: 5000, log }, async function () {
        log(`downloadBoxUrl: downloading ${url} to ${file}`);
        const [error] = await safe(shell.spawn('curl', ['-s', '--fail', url, '-o', file], { encoding: 'utf8' }));
        if (error) throw new BoxError(BoxError.NETWORK_ERROR, `Failed to download ${url}: ${error.message}`);
        log('downloadBoxUrl: done');
    });
}

async function gpgVerifyBoxTarball(file, sig) {
    assert.strictEqual(typeof file, 'string');
    assert.strictEqual(typeof sig, 'string');

    const [error, stdout] = await safe(shell.spawn('/usr/bin/gpg', ['--status-fd', '1', '--no-default-keyring', '--keyring', RELEASES_PUBLIC_KEY, '--verify', sig, file], { encoding: 'utf8' }));
    if (error) {
        log(`gpgVerifyBoxTarball: command failed. error: ${error}\n stdout: ${error.stdout}\n stderr: ${error.stderr}`);
        throw new BoxError(BoxError.NOT_SIGNED, `The signature in ${path.basename(sig)} could not be verified (command failed)`);
    }

    if (stdout.indexOf('[GNUPG:] VALIDSIG 0EADB19CDDA23CD0FE71E3470A372F8703C493CC') !== -1) return; // success

    log(`gpgVerifyBoxTarball: verification of ${sig} failed: ${stdout}\n`);

    throw new BoxError(BoxError.NOT_SIGNED, `The signature in ${path.basename(sig)} could not be verified (bad sig)`);
}

async function extractBoxTarball(tarball, dir) {
    assert.strictEqual(typeof tarball, 'string');
    assert.strictEqual(typeof dir, 'string');

    log(`extractBoxTarball: extracting ${tarball} to ${dir}`);

    const [error] = await safe(shell.spawn('tar', ['-zxf', tarball, '-C', dir], { encoding: 'utf8' }));
    if (error) throw new BoxError(BoxError.FS_ERROR, `Failed to extract release package: ${error.message}`);
    safe.fs.unlinkSync(tarball);

    log('extractBoxTarball: extracted');
}

async function verifyBoxUpdateInfo(versionsFile, updateInfo) {
    assert.strictEqual(typeof versionsFile, 'string');
    assert.strictEqual(typeof updateInfo, 'object');

    const releases = safe.JSON.parse(safe.fs.readFileSync(versionsFile, 'utf8')) || {};
    if (!releases[constants.VERSION]) throw new BoxError(BoxError.EXTERNAL_ERROR, `No version info for ${constants.VERSION}`);
    if (!releases[constants.VERSION].next) throw new BoxError(BoxError.EXTERNAL_ERROR, `No next version info for ${constants.VERSION}`);
    const nextVersion = releases[constants.VERSION].next;
    if (typeof releases[nextVersion] !== 'object' || !releases[nextVersion]) throw new BoxError(BoxError.EXTERNAL_ERROR, 'No next version info');
    if (releases[nextVersion].sourceTarballUrl !== updateInfo.sourceTarballUrl) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Version info mismatch');
}

async function downloadAndVerifyBoxRelease(updateInfo) {
    assert.strictEqual(typeof updateInfo, 'object');

    const filenames = await fs.promises.readdir(os.tmpdir());
    const oldArtifactNames = filenames.filter(f => f.startsWith('box-'));
    for (const artifactName of oldArtifactNames) {
        const fullPath = path.join(os.tmpdir(), artifactName);
        log(`downloadAndVerifyBoxRelease: removing old artifact ${fullPath}`);
        await fs.promises.rm(fullPath, { recursive: true, force: true });
    }

    await downloadBoxUrl(updateInfo.boxVersionsUrl, `${paths.UPDATE_DIR}/versions.json`);
    await downloadBoxUrl(updateInfo.boxVersionsSigUrl, `${paths.UPDATE_DIR}/versions.json.sig`);
    await gpgVerifyBoxTarball(`${paths.UPDATE_DIR}/versions.json`, `${paths.UPDATE_DIR}/versions.json.sig`);
    await verifyBoxUpdateInfo(`${paths.UPDATE_DIR}/versions.json`, updateInfo);
    await downloadBoxUrl(updateInfo.sourceTarballUrl, `${paths.UPDATE_DIR}/box.tar.gz`);
    await downloadBoxUrl(updateInfo.sourceTarballSigUrl, `${paths.UPDATE_DIR}/box.tar.gz.sig`);
    await gpgVerifyBoxTarball(`${paths.UPDATE_DIR}/box.tar.gz`, `${paths.UPDATE_DIR}/box.tar.gz.sig`);

    const newBoxSource = path.join(os.tmpdir(), 'box-' + crypto.randomBytes(4).readUInt32LE(0));
    const [mkdirError] = await safe(fs.promises.mkdir(newBoxSource, { recursive: true }));
    if (mkdirError) throw new BoxError(BoxError.FS_ERROR, `Failed to create directory ${newBoxSource}: ${mkdirError.message}`);
    await extractBoxTarball(`${paths.UPDATE_DIR}/box.tar.gz`, newBoxSource);

    return { file: newBoxSource };
}

async function checkFreeDiskSpace(neededSpace) {
    assert.strictEqual(typeof neededSpace, 'number');

    // can probably be a bit more aggressive here since a new update can bring in new docker images
    const [error, diskUsage] = await safe(df.file('/'));
    if (error) throw new BoxError(BoxError.FS_ERROR, error);

    if (diskUsage.available < neededSpace) throw new BoxError(BoxError.FS_ERROR, `Not enough disk space. Updates require at least 2GB of free space. Available: ${df.prettyBytes(diskUsage.available)}`);
}

async function updateBox(boxUpdateInfo, options, progressCallback) {
    assert(boxUpdateInfo && typeof boxUpdateInfo === 'object');
    assert(options && typeof options === 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    progressCallback({ percent: 1, message: 'Checking disk space' });

    await checkFreeDiskSpace(2*1024*1024*1024);

    progressCallback({ percent: 5, message: 'Downloading and verifying release' });

    const packageInfo = await downloadAndVerifyBoxRelease(boxUpdateInfo);

    if (!options.skipBackup) {
        progressCallback({ percent: 10, message: 'Backing up' });

        const sites = await backupSites.listByContentForUpdates('box');
        if (sites.length === 0) throw new BoxError(BoxError.BAD_STATE, 'No backup site is enabled to store automatic-update backups');

        for (const site of sites) {
            await backuptask.fullBackup(site.id, { preserveSecs: 3*7*24*60*60 }, (progress) => progressCallback({ percent: 10+progress.percent*70/100, message: progress.message }));
        }

        await checkFreeDiskSpace(2*1024*1024*1024); // check again in case backup is in same disk
    }

    await locks.wait(locks.TYPE_BOX_UPDATE);

    log(`Updating box with ${boxUpdateInfo.sourceTarballUrl}`);
    progressCallback({ percent: 70, message: 'Installing update...' });
    const [error] = await safe(shell.sudo([ UPDATE_CMD, packageInfo.file, process.stdout.logFile ], {}));  // run installer.sh from new box code as a separate service
    if (error) {
        await locks.release(locks.TYPE_BOX_UPDATE);
        throw new BoxError(BoxError.EXTERNAL_ERROR, 'Update failed. Check logs for more information.');
    }

    // Do not add any code here. The installer script will stop the box code any instant
}

async function checkBoxUpdateRequirements(boxUpdateInfo) {
    assert.strictEqual(typeof boxUpdateInfo, 'object');

    const result = await apps.list();

    for (const app of result) {
        const maxBoxVersion = app.manifest.maxBoxVersion;
        if (semver.valid(maxBoxVersion) && semver.gt(boxUpdateInfo.version, maxBoxVersion)) {
            throw new BoxError(BoxError.BAD_STATE, `Cannot update to v${boxUpdateInfo.version} because ${app.fqdn} has a maxBoxVersion of ${maxBoxVersion}`);
        }
    }
}

async function getBoxUpdate() {
    const updateInfo = safe.JSON.parse(safe.fs.readFileSync(paths.BOX_UPDATE_FILE, 'utf8'));
    return updateInfo || null;
}

async function startBoxUpdateTask(options, auditSource) {
    assert.strictEqual(typeof options, 'object');
    assert.strictEqual(typeof auditSource, 'object');

    const boxUpdateInfo = await getBoxUpdate();
    if (!boxUpdateInfo) throw new BoxError(BoxError.NOT_FOUND, 'No update available');
    if (!boxUpdateInfo.sourceTarballUrl) throw new BoxError(BoxError.BAD_STATE, 'No automatic update available');
    if (semver.gte(constants.VERSION, boxUpdateInfo.version)) throw new BoxError(BoxError.NOT_FOUND, 'No update available'); // can happen after update completed or hotfix

    await checkBoxUpdateRequirements(boxUpdateInfo);

    const sites = await backupSites.listByContentForUpdates('box');
    if (!options.skipBackup && sites.length === 0) throw new BoxError(BoxError.BAD_STATE, 'No backup site is enabled to store automatic-update backups. Enable this in a backup site to continue.');

    const [error] = await safe(locks.acquire(locks.TYPE_BOX_UPDATE_TASK));
    if (error) throw new BoxError(BoxError.BAD_STATE, `Another update task is in progress: ${error.message}`);

    const memoryLimit = sites.reduce((acc, cur) => cur.limits?.memoryLimit ? Math.max(cur.limits.memoryLimit/1024/1024, acc) : acc, 800);

    const taskId = await tasks.add(tasks.TASK_BOX_UPDATE, [ boxUpdateInfo, options ]);
    await eventlog.add(eventlog.ACTION_UPDATE, auditSource, { taskId, boxUpdateInfo });

    // background
    tasks.startTask(taskId, { timeout: 20 * 60 * 60 * 1000 /* 20 hours */, nice: 15, memoryLimit })
        .then(() => log('startBoxUpdateTask: update task completed'))
        .catch(async (updateError) => {
            log('Update failed with error. %o', updateError);

            await locks.release(locks.TYPE_BOX_UPDATE_TASK);
            await locks.releaseByTaskId(taskId);

            const timedOut = updateError.code === tasks.ETIMEOUT;
            await eventlog.add(eventlog.ACTION_UPDATE_FINISH, auditSource, { taskId, errorMessage: updateError.message, timedOut });
        });

    return taskId;
}

async function notifyBoxUpdate() {
    const version = safe.fs.readFileSync(paths.VERSION_FILE, 'utf8');
    if (version === constants.VERSION) return;

    safe.fs.unlinkSync(paths.BOX_UPDATE_FILE);

    if (!version) {
        await eventlog.add(eventlog.ACTION_INSTALL_FINISH, AuditSource.CRON, { version: constants.VERSION });
    } else {
        log(`notifyBoxUpdate: update finished from ${version} to ${constants.VERSION}`);
        const [error] = await safe(tasks.setCompletedByType(tasks.TASK_BOX_UPDATE, { error: null }));
        if (error && error.reason !== BoxError.NOT_FOUND) throw error; // when hotfixing, task may not exist
        await eventlog.add(eventlog.ACTION_UPDATE_FINISH, AuditSource.CRON, { errorMessage: '', oldVersion: version || 'dev', newVersion: constants.VERSION });
        await notifications.unpin(notifications.TYPE_BOX_UPDATE, { context: constants.VERSION });
    }

    safe.fs.writeFileSync(paths.VERSION_FILE, constants.VERSION, 'utf8');
}

async function autoUpdate(auditSource) {
    assert.strictEqual(typeof auditSource, 'object');

    const { policy } = await getAutoupdateConfig();
    if (policy === AUTOUPDATE_POLICY_NEVER) return;

    if (policy === AUTOUPDATE_POLICY_PLATFORM_AND_APPS) {
        const boxUpdateInfo = await getBoxUpdate();
        // do box before app updates. for the off chance that the box logic fixes some app update logic issue
        if (boxUpdateInfo && !boxUpdateInfo.unstable) {
            log('autoUpdate: starting box autoupdate to %j', boxUpdateInfo.version);
            const [error] = await safe(startBoxUpdateTask({ skipBackup: false }, AuditSource.CRON));
            if (!error) return; // do not start app updates when a box update got scheduled
            log(`autoUpdate: failed to start box autoupdate task: ${error.message}`);
            // fall through to update apps if box update never started (failed ubuntu or avx check)
        }
    }

    const result = await apps.list();
    for (const app of result) {
        if (!app.updateInfo) continue;
        if (!app.updateInfo.isAutoUpdatable) {
            log(`autoUpdate: ${app.fqdn} requires manual update. skipping`);
            continue;
        }

        const data = {
            manifest: app.updateInfo.manifest,
            force: false
        };

        log(`autoUpdate: ${app.fqdn} will be automatically updated`);
        const [updateError] = await safe(apps.updateApp(app, data, auditSource));
        if (updateError) {
            log(`autoUpdate: error autoupdating ${app.fqdn}: ${updateError.message}`);
            await safe(eventlog.add(eventlog.ACTION_APP_UPDATE_FINISH, auditSource, { app, toManifest: data.manifest, fromManifest: app.manifest, success: false,
                errorMessage: updateError.message, backupError: !!updateError.backupError }), { debug: log });
        }
    }
}

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

    let updateInfo = null;

    if (app.appStoreId) {
        updateInfo = await appstore.getAppUpdate(app, options);
    } else if (app.versionsUrl) {
        updateInfo = await community.getAppUpdate(app, options);
    }

    if (updateInfo) {
        await apps.update(app.id, { updateInfo });
        apps.attachUpdateInfoProperties(app, updateInfo); // these are dynamic properties, not to be saved in db
    }

    return updateInfo;
}

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

    log('checkBoxUpdate: checking for updates');

    const updateInfo = await appstore.getBoxUpdate(options);
    if (updateInfo) {
        safe.fs.writeFileSync(paths.BOX_UPDATE_FILE, JSON.stringify(updateInfo, null, 4));
    } else {
        safe.fs.unlinkSync(paths.BOX_UPDATE_FILE);
    }
}

async function raiseNotifications() {
    const { policy } = await getAutoupdateConfig();

    const boxUpdate = await getBoxUpdate();
    if (policy !== AUTOUPDATE_POLICY_PLATFORM_AND_APPS && boxUpdate) {
        const changelog = boxUpdate.changelog.map((m) => `* ${m}\n`).join('');
        const message = `Changelog:\n${changelog}\n\nGo to the Settings view to update.\n\n`;

        await notifications.pin(notifications.TYPE_BOX_UPDATE, `Cloudron v${boxUpdate.version} is available`, message, { context: boxUpdate.version });
    }

    const result = await apps.list();
    for (const app of result) {
        if (!app.updateInfo) continue;

        // currently, we do not raise notifications when auto-update is disabled. separate notifications appears spammy when having many apps
        // in the future, we can maybe aggregate?
        if (!app.updateInfo.isAutoUpdatable) {
            log(`autoUpdate: ${app.fqdn} cannot be autoupdated. skipping`);
            await notifications.pin(notifications.TYPE_MANUAL_APP_UPDATE_NEEDED, `${app.manifest.title} at ${app.fqdn} requires manual update to version ${app.updateInfo.manifest.version}`,
                `Changelog:\n${app.updateInfo.manifest.changelog}\n`, { context: app.id });
            continue;
        }
    }
}

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

    const [boxError] = await safe(checkBoxUpdate(options));
    if (boxError) log('checkForUpdates: error checking for box updates: %o', boxError);

    // check app updates
    const result = await apps.list();
    for (const app of result) {
        await safe(checkAppUpdate(app, options), { debug: log });
    }

    // raise notifications here because the updatechecker runs regardless of auto-updater cron job
    await raiseNotifications();
}

export default {
    AUTOUPDATE_POLICY_NEVER,
    AUTOUPDATE_POLICY_APPS_ONLY,
    AUTOUPDATE_POLICY_PLATFORM_AND_APPS,

    setAutoupdateConfig,
    getAutoupdateConfig,

    startBoxUpdateTask,
    updateBox,

    autoUpdate,

    notifyBoxUpdate,

    checkForUpdates,
    checkAppUpdate,
    checkBoxUpdate,

    getBoxUpdate,
};
