import assert from 'node:assert';
import BoxError from './boxerror.js';
import crypto from 'node:crypto';
import logger from './logger.js';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import safe from 'safetydance';
import shellModule from './shell.js';

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


async function generateKey(type) {
    log(`generateKey: generating new key for${type}`);

    if (type === 'rsa4096') {
        return await shell.spawn('openssl', ['genrsa', '4096'], { encoding: 'utf8' });
    } else if (type === 'secp256r1') {
        return await shell.spawn('openssl', ['ecparam', '-genkey', '-name', type], { encoding: 'utf8' });
    }

    throw new BoxError(BoxError.INTERNAL_ERROR, `Unhandled key type ${type}`);
}

async function getModulus(pem) {
    assert.strictEqual(typeof pem, 'string');

    const stdout = await shell.spawn('openssl', ['rsa', '-modulus', '-noout'], { encoding: 'utf8', input: pem });
    const match = stdout.match(/Modulus=([0-9a-fA-F]+)$/m);
    if (!match) throw new BoxError(BoxError.OPENSSL_ERROR, 'Could not get modulus');
    return Buffer.from(match[1], 'hex');
}

async function pemToDer(pem) {
    assert.strictEqual(typeof pem, 'string');

    return await shell.spawn('openssl', ['req', '-inform', 'pem', '-outform', 'der'], { input: pem });
}

async function createCsr(key, cn, altNames) {
    assert.strictEqual(typeof key, 'string');

    const [error, tmpdir] = await safe(fs.promises.mkdtemp(path.join(os.tmpdir(), 'acme-')));
    if (error) throw new BoxError(BoxError.FS_ERROR, `Error creating temporary directory for openssl config: ${error.message}`);

    const keyFilePath = path.join(tmpdir, 'key');
    if (!safe.fs.writeFileSync(keyFilePath, key)) throw new BoxError(BoxError.FS_ERROR, `Failed to write key file: ${safe.error.message}`);

    // we used to use -addext to the CLI to add these but that arg doesn't work on Ubuntu 16.04
    // empty distinguished_name section is required for Ubuntu 16 openssl
    let conf = '[req]\nreq_extensions = v3_req\ndistinguished_name = req_distinguished_name\n'
        + '[req_distinguished_name]\n\n'
        + '[v3_req]\nbasicConstraints = CA:FALSE\nkeyUsage = nonRepudiation, digitalSignature, keyEncipherment\nsubjectAltName = @alt_names\n'
        + '[alt_names]\n';

    altNames.forEach((an, i) => conf += `DNS.${i+1} = ${an}\n`);

    const opensslConfigFile = path.join(tmpdir, 'openssl.conf');
    if (!safe.fs.writeFileSync(opensslConfigFile, conf)) throw new BoxError(BoxError.FS_ERROR, `Failed to write openssl config: ${safe.error.message}`);

    // while we pass the CN anyways, subjectAltName takes precedence
    const csrPem = await shell.spawn('openssl', ['req', '-new', '-key', keyFilePath, '-outform', 'PEM', '-subj', `/CN=${cn}`, '-config', opensslConfigFile], { encoding: 'utf8' });
    await safe(fs.promises.rm(tmpdir, { recursive: true, force: true }));
    log(`createCsr: csr file created for ${cn}`);
    return csrPem; // inspect with openssl req -text -noout -in hostname.csr -inform pem
};

async function getCertificateDates(cert) {
    assert.strictEqual(typeof cert, 'string');

    const [error, result] = await safe(shell.spawn('openssl', ['x509', '-startdate', '-enddate', '-subject', '-noout'], { encoding: 'utf8', input: cert }));
    if (error) return { startDate: null, endDate: null } ; // some error

    const lines = result.trim().split('\n');
    const notBefore = lines[0].split('=')[1];
    const notBeforeDate = new Date(notBefore);

    const notAfter = lines[1].split('=')[1];
    const notAfterDate = new Date(notAfter);

    const daysLeft = (notAfterDate - new Date())/(24 * 60 * 60 * 1000);
    log(`expiryDate: ${lines[2]} notBefore=${notBefore} notAfter=${notAfter} daysLeft=${daysLeft}`);

    return { startDate: notBeforeDate, endDate: notAfterDate };
}

