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 safe from 'safetydance';
import superagent from '@cloudron/superagent';
import timers from 'timers/promises';
import waitForDns from './waitfordns.js';

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


// Rate limit note: Porkbun return 503 when it hits rate limits. It's as low as 1 req/second
// https://github.com/cullenmcdermott/terraform-provider-porkbun/issues/23#issuecomment-1366859999
const PORKBUN_API = 'https://api.porkbun.com/api/json/v3/dns';

function formatError(response) {
    return `Porkbun DNS error ${response.status} ${response.text}`;
}

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

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

async function createRequest(method, url, data) {
    assert.strictEqual(typeof method, 'string');
    assert.strictEqual(typeof url, 'string');
    assert.strictEqual(typeof data, 'object');

    await timers.setTimeout(3000); // see rate limit note at top of file
    return superagent.request(method, url).retry(5).timeout(30 * 1000).send(data).ok(() => true);
}

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

    log(`get: ${name} zone:${zoneName} type:${type}`);

    const data = {
        secretapikey: domainConfig.secretapikey,
        apikey: domainConfig.apikey,
    };

    const [error, response] = await safe(createRequest('POST', `${PORKBUN_API}/retrieveByNameType/${zoneName}/${type}/${name}`, data));
    if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
    if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
    if (response.body.status !== 'SUCCESS') throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid status in response: ${JSON.stringify(response.body)}`);
    if (!Array.isArray(response.body.records)) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid records in response: ${JSON.stringify(response.body)}`);

    // TXT records are returned without quoting. When no records match, it returns empty records array
    return response.body.records;
}

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

    const data = {
        secretapikey: domainConfig.secretapikey,
        apikey: domainConfig.apikey,
    };

    // deletes all the records matching type+name
    const [error, response] = await safe(createRequest('POST', `${PORKBUN_API}/deleteByNameType/${zoneName}/${type}/${name}`, data));
    if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
    if (response.status === 400) return; // not found, "Could not delete record."
    if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
    if (response.body.status !== 'SUCCESS') throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid status in response: ${JSON.stringify(response.body)}`);
}

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

    const domainConfig = domainObject.config,
        zoneName = domainObject.zoneName,
        name = dns.getName(domainObject, location, type) || '';

    log(`upsert: ${name} zone:${zoneName} type:${type} values:${JSON.stringify(values)}`);

    await delDnsRecords(domainConfig, zoneName, name, type);

    for (const value of values) {
        const data = {
            secretapikey: domainConfig.secretapikey,
            apikey: domainConfig.apikey,
            ttl: '10', // 600 seems to be minimum anyway
            type,
            name
        };

        if (type === 'MX') {
            data.prio = value.split(' ')[0]; // string
            data.content = value.split(' ')[1];
        } else if (type === 'TXT') { // strip quotes
            data.content = value.startsWith('"') && value.endsWith('"') ? value.slice(1, value.length-1) : value;
        } else {
            data.content = value;
        }

        const [error, response] = await safe(createRequest('POST', `${PORKBUN_API}/create/${zoneName}`, data));

        if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
        if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
        if (response.body.status !== 'SUCCESS') throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid status in response: ${JSON.stringify(response.body)}`);
        if (!response.body.id) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid id in response: ${JSON.stringify(response.body)}`);

        log(`upsert: created record with id ${response.body.id}`);
    }
}

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

    const domainConfig = domainObject.config,
        zoneName = domainObject.zoneName,
        name = dns.getName(domainObject, location, type) || '';

    const records = await getDnsRecords(domainConfig, zoneName, name, type);
    return records.map(r => r.content);
}

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

    const domainConfig = domainObject.config,
        zoneName = domainObject.zoneName,
        name = dns.getName(domainObject, location, type) || '';

    log(`del: ${name} zone ${zoneName} of type ${type} with values ${JSON.stringify(values)}`);

    const data = {
        secretapikey: domainConfig.secretapikey,
        apikey: domainConfig.apikey,
    };

    // note that deleteByNameType deletes all the records matching type+name. we only want to delete records matching values
    const records = await getDnsRecords(domainConfig, zoneName, name, type);
    const ids = records.filter(r => values.includes(r.content)).map(r => r.id);

    for (const id of ids) {
        const [error, response] = await safe(createRequest('POST', `${PORKBUN_API}/delete/${zoneName}/${id}`, data));
        if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
        if (response.status === 400) continue; // not found! "Invalid record id."
        if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
        if (response.body.status !== 'SUCCESS') throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid status in response: ${JSON.stringify(response.body)}`);
    }
}

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,
        zoneName = domainObject.zoneName;

    if (!domainConfig.secretapikey || typeof domainConfig.secretapikey !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'secretapikey must be a non-empty string');
    if (!domainConfig.apikey || typeof domainConfig.apikey !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'apikey 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 ip = '127.0.0.1';

    const credentials = {
        secretapikey: domainConfig.secretapikey,
        apikey: domainConfig.apikey,
        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.every(function (n) { return n.toLowerCase().indexOf('.ns.porkbun.com') !== -1; })) {
        log('verifyDomainConfig: %j does not contain Porkbun NS', nameservers);
        if (!domainConfig.customNameservers) throw new BoxError(BoxError.BAD_FIELD, 'Domain nameservers are not set to Porkbun');
    }

    const location = 'cloudrontestdns';

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

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

    return credentials;
}

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