import assert from 'node:assert';
import BoxError from './boxerror.js';
import constants from './constants.js';
import logger from './logger.js';
import dns from './dns.js';
import docker from './docker.js';
import domains from './domains.js';
import eventlog from './eventlog.js';
import fs from 'node:fs';
import hat from './hat.js';
import infra from './infra_version.js';
import Location from './location.js';
import locks from './locks.js';
import mail from './mail.js';
import paths from './paths.js';
import platform from './platform.js';
import reverseProxy from './reverseproxy.js';
import safe from 'safetydance';
import services from './services.js';
import settings from './settings.js';
import shellModule from './shell.js';
import tasks from './tasks.js';
import users from './users.js';

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

const DEFAULT_MEMORY_LIMIT = 512 * 1024 * 1024;


async function createMailConfig(mailFqdn) {
    assert.strictEqual(typeof mailFqdn, 'string');

    log(`createMailConfig: generating mail config with ${mailFqdn}`);

    const mailDomains = await mail.listDomains();

    const mailOutDomains = mailDomains.filter(d => d.relay.provider !== 'noop').map(d => d.domain).join(',');
    const mailInDomains = mailDomains.filter(function (d) { return d.enabled; }).map(function (d) { return d.domain; }).join(',');

    // mail_domain is used for SRS
    if (!safe.fs.writeFileSync(`${paths.MAIL_CONFIG_DIR}/mail.ini`,
        `mail_in_domains=${mailInDomains}\nmail_out_domains=${mailOutDomains}\nmail_server_name=${mailFqdn}\n\n`, 'utf8')) {
        throw new BoxError(BoxError.FS_ERROR, `Could not create mail var file: ${safe.error.message}`);
    }

    // enable_outbound makes plugin forward email for relayed mail. non-relayed mail always hits LMTP plugin first
    if (!safe.fs.writeFileSync(`${paths.MAIL_CONFIG_DIR}/smtp_forward.ini`, 'enable_outbound=false\ndomain_selector=mail_from\n', 'utf8')) {
        throw new BoxError(BoxError.FS_ERROR, `Could not create smtp forward file: ${safe.error.message}`);
    }

    // create sections for per-domain configuration
    for (const domain of mailDomains) {
        const catchAll = domain.catchAll.join(',');
        const mailFromValidation = domain.mailFromValidation;

        if (!safe.fs.appendFileSync(`${paths.MAIL_CONFIG_DIR}/mail.ini`,
            `[${domain.domain}]\ncatch_all=${catchAll}\nmail_from_validation=${mailFromValidation}\n\n`, 'utf8')) {
            throw new BoxError(BoxError.FS_ERROR, `Could not create mail var file: ${safe.error.message}`);
        }

        if (!safe.fs.writeFileSync(`${paths.MAIL_CONFIG_DIR}/banner/${domain.domain}.text`, domain.banner.text || '')) throw new BoxError(BoxError.FS_ERROR, `Could not create text banner file: ${safe.error.message}`);
        if (!safe.fs.writeFileSync(`${paths.MAIL_CONFIG_DIR}/banner/${domain.domain}.html`, domain.banner.html || '')) throw new BoxError(BoxError.FS_ERROR, `Could not create html banner file: ${safe.error.message}`);

        safe.fs.mkdirSync(`${paths.MAIL_CONFIG_DIR}/dkim/${domain.domain}`, { recursive: true });
        if (!safe.fs.writeFileSync(`${paths.MAIL_CONFIG_DIR}/dkim/${domain.domain}/public`, domain.dkimKey.publicKey)) throw new BoxError(BoxError.FS_ERROR, `Could not create public key file: ${safe.error.message}`);
        if (!safe.fs.writeFileSync(`${paths.MAIL_CONFIG_DIR}/dkim/${domain.domain}/private`, domain.dkimKey.privateKey)) throw new BoxError(BoxError.FS_ERROR, `Could not create private key file: ${safe.error.message}`);
        if (!safe.fs.writeFileSync(`${paths.MAIL_CONFIG_DIR}/dkim/${domain.domain}/selector`, domain.dkimSelector)) throw new BoxError(BoxError.FS_ERROR, `Could not create selector file: ${safe.error.message}`);

        // if the 'yellowtent' user of OS and the 'cloudron' user of mail container don't match, the keys become inaccessible by mail code
        if (!safe.fs.chmodSync(`${paths.MAIL_CONFIG_DIR}/dkim/${domain.domain}/private`, 0o644)) throw new BoxError(BoxError.FS_ERROR, safe.error);

        const relay = domain.relay;

        const enableRelay = relay.provider !== 'cloudron-smtp' && relay.provider !== 'noop',
            host = relay.host || '',
            port = relay.port || 25,
            // office365 removed plain auth (https://support.microsoft.com/en-us/office/outlook-com-no-longer-supports-auth-plain-authentication-07f7d5e9-1697-465f-84d2-4513d4ff0145)
            authType = relay.username ? (relay.provider === 'office365-legacy-smtp' ? 'login' : 'plain') : '',
            username = relay.username || '',
            password = relay.password || '',
            forceFromAddress = relay.forceFromAddress ? 'true' : 'false';

        if (!enableRelay) continue;

        const relayData = `[${domain.domain}]\nenable_outbound=true\nhost=${host}\nport=${port}\nenable_tls=true\nauth_type=${authType}\nauth_user=${username}\nauth_pass=${password}\nforce_from_address=${forceFromAddress}\n\n`;

        if (!safe.fs.appendFileSync(paths.MAIL_CONFIG_DIR + '/smtp_forward.ini', relayData, 'utf8')) {
            throw new BoxError(BoxError.FS_ERROR, `Could not create mail var file: ${safe.error.message}`);
        }
    }

    return mailInDomains.length !== 0 /* allowInbound */;
}

