import acme2 from './acme2.js';
import apps from './apps.js';
import assert from 'node:assert';
import blobs from './blobs.js';
import BoxError from './boxerror.js';
import constants from './constants.js';
import dashboard from './dashboard.js';
import logger from './logger.js';
import dns from './dns.js';
import docker from './docker.js';
import domains from './domains.js';
import ejs from 'ejs';
import eventlog from './eventlog.js';
import ipaddr from './ipaddr.js';
import fs from 'node:fs';
import Location from './location.js';
import mailServer from './mailserver.js';
import network from './network.js';
import openssl from './openssl.js';
import path from 'node:path';
import paths from './paths.js';
import safe from 'safetydance';
import settings from './settings.js';
import shellModule from './shell.js';
import tasks from './tasks.js';

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

const NGINX_APPCONFIG_EJS = fs.readFileSync(import.meta.dirname + '/nginxconfig.ejs', { encoding: 'utf8' });
const RESTART_SERVICE_CMD = path.join(import.meta.dirname, 'scripts/restartservice.sh');

function nginxLocation(s) {
    if (!s.startsWith('!')) return s;

    const re = s.replace(/[\^$\\.*+?()[\]{}|]/g, '\\$&'); // https://github.com/es-shims/regexp.escape/blob/master/implementation.js
    return `~ ^(?!(${re.slice(1)}))`; // negative regex assertion - https://stackoverflow.com/questions/16302897/nginx-location-not-equal-to-regex
}

// checks if the certificate matches the options provided by user (like wildcard, le-staging etc)
async function providerMatches(domainObject, cert) {
    assert.strictEqual(typeof domainObject, 'object');
    assert.strictEqual(typeof cert, 'string');

    const [error, subjectAndIssuer] = await safe(openssl.getSubjectAndIssuer(cert));
    if (error) return false; // something bad happenned

    const { subject, issuer } = subjectAndIssuer;
    const domain = subject.substr(subject.indexOf('=') + 1).trim(); // subject can be /CN=, CN=, CN = and other forms
    const isWildcardCert = domain.includes('*');
    const isLetsEncryptProd = issuer.includes('Let\'s Encrypt') && !issuer.includes('STAGING');

    const prod = domainObject.tlsConfig.provider.match(/.*-prod/) !== null; // matches 'le-prod' or 'letsencrypt-prod'
    const wildcard = !!domainObject.tlsConfig.wildcard;

    const issuerMismatch = (prod && !isLetsEncryptProd) || (!prod && isLetsEncryptProd);
    // bare domain is not part of wildcard SAN
    const wildcardMismatch = (domain !== domainObject.domain) && (wildcard && !isWildcardCert) || (!wildcard && isWildcardCert);

    const mismatch = issuerMismatch || wildcardMismatch;

    log(`providerMatches: subject=${subject} domain=${domain} issuer=${issuer} `
        + `wildcard=${isWildcardCert}/${wildcard} prod=${isLetsEncryptProd}/${prod} `
        + `issuerMismatch=${issuerMismatch} wildcardMismatch=${wildcardMismatch} match=${!mismatch}`);

    return !mismatch;
}

async function reload() {
    if (constants.TEST) return;

    const [error] = await safe(shell.sudo([ RESTART_SERVICE_CMD, 'nginx' ], {}));
    if (error) throw new BoxError(BoxError.NGINX_ERROR, `Error reloading nginx: ${error.message}`);
}

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

    return [new Location(app.subdomain, app.domain, Location.TYPE_PRIMARY, app.certificate)]
        .concat(app.secondaryDomains.map(sd => new Location(sd.subdomain, sd.domain, Location.TYPE_SECONDARY, sd.certificate)))
        .concat(app.redirectDomains.map(rd => new Location(rd.subdomain, rd.domain, Location.TYPE_REDIRECT, rd.certificate)))
        .concat(app.aliasDomains.map(ad => new Location(ad.subdomain, ad.domain, Location.TYPE_ALIAS, ad.certificate)));
}

