import assert from 'node:assert';
import BoxError from './boxerror.js';
import constants from './constants.js';
import crypto from 'node:crypto';
import dashboard from './dashboard.js';
import database from './database.js';
import logger from './logger.js';
import eventlog from './eventlog.js';
import mailServer from './mailserver.js';
import notifications from './notifications.js';
import openssl from './openssl.js';
import reverseProxy from './reverseproxy.js';
import safe from 'safetydance';
import tld from 'tldjs';
import _ from './underscore.js';
import dnsBunny from './dns/bunny.js';
import dnsCloudflare from './dns/cloudflare.js';
import dnsDesec from './dns/desec.js';
import dnsDnsimple from './dns/dnsimple.js';
import dnsRoute53 from './dns/route53.js';
import dnsGcdns from './dns/gcdns.js';
import dnsDigitalocean from './dns/digitalocean.js';
import dnsGandi from './dns/gandi.js';
import dnsGodaddy from './dns/godaddy.js';
import dnsHetzner from './dns/hetzner.js';
import dnsHetznercloud from './dns/hetznercloud.js';
import dnsInwx from './dns/inwx.js';
import dnsLinode from './dns/linode.js';
import dnsVultr from './dns/vultr.js';
import dnsNamecom from './dns/namecom.js';
import dnsNamecheap from './dns/namecheap.js';
import dnsNetcup from './dns/netcup.js';
import dnsNoop from './dns/noop.js';
import dnsOvh from './dns/ovh.js';
import dnsManual from './dns/manual.js';
import dnsPorkbun from './dns/porkbun.js';
import dnsWildcard from './dns/wildcard.js';

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


const DOMAINS_FIELDS = [ 'domain', 'zoneName', 'provider', 'configJson', 'tlsConfigJson', 'wellKnownJson', 'fallbackCertificateJson' ].join(',');

function postProcess(data) {
    data.config = safe.JSON.parse(data.configJson);
    delete data.configJson;

    data.tlsConfig = safe.JSON.parse(data.tlsConfigJson);
    delete data.tlsConfigJson;

    data.wellKnown = safe.JSON.parse(data.wellKnownJson);
    delete data.wellKnownJson;

    data.fallbackCertificate = safe.JSON.parse(data.fallbackCertificateJson);
    delete data.fallbackCertificateJson;

    return data;
}

const DNS_PROVIDERS = {
    bunny: dnsBunny, cloudflare: dnsCloudflare, desec: dnsDesec, dnsimple: dnsDnsimple,
    route53: dnsRoute53, gcdns: dnsGcdns, digitalocean: dnsDigitalocean, gandi: dnsGandi,
    godaddy: dnsGodaddy, hetzner: dnsHetzner, hetznercloud: dnsHetznercloud, inwx: dnsInwx,
    linode: dnsLinode, vultr: dnsVultr, namecom: dnsNamecom, namecheap: dnsNamecheap,
    netcup: dnsNetcup, noop: dnsNoop, ovh: dnsOvh, manual: dnsManual,
    porkbun: dnsPorkbun, wildcard: dnsWildcard
};

// choose which subdomain backend we use for test purpose we use route53
function api(provider) {
    assert.strictEqual(typeof provider, 'string');

    return DNS_PROVIDERS[provider] || null;
}

async function verifyDomainConfig(domainConfig, domain, zoneName, provider) {
    assert(domainConfig && typeof domainConfig === 'object'); // the dns config to test with
    assert.strictEqual(typeof domain, 'string');
    assert.strictEqual(typeof zoneName, 'string');
    assert.strictEqual(typeof provider, 'string');

    const backend = api(provider);
    if (!backend) throw new BoxError(BoxError.BAD_FIELD, 'Invalid provider');

    const domainObject = { config: domainConfig, domain, zoneName };
    const [error, sanitizedConfig] = await safe(api(provider).verifyDomainConfig(domainObject));
    if (error && error.reason === BoxError.ACCESS_DENIED) throw new BoxError(BoxError.BAD_FIELD, `Access denied: ${error.message}`);
    if (error && error.reason === BoxError.NOT_FOUND) throw new BoxError(BoxError.BAD_FIELD, `Zone not found: ${error.message}`);
    if (error && error.reason === BoxError.EXTERNAL_ERROR) throw new BoxError(BoxError.BAD_FIELD, `Configuration error: ${error.message}`);
    if (error) throw error;

    return sanitizedConfig;
}

