import assert from 'node:assert';
import AuditSource from './auditsource.js';
import BoxError from './boxerror.js';
import changelog from './changelog.js';
import dashboard from './dashboard.js';
import database from './database.js';
import logger from './logger.js';
import eventlog from './eventlog.js';
import mailer from './mailer.js';
import safe from 'safetydance';
import users from './users.js';

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

const TYPE_CLOUDRON_INSTALLED = 'cloudronInstalled';
const TYPE_CLOUDRON_UPDATED = 'cloudronUpdated';
const TYPE_CLOUDRON_UPDATE_FAILED = 'cloudronUpdateFailed';
const TYPE_CERTIFICATE_RENEWAL_FAILED = 'certificateRenewalFailed';
const TYPE_BACKUP_CONFIG = 'backupConfig';
const TYPE_APP_OOM = 'appOutOfMemory';
const TYPE_APP_UPDATED = 'appUpdated';
const TYPE_BACKUP_FAILED = 'backupFailed';
const TYPE_APP_DOWN = 'appDown';
const TYPE_APP_UP = 'appUp';
const TYPE_DISK_SPACE = 'diskSpace';
const TYPE_MAIL_STATUS = 'mailStatus';
const TYPE_REBOOT = 'reboot';
const TYPE_UPDATE_UBUNTU = 'ubuntuUpdate';
const TYPE_BOX_UPDATE = 'boxUpdate';
const TYPE_MANUAL_APP_UPDATE_NEEDED = 'manualAppUpdate';
const TYPE_APP_AUTO_UPDATE_FAILED = 'appAutoUpdateFailed';

const NOTIFICATION_FIELDS = [ 'id', 'eventId', 'type', 'title', 'message', 'creationTime', 'acknowledged', 'context' ];

function postProcess(result) {
    assert.strictEqual(typeof result, 'object');
    result.id = String(result.id);
    result.acknowledged = !!result.acknowledged; // convert to boolean
    return result;
}

async function add(type, title, message, data) {
    assert.strictEqual(typeof type, 'string');
    assert.strictEqual(typeof title, 'string');
    assert.strictEqual(typeof message, 'string');
    assert.strictEqual(typeof data, 'object');

    log(`add: ${type} ${title}`);

    const query = 'INSERT INTO notifications (type, title, message, acknowledged, eventId, context) VALUES (?, ?, ?, ?, ?, ?)';
    const args = [ type, title, message, false, data?.eventId || null, data.context || '' ];

    const result = await database.query(query, args);
    return String(result.insertId);
}

async function get(id) {
    assert.strictEqual(typeof id, 'string');

    const result = await database.query(`SELECT ${NOTIFICATION_FIELDS} FROM notifications WHERE id = ?`, [ id ]);
    if (result.length === 0) return null;

    return postProcess(result[0]);
}

async function getByType(type, context) {
    assert.strictEqual(typeof type, 'string');
    assert.strictEqual(typeof context, 'string');

    const results = await database.query(`SELECT ${NOTIFICATION_FIELDS} from notifications WHERE type = ? AND context = ? ORDER BY creationTime LIMIT 1`, [ type, context || '']);
    if (results.length === 0) return null;

    return postProcess(results[0]);
}

async function update(notification, data) {
    assert.strictEqual(typeof notification, 'object');
    assert.strictEqual(typeof data, 'object');

    const args = [];
    const fields = [];
    for (const k in data) {
        fields.push(k + ' = ?');
        args.push(data[k]);
    }
    args.push(notification.id);

    const result = await database.query('UPDATE notifications SET ' + fields.join(', ') + ' WHERE id = ?', args);
    if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Notification not found');
}

async function del(id) {
    assert.strictEqual(typeof id, 'string');

    const result = await database.query('DELETE FROM notifications WHERE id = ?', [ id ]);
    if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Notification not found');
}

async function list(filters, page, perPage) {
    assert.strictEqual(typeof filters, 'object');
    assert.strictEqual(typeof page, 'number');
    assert.strictEqual(typeof perPage, 'number');

    const args = [];

    const where = [];
    if ('acknowledged' in filters) {
        where.push('acknowledged=?');
        args.push(filters.acknowledged);
    }

    let query = `SELECT ${NOTIFICATION_FIELDS} FROM notifications`;
    if (where.length) query += ' WHERE ' + where.join(' AND ');
    query += ' ORDER BY creationTime DESC LIMIT ?,?';

    args.push((page-1)*perPage);
    args.push(perPage);

    const results = await database.query(query, args);
    results.forEach(postProcess);
    return results;
}

