/* -*- Mode: javascript; tab-width: 20; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
/* ***** BEGIN LICENSE BLOCK *****
 * Version: MPL 1.1/GPL 2.0/LGPL 2.1
 *
 * The contents of this file are subject to the Mozilla Public License Version
 * 1.1 (the "License"); you may not use this file except in compliance with
 * the License. You may obtain a copy of the License at
 * http://www.mozilla.org/MPL/
 *
 * Software distributed under the License is distributed on an "AS IS" basis,
 * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
 * for the specific language governing rights and limitations under the
 * License.
 *
 * The Original Code is lightning code.
 *
 * The Initial Developer of the Original Code is
 *  Oracle Corporation
 * Portions created by the Initial Developer are Copyright (C) 2005
 * the Initial Developer. All Rights Reserved.
 *
 * Contributor(s):
 *   Vladimir Vukicevic <vladimir.vukicevic@oracle.com>
 *   Matthew Willis <lilmatt@mozilla.com>
 *   Daniel Boelzle <daniel.boelzle@sun.com>
 *
 * Alternatively, the contents of this file may be used under the terms of
 * either the GNU General Public License Version 2 or later (the "GPL"), or
 * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
 * in which case the provisions of the GPL or the LGPL are applicable instead
 * of those above. If you wish to allow use of your version of this file only
 * under the terms of either the GPL or the LGPL, and not to allow others to
 * use your version of this file under the terms of the MPL, indicate your
 * decision by deleting the provisions above and replace them with the notice
 * and other provisions required by the GPL or the LGPL. If you do not delete
 * the provisions above, a recipient may use your version of this file under
 * the terms of any one of the MPL, the GPL or the LGPL.
 *
 * ***** END LICENSE BLOCK ***** */

function calRecurrenceInfo() {
    this.mRecurrenceItems = new Array();
    this.mExceptions = new Array();
}

function calDebug() {
    dump.apply(null, arguments);
}

var calRecurrenceInfoClassInfo = {
    getInterfaces: function (count) {
        var ifaces = [
            Components.interfaces.nsISupports,
            Components.interfaces.calIRecurrenceInfo,
            Components.interfaces.nsIClassInfo
        ];
        count.value = ifaces.length;
        return ifaces;
    },

    getHelperForLanguage: function (language) {
        return null;
    },

    contractID: "@mozilla.org/calendar/recurrence-info;1",
    classDescription: "Calendar Recurrence Info",
    classID: Components.ID("{04027036-5884-4a30-b4af-f2cad79f6edf}"),
    implementationLanguage: Components.interfaces.nsIProgrammingLanguage.JAVASCRIPT,
    flags: 0
};