function validateTlsConfig(tlsConfig, dnsProvider) {
    assert.strictEqual(typeof tlsConfig, 'object');
    assert.strictEqual(typeof dnsProvider, 'string');

    switch (tlsConfig.provider) {
    case 'letsencrypt-prod':
    case 'letsencrypt-staging':
    case 'fallback':
        break;
    default:
        return new BoxError(BoxError.BAD_FIELD, 'tlsConfig.provider must be fallback, letsencrypt-prod/staging');
    }

    if (tlsConfig.wildcard) {
        if (!tlsConfig.provider.startsWith('letsencrypt')) return new BoxError(BoxError.BAD_FIELD, 'wildcard can only be set with letsencrypt');
        if (dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') return new BoxError(BoxError.BAD_FIELD, 'wildcard cert requires a programmable DNS backend');
    }

    return null;
}

function validateWellKnown(wellKnown) {
    assert.strictEqual(typeof wellKnown, 'object');

    if (wellKnown === null) return null;

    for (const key of Object.keys(wellKnown)) {
        if (typeof wellKnown[key] !== 'string') return new BoxError(BoxError.BAD_FIELD, `well-known value for ${key} must be a string`);
    }

    if (wellKnown.carddav && wellKnown.carddav.includes('://')) return new BoxError(BoxError.BAD_FIELD, 'carddav must be a domain, not a URL');
    if (wellKnown.caldav && wellKnown.caldav.includes('://')) return new BoxError(BoxError.BAD_FIELD, 'caldav must be a domain, not a URL');

    return null;
}

async function add(domain, data, auditSource) {
    assert.strictEqual(typeof domain, 'string');
    assert.strictEqual(typeof data.zoneName, 'string');
    assert.strictEqual(typeof data.provider, 'string');
    assert.strictEqual(typeof data.config, 'object');
    assert.strictEqual(typeof data.fallbackCertificate, 'object');
    assert.strictEqual(typeof data.tlsConfig, 'object');

    const { provider, config, tlsConfig } = data;

    if (!tld.isValid(domain)) throw new BoxError(BoxError.BAD_FIELD, 'Invalid domain');
    if (domain.endsWith('.')) throw new BoxError(BoxError.BAD_FIELD, 'Invalid domain');

    let zoneName;
    if (data.zoneName) {
        if (!tld.isValid(data.zoneName)) throw new BoxError(BoxError.BAD_FIELD, 'Invalid zoneName');
        if (data.zoneName.endsWith('.')) throw new BoxError(BoxError.BAD_FIELD, 'Invalid zoneName');
        zoneName = data.zoneName;
    } else {
        zoneName = tld.getDomain(domain) || domain;
    }

    let fallbackCertificate;
    if (data.fallbackCertificate) {
        await openssl.validateCertificate('test', domain, data.fallbackCertificate);
        fallbackCertificate = data.fallbackCertificate;
    } else {
        fallbackCertificate = await openssl.generateCertificate(domain);
    }

    let error = validateTlsConfig(tlsConfig, provider);
    if (error) throw error;

    const dkimKey = await openssl.generateDkimKey();

    let dkimSelector = data.dkimSelector;
    if (!data.dkimSelector) {
        const { domain:dashboardDomain } = await dashboard.getLocation();
        // create a unique suffix. this lets one add this domain can be added in another cloudron instance and not have their dkim selector conflict
        const suffix = crypto.createHash('sha256').update(dashboardDomain).digest('hex').substr(0, 6);
        dkimSelector = `cloudron-${suffix}`;
    }

    const sanitizedConfig = await verifyDomainConfig(config, domain, zoneName, provider);

    const queries = [
        { query: 'INSERT INTO domains (domain, zoneName, provider, configJson, tlsConfigJson, fallbackCertificateJson) VALUES (?, ?, ?, ?, ?, ?)',
            args: [ domain, zoneName, provider, JSON.stringify(sanitizedConfig), JSON.stringify(tlsConfig), JSON.stringify(fallbackCertificate) ] },
        { query: 'INSERT INTO mail (domain, dkimKeyJson, dkimSelector) VALUES (?, ?, ?)', args: [ domain, JSON.stringify(dkimKey), dkimSelector || 'cloudron' ] },
    ];

    [error] = await safe(database.transaction(queries));
    if (error && error.sqlCode === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, 'Domain already exists');
    if (error) throw new BoxError(BoxError.DATABASE_ERROR, error);

    await reverseProxy.setFallbackCertificate(domain, fallbackCertificate);

    await eventlog.add(eventlog.ACTION_DOMAIN_ADD, auditSource, { domain, zoneName, provider });

    safe(mailServer.onDomainAdded(domain), { debug: log }); // background
}

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

    const result = await database.query(`SELECT ${DOMAINS_FIELDS} FROM domains WHERE domain=?`, [ domain ]);
    if (result.length === 0) return null;
    return postProcess(result[0]);
}

async function list() {
    const results = await database.query(`SELECT ${DOMAINS_FIELDS} FROM domains ORDER BY domain`);

    results.forEach(postProcess);

    return results.sort((d1, d2) => { // domains are alphabetically sorted
        return d1.domain.localeCompare(d2.domain);
    });
}

async function setConfig(domain, data, auditSource) {
    assert.strictEqual(typeof domain, 'string');
    assert.strictEqual(typeof data.zoneName, 'string');
    assert.strictEqual(typeof data.provider, 'string');
    assert.strictEqual(typeof data.config, 'object');
    assert.strictEqual(typeof data.fallbackCertificate, 'object');
    assert.strictEqual(typeof data.tlsConfig, 'object');
    assert.strictEqual(typeof auditSource, 'object');

    const { provider, config, fallbackCertificate, tlsConfig } = data;
    const { domain:dashboardDomain } = await dashboard.getLocation();
    if (constants.DEMO && (domain === dashboardDomain)) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');

    const domainObject = await get(domain);
    let zoneName;
    if (data.zoneName) {
        if (!tld.isValid(data.zoneName)) throw new BoxError(BoxError.BAD_FIELD, 'Invalid zoneName');
        zoneName = data.zoneName;
    } else {
        zoneName = domainObject.zoneName;
    }

    if (fallbackCertificate) await openssl.validateCertificate('test', domain, fallbackCertificate);

    const tlsConfigError = validateTlsConfig(tlsConfig, provider);
    if (tlsConfigError) throw tlsConfigError;

    if (provider === domainObject.provider) api(provider).injectPrivateFields(config, domainObject.config);

    const sanitizedConfig = await verifyDomainConfig(config, domain, zoneName, provider);

    const newData = {
        config: sanitizedConfig,
        zoneName,
        provider,
        tlsConfig,
    };

    if (fallbackCertificate) newData.fallbackCertificate = fallbackCertificate;

    const args = [], fields = [];
    for (const k in newData) {
        if (k === 'config' || k === 'tlsConfig' || k === 'fallbackCertificate') { // json fields
            fields.push(`${k}Json = ?`);
            args.push(JSON.stringify(newData[k]));
        } else {
            fields.push(k + ' = ?');
            args.push(newData[k]);
        }
    }
    args.push(domain);

    const result = await database.query('UPDATE domains SET ' + fields.join(', ') + ' WHERE domain=?', args);
    if (result.affectedRows === 0) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found');

    if (fallbackCertificate) await reverseProxy.setFallbackCertificate(domain, fallbackCertificate);
    if (!_.isEqual(domainObject.tlsConfig, tlsConfig.provider)) await reverseProxy.handleCertificateProviderChanged(domain);

    await eventlog.add(eventlog.ACTION_DOMAIN_UPDATE, auditSource, { domain, zoneName, provider });
}

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

    const wellKnownError = validateWellKnown(wellKnown);
    if (wellKnownError) throw wellKnownError;

    const result = await database.query('UPDATE domains SET wellKnownJson = ? WHERE domain=?', [ JSON.stringify(wellKnown), domain ]);
    if (result.affectedRows === 0) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found');

    await eventlog.add(eventlog.ACTION_DOMAIN_UPDATE, auditSource, { domain, wellKnown });
}

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

    const { domain:dashboardDomain } = await dashboard.getLocation();
    if (domain === dashboardDomain) throw new BoxError(BoxError.CONFLICT, 'Cannot remove admin domain');

    const { domain:mailDomain } = await mailServer.getLocation();
    if (domain === mailDomain) throw new BoxError(BoxError.CONFLICT, 'Cannot remove mail domain. Change the mail server location first');

    const queries = [
        { query: 'DELETE FROM mail WHERE domain = ?', args: [ domain ] },
        { query: 'DELETE FROM domains WHERE domain = ?', args: [ domain ] },
    ];

    const [error, results] = await safe(database.transaction(queries));
    if (error && error.sqlCode === 'ER_ROW_IS_REFERENCED_2') {
        if (error.message.includes('mailboxes_aliasDomain_constraint')) throw new BoxError(BoxError.CONFLICT, 'Domain is in use in a mailbox, list or an alias');
        if (error.message.includes('mailboxes_domain_constraint')) throw new BoxError(BoxError.CONFLICT, 'Domain is in use in a mailbox, list or an alias');
        if (error.message.includes('apps_mailDomain_constraint')) throw new BoxError(BoxError.CONFLICT, 'Domain is in use in an app\'s mailbox section');
        if (error.message.includes('locations')) throw new BoxError(BoxError.CONFLICT, 'Domain is in use in an app\'s location');
        throw new BoxError(BoxError.CONFLICT, error.message);
    }
    if (error) throw error;
    if (results[1].affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found');

    await eventlog.add(eventlog.ACTION_DOMAIN_REMOVE, auditSource, { domain });

    safe(mailServer.onDomainRemoved(domain));
}