function getAcmeCertificateNameSync(fqdn, domainObject) {
    assert.strictEqual(typeof fqdn, 'string'); // this can contain wildcard domain (for alias domains)
    assert.strictEqual(typeof domainObject, 'object');

    if (fqdn !== domainObject.domain && domainObject.tlsConfig.wildcard) { // bare domain is not part of wildcard SAN
        return dns.makeWildcard(fqdn).replace('*.', '_.');
    } else if (fqdn.includes('*')) { // alias domain with non-wildcard cert
        return fqdn.replace('*.', '_.');
    } else {
        return fqdn;
    }
}

async function needsRenewal(cert, renewalInfo, options) {
    assert.strictEqual(typeof cert, 'string');
    assert.strictEqual(typeof renewalInfo, 'object');
    assert.strictEqual(typeof options, 'object');

    const { startDate, endDate } = await openssl.getCertificateDates(cert);
    const now = new Date();

    // if we have ARI respect it. this allows us to be expempt from rate limit checks when "replaces" field is set
    // https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients#step-6-indicating-which-certificate-is-replaced-by-this-new-order
    if (renewalInfo) {
        const rt = new Date(renewalInfo.rt);
        let renew = false;
        if (now.getTime() >= rt.getTime()) renew = true; // renew immediately since now is in the past
        else if ((now.getTime() + (24*60*60*1000)) >= rt.getTime()) renew = true; // next cron run will be in the past
        log(`needsRenewal: ${renew}. ARI ${JSON.stringify(renewalInfo)}`);
       return renew; // can wait
    }

    // for CAs without ARI, fallback to 1 month
    let isExpiring;
    if (options.forceRenewal) {
        isExpiring = (now - startDate) > (65 * 60 * 1000); // was renewed 5 minutes ago. LE backdates issue date by 1 hour for clock skew
    } else {
        isExpiring = (endDate - now) <= (30 * 24 * 60 * 60 * 1000); // expiring in a month
    }

    log(`needsRenewal: ${isExpiring}. force: ${!!options.forceRenewal}`);
    return isExpiring;
}

async function getCertificate(location) {
    assert.strictEqual(typeof location, 'object');

    const domainObject = await domains.get(location.domain);
    if (!domainObject) throw new BoxError(BoxError.NOT_FOUND, `${location.domain} not found`);

    if (location.certificate) return location.certificate;
    if (domainObject.tlsConfig.provider === 'fallback') return domainObject.fallbackCertificate;

    const certName = getAcmeCertificateNameSync(location.fqdn, domainObject);
    const cert = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.cert`);
    const key = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.key`);
    if (!key || !cert) return domainObject.fallbackCertificate;

    return { key, cert };
}

async function getMailCertificate() {
    const mailLocation = await mailServer.getLocation();
    return await getCertificate(mailLocation);
}

async function getDirectoryServerCertificate() {
    const dashboardLocation = await dashboard.getLocation();
    return await getCertificate(dashboardLocation);
}

function writeFileSync(filePath, data) {
    assert.strictEqual(typeof filePath, 'string');
    assert.strictEqual(typeof data, 'string');

    const curData = safe.fs.readFileSync(filePath, { encoding: 'utf8' });
    if (curData === data) return false;
    if (!safe.fs.writeFileSync(filePath, data)) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
    return true;
}

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

    const certificateDir = `${paths.PLATFORM_DATA_DIR}/tls/${app.id}`;
    const contents = [];
    for (const location of getAppLocationsSync(app)) {
        if (location.type === Location.TYPE_REDIRECT) continue;

        const certificate = await getCertificate(location);
        contents.push({ filename: `${location.fqdn.replace('*', '_')}.cert`, data: certificate.cert });
        contents.push({ filename: `${location.fqdn.replace('*', '_')}.key`, data: certificate.key });

        if (location.type === Location.TYPE_PRIMARY) { // backward compat
            contents.push({ filename: 'tls_cert.pem', data: certificate.cert });
            contents.push({ filename: 'tls_key.pem', data: certificate.key });
        }
    }

    let changed = 0;
    for (const content of contents) {
        if (writeFileSync(`${certificateDir}/${content.filename}`, content.data)) ++changed;
    }
    log(`setupTlsAddon: ${changed} files changed`);

    // clean up any certs of old locations
    const filenamesInUse = new Set(contents.map(c => c.filename));
    const filenames = safe.fs.readdirSync(certificateDir) || [];
    let removed = 0;
    for (const filename of filenames) {
        if (filenamesInUse.has(filename)) continue;
        safe.fs.unlinkSync(path.join(certificateDir, filename));
        ++removed;
    }
    log(`setupTlsAddon: ${removed} files removed`);

    if (changed || removed) await docker.restartContainer(app.id);
}