async function oomEvent(eventId, containerId, app, addonName, event) {
    assert.strictEqual(typeof eventId, 'string');
    assert.strictEqual(typeof containerId, 'string');
    assert.strictEqual(typeof app, 'object');
    assert(addonName === null || typeof addonName === 'string');

    assert(app || addonName);

    const { fqdn:dashboardFqdn } = await dashboard.getLocation();
    let title, message;
    if (addonName) {
        if (app) {
            title = `The ${addonName} service of the app at ${app.fqdn} ran out of memory`;
        } else {
            title = `The ${addonName} service ran out of memory`;
        }
        message = `The service has been restarted automatically. If you see this notification often, consider increasing the [memory limit](https://${dashboardFqdn}/#/services)`;
    } else if (app) {
        title = `The app at ${app.fqdn} ran out of memory.`;
        message = `The app has been restarted automatically. If you see this notification often, consider increasing the [memory limit](https://${dashboardFqdn}/#/app/${app.id}/resources)`;
    }

    await add(TYPE_APP_OOM, title, message, { eventId });

    const admins = await users.getAdmins();
    for (const admin of admins) {
        if (admin.notificationConfig.includes(TYPE_APP_OOM)) {
            await safe(mailer.oomEvent(admin.email, containerId, app, addonName, event), { debug: log });
        }
    }
}

async function appUp(eventId, app) {
    assert.strictEqual(typeof eventId, 'string');
    assert.strictEqual(typeof app, 'object');

    const admins = await users.getAdmins();
    for (const admin of admins) {
        if (admin.notificationConfig.includes(TYPE_APP_UP)) {
            await safe(mailer.appUp(admin.email, app), { debug: log });
        }
    }
}

async function appDown(eventId, app) {
    assert.strictEqual(typeof eventId, 'string');
    assert.strictEqual(typeof app, 'object');

    const admins = await users.getAdmins();
    for (const admin of admins) {
        if (admin.notificationConfig.includes(TYPE_APP_DOWN)) {
            await safe(mailer.appDown(admin.email, app), { debug: log });
        }
    }
}

async function appUpdated(eventId, app, fromManifest, toManifest) {
    assert.strictEqual(typeof eventId, 'string');
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof fromManifest, 'object');
    assert.strictEqual(typeof toManifest, 'object');

    if (!app.appStoreId && !app.versionsUrl) return; // skip notification of dev apps

    const tmp = toManifest.description.match(/<upstream>(.*)<\/upstream>/i);
    const upstreamVersion = (tmp && tmp[1]) ? tmp[1] : '';
    const title = upstreamVersion ? `${toManifest.title} at ${app.fqdn} updated to ${upstreamVersion} (package version ${toManifest.version})`
        : `${toManifest.title} at ${app.fqdn} updated to package version ${toManifest.version}`;

    await add(TYPE_APP_UPDATED, title, `The application installed at https://${app.fqdn} was updated.\n\nChangelog:\n${toManifest.changelog}\n`, { eventId });
}

async function boxUpdated(eventId, oldVersion, newVersion) {
    assert.strictEqual(typeof eventId, 'string');
    assert.strictEqual(typeof oldVersion, 'string');
    assert.strictEqual(typeof newVersion, 'string');

    const changes = changelog.getChanges(newVersion);
    const changelogMarkdown = changes.map((m) => `* ${m}\n`).join('');

    await add(TYPE_CLOUDRON_UPDATED, `Cloudron updated to v${newVersion}`, `Cloudron was updated from v${oldVersion} to v${newVersion}.\n\nChangelog:\n${changelogMarkdown}\n`, { eventId });
}

async function boxInstalled(eventId, version) {
    assert.strictEqual(typeof eventId, 'string');
    assert.strictEqual(typeof version, 'string');

    const changes = changelog.getChanges(version.replace(/\.([^.]*)$/, '.0')); // last .0 release
    const changelogMarkdown = changes.map((m) => `* ${m}\n`).join('');

    await add(TYPE_CLOUDRON_INSTALLED, `Cloudron v${version} installed`, `Cloudron v${version} was installed.\n\nChangelog:\n${changelogMarkdown}\n`, { eventId });
}

