import assert from 'node:assert';
import blobs from './blobs.js';
import BoxError from './boxerror.js';
import crypto from 'node:crypto';
import logger from './logger.js';
import dns from './dns.js';
import openssl from './openssl.js';
import path from 'node:path';
import paths from './paths.js';
import promiseRetry from './promise-retry.js';
import safe from 'safetydance';
import superagent from '@cloudron/superagent';
import users from './users.js';

const { log } = logger('cert/acme2');

const CA_PROD_DIRECTORY_URL = 'https://acme-v02.api.letsencrypt.org/directory',
    CA_STAGING_DIRECTORY_URL = 'https://acme-staging-v02.api.letsencrypt.org/directory';

// http://jose.readthedocs.org/en/latest/
// https://www.ietf.org/proceedings/92/slides/slides-92-acme-1.pdf
// https://community.letsencrypt.org/t/list-of-client-implementations/2103

function Acme2(fqdn, domainObject, email, key, options) {
    assert.strictEqual(typeof fqdn, 'string');
    assert.strictEqual(typeof domainObject, 'object');
    assert.strictEqual(typeof email, 'string');
    assert.strictEqual(typeof key, 'string');
    assert.strictEqual(typeof options, 'object'); // { profile }

    this.fqdn = fqdn;
    this.accountKey = null;
    this.email = email;
    this.key = key;
    this.accountKeyId = null;
    const prod = domainObject.tlsConfig.provider.match(/.*-prod/) !== null; // matches 'le-prod' or 'letsencrypt-prod'
    this.caDirectory = prod ? CA_PROD_DIRECTORY_URL : CA_STAGING_DIRECTORY_URL;
    this.directory = {};
    this.forceHttpAuthorization = domainObject.provider.match(/noop|manual|wildcard/) !== null;
    this.wildcard = !!domainObject.tlsConfig.wildcard;
    this.domain = domainObject.domain;

    if (fqdn !== this.domain && this.wildcard) { // bare domain is not part of wildcard SAN
        this.cn = dns.makeWildcard(fqdn);
        this.altNames = [ this.cn ];
        if (fqdn.startsWith('*.')) this.altNames.push(fqdn.replace(/^\*\./, '')); // add bare domain to cert for wildcard certs
    } else {
        this.cn = fqdn;
        this.altNames = [ this.cn ];
    }

    this.certName = this.cn.replace('*.', '_.');

    this.profile = options.profile || ''; // https://letsencrypt.org/docs/profiles/ . is validated against the directory

    log(`Acme2: will get cert for fqdn: ${this.fqdn} cn: ${this.cn} certName: ${this.certName} wildcard: ${this.wildcard} http: ${this.forceHttpAuthorization}`);
}