async function clear() {
    await database.query('DELETE FROM domains');
}

// removes all fields that are strictly private and should never be returned by API calls
function removePrivateFields(domain) {
    const result = _.pick(domain, ['domain', 'zoneName', 'provider', 'config', 'tlsConfig', 'fallbackCertificate', 'wellKnown']);
    const out = api(result.provider).removePrivateFields(result);
    delete out.fallbackCertificate.key;
    return out;
}

// removes all fields that are not accessible by a normal user. currently, this is same as for admin users since config tokens are removed
function removeRestrictedFields(domain) {
    return removePrivateFields(domain);
}

async function getDomainObjectMap() {
    const domainObjects = await list();
    const domainObjectMap = {};
    for (const d of domainObjects) { domainObjectMap[d.domain] = d; }
    return domainObjectMap;
}

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

    log(`checkConfig: validating domain configs`);

    for (const domainObject of await list()) {
        if (domainObject.provider === 'noop' || domainObject.provider === 'manual' || domainObject.provider === 'wildcard') {
            await notifications.unpin(notifications.TYPE_DOMAIN_CONFIG_CHECK_FAILED, { context: domainObject.domain });
            continue;
        }

        const [error] = await safe(api(domainObject.provider).verifyDomainConfig(domainObject));
        if (!error) {
            await notifications.unpin(notifications.TYPE_DOMAIN_CONFIG_CHECK_FAILED, { context: domainObject.domain });
            continue;
        }

        let errorMessage;
        if (error.reason === BoxError.ACCESS_DENIED) {
            errorMessage = `Access denied: ${error.message}`;
        } else if (error.reason === BoxError.NOT_FOUND) {
            errorMessage = `Zone not found: ${error.message}`;
        } else if (error.reason === BoxError.EXTERNAL_ERROR) {
            errorMessage = `Configuration error: ${error.message}`;
        } else {
            errorMessage = `General error: ${error.message}`;
        }

        log(`checkConfig: ${domainObject.domain} is not configured properly`, error);

        await notifications.pin(notifications.TYPE_DOMAIN_CONFIG_CHECK_FAILED, `Domain ${domainObject.domain} is not configured properly`,
            errorMessage, { context: domainObject.domain });
    }
}

export default {
    add,
    get,
    list,
    setConfig,
    setWellKnown,
    del,
    clear,

    getDomainObjectMap,

    removePrivateFields,
    removeRestrictedFields,

    checkConfigs
};