// write if contents mismatch (thus preserving mtime)
async function notifyCertChange() {
    await mailServer.checkCertificate();
    await shell.sudo([ RESTART_SERVICE_CMD, 'box' ], {}); // directory server
    const allApps = (await apps.list()).filter(app => app.runState !== apps.RSTATE_STOPPED);
    for (const app of allApps) {
        if (app.manifest.addons?.tls) await setupTlsAddon(app);
    }
}

async function setFallbackCertificate(domain, certificate) {
    assert.strictEqual(typeof domain, 'string');
    assert(certificate && typeof certificate === 'object');

    log(`setFallbackCertificate: setting certs for domain ${domain}`);
    if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`), certificate.cert)) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
    if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`), certificate.key)) throw new BoxError(BoxError.FS_ERROR, safe.error.message);

    await reload();
    await notifyCertChange(); // if domain uses fallback certs, propagate immediately
}

// writes latest certificate to disk and returns the path
async function writeCertificate(location) {
    assert.strictEqual(typeof location, 'object');

    const { domain, fqdn } = location;
    const domainObject = await domains.get(domain);
    if (!domainObject) throw new BoxError(BoxError.NOT_FOUND, `${domain} not found`);

    if (location.certificate) {
        const certFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.user.cert`);
        const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.user.key`);

        writeFileSync(certFilePath, location.certificate.cert);
        writeFileSync(keyFilePath, location.certificate.key);

        return { certFilePath, keyFilePath };
    }

    if (domainObject.tlsConfig.provider === 'fallback') {
        const certFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`);
        const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`);

        log(`writeCertificate: ${fqdn} will use fallback certs`);
        writeFileSync(certFilePath, domainObject.fallbackCertificate.cert);
        writeFileSync(keyFilePath, domainObject.fallbackCertificate.key);

        return { certFilePath, keyFilePath };
    }

    const certName = getAcmeCertificateNameSync(fqdn, domainObject);
    let cert = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.cert`);
    let key = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.key`);

    if (!key || !cert) { // use fallback certs if we didn't manage to get acme certs
        log(`writeCertificate: ${fqdn} will use fallback certs because acme is missing`);
        cert = domainObject.fallbackCertificate.cert;
        key = domainObject.fallbackCertificate.key;
    }
    const certFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.cert`);
    const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.key`);

    writeFileSync(certFilePath, cert);
    writeFileSync(keyFilePath, key);

    return { certFilePath, keyFilePath };
}

