'use strict';

const { validateSvg } = require('mailauth/lib/bimi/validate-svg');
const { vmc } = require('@postalsys/vmc');
const { formatDomain, getAlignment } = require('mailauth/lib/tools');
const { bimi: bimiLookup } = require('mailauth/lib/bimi');
const crypto = require('crypto');
const log = require('npmlog');

const FETCH_TIMEOUT = 5 * 1000;

// Use fake User-Agent to pass UA checks for Akamai
const USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/81.0';

const { fetch: fetchCmd, Agent } = require('undici');
const fetchAgent = new Agent({ connect: { timeout: FETCH_TIMEOUT } });

class BimiHandler {
    static create(options = {}) {
        return new BimiHandler(options);
    }

    constructor(options) {
        this.options = options || {};

        this.database = options.database;
        this.loggelf = options.loggelf || (() => false);
    }

    async download(url, bimiDocument, bimiType, bimiDomain) {
        if (!url) {
            return false;
        }

        bimiDocument = bimiDocument || {};

        const parsedUrl = new URL(url);

        switch (parsedUrl.protocol) {
            case 'https:':
                break;

            case 'http:': {
                let error = new Error(`Only HTTPS addresses are allowed`);
                error.code = 'PROTO_NOT_HTTPS';

                error.source = 'pre-request';
                throw error;
            }

            default: {
                let error = new Error(`Unknown protocol ${parsedUrl.protocol}`);
                error.code = 'UNKNOWN_PROTO';

                error.source = 'pre-request';
                throw error;
            }
        }

        const headers = {
            // Comment: AKAMAI does some strange UA based filtering that messes up the request
            // 'User-Agent': `${packageData.name}/${packageData.version} (+${packageData.homepage}`
            'User-Agent': USER_AGENT
        };

        if (bimiDocument.etag) {
            headers['If-None-Match'] = bimiDocument.etag;
        }

        if (bimiDocument.lastModified) {
            headers['If-Modified-Since'] = bimiDocument.lastModified;
        }

        let res = await fetchCmd(parsedUrl, {
            headers,
            redirect: 'manual',
            dispatcher: fetchAgent
        });

        if (res.status === 304) {
            // no changes
            let err = new Error('No changes');
            err.code = 'NO_CHANGES';
            throw err;
        }

        if (!res.ok) {
            let error = new Error(`Request failed with status ${res.status}`);
            error.code = 'HTTP_REQUEST_FAILED';

            this.loggelf({
                short_message: `[BIMI FETCH] ${url}`,
                _mail_action: 'bimi_fetch',
                _bimi_url: url,
                _bimi_type: bimiType,
                _bimi_domain: bimiDomain,
                _req_etag: bimiDocument.etag,
                _req_last_modified: bimiDocument.lastModified,
                _failure: 'yes',
                _error: error.message,
                _err_code: error.code
            });

            throw error;
        }

        const arrayBufferValue = await res.arrayBuffer();
        const content = Buffer.from(arrayBufferValue);

        this.loggelf({
            short_message: `[BIMI FETCH] ${url}`,
            _mail_action: 'bimi_fetch',
            _bimi_url: url,
            _bimi_type: bimiType,
            _bimi_domain: bimiDomain,
            _status_code: res.status,
            _req_etag: bimiDocument.etag,
            _req_last_modified: bimiDocument.lastModified,
            _res_etag: res.headers.get('ETag'),
            _res_last_modified: res.headers.get('Last-Modified')
        });

        if (!res.status || res.status < 200 || res.status >= 300) {
            let err = new Error(`Invalid response code ${res.status || '-'}`);

            if (res.headers.get('Location') && res.status >= 300 && res.status < 400) {
                err.code = 'REDIRECT_NOT_ALLOWED';
            } else {
                err.code = 'HTTP_STATUS_' + (res.status || 'NA');
            }

            err.details = err.details || {
                code: res.status,
                url,
                etag: bimiDocument.etag,
                lastModified: bimiDocument.lastModified,
                location: res.headers.get('Location')
            };

            throw err;
        }
        return {
            content,
            etag: res.headers.get('ETag'),
            lastModified: res.headers.get('Last-Modified')
        };
    }

