import BoxError from './boxerror.js';
import apps from './apps.js';
import archives from './archives.js';
import assert from 'node:assert';
import backups from './backups.js';
import backupFormats from './backupformats.js';
import backupSites from './backupsites.js';
import constants from './constants.js';
import logger from './logger.js';
import moment from 'moment';
import path from 'node:path';
import safe from 'safetydance';

const { log } = logger('backupcleaner');

function applyBackupRetention(allBackups, retention, referencedBackupIds) {
    assert(Array.isArray(allBackups));
    assert.strictEqual(typeof retention, 'object');
    assert(Array.isArray(referencedBackupIds));

    const now = new Date();

    for (const backup of allBackups) {
        if (backup.state === backups.BACKUP_STATE_ERROR) {
            backup.discardReason = 'error';
        } else if (backup.state === backups.BACKUP_STATE_CREATING) {
            if ((now - backup.creationTime) < 48*60*60*1000) backup.keepReason = 'creating';
            else backup.discardReason = 'creating-too-long';
        } else if (referencedBackupIds.includes(backup.id)) { // could also be in archives
            backup.keepReason = 'referenced';
        } else if ((backup.preserveSecs === -1) || ((now - backup.creationTime) < (backup.preserveSecs * 1000))) {
            backup.keepReason = 'preserveSecs';
        } else if ((now - backup.creationTime < retention.keepWithinSecs * 1000) || retention.keepWithinSecs < 0) {
            backup.keepReason = 'keepWithinSecs';
        }
    }

    const KEEP_FORMATS = {
        keepDaily: 'Y-M-D',
        keepWeekly: 'Y-W',
        keepMonthly: 'Y-M',
        keepYearly: 'Y'
    };

    for (const format of [ 'keepDaily', 'keepWeekly', 'keepMonthly', 'keepYearly' ]) {
        if (!(format in retention)) continue;

        const n = retention[format]; // we want to keep "n" backups of format
        if (!n) continue; // disabled rule

        let lastPeriod = null, keptSoFar = 0;
        for (const backup of allBackups) {
            if (backup.discardReason) continue; // already discarded for some reason
            if (backup.keepReason && backup.keepReason !== 'referenced') continue; // kept for some other reason
            const period = moment(backup.creationTime).format(KEEP_FORMATS[format]);
            if (period === lastPeriod) continue; // already kept for this period

            lastPeriod = period;
            backup.keepReason = backup.keepReason ? `${backup.keepReason}+${format}` : format;
            if (++keptSoFar === n) break;
        }
    }

    if (retention.keepLatest) {
        const latestNormalBackup = allBackups.find(b => b.state === backups.BACKUP_STATE_NORMAL);
        if (latestNormalBackup && !latestNormalBackup.keepReason) latestNormalBackup.keepReason = 'latest';
    }

    for (const backup of allBackups) {
        log(`applyBackupRetention: ${backup.remotePath} keep/discard: ${backup.keepReason || backup.discardReason || 'unprocessed'}`);
    }
}

