import assert from 'node:assert';
import BoxError from '../boxerror.js';
import constants from '../constants.js';
import logger from '../logger.js';
import dig from '../dig.js';
import dns from '../dns.js';
import network from '../network.js';
import safe from 'safetydance';
import superagent from '@cloudron/superagent';
import timers from 'timers/promises';
import util from 'node:util';
import waitForDns from './waitfordns.js';
import xml2js from 'xml2js';

const { log } = logger('dns/namecheap');


const ENDPOINT = 'https://api.namecheap.com/xml.response';

function removePrivateFields(domainObject) {
    delete domainObject.config.token;
    return domainObject;
}

function injectPrivateFields(newConfig, currentConfig) {
    if (!Object.hasOwn(newConfig, 'token')) newConfig.token = currentConfig.token;
}

async function getQuery(domainConfig) {
    assert.strictEqual(typeof domainConfig, 'object');

    const ip = await network.getIPv4(); // only supports ipv4

    // https://www.namecheap.com/support/knowledgebase/article.aspx/9739/63/api-faq/#z . 50 / minute
    await timers.setTimeout(5000); // limits to 12req/min for this process. we can have 3 apptasks in parallel

    return {
        ApiUser: domainConfig.username,
        ApiKey: domainConfig.token,
        UserName: domainConfig.username,
        ClientIp: ip
    };
}