    async getBimiData(url, type, bimiDomain) {
        if (!url) {
            return false;
        }

        const now = new Date();

        let bimiDocument = await this.database.collection('bimi').findOne({ url, type });

        let bimiTtl = bimiDocument?.error ? 1 * 3600 * 1000 : 24 * 3600 * 1000;

        if (bimiDocument && bimiDocument?.updated > new Date(now.getTime() - bimiTtl)) {
            if (
                bimiDocument.error &&
                // ignore errors if a valid VMC is cached
                !(type === 'authority' && bimiDocument?.vmc?.certificate?.validTo && new Date(bimiDocument?.vmc?.certificate?.validTo) >= now)
            ) {
                let error = new Error(bimiDocument.error.message);
                if (bimiDocument.error.details) {
                    error.details = bimiDocument.error.details;
                }
                if (bimiDocument.error.code) {
                    error.code = bimiDocument.error.code;
                }

                error.source = 'db';
                throw error;
            }

            if (bimiDocument?.content?.buffer) {
                bimiDocument.content = bimiDocument.content.buffer;
            }

            bimiDocument.source = 'db';
            bimiDocument.error = null; // override existing error if using a cached valid VMC
            return bimiDocument;
        }

        let bimiDocumentUpdate = {
            updated: now
        };

        // Step 1. Download

        let file;
        try {
            let { content, etag, lastModified } = await this.download(url, bimiDocument, type, bimiDomain);
            bimiDocumentUpdate.etag = etag || null;
            bimiDocumentUpdate.lastModified = lastModified || null;

            file = content;
        } catch (err) {
            if (err.code === 'NO_CHANGES') {
                if (bimiDocument?.content?.buffer && bimiDocument.error?.type === 'download') {
                    // download failed last time, so run validations again
                    file = bimiDocument.content.buffer;
                } else {
                    // existing document is good enough, proceed to checkout
                    let r = await this.database.collection('bimi').findOneAndUpdate(
                        {
                            type,
                            url
                        },
                        {
                            $set: bimiDocumentUpdate,
                            $setOnInsert: {
                                url,
                                type,
                                created: new Date()
                            }
                        },
                        { upsert: true, returnDocument: 'after' }
                    );

                    let updatedBimiDocument = r?.value;
                    if (updatedBimiDocument?.content?.buffer) {
                        updatedBimiDocument.content = updatedBimiDocument.content.buffer;
                    }

                    if (bimiDocument.error) {
                        let error = new Error(bimiDocument.error.message);
                        if (bimiDocument.error.details) {
                            error.details = bimiDocument.error.details;
                        }
                        if (bimiDocument.error.code) {
                            error.code = bimiDocument.error.code;
                        }

                        error.source = 'cache-hit';
                        throw error;
                    }

                    updatedBimiDocument.source = 'cache-hit';
                    return updatedBimiDocument;
                }
            } else {
                bimiDocumentUpdate.error = {
                    message: err.message,
                    details: err.details,
                    code: err.code,
                    type: 'download',
                    time: now
                };

                try {
                    await this.database.collection('bimi').updateOne(
                        {
                            url,
                            type
                        },
                        {
                            $set: bimiDocumentUpdate,
                            $setOnInsert: {
                                type,
                                url,
                                created: new Date()
                            }
                        },
                        { upsert: true }
                    );
                } catch (err) {
                    // ignore
                }

                err.source = 'post-request';
                throw err;
            }
        }

        // Step 2. Validate VMC
        if (type === 'authority') {
            try {
                let vmcData = await vmc(file);

                if (!vmcData.logoFile) {
                    let error = new Error('VMC does not contain a logo file');
                    error.code = 'MISSING_VMC_LOGO';

                    error.source = 'post-request';
                    throw error;
                }

                if (vmcData?.mediaType?.toLowerCase() !== 'image/svg+xml') {
                    let error = new Error('Invalid media type for the logo file');
                    error.details = {
                        mediaType: vmcData.mediaType
                    };
                    error.code = 'INVALID_MEDIATYPE';

                    error.source = 'post-request';
                    throw error;
                }

                if (!vmcData.validHash) {
                    let error = new Error('VMC hash does not match logo file');
                    error.details = {
                        hashAlgo: vmcData.hashAlgo,
                        hashValue: vmcData.hashValue,
                        logoFile: vmcData.logoFile
                    };
                    error.code = 'INVALID_LOGO_HASH';

                    error.source = 'post-request';
                    throw error;
                }

                bimiDocumentUpdate.content = Buffer.from(vmcData.logoFile, 'base64');
                bimiDocumentUpdate.vmc = vmcData;
            } catch (err) {
                bimiDocumentUpdate.error = {
                    message: err.message,
                    details: err.details,
                    code: err.code,
                    type: 'vmc',
                    time: now
                };

                try {
                    await this.database.collection('bimi').updateOne(
                        {
                            type,
                            url
                        },
                        {
                            $set: bimiDocumentUpdate,
                            $setOnInsert: {
                                type,
                                url,
                                created: new Date()
                            }
                        },
                        { upsert: true }
                    );
                } catch (err) {
                    // ignore
                }

                err.source = err.source || 'post-request';
                throw err;
            }
        } else {
            bimiDocumentUpdate.content = file;
        }

        // Step 3. Validate SVG

        try {
            validateSvg(bimiDocumentUpdate.content);
        } catch (err) {
            let error = new Error('VMC logo SVG validation failed');
            error.details = Object.assign(
                {
                    message: err.message
                },
                error.details || {},
                err.code ? { code: err.code } : {}
            );
            error.code = 'SVG_VALIDATION_FAILED';

            bimiDocumentUpdate.error = {
                message: error.message,
                details: error.details,
                code: error.code,
                type: 'svg',
                time: now
            };

            try {
                await this.database.collection('bimi').updateOne(
                    {
                        type,
                        url
                    },
                    {
                        $set: bimiDocumentUpdate,
                        $setOnInsert: {
                            type,
                            url,
                            created: new Date()
                        }
                    },
                    { upsert: true }
                );
            } catch (err) {
                // ignore
            }

            error.source = 'post-request';
            throw error;
        }

        // clear pending errors
        bimiDocumentUpdate.error = null;

        let r = await this.database.collection('bimi').findOneAndUpdate(
            {
                type,
                url
            },
            {
                $set: bimiDocumentUpdate,
                $setOnInsert: {
                    type,
                    url,
                    created: new Date()
                }
            },
            { upsert: true, returnDocument: 'after' }
        );

        let updatedBimiDocument = r?.value;

        if (updatedBimiDocument?.content?.buffer) {
            updatedBimiDocument.content = updatedBimiDocument.content.buffer;
        }

        updatedBimiDocument.source = 'new';
        return updatedBimiDocument;
    }