async function getKey(certName) {
    assert.strictEqual(typeof certName, 'string');

    const key = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.key`);
    if (key) return key;

    log(`ensureKey: generating new key for ${certName}`);
    // secp384r1 is same as prime256v1. openssl ecparam -list_curves. we used to use secp384r1 but it doesn't seem to be accepted by few mail servers
    return await openssl.generateKey('secp256r1');
};

async function getRenewalInfo(cert, certName) {
    const renewalInfo = JSON.parse(await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.renewal`));
    if (!renewalInfo) return null;

    if (Date.now() < (new Date(renewalInfo.valid)).getTime()) return renewalInfo; // still valid

    log(`getRenewalInfo: ${certName} refreshing`);
    const [error, result] = await safe(acme2.getRenewalInfo(cert, renewalInfo.url));
    if (error) {
        log(`getRenewalInfo: ${certName} error getting renewal info`, error);
        await blobs.del(`${blobs.CERT_PREFIX}-${certName}.renewal`);
    } else {
        log(`getRenewalInfo: ${certName} updated: ${JSON.stringify(result)}`);
        await blobs.setString(`${blobs.CERT_PREFIX}-${certName}.renewal`, JSON.stringify(result));
    }

    return error ? null : result;
}

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

    const domainObject = await domains.get(location.domain);
    if (!domainObject) throw new BoxError(BoxError.NOT_FOUND, `${location.domain} not found`);

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

    if (location.certificate) { // user certificate
        log(`ensureCertificate: ${fqdn} will use user certs`);
        return;
    }

    if (domainObject.tlsConfig.provider === 'fallback') {
        log(`ensureCertificate: ${fqdn} will use fallback certs`);
        return;
    }

    const certName = getAcmeCertificateNameSync(fqdn, domainObject);
    const key = await getKey(certName); // generates one on the fly. we only save the key in db if we end up using it
    const cert = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.cert`);

    if (key && cert) {
        const sameProvider = await providerMatches(domainObject, cert);
        const renewalInfo = await getRenewalInfo(cert, certName);
        const outdated = await needsRenewal(cert, renewalInfo, options);

        if (sameProvider && !outdated) {
            log(`ensureCertificate: ${fqdn} acme cert exists and is up to date`);
            return;
        }
        log(`ensureCertificate: ${fqdn} acme cert exists but provider mismatch or needs renewal`);
    }

    log(`ensureCertificate: ${fqdn} needs acme cert`);
    const [error, result] = await safe(acme2.getCertificate(fqdn, domainObject, key));

    if (!error) {
        await blobs.setString(`${blobs.CERT_PREFIX}-${certName}.key`, key);
        await blobs.setString(`${blobs.CERT_PREFIX}-${certName}.cert`, result.cert);
        await blobs.setString(`${blobs.CERT_PREFIX}-${certName}.csr`, result.csr);
        await blobs.setString(`${blobs.CERT_PREFIX}-${certName}.renewal`, JSON.stringify(result.renewalInfo));
    }

    log(`ensureCertificate: error: ${error?.message || 'null'}`);

    await safe(eventlog.add(eventlog.ACTION_CERTIFICATE_NEW, auditSource, { domain: fqdn, errorMessage: error?.message || '', renewalInfo: result?.renewalInfo || null }));
}

async function writeDashboardNginxConfig(vhost, certificatePath) {
    assert.strictEqual(typeof vhost, 'string');
    assert.strictEqual(typeof certificatePath, 'object');

    const data = {
        sourceDir: path.resolve(import.meta.dirname, '..'),
        vhost,
        hasIPv6: network.hasIPv6(),
        endpoint: 'dashboard',
        certFilePath: certificatePath.certFilePath,
        keyFilePath: certificatePath.keyFilePath,
        robotsTxtQuoted: JSON.stringify('User-agent: *\nDisallow: /\n'),
        proxyAuth: { enabled: false, id: null, location: nginxLocation('/') },
        hstsPreload: false
    };
    const nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data);
    const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `dashboard/${vhost}.conf`);

    writeFileSync(nginxConfigFilename, nginxConf);
}

// also syncs the certs to disk
async function writeDashboardConfig(subdomain, domain) {
    assert.strictEqual(typeof subdomain, 'string');
    assert.strictEqual(typeof domain, 'string');

    log(`writeDashboardConfig: writing dashboard config for ${domain}`);

    const dashboardFqdn = dns.fqdn(subdomain, domain);
    const location = { domain, fqdn: dashboardFqdn, certificate: null };
    const certificatePath = await writeCertificate(location);
    await writeDashboardNginxConfig(dashboardFqdn, certificatePath);
    await reload();
}

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

    log(`removeDashboardConfig: removing dashboard config of ${domain}`);

    const vhost = dns.fqdn(subdomain, domain);
    const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `dashboard/${vhost}.conf`);

    if (!safe.fs.unlinkSync(nginxConfigFilename)) throw new BoxError(BoxError.FS_ERROR, safe.error.message);

    await reload();
}

function normalizeCSP(csp) {
    const lines = csp.split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('#'));
    const statements = lines.map(line => line.endsWith(';') ? line : `${line};`); // semicolon terminate all lines
    return statements.join(' ').replace(/\s+/g, ' ').trim(); // merge into single line
}

async function writeAppLocationNginxConfig(app, location, certificatePath) {
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof location, 'object');
    assert.strictEqual(typeof certificatePath, 'object');

    const { type, fqdn} = location;

    const data = {
        sourceDir: path.resolve(import.meta.dirname, '..'),
        vhost: fqdn,
        hasIPv6: network.hasIPv6(),
        ip: null,
        port: null,
        endpoint: null,
        redirectTo: null,
        certFilePath: certificatePath.certFilePath,
        keyFilePath: certificatePath.keyFilePath,
        robotsTxtQuoted: null,
        cspQuoted: null,
        hideHeaders: [],
        proxyAuth: { enabled: false },
        upstreamUri: '', // only for endpoint === external
        hstsPreload: !!app.reverseProxyConfig?.hstsPreload
    };

    if (type === Location.TYPE_PRIMARY || type === Location.TYPE_ALIAS || type === Location.TYPE_SECONDARY) {
        data.endpoint = 'app';

        if (app.manifest.id === constants.PROXY_APP_APPSTORE_ID) {
            data.endpoint = 'external';
            data.upstreamUri = app.upstreamUri;
        }

        // maybe these should become per domain at some point
        const reverseProxyConfig = app.reverseProxyConfig || {}; // some of our code uses fake app objects
        if (reverseProxyConfig.robotsTxt) data.robotsTxtQuoted = JSON.stringify(app.reverseProxyConfig.robotsTxt);
        if (reverseProxyConfig.csp) {
            data.cspQuoted = `"${normalizeCSP(app.reverseProxyConfig.csp)}"`;
            data.hideHeaders = [ 'Content-Security-Policy' ];
            if (reverseProxyConfig.csp.includes('frame-ancestors ')) data.hideHeaders.push('X-Frame-Options');
        }
        if (type === Location.TYPE_PRIMARY || type == Location.TYPE_ALIAS) {
            data.proxyAuth = {
                enabled: app.sso && app.manifest.addons && app.manifest.addons.proxyAuth,
                id: app.id,
                location: nginxLocation(safe.query(app.manifest, 'addons.proxyAuth.path') || '/')
            };
            data.ip = app.containerIp;
            data.port = app.manifest.httpPort;

            if (data.proxyAuth.enabled) {
                data.proxyAuth.oidcClientId = app.id;
                data.proxyAuth.oidcEndpoint = (await dashboard.getLocation()).fqdn;
            }
        } else if (type === Location.TYPE_SECONDARY) {
            data.ip = app.containerIp;
            const secondaryDomain = app.secondaryDomains.find(sd => sd.fqdn === fqdn);
            data.port = app.manifest.httpPorts[secondaryDomain.environmentVariable].containerPort;
        }
    } else if (type === Location.TYPE_REDIRECT) {
        data.proxyAuth = { enabled: false, id: app.id, location: nginxLocation('/') };
        data.endpoint = 'redirect';
        data.redirectTo = app.fqdn;
    }

    const nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data);
    const filename = path.join(paths.NGINX_APPCONFIG_DIR, app.id, `${fqdn.replace('*', '_')}.conf`);
    log(`writeAppLocationNginxConfig: writing config for "${fqdn}" to ${filename} with options ${JSON.stringify(data)}`);
    writeFileSync(filename, nginxConf);
}

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

    const locations = getAppLocationsSync(app);

    if (!safe.fs.mkdirSync(path.join(paths.NGINX_APPCONFIG_DIR, app.id), { recursive: true })) throw new BoxError(BoxError.FS_ERROR, `Could not create nginx config directory: ${safe.error.message}`);

    for (const location of locations) {
        const certificatePath = await writeCertificate(location);
        await writeAppLocationNginxConfig(app, location, certificatePath);
    }

    await reload();
}

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

    const certificatePath = await writeCertificate(location);
    await writeAppLocationNginxConfig(app, location, certificatePath);
    await reload();
}

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

    const locations = getAppLocationsSync(app);

    for (const location of locations) {
        await ensureCertificate(location, {}, auditSource);
    }

    await writeAppConfigs(app);

    if (app.manifest.addons?.tls) await setupTlsAddon(app);
}

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

    if (!safe.fs.rmSync(path.join(paths.NGINX_APPCONFIG_DIR, app.id), { recursive: true, force: true })) throw new BoxError(BoxError.FS_ERROR, `Could not remove nginx config directory: ${safe.error.message}`);
    await reload();
}

async function cleanupCerts(locations, auditSource, progressCallback) {
    assert(Array.isArray(locations));
    assert.strictEqual(typeof auditSource, 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    progressCallback({ message: 'Checking expired certs for removal' });

    const domainObjectMap = await domains.getDomainObjectMap();
    const certNamesInUse = new Set();
    for (const location of locations) {
        certNamesInUse.add(await getAcmeCertificateNameSync(location.fqdn, domainObjectMap[location.domain]));
    }

    const now = new Date();
    const certIds = await blobs.listCertIds();
    const removedCertNames = [];
    for (const certId of certIds) {
        const certName = certId.match(new RegExp(`${blobs.CERT_PREFIX}-(.*).cert`))[1];
        if (certNamesInUse.has(certName)) continue;

        const cert = await blobs.getString(certId);
        const { endDate } = await openssl.getCertificateDates(cert);
        if (!endDate) continue; // some error

        if (now - endDate >= (60 * 60 * 24 * 30 * 6 * 1000)) { // expired 6 months ago and not in use
            progressCallback({ message: `deleting certs of ${certName}` });

            // it is safe to delete the certs of stopped apps because their nginx configs are removed
            safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${certName}.cert`));
            safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${certName}.key`));
            safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${certName}.csr`));

            await blobs.del(`${blobs.CERT_PREFIX}-${certName}.key`);
            await blobs.del(`${blobs.CERT_PREFIX}-${certName}.cert`);
            await blobs.del(`${blobs.CERT_PREFIX}-${certName}.csr`);
            await blobs.del(`${blobs.CERT_PREFIX}-${certName}.renewal`);

            removedCertNames.push(certName);
        }
    }

    if (removedCertNames.length) await safe(eventlog.add(eventlog.ACTION_CERTIFICATE_CLEANUP, auditSource, { domains: removedCertNames }));

    log('cleanupCerts: done');
}

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

    let locations = [];
    const dashboardLocation = await dashboard.getLocation();
    locations.push(dashboardLocation);

    const mailLocation = await mailServer.getLocation();
    if (dashboardLocation.fqdn !== mailLocation.fqdn) locations.push(mailLocation);

    const allApps = (await apps.list()).filter(app => app.runState !== apps.RSTATE_STOPPED);
    for (const app of allApps) {
        locations = locations.concat(getAppLocationsSync(app));
    }

    let percent = 1;
    for (const location of locations) {
        percent = Math.min(percent + Math.round(80 / locations.length), 85);
        progressCallback({ percent, message: `Ensuring certs of ${location.fqdn}` });
        await ensureCertificate(location, options, auditSource);
    }

    if (options.rebuild || fs.existsSync(paths.REVERSE_PROXY_REBUILD_FILE)) {
        progressCallback({ percent: 90, message: 'Rebuilding app configs' });
        for (const app of allApps) {
            await writeAppConfigs(app);
        }
        await writeDashboardConfig(dashboardLocation.subdomain, dashboardLocation.domain);
        await notifyCertChange(); // this allows user to "rebuild" using UI just in case we crashed and went out of sync
        safe.fs.unlinkSync(paths.REVERSE_PROXY_REBUILD_FILE);
    } else {
        progressCallback({ percent: 90, message: 'Syncing certificates' });
        // sync all locations and not just the ones that changed. this helps with 0 length certs when disk is full and also
        // if renewal task crashed midway.
        for (const location of locations) {
            await writeCertificate(location);
        }
        await reload();
        await notifyCertChange(); // propagate any cert changes to services
    }

    progressCallback({ percent: 95, message: 'Cleaning up expired certs' });
    await cleanupCerts(locations, auditSource, progressCallback);
}

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

    const taskId = await tasks.add(tasks.TASK_CHECK_CERTS, [ options, auditSource ]);
    safe(tasks.startTask(taskId, {}), { debug: log }); // background
    return taskId;
}

