/* eslint no-control-regex:0 */

'use strict';

const { formatDate, formatFlag, canUseFlag, isDate } = require('./tools.js');

/**
 * Sets a boolean flag in the IMAP search attributes.
 * Automatically handles UN- prefixing for falsy values.
 *
 * @param {Array} attributes - Array to append the attribute to
 * @param {string} term - The flag name (e.g., 'SEEN', 'DELETED')
 * @param {boolean} value - Whether to set or unset the flag
 * @example
 * setBoolOpt(attributes, 'SEEN', false) // Adds 'UNSEEN'
 * setBoolOpt(attributes, 'UNSEEN', false) // Adds 'SEEN' (removes UN prefix)
 */
let setBoolOpt = (attributes, term, value) => {
    if (!value) {
        // For falsy values, toggle the UN- prefix
        if (/^un/i.test(term)) {
            // Remove existing UN prefix
            term = term.slice(2);
        } else {
            // Add UN prefix
            term = 'UN' + term;
        }
    }

    attributes.push({ type: 'ATOM', value: term.toUpperCase() });
};

/**
 * Adds a search option with its value(s) to the attributes array.
 * Handles NOT operations and array values.
 *
 * @param {Array} attributes - Array to append the attribute to
 * @param {string} term - The search term (e.g., 'FROM', 'SUBJECT')
 * @param {*} value - The value for the search term (string, array, or falsy for NOT)
 * @param {string} [type='ATOM'] - The attribute type
 */
let setOpt = (attributes, term, value, type) => {
    type = type || 'ATOM';

    // Handle NOT operations for false or null values
    if (value === false || value === null) {
        attributes.push({ type, value: 'NOT' });
    }

    attributes.push({ type, value: term.toUpperCase() });

    // Handle array values (e.g., multiple UIDs)
    if (Array.isArray(value)) {
        value.forEach(entry => attributes.push({ type, value: (entry || '').toString() }));
    } else {
        attributes.push({ type, value: value.toString() });
    }
};

/**
 * Processes date fields for IMAP search.
 * Converts JavaScript dates to IMAP date format.
 *
 * @param {Array} attributes - Array to append the attribute to
 * @param {string} term - The date search term (e.g., 'BEFORE', 'SINCE')
 * @param {*} value - Date value to format
 */
let processDateField = (attributes, term, value) => {
    if (['BEFORE', 'SENTBEFORE'].includes(term.toUpperCase()) && isDate(value) && value.toISOString().substring(11) !== '00:00:00.000Z') {
        // Set to next day to include current day as well, othwerise BEFORE+AFTER
        // searches for the same day but different time values do not match anything
        value = new Date(value.getTime() + 24 * 3600 * 1000);
    }

    let date = formatDate(value);
    if (!date) {
        return;
    }

    setOpt(attributes, term, date);
};

// Pre-compiled regex for better performance
const UNICODE_PATTERN = /[^\x00-\x7F]/;

/**
 * Checks if a string contains Unicode characters.
 * Used to determine if CHARSET UTF-8 needs to be specified.
 *
 * @param {*} str - String to check
 * @returns {boolean} True if string contains non-ASCII characters
 */
let isUnicodeString = str => {
    if (!str || typeof str !== 'string') {
        return false;
    }

    // Regex test is ~3-5x faster than Buffer.byteLength
    // Matches any character outside ASCII range (0x00-0x7F)
    return UNICODE_PATTERN.test(str);
};

/**
 * Compiles a JavaScript object query into IMAP search command attributes.
 * Supports standard IMAP search criteria and extensions like OBJECTID and Gmail extensions.
 *
 * @param {Object} connection - IMAP connection object
 * @param {Map} connection.capabilities - Map of server capabilities
 * @param {Set} connection.enabled - Set of enabled extensions
 * @param {Object} connection.mailbox - Current mailbox information
 * @param {Set} connection.mailbox.flags - Available flags in the mailbox
 * @param {Object} query - Search query object
 * @returns {Array} Array of IMAP search attributes
 * @throws {Error} When required server extensions are not available
 *
 * @example
 * // Simple search for unseen messages from a sender
 * searchCompiler(connection, {
 *   unseen: true,
 *   from: 'sender@example.com'
 * });
 *
 * @example
 * // Complex OR search with date range
 * searchCompiler(connection, {
 *   or: [
 *     { from: 'alice@example.com' },
 *     { from: 'bob@example.com' }
 *   ],
 *   since: new Date('2024-01-01')
 * });
 */