    async getInfo(bimiData) {
        let [
            { reason: locationError, value: locationValue, status: locationStatus },
            { reason: authorityError, value: authorityValue, status: authorityStatus }
        ] = await Promise.allSettled([
            this.getBimiData(bimiData.location, 'location', bimiData.status?.header?.d),
            this.getBimiData(bimiData.authority, 'authority', bimiData.status?.header?.d)
        ]);

        if (authorityError) {
            throw authorityError;
        }

        if (authorityStatus === 'fulfilled' && authorityValue) {
            let selector = bimiData.status?.header?.selector;
            let d = bimiData.status?.header?.d;

            // validate domain
            let selectorSet = [];
            let domainSet = [];
            authorityValue.vmc?.certificate?.subjectAltName?.map(formatDomain)?.forEach(domain => {
                if (/\b_bimi\./.test(domain)) {
                    selectorSet.push(domain);
                } else {
                    domainSet.push(domain);
                }
            });

            let domainVerified = false;

            if (selector && selectorSet.includes(formatDomain(`${selector}._bimi.${d}`))) {
                domainVerified = true;
            } else {
                let alignedDomain = getAlignment(d, domainSet, false);
                if (alignedDomain) {
                    domainVerified = true;
                }
            }

            if (!domainVerified) {
                let error = new Error('Domain can not be verified');
                error.details = {
                    subjectAltName: authorityValue.vmc?.certificate?.subjectAltName,
                    selector,
                    d
                };
                error.code = 'VMC_DOMAIN_MISMATCH';
                throw error;
            }

            if (locationStatus === 'fulfilled' && locationValue?.content && authorityValue.vmc?.hashAlgo && authorityValue.vmc?.validHash) {
                let hash = crypto
                    .createHash(authorityValue.vmc.hashAlgo)
                    //sss
                    .update(locationValue.content)
                    .digest('hex');
                if (hash === authorityValue.vmc.hashValue) {
                    // logo files match, so location URL is safe to use
                    authorityValue.locationUrl = bimiData.location;
                } else {
                    log.info(
                        'BIMI',
                        'Logo files from l= and a= do not match lh=%s ah=%s algo=%s d=%s',
                        hash,
                        authorityValue.vmc.hashValue,
                        authorityValue.vmc.hashAlgo,
                        d
                    );
                    authorityValue.locationUrl = bimiData.location;
                }
            }

            return authorityValue;
        }

        // If signed VMC was ok, then ignore any errors from regular SVG as this would not be used anyway
        if (locationError) {
            throw locationError;
        }

        return locationStatus === 'fulfilled' && locationValue;
    }