async function boxUpdateError(eventId, errorMessage) {
    assert.strictEqual(typeof eventId, 'string');
    assert.strictEqual(typeof errorMessage, 'string');

    await add(TYPE_CLOUDRON_UPDATE_FAILED, 'Cloudron update failed', `Failed to update Cloudron: ${errorMessage}.`, { eventId });

    const admins = await users.getAdmins();
    for (const admin of admins) {
        if (admin.notificationConfig.includes(TYPE_CLOUDRON_UPDATE_FAILED)) {
            await safe(mailer.boxUpdateError(admin.email, errorMessage), { debug: log });
        }
    }
}

async function certificateRenewalError(eventId, fqdn, errorMessage) {
    assert.strictEqual(typeof eventId, 'string');
    assert.strictEqual(typeof fqdn, 'string');
    assert.strictEqual(typeof errorMessage, 'string');

    await add(TYPE_CERTIFICATE_RENEWAL_FAILED, `Certificate renewal of ${fqdn} failed`, `Failed to renew certs of ${fqdn}: ${errorMessage}. Renewal will be retried in 12 hours.`, { eventId });

    const admins = await users.getAdmins();
    for (const admin of admins) {
        if (admin.notificationConfig.includes(TYPE_CERTIFICATE_RENEWAL_FAILED)) {
            await safe(mailer.certificateRenewalError(admin.email, fqdn, errorMessage), { debug: log });
        }
    }
}

async function appAutoUpdateFailed(eventId, app, errorMessage, backupError) {
    assert.strictEqual(typeof eventId, 'string');
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof errorMessage, 'string');
    assert.strictEqual(typeof backupError, 'boolean');

    const title = `Automatic update of ${app.manifest.title} at ${app.fqdn} failed`;
    const message = `The automatic update of the app at https://${app.fqdn} failed: ${errorMessage}`;

    await add(TYPE_APP_AUTO_UPDATE_FAILED, title, message, { eventId });

    const admins = await users.getAdmins();
    for (const admin of admins) {
        if (admin.notificationConfig.includes(TYPE_APP_AUTO_UPDATE_FAILED)) {
            await safe(mailer.appAutoUpdateFailed(admin.email, app, errorMessage, backupError), { debug: log });
        }
    }
}

async function backupFailed(eventId, taskId, errorMessage) {
    assert.strictEqual(typeof eventId, 'string');
    assert.strictEqual(typeof taskId, 'string');
    assert.strictEqual(typeof errorMessage, 'string');

    await add(TYPE_BACKUP_FAILED, 'Backup failed', `Backup failed: ${errorMessage}. Logs are available [here](/logs.html?taskId=${taskId}).`, { eventId });

    const { fqdn:dashboardFqdn } = await dashboard.getLocation();
    const superadmins = await users.getSuperadmins();
    for (const superadmin of superadmins) {
        if (superadmin.notificationConfig.includes(TYPE_BACKUP_FAILED)) {
            await safe(mailer.backupFailed(superadmin.email, errorMessage, `https://${dashboardFqdn}/logs.html?taskId=${taskId}`), { debug: log });
        }
    }
}

async function rebootRequired() {
    const admins = await users.getAdmins();
    for (const admin of admins) {
        if (admin.notificationConfig.includes(TYPE_REBOOT)) {
            await safe(mailer.rebootRequired(admin.email), { debug: log });
        }
    }
}

async function lowDiskSpace(message) {
    assert.strictEqual(typeof message, 'string');

    const admins = await users.getAdmins();
    for (const admin of admins) {
        if (admin.notificationConfig.includes(TYPE_DISK_SPACE)) {
            await safe(mailer.lowDiskSpace(admin.email, message), { debug: log });
        }
    }
}

async function onPin(type, message) {
    assert.strictEqual(typeof type, 'string'); // TYPE_
    assert.strictEqual(typeof message, 'string');

    switch (type) {
    case TYPE_REBOOT:
        return await rebootRequired();
    case TYPE_DISK_SPACE:
        return await lowDiskSpace(message);
    default:
        break;
    }
}