// note: this needs a lock because of container deletion/creation being non-reentrant
async function configureMail(mailFqdn, mailDomain, serviceConfig) {
    assert.strictEqual(typeof mailFqdn, 'string');
    assert.strictEqual(typeof mailDomain, 'string');
    assert.strictEqual(typeof serviceConfig, 'object');

    // mail (note: 2587 is hardcoded in mail container and app use this port)
    // MAIL_SERVER_NAME is the hostname of the mailserver i.e server uses these certs
    // MAIL_DOMAIN is the domain for which this server is relaying mails
    // mail container uses /app/data for backed up data and /run for restart-able data

    const image = infra.images.mail;
    const memoryLimit = serviceConfig.memoryLimit || DEFAULT_MEMORY_LIMIT;
    const cloudronToken = hat(8 * 128), relayToken = hat(8 * 128);

    const certificate = await reverseProxy.getMailCertificate();

    const dhparamsFilePath = `${paths.MAIL_CONFIG_DIR}/dhparams.pem`;
    const mailCertFilePath = `${paths.MAIL_CONFIG_DIR}/tls_cert.pem`;
    const mailKeyFilePath =  `${paths.MAIL_CONFIG_DIR}/tls_key.pem`;

    const [readError, dhparams] = await safe(fs.promises.readFile(paths.DHPARAMS_FILE));
    if (readError) throw new BoxError(BoxError.FS_ERROR, `Could not read dhparams: ${readError.message}`);
    const [copyError] = await safe(fs.promises.writeFile(dhparamsFilePath, dhparams));
    if (copyError) throw new BoxError(BoxError.FS_ERROR, `Could not copy dhparams: ${copyError.message}`);
    if (!safe.fs.writeFileSync(mailCertFilePath, certificate.cert)) throw new BoxError(BoxError.FS_ERROR, `Could not create cert file: ${safe.error.message}`);
    if (!safe.fs.writeFileSync(mailKeyFilePath, certificate.key)) throw new BoxError(BoxError.FS_ERROR, `Could not create key file: ${safe.error.message}`);

    // if the 'yellowtent' user of OS and the 'cloudron' user of mail container don't match, the keys become inaccessible by mail code
    if (!safe.fs.chmodSync(mailKeyFilePath, 0o644)) throw new BoxError(BoxError.FS_ERROR, `Could not chmod key file: ${safe.error.message}`);

    log('configureMail: stopping and deleting previous mail container');
    await docker.stopContainer('mail');
    await docker.deleteContainer('mail');

    const allowInbound = await createMailConfig(mailFqdn);

    const ports = allowInbound ? '-p 587:2587 -p 993:9993 -p 4190:4190 -p 25:2587 -p 465:2465 -p 995:9995' : '';
    const readOnly = !serviceConfig.recoveryMode ? '--read-only' : '';
    const cmd = serviceConfig.recoveryMode ? '/bin/bash -c \'echo "Debug mode. Sleeping" && sleep infinity\'' : '';
    const logLevel = serviceConfig.recoveryMode ? 'data' : 'info';

    const runCmd = `docker run --restart=unless-stopped -d --name=mail \
                --net cloudron \
                --net-alias mail \
                --log-driver syslog \
                --log-opt syslog-address=unix://${paths.SYSLOG_SOCKET_FILE} \
                --log-opt syslog-format=rfc5424 \
                --log-opt tag=mail \
                -m ${memoryLimit} \
                --memory-swap -1 \
                --dns 172.18.0.1 \
                --dns-search=. \
                --ip ${constants.MAIL_SERVICE_IPv4} \
                -e CLOUDRON_MAIL_TOKEN=${cloudronToken} \
                -e CLOUDRON_RELAY_TOKEN=${relayToken} \
                -e LOGLEVEL=${logLevel} \
                -v ${paths.MAIL_DATA_DIR}:/app/data \
                -v ${paths.MAIL_CONFIG_DIR}:/etc/mail:ro \
                ${ports} \
                --label isCloudronManaged=true \
                ${readOnly} -v /run -v /tmp ${image} ${cmd}`;

    log('configureMail: starting mail container');
    await shell.bash(runCmd, { encoding: 'utf8' });
}

