/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

Components.utils.import("resource://calendar/modules/calUtils.jsm");
Components.utils.import("resource://calendar/modules/calXMLUtils.jsm");
Components.utils.import("resource://calendar/modules/calRecurrenceUtils.jsm");
Components.utils.import("resource://gre/modules/Preferences.jsm");
Components.utils.import("resource://calendar/modules/ltnUtils.jsm");
Components.utils.import("resource:///modules/mailServices.js");

this.EXPORTED_SYMBOLS = ["ltn"]; // even though it's defined in ltnUtils.jsm, import needs this
ltn.invitation = {
    /**
     * Returns a header title for an ITIP item depending on the response method
     * @param  {calItipItem}     aItipItem  the itip item to check
     * @return {String}          the header title
     */
    getItipHeader: function (aItipItem) {
        let header;

        if (aItipItem) {
            let item = aItipItem.getItemList({})[0];
            let summary = item.getProperty("SUMMARY") || "";
            let organizer = item.organizer;
            let organizerString = (organizer) ?
              (organizer.commonName || organizer.toString()) : "";

            switch (aItipItem.responseMethod) {
                case "REQUEST":
                    header = ltn.getString("lightning",
                                           "itipRequestBody",
                                           [organizerString, summary]);
                    break;
                case "CANCEL":
                    header = ltn.getString("lightning",
                                           "itipCancelBody",
                                           [organizerString, summary]);
                    break;
                case "REPLY": {
                    // This is a reply received from someone else, there should
                    // be just one attendee, the attendee that replied. If
                    // there is more than one attendee, just take the first so
                    // code doesn't break here.
                    let attendees = item.getAttendees({});
                    if (attendees && attendees.length >= 1) {
                        let sender = attendees[0];
                        let statusString = (sender.participationStatus == "DECLINED" ?
                                            "itipReplyBodyDecline" :
                                            "itipReplyBodyAccept");

                        header = ltn.getString("lightning", statusString, [sender.toString()]);
                    } else {
                        header = "";
                    }
                    break;
                }
            }
        }

        if (!header) {
            header = ltn.getString("lightning", "imipHtml.header", null);
        }

        return header;
    },

    /**
     * Returns the html representation of the event as a DOM document.
     *
     * @param  {calIItemBase} aEvent     The event to parse into html.
     * @param  {calItipItem}  aItipItem  The itip item, which containes aEvent.
     * @return {DOM}                     The html representation of aEvent.
     */
    createInvitationOverlay: function (aEvent, aItipItem) {
        // Creates HTML using the Node strings in the properties file
        let doc = cal.xml.parseFile("chrome://lightning/content/lightning-invitation.xhtml");
        let formatter = cal.getDateFormatter();

        let linkConverter = Components.classes["@mozilla.org/txttohtmlconv;1"]
                                      .getService(Components.interfaces.mozITXTToHTMLConv);

        let field = function (aField, aContentText, aConvert) {
            let descr = doc.getElementById("imipHtml-" + aField + "-descr");
            if (descr) {
                let labelText = ltn.getString("lightning", "imipHtml." + aField, null);
                descr.textContent = labelText;
            }

            if (aContentText) {
                let content = doc.getElementById("imipHtml-" + aField + "-content");
                doc.getElementById("imipHtml-" + aField + "-row").hidden = false;
                if (aConvert) {
                    // we convert special characters first to not mix up html conversion
                    let mode = Components.interfaces.mozITXTToHTMLConv.kEntities;
                    let contentText = linkConverter.scanTXT(aContentText, mode);
                    mode = Components.interfaces.mozITXTToHTMLConv.kStructPhrase +
                           Components.interfaces.mozITXTToHTMLConv.kGlyphSubstitution +
                           Components.interfaces.mozITXTToHTMLConv.kURLs;
                    content.innerHTML = linkConverter.scanHTML(contentText, mode);
                } else {
                    content.textContent = aContentText;
                }
            }
        }

        // Simple fields
        let headerDescr = doc.getElementById("imipHtml-header-descr");
        if (headerDescr) {
            headerDescr.textContent = ltn.invitation.getItipHeader(aItipItem);
        }

        field("summary", aEvent.title);
        field("location", aEvent.getProperty("LOCATION"));

        let dateString = formatter.formatItemInterval(aEvent);

        if (aEvent.recurrenceInfo) {
            let kDefaultTimezone = cal.calendarDefaultTimezone();
            let startDate =  aEvent.startDate;
            let endDate = aEvent.endDate;
            startDate = startDate ? startDate.getInTimezone(kDefaultTimezone) : null;
            endDate = endDate ? endDate.getInTimezone(kDefaultTimezone) : null;
            let repeatString = recurrenceRule2String(aEvent.recurrenceInfo, startDate,
                                                     endDate, startDate.isDate);
            if (repeatString) {
                dateString = repeatString;
            }

            let formattedExDates = [];
            let modifiedOccurrences = [];

            let dateComptor = function (a,b) {
                return a.startDate.compare(b.startDate);
            }

            // Show removed instances
            for (let exc of aEvent.recurrenceInfo.getRecurrenceItems({})) {
                if (exc instanceof Components.interfaces.calIRecurrenceDate) {
                    if (exc.isNegative) {
                        // This is an EXDATE
                        formattedExDates.push(formatter.formatDateTime(exc.date));
                    } else {
                        // This is an RDATE, close enough to a modified occurrence
                        let excItem = aEvent.recurrenceInfo.getOccurrenceFor(exc.date);
                        cal.binaryInsert(modifiedOccurrences, excItem, dateComptor, true)
                    }
                }
            }
            if (formattedExDates.length > 0) {
                field("canceledOccurrences", formattedExDates.join("\n"));
            }

            // Show modified occurrences
            for (let recurrenceId of aEvent.recurrenceInfo.getExceptionIds({})) {
                let exc = aEvent.recurrenceInfo.getExceptionFor(recurrenceId);
                let excLocation = exc.getProperty("LOCATION");

                // Only show modified occurrence if start, duration or location
                // has changed.
                if (exc.startDate.compare(exc.recurrenceId) != 0 ||
                    exc.duration.compare(aEvent.duration) != 0 ||
                    excLocation != aEvent.getProperty("LOCATION")) {
                    cal.binaryInsert(modifiedOccurrences, exc, dateComptor, true)
                }
            }

            let stringifyOcc = function (occ) {
                let formattedExc = formatter.formatItemInterval(occ);
                let occLocation = occ.getProperty("LOCATION");
                if (occLocation != aEvent.getProperty("LOCATION")) {
                    let location = ltn.getString("lightning", "imipHtml.newLocation", [occLocation]);
                    formattedExc += " (" + location + ")";
                }
                return formattedExc;
            }

            if (modifiedOccurrences.length > 0) {
                field("modifiedOccurrences", modifiedOccurrences.map(stringifyOcc).join("\n"));
            }
        }

        field("when", dateString);
        field("comment", aEvent.getProperty("COMMENT"), true);

        // DESCRIPTION field
        let eventDescription = (aEvent.getProperty("DESCRIPTION") || "")
                                    /* Remove the useless "Outlookism" squiggle. */
                                    .replace("*~*~*~*~*~*~*~*~*~*", "");
        field("description", eventDescription, true);

        // ATTENDEE and ORGANIZER fields
        let attendees = aEvent.getAttendees({});
        let attendeeTemplate = doc.getElementById("attendee-template");
        let attendeeTable = doc.getElementById("attendee-table");
        let organizerTable = doc.getElementById("organizer-table");
        doc.getElementById("imipHtml-attendees-row").hidden = (attendees.length < 1);
        doc.getElementById("imipHtml-organizer-row").hidden = !aEvent.organizer;

        let setupAttendee = function (aAttendee) {
            let row = attendeeTemplate.cloneNode(true);
            row.removeAttribute("id");
            row.removeAttribute("hidden");

            // resolve delegatees/delegators to display also the CN
            let del = cal.resolveDelegation(aAttendee, attendees);
            if (del.delegators != "") {
                del.delegators = " " + ltn.getString("lightning", "imipHtml.attendeeDelegatedFrom",
                                                     [del.delegators]);
            }

            // display itip icon
            let role = aAttendee.role || "REQ-PARTICIPANT";
            let ps = aAttendee.participationStatus || "NEEDS-ACTION";
            let ut = aAttendee.userType || "INDIVIDUAL";
            let itipIcon = row.getElementsByClassName("itip-icon")[0];
            itipIcon.setAttribute("role", role);
            itipIcon.setAttribute("usertype", ut);
            itipIcon.setAttribute("partstat", ps);
            let attName = (aAttendee.commonName && aAttendee.commonName.length)
                          ? aAttendee.commonName : aAttendee.toString();
            let utString = ltn.getString("lightning", "imipHtml.attendeeUserType2." + ut,
                                         [aAttendee.toString()]);
            let roleString = ltn.getString("lightning", "imipHtml.attendeeRole2." + role,
                                           [utString]);
            let psString = ltn.getString("lightning", "imipHtml.attendeePartStat2." + ps,
                                         [attName, del.delegatees]);
            let itipTooltip = ltn.getString("lightning", "imipHtml.attendee.combined",
                                            [roleString, psString]);
            row.setAttribute("title", itipTooltip);
            // display attendee
            row.getElementsByClassName("attendee-name")[0].textContent = aAttendee.toString() +
                                                                         del.delegators;
            return row;
        };

        // Fill rows for attendees and organizer
        field("attendees");
        for (let attendee of attendees) {
            attendeeTable.appendChild(setupAttendee(attendee));
        }

        field("organizer");
        if (aEvent.organizer) {
            organizerTable.appendChild(setupAttendee(aEvent.organizer));
        }

        return doc;
    },

    /**
     * Expects and return a serialized DOM - use cal.xml.serializeDOM(aDOM)
     * @param  {String} aOldDoc    serialized DOM of the the old document
     * @param  {String} aNewDoc    serialized DOM of the the new document
     * @param  {String} aIgnoreId  attendee id to ignore, usually the organizer
     * @return {String}            updated serialized DOM of the new document
     */
    compareInvitationOverlay: function (aOldDoc, aNewDoc, aIgnoreId) {
        /**
         * Transforms text node content to formated child nodes. Decorations are defined in imip.css
         * @param {Node}    aToNode text node to change
         * @param {String}  aType   use 'newline' for the same, 'added' or 'removed' for decoration
         * @param {String}  aText   [optional]
         * @param {Boolean} aClear  [optional] for consecutive changes on the same node, set to false
         */
        function _content2Child(aToNode, aType, aText = '', aClear = true) {
            let nodeDoc = aToNode.ownerDocument;
            if (aClear && aToNode.hasChildNodes()) {
                aToNode.removeChild(aToNode.firstChild);
            }
            let n = nodeDoc.createElement((aType.toLowerCase() == 'newline') ? 'br' : 'span');
            switch (aType) {
                case 'added':
                case 'modified':
                case 'removed':
                    n.className = aType;
                    if (Preferences.get('calendar.view.useSystemColors', false)) {
                        n.setAttribute('systemcolors', true);
                    }
                    break;
            }
            n.textContent = aText;
            aToNode.appendChild(n);
        }
        /**
         * Extracts attendees from the given document
         * @param   {Node}   aDoc      document to search in
         * @param   {String} aElement  element name as used in _compareElement()
         * @returns {Array}            attendee nodes
         */
        function _getAttendees(aDoc, aElement) {
            let attendees = [];
            for (let att of aDoc.getElementsByClassName('attendee-name')) {
                if (!att.parentNode.hidden &&
                    att.parentNode.parentNode.id == (aElement + '-table')) {
                    attendees[att.textContent] = att;
                }
            }
            return attendees;
        }
        /**
         * Compares both documents for elements related to the given name
         * @param {String} aElement  part of the element id within the html template
         */
        function _compareElement(aElement) {
            let element = (aElement == 'attendee') ? aElement + 's' : aElement;
            let oldRow = aOldDoc.getElementById('imipHtml-' + element + '-row');
            let newRow = aNewDoc.getElementById('imipHtml-' + element + '-row');
            let row = doc.getElementById('imipHtml-' + element + '-row');
            let oldContent = aOldDoc.getElementById('imipHtml-' + aElement + '-content');
            let content = doc.getElementById('imipHtml-' + aElement + '-content');

            if (newRow.hidden && !oldRow.hidden) {
                // element was removed
                // we only need to check for simple elements here: attendee or organizer row
                // cannot be removed
                if (oldContent) {
                    _content2Child(content, 'removed', oldContent.textContent);
                    row.hidden = false;
                }
            } else if (!newRow.hidden && oldRow.hidden) {
                // the element was added
                // we only need to check for simple elements here: attendee or organizer row
                // must have been there before
                if (content) {
                    _content2Child(content, 'added', content.textContent);
                }
            } else if (!newRow.hidden && !oldRow.hidden) {
                // the element may have been modified
                if (content) {
                    if (content.textContent != oldContent.textContent) {
                        _content2Child(content, 'added', content.textContent);
                        _content2Child(content, 'newline', null, false);
                        _content2Child(content, 'removed', oldContent.textContent, false);
                    }
                } else {
                    content = doc.getElementById(aElement + '-table');
                    oldContent = aOldDoc.getElementById(aElement + '-table');
                    let excludeAddress = cal.removeMailTo(aIgnoreId);
                    if (content && oldContent && !content.isEqualNode(oldContent)) {
                        // extract attendees
                        let attendees = _getAttendees(doc, aElement);
                        let oldAttendees = _getAttendees(aOldDoc, aElement);
                        // decorate newly added attendees
                        for (let att of Object.keys(attendees)) {
                            if (!(att in oldAttendees)) {
                                _content2Child(attendees[att], 'added', att);
                            }
                        }
                        for (let att of Object.keys(oldAttendees)) {
                            // if att is the user his/herself, who accepted an invitation he/she was
                            // not invited to, we exclude him/her from decoration
                            let notExcluded = (excludeAddress == "" ||
                                               !att.includes(excludeAddress));
                            // decorate removed attendees
                            if (!(att in attendees) && notExcluded) {
                                _content2Child(oldAttendees[att], 'removed', att);
                                content.appendChild(oldAttendees[att].parentNode.cloneNode(true));
                            }
                            // highlight partstat, role or usertype changes
                            else if ((att in attendees) && notExcluded) {
                                let oldAtts = oldAttendees[att].parentNode
                                                               .getElementsByClassName("itip-icon")[0]
                                                               .attributes;
                                let newAtts = attendees[att].parentNode
                                                            .getElementsByClassName("itip-icon")[0]
                                                            .attributes;
                                let hasChanged = function (name) {
                                    return oldAtts.getNamedItem(name).value !=
                                           newAtts.getNamedItem(name).value;
                                };
                                if (["role", "partstat", "usertype"].some(hasChanged)) {
                                    _content2Child(attendees[att], 'modified', att);
                                }
                            }
                        }
                    }
                }
            }
        }
        aOldDoc = cal.xml.parseString(aOldDoc);
        aNewDoc = cal.xml.parseString(aNewDoc);
        let doc = aNewDoc.cloneNode(true);
        // elements to consider for comparison
        ['summary', 'location', 'when', 'canceledOccurrences',
         'modifiedOccurrences', 'organizer', 'attendee'].forEach(_compareElement);
        return cal.xml.serializeDOM(doc);
    },

    /**
     * Returns the header section for an invitation email.
     * @param   {String}         aMessageId  the message id to use for that email
     * @param   {nsIMsgIdentity} aIdentity   the identity to use for that email
     * @returns {String}                     the source code of the header section of the email
     */
    getHeaderSection: function (aMessageId, aIdentity, aToList, aSubject) {
        let from = !aIdentity.fullName.length ? aIdentity.email :
                                                cal.validateRecipientList(aIdentity.fullName +
                                                                          " <" + aIdentity.email + ">");
        let header = ("MIME-version: 1.0\r\n" +
                      (aIdentity.replyTo ? "Return-path: " +
                                           ltn.invitation.encodeMimeHeader(aIdentity.replyTo, true) +
                                           "\r\n" : "") +
                      "From: " + ltn.invitation.encodeMimeHeader(from, true) + "\r\n" +
                      (aIdentity.organization ? "Organization: " +
                                                ltn.invitation.encodeMimeHeader(aIdentity.organization) +
                                                "\r\n" : "") +
                      "Message-ID: " + aMessageId + "\r\n" +
                      "To: " + ltn.invitation.encodeMimeHeader(aToList, true) + "\r\n" +
                      "Date: " + ltn.invitation.getRfc5322FormattedDate() + "\r\n" +
                      "Subject: " + ltn.invitation
                                       .encodeMimeHeader(aSubject.replace(/(\n|\r\n)/, "|")) + "\r\n");
        let validRecipients;
        if (aIdentity.doCc) {
            validRecipients = cal.validateRecipientList(aIdentity.doCcList);
            if (validRecipients != "") {
                header += ("Cc: " + ltn.invitation.encodeMimeHeader(validRecipients, true) + "\r\n");
            }
        }
        if (aIdentity.doBcc) {
            validRecipients = cal.validateRecipientList(aIdentity.doBccList);
            if (validRecipients != "") {
                header += ("Bcc: " + ltn.invitation.encodeMimeHeader(validRecipients, true) + "\r\n");
            }
        }
        return header;
    },

    /**
     * Returns a datetime string according to section 3.3 of RfC5322
     * @param  {Date}   [optional] Js Date object to format; if not provided current DateTime is used
     * @return {String}            Datetime string with a modified tz-offset notation compared to
     *                             Date.toString() like "Fri, 20 Nov 2015 09:45:36 +0100"
     */
    getRfc5322FormattedDate: function (aDate = null) {
        let date = aDate || new Date();
        let str = date.toString()
                      .replace(/^(\w{3}) (\w{3}) (\d{2}) (\d{4}) ([0-9:]{8}) GMT([+-])(\d{4}).*$/,
                               "$1, $3 $2 $4 $5 $6$7");
        // according to section 3.3 of RfC5322, +0000 should be used for defined timezones using
        // UTC time, while -0000 should indicate a floating time instead
        let tz = cal.calendarDefaultTimezone();
        if(tz && tz.isFloating) {
            str.replace(/\+0000$/, "-0000");
        }
        return str;
    },

    /**
     * Converts a given unicode text to utf-8 and normalizes line-breaks to \r\n
     * @param  {String} aText   a unicode encoded string
     * @return {String}         the converted uft-8 encoded string
     */
    encodeUTF8: function (aText) {
        return ltn.invitation.convertFromUnicode("UTF-8", aText).replace(/(\r\n)|\n/g, "\r\n");
    },

    /**
     * Converts a given unicode text
     * @param  {String} aCharset   target character set
     * @param  {String} aSrc       unicode text to convert
     * @return {String}            the converted string
     */
    convertFromUnicode: function (aCharset, aSrc) {
        let unicodeConverter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"]
                                         .createInstance(Components.interfaces.nsIScriptableUnicodeConverter);
        unicodeConverter.charset = aCharset;
        return unicodeConverter.ConvertFromUnicode(aSrc);
    },

    /**
     * Converts a header to a mime encoded header
     * @param  {String}  aHeader   a header to encode
     * @param  {boolean} aIsEmail  if enabled, only the CN but not the email address gets
     *                             converted - default value is false
     * @return {String}            the encoded string
     */
    encodeMimeHeader: function (aHeader, aIsEmail = false) {
        let fieldNameLen = (aHeader.indexOf(": ") + 2);
        return MailServices.mimeConverter
                           .encodeMimePartIIStr_UTF8(aHeader,
                                                     aIsEmail,
                                                     "UTF-8",
                                                     fieldNameLen,
                                                     Components.interfaces
                                                               .nsIMimeConverter
                                                               .MIME_ENCODED_WORD_SIZE);
    }
};