function removeAppConfigs() {
    log('removeAppConfigs: removing app nginx configs');

    // remove all configs which are not the default or current dashboard
    for (const entry of fs.readdirSync(paths.NGINX_APPCONFIG_DIR, { withFileTypes: true })) {
        if (entry.isDirectory() && entry.name === 'dashboard') continue;
        if (entry.isFile() && entry.name === constants.NGINX_DEFAULT_CONFIG_FILE_NAME) continue;

        const fullPath = path.join(paths.NGINX_APPCONFIG_DIR, entry.name);
        if (entry.isDirectory()) {
            fs.rmSync(fullPath, { recursive: true, force: true });
        } else if (entry.isFile()) {
            fs.unlinkSync(fullPath);
        }
    }
}

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

    const certFilePath = path.join(paths.NGINX_CERT_DIR,  'default.cert');
    const keyFilePath = path.join(paths.NGINX_CERT_DIR, 'default.key');

    if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) {
        log('writeDefaultConfig: create new cert');

        const cn = 'cloudron-' + (new Date()).toISOString(); // randomize date a bit to keep firefox happy

        const { cert, key } = await openssl.generateCertificate(cn);
        if (!safe.fs.writeFileSync(keyFilePath, key)) throw new BoxError(BoxError.FS_ERROR, safe.error);
        if (!safe.fs.writeFileSync(certFilePath, cert)) throw new BoxError(BoxError.FS_ERROR, safe.error);
    }

    const data = {
        sourceDir: path.resolve(import.meta.dirname, '..'),
        vhost: '',
        hasIPv6: network.hasIPv6(),
        endpoint: options.activated ? 'ip' : 'setup',
        certFilePath,
        keyFilePath,
        robotsTxtQuoted: JSON.stringify('User-agent: *\nDisallow: /\n'),
        proxyAuth: { enabled: false, id: null, location: nginxLocation('/') },
        hstsPreload: false
    };
    const nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data);
    const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, constants.NGINX_DEFAULT_CONFIG_FILE_NAME);

    log(`writeDefaultConfig: writing configs for endpoint "${data.endpoint}"`);

    if (!safe.fs.writeFileSync(nginxConfigFilename, nginxConf)) throw new BoxError(BoxError.FS_ERROR, safe.error);

    await reload();
}

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

    safe.fs.appendFileSync(paths.REVERSE_PROXY_REBUILD_FILE, `${domain}\n`, 'utf8');
}

