'use strict';

const log = require('npmlog');
const ObjectID = require('mongodb').ObjectID;
const forward = require('./forward');
const autoreply = require('./autoreply');
const Maildropper = require('./maildropper');
const tools = require('./tools');
const consts = require('./consts');
const util = require('util');

class FilterHandler {
    constructor(options) {
        this.db = options.db;
        this.messageHandler = options.messageHandler;

        this.prepareMessage = util.promisify(this.messageHandler.prepareMessage.bind(this.messageHandler));
        this.encryptMessage = util.promisify(this.messageHandler.encryptMessage.bind(this.messageHandler));

        this.addMessage = util.promisify((...args) => {
            let callback = args.pop();
            this.messageHandler.add(...args, (err, status, data) => {
                if (err) {
                    return callback(err);
                }
                return callback(null, { status, data });
            });
        });

        this.ttlcounter = util.promisify(this.messageHandler.counters.ttlcounter.bind(this.messageHandler.counters));
        this.forward = util.promisify(forward);
        this.autoreply = util.promisify(autoreply);

        this.maildrop = new Maildropper({
            db: this.db,
            zone: options.sender.zone,
            collection: options.sender.collection,
            gfs: options.sender.gfs,
            loopSecret: options.sender.loopSecret
        });

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

    getUserData(address, callback) {
        let query = {};
        if (!address) {
            return callback(null, false);
        }
        if (typeof address === 'object' && address._id) {
            return callback(null, address);
        }

        let collection;

        if (tools.isId(address)) {
            query._id = new ObjectID(address);
            collection = 'users';
        } else if (typeof address !== 'string') {
            return callback(null, false);
        } else if (address.indexOf('@') >= 0) {
            query.addrview = tools.uview(address);
            collection = 'addresses';
        } else {
            query.unameview = address.replace(/\./g, '');
            collection = 'users';
        }

        let fields = {
            name: true,
            forwards: true,
            targets: true,
            autoreply: true,
            encryptMessages: true,
            encryptForwarded: true,
            pubKey: true,
            spamLevel: true,
            tagsview: true
        };

        if (collection === 'users') {
            return this.db.users.collection('users').findOne(
                query,
                {
                    projection: fields
                },
                callback
            );
        }

        return this.db.users.collection('addresses').findOne(query, (err, addressData) => {
            if (err) {
                return callback(err);
            }
            if (!addressData || !!addressData.user) {
                return callback(null, false);
            }
            return this.db.users.collection('users').findOne(
                {
                    _id: addressData.user
                },
                {
                    projection: fields
                },
                callback
            );
        });
    }

    process(options, callback) {
        this.getUserData(options.user || options.recipient, (err, userData) => {
            if (err) {
                return callback(err);
            }

            if (!userData) {
                return callback(null, false);
            }

            this.storeMessage(userData, options)
                .then(status => callback(null, status.response, status.prepared))
                .catch(callback);
        });
    }

    async storeMessage(userData, options) {
        let sender = options.sender || '';
        let recipient = options.recipient || userData.address;

        let filterResults = [];

        // create Delivered-To and Return-Path headers
        let extraHeader = Buffer.from(['Delivered-To: ' + recipient, 'Return-Path: <' + sender + '>'].join('\r\n') + '\r\n');

        let chunks = options.chunks;
        let chunklen = options.chunklen;

        if (!chunks && options.raw) {
            chunks = [options.raw];
            chunklen = options.raw.length;
        }

        let rawchunks = chunks;

        let prepared;

        if (options.mimeTree) {
            if (options.mimeTree && options.mimeTree.header) {
                // remove old headers
                if (/^Delivered-To/.test(options.mimeTree.header[0])) {
                    options.mimeTree.header.shift();
                }
                if (/^Return-Path/.test(options.mimeTree.header[0])) {
                    options.mimeTree.header.shift();
                }
            }
            prepared = await this.prepareMessage({
                mimeTree: options.mimeTree
            });
        } else {
            let raw = Buffer.concat(chunks, chunklen);
            prepared = await this.prepareMessage({
                raw
            });
        }

        prepared.mimeTree.header.unshift('Return-Path: <' + sender + '>');
        prepared.mimeTree.header.unshift('Delivered-To: ' + recipient);

        prepared.mimeTree.parsedHeader['return-path'] = '<' + sender + '>';
        prepared.mimeTree.parsedHeader['delivered-to'] = '<' + recipient + '>';

        // updated Delivered-To in indexed headers object
        for (let i = prepared.headers.length - 1; i > 0; i--) {
            if (prepared.headers.key === 'delivered-to') {
                prepared.headers.splice(i, 1);
            }
        }
        prepared.headers.push({ key: 'delivered-to', value: recipient.toLowerCase() });

        prepared.size = this.messageHandler.indexer.getSize(prepared.mimeTree);

        let maildata = options.maildata || this.messageHandler.indexer.getMaildata(prepared.mimeTree);

        // default flags are empty
        let flags = [];

        // default mailbox target is INBOX
        let mailboxQueryKey = 'path';
        let mailboxQueryValue = 'INBOX';

        // allow to define mailbox
        if (options.mailbox && tools.isId(options.mailbox)) {
            mailboxQueryKey = 'mailbox';
            mailboxQueryValue = new ObjectID(options.mailbox);
        }

        let meta = options.meta || {};

        let parsedHeader = (prepared.mimeTree.parsedHeader && prepared.mimeTree.parsedHeader) || {};

        let received = [].concat(parsedHeader.received || []);
        if (received.length) {
            let receivedData = parseReceived(received[0]);

            if (!receivedData.has('id') && received.length > 1) {
                receivedData = parseReceived(received[1]);
            }

            if (receivedData.has('with')) {
                meta.transtype = receivedData.get('with');
            }

            if (receivedData.has('id')) {
                meta.queueId = receivedData.get('id');
            }

            if (receivedData.has('from')) {
                meta.origin = receivedData.get('from');
            }
        }

        let filters = [];
        try {
            filters = await this.db.database
                .collection('filters')
                .find({
                    user: userData._id,
                    disabled: { $ne: true }
                })
                .sort({
                    _id: 1
                })
                .toArray();
        } catch (err) {
            // ignore as filters are not so importand
        }

        let isEncrypted = false;
        let forwardTargets = new Map();

        let matchingFilters = [];
        let filterActions = new Map();

        // check global whitelist/blacklist before filters
        if (userData.tagsview && userData.tagsview.length) {
            let from = parsedHeader.from || parsedHeader.sender;
            from = [].concat(from || []);
            tools.decodeAddresses(from);
            from = tools.flatAddresses(from);

            if (from && from.length) {
                from = from[0];
                let domain = tools.normalizeDomain(from.address.split('@').pop());
                try {
                    let domainaccessData = await this.db.database.collection('domainaccess').findOne({
                        tag: { $in: userData.tagsview },
                        domain
                    });

                    if (domainaccessData) {
                        switch (domainaccessData.action) {
                            case 'block':
                                filterActions.set('spam', true);
                                matchingFilters.push(`block:${domainaccessData.tag}:${domainaccessData._id}`);
                                break;
                            case 'allow':
                                filterActions.set('spam', false);
                                matchingFilters.push(`allow:${domainaccessData.tag}:${domainaccessData._id}`);
                                break;
                        }
                    }
                } catch (err) {
                    // ignore, not important
                }
            }
        }

        for (let filterData of filters) {
            if (!(await checkFilter(filterData, prepared, maildata))) {
                continue;
            }

            matchingFilters.push(filterData.id || filterData._id);

            // apply matching filter
            Object.keys(filterData.action).forEach(key => {
                if (key === 'targets') {
                    [].concat(filterData.action[key] || []).forEach(target => {
                        forwardTargets.set(target.value, target);
                    });
                    return;
                }

                // if a previous filter already has set a value then do not touch it
                if (!filterActions.has(key)) {
                    filterActions.set(key, filterData.action[key]);
                }
            });
        }

        if (typeof userData.spamLevel === 'number' && userData.spamLevel >= 0) {
            let isSpam;

            if (userData.spamLevel === 0) {
                // always mark as spam
                isSpam = true;
            } else if (userData.spamLevel === 100) {
                // always mark as ham
                isSpam = false;
                filterActions.set('spam', false);
            } else if (!filterActions.has('spam')) {
                let spamScore;
                switch (meta.spamAction) {
                    case 'reject':
                        spamScore = 75;
                        break;

                    case 'rewrite subject':
                    case 'soft reject':
                    case 'greylist':
                        spamScore = 50;
                        break;

                    case 'add header':
                        spamScore = 25;
                        break;

                    case 'no action':
                    default:
                        spamScore = 0;
                        break;
                }
                isSpam = spamScore >= userData.spamLevel;
            }

            if (isSpam && !filterActions.has('spam')) {
                // only update if spam decision is not yet made
                filterActions.set('spam', true);
            }
        }

        let encryptMessage = async () => {
            if (isEncrypted) {
                return;
            }

            let encrypted = await this.encryptMessage(userData.pubKey, {
                chunks,
                chunklen
            });

            if (encrypted) {
                chunks = [encrypted];
                chunklen = encrypted.length;
                isEncrypted = true;

                prepared = await this.prepareMessage({
                    raw: Buffer.concat([extraHeader, encrypted])
                });
                maildata = this.messageHandler.indexer.getMaildata(prepared.mimeTree);
            }
        };

        let forwardMessage = async () => {
            if (!filterActions.get('delete')) {
                // forward to default recipient only if the message is not deleted

                if (userData.targets && userData.targets.length) {
                    userData.targets.forEach(targetData => {
                        let key = targetData.value;
                        if (targetData.type === 'relay') {
                            targetData.recipient = userData.address;
                            key = `${targetData.recipient}:${targetData.value}`;
                        }
                        forwardTargets.set(key, targetData);
                    });
                } else if (options.targets && options.targets.length) {
                    // if user had no special targets, then use default ones provided by options
                    options.targets.forEach(targetData => {
                        let key = targetData.value;
                        if (targetData.type === 'relay') {
                            targetData.recipient = userData.address;
                            key = `${targetData.recipient}:${targetData.value}`;
                        }
                        forwardTargets.set(key, targetData);
                    });
                }
            }

            // never forward messages marked as spam
            if (!forwardTargets.size) {
                return false;
            }

            const targets = Array.from(forwardTargets).map(row => ({
                type: row[1].type,
                value: row[1].value,
                recipient
            }));

            const logdata = {
                _user: userData._id.toString(),
                _mail_action: 'forward',
                _sender: sender,
                _recipient: recipient,
                _target_address: (targets || []).map(target => ((target && target.value) || target).toString().replace(/\?.*$/, '')).join('\n'),
                _message_id: prepared.mimeTree.parsedHeader['message-id']
            };

            if (filterActions.get('spam')) {
                logdata.short_message = '[FRWRDFAIL] Skipped forwarding due to spam';
                logdata._error = 'Skipped forwarding due to spam';
                logdata._code = 'ESPAM';
                this.loggelf(logdata);
                return;
            }

            // check limiting counters
            try {
                let counterResult = await this.ttlcounter(
                    'wdf:' + userData._id.toString(),
                    forwardTargets.size,
                    userData.forwards || consts.MAX_FORWARDS,
                    false
                );
                if (!counterResult.success) {
                    log.silly('Filter', 'FRWRDFAIL key=%s error=%s', 'wdf:' + userData._id.toString(), 'Precondition failed');

                    logdata.short_message = '[FRWRDFAIL] Skipped forwarding due to rate limiting';
                    logdata._error = 'Skipped forwarding due to rate limiting';
                    logdata._code = 'ERATELIMIT';
                    this.loggelf(logdata);
                    return false;
                }
            } catch (err) {
                // failed checks, ignore
                log.info('Filter', 'FRWRDFAIL key=%s error=%s', 'wdf:' + userData._id.toString(), err.message);

                logdata.short_message = '[FRWRDFAIL] Skipped forwarding due to database error';
                logdata._error = err.message;
                logdata._code = err.code;
                this.loggelf(logdata);
            }

            if (userData.encryptForwarded && userData.pubKey) {
                await encryptMessage();
            }

            try {
                let forwardResponse = await this.forward({
                    db: this.db,
                    maildrop: this.maildrop,

                    parentId: prepared.id,
                    userData,
                    sender,
                    recipient,

                    targets,

                    chunks,
                    chunklen
                });

                if (forwardResponse) {
                    logdata.short_message = '[FRWRDOK] Scheduled forwarding';
                    logdata._target_queue_id = forwardResponse;
                    this.loggelf(logdata);
                }

                return forwardResponse;
            } catch (err) {
                logdata.short_message = '[FRWRDFAIL] Skipped forwarding due to queueing error';
                logdata._error = err.message;
                logdata._code = err.code;
                this.loggelf(logdata);
            }
        };

        let sendAutoreply = async () => {
            // never reply to messages marked as spam
            if (!sender || !userData.autoreply || filterActions.get('spam') || options.disableAutoreply) {
                return;
            }

            let curtime = new Date();
            let autoreplyData = await this.db.database.collection('autoreplies').findOne({
                user: userData._id
            });

            if (!autoreplyData || !autoreplyData.status) {
                return false;
            }

            if (autoreplyData.start && autoreplyData.start > curtime) {
                return false;
            }

            if (autoreplyData.end && autoreplyData.end < curtime) {
                return false;
            }

            let autoreplyResponse = await this.autoreply(
                {
                    db: this.db,
                    maildrop: this.maildrop,

                    parentId: prepared.id,
                    userData,
                    sender,
                    recipient,
                    chunks,
                    chunklen,
                    messageHandler: this.messageHandler
                },
                autoreplyData
            );

            return autoreplyResponse;
        };

        let outbound = [];

        try {
            let forwardId = await forwardMessage();
            if (forwardId) {
                filterResults.push({
                    forward: Array.from(forwardTargets)
                        .map(row => row[0])
                        .join(','),
                    'forward-queue-id': forwardId
                });
                outbound.push(forwardId);
                log.silly(
                    'Filter',
                    '%s FRWRDOK id=%s from=%s to=%s target=%s',
                    prepared.id.toString(),
                    forwardId,
                    sender,
                    recipient,
                    Array.from(forwardTargets)
                        .map(row => row[0])
                        .join(',')
                );
            }
        } catch (err) {
            log.error(
                'Filter',
                '%s FRWRDFAIL from=%s to=%s target=%s error=%s',
                prepared.id.toString(),
                sender,
                recipient,
                Array.from(forwardTargets)
                    .map(row => row[0])
                    .join(','),
                err.message
            );
        }

        try {
            let autoreplyId = await sendAutoreply();
            if (autoreplyId) {
                filterResults.push({ autoreply: sender, 'autoreply-queue-id': autoreplyId });
                outbound.push(autoreplyId);
                log.silly('Filter', '%s AUTOREPLYOK id=%s from=%s to=%s', prepared.id.toString(), autoreplyId, '<>', sender);
            }
        } catch (err) {
            log.error('Filter', '%s AUTOREPLYFAIL from=%s to=%s error=%s', prepared.id.toString(), '<>', sender, err.message);
        }

        if (filterActions.get('delete')) {
            // nothing to do with the message, just continue
            let err = new Error(`Message dropped by policy [${matchingFilters.map(id => (id || '').toString()).join(':')}]`);
            err.code = 'DroppedByPolicy';

            filterResults.push({ delete: true });

            try {
                let audits = await this.db.database
                    .collection('audits')
                    .find({ user: userData._id, expires: { $gt: new Date() } })
                    .toArray();

                let now = new Date();
                for (let auditData of audits) {
                    if ((auditData.start && auditData.start > now) || (auditData.end && auditData.end < now)) {
                        // audit not active
                        continue;
                    }
                    await this.auditHandler.store(auditData._id, rawchunks, {
                        date: prepared.idate || new Date(),
                        msgid: prepared.msgid,
                        header: prepared.mimeTree && prepared.mimeTree.parsedHeader,
                        ha: prepared.ha,
                        info: Object.assign({ notStored: true }, meta || {})
                    });
                }
            } catch (err) {
                log.error('Filter', '%s AUDITFAIL from=%s to=%s error=%s', prepared.id.toString(), '<>', sender, err.message);
            }
            return {
                response: {
                    userData,
                    response: 'Message dropped by policy as ' + prepared.id.toString(),
                    error: err
                }
            };
        }

        // apply filter results to the message
        filterActions.forEach((value, key) => {
            switch (key) {
                case 'spam':
                    if (value > 0) {
                        // positive value is spam
                        mailboxQueryKey = 'specialUse';
                        mailboxQueryValue = '\\Junk';
                        filterResults.push({ spam: true });
                    }
                    break;
                case 'seen':
                    if (value) {
                        flags.push('\\Seen');
                        filterResults.push({ seen: true });
                    }
                    break;
                case 'flag':
                    if (value) {
                        flags.push('\\Flagged');
                        filterResults.push({ flagged: true });
                    }
                    break;
                case 'mailbox':
                    if (value) {
                        // positive value is spam
                        mailboxQueryKey = 'mailbox';
                        mailboxQueryValue = value;
                    }
                    break;
            }
        });

        let messageOpts = {
            user: userData._id,
            [mailboxQueryKey]: mailboxQueryValue,
            inboxDefault: true, // if mailbox is not found, then store to INBOX

            prepared,
            maildata,

            meta,

            filters: matchingFilters,

            date: false,
            flags,

            rawchunks
        };

        if (options.verificationResults) {
            messageOpts.verificationResults = options.verificationResults;
        }

        if (outbound && outbound.length) {
            messageOpts.outbound = [].concat(outbound || []);
        }

        if (forwardTargets.size) {
            messageOpts.forwardTargets = Array.from(forwardTargets).map(row => ({
                type: row[1].type,
                value: row[1].value
            }));
        }

        if (userData.encryptMessages && userData.pubKey) {
            await encryptMessage();
            if (isEncrypted) {
                // make sure we have the updated message structure values
                messageOpts.prepared = prepared;
                messageOpts.maildata = maildata;
                filterResults.push({ encrypted: true });
            }
        }

        if (matchingFilters && matchingFilters.length) {
            filterResults.push({
                matchingFilters: matchingFilters.map(id => (id || '').toString())
            });
        }

        try {
            let { data } = await this.addMessage(messageOpts);

            if (data) {
                filterResults.push({
                    mailbox: data.mailbox && data.mailbox.toString(),
                    path: data.mailboxPath,
                    uid: data.uid,
                    id: data.id && data.id.toString()
                });

                return {
                    response: {
                        userData,
                        response: 'Message stored as ' + data.id.toString(),
                        filterResults,
                        attachments: (maildata && maildata.attachments) || []
                    },
                    prepared:
                        (!isEncrypted && {
                            // reuse parsed values
                            mimeTree: messageOpts.prepared.mimeTree,
                            maildata: messageOpts.maildata
                        }) ||
                        false
                };
            }
        } catch (err) {
            return {
                response: {
                    userData,
                    response: err,
                    filterResults,
                    attachments: (maildata && maildata.attachments) || [],
                    error: err
                },
                prepared:
                    (!isEncrypted && {
                        // reuse parsed values
                        mimeTree: messageOpts.prepared.mimeTree,
                        maildata: messageOpts.maildata
                    }) ||
                    false
            };
        }
    }
}

async function checkFilter(filterData, prepared, maildata) {
    if (!filterData || !filterData.query) {
        return false;
    }

    let query = filterData.query;

    // prepare filter data
    let headerFilters = new Map();
    if (query.headers) {
        Object.keys(query.headers).forEach(key => {
            let header = key.replace(/[A-Z]+/g, c => '-' + c.toLowerCase());
            let value = query.headers[key];
            if (!value || !value.isRegex) {
                value = (query.headers[key] || '').toString().toLowerCase();
            }
            if (value) {
                if (header === 'list-id' && typeof value === 'string' && value.indexOf('<') >= 0) {
                    // only check actual ID part of the List-ID header
                    let m = value.match(/<([^>]+)/);
                    if (m && m[1] && m[1].trim()) {
                        value = m[1].trim();
                    }
                }

                headerFilters.set(header, value);
            }
        });
    }

    // check headers
    if (headerFilters.size) {
        let headerMatches = new Set();
        for (let j = prepared.headers.length - 1; j >= 0; j--) {
            let header = prepared.headers[j];
            let key = header.key;

            switch (key) {
                case 'cc':
                case 'delivered-to':
                    if (!headerFilters.get(key)) {
                        // match against "to" query
                        key = 'to';
                    }
                    break;

                case 'sender':
                    if (!headerFilters.get(key)) {
                        // match against "from" query
                        key = 'from';
                    }
                    break;
            }

            if (headerFilters.has(key)) {
                let check = headerFilters.get(key);
                // value should already be lower case though
                let value = (header.value || '').toString().toLowerCase();

                if (check.isRegex) {
                    if (check.test(value)) {
                        headerMatches.add(key);
                    }
                } else if (value.indexOf(check) >= 0) {
                    headerMatches.add(key);
                }
            }
        }

        if (headerMatches.size < headerFilters.size) {
            // not enough matches
            return false;
        }
    }

    if (typeof query.ha === 'boolean') {
        let hasAttachments = maildata.attachments && maildata.attachments.length;
        // true ha means attachmens must exist
        if (!hasAttachments && query.ha) {
            return false;
        }
    }

    if (query.size) {
        let messageSize = prepared.size;
        let filterSize = Math.abs(query.size);
        // negative value means "less than", positive means "more than"
        if (query.size < 0 && messageSize > filterSize) {
            return false;
        }
        if (query.size > 0 && messageSize < filterSize) {
            return false;
        }
    }

    if (query.text && maildata.text.toLowerCase().replace(/\s+/g, ' ').indexOf(query.text.toLowerCase()) < 0) {
        // message plaintext does not match the text field value
        return false;
    }

    log.silly('Filter', 'Filter %s matched message %s', filterData.id, prepared.id);

    // we reached the end of the filter, so this means we have a match
    return filterData;
}

module.exports = FilterHandler;

function parseReceived(str) {
    let result = new Map();

    str.trim()
        .replace(/[\r\n\s\t]+/g, ' ')
        .trim()
        .replace(/(^|\s+)(from|by|with|id|for)\s+([^\s]+)/gi, (m, p, k, v) => {
            let key = k.toLowerCase();
            let value = v;
            if (!result.has(key)) {
                result.set(key, value);
            }
        });

    let date = str.split(';').pop().trim();
    if (date) {
        date = new Date(date);
        if (date.getTime()) {
            result.set('date', date);
        }
    }

    return result;
}