async function pin(type, title, message, options) {
    assert.strictEqual(typeof type, 'string'); // TYPE_
    assert.strictEqual(typeof title, 'string');
    assert.strictEqual(typeof message, 'string');
    assert.strictEqual(typeof options, 'object');

    const result = await getByType(type, options.context || '');
    if (!result) {
        await onPin(type, message);
        return await add(type, title, message, { eventId: null, context: options.context || '' });
    }

    // do not reset the ack state if user has already seen the update notification
    const isUpdateType = type === TYPE_BOX_UPDATE || type === TYPE_MANUAL_APP_UPDATE_NEEDED;
    const acknowledged = (isUpdateType && result.message === message) ? result.acknowledged : false;

    if (result.acknowledged && !acknowledged) await onPin(type, message);

    await update(result, { title, message, acknowledged, creationTime: new Date() });
    return result.id;
}

async function unpin(type, options) {
    assert.strictEqual(typeof type, 'string'); // TYPE_
    assert.strictEqual(typeof options, 'object');

    await database.query('UPDATE notifications SET acknowledged=1 WHERE type = ? AND context = ?', [ type, options.context || '' ]);
}

async function onEvent(eventId, action, source, data) {
    assert.strictEqual(typeof eventId, 'string');
    assert.strictEqual(typeof action, 'string');
    assert.strictEqual(typeof source, 'object');
    assert.strictEqual(typeof data, 'object');

    switch (action) {
    case eventlog.ACTION_APP_OOM:
        return await oomEvent(eventId, data.containerId, data.app, data.addonName, data.event);

    case eventlog.ACTION_APP_UP:
        return await appUp(eventId, data.app);

    case eventlog.ACTION_APP_DOWN:
        return await appDown(eventId, data.app);

    case eventlog.ACTION_APP_UPDATE_FINISH:
        if (source.username !== AuditSource.CRON.username) return; // updated by user
        if (data.errorMessage) return await appAutoUpdateFailed(eventId, data.app, data.errorMessage, data.backupError);
        return await appUpdated(eventId, data.app, data.fromManifest, data.toManifest);

    case eventlog.ACTION_CERTIFICATE_RENEWAL:
    case eventlog.ACTION_CERTIFICATE_NEW:
        if (!data.errorMessage) return;
        if (!data.notAfter || (data.notAfter - new Date() >= (10 * 24 * 60 * 60 * 1000))) return; // more than 10 days left to expire
        return await certificateRenewalError(eventId, data.domain, data.errorMessage);

    case eventlog.ACTION_BACKUP_FINISH:
        if (!data.errorMessage) return;
        if (source.username !== AuditSource.CRON.username && !data.timedOut) return; // manual stop by user

        return await backupFailed(eventId, data.taskId, data.errorMessage); // only notify for automated backups or timedout

    case eventlog.ACTION_INSTALL_FINISH:
        return await boxInstalled(eventId, data.version);

    case eventlog.ACTION_UPDATE_FINISH:
        if (!data.errorMessage) return await boxUpdated(eventId, data.oldVersion, data.newVersion);
        if (data.timedOut) return await boxUpdateError(eventId, data.errorMessage);
        return;

    default:
        return;
    }
}

const TYPE_DOMAIN_CONFIG_CHECK_FAILED = 'domainConfigCheckFailed';

export default {
    get,
    update,
    list,
    del,
    onEvent,
    TYPE_CLOUDRON_INSTALLED,
    TYPE_CLOUDRON_UPDATED,
    TYPE_CLOUDRON_UPDATE_FAILED,
    TYPE_CERTIFICATE_RENEWAL_FAILED,
    TYPE_BACKUP_CONFIG,
    TYPE_APP_OOM,
    TYPE_APP_UPDATED,
    TYPE_BACKUP_FAILED,
    TYPE_APP_DOWN,
    TYPE_APP_UP,
    TYPE_DISK_SPACE,
    TYPE_MAIL_STATUS,
    TYPE_REBOOT,
    TYPE_UPDATE_UBUNTU,
    TYPE_BOX_UPDATE,
    TYPE_MANUAL_APP_UPDATE_NEEDED,
    TYPE_APP_AUTO_UPDATE_FAILED,
    TYPE_DOMAIN_CONFIG_CHECK_FAILED,
    pin,
    unpin,
    _add: add,
};