async function getLocation() {
    const subdomain = await settings.get(settings.MAIL_SUBDOMAIN_KEY);
    const domain = await settings.get(settings.MAIL_DOMAIN_KEY);

    return new Location(subdomain, domain, Location.TYPE_MAIL);
}

async function restart() {
    if (constants.TEST && !process.env.TEST_CREATE_INFRA) return;

    const mailConfig = await services.getServiceConfig('mail');
    const { domain, fqdn } = await getLocation();
    log(`restart: restarting mail container with mailFqdn:${fqdn} mailDomain:${domain}`);

    // NOTE: the email container has to be re-created. this is because some of the settings like solr config rely on starting with a clean /run state
    await locks.wait(locks.TYPE_MAIL_SERVER_RESTART);
    const [error] = await safe(configureMail(fqdn, domain, mailConfig));
    await locks.release(locks.TYPE_MAIL_SERVER_RESTART);
    if (error) throw error;
}

async function start(existingInfra) {
    assert.strictEqual(typeof existingInfra, 'object');

    log('startMail: starting');
    await restart();

    if (existingInfra.version !== 'none' && existingInfra.images.mail !== infra.images.mail) await docker.deleteImage(existingInfra.images.mail);
}

async function restartIfActivated() {
    const activated = await users.isActivated();

    if (!activated) {
        log('restartIfActivated: skipping restart of mail container since Cloudron is not activated yet');
        return; // not provisioned yet, do not restart container after dns setup
    }

    log('restartIfActivated: restarting on activated');
    await restart();
}

async function onDomainAdded(domain) {
    assert.strictEqual(typeof domain, 'string');

    const { fqdn } = await getLocation();
    if (!fqdn) return; // mail domain is not set yet (when provisioning)

    log(`onDomainAdded: configuring mail for added domain ${domain}`);
    await mail.upsertDnsRecords(domain, fqdn);
    await restartIfActivated();
}

async function onDomainRemoved(domain) {
    assert.strictEqual(typeof domain, 'string');

    log(`onDomainRemoved: configuring mail for removed domain ${domain}`);
    await restart();
}

