/*
 * Copyright (C) 2011 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (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.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.providers.calendar;

import com.android.calendarcommon.DateException;
import com.android.calendarcommon.EventRecurrence;
import com.android.calendarcommon.RecurrenceProcessor;
import com.android.calendarcommon.RecurrenceSet;
import com.android.providers.calendar.CalendarDatabaseHelper.Tables;

import android.content.ContentValues;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteQueryBuilder;
import android.os.Debug;
import android.provider.CalendarContract.Calendars;
import android.provider.CalendarContract.Events;
import android.provider.CalendarContract.Instances;
import android.text.TextUtils;
import android.text.format.Time;
import android.util.Log;
import android.util.TimeFormatException;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Set;

public class CalendarInstancesHelper {
    public static final class EventInstancesMap extends
            HashMap<String, CalendarInstancesHelper.InstancesList> {
        public void add(String syncIdKey, ContentValues values) {
            CalendarInstancesHelper.InstancesList instances = get(syncIdKey);
            if (instances == null) {
                instances = new CalendarInstancesHelper.InstancesList();
                put(syncIdKey, instances);
            }
            instances.add(values);
        }
    }

    public static final class InstancesList extends ArrayList<ContentValues> {
    }

    private static final String TAG = "CalInstances";
    private CalendarDatabaseHelper mDbHelper;
    private SQLiteDatabase mDb;
    private MetaData mMetaData;
    private CalendarCache mCalendarCache;

    private static final String SQL_WHERE_GET_EVENTS_ENTRIES =
            "((" + Events.DTSTART + " <= ? AND "
                    + "(" + Events.LAST_DATE + " IS NULL OR " + Events.LAST_DATE + " >= ?)) OR "
            + "(" + Events.ORIGINAL_INSTANCE_TIME + " IS NOT NULL AND "
                    + Events.ORIGINAL_INSTANCE_TIME
                    + " <= ? AND " + Events.ORIGINAL_INSTANCE_TIME + " >= ?)) AND "
            + "(" + Calendars.SYNC_EVENTS + " != ?) AND "
            + "(" + Events.LAST_SYNCED + " = ?)";

    /**
     * Determines the set of Events where the _id matches the first query argument, or the
     * originalId matches the second argument.  Returns the _id field from the set of
     * Instances whose event_id field matches one of those events.
     */
    private static final String SQL_WHERE_ID_FROM_INSTANCES_NOT_SYNCED =
            Instances._ID + " IN " +
            "(SELECT " + Tables.INSTANCES + "." + Instances._ID + " as _id" +
            " FROM " + Tables.INSTANCES +
            " INNER JOIN " + Tables.EVENTS +
            " ON (" +
            Tables.EVENTS + "." + Events._ID + "=" + Tables.INSTANCES + "." + Instances.EVENT_ID +
            ")" +
            " WHERE " + Tables.EVENTS + "." + Events._ID + "=? OR " +
                    Tables.EVENTS + "." + Events.ORIGINAL_ID + "=?)";

    /**
     * Determines the set of Events where the _sync_id matches the first query argument, or the
     * originalSyncId matches the second argument.  Returns the _id field from the set of
     * Instances whose event_id field matches one of those events.
     */
    private static final String SQL_WHERE_ID_FROM_INSTANCES_SYNCED =
            Instances._ID + " IN " +
            "(SELECT " + Tables.INSTANCES + "." + Instances._ID + " as _id" +
            " FROM " + Tables.INSTANCES +
            " INNER JOIN " + Tables.EVENTS +
            " ON (" +
            Tables.EVENTS + "." + Events._ID + "=" + Tables.INSTANCES + "." + Instances.EVENT_ID +
            ")" +
            " WHERE " + Tables.EVENTS + "." + Events._SYNC_ID + "=?" + " OR " +
                    Tables.EVENTS + "." + Events.ORIGINAL_SYNC_ID + "=?)";

    private static final String[] EXPAND_COLUMNS = new String[] {
            Events._ID,
            Events._SYNC_ID,
            Events.STATUS,
            Events.DTSTART,
            Events.DTEND,
            Events.EVENT_TIMEZONE,
            Events.RRULE,
            Events.RDATE,
            Events.EXRULE,
            Events.EXDATE,
            Events.DURATION,
            Events.ALL_DAY,
            Events.ORIGINAL_SYNC_ID,
            Events.ORIGINAL_INSTANCE_TIME,
            Events.CALENDAR_ID,
            Events.DELETED
    };

    // To determine if a recurrence exception originally overlapped the
    // window, we need to assume a maximum duration, since we only know
    // the original start time.
    private static final int MAX_ASSUMED_DURATION = 7 * 24 * 60 * 60 * 1000;

    public CalendarInstancesHelper(CalendarDatabaseHelper calendarDbHelper, MetaData metaData) {
        mDbHelper = calendarDbHelper;
        mDb = mDbHelper.getWritableDatabase();
        mMetaData = metaData;
        mCalendarCache = new CalendarCache(mDbHelper);
    }

    /**
     * Extract the value from the specifed row and column of the Events table.
     *
     * @param db The database to access.
     * @param rowId The Event's _id.
     * @param columnName The name of the column to access.
     * @return The value in string form.
     */
    private static String getEventValue(SQLiteDatabase db, long rowId, String columnName) {
        String where = "SELECT " + columnName + " FROM " + Tables.EVENTS +
            " WHERE " + Events._ID + "=?";
        return DatabaseUtils.stringForQuery(db, where,
                new String[] { String.valueOf(rowId) });
    }

    /**
     * Perform instance expansion on the given entries.
     *
     * @param begin Window start (ms).
     * @param end Window end (ms).
     * @param localTimezone
     * @param entries The entries to process.
     */
    protected void performInstanceExpansion(long begin, long end, String localTimezone,
            Cursor entries) {
        // TODO: this only knows how to work with events that have been synced with the server
        RecurrenceProcessor rp = new RecurrenceProcessor();

        // Key into the instance values to hold the original event concatenated
        // with calendar id.
        final String ORIGINAL_EVENT_AND_CALENDAR = "ORIGINAL_EVENT_AND_CALENDAR";

        int statusColumn = entries.getColumnIndex(Events.STATUS);
        int dtstartColumn = entries.getColumnIndex(Events.DTSTART);
        int dtendColumn = entries.getColumnIndex(Events.DTEND);
        int eventTimezoneColumn = entries.getColumnIndex(Events.EVENT_TIMEZONE);
        int durationColumn = entries.getColumnIndex(Events.DURATION);
        int rruleColumn = entries.getColumnIndex(Events.RRULE);
        int rdateColumn = entries.getColumnIndex(Events.RDATE);
        int exruleColumn = entries.getColumnIndex(Events.EXRULE);
        int exdateColumn = entries.getColumnIndex(Events.EXDATE);
        int allDayColumn = entries.getColumnIndex(Events.ALL_DAY);
        int idColumn = entries.getColumnIndex(Events._ID);
        int syncIdColumn = entries.getColumnIndex(Events._SYNC_ID);
        int originalEventColumn = entries.getColumnIndex(Events.ORIGINAL_SYNC_ID);
        int originalInstanceTimeColumn = entries.getColumnIndex(Events.ORIGINAL_INSTANCE_TIME);
        int calendarIdColumn = entries.getColumnIndex(Events.CALENDAR_ID);
        int deletedColumn = entries.getColumnIndex(Events.DELETED);

        ContentValues initialValues;
        CalendarInstancesHelper.EventInstancesMap instancesMap =
            new CalendarInstancesHelper.EventInstancesMap();

        Duration duration = new Duration();
        Time eventTime = new Time();

        // Invariant: entries contains all events that affect the current
        // window.  It consists of:
        // a) Individual events that fall in the window.  These will be
        //    displayed.
        // b) Recurrences that included the window.  These will be displayed
        //    if not canceled.
        // c) Recurrence exceptions that fall in the window.  These will be
        //    displayed if not cancellations.
        // d) Recurrence exceptions that modify an instance inside the
        //    window (subject to 1 week assumption above), but are outside
        //    the window.  These will not be displayed.  Cases c and d are
        //    distinguished by the start / end time.

        while (entries.moveToNext()) {
            try {
                initialValues = null;

                boolean allDay = entries.getInt(allDayColumn) != 0;

                String eventTimezone = entries.getString(eventTimezoneColumn);
                if (allDay || TextUtils.isEmpty(eventTimezone)) {
                    // in the events table, allDay events start at midnight.
                    // this forces them to stay at midnight for all day events
                    // TODO: check that this actually does the right thing.
                    eventTimezone = Time.TIMEZONE_UTC;
                }

                long dtstartMillis = entries.getLong(dtstartColumn);
                Long eventId = Long.valueOf(entries.getLong(idColumn));

                String durationStr = entries.getString(durationColumn);
                if (durationStr != null) {
                    try {
                        duration.parse(durationStr);
                    }
                    catch (DateException e) {
                        if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) {
                            Log.w(CalendarProvider2.TAG, "error parsing duration for event "
                                    + eventId + "'" + durationStr + "'", e);
                        }
                        duration.sign = 1;
                        duration.weeks = 0;
                        duration.days = 0;
                        duration.hours = 0;
                        duration.minutes = 0;
                        duration.seconds = 0;
                        durationStr = "+P0S";
                    }
                }

                String syncId = entries.getString(syncIdColumn);
                String originalEvent = entries.getString(originalEventColumn);

                long originalInstanceTimeMillis = -1;
                if (!entries.isNull(originalInstanceTimeColumn)) {
                    originalInstanceTimeMillis= entries.getLong(originalInstanceTimeColumn);
                }
                int status = entries.getInt(statusColumn);
                boolean deleted = (entries.getInt(deletedColumn) != 0);

                String rruleStr = entries.getString(rruleColumn);
                String rdateStr = entries.getString(rdateColumn);
                String exruleStr = entries.getString(exruleColumn);
                String exdateStr = entries.getString(exdateColumn);
                long calendarId = entries.getLong(calendarIdColumn);
                // key into instancesMap
                String syncIdKey = CalendarInstancesHelper.getSyncIdKey(syncId, calendarId);

                RecurrenceSet recur = null;
                try {
                    recur = new RecurrenceSet(rruleStr, rdateStr, exruleStr, exdateStr);
                } catch (EventRecurrence.InvalidFormatException e) {
                    if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) {
                        Log.w(CalendarProvider2.TAG, "Could not parse RRULE recurrence string: "
                                + rruleStr, e);
                    }
                    continue;
                }

                if (null != recur && recur.hasRecurrence()) {
                    // the event is repeating

                    if (status == Events.STATUS_CANCELED) {
                        // should not happen!
                        if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) {
                            Log.e(CalendarProvider2.TAG, "Found canceled recurring event in "
                                    + "Events table.  Ignoring.");
                        }
                        continue;
                    }
                    if (deleted) {
                        if (Log.isLoggable(CalendarProvider2.TAG, Log.DEBUG)) {
                            Log.d(CalendarProvider2.TAG, "Found deleted recurring event in "
                                    + "Events table.  Ignoring.");
                        }
                        continue;
                    }

                    // need to parse the event into a local calendar.
                    eventTime.timezone = eventTimezone;
                    eventTime.set(dtstartMillis);
                    eventTime.allDay = allDay;

                    if (durationStr == null) {
                        // should not happen.
                        if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) {
                            Log.e(CalendarProvider2.TAG, "Repeating event has no duration -- "
                                    + "should not happen.");
                        }
                        if (allDay) {
                            // set to one day.
                            duration.sign = 1;
                            duration.weeks = 0;
                            duration.days = 1;
                            duration.hours = 0;
                            duration.minutes = 0;
                            duration.seconds = 0;
                            durationStr = "+P1D";
                        } else {
                            // compute the duration from dtend, if we can.
                            // otherwise, use 0s.
                            duration.sign = 1;
                            duration.weeks = 0;
                            duration.days = 0;
                            duration.hours = 0;
                            duration.minutes = 0;
                            if (!entries.isNull(dtendColumn)) {
                                long dtendMillis = entries.getLong(dtendColumn);
                                duration.seconds = (int) ((dtendMillis - dtstartMillis) / 1000);
                                durationStr = "+P" + duration.seconds + "S";
                            } else {
                                duration.seconds = 0;
                                durationStr = "+P0S";
                            }
                        }
                    }

                    long[] dates;
                    dates = rp.expand(eventTime, recur, begin, end);

                    // Initialize the "eventTime" timezone outside the loop.
                    // This is used in computeTimezoneDependentFields().
                    if (allDay) {
                        eventTime.timezone = Time.TIMEZONE_UTC;
                    } else {
                        eventTime.timezone = localTimezone;
                    }

                    long durationMillis = duration.getMillis();
                    for (long date : dates) {
                        initialValues = new ContentValues();
                        initialValues.put(Instances.EVENT_ID, eventId);

                        initialValues.put(Instances.BEGIN, date);
                        long dtendMillis = date + durationMillis;
                        initialValues.put(Instances.END, dtendMillis);

                        CalendarInstancesHelper.computeTimezoneDependentFields(date, dtendMillis,
                                eventTime, initialValues);
                        instancesMap.add(syncIdKey, initialValues);
                    }
                } else {
                    // the event is not repeating
                    initialValues = new ContentValues();

                    // if this event has an "original" field, then record
                    // that we need to cancel the original event (we can't
                    // do that here because the order of this loop isn't
                    // defined)
                    if (originalEvent != null && originalInstanceTimeMillis != -1) {
                        // The ORIGINAL_EVENT_AND_CALENDAR holds the
                        // calendar id concatenated with the ORIGINAL_EVENT to form
                        // a unique key, matching the keys for instancesMap.
                        initialValues.put(ORIGINAL_EVENT_AND_CALENDAR,
                                CalendarInstancesHelper.getSyncIdKey(originalEvent, calendarId));
                        initialValues.put(Events.ORIGINAL_INSTANCE_TIME,
                                originalInstanceTimeMillis);
                        initialValues.put(Events.STATUS, status);
                    }

                    long dtendMillis = dtstartMillis;
                    if (durationStr == null) {
                        if (!entries.isNull(dtendColumn)) {
                            dtendMillis = entries.getLong(dtendColumn);
                        }
                    } else {
                        dtendMillis = duration.addTo(dtstartMillis);
                    }

                    // this non-recurring event might be a recurrence exception that doesn't
                    // actually fall within our expansion window, but instead was selected
                    // so we can correctly cancel expanded recurrence instances below.  do not
                    // add events to the instances map if they don't actually fall within our
                    // expansion window.
                    if ((dtendMillis < begin) || (dtstartMillis > end)) {
                        if (originalEvent != null && originalInstanceTimeMillis != -1) {
                            initialValues.put(Events.STATUS, Events.STATUS_CANCELED);
                        } else {
                            if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) {
                                Log.w(CalendarProvider2.TAG, "Unexpected event outside window: "
                                        + syncId);
                            }
                            continue;
                        }
                    }

                    initialValues.put(Instances.EVENT_ID, eventId);

                    initialValues.put(Instances.BEGIN, dtstartMillis);
                    initialValues.put(Instances.END, dtendMillis);

                    // we temporarily store the DELETED status (will be cleaned later)
                    initialValues.put(Events.DELETED, deleted);

                    if (allDay) {
                        eventTime.timezone = Time.TIMEZONE_UTC;
                    } else {
                        eventTime.timezone = localTimezone;
                    }
                    CalendarInstancesHelper.computeTimezoneDependentFields(dtstartMillis,
                            dtendMillis, eventTime, initialValues);

                    instancesMap.add(syncIdKey, initialValues);
                }
            } catch (DateException e) {
                if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) {
                    Log.w(CalendarProvider2.TAG, "RecurrenceProcessor error ", e);
                }
            } catch (TimeFormatException e) {
                if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) {
                    Log.w(CalendarProvider2.TAG, "RecurrenceProcessor error ", e);
                }
            }
        }

        // Invariant: instancesMap contains all instances that affect the
        // window, indexed by original sync id concatenated with calendar id.
        // It consists of:
        // a) Individual events that fall in the window.  They have:
        //   EVENT_ID, BEGIN, END
        // b) Instances of recurrences that fall in the window.  They may
        //   be subject to exceptions.  They have:
        //   EVENT_ID, BEGIN, END
        // c) Exceptions that fall in the window.  They have:
        //   ORIGINAL_EVENT_AND_CALENDAR, ORIGINAL_INSTANCE_TIME, STATUS (since they can
        //   be a modification or cancellation), EVENT_ID, BEGIN, END
        // d) Recurrence exceptions that modify an instance inside the
        //   window but fall outside the window.  They have:
        //   ORIGINAL_EVENT_AND_CALENDAR, ORIGINAL_INSTANCE_TIME, STATUS =
        //   STATUS_CANCELED, EVENT_ID, BEGIN, END

        // First, delete the original instances corresponding to recurrence
        // exceptions.  We do this by iterating over the list and for each
        // recurrence exception, we search the list for an instance with a
        // matching "original instance time".  If we find such an instance,
        // we remove it from the list.  If we don't find such an instance
        // then we cancel the recurrence exception.
        Set<String> keys = instancesMap.keySet();
        for (String syncIdKey : keys) {
            CalendarInstancesHelper.InstancesList list = instancesMap.get(syncIdKey);
            for (ContentValues values : list) {

                // If this instance is not a recurrence exception, then
                // skip it.
                if (!values.containsKey(ORIGINAL_EVENT_AND_CALENDAR)) {
                    continue;
                }

                String originalEventPlusCalendar = values.getAsString(ORIGINAL_EVENT_AND_CALENDAR);
                long originalTime = values.getAsLong(Events.ORIGINAL_INSTANCE_TIME);
                CalendarInstancesHelper.InstancesList originalList = instancesMap
                        .get(originalEventPlusCalendar);
                if (originalList == null) {
                    // The original recurrence is not present, so don't try canceling it.
                    continue;
                }

                // Search the original event for a matching original
                // instance time.  If there is a matching one, then remove
                // the original one.  We do this both for exceptions that
                // change the original instance as well as for exceptions
                // that delete the original instance.
                for (int num = originalList.size() - 1; num >= 0; num--) {
                    ContentValues originalValues = originalList.get(num);
                    long beginTime = originalValues.getAsLong(Instances.BEGIN);
                    if (beginTime == originalTime) {
                        // We found the original instance, so remove it.
                        originalList.remove(num);
                    }
                }
            }
        }

        // Invariant: instancesMap contains filtered instances.
        // It consists of:
        // a) Individual events that fall in the window.
        // b) Instances of recurrences that fall in the window and have not
        //   been subject to exceptions.
        // c) Exceptions that fall in the window.  They will have
        //   STATUS_CANCELED if they are cancellations.
        // d) Recurrence exceptions that modify an instance inside the
        //   window but fall outside the window.  These are STATUS_CANCELED.

        // Now do the inserts.  Since the db lock is held when this method is executed,
        // this will be done in a transaction.
        // NOTE: if there is lock contention (e.g., a sync is trying to merge into the db
        // while the calendar app is trying to query the db (expanding instances)), we will
        // not be "polite" and yield the lock until we're done.  This will favor local query
        // operations over sync/write operations.
        for (String syncIdKey : keys) {
            CalendarInstancesHelper.InstancesList list = instancesMap.get(syncIdKey);
            for (ContentValues values : list) {

                // If this instance was cancelled or deleted then don't create a new
                // instance.
                Integer status = values.getAsInteger(Events.STATUS);
                boolean deleted = values.containsKey(Events.DELETED) ?
                        values.getAsBoolean(Events.DELETED) : false;
                if ((status != null && status == Events.STATUS_CANCELED) || deleted) {
                    continue;
                }

                // We remove this useless key (not valid in the context of Instances table)
                values.remove(Events.DELETED);

                // Remove these fields before inserting a new instance
                values.remove(ORIGINAL_EVENT_AND_CALENDAR);
                values.remove(Events.ORIGINAL_INSTANCE_TIME);
                values.remove(Events.STATUS);

                mDbHelper.instancesReplace(values);
            }
        }
    }

    /**
     * Make instances for the given range.
     */
    protected void expandInstanceRangeLocked(long begin, long end, String localTimezone) {

        if (CalendarProvider2.PROFILE) {
            Debug.startMethodTracing("expandInstanceRangeLocked");
        }

        if (Log.isLoggable(TAG, Log.VERBOSE)) {
            Log.v(TAG, "Expanding events between " + begin + " and " + end);
        }

        Cursor entries = getEntries(begin, end);
        try {
            performInstanceExpansion(begin, end, localTimezone, entries);
        } finally {
            if (entries != null) {
                entries.close();
            }
        }
        if (CalendarProvider2.PROFILE) {
            Debug.stopMethodTracing();
        }
    }

    /**
     * Get all entries affecting the given window.
     *
     * @param begin Window start (ms).
     * @param end Window end (ms).
     * @return Cursor for the entries; caller must close it.
     */
    private Cursor getEntries(long begin, long end) {
        SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
        qb.setTables(CalendarDatabaseHelper.Views.EVENTS);
        qb.setProjectionMap(CalendarProvider2.sEventsProjectionMap);

        String beginString = String.valueOf(begin);
        String endString = String.valueOf(end);

        // grab recurrence exceptions that fall outside our expansion window but
        // modify
        // recurrences that do fall within our window. we won't insert these
        // into the output
        // set of instances, but instead will just add them to our cancellations
        // list, so we
        // can cancel the correct recurrence expansion instances.
        // we don't have originalInstanceDuration or end time. for now, assume
        // the original
        // instance lasts no longer than 1 week.
        // also filter with syncable state (we dont want the entries from a non
        // syncable account)
        // also filter with last_synced=0 so we don't expand events that were
        // dup'ed for partial updates.
        // TODO: compute the originalInstanceEndTime or get this from the
        // server.
        qb.appendWhere(SQL_WHERE_GET_EVENTS_ENTRIES);
        String selectionArgs[] = new String[] {
                endString,
                beginString,
                endString,
                String.valueOf(begin - MAX_ASSUMED_DURATION),
                "0", // Calendars.SYNC_EVENTS
                "0", // Events.LAST_SYNCED
        };
        Cursor c = qb.query(mDb, EXPAND_COLUMNS, null /* selection */, selectionArgs,
                null /* groupBy */, null /* having */, null /* sortOrder */);
        if (Log.isLoggable(TAG, Log.VERBOSE)) {
            Log.v(TAG, "Instance expansion:  got " + c.getCount() + " entries");
        }
        return c;
    }

    /**
     * Updates the instances table when an event is added or updated.
     *
     * @param values The new values of the event.
     * @param rowId The database row id of the event.
     * @param newEvent true if the event is new.
     * @param db The database
     */
    public void updateInstancesLocked(ContentValues values, long rowId, boolean newEvent,
            SQLiteDatabase db) {
        /*
         * This may be a recurring event (has an RRULE or RDATE), an exception to a recurring
         * event (has ORIGINAL_ID or ORIGINAL_SYNC_ID), or a regular event.  Recurring events
         * and exceptions require additional handling.
         *
         * If this is not a new event, it may already have entries in Instances, so we want
         * to delete those before we do any additional work.
         */

        // If there are no expanded Instances, then return.
        MetaData.Fields fields = mMetaData.getFieldsLocked();
        if (fields.maxInstance == 0) {
            return;
        }

        Long dtstartMillis = values.getAsLong(Events.DTSTART);
        if (dtstartMillis == null) {
            if (newEvent) {
                // must be present for a new event.
                throw new RuntimeException("DTSTART missing.");
            }
            if (Log.isLoggable(TAG, Log.VERBOSE)) {
                Log.v(TAG, "Missing DTSTART.  No need to update instance.");
            }
            return;
        }

        if (!newEvent) {
            // Want to do this for regular event, recurrence, or exception.
            // For recurrence or exception, more deletion may happen below if we
            // do an instance expansion. This deletion will suffice if the
            // exception
            // is moved outside the window, for instance.
            db.delete(Tables.INSTANCES, Instances.EVENT_ID + "=?", new String[] {
                String.valueOf(rowId)
            });
        }

        String rrule = values.getAsString(Events.RRULE);
        String rdate = values.getAsString(Events.RDATE);
        String originalId = values.getAsString(Events.ORIGINAL_ID);
        String originalSyncId = values.getAsString(Events.ORIGINAL_SYNC_ID);
        if (CalendarProvider2.isRecurrenceEvent(rrule, rdate, originalId, originalSyncId)) {
            Long lastDateMillis = values.getAsLong(Events.LAST_DATE);
            Long originalInstanceTime = values.getAsLong(Events.ORIGINAL_INSTANCE_TIME);

            // The recurrence or exception needs to be (re-)expanded if:
            // a) Exception or recurrence that falls inside window
            boolean insideWindow = dtstartMillis <= fields.maxInstance
                    && (lastDateMillis == null || lastDateMillis >= fields.minInstance);
            // b) Exception that affects instance inside window
            // These conditions match the query in getEntries
            // See getEntries comment for explanation of subtracting 1 week.
            boolean affectsWindow = originalInstanceTime != null
                    && originalInstanceTime <= fields.maxInstance
                    && originalInstanceTime >= fields.minInstance - MAX_ASSUMED_DURATION;
            if (CalendarProvider2.DEBUG_INSTANCES) {
                Log.d(TAG + "-i", "Recurrence: inside=" + insideWindow +
                        ", affects=" + affectsWindow);
            }
            if (insideWindow || affectsWindow) {
                updateRecurrenceInstancesLocked(values, rowId, db);
            }
            // TODO: an exception creation or update could be optimized by
            // updating just the affected instances, instead of regenerating
            // the recurrence.
            return;
        }

        Long dtendMillis = values.getAsLong(Events.DTEND);
        if (dtendMillis == null) {
            dtendMillis = dtstartMillis;
        }

        // if the event is in the expanded range, insert
        // into the instances table.
        // TODO: deal with durations. currently, durations are only used in
        // recurrences.

        if (dtstartMillis <= fields.maxInstance && dtendMillis >= fields.minInstance) {
            ContentValues instanceValues = new ContentValues();
            instanceValues.put(Instances.EVENT_ID, rowId);
            instanceValues.put(Instances.BEGIN, dtstartMillis);
            instanceValues.put(Instances.END, dtendMillis);

            boolean allDay = false;
            Integer allDayInteger = values.getAsInteger(Events.ALL_DAY);
            if (allDayInteger != null) {
                allDay = allDayInteger != 0;
            }

            // Update the timezone-dependent fields.
            Time local = new Time();
            if (allDay) {
                local.timezone = Time.TIMEZONE_UTC;
            } else {
                local.timezone = fields.timezone;
            }

            CalendarInstancesHelper.computeTimezoneDependentFields(dtstartMillis, dtendMillis,
                    local, instanceValues);
            mDbHelper.instancesInsert(instanceValues);
        }
    }

    /**
     * Do incremental Instances update of a recurrence or recurrence exception.
     * This method does performInstanceExpansion on just the modified
     * recurrence, to avoid the overhead of recomputing the entire instance
     * table.
     *
     * @param values The new values of the event.
     * @param rowId The database row id of the event.
     * @param db The database
     */
    private void updateRecurrenceInstancesLocked(ContentValues values, long rowId,
            SQLiteDatabase db) {
        /*
         *  There are two categories of event that "rowId" may refer to:
         *  (1) Recurrence event.
         *  (2) Exception to recurrence event.  Has non-empty originalId (if it originated
         *      locally), originalSyncId (if it originated from the server), or both (if
         *      it's fully synchronized).
         *
         * Exceptions may arrive from the server before the recurrence event, which means:
         *  - We could find an originalSyncId but a lookup on originalSyncId could fail (in
         *    which case we can just ignore the exception for now).
         *  - There may be a brief period between the time we receive a recurrence and the
         *    time we set originalId in related exceptions where originalSyncId is the only
         *    way to find exceptions for a recurrence.  Thus, an empty originalId field may
         *    not be used to decide if an event is an exception.
         */

        MetaData.Fields fields = mMetaData.getFieldsLocked();
        String instancesTimezone = mCalendarCache.readTimezoneInstances();

        // Get the originalSyncId.  If it's not in "values", check the database.
        String originalSyncId = values.getAsString(Events.ORIGINAL_SYNC_ID);
        if (originalSyncId == null) {
            originalSyncId = getEventValue(db, rowId, Events.ORIGINAL_SYNC_ID);
        }

        String recurrenceSyncId;
        if (originalSyncId != null) {
            // This event is an exception; set recurrenceSyncId to the original.
            recurrenceSyncId = originalSyncId;
        } else {
            // This could be a recurrence or an exception.  If it has been synced with the
            // server we can get the _sync_id and know for certain that it's a recurrence.
            // If not, we'll deal with it below.
            recurrenceSyncId = values.getAsString(Events._SYNC_ID);
            if (recurrenceSyncId == null) {
                // Not in "values", check the database.
                recurrenceSyncId = getEventValue(db, rowId, Events._SYNC_ID);
            }
        }

        // Clear out old instances
        int delCount;
        if (recurrenceSyncId == null) {
            // We're creating or updating a recurrence or exception that hasn't been to the
            // server.  If this is a recurrence event, the event ID is simply the rowId.  If
            // it's an exception, we will find the value in the originalId field.
            String originalId = values.getAsString(Events.ORIGINAL_ID);
            if (originalId == null) {
                // Not in "values", check the database.
                originalId = getEventValue(db, rowId, Events.ORIGINAL_ID);
            }
            String recurrenceId;
            if (originalId != null) {
                // This event is an exception; set recurrenceId to the original.
                recurrenceId = originalId;
            } else {
                // This event is a recurrence, so we just use the ID that was passed in.
                recurrenceId = String.valueOf(rowId);
            }

            // Delete Instances entries for this Event (_id == recurrenceId) and for exceptions
            // to this Event (originalId == recurrenceId).
            String where = SQL_WHERE_ID_FROM_INSTANCES_NOT_SYNCED;
            delCount = db.delete(Tables.INSTANCES, where, new String[] {
                    recurrenceId, recurrenceId
            });
        } else {
            // We're creating or updating a recurrence or exception that has been synced with
            // the server.  Delete Instances entries for this Event (_sync_id == recurrenceSyncId)
            // and for exceptions to this Event (originalSyncId == recurrenceSyncId).
            String where = SQL_WHERE_ID_FROM_INSTANCES_SYNCED;
            delCount = db.delete(Tables.INSTANCES, where, new String[] {
                    recurrenceSyncId, recurrenceSyncId
            });
        }

        //Log.d(TAG, "Recurrence: deleted " + delCount + " instances");
        //dumpInstancesTable(db);

        // Now do instance expansion
        // TODO: passing "rowId" is wrong if this is an exception - need originalId then
        Cursor entries = getRelevantRecurrenceEntries(recurrenceSyncId, rowId);
        try {
            performInstanceExpansion(fields.minInstance, fields.maxInstance,
                    instancesTimezone, entries);
        } finally {
            if (entries != null) {
                entries.close();
            }
        }
    }

    /**
     * Determines the recurrence entries associated with a particular
     * recurrence. This set is the base recurrence and any exception. Normally
     * the entries are indicated by the sync id of the base recurrence (which is
     * the originalSyncId in the exceptions). However, a complication is that a
     * recurrence may not yet have a sync id. In that case, the recurrence is
     * specified by the rowId.
     *
     * @param recurrenceSyncId The sync id of the base recurrence, or null.
     * @param rowId The row id of the base recurrence.
     * @return the relevant entries.
     */
    private Cursor getRelevantRecurrenceEntries(String recurrenceSyncId, long rowId) {
        SQLiteQueryBuilder qb = new SQLiteQueryBuilder();

        qb.setTables(CalendarDatabaseHelper.Views.EVENTS);
        qb.setProjectionMap(CalendarProvider2.sEventsProjectionMap);
        String selectionArgs[];
        if (recurrenceSyncId == null) {
            String where = CalendarProvider2.SQL_WHERE_ID;
            qb.appendWhere(where);
            selectionArgs = new String[] {
                String.valueOf(rowId)
            };
        } else {
            // don't expand events that were dup'ed for partial updates
            String where = "(" + Events._SYNC_ID + "=? OR " + Events.ORIGINAL_SYNC_ID + "=?) AND "
                    + Events.LAST_SYNCED + " = ?";
            qb.appendWhere(where);
            selectionArgs = new String[] {
                    recurrenceSyncId,
                    recurrenceSyncId,
                    "0", // Events.LAST_SYNCED
            };
        }
        if (Log.isLoggable(TAG, Log.VERBOSE)) {
            Log.v(TAG, "Retrieving events to expand: " + qb.toString());
        }

        return qb.query(mDb, EXPAND_COLUMNS, null /* selection */, selectionArgs,
                null /* groupBy */, null /* having */, null /* sortOrder */);
    }

    /**
     * Generates a unique key from the syncId and calendarId. The purpose of
     * this is to prevent collisions if two different calendars use the same
     * sync id. This can happen if a Google calendar is accessed by two
     * different accounts, or with Exchange, where ids are not unique between
     * calendars.
     *
     * @param syncId Id for the event
     * @param calendarId Id for the calendar
     * @return key
     */
    static String getSyncIdKey(String syncId, long calendarId) {
        return calendarId + ":" + syncId;
    }

    /**
     * Computes the timezone-dependent fields of an instance of an event and
     * updates the "values" map to contain those fields.
     *
     * @param begin the start time of the instance (in UTC milliseconds)
     * @param end the end time of the instance (in UTC milliseconds)
     * @param local a Time object with the timezone set to the local timezone
     * @param values a map that will contain the timezone-dependent fields
     */
    static void computeTimezoneDependentFields(long begin, long end,
            Time local, ContentValues values) {
        local.set(begin);
        int startDay = Time.getJulianDay(begin, local.gmtoff);
        int startMinute = local.hour * 60 + local.minute;

        local.set(end);
        int endDay = Time.getJulianDay(end, local.gmtoff);
        int endMinute = local.hour * 60 + local.minute;

        // Special case for midnight, which has endMinute == 0.  Change
        // that to +24 hours on the previous day to make everything simpler.
        // Exception: if start and end minute are both 0 on the same day,
        // then leave endMinute alone.
        if (endMinute == 0 && endDay > startDay) {
            endMinute = 24 * 60;
            endDay -= 1;
        }

        values.put(Instances.START_DAY, startDay);
        values.put(Instances.END_DAY, endDay);
        values.put(Instances.START_MINUTE, startMinute);
        values.put(Instances.END_MINUTE, endMinute);
    }

    /**
     * Dumps the contents of the Instances table to the log file.
     */
    private static void dumpInstancesTable(SQLiteDatabase db) {
        Cursor cursor = db.query(Tables.INSTANCES, null, null, null, null, null, null);
        DatabaseUtils.dumpCursor(cursor);
    }
}