async function getSubjectAndIssuer(cert) {
    assert.strictEqual(typeof cert, 'string');

    const subjectAndIssuer = await shell.spawn('openssl', ['x509', '-noout', '-subject', '-issuer'], { encoding: 'utf8', input: cert });
    const subject = subjectAndIssuer.match(/^subject=(.*)$/m)[1];
    const issuer = subjectAndIssuer.match(/^issuer=(.*)$/m)[1];

    return { subject, issuer };
}

// this is used in migration - 20211006200150-domains-ensure-fallbackCertificate.js
async function generateCertificate(domain) {
    assert.strictEqual(typeof domain, 'string');

    const certFilePath = path.join(os.tmpdir(), `${domain}-${crypto.randomBytes(4).readUInt32LE(0)}.cert`);
    const keyFilePath = path.join(os.tmpdir(), `${domain}-${crypto.randomBytes(4).readUInt32LE(0)}.key`);

    const opensslConf = safe.fs.readFileSync('/etc/ssl/openssl.cnf', 'utf8');
    const cn = domain;

    log(`generateCertificate: domain=${domain} cn=${cn}`);

    // SAN must contain all the domains since CN check is based on implementation if SAN is found. -checkhost also checks only SAN if present!
    const opensslConfWithSan = `${opensslConf}\n[SAN]\nsubjectAltName=DNS:${domain},DNS:*.${cn}\n`;
    const configFile = path.join(os.tmpdir(), 'openssl-' + crypto.randomBytes(4).readUInt32LE(0) + '.conf');
    safe.fs.writeFileSync(configFile, opensslConfWithSan, 'utf8');
    // the days field is chosen to be less than 825 days per apple requirement (https://support.apple.com/en-us/HT210176)
    await shell.spawn('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-keyout', keyFilePath, '-out', certFilePath, '-days', '800', '-subj', `/CN=*.${cn}`, '-extensions', 'SAN', '-config', configFile, '-nodes'], { encoding: 'utf8 '});
    safe.fs.unlinkSync(configFile);

    const cert = safe.fs.readFileSync(certFilePath, 'utf8');
    if (!cert) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
    safe.fs.unlinkSync(certFilePath);

    const key = safe.fs.readFileSync(keyFilePath, 'utf8');
    if (!key) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
    safe.fs.unlinkSync(keyFilePath);

    return { cert, key };
}

async function hasExpired(cert) {
    const [error] = await safe(shell.spawn('openssl', ['x509', '-checkend', '0'], { input: cert }));
    return !!error;
}

async function getPublicKey(pem, type) {
    if (type === 'cert') {
        const [pubKeyError1, pubKeyFromCert] = await safe(shell.spawn('openssl', ['x509', '-noout', '-pubkey'], { encoding: 'utf8', input: pem }));
        if (pubKeyError1) throw new BoxError(BoxError.BAD_FIELD, 'Could not get public key from cert');
        return pubKeyFromCert;
    } else if (type === 'key') {
        const [pubKeyError2, pubKeyFromKey] = await safe(shell.spawn('openssl', ['pkey', '-pubout'], { encoding: 'utf8', input: pem }));
        if (pubKeyError2) throw new BoxError(BoxError.BAD_FIELD, 'Could not get public key from private key');
        return pubKeyFromKey;
    }
}

async function checkHost(cert, fqdn) {
    const checkHostOutput = await shell.spawn('openssl', ['x509', '-noout', '-checkhost', fqdn], { encoding: 'utf8', input: cert });
    return checkHostOutput.indexOf('does match certificate') !== -1;
}