async function getTrustedIps() {
    const value = await settings.get(settings.TRUSTED_IPS_KEY);
    return value || '';
}

async function setTrustedIps(trustedIps) {
    assert.strictEqual(typeof trustedIps, 'string');

    let trustedIpsConfig = 'real_ip_header X-Forwarded-For;\nreal_ip_recursive on;\n';

    for (const line of trustedIps.split('\n')) {
        if (!line || line.startsWith('#')) continue;
        const rangeOrIP = line.trim();
        if (!ipaddr.isValid(rangeOrIP) && !ipaddr.isValidCIDR(rangeOrIP)) throw new BoxError(BoxError.BAD_FIELD, `'${rangeOrIP}' is not a valid IP or range`);
        trustedIpsConfig += `set_real_ip_from ${rangeOrIP};\n`;
    }

    await settings.set(settings.TRUSTED_IPS_KEY, trustedIps);
    if (!safe.fs.writeFileSync(paths.NGINX_TRUSTED_IPS_FILE, trustedIpsConfig, 'utf8')) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
    await reload();
}

async function reprovision() {
    log('reprovision: restoring fallback certs and trusted ips');

    const result = await domains.list();

    for (const domain of result) {
        if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, `${domain.domain}.host.cert`), domain.fallbackCertificate.cert)) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
        if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, `${domain.domain}.host.key`), domain.fallbackCertificate.key)) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
    }

    await setTrustedIps(await getTrustedIps());
}

export default {
    setUserCertificate, // per location certificate
    setFallbackCertificate, // per domain certificate

    getMailCertificate,
    getDirectoryServerCertificate,

    ensureCertificate,

    startRenewCerts,
    checkCerts,

    // the 'configure' functions ensure a certificate and generate nginx config
    configureApp,
    unconfigureApp,

    // these only generate nginx config
    writeDefaultConfig,
    writeDashboardConfig,
    writeAppConfigs,

    removeDashboardConfig,
    removeAppConfigs,
    reprovision,

    handleCertificateProviderChanged,

    getTrustedIps,
    setTrustedIps
};