async function getZone(domainConfig, zoneName) {
    assert.strictEqual(typeof domainConfig, 'object');
    assert.strictEqual(typeof zoneName, 'string');

    const query = await getQuery(domainConfig);
    query.Command = 'namecheap.domains.dns.getHosts';
    query.SLD = zoneName.split('.', 1)[0];
    query.TLD = zoneName.slice(query.SLD.length + 1);

    const [error, response] = await safe(superagent.get(ENDPOINT).query(query).ok(() => true));
    if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);

    const parser = new xml2js.Parser();
    const [parserError, result] = await safe(util.promisify(parser.parseString)(response.text));
    if (parserError) throw new BoxError(BoxError.EXTERNAL_ERROR, parserError);

    const tmp = result.ApiResponse;
    if (tmp['$'].Status !== 'OK') {
        const errorMessage = safe.query(tmp, 'Errors[0].Error[0]._', 'Invalid response');
        if (errorMessage === 'API Key is invalid or API access has not been enabled') throw new BoxError(BoxError.ACCESS_DENIED, errorMessage);

        throw new BoxError(BoxError.EXTERNAL_ERROR, errorMessage);
    }
    const host = safe.query(tmp, 'CommandResponse[0].DomainDNSGetHostsResult[0].host', []);
    if (!host) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid response: ${JSON.stringify(tmp)}`);
    if (!Array.isArray(host)) throw new BoxError(BoxError.EXTERNAL_ERROR, `host is not an array: ${JSON.stringify(tmp)}`);

    const hosts = host.map(h => h['$']);
    return hosts;
}

async function setZone(domainConfig, zoneName, hosts) {
    assert.strictEqual(typeof domainConfig, 'object');
    assert.strictEqual(typeof zoneName, 'string');
    assert(Array.isArray(hosts));

    const query = await getQuery(domainConfig);
    query.Command = 'namecheap.domains.dns.setHosts';
    query.SLD = zoneName.split('.', 1)[0];
    query.TLD = zoneName.slice(query.SLD.length + 1);

    // Map to query params https://www.namecheap.com/support/api/methods/domains-dns/set-hosts.aspx
    hosts.forEach(function (host, i) {
        const n = i+1; // api starts with 1 not 0
        query['TTL' + n] = '300'; // keep it low
        query['HostName' + n] = host.HostName || host.Name;
        query['RecordType' + n] = host.RecordType || host.Type;
        query['Address' + n] = host.Address;

        if (host.Type === 'MX') {
            query['EmailType' + n] = 'MX';
            if (host.MXPref) query['MXPref' + n] = host.MXPref;
        }
    });

    // namecheap recommends sending as POSTDATA with > 10 records
    const qs = new URLSearchParams(query).toString();

    const [error, response] = await safe(superagent.post(ENDPOINT).set('Content-Type', 'application/x-www-form-urlencoded').send(qs).ok(() => true));
    if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, error);

    const parser = new xml2js.Parser();
    const [parserError, result] = await safe(util.promisify(parser.parseString)(response.text));
    if (parserError) throw new BoxError(BoxError.EXTERNAL_ERROR, parserError.message);

    const tmp = result.ApiResponse;
    if (tmp['$'].Status !== 'OK') {
        const errorMessage = safe.query(tmp, 'Errors[0].Error[0]._', 'Invalid response');
        if (errorMessage === 'API Key is invalid or API access has not been enabled') throw new BoxError(BoxError.ACCESS_DENIED, errorMessage);

        throw new BoxError(BoxError.EXTERNAL_ERROR, errorMessage);
    }
    if (!tmp.CommandResponse[0]) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response');
    if (!tmp.CommandResponse[0].DomainDNSSetHostsResult[0]) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response');
    if (tmp.CommandResponse[0].DomainDNSSetHostsResult[0]['$'].IsSuccess !== 'true') throw new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response');
}

async function upsert(domainObject, subdomain, type, values) {
    assert.strictEqual(typeof domainObject, 'object');
    assert.strictEqual(typeof subdomain, 'string');
    assert.strictEqual(typeof type, 'string');
    assert(Array.isArray(values));

    const domainConfig = domainObject.config;
    const zoneName = domainObject.zoneName;

    subdomain = dns.getName(domainObject, subdomain, type) || '@';

    log('upsert: %s for zone %s of type %s with values %j', subdomain, zoneName, type, values);

    const result = await getZone(domainConfig, zoneName);

    // Array to keep track of records that need to be inserted
    const toInsert = [];

    for (let i = 0; i < values.length; i++) {
        const curValue = values[i];
        let wasUpdate = false;

        for (let j = 0; j < result.length; j++) {
            const curHost = result[j];

            if (curHost.Type === type && curHost.Name === subdomain) {
                // Updating an already existing host
                wasUpdate = true;
                if (type === 'MX') {
                    curHost.MXPref = curValue.split(' ')[0];
                    curHost.Address = curValue.split(' ')[1];
                } else {
                    curHost.Address = curValue;
                }
            }
        }

        // We don't have this host at all yet, let's push to toInsert array
        if (!wasUpdate) {
            const newRecord = {
                RecordType: type,
                HostName: subdomain,
                Address: curValue
            };

            // Special case for MX records
            if (type === 'MX') {
                newRecord.MXPref = curValue.split(' ')[0];
                newRecord.Address = curValue.split(' ')[1];
            }

            toInsert.push(newRecord);

        }
    }

    const hosts = result.concat(toInsert);

    return await setZone(domainConfig, zoneName, hosts);
}

async function get(domainObject, subdomain, type) {
    assert.strictEqual(typeof domainObject, 'object');
    assert.strictEqual(typeof subdomain, 'string');
    assert.strictEqual(typeof type, 'string');

    const domainConfig = domainObject.config;
    const zoneName = domainObject.zoneName;

    subdomain = dns.getName(domainObject, subdomain, type) || '@';

    const result = await getZone(domainConfig, zoneName);

    // We need to filter hosts to ones with this subdomain and type
    const actualHosts = result.filter((host) => host.Type === type && host.Name === subdomain);

    const tmp = actualHosts.map(function (record) { return record.Address; });
    return tmp;
}

async function del(domainObject, subdomain, type, values) {
    assert.strictEqual(typeof domainObject, 'object');
    assert.strictEqual(typeof subdomain, 'string');
    assert.strictEqual(typeof type, 'string');
    assert(Array.isArray(values));

    const domainConfig = domainObject.config;
    const zoneName = domainObject.zoneName;

    subdomain = dns.getName(domainObject, subdomain, type) || '@';

    log('del: %s for zone %s of type %s with values %j', subdomain, zoneName, type, values);

    let result = await getZone(domainConfig, zoneName);
    if (result.length === 0) return;
    const originalLength = result.length;

    for (let i = 0; i < values.length; i++) {
        const curValue = values[i];

        result = result.filter(curHost => curHost.Type !== type || curHost.Name !== subdomain || curHost.Address !== curValue);
    }

    if (result.length !== originalLength) return await setZone(domainConfig, zoneName, result);
}

async function wait(domainObject, subdomain, type, value, options) {
    assert.strictEqual(typeof domainObject, 'object');
    assert.strictEqual(typeof subdomain, 'string');
    assert.strictEqual(typeof type, 'string');
    assert.strictEqual(typeof value, 'string');
    assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }

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

    await waitForDns(fqdn, domainObject.zoneName, type, value, options);
}

async function verifyDomainConfig(domainObject) {
    assert.strictEqual(typeof domainObject, 'object');

    const domainConfig = domainObject.config;
    const zoneName = domainObject.zoneName;
    const ip = '127.0.0.1';

    if (!domainConfig.username || typeof domainConfig.username !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'username must be a non-empty string');
    if (!domainConfig.token || typeof domainConfig.token !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'token must be a non-empty string');
    if ('customNameservers' in domainConfig && typeof domainConfig.customNameservers !== 'boolean') throw new BoxError(BoxError.BAD_FIELD, 'customNameservers must be a boolean');

    const credentials = {
        username: domainConfig.username,
        token: domainConfig.token,
        customNameservers: !!domainConfig.customNameservers
    };

    if (constants.TEST) return credentials; // this shouldn't be here

    const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
    if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');
    if (error || !nameservers) throw new BoxError(BoxError.BAD_FIELD, error ? error.message : 'Unable to get nameservers');

    if (nameservers.some(function (n) { return n.toLowerCase().indexOf('.registrar-servers.com') === -1; })) {
        log('verifyDomainConfig: %j does not contains NC NS', nameservers);
        if (!domainConfig.customNameservers) throw new BoxError(BoxError.BAD_FIELD, 'Domain nameservers are not set to NameCheap');
    }

    const testSubdomain = 'cloudrontestdns';

    await upsert(domainObject, testSubdomain, 'A', [ip]);
    log('verifyDomainConfig: Test A record added');

    await del(domainObject, testSubdomain, 'A', [ip]);
    log('verifyDomainConfig: Test A record removed again');

    return credentials;
}

export default {
    removePrivateFields,
    injectPrivateFields,
    upsert,
    get,
    del,
    verifyDomainConfig,
    wait
};