async function generateDkimKey() {
    const publicKeyFilePath = path.join(os.tmpdir(), `dkim-${crypto.randomBytes(4).readUInt32LE(0)}.public`);
    const privateKeyFilePath = path.join(os.tmpdir(), `dkim-${crypto.randomBytes(4).readUInt32LE(0)}.private`);

    // https://www.unlocktheinbox.com/dkim-key-length-statistics/ and https://docs.aws.amazon.com/ses/latest/DeveloperGuide/send-email-authentication-dkim-easy.html for key size
    await shell.spawn('openssl', ['genrsa', '-out', privateKeyFilePath, '1024'], {});
    await shell.spawn('openssl', ['rsa', '-in', privateKeyFilePath, '-out', publicKeyFilePath, '-pubout', '-outform', 'PEM'], {});

    const publicKey = safe.fs.readFileSync(publicKeyFilePath, 'utf8');
    if (!publicKey) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
    safe.fs.unlinkSync(publicKeyFilePath);

    const privateKey = safe.fs.readFileSync(privateKeyFilePath, 'utf8');
    if (!privateKey) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
    safe.fs.unlinkSync(privateKeyFilePath);

    return { publicKey, privateKey };
}

async function generateDhparam() {
    log('generateDhparam: generating dhparams');
    return await shell.spawn('openssl', ['dhparam', '-dsaparam', '2048'], { encoding: 'utf8' });
}

// note: https://tools.ietf.org/html/rfc4346#section-7.4.2 (certificate_list) requires that the
// servers certificate appears first (and not the intermediate cert)
async function validateCertificate(subdomain, domain, certificate) {
    assert.strictEqual(typeof subdomain, 'string');
    assert.strictEqual(typeof domain, 'string');
    assert(certificate && typeof certificate, 'object');

    const { cert, key } = certificate;

    // check for empty cert and key strings
    if (!cert && key) throw new BoxError(BoxError.BAD_FIELD, 'missing cert');
    if (cert && !key) throw new BoxError(BoxError.BAD_FIELD, 'missing key');

    // -checkhost checks for SAN or CN exclusively. SAN takes precedence and if present, ignores the CN.
    const fqdn = subdomain + (subdomain ? '.' : '') + domain;

    const [checkHostError, match] = await safe(checkHost(cert, fqdn));
    if (checkHostError) throw new BoxError(BoxError.BAD_FIELD, 'Could not validate certificate');
    if (!match) throw new BoxError(BoxError.BAD_FIELD, `Certificate is not valid for this domain. Expecting ${fqdn}`);

    // check if public key in the cert and private key matches. pkey below works for RSA and ECDSA keys
    const [pubKeyError1, pubKeyFromCert] = await safe(getPublicKey(cert, 'cert'));
    if (pubKeyError1) throw new BoxError(BoxError.BAD_FIELD, 'Could not get public key from cert');
    const [pubKeyError2, pubKeyFromKey] = await safe(getPublicKey(key, 'key'));
    if (pubKeyError2) throw new BoxError(BoxError.BAD_FIELD, 'Could not get public key from private key');

    if (pubKeyFromCert !== pubKeyFromKey) throw new BoxError(BoxError.BAD_FIELD, 'Public key does not match the certificate.');

    // check expiration
    const expired = await hasExpired(cert);
    if (expired) throw new BoxError(BoxError.BAD_FIELD, 'Certificate has expired');

    return null;
}

async function getSerial(pem) {
    assert.strictEqual(typeof pem, 'string');

    const serialOut = await shell.spawn('openssl', ['x509',  '-noout', '-serial'], { encoding: 'utf8', input: pem });
    return Buffer.from(serialOut.trim().split('=')[1], 'hex'); // serial=xx
}

async function getAuthorityKeyId(pem) {
    assert.strictEqual(typeof pem, 'string');

    const stdout = await shell.spawn('openssl', ['x509', '-noout', '-text'], { encoding: 'utf8', input: pem });
    // if there is multiple AKI, it can have "keyid:" . length is also not fixed to 59
    const akiMatch = stdout.match(/Authority Key Identifier[\s\S]*?\n\s*([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2})+)/m);
    if (!akiMatch) throw new BoxError(BoxError.OPENSSL_ERROR, 'AKI not found');
    return Buffer.from(akiMatch[1].replace(/:/g, ''), 'hex');
}

export default {
    createCsr,
    generateKey,
    getModulus,
    pemToDer,
    getCertificateDates,
    getSubjectAndIssuer,
    generateCertificate,
    hasExpired,
    getPublicKey,
    checkHost,
    generateDkimKey,
    generateDhparam,
    validateCertificate,
    getSerial,
    getAuthorityKeyId
};