    /**
     * Helper method to fetch BIMI info for a domain name and selector
     * @param {String} domain
     * @param {String} [selector]
     * @returns {Object} BIMI record
     */
    async fetchByDomain(domain, selector) {
        const bimiVerificationResults = await bimiLookup({
            dmarc: {
                status: {
                    result: 'pass',
                    header: {
                        from: domain
                    }
                },
                domain,
                policy: 'reject'
            },

            headers: {
                parsed:
                    selector && selector !== 'default'
                        ? [
                              {
                                  key: 'bimi-selector',
                                  line: `v=BIMI1; s=${selector}`
                              }
                          ]
                        : []
            }
        });

        return await this.getInfo(bimiVerificationResults);
    }
}

module.exports = BimiHandler;

/*
const db = require('./db');
db.connect(() => {
    let bimi = BimiHandler.create({
        database: db.database
    });

    let zoneBimi = {
        status: {
            header: {
                selector: 'default',
                d: 'zone.ee'
            },
            result: 'pass'
        },
        rr: 'v=BIMI1; l=https://zone.ee/common/img/zone_profile_square_bimi.svg;a=https://zone.ee/.well-known/bimi.pem',
        location: 'https://zone.ee/common/img/zone_profile_square_bimi.svg',
        authority: 'https://zone.ee/.well-known/bimi.pem',
        info: 'bimi=pass header.selector=default header.d=zone.ee'
    };

    zoneBimi = {
        status: {
            header: {
                selector: 'default',
                d: 'ups.com'
            },
            result: 'pass'
        },
        rr: 'v=BIMI1; l=https://www.ups.com/assets/resources/bimi/ups_bimi_logo.svg;  a=https://www.ups.com/assets/resources/bimi/ups_bimi_vmc.pem;',
        location: 'https://www.ups.com/assets/resources/bimi/ups_bimi_logo.svg',
        authority: 'https://www.ups.com/assets/resources/bimi/ups_bimi_vmc.pem',
        info: 'bimi=pass header.selector=default header.d=ups.com'
    };

    bimi.getInfo(zoneBimi)
        .then(result => console.log(require('util').inspect(result, false, 22)))
        .catch(err => console.error(err))
        .finally(() => process.exit());
});
*/