async function checkCertificate() {
    const certificate = await reverseProxy.getMailCertificate();
    const cert = safe.fs.readFileSync(`${paths.MAIL_CONFIG_DIR}/tls_cert.pem`, { encoding: 'utf8' });
    if (cert === certificate.cert) {
        log('checkCertificate: certificate has not changed');
        return;
    }
    log('checkCertificate: certificate has changed');

    await restartIfActivated();
}

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

    const location = await getLocation();
    const fqdn = dns.fqdn(location.subdomain, location.domain);

    progressCallback({ percent: 10, message: `Registering DNS for ${fqdn}` });
    await dns.registerLocations([location], { overwriteDns: true }, progressCallback);

    progressCallback({ percent: 25, message: `Waiting for DNS propagation of ${fqdn}` });
    await dns.waitForLocations([location], progressCallback);

    progressCallback({ percent: 40, message: `Getting certificate for ${fqdn}` });
    await reverseProxy.ensureCertificate(location, {}, auditSource);

    const allDomains = await domains.list();
    let progress = 45;
    for (let idx = 0; idx < allDomains.length; idx++) {
        const domainObject = allDomains[idx];
        progressCallback({ percent: progress, message: `Updating DNS of ${domainObject.domain}` });
        progress = Math.min(progress + Math.round(40 / allDomains.length), 88);

        const [error] = await safe(mail.upsertDnsRecords(domainObject.domain, fqdn)); // ignore any errors. we anyway report dns errors in status tab
        progressCallback({ percent: progress, message: `Updated DNS of ${domainObject.domain}: ${error ? error.message : 'success'}` });
    }

    progressCallback({ percent: 90, message: 'Restarting mail server' });
    await restartIfActivated();
}

async function setLocation(subdomain, domain) {
    assert.strictEqual(typeof subdomain, 'string');
    assert.strictEqual(typeof domain, 'string');

    await settings.set(settings.MAIL_SUBDOMAIN_KEY, subdomain);
    await settings.set(settings.MAIL_DOMAIN_KEY, domain);
}

async function startChangeLocation(subdomain, domain, auditSource) {
    assert.strictEqual(typeof subdomain, 'string');
    assert.strictEqual(typeof domain, 'string');
    assert.strictEqual(typeof auditSource, 'object');

    const domainObjectMap = await domains.getDomainObjectMap();
    if (!(domain in domainObjectMap)) throw new BoxError(BoxError.BAD_FIELD, `No such domain '${domain}'`);
    const error = dns.validateHostname(subdomain, domain);
    if (error) throw new BoxError(BoxError.BAD_FIELD, `Bad mail location: ${error.message}`);

    await setLocation(subdomain, domain);

    const taskId = await tasks.add(tasks.TASK_CHANGE_MAIL_LOCATION, [ auditSource ]);
    // background
    tasks.startTask(taskId, {})
        .then(async () => {
            await platform.onMailServerLocationChanged(auditSource);
        })
        .catch((taskError) => log(`startChangeLocation`, taskError));

    await eventlog.add(eventlog.ACTION_MAIL_LOCATION, auditSource, { subdomain, domain, taskId });
    return taskId;
}

async function getMailAuth() {
    const data = await docker.inspect('mail');
    const ip = safe.query(data, 'NetworkSettings.Networks.cloudron.IPAddress');
    if (!ip) throw new BoxError(BoxError.MAIL_ERROR, 'Error querying mail server IP');

    // extract the relay token for auth
    const env = safe.query(data, 'Config.Env', null);
    if (!env) throw new BoxError(BoxError.MAIL_ERROR, 'Error getting mail env');
    const tmp = env.find(function (e) { return e.indexOf('CLOUDRON_RELAY_TOKEN') === 0; });
    if (!tmp) throw new BoxError(BoxError.MAIL_ERROR, 'Error getting CLOUDRON_RELAY_TOKEN env var');
    const relayToken = tmp.slice('CLOUDRON_RELAY_TOKEN'.length + 1); // +1 for the = sign
    if (!relayToken) throw new BoxError(BoxError.MAIL_ERROR, 'Error parsing CLOUDRON_RELAY_TOKEN');

    return {
        ip,
        port: constants.INTERNAL_SMTP_PORT,
        relayToken
    };
}

export default {
    restart,
    start,
    onDomainAdded,
    onDomainRemoved,
    checkCertificate,
    getMailAuth,
    getLocation,
    startChangeLocation,
    changeLocation,
    setLocation,
    DEFAULT_MEMORY_LIMIT,
};