async function removeBackup(site, backup, progressCallback) {
    assert.strictEqual(typeof site, 'object');
    assert.strictEqual(typeof backup, 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    const remotePath = backup.remotePath;

    let removeError;
    if (site.format ==='tgz') {
        progressCallback({ message: `${backup.remotePath}: Removing ${remotePath}`});
        [removeError] = await safe(backupSites.storageApi(site).remove(site.config, remotePath));
    } else {
        progressCallback({ message: `${backup.remotePath}: Removing directory ${remotePath}`});
        [removeError] = await safe(backupSites.storageApi(site).removeDir(site.config, site.limits, remotePath, progressCallback));
    }

    if (removeError) {
        log(`removeBackup: error removing backup ${removeError.message}`);
        return;
    }

    // remove integrity info
    const [removeIntegrityError] = await safe(backupSites.storageApi(site).remove(site.config, `${remotePath}.backupinfo`));
    if (removeIntegrityError) log(`removeBackup: could not remove integrity info: ${removeIntegrityError.message}`);

    // prune empty directory if possible
    const [pruneError] = await safe(backupSites.storageApi(site).remove(site.config, path.dirname(remotePath)));
    if (pruneError) log(`removeBackup: unable to prune backup directory ${path.dirname(remotePath)}: ${pruneError.message}`);

    const [delError] = await safe(backups.del(backup.id));
    if (delError) log(`removeBackup: error removing ${backup.id} from database. %o`, delError);
    else log(`removeBackup: removed ${backup.remotePath}`);
}

async function cleanupAppBackups(site, referencedBackupIds, progressCallback) {
    assert.strictEqual(typeof site, 'object');
    assert(Array.isArray(referencedBackupIds));
    assert.strictEqual(typeof progressCallback, 'function');

    const removedAppBackupPaths = [];

    const allApps = await apps.list();
    const allAppIds = allApps.map(a => a.id);

    // high number, try to get all app backups as we had a cloudron with over 100 apps with 4 daily backups for one month!
    const appBackups = await backups.listByTypePaged(backups.BACKUP_TYPE_APP, site.id, 1, 100000);

    // collate the backups by app id. note that the app could already have been uninstalled
    const appBackupsById = {};
    for (const appBackup of appBackups) {
        if (!appBackupsById[appBackup.identifier]) appBackupsById[appBackup.identifier] = [];
        appBackupsById[appBackup.identifier].push(appBackup);
    }

    // apply backup policy per app. keep latest backup only for existing apps
    let appBackupsToRemove = [];
    for (const appId of Object.keys(appBackupsById)) {
        const appRetention = Object.assign({ keepLatest: allAppIds.includes(appId) }, site.retention);
        log(`cleanupAppBackups: applying retention for appId ${appId} retention: ${JSON.stringify(appRetention)}`);
        applyBackupRetention(appBackupsById[appId], appRetention, referencedBackupIds);
        appBackupsToRemove = appBackupsToRemove.concat(appBackupsById[appId].filter(b => !b.keepReason));
    }

    for (const appBackup of appBackupsToRemove) {
        await progressCallback({ message: `Removing app backup (${appBackup.identifier}): ${appBackup.id}`});
        removedAppBackupPaths.push(appBackup.remotePath);
        await removeBackup(site, appBackup, progressCallback); // never errors
    }

    log('cleanupAppBackups: done');

    return removedAppBackupPaths;
}

async function cleanupMailBackups(site, referencedBackupIds, progressCallback) {
    assert.strictEqual(typeof site, 'object');
    assert(Array.isArray(referencedBackupIds));
    assert.strictEqual(typeof progressCallback, 'function');

    const removedMailBackupPaths = [];

    const mailBackups = await backups.listByTypePaged(backups.BACKUP_TYPE_MAIL, site.id, 1, 100000);

    applyBackupRetention(mailBackups, Object.assign({ keepLatest: true }, site.retention), referencedBackupIds);

    for (const mailBackup of mailBackups) {
        if (mailBackup.keepReason) continue;
        await progressCallback({ message: `Removing mail backup ${mailBackup.remotePath}`});
        removedMailBackupPaths.push(mailBackup.remotePath);
        await removeBackup(site, mailBackup, progressCallback); // never errors
    }

    log('cleanupMailBackups: done');

    return removedMailBackupPaths;
}

async function cleanupBoxBackups(site, progressCallback) {
    assert.strictEqual(typeof progressCallback, 'function');

    let referencedBackupIds = [];
    const removedBoxBackupPaths = [];

    // We need to fetch all box backups to be able to compile a list of all referenced app backupSites.
    // Otherwise if we miss some app backups, they will get purged!
    // 100000 here should be seen as infinity
    const boxBackups = await backups.listByTypePaged(backups.BACKUP_TYPE_BOX, site.id, 1, 100000);

    applyBackupRetention(boxBackups, Object.assign({ keepLatest: true }, site.retention), [] /* references */);

    for (const boxBackup of boxBackups) {
        if (boxBackup.keepReason) {
            referencedBackupIds = referencedBackupIds.concat(boxBackup.dependsOn);
            continue;
        }

        await progressCallback({ message: `Removing box backup ${boxBackup.remotePath}`});

        removedBoxBackupPaths.push(boxBackup.remotePath);
        await removeBackup(site, boxBackup, progressCallback);
    }

    log('cleanupBoxBackups: done');

    return { removedBoxBackupPaths, referencedBackupIds };
}

// cleans up the database by checking if backup exists in the remote. this can happen if user had set some bucket policy
async function cleanupMissingBackups(site, progressCallback) {
    assert.strictEqual(typeof site, 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    const perPage = 1000;
    const missingBackupPaths = [];

    if (constants.TEST) return missingBackupPaths;

    let page = 1, result;
    do {
        result = await backups.listByTypePaged(backups.BACKUP_TYPE_BOX, site.id, page, perPage);

        for (const backup of result) {
            if (backup.state !== backups.BACKUP_STATE_NORMAL) continue; // note: errored and incomplete backups are cleaned up by the backup retention logic

            const ext = backupFormats.api(site.format).getFileExtension(!!site.encryption);
            const remotePath = backup.remotePath + (ext ? '' : '/'); // add trailing slash to indicate directory
            const [existsError, exists] = await safe(backupSites.storageApi(site).exists(site.config, remotePath));
            if (existsError || exists) continue;

            await progressCallback({ message: `Removing missing backup ${backup.remotePath}`});

            const [delError] = await safe(backups.del(backup.id));
            if (delError) log(`cleanupMissingBackups: error removing ${backup.id} from database. %o`, delError);

            missingBackupPaths.push(backup.remotePath);
        }

        ++ page;
    } while (result.length === perPage);

    log('cleanupMissingBackups: done');

    return missingBackupPaths;
}

// removes the snapshots of apps that have been uninstalled
async function removeOldAppSnapshots(site) {
    assert.strictEqual(typeof site, 'object');

    const snapshotInfo = await backupSites.getSnapshotInfo(site);

    const progressCallback = (progress) => { log(`removeOldAppSnapshots: ${progress.message}`); };

    for (const appId of Object.keys(snapshotInfo)) {
        if (appId === 'box' || appId === 'mail') continue;

        const app = await apps.get(appId);
        if (app !== null) continue; // app is still installed

        const ext = backupFormats.api(site.format).getFileExtension(!!site.encryption);
        const remotePath = `snapshot/app_${appId}${ext}`;
        if (ext) {
            await safe(backupSites.storageApi(site).remove(site.config, remotePath), { debug: log });
        } else {
            await safe(backupSites.storageApi(site).removeDir(site.config, site.limits, remotePath, progressCallback), { debug: log });
        }

        await backupSites.setSnapshotInfo(site, appId, null /* info */);
        log(`removeOldAppSnapshots: removed snapshot of app ${appId}`);
    }

    log('removeOldAppSnapshots: done');
}

async function run(siteId, progressCallback) {
    assert.strictEqual(typeof siteId, 'string');
    assert.strictEqual(typeof progressCallback, 'function');

    const site = await backupSites.get(siteId);
    if (!site) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Target not found');

    log(`run: retention is ${JSON.stringify(site.retention)}`);

    const status = await backupSites.ensureMounted(site);
    log(`run: mount point status is ${JSON.stringify(status)}`);
    if (status.state !== 'active') throw new BoxError(BoxError.MOUNT_ERROR, `Backup endpoint is not mounted: ${status.message}`);

    if (site.retention.keepWithinSecs < 0) {
        log('run: keeping all backups');
        return {};
    }

    await progressCallback({ percent: 5, message: 'Cleaning box backups' });
    const { removedBoxBackupPaths, referencedBackupIds } = await cleanupBoxBackups(site, progressCallback); // references is app or mail backup ids

    await progressCallback({ percent: 15, message: 'Cleaning mail backups' });
    const removedMailBackupPaths = await cleanupMailBackups(site, referencedBackupIds, progressCallback);

    await progressCallback({ percent: 25, message: 'Cleaning app backups' });
    const archivedBackupIds = await archives.listBackupIds();
    const removedAppBackupPaths = await cleanupAppBackups(site, referencedBackupIds.concat(archivedBackupIds), progressCallback);

    await progressCallback({ percent: 60, message: 'Checking storage backend and removing stale entries in database' });
    const missingBackupPaths = await cleanupMissingBackups(site, progressCallback);

    await progressCallback({ percent: 80, message: 'Removing snapshots of uninstalled apps' });
    await removeOldAppSnapshots(site);

    await progressCallback({ percent: 90, message: 'Cleaning storage artifacts' });
    await backupSites.storageApi(site).cleanup(site.config, progressCallback);

    return { removedBoxBackupPaths, removedMailBackupPaths, removedAppBackupPaths, missingBackupPaths };
}

const _applyBackupRetention = applyBackupRetention;

export default {
    run,
    _applyBackupRetention,
};