// urlsafe base64 encoding (jose)
function urlBase64Encode(base64String) {
    return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

function b64(str) {
    const buf = Buffer.isBuffer(str) ? str : Buffer.from(str);
    return urlBase64Encode(buf.toString('base64'));
}

// https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients#step-3-constructing-the-ari-certid
// https://www.rfc-editor.org/rfc/rfc9773.txt
async function getAriCertId(certPem) {
    assert.strictEqual(typeof certPem, 'string');

    const aki = await openssl.getAuthorityKeyId(certPem);
    const serial = await openssl.getSerial(certPem);

    return b64(aki) + '.' + b64(serial);
};

// ARI - https://www.rfc-editor.org/rfc/rfc9773.txt . https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients#step-3-constructing-the-ari-certid
async function getRenewalInfo(certPem, renewalUrl) {
    assert.strictEqual(typeof certPem, 'string');
    assert.strictEqual(typeof renewalUrl, 'string');

    const now = new Date();

    const ariCertId = await getAriCertId(certPem);
    const response = await superagent.get(`${renewalUrl}/${ariCertId}`).timeout(30000).ok(() => true);
    if (response.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Invalid response code when renewal info : ${response.status} ${response.text}`);

    const body = response.body;
    if (typeof body.suggestedWindow?.start !== 'string') throw new BoxError(BoxError.ACME_ERROR, `Invalid response suggestedWindow.start : ${response.text}`);
    if (typeof body.suggestedWindow?.end !== 'string') throw new BoxError(BoxError.ACME_ERROR, `Invalid response suggestedWindow.end : ${response.text}`);
    const start = new Date(body.suggestedWindow.start), end = new Date(body.suggestedWindow.end);

    const retryAfter = Number.parseInt(response.headers['Retry-After'.toLocaleLowerCase()], 10); // seconds
    if (!Number.isFinite(retryAfter)) throw new BoxError(BoxError.ACME_ERROR, 'Missing or invalid retry-after in response header');
    const valid = new Date(now.getTime() + retryAfter*1000);

    const rt = new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())); // a uniform random time in the window

    return {
        start: start.toUTCString(),
        end: end.toUTCString(),
        rt: rt.toUTCString(),
        valid: valid.toUTCString(),
        url: renewalUrl,
        ts: now.toUTCString()
    };
};

Acme2.prototype.sendSignedRequest = async function (url, payload) {
    assert.strictEqual(typeof url, 'string');
    assert.strictEqual(typeof payload, 'string');
    assert.strictEqual(typeof this.accountKey, 'string');

    const that = this;
    const header = {
        url: url,
        alg: 'RS256'
    };

    // keyId is null when registering account
    if (this.accountKeyId) {
        header.kid = this.accountKeyId;
    } else {
        header.jwk = {
            e: b64(Buffer.from([0x01, 0x00, 0x01])), // exponent - 65537
            kty: 'RSA',
            n: b64(await openssl.getModulus(this.accountKey))
        };
    }

    const payload64 = b64(payload);

    let [error, response] = await safe(superagent.get(this.directory.newNonce).timeout(30000).ok(() => true));
    if (error) throw new BoxError(BoxError.NETWORK_ERROR, `Network error sending signed request: ${error.message}`);
    if (response.status !== 204) throw new BoxError(BoxError.ACME_ERROR, `Invalid response code when fetching nonce : ${response.status}`);

    const nonce = response.headers['Replay-Nonce'.toLowerCase()];
    if (!nonce) throw new BoxError(BoxError.ACME_ERROR, 'No nonce in response');

    log(`sendSignedRequest: using nonce ${nonce} for url ${url}`);

    const protected64 = b64(JSON.stringify(Object.assign({}, header, { nonce: nonce })));

    const signer = crypto.createSign('RSA-SHA256');
    signer.update(protected64 + '.' + payload64, 'utf8');
    const signature64 = urlBase64Encode(signer.sign(that.accountKey, 'base64'));

    const data = {
        protected: protected64,
        payload: payload64,
        signature: signature64
    };

    [error, response] = await safe(superagent.post(url).send(data).set('Content-Type', 'application/jose+json').set('User-Agent', 'acme-cloudron').timeout(30000).ok(() => true));
    if (error) throw new BoxError(BoxError.NETWORK_ERROR, `Network error sending signed request: ${error.message}`);

    return response;
};

// https://tools.ietf.org/html/rfc8555#section-6.3
Acme2.prototype.postAsGet = async function (url) {
    return await this.sendSignedRequest(url, '');
};

Acme2.prototype.updateContact = async function (registrationUri) {
    assert.strictEqual(typeof registrationUri, 'string');

    log(`updateContact: registrationUri: ${registrationUri} email: ${this.email}`);

    // https://github.com/ietf-wg-acme/acme/issues/30
    const payload = {
        contact: [ 'mailto:' + this.email ]
    };

    const result = await this.sendSignedRequest(registrationUri, JSON.stringify(payload));
    if (result.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Failed to update contact. Expecting 200, got ${result.status} ${result.text}`);

    log(`updateContact: contact of user updated to ${this.email}`);
};

Acme2.prototype.ensureAccount = async function () {
    const payload = {
        termsOfServiceAgreed: true
    };

    log('ensureAccount: registering user');

    this.accountKey = await blobs.getString(blobs.ACME_ACCOUNT_KEY);
    if (!this.accountKey) {
        log('ensureAccount: generating new account keys');
        this.accountKey = await openssl.generateKey('rsa4096');
        await blobs.setString(blobs.ACME_ACCOUNT_KEY, this.accountKey);
    }

    let result = await this.sendSignedRequest(this.directory.newAccount, JSON.stringify(payload));
    if (result.status === 403 && result.body.type === 'urn:ietf:params:acme:error:unauthorized') {
        log(`ensureAccount: key was revoked. ${result.status} ${result.text}. generating new account key`);
        this.accountKey = await openssl.generateKey('rsa4096');
        await blobs.setString(blobs.ACME_ACCOUNT_KEY, this.accountKey);
        result = await this.sendSignedRequest(this.directory.newAccount, JSON.stringify(payload));
    }

    // 200 if already exists. 201 for new accounts
    if (result.status !== 200 && result.status !== 201) throw new BoxError(BoxError.ACME_ERROR, `Failed to register new account. Expecting 200 or 201, got ${result.status} ${result.text}`);

    log(`ensureAccount: user registered keyid: ${result.headers.location}`);

    this.accountKeyId = result.headers.location;

    await this.updateContact(result.headers.location);
};

Acme2.prototype.newOrder = async function () {
    const payload = { identifiers: [] };
    if (this.profile) payload.profile = this.profile;
    this.altNames.forEach(an => {
        payload.identifiers.push({
            type: 'dns',
            value: an
        });
    });

    log(`newOrder: ${JSON.stringify(this.altNames)}`);

    const result = await this.sendSignedRequest(this.directory.newOrder, JSON.stringify(payload));
    if (result.status === 403) throw new BoxError(BoxError.ACCESS_DENIED, `Forbidden sending new order: ${result.body.detail}`);
    if (result.status !== 201) throw new BoxError(BoxError.ACME_ERROR, `Failed to send new order. Expecting 201, got ${result.status} ${result.text}`);

    const order = result.body, orderUrl = result.headers.location;
    log(`newOrder: created order ${this.cn} order: ${result.text} orderUrl: ${orderUrl}`);

    if (!Array.isArray(order.authorizations)) throw new BoxError(BoxError.ACME_ERROR, 'invalid authorizations in order');
    if (typeof order.finalize !== 'string') throw new BoxError(BoxError.ACME_ERROR, 'invalid finalize in order');
    if (typeof orderUrl !== 'string') throw new BoxError(BoxError.ACME_ERROR, 'invalid order location in order header');

    return { order, orderUrl };
};

Acme2.prototype.waitForOrder = async function (orderUrl) {
    assert.strictEqual(typeof orderUrl, 'string');

    log(`waitForOrder: ${orderUrl}`);

    return await promiseRetry({ times: 15, interval: 20000, log }, async () => {
        log('waitForOrder: getting status');

        const result = await this.postAsGet(orderUrl);
        if (result.status !== 200) {
            log(`waitForOrder: invalid response code getting uri ${result.status}`);
            throw new BoxError(BoxError.ACME_ERROR, `Bad response when waiting for order. code: ${result.status}`);
        }

        log('waitForOrder: status is "%s %j', result.body.status, result.body);

        if (result.body.status === 'pending' || result.body.status === 'processing') throw new BoxError(BoxError.ACME_ERROR, `Request is in ${result.body.status} state`);
        else if (result.body.status === 'valid' && result.body.certificate) return result.body.certificate;
        else throw new BoxError(BoxError.ACME_ERROR, `Unexpected status or invalid response when waiting for order: ${result.text}`);
    });
};

Acme2.prototype.getKeyAuthorization = async function (token) {
    assert(typeof this.accountKey, 'string');

    const jwk = {
        e: b64(Buffer.from([0x01, 0x00, 0x01])), // Exponent - 65537
        kty: 'RSA',
        n: b64(await openssl.getModulus(this.accountKey))
    };

    const shasum = crypto.createHash('sha256');
    shasum.update(JSON.stringify(jwk));
    const thumbprint = urlBase64Encode(shasum.digest('base64'));
    return token + '.' + thumbprint;
};

Acme2.prototype.notifyChallengeReady = async function (challenge) {
    assert.strictEqual(typeof challenge, 'object'); // { type, status, url, token }

    log(`notifyChallengeReady: ${challenge.url} was met`);

    const keyAuthorization = await this.getKeyAuthorization(challenge.token);

    const payload = {
        resource: 'challenge',
        keyAuthorization: keyAuthorization
    };

    const result = await this.sendSignedRequest(challenge.url, JSON.stringify(payload));
    if (result.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Failed to notify challenge. Expecting 200, got ${result.status} ${result.text}`);
};

Acme2.prototype.waitForChallenge = async function (challenge) {
    assert.strictEqual(typeof challenge, 'object');

    log(`waitingForChallenge: ${JSON.stringify(challenge)}`);

    await promiseRetry({ times: 15, interval: 20000, log }, async () => {
        log('waitingForChallenge: getting status');

        const result = await this.postAsGet(challenge.url);
        if (result.status !== 200) {
            log(`waitForChallenge: invalid response code getting uri ${result.status}`);
            throw new BoxError(BoxError.ACME_ERROR, `Bad response code when waiting for challenge : ${result.status}`);
        }

        log(`waitForChallenge: status is "${result.body.status}" "${result.text}"`);

        if (result.body.status === 'pending') throw new BoxError(BoxError.ACME_ERROR, 'Challenge is in pending state');
        else if (result.body.status === 'valid') return;
        else throw new BoxError(BoxError.ACME_ERROR, `Unexpected status when waiting for challenge: ${result.body.status}`);
    });
};

// https://community.letsencrypt.org/t/public-beta-rate-limits/4772 for rate limits
Acme2.prototype.signCertificate = async function (finalizationUrl, csrPem) {
    assert.strictEqual(typeof finalizationUrl, 'string');
    assert.strictEqual(typeof csrPem, 'string');

    const csrDer = await openssl.pemToDer(csrPem);

    const payload = {
        csr: b64(csrDer)
    };

    log(`signCertificate: sending sign request to ${finalizationUrl}`);

    const result = await this.sendSignedRequest(finalizationUrl, JSON.stringify(payload));
    // 429 means we reached the cert limit for this domain
    if (result.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Failed to sign certificate. Expecting 200, got ${result.status} ${result.text}`);
};

Acme2.prototype.downloadCertificate = async function (certUrl) {
    assert.strictEqual(typeof certUrl, 'string');

    return await promiseRetry({ times: 5, interval: 20000, log }, async () => {
        log(`downloadCertificate: downloading certificate of ${this.cn}`);

        const result = await this.postAsGet(certUrl);
        if (result.status === 202) throw new BoxError(BoxError.ACME_ERROR, 'Retry downloading certificate');
        if (result.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Failed to get cert. Expecting 200, got ${result.status} ${result.text}`);

        const fullChainPem = result.body.toString('utf8'); // buffer
        return fullChainPem;
    });
};

Acme2.prototype.prepareHttpChallenge = async function (challenge) {
    assert.strictEqual(typeof challenge, 'object');

    log(`prepareHttpChallenge: preparing for challenge ${JSON.stringify(challenge)}`);

    const keyAuthorization = await this.getKeyAuthorization(challenge.token);

    const challengeFilePath = path.join(paths.ACME_CHALLENGES_DIR, challenge.token);
    log(`prepareHttpChallenge: writing ${keyAuthorization} to ${challengeFilePath}`);

    if (!safe.fs.writeFileSync(challengeFilePath, keyAuthorization)) throw new BoxError(BoxError.FS_ERROR, `Error writing challenge: ${safe.error.message}`);
};

Acme2.prototype.cleanupHttpChallenge = async function (challenge) {
    assert.strictEqual(typeof challenge, 'object');

    const challengeFilePath = path.join(paths.ACME_CHALLENGES_DIR, challenge.token);
    log(`cleanupHttpChallenge: unlinking ${challengeFilePath}`);

    if (!safe.fs.unlinkSync(challengeFilePath)) throw new BoxError(BoxError.FS_ERROR, `Error unlinking challenge: ${safe.error.message}`);
};

function getChallengeSubdomain(cn, domain) {
    let challengeSubdomain;

    if (cn === domain) {
        challengeSubdomain = '_acme-challenge';
    } else if (cn.includes('*')) { // wildcard
        const subdomain = cn.slice(0, -domain.length - 1);
        challengeSubdomain = subdomain ? subdomain.replace('*', '_acme-challenge') : '_acme-challenge';
    } else {
        challengeSubdomain = '_acme-challenge.' + cn.slice(0, -domain.length - 1);
    }

    log(`getChallengeSubdomain: challenge subdomain for cn ${cn} at domain ${domain} is ${challengeSubdomain}`);

    return challengeSubdomain;
}

Acme2.prototype.prepareDnsChallenge = async function (cn, challenge) {
    assert.strictEqual(typeof challenge, 'object');

    log(`prepareDnsChallenge: preparing for challenge: ${JSON.stringify(challenge)}`);

    const keyAuthorization = await this.getKeyAuthorization(challenge.token);
    const shasum = crypto.createHash('sha256');
    shasum.update(keyAuthorization);

    const txtValue = urlBase64Encode(shasum.digest('base64'));
    const challengeSubdomain = getChallengeSubdomain(cn, this.domain);

    log(`prepareDnsChallenge: update ${challengeSubdomain} with ${txtValue}`);

    await dns.upsertDnsRecords(challengeSubdomain, this.domain, 'TXT', [ `"${txtValue}"` ]);

    await dns.waitForDnsRecord(challengeSubdomain, this.domain, 'TXT', txtValue, { times: 200 });
};

Acme2.prototype.cleanupDnsChallenge = async function (cn, challenge) {
    assert.strictEqual(typeof cn, 'string');
    assert.strictEqual(typeof challenge, 'object');

    const keyAuthorization = await this.getKeyAuthorization(challenge.token);
    const shasum = crypto.createHash('sha256');
    shasum.update(keyAuthorization);

    const txtValue = urlBase64Encode(shasum.digest('base64'));
    const challengeSubdomain = getChallengeSubdomain(cn, this.domain);

    log(`cleanupDnsChallenge: remove ${challengeSubdomain} with ${txtValue}`);

    await dns.removeDnsRecords(challengeSubdomain, this.domain, 'TXT', [ `"${txtValue}"` ]);
};

Acme2.prototype.prepareChallenge = async function (cn, authorization) {
    assert.strictEqual(typeof cn, 'string');
    assert.strictEqual(typeof authorization, 'object');

    log(`prepareChallenge: http: ${this.forceHttpAuthorization} cn: ${cn} authorization: ${JSON.stringify(authorization)}`);

    // validation is cached by LE for 60 days or so. if a user switches from non-wildcard DNS (http challenge) to programmatic DNS (dns challenge), then
    // LE remembers the challenge type and won't give us a dns challenge for 60 days!
    // https://letsencrypt.org/docs/faq/#i-successfully-renewed-a-certificate-but-validation-didn-t-happen-this-time-how-is-that-possible
    const dnsChallenges = authorization.challenges.filter(function(x) { return x.type === 'dns-01'; });
    const httpChallenges = authorization.challenges.filter(function(x) { return x.type === 'http-01'; });

    if (this.forceHttpAuthorization || dnsChallenges.length === 0) {
        if (httpChallenges.length === 0) throw new BoxError(BoxError.ACME_ERROR, 'no http challenges');
        await this.prepareHttpChallenge(httpChallenges[0]);
        return httpChallenges[0];
    }

    await this.prepareDnsChallenge(cn, dnsChallenges[0]);
    return dnsChallenges[0];
};

Acme2.prototype.cleanupChallenge = async function (cn, challenge) {
    assert.strictEqual(typeof cn, 'string');
    assert.strictEqual(typeof challenge, 'object');

    log(`cleanupChallenge: http: ${this.forceHttpAuthorization}`);

    if (this.forceHttpAuthorization) {
        await this.cleanupHttpChallenge(challenge);
    } else {
        await this.cleanupDnsChallenge(cn, challenge);
    }
};

Acme2.prototype.acmeFlow = async function () {
    await this.ensureAccount();
    const { order, orderUrl } = await this.newOrder();

    for (const authorizationUrl of order.authorizations) {
        log(`acmeFlow: authorizing ${authorizationUrl}`);

        const response = await this.postAsGet(authorizationUrl);
        if (response.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Invalid response code getting authorization : ${response.status}`);
        const authorization = response.body; // { identifier, status, expires, challenges, wildcard }
        const cn = authorization.wildcard ? `*.${authorization.identifier.value}` : authorization.identifier.value;

        const challenge = await this.prepareChallenge(cn, authorization);
        await this.notifyChallengeReady(challenge);
        await this.waitForChallenge(challenge);
        await safe(this.cleanupChallenge(cn, challenge), { debug: log });
    }

    const csr = await openssl.createCsr(this.key, this.cn, this.altNames);
    await this.signCertificate(order.finalize, csr);
    const certUrl = await this.waitForOrder(orderUrl);
    const cert = await this.downloadCertificate(certUrl);

    const renewalInfo = typeof this.directory.renewalInfo === 'string' ? await getRenewalInfo(cert, this.directory.renewalInfo) : null;

    return { cert, csr, renewalInfo };
};

Acme2.prototype.loadDirectory = async function () {
    await promiseRetry({ times: 3, interval: 20000, log }, async () => {
        const response = await superagent.get(this.caDirectory).timeout(30000).ok(() => true);

        if (response.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Invalid response code when fetching directory : ${response.status}`);

        const body = response.body;

        if (typeof body.newNonce !== 'string' || typeof body.newOrder !== 'string' || typeof body.newAccount !== 'string') throw new BoxError(BoxError.ACME_ERROR, `Invalid response body : ${response.text}`);

        const availableProfiles = Object.keys(body.meta?.profiles || {}); // https://www.ietf.org/archive/id/draft-aaron-acme-profiles-01.txt
        if (this.profile && !availableProfiles.includes(this.profile)) throw new BoxError(BoxError.BAD_FIELD, `No such profile "${this.profile}" : ${response.text}`);

        this.directory = body; // has meta.profiles
    });
};

Acme2.prototype.getCertificate = async function () {
    log(`getCertificate: start acme flow for ${this.cn} from ${this.caDirectory}`);

    await this.loadDirectory();
    const result = await this.acmeFlow(); // { key, cert, csr, renewalInfo }
    log(`getCertificate: acme flow completed for ${this.cn}. renewalInfo: ${JSON.stringify(result.renewalInfo)}`);
    return result;
};

async function getCertificate(fqdn, domainObject, key) {
    assert.strictEqual(typeof fqdn, 'string'); // this can also be a wildcard domain (for alias domains)
    assert.strictEqual(typeof domainObject, 'object');
    assert.strictEqual(typeof key, 'string');

    // registering user with an email requires A or MX record (https://github.com/letsencrypt/boulder/issues/1197)
    // we cannot use admin@fqdn because the user might not have set it up.
    // we simply update the account with the latest email we have each time when getting letsencrypt certs
    // https://github.com/ietf-wg-acme/acme/issues/30
    const owner = await users.getOwner();
    const email = owner?.email || 'webmaster@cloudron.io'; // can error if not activated yet

    return await promiseRetry({ times: 3, interval: 0, log }, async function () {
        log(`getCertificate: for fqdn ${fqdn} and domain ${domainObject.domain}`);

        const acme = new Acme2(fqdn, domainObject, email, key, { /* profile: 'shortlived' */ });
        return await acme.getCertificate();
    });
}

const _name = 'acme';

export default {
    getCertificate,
    getRenewalInfo,
    _name,
    _getChallengeSubdomain: getChallengeSubdomain,
};