module.exports.searchCompiler = (connection, query) => {
    const attributes = [];

    // Track if we need to specify UTF-8 charset
    let hasUnicode = false;
    const mailbox = connection.mailbox;

    /**
     * Recursively walks through the query object and builds IMAP attributes.
     * @param {Object} params - Query parameters to process
     */
    const walk = params => {
        // Walks a query object and wraps the resulting attributes in a
        // sub-array so the IMAP compiler emits parentheses around them.
        // Used when a single search-key is required (NOT, OR operands)
        // but the condition has multiple keys (RFC 3501 Section 6.4.4).
        let walkGrouped = obj => {
            let startIdx = attributes.length;
            walk(obj);
            let subAttrs = attributes.splice(startIdx);
            attributes.push(subAttrs);
        };

        Object.keys(params || {}).forEach(term => {
            switch (term.toUpperCase()) {
                // Custom sequence range support (non-standard)
                case 'SEQ':
                    {
                        let value = params[term];
                        if (typeof value === 'number') {
                            value = value.toString();
                        }
                        // Only accept valid sequence strings (no whitespace)
                        if (typeof value === 'string' && /^\S+$/.test(value)) {
                            attributes.push({ type: 'SEQUENCE', value });
                        }
                    }
                    break;

                // Boolean flags that support UN- prefixing
                case 'ANSWERED':
                case 'DELETED':
                case 'DRAFT':
                case 'FLAGGED':
                case 'SEEN':
                case 'UNANSWERED':
                case 'UNDELETED':
                case 'UNDRAFT':
                case 'UNFLAGGED':
                case 'UNSEEN':
                    // toggles UN-prefix for falsy values
                    setBoolOpt(attributes, term, !!params[term]);
                    break;

                // Simple boolean flags without UN- support
                case 'ALL':
                case 'NEW':
                case 'OLD':
                case 'RECENT':
                    if (params[term]) {
                        setBoolOpt(attributes, term, true);
                    }
                    break;

                // Numeric comparisons
                case 'LARGER':
                case 'SMALLER':
                case 'MODSEQ':
                    if (params[term]) {
                        setOpt(attributes, term, params[term]);
                    }
                    break;

                // Text search fields - check for Unicode
                case 'BCC':
                case 'BODY':
                case 'CC':
                case 'FROM':
                case 'SUBJECT':
                case 'TEXT':
                case 'TO':
                    if (isUnicodeString(params[term])) {
                        hasUnicode = true;
                    }
                    if (params[term]) {
                        setOpt(attributes, term, params[term]);
                    }
                    break;

                // UID sequences
                case 'UID':
                    if (params[term]) {
                        setOpt(attributes, term, params[term], 'SEQUENCE');
                    }
                    break;

                // Email ID support (OBJECTID or Gmail extension)
                case 'EMAILID':
                    if (connection.capabilities.has('OBJECTID')) {
                        setOpt(attributes, 'EMAILID', params[term]);
                    } else if (connection.capabilities.has('X-GM-EXT-1')) {
                        // Fallback to Gmail message ID
                        setOpt(attributes, 'X-GM-MSGID', params[term]);
                    }
                    break;

                // Thread ID support (OBJECTID or Gmail extension)
                case 'THREADID':
                    if (connection.capabilities.has('OBJECTID')) {
                        setOpt(attributes, 'THREADID', params[term]);
                    } else if (connection.capabilities.has('X-GM-EXT-1')) {
                        // Fallback to Gmail thread ID
                        setOpt(attributes, 'X-GM-THRID', params[term]);
                    }
                    break;

                // Gmail raw search
                case 'GMRAW':
                case 'GMAILRAW': // alias for GMRAW
                    if (connection.capabilities.has('X-GM-EXT-1')) {
                        if (isUnicodeString(params[term])) {
                            hasUnicode = true;
                        }
                        setOpt(attributes, 'X-GM-RAW', params[term]);
                    } else {
                        let error = new Error('Server does not support X-GM-EXT-1 extension required for X-GM-RAW');
                        error.code = 'MissingServerExtension';
                        throw error;
                    }
                    break;

                // Gmail label search. Compiles { has, not } into an X-GM-RAW "label:"/"-label:" query
                // since Gmail labels are not a native IMAP SEARCH key. Gmail-only (X-GM-EXT-1).
                case 'LABELS': {
                    let labelQuery = params[term];
                    if (!labelQuery || typeof labelQuery !== 'object') {
                        break;
                    }

                    // Collapse whitespace/quotes and quote multi-word names so they survive as a single token
                    let formatLabel = name => {
                        name = (name || '')
                            .toString()
                            .replace(/[\s"]+/g, ' ')
                            .trim();
                        return name.indexOf(' ') >= 0 ? `"${name}"` : name;
                    };

                    let rawParts = [];
                    for (let name of [].concat(labelQuery.has || [])) {
                        if (name) {
                            rawParts.push(`label:${formatLabel(name)}`);
                        }
                    }
                    for (let name of [].concat(labelQuery.not || [])) {
                        if (name) {
                            rawParts.push(`-label:${formatLabel(name)}`);
                        }
                    }

                    // Empty filter is a no-op on any server (do not require the extension)
                    if (!rawParts.length) {
                        break;
                    }

                    if (!connection.capabilities.has('X-GM-EXT-1')) {
                        let error = new Error('Server does not support X-GM-EXT-1 extension required for label search');
                        error.code = 'MissingServerExtension';
                        throw error;
                    }

                    let rawQuery = rawParts.join(' ');
                    if (isUnicodeString(rawQuery)) {
                        hasUnicode = true;
                    }
                    setOpt(attributes, 'X-GM-RAW', rawQuery);
                    break;
                }

                // Date searches with WITHIN extension support
                case 'BEFORE':
                case 'SINCE':
                    {
                        // Use WITHIN extension for better timezone handling if available
                        if (connection.capabilities.has('WITHIN') && isDate(params[term])) {
                            // Convert to seconds ago from now
                            const now = Date.now();
                            const withinSeconds = Math.round(Math.max(0, now - params[term].getTime()) / 1000);
                            const withinKeyword = term.toUpperCase() === 'BEFORE' ? 'OLDER' : 'YOUNGER';
                            setOpt(attributes, withinKeyword, withinSeconds.toString());
                            break;
                        }

                        // Fallback to standard date search
                        processDateField(attributes, term, params[term]);
                    }
                    break;

                // Standard date searches
                case 'ON':
                case 'SENTBEFORE':
                case 'SENTON':
                case 'SENTSINCE':
                    processDateField(attributes, term, params[term]);
                    break;

                // Keyword/flag searches
                case 'KEYWORD':
                case 'UNKEYWORD':
                    {
                        let flag = formatFlag(params[term]);
                        // Only add if flag is supported or already exists in mailbox
                        if (canUseFlag(mailbox, flag) || mailbox.flags.has(flag)) {
                            setOpt(attributes, term, flag);
                        }
                    }
                    break;

                // Header field searches
                case 'HEADER':
                    if (params[term] && typeof params[term] === 'object') {
                        Object.keys(params[term]).forEach(header => {
                            let value = params[term][header];

                            // Allow boolean true to search for header existence
                            if (value === true) {
                                value = '';
                            }

                            // Skip non-string values (after true->'' conversion)
                            if (typeof value !== 'string') {
                                return;
                            }

                            if (isUnicodeString(value)) {
                                hasUnicode = true;
                            }

                            setOpt(attributes, term, [header.toUpperCase().trim(), value]);
                        });
                    }
                    break;

                // NOT operator
                case 'NOT':
                    if (params[term] && typeof params[term] === 'object') {
                        attributes.push({ type: 'ATOM', value: 'NOT' });
                        if (Object.keys(params[term]).length > 1) {
                            walkGrouped(params[term]);
                        } else {
                            walk(params[term]);
                        }
                    }
                    break;

                // OR operator - complex logic for building OR trees
                case 'OR':
                    {
                        if (!params[term] || !Array.isArray(params[term]) || !params[term].length) {
                            break;
                        }

                        // Single element - just process it directly
                        if (params[term].length === 1) {
                            if (typeof params[term][0] === 'object' && params[term][0]) {
                                walk(params[term][0]);
                            }
                            break;
                        }

                        /**
                         * Generates a binary tree structure for OR operations.
                         * IMAP OR takes exactly 2 operands, so we need to nest them.
                         *
                         * @param {Array} list - List of conditions to OR together
                         * @returns {Array} Binary tree structure
                         */
                        let genOrTree = list => {
                            let group = false;
                            let groups = [];

                            // Group items in pairs
                            list.forEach((entry, i) => {
                                if (i % 2 === 0) {
                                    group = [entry];
                                } else {
                                    group.push(entry);
                                    groups.push(group);
                                    group = false;
                                }
                            });

                            // Handle odd number of items
                            if (group && group.length) {
                                while (group.length === 1 && Array.isArray(group[0])) {
                                    group = group[0];
                                }

                                groups.push(group);
                            }

                            // Recursively group until we have a binary tree
                            while (groups.length > 2) {
                                groups = genOrTree(groups);
                            }

                            // Flatten single-element arrays
                            while (groups.length === 1 && Array.isArray(groups[0])) {
                                groups = groups[0];
                            }

                            return groups;
                        };

                        /**
                         * Walks the OR tree and generates IMAP commands.
                         * @param {Array|Object} entry - Tree node to process
                         */
                        let walkOrTree = entry => {
                            if (Array.isArray(entry)) {
                                if (entry.length > 1) {
                                    attributes.push({ type: 'ATOM', value: 'OR' });
                                }
                                entry.forEach(walkOrTree);
                                return;
                            }
                            if (entry && typeof entry === 'object') {
                                if (Object.keys(entry).length > 1) {
                                    walkGrouped(entry);
                                } else {
                                    walk(entry);
                                }
                            }
                        };

                        walkOrTree(genOrTree(params[term]));
                    }
                    break;
            }
        });
    };

    // Process the query
    walk(query);

    // If we encountered Unicode strings and UTF-8 is not already accepted,
    // prepend CHARSET UTF-8 to the search command
    if (hasUnicode && !connection.enabled.has('UTF8=ACCEPT')) {
        attributes.unshift({ type: 'ATOM', value: 'UTF-8' });
        attributes.unshift({ type: 'ATOM', value: 'CHARSET' });
    }

    return attributes;
};