calRecurrenceInfo.prototype = {
    // QI with CI
    QueryInterface: function(aIID) {
        if (aIID.equals(Components.interfaces.nsISupports) ||
            aIID.equals(Components.interfaces.calIRecurrenceInfo))
            return this;

        if (aIID.equals(Components.interfaces.nsIClassInfo))
            return calRecurrenceInfoClassInfo;

        throw Components.results.NS_ERROR_NO_INTERFACE;
    },

    //
    // Mutability bits
    //
    mImmutable: false,
    get isMutable() { return !this.mImmutable; },
    makeImmutable: function() {
        if (this.mImmutable)
            return;

        for each (ritem in this.mRecurrenceItems) {
            if (ritem.isMutable)
                ritem.makeImmutable();
        }

        for each (ex in this.mExceptions) {
            if (ex.item.isMutable)
                ex.item.makeImmutable();
        }

        this.mImmutable = true;
    },

    clone: function() {
        var cloned = new calRecurrenceInfo();
        cloned.mBaseItem = this.mBaseItem;

        var clonedItems = [];
        for each (ritem in this.mRecurrenceItems)
            clonedItems.push(ritem.clone());
        cloned.mRecurrenceItems = clonedItems;

        var clonedExceptions = [];
        for each (exitem in this.mExceptions) {
            var c = exitem.item.cloneShallow(this.mBaseItem);
            clonedExceptions.push( { id: exitem.id, item: c } );
        }
        cloned.mExceptions = clonedExceptions;

        return cloned;
    },

    //
    // calIRecurrenceInfo impl
    //
    mBaseItem: null,

    get item() {
        return this.mBaseItem;
    },

    set item(value) {
        if (this.mImmutable)
            throw Components.results.NS_ERROR_OBJECT_IS_IMMUTABLE;

        this.mBaseItem = value;
        // patch exception's parentItem:
        for each (exitem in this.mExceptions) {
            exitem.item.parentItem = value;
        }
    },

    mRecurrenceItems: null,

    get isFinite() {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        for each (ritem in this.mRecurrenceItems) {
            if (!ritem.isFinite)
                return false;
        }

        return true;
    },

    getRecurrenceItems: function(aCount) {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        aCount.value = this.mRecurrenceItems.length;
        return this.mRecurrenceItems;
    },

    setRecurrenceItems: function(aCount, aItems) {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        if (this.mImmutable)
            throw Components.results.NS_ERROR_OBJECT_IS_IMMUTABLE;

        // should we clone these?
        this.mRecurrenceItems = aItems;
    },

    countRecurrenceItems: function() {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        return this.mRecurrenceItems.length;
    },

    getRecurrenceItemAt: function(aIndex) {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        if (aIndex < 0 || aIndex >= this.mRecurrenceItems.length)
            throw Components.results.NS_ERROR_INVALID_ARG;

        return this.mRecurrenceItems[aIndex];
    },

    appendRecurrenceItem: function(aItem) {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        if (this.mImmutable)
            throw Components.results.NS_ERROR_OBJECT_IS_IMMUTABLE;

        this.mRecurrenceItems.push(aItem);
    },

    deleteRecurrenceItemAt: function(aIndex) {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        if (this.mImmutable)
            throw Components.results.NS_ERROR_OBJECT_IS_IMMUTABLE;

        if (aIndex < 0 || aIndex >= this.mRecurrenceItems.length)
            throw Components.results.NS_ERROR_INVALID_ARG;

        this.mRecurrenceItems.splice(aIndex, 1);
    },

    deleteRecurrenceItem: function(aItem) {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        if (this.mImmutable)
            throw Components.results.NS_ERROR_OBJECT_IS_IMMUTABLE;

        // Because xpcom objects can be wrapped in various ways, testing for
        // mere == sometimes returns false even when it should be true.  Use
        // the interface pointer returned by sip to avoid that problem.
        var sip1 = Components.classes["@mozilla.org/supports-interface-pointer;1"]
                            .createInstance(Components.interfaces.nsISupportsInterfacePointer);
        sip1.data = aItem;
        sip1.dataIID = Components.interfaces.calIRecurrenceItem;
        for (var i = 0; i < this.mRecurrenceItems.length; i++) {
            if (this.mRecurrenceItems[i] == sip1.data) {
                this.deleteRecurrenceItemAt(i);
                return;
            }
        }

        throw Components.results.NS_ERROR_INVALID_ARG;
    },

    insertRecurrenceItemAt: function(aItem, aIndex) {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        if (this.mImmutable)
            throw Components.results.NS_ERROR_OBJECT_IS_IMMUTABLE;

        if (aIndex < 0 || aIndex > this.mRecurrenceItems.length)
            throw Components.results.NS_ERROR_INVALID_ARG;

        this.mRecurrenceItems.splice(aIndex, 0, aItem);
    },

    clearRecurrenceItems: function() {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        if (this.mImmutable)
            throw Components.results.NS_ERROR_OBJECT_IS_IMMUTABLE;

        this.mRecurrenceItems = new Array();
    },

    //
    // calculations
    //

    getNextOccurrenceDate: function (aTime) {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        var startDate = this.mBaseItem.recurrenceStartDate;
        var dates = [];

        for each (ritem in this.mRecurrenceItems) {
            var date = ritem.getNextOccurrence(startDate, aTime);
            if (!date)
                continue;

            if (ritem.isNegative)
                dates = dates.filter(function (d) { return (d.compare(date) != 0); });
            else
                dates.push(date);
        }

        // if no dates, there's no next
        if (dates.length == 0)
            return null;

        // find the earliest date
        var earliestDate = dates[0];
        dates.forEach(function (d) { if (d.compare(earliestDate) < 0) earliestDate = d; });

        return earliestDate;
    },

    getNextOccurrence: function (aTime) {
        var earliestDate = this.getNextOccurrenceDate (aTime);
        if (!earliestDate)
            return null;

        if (this.mExceptions) {
            // scan exceptions for any dates earlier than
            // earliestDate (but still after aTime)
            this.mExceptions.forEach (function (ex) {
                                          var dtstart = ex.item.getProperty("DTSTART");
                                          if (aTime.compare(dtstart) <= 0 &&
                                              earliestDate.compare(dtstart) > 0)
                                          {
                                              earliestDate = dtstart;
                                          }
                                      });
        }

        var startDate = earliestDate.clone();
        var endDate = null;

        if (this.mBaseItem.hasProperty("DTEND")) {
            endDate = earliestDate.clone();
            endDate.addDuration(this.mBaseItem.duration);
        }

        var proxy = this.mBaseItem.createProxy();
        proxy.recurrenceId = earliestDate.clone();

        proxy.setProperty("DTSTART", startDate);
        if (endDate)
            proxy.setProperty("DTEND", endDate);

        return proxy;
    },

    // internal helper function; 
    calculateDates: function (aRangeStart, aRangeEnd,
                              aMaxCount, aReturnRIDs)
    {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        // workaround for UTC- timezones
        var rangeStart = ensureDateTime(aRangeStart);
        var rangeEnd = ensureDateTime(aRangeEnd);

        // If aRangeStart falls in the middle of an occurrence, libical will
        // not return that occurrence when we go and ask for an
        // icalrecur_iterator_new.  This actually seems fairly rational, so 
        // instead of hacking libical, I'm going to move aRangeStart back far
        // enough to make sure we get the occurrences we might miss.
        var searchStart = rangeStart.clone();
        var baseDuration = null;
        try {
            baseDuration = this.mBaseItem.duration;
            var duration = baseDuration.clone();
            duration.isNegative = true;
            searchStart.addDuration(duration);
        } catch(ex) {
            dump("recurrence tweaking exception:"+ex+'\n');
        }

        var startDate = this.mBaseItem.recurrenceStartDate;
        var dates = [];

        // DTSTART/DUE is always part of the (positive) expanded set:
        // the base item cannot be replaced by an exception;
        // an exception can only be defined on an item resulting from an RDATE/RRULE;
        // DTSTART always equals RECURRENCE-ID for items expanded from RRULE
        var baseOccDate = checkIfInRange(this.mBaseItem, aRangeStart, aRangeEnd, true);
        if (baseOccDate) {
            dates.push(baseOccDate);
        }

        // toss in exceptions first:
        if (this.mExceptions) {
            this.mExceptions.forEach(
                function(ex) {
                    var occDate = checkIfInRange(ex.item, aRangeStart, aRangeEnd, true);
                    if (occDate) {
                        dates.push(aReturnRIDs ? ex.id : occDate);
                    }
                });
        }

        // if both range start and end are specified, we ask for all of the occurrences,
        // to make sure we catch all possible exceptions.  If aRangeEnd isn't specified,
        // then we have to ask for aMaxCount, and hope for the best.
        var maxCount;
        if (rangeStart && rangeEnd) {
            maxCount = 0;
        } else {
            maxCount = aMaxCount;
        }

        // apply positive items before negative:
        var sortedRecurrenceItems = [];
        for each ( var ritem in this.mRecurrenceItems ) {
            if (ritem.isNegative)
                sortedRecurrenceItems.push(ritem);
            else
                sortedRecurrenceItems.unshift(ritem);
        }
        for each (ritem in sortedRecurrenceItems) {
            var cur_dates;

            cur_dates = ritem.getOccurrences(startDate,
                                             searchStart,
                                             rangeEnd,
                                             maxCount, {});

            if (cur_dates.length == 0)
                continue;

            if (ritem.isNegative) {
                // if this is negative, we look for any of the given dates
                // in the existing set, and remove them if they're
                // present.

                // XXX: i'm pretty sure negative dates can't really have exceptions
                // (like, you can't make a date "real" by defining an RECURRENCE-ID which
                // is an EXDATE, and then giving it a real DTSTART) -- so we don't
                // check exceptions here
                cur_dates.forEach (function (dateToRemove) {
                                       dates = dates.filter(function (d) { return d.compare(dateToRemove) != 0; });
                                   });
            } else {
                // if positive, we just add these date to the existing set,
                // but only if they're not already there

                var index = 0;
                const len = cur_dates.length;

                // skip items before rangeStart due to searchStart libical hack:
                if (rangeStart && baseDuration) {
                    for (; index < len; ++index) {
                        var date = cur_dates[index].clone();
                        date.addDuration(baseDuration);
                        if (rangeStart.compare(date) < 0) {
                            break;
                        }
                    }
                }
                for (; index < len; ++index) {
                    var dateToAdd = cur_dates[index];
                    if (!dates.some(function (d) { return d.compare(dateToAdd) == 0; })) {
                        dates.push(dateToAdd);
                    }
                }
            }
        }

        // now sort the list
        dates.sort(function (a,b) { return a.compare(b); });

        // chop anything over aMaxCount, if specified
        if (aMaxCount && dates.length > aMaxCount)
            dates = dates.splice(aMaxCount, dates.length - aMaxCount);

        return dates;
    },

    getOccurrenceDates: function (aRangeStart, aRangeEnd,
                                  aMaxCount, aCount)
    {
        var dates = this.calculateDates(aRangeStart, aRangeEnd, aMaxCount, false);
        aCount.value = dates.length;
        return dates;
    },

    getOccurrences: function (aRangeStart, aRangeEnd,
                              aMaxCount,
                              aCount)
    {
        var dates = this.calculateDates(aRangeStart, aRangeEnd, aMaxCount, true);
        if (dates.length == 0) {
            aCount.value = 0;
            return [];
        }

        var count = aMaxCount;
        if (!count)
            count = dates.length;

        var results = [];

        for (var i = 0; i < count; i++) {
            var proxy = this.getOccurrenceFor(dates[i]);
            results.push(proxy);
        }

        aCount.value = results.length;
        return results;
    },

    getOccurrenceFor: function (aRecurrenceId) {
        var proxy = this.getExceptionFor(aRecurrenceId, false);
        if (!proxy) {
            var duration = null;
            
            var name = "DTEND";
            if (this.mBaseItem instanceof Components.interfaces.calITodo)
                name = "DUE";
                
            if (this.mBaseItem.hasProperty(name))
                duration = this.mBaseItem.duration;

            proxy = this.mBaseItem.createProxy();
            proxy.recurrenceId = aRecurrenceId;
            proxy.setProperty("DTSTART", aRecurrenceId.clone());
            if (duration) {
                var enddate = aRecurrenceId.clone();
                enddate.addDuration(duration);
                proxy.setProperty(name, enddate);
            }
            if (!this.mBaseItem.isMutable)
                proxy.makeImmutable();
        }
        return proxy;
    },

    removeOccurrenceAt: function (aRecurrenceId) {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        if (this.mImmutable)
            throw Components.results.NS_ERROR_OBJECT_IS_IMMUTABLE;

        var d = Components.classes["@mozilla.org/calendar/recurrence-date;1"].createInstance(Components.interfaces.calIRecurrenceDate);
        d.isNegative = true;
        d.date = aRecurrenceId.clone();

        this.removeExceptionFor(d.date);

        this.appendRecurrenceItem(d);
    },

    restoreOccurrenceAt: function (aRecurrenceId) {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        if (this.mImmutable)
            throw Components.results.NS_ERROR_OBJECT_IS_IMMUTABLE;

        for (var i = 0; i < this.mRecurrenceItems.length; i++) {
            if (this.mRecurrenceItems[i] instanceof Components.interfaces.calIRecurrenceDate) {
                var rd = this.mRecurrenceItems[i].QueryInterface(Components.interfaces.calIRecurrenceDate);
                if (rd.isNegative && rd.date.compare(aRecurrenceId) == 0) {
                    return this.deleteRecurrenceItemAt(i);
                }
            }
        }

        throw Components.results.NS_ERROR_INVALID_ARG;
    },

    //
    // exceptions
    //

    //
    // Some notes:
    //
    // The way I read ICAL, RECURRENCE-ID is used to specify a
    // particular instance of a recurring event, according to the
    // RRULEs/RDATEs/etc. specified in the base event.  If one of
    // these is to be changed ("an exception"), then it can be
    // referenced via the UID of the original event, and a
    // RECURRENCE-ID of the start time of the instance to change.
    // This, to me, means that an event where one of the instances has
    // changed to a different time has a RECURRENCE-ID of the original
    // start time, and a DTSTART/DTEND representing the new time.
    //
    // ITIP, however, seems to want something different -- you're
    // supposed to use UID/RECURRENCE-ID to select from the current
    // set of occurrences of an event.  If you change the DTSTART for
    // an instance, you're supposed to use the old (original) DTSTART
    // as the RECURRENCE-ID, and put the new time as the DTSTART.
    // However, after that change, to refer to that instance in the
    // future, you have to use the modified DTSTART as the
    // RECURRENCE-ID.  This madness is described in ITIP end of
    // section 3.7.1.
    // 
    // This implementation does the first approach (RECURRENCE-ID will
    // never change even if DTSTART for that instance changes), which
    // I think is the right thing to do for CalDAV; I don't know what
    // we'll do for incoming ITIP events though.
    //

    mExceptions: null,

    modifyException: function (anItem) {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        // the item must be an occurrence
        if (anItem.parentItem == anItem)
            throw Components.results.NS_ERROR_UNEXPECTED;

        if (anItem.parentItem.calendar != this.mBaseItem.calendar &&
            anItem.parentItem.id != this.mBaseItem.id)
        {
            calDebug ("recurrenceInfo::addException: item parentItem != this.mBaseItem (calendar/id)!\n");
            throw Components.results.NS_ERROR_INVALID_ARG;
        }

        if (anItem.recurrenceId == null) {
            calDebug ("recurrenceInfo::addException: item with null recurrenceId!\n");
            throw Components.results.NS_ERROR_INVALID_ARG;
        }

        var itemtoadd;
        if (anItem.isMutable) {
            itemtoadd = anItem.cloneShallow(this.mBaseItem);
            itemtoadd.makeImmutable();
        } else {
            itemtoadd = anItem;
        }

        // we're going to assume that the recurrenceId is valid here,
        // because presumably the item came from one of our functions

        // remove any old one, if present
        this.removeExceptionFor(anItem.recurrenceId);

        this.mExceptions.push( { id: itemtoadd.recurrenceId, item: itemtoadd } );
    },

    createExceptionFor: function (aRecurrenceId) {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        // XX should it be an error to createExceptionFor
        // an already-existing recurrenceId?
        var existing = this.getExceptionFor(aRecurrenceId, false);
        if (existing)
            return existing;

        // check if aRecurrenceId is valid.

        // this is a bit of a hack; we know that ranges are defined as [start, end),
        // so we do a search on aRecurrenceId and aRecurrenceId.seconds + 1.
        var rangeStart = aRecurrenceId;
        var rangeEnd = aRecurrenceId.clone();
        rangeEnd.second += 1;

        var dates = this.getOccurrenceDates (rangeStart, rangeEnd, 1, {});
        var found = false;
        for each (d in dates) {
            if (d.compare(aRecurrenceId) == 0) {
                found = true;
                break;
            }
        }

        // not found; the recurrence id is invalid
        if (!found)
            throw Components.results.NS_ERROR_INVALID_ARG;

        var rid = aRecurrenceId.clone();
        rid.makeImmutable();

        var newex = this.mBaseItem.createProxy();
        newex.recurrenceId = rid;

        this.mExceptions.push({id: rid, item: newex});

        return newex;
    },

    getExceptionFor: function (aRecurrenceId, aCreate) {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        for each (ex in this.mExceptions) {
            if (ex.id.compare(aRecurrenceId) == 0)
                return ex.item;
        }

        if (aCreate) {
            return this.createExceptionFor(aRecurrenceId);
        }
        return null;
    },

    removeExceptionFor: function (aRecurrenceId) {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        this.mExceptions = this.mExceptions.filter (function(ex) {
                                                        return (ex.id.compare(aRecurrenceId) != 0);
                                                    });
    },

    getExceptionIds: function (aCount) {
        if (!this.mBaseItem)
            throw Components.results.NS_ERROR_NOT_INITIALIZED;

        var ids = this.mExceptions.map (function(ex) {
                                            return ex.id;
                                        });

        aCount.value = ids.length;
        return ids;
    },
    
    // changing the startdate of an item needs to take exceptions into account.
    // in case we're about to modify a parentItem (aka 'folded' item), we need
    // to modify the recurrenceId's of all possibly existing exceptions as well.
    onStartDateChange: function (aNewStartTime, aOldStartTime) {

        // passing null for the new starttime would indicate an error condition,
        // since having a recurrence without a starttime is invalid.
        if (!aNewStartTime) {
            throw Components.results.NS_ERROR_INVALID_ARG;
        }
    
        // no need to check for changes if there's no previous starttime.
        if (!aOldStartTime) {
            return;
        }
    
        // convert both dates to UTC since subtractDate is not timezone aware.
        aOldStartTime = aOldStartTime.getInTimezone(UTC());
        aNewStartTime = aNewStartTime.getInTimezone(UTC());
        var timeDiff = aNewStartTime.subtractDate(aOldStartTime);
        var exceptions = this.getExceptionIds({});
        var modifiedExceptions = [];
        for each (var exid in exceptions) {
            var ex = this.getExceptionFor(exid, false);
            if (ex) {
                if (!ex.isMutable) {
                    ex = ex.cloneShallow(this.item);
                }
                ex.recurrenceId.addDuration(timeDiff);
                
                modifiedExceptions.push(ex);
                this.removeExceptionFor(exid);
            }
        }
        for each (var modifiedEx in modifiedExceptions) {
            this.modifyException(modifiedEx);
        }

        // also take RDATE's and EXDATE's into account.
        const kCalIRecurrenceDate = Components.interfaces.calIRecurrenceDate;
        const kCalIRecurrenceDateSet = Components.interfaces.calIRecurrenceDateSet;
        var ritems = this.getRecurrenceItems({});
        for (var i in ritems) {
            var ritem = ritems[i];
            if (ritem instanceof kCalIRecurrenceDate) {
                ritem = ritem.QueryInterface(kCalIRecurrenceDate);
                ritem.date.addDuration(timeDiff);
            } else if (ritem instanceof kCalIRecurrenceDateSet) {
                ritem = ritem.QueryInterface(kCalIRecurrenceDateSet);
                var rdates = ritem.getDates({});
                for each (var date in rdates) {
                    date.addDuration(timeDiff);
                }
                ritem.setDates(rdates.length,rdates);
            }
        }
    }
};
