package com.android.exchange.adapter;

import android.content.ContentProviderOperation;
import android.content.ContentProviderResult;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.OperationApplicationException;
import android.database.Cursor;
import android.net.Uri;
import android.os.RemoteException;
import android.os.TransactionTooLargeException;
import android.provider.CalendarContract;
import android.provider.CalendarContract.Attendees;
import android.provider.CalendarContract.Calendars;
import android.provider.CalendarContract.Events;
import android.provider.CalendarContract.ExtendedProperties;
import android.provider.CalendarContract.Reminders;
import android.provider.CalendarContract.SyncState;
import android.provider.SyncStateContract;
import android.text.format.DateUtils;

import com.android.emailcommon.provider.Account;
import com.android.emailcommon.provider.Mailbox;
import com.android.emailcommon.utility.Utility;
import com.android.exchange.Eas;
import com.android.exchange.adapter.AbstractSyncAdapter.Operation;
import com.android.exchange.eas.EasSyncCalendar;
import com.android.exchange.utility.CalendarUtilities;
import com.android.mail.utils.LogUtils;
import com.google.common.annotations.VisibleForTesting;

import java.io.IOException;
import java.io.InputStream;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.GregorianCalendar;
import java.util.Map.Entry;
import java.util.TimeZone;

public class CalendarSyncParser extends AbstractSyncParser {
    private static final String TAG = Eas.LOG_TAG;

    private final TimeZone UTC_TIMEZONE = TimeZone.getTimeZone("UTC");
    private final TimeZone mLocalTimeZone = TimeZone.getDefault();

    private final long mCalendarId;
    private final android.accounts.Account mAccountManagerAccount;
    private final Uri mAsSyncAdapterAttendees;
    private final Uri mAsSyncAdapterEvents;

    private final String[] mBindArgument = new String[1];
    private final CalendarOperations mOps;


    private static final String EVENT_SAVED_TIMEZONE_COLUMN = Events.SYNC_DATA1;
    // Since exceptions will have the same _SYNC_ID as the original event we have to check that
    // there's no original event when finding an item by _SYNC_ID
    private static final String SERVER_ID_AND_CALENDAR_ID = Events._SYNC_ID + "=? AND " +
        Events.ORIGINAL_SYNC_ID + " ISNULL AND " + Events.CALENDAR_ID + "=?";
    private static final String CLIENT_ID_SELECTION = Events.SYNC_DATA2 + "=?";
    private static final String ATTENDEES_EXCEPT_ORGANIZER = Attendees.EVENT_ID + "=? AND " +
        Attendees.ATTENDEE_RELATIONSHIP + "!=" + Attendees.RELATIONSHIP_ORGANIZER;
    private static final String[] ID_PROJECTION = new String[] {Events._ID};
    private static final String EVENT_ID_AND_NAME =
        ExtendedProperties.EVENT_ID + "=? AND " + ExtendedProperties.NAME + "=?";

    private static final String[] EXTENDED_PROPERTY_PROJECTION =
        new String[] {ExtendedProperties._ID};
    private static final int EXTENDED_PROPERTY_ID = 0;

    private static final String CATEGORY_TOKENIZER_DELIMITER = "\\";
    private static final String ATTENDEE_TOKENIZER_DELIMITER = CATEGORY_TOKENIZER_DELIMITER;

    private static final String EXTENDED_PROPERTY_USER_ATTENDEE_STATUS = "userAttendeeStatus";
    private static final String EXTENDED_PROPERTY_ATTENDEES = "attendees";
    private static final String EXTENDED_PROPERTY_DTSTAMP = "dtstamp";
    private static final String EXTENDED_PROPERTY_MEETING_STATUS = "meeting_status";
    private static final String EXTENDED_PROPERTY_CATEGORIES = "categories";
    // Used to indicate that we removed the attendee list because it was too large
    private static final String EXTENDED_PROPERTY_ATTENDEES_REDACTED = "attendeesRedacted";
    // Used to indicate that upsyncs aren't allowed (we catch this in sendLocalChanges)
    private static final String EXTENDED_PROPERTY_UPSYNC_PROHIBITED = "upsyncProhibited";

    private static final Operation PLACEHOLDER_OPERATION =
        new Operation(ContentProviderOperation.newInsert(Uri.EMPTY));

    private static final long SEPARATOR_ID = Long.MAX_VALUE;

    // Maximum number of allowed attendees; above this number, we mark the Event with the
    // attendeesRedacted extended property and don't allow the event to be upsynced to the server
    private static final int MAX_SYNCED_ATTENDEES = 50;
    // We set the organizer to this when the user is the organizer and we've redacted the
    // attendee list.  By making the meeting organizer OTHER than the user, we cause the UI to
    // prevent edits to this event (except local changes like reminder).
    private static final String BOGUS_ORGANIZER_EMAIL = "upload_disallowed@uploadisdisallowed.aaa";
    // Maximum number of CPO's before we start redacting attendees in exceptions
    // The number 500 has been determined empirically; 1500 CPOs appears to be the limit before
    // binder failures occur, but we need room at any point for additional events/exceptions so
    // we set our limit at 1/3 of the apparent maximum for extra safety
    // TODO Find a better solution to this workaround
    private static final int MAX_OPS_BEFORE_EXCEPTION_ATTENDEE_REDACTION = 500;

    public CalendarSyncParser(final Context context, final ContentResolver resolver,
            final InputStream in, final Mailbox mailbox, final Account account,
            final android.accounts.Account accountManagerAccount,
            final long calendarId) throws IOException {
        super(context, resolver, in, mailbox, account);
        mAccountManagerAccount = accountManagerAccount;
        mCalendarId = calendarId;
        mAsSyncAdapterAttendees = asSyncAdapter(Attendees.CONTENT_URI,
                mAccount.mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE);
        mAsSyncAdapterEvents = asSyncAdapter(Events.CONTENT_URI,
                mAccount.mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE);
        mOps = new CalendarOperations(resolver, mAsSyncAdapterAttendees, mAsSyncAdapterEvents,
                asSyncAdapter(Reminders.CONTENT_URI, mAccount.mEmailAddress,
                        Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE),
                asSyncAdapter(ExtendedProperties.CONTENT_URI, mAccount.mEmailAddress,
                        Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE));
    }

    protected static class CalendarOperations extends ArrayList<Operation> {
        private static final long serialVersionUID = 1L;
        public int mCount = 0;
        private int mEventStart = 0;
        private final ContentResolver mContentResolver;
        private final Uri mAsSyncAdapterAttendees;
        private final Uri mAsSyncAdapterEvents;
        private final Uri mAsSyncAdapterReminders;
        private final Uri mAsSyncAdapterExtendedProperties;

        public CalendarOperations(final ContentResolver contentResolver,
                final Uri asSyncAdapterAttendees, final Uri asSyncAdapterEvents,
                final Uri asSyncAdapterReminders, final Uri asSyncAdapterExtendedProperties) {
            mContentResolver = contentResolver;
            mAsSyncAdapterAttendees = asSyncAdapterAttendees;
            mAsSyncAdapterEvents = asSyncAdapterEvents;
            mAsSyncAdapterReminders = asSyncAdapterReminders;
            mAsSyncAdapterExtendedProperties = asSyncAdapterExtendedProperties;
        }

        @Override
        public boolean add(Operation op) {
            super.add(op);
            mCount++;
            return true;
        }

        public int newEvent(Operation op) {
            mEventStart = mCount;
            add(op);
            return mEventStart;
        }

        public int newDelete(long id, String serverId) {
            int offset = mCount;
            delete(id, serverId);
            return offset;
        }

        public void newAttendee(ContentValues cv) {
            newAttendee(cv, mEventStart);
        }

        public void newAttendee(ContentValues cv, int eventStart) {
            add(new Operation(ContentProviderOperation.newInsert(mAsSyncAdapterAttendees)
                    .withValues(cv),
                    Attendees.EVENT_ID,
                    eventStart));
        }

        public void updatedAttendee(ContentValues cv, long id) {
            cv.put(Attendees.EVENT_ID, id);
            add(new Operation(ContentProviderOperation.newInsert(mAsSyncAdapterAttendees)
                    .withValues(cv)));
        }

        public void newException(ContentValues cv) {
            add(new Operation(ContentProviderOperation.newInsert(mAsSyncAdapterEvents)
                    .withValues(cv)));
        }

        public void newExtendedProperty(String name, String value) {
            add(new Operation(ContentProviderOperation.newInsert(mAsSyncAdapterExtendedProperties)
                    .withValue(ExtendedProperties.NAME, name)
                    .withValue(ExtendedProperties.VALUE, value),
                    ExtendedProperties.EVENT_ID,
                    mEventStart));
        }

        public void updatedExtendedProperty(String name, String value, long id) {
            // Find an existing ExtendedProperties row for this event and property name
            Cursor c = mContentResolver.query(ExtendedProperties.CONTENT_URI,
                    EXTENDED_PROPERTY_PROJECTION, EVENT_ID_AND_NAME,
                    new String[] {Long.toString(id), name}, null);
            long extendedPropertyId = -1;
            // If there is one, capture its _id
            if (c != null) {
                try {
                    if (c.moveToFirst()) {
                        extendedPropertyId = c.getLong(EXTENDED_PROPERTY_ID);
                    }
                } finally {
                    c.close();
                }
            }
            // Either do an update or an insert, depending on whether one
            // already exists
            if (extendedPropertyId >= 0) {
                add(new Operation(ContentProviderOperation
                        .newUpdate(
                                ContentUris.withAppendedId(mAsSyncAdapterExtendedProperties,
                                        extendedPropertyId))
                        .withValue(ExtendedProperties.VALUE, value)));
            } else {
                newExtendedProperty(name, value);
            }
        }

        public void newReminder(int mins, int eventStart) {
            add(new Operation(ContentProviderOperation.newInsert(mAsSyncAdapterReminders)
                    .withValue(Reminders.MINUTES, mins)
                    .withValue(Reminders.METHOD, Reminders.METHOD_ALERT),
                    ExtendedProperties.EVENT_ID,
                    eventStart));
        }

        public void newReminder(int mins) {
            newReminder(mins, mEventStart);
        }

        public void delete(long id, String syncId) {
            add(new Operation(ContentProviderOperation.newDelete(
                    ContentUris.withAppendedId(mAsSyncAdapterEvents, id))));
            // Delete the exceptions for this Event (CalendarProvider doesn't do this)
            add(new Operation(ContentProviderOperation
                    .newDelete(mAsSyncAdapterEvents)
                    .withSelection(Events.ORIGINAL_SYNC_ID + "=?", new String[] {syncId})));
        }
    }

    private static Uri asSyncAdapter(Uri uri, String account, String accountType) {
        return uri.buildUpon().appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
                .appendQueryParameter(Calendars.ACCOUNT_NAME, account)
                .appendQueryParameter(Calendars.ACCOUNT_TYPE, accountType).build();
    }

    private static void addOrganizerToAttendees(CalendarOperations ops, long eventId,
            String organizerName, String organizerEmail) {
        // Handle the organizer (who IS an attendee on device, but NOT in EAS)
        if (organizerName != null || organizerEmail != null) {
            ContentValues attendeeCv = new ContentValues();
            if (organizerName != null) {
                attendeeCv.put(Attendees.ATTENDEE_NAME, organizerName);
            }
            if (organizerEmail != null) {
                attendeeCv.put(Attendees.ATTENDEE_EMAIL, organizerEmail);
            }
            attendeeCv.put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ORGANIZER);
            attendeeCv.put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_REQUIRED);
            attendeeCv.put(Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_STATUS_ACCEPTED);
            if (eventId < 0) {
                ops.newAttendee(attendeeCv);
            } else {
                ops.updatedAttendee(attendeeCv, eventId);
            }
        }
    }

    /**
     * Set DTSTART, DTEND, DURATION and EVENT_TIMEZONE as appropriate for the given Event
     * The follow rules are enforced by CalendarProvider2:
     *   Events that aren't exceptions MUST have either 1) a DTEND or 2) a DURATION
     *   Recurring events (i.e. events with RRULE) must have a DURATION
     *   All-day recurring events MUST have a DURATION that is in the form P<n>D
     *   Other events MAY have a DURATION in any valid form (we use P<n>M)
     *   All-day events MUST have hour, minute, and second = 0; in addition, they must have
     *   the EVENT_TIMEZONE set to UTC
     *   Also, exceptions to all-day events need to have an ORIGINAL_INSTANCE_TIME that has
     *   hour, minute, and second = 0 and be set in UTC
     * @param cv the ContentValues for the Event
     * @param startTime the start time for the Event
     * @param endTime the end time for the Event
     * @param allDayEvent whether this is an all day event (1) or not (0)
     */
    /*package*/ void setTimeRelatedValues(ContentValues cv, long startTime, long endTime,
            int allDayEvent) {
        // If there's no startTime, the event will be found to be invalid, so return
        if (startTime < 0) return;
        // EAS events can arrive without an end time, but CalendarProvider requires them
        // so we'll default to 30 minutes; this will be superceded if this is an all-day event
        if (endTime < 0) endTime = startTime + (30 * DateUtils.MINUTE_IN_MILLIS);

        // If this is an all-day event, set hour, minute, and second to zero, and use UTC
        if (allDayEvent != 0) {
            startTime = CalendarUtilities.getUtcAllDayCalendarTime(startTime, mLocalTimeZone);
            endTime = CalendarUtilities.getUtcAllDayCalendarTime(endTime, mLocalTimeZone);
            String originalTimeZone = cv.getAsString(Events.EVENT_TIMEZONE);
            cv.put(EVENT_SAVED_TIMEZONE_COLUMN, originalTimeZone);
            cv.put(Events.EVENT_TIMEZONE, UTC_TIMEZONE.getID());
        }

        // If this is an exception, and the original was an all-day event, make sure the
        // original instance time has hour, minute, and second set to zero, and is in UTC
        if (cv.containsKey(Events.ORIGINAL_INSTANCE_TIME) &&
                cv.containsKey(Events.ORIGINAL_ALL_DAY)) {
            Integer ade = cv.getAsInteger(Events.ORIGINAL_ALL_DAY);
            if (ade != null && ade != 0) {
                long exceptionTime = cv.getAsLong(Events.ORIGINAL_INSTANCE_TIME);
                final GregorianCalendar cal = new GregorianCalendar(UTC_TIMEZONE);
                exceptionTime = CalendarUtilities.getUtcAllDayCalendarTime(exceptionTime,
                        mLocalTimeZone);
                cal.setTimeInMillis(exceptionTime);
                cal.set(GregorianCalendar.HOUR_OF_DAY, 0);
                cal.set(GregorianCalendar.MINUTE, 0);
                cal.set(GregorianCalendar.SECOND, 0);
                cv.put(Events.ORIGINAL_INSTANCE_TIME, cal.getTimeInMillis());
            }
        }

        // Always set DTSTART
        cv.put(Events.DTSTART, startTime);
        // For recurring events, set DURATION.  Use P<n>D format for all day events
        if (cv.containsKey(Events.RRULE)) {
            if (allDayEvent != 0) {
                cv.put(Events.DURATION, "P" + ((endTime - startTime) / DateUtils.DAY_IN_MILLIS) + "D");
            }
            else {
                cv.put(Events.DURATION, "P" + ((endTime - startTime) / DateUtils.MINUTE_IN_MILLIS) + "M");
            }
        // For other events, set DTEND and LAST_DATE
        } else {
            cv.put(Events.DTEND, endTime);
            cv.put(Events.LAST_DATE, endTime);
        }
    }

    public void addEvent(CalendarOperations ops, String serverId, boolean update)
            throws IOException {
        ContentValues cv = new ContentValues();
        cv.put(Events.CALENDAR_ID, mCalendarId);
        cv.put(Events._SYNC_ID, serverId);
        cv.put(Events.HAS_ATTENDEE_DATA, 1);
        cv.put(Events.SYNC_DATA2, "0");

        int allDayEvent = 0;
        String organizerName = null;
        String organizerEmail = null;
        int eventOffset = -1;
        int deleteOffset = -1;
        int busyStatus = CalendarUtilities.BUSY_STATUS_TENTATIVE;
        int responseType = CalendarUtilities.RESPONSE_TYPE_NONE;

        boolean firstTag = true;
        long eventId = -1;
        long startTime = -1;
        long endTime = -1;
        TimeZone timeZone = null;

        // Keep track of the attendees; exceptions will need them
        ArrayList<ContentValues> attendeeValues = new ArrayList<ContentValues>();
        int reminderMins = -1;
        String dtStamp = null;
        boolean organizerAdded = false;

        while (nextTag(Tags.SYNC_APPLICATION_DATA) != END) {
            if (update && firstTag) {
                // Find the event that's being updated
                Cursor c = getServerIdCursor(serverId);
                long id = -1;
                try {
                    if (c != null && c.moveToFirst()) {
                        id = c.getLong(0);
                    }
                } finally {
                    if (c != null) c.close();
                }
                if (id > 0) {
                    // DTSTAMP can come first, and we simply need to track it
                    if (tag == Tags.CALENDAR_DTSTAMP) {
                        dtStamp = getValue();
                        continue;
                    } else if (tag == Tags.CALENDAR_ATTENDEES) {
                        // This is an attendees-only update; just
                        // delete/re-add attendees
                        mBindArgument[0] = Long.toString(id);
                        ops.add(new Operation(ContentProviderOperation
                                .newDelete(mAsSyncAdapterAttendees)
                                .withSelection(ATTENDEES_EXCEPT_ORGANIZER, mBindArgument)));
                        eventId = id;
                    } else {
                        // Otherwise, delete the original event and recreate it
                        userLog("Changing (delete/add) event ", serverId);
                        deleteOffset = ops.newDelete(id, serverId);
                        // Add a placeholder event so that associated tables can reference
                        // this as a back reference.  We add the event at the end of the method
                        eventOffset = ops.newEvent(PLACEHOLDER_OPERATION);
                    }
                } else {
                    // The changed item isn't found. We'll treat this as a new item
                    eventOffset = ops.newEvent(PLACEHOLDER_OPERATION);
                    userLog(TAG, "Changed item not found; treating as new.");
                }
            } else if (firstTag) {
                // Add a placeholder event so that associated tables can reference
                // this as a back reference.  We add the event at the end of the method
               eventOffset = ops.newEvent(PLACEHOLDER_OPERATION);
            }
            firstTag = false;
            switch (tag) {
                case Tags.CALENDAR_ALL_DAY_EVENT:
                    allDayEvent = getValueInt();
                    if (allDayEvent != 0 && timeZone != null) {
                        // If the event doesn't start at midnight local time, we won't consider
                        // this an all-day event in the local time zone (this is what OWA does)
                        GregorianCalendar cal = new GregorianCalendar(mLocalTimeZone);
                        cal.setTimeInMillis(startTime);
                        userLog("All-day event arrived in: " + timeZone.getID());
                        if (cal.get(GregorianCalendar.HOUR_OF_DAY) != 0 ||
                                cal.get(GregorianCalendar.MINUTE) != 0) {
                            allDayEvent = 0;
                            userLog("Not an all-day event locally: " + mLocalTimeZone.getID());
                        }
                    }
                    cv.put(Events.ALL_DAY, allDayEvent);
                    break;
                case Tags.CALENDAR_ATTACHMENTS:
                    attachmentsParser();
                    break;
                case Tags.CALENDAR_ATTENDEES:
                    // If eventId >= 0, this is an update; otherwise, a new Event
                    attendeeValues = attendeesParser();
                    break;
                case Tags.BASE_BODY:
                    cv.put(Events.DESCRIPTION, bodyParser());
                    break;
                case Tags.CALENDAR_BODY:
                    cv.put(Events.DESCRIPTION, getValue());
                    break;
                case Tags.CALENDAR_TIME_ZONE:
                    timeZone = CalendarUtilities.tziStringToTimeZone(getValue());
                    if (timeZone == null) {
                        timeZone = mLocalTimeZone;
                    }
                    cv.put(Events.EVENT_TIMEZONE, timeZone.getID());
                    break;
                case Tags.CALENDAR_START_TIME:
                    try {
                        startTime = Utility.parseDateTimeToMillis(getValue());
                    } catch (ParseException e) {
                        LogUtils.w(TAG, "Parse error for CALENDAR_START_TIME tag.", e);
                    }
                    break;
                case Tags.CALENDAR_END_TIME:
                    try {
                        endTime = Utility.parseDateTimeToMillis(getValue());
                    } catch (ParseException e) {
                        LogUtils.w(TAG, "Parse error for CALENDAR_END_TIME tag.", e);
                    }
                    break;
                case Tags.CALENDAR_EXCEPTIONS:
                    // For exceptions to show the organizer, the organizer must be added before
                    // we call exceptionsParser
                    addOrganizerToAttendees(ops, eventId, organizerName, organizerEmail);
                    organizerAdded = true;
                    exceptionsParser(ops, cv, attendeeValues, reminderMins, busyStatus,
                            startTime, endTime);
                    break;
                case Tags.CALENDAR_LOCATION:
                    cv.put(Events.EVENT_LOCATION, getValue());
                    break;
                case Tags.CALENDAR_RECURRENCE:
                    String rrule = recurrenceParser();
                    if (rrule != null) {
                        cv.put(Events.RRULE, rrule);
                    }
                    break;
                case Tags.CALENDAR_ORGANIZER_EMAIL:
                    organizerEmail = getValue();
                    cv.put(Events.ORGANIZER, organizerEmail);
                    break;
                case Tags.CALENDAR_SUBJECT:
                    cv.put(Events.TITLE, getValue());
                    break;
                case Tags.CALENDAR_SENSITIVITY:
                    cv.put(Events.ACCESS_LEVEL, encodeVisibility(getValueInt()));
                    break;
                case Tags.CALENDAR_ORGANIZER_NAME:
                    organizerName = getValue();
                    break;
                case Tags.CALENDAR_REMINDER_MINS_BEFORE:
                    // Save away whether this tag has content; Exchange 2010 sends an empty tag
                    // rather than not sending one (as with Ex07 and Ex03)
                    boolean hasContent = !noContent;
                    reminderMins = getValueInt();
                    if (hasContent) {
                        ops.newReminder(reminderMins);
                        cv.put(Events.HAS_ALARM, 1);
                    }
                    break;
                // The following are fields we should save (for changes), though they don't
                // relate to data used by CalendarProvider at this point
                case Tags.CALENDAR_UID:
                    cv.put(Events.SYNC_DATA2, getValue());
                    break;
                case Tags.CALENDAR_DTSTAMP:
                    dtStamp = getValue();
                    break;
                case Tags.CALENDAR_MEETING_STATUS:
                    ops.newExtendedProperty(EXTENDED_PROPERTY_MEETING_STATUS, getValue());
                    break;
                case Tags.CALENDAR_BUSY_STATUS:
                    // We'll set the user's status in the Attendees table below
                    // Don't set selfAttendeeStatus or CalendarProvider will create a duplicate
                    // attendee!
                    busyStatus = getValueInt();
                    break;
                case Tags.CALENDAR_RESPONSE_TYPE:
                    // EAS 14+ uses this for the user's response status; we'll use this instead
                    // of busy status, if it appears
                    responseType = getValueInt();
                    break;
                case Tags.CALENDAR_CATEGORIES:
                    String categories = categoriesParser();
                    if (categories.length() > 0) {
                        ops.newExtendedProperty(EXTENDED_PROPERTY_CATEGORIES, categories);
                    }
                    break;
                default:
                    skipTag();
            }
        }

        // Enforce CalendarProvider required properties
        setTimeRelatedValues(cv, startTime, endTime, allDayEvent);

        // Set user's availability
        cv.put(Events.AVAILABILITY, CalendarUtilities.availabilityFromBusyStatus(busyStatus));

        // If we haven't added the organizer to attendees, do it now
        if (!organizerAdded) {
            addOrganizerToAttendees(ops, eventId, organizerName, organizerEmail);
        }

        // Note that organizerEmail can be null with a DTSTAMP only change from the server
        boolean selfOrganizer = (mAccount.mEmailAddress.equals(organizerEmail));

        // Store email addresses of attendees (in a tokenizable string) in ExtendedProperties
        // If the user is an attendee, set the attendee status using busyStatus (note that the
        // busyStatus is inherited from the parent unless it's specified in the exception)
        // Add the insert/update operation for each attendee (based on whether it's add/change)
        int numAttendees = attendeeValues.size();
        if (numAttendees > MAX_SYNCED_ATTENDEES) {
            // Indicate that we've redacted attendees.  If we're the organizer, disable edit
            // by setting organizerEmail to a bogus value and by setting the upsync prohibited
            // extended properly.
            // Note that we don't set ANY attendees if we're in this branch; however, the
            // organizer has already been included above, and WILL show up (which is good)
            if (eventId < 0) {
                ops.newExtendedProperty(EXTENDED_PROPERTY_ATTENDEES_REDACTED, "1");
                if (selfOrganizer) {
                    ops.newExtendedProperty(EXTENDED_PROPERTY_UPSYNC_PROHIBITED, "1");
                }
            } else {
                ops.updatedExtendedProperty(EXTENDED_PROPERTY_ATTENDEES_REDACTED, "1", eventId);
                if (selfOrganizer) {
                    ops.updatedExtendedProperty(EXTENDED_PROPERTY_UPSYNC_PROHIBITED, "1",
                            eventId);
                }
            }
            if (selfOrganizer) {
                organizerEmail = BOGUS_ORGANIZER_EMAIL;
                cv.put(Events.ORGANIZER, organizerEmail);
            }
            // Tell UI that we don't have any attendees
            cv.put(Events.HAS_ATTENDEE_DATA, "0");
            LogUtils.d(TAG, "Maximum number of attendees exceeded; redacting");
        } else if (numAttendees > 0) {
            StringBuilder sb = new StringBuilder();
            for (ContentValues attendee: attendeeValues) {
                String attendeeEmail = attendee.getAsString(Attendees.ATTENDEE_EMAIL);
                sb.append(attendeeEmail);
                sb.append(ATTENDEE_TOKENIZER_DELIMITER);
                if (mAccount.mEmailAddress.equalsIgnoreCase(attendeeEmail)) {
                    int attendeeStatus;
                    // We'll use the response type (EAS 14), if we've got one; otherwise, we'll
                    // try to infer it from busy status
                    if (responseType != CalendarUtilities.RESPONSE_TYPE_NONE) {
                        attendeeStatus =
                            CalendarUtilities.attendeeStatusFromResponseType(responseType);
                    } else if (!update) {
                        // For new events in EAS < 14, we have no idea what the busy status
                        // means, so we show "none", allowing the user to select an option.
                        attendeeStatus = Attendees.ATTENDEE_STATUS_NONE;
                    } else {
                        // For updated events, we'll try to infer the attendee status from the
                        // busy status
                        attendeeStatus =
                            CalendarUtilities.attendeeStatusFromBusyStatus(busyStatus);
                    }
                    attendee.put(Attendees.ATTENDEE_STATUS, attendeeStatus);
                    // If we're an attendee, save away our initial attendee status in the
                    // event's ExtendedProperties (we look for differences between this and
                    // the user's current attendee status to determine whether an email needs
                    // to be sent to the organizer)
                    // organizerEmail will be null in the case that this is an attendees-only
                    // change from the server
                    if (organizerEmail == null ||
                            !organizerEmail.equalsIgnoreCase(attendeeEmail)) {
                        if (eventId < 0) {
                            ops.newExtendedProperty(EXTENDED_PROPERTY_USER_ATTENDEE_STATUS,
                                    Integer.toString(attendeeStatus));
                        } else {
                            ops.updatedExtendedProperty(EXTENDED_PROPERTY_USER_ATTENDEE_STATUS,
                                    Integer.toString(attendeeStatus), eventId);

                        }
                    }
                }
                if (eventId < 0) {
                    ops.newAttendee(attendee);
                } else {
                    ops.updatedAttendee(attendee, eventId);
                }
            }
            if (eventId < 0) {
                ops.newExtendedProperty(EXTENDED_PROPERTY_ATTENDEES, sb.toString());
                ops.newExtendedProperty(EXTENDED_PROPERTY_ATTENDEES_REDACTED, "0");
                ops.newExtendedProperty(EXTENDED_PROPERTY_UPSYNC_PROHIBITED, "0");
            } else {
                ops.updatedExtendedProperty(EXTENDED_PROPERTY_ATTENDEES, sb.toString(),
                        eventId);
                ops.updatedExtendedProperty(EXTENDED_PROPERTY_ATTENDEES_REDACTED, "0", eventId);
                ops.updatedExtendedProperty(EXTENDED_PROPERTY_UPSYNC_PROHIBITED, "0", eventId);
            }
        }

        // Put the real event in the proper place in the ops ArrayList
        if (eventOffset >= 0) {
            // Store away the DTSTAMP here
            if (dtStamp != null) {
                ops.newExtendedProperty(EXTENDED_PROPERTY_DTSTAMP, dtStamp);
            }

            if (isValidEventValues(cv)) {
                ops.set(eventOffset,
                        new Operation(ContentProviderOperation
                                .newInsert(mAsSyncAdapterEvents).withValues(cv)));
            } else {
                // If we can't add this event (it's invalid), remove all of the inserts
                // we've built for it
                int cnt = ops.mCount - eventOffset;
                userLog(TAG, "Removing " + cnt + " inserts from mOps");
                for (int i = 0; i < cnt; i++) {
                    ops.remove(eventOffset);
                }
                ops.mCount = eventOffset;
                // If this is a change, we need to also remove the deletion that comes
                // before the addition
                if (deleteOffset >= 0) {
                    // Remove the deletion
                    ops.remove(deleteOffset);
                    // And the deletion of exceptions
                    ops.remove(deleteOffset);
                    userLog(TAG, "Removing deletion ops from mOps");
                    ops.mCount = deleteOffset;
                }
            }
        }
        // Mark the end of the event
        addSeparatorOperation(ops, Events.CONTENT_URI);
    }

    private void logEventColumns(ContentValues cv, String reason) {
        if (Eas.USER_LOG) {
            StringBuilder sb =
                new StringBuilder("Event invalid, " + reason + ", skipping: Columns = ");
            for (Entry<String, Object> entry: cv.valueSet()) {
                sb.append(entry.getKey());
                sb.append('/');
            }
            userLog(TAG, sb.toString());
        }
    }

    /*package*/ boolean isValidEventValues(ContentValues cv) {
        boolean isException = cv.containsKey(Events.ORIGINAL_INSTANCE_TIME);
        // All events require DTSTART
        if (!cv.containsKey(Events.DTSTART)) {
            logEventColumns(cv, "DTSTART missing");
            return false;
        // If we're a top-level event, we must have _SYNC_DATA (uid)
        } else if (!isException && !cv.containsKey(Events.SYNC_DATA2)) {
            logEventColumns(cv, "_SYNC_DATA missing");
            return false;
        // We must also have DTEND or DURATION if we're not an exception
        } else if (!isException && !cv.containsKey(Events.DTEND) &&
                !cv.containsKey(Events.DURATION)) {
            logEventColumns(cv, "DTEND/DURATION missing");
            return false;
        // Exceptions require DTEND
        } else if (isException && !cv.containsKey(Events.DTEND)) {
            logEventColumns(cv, "Exception missing DTEND");
            return false;
        // If this is a recurrence, we need a DURATION (in days if an all-day event)
        } else if (cv.containsKey(Events.RRULE)) {
            String duration = cv.getAsString(Events.DURATION);
            if (duration == null) return false;
            if (cv.containsKey(Events.ALL_DAY)) {
                Integer ade = cv.getAsInteger(Events.ALL_DAY);
                if (ade != null && ade != 0 && !duration.endsWith("D")) {
                    return false;
                }
            }
        }
        return true;
    }

    public String recurrenceParser() throws IOException {
        // Turn this information into an RRULE
        int type = -1;
        int occurrences = -1;
        int interval = -1;
        int dow = -1;
        int dom = -1;
        int wom = -1;
        int moy = -1;
        String until = null;

        while (nextTag(Tags.CALENDAR_RECURRENCE) != END) {
            switch (tag) {
                case Tags.CALENDAR_RECURRENCE_TYPE:
                    type = getValueInt();
                    break;
                case Tags.CALENDAR_RECURRENCE_INTERVAL:
                    interval = getValueInt();
                    break;
                case Tags.CALENDAR_RECURRENCE_OCCURRENCES:
                    occurrences = getValueInt();
                    break;
                case Tags.CALENDAR_RECURRENCE_DAYOFWEEK:
                    dow = getValueInt();
                    break;
                case Tags.CALENDAR_RECURRENCE_DAYOFMONTH:
                    dom = getValueInt();
                    break;
                case Tags.CALENDAR_RECURRENCE_WEEKOFMONTH:
                    wom = getValueInt();
                    break;
                case Tags.CALENDAR_RECURRENCE_MONTHOFYEAR:
                    moy = getValueInt();
                    break;
                case Tags.CALENDAR_RECURRENCE_UNTIL:
                    until = getValue();
                    break;
                default:
                   skipTag();
            }
        }

        return CalendarUtilities.rruleFromRecurrence(type, occurrences, interval,
                dow, dom, wom, moy, until);
    }

    private void exceptionParser(CalendarOperations ops, ContentValues parentCv,
            ArrayList<ContentValues> attendeeValues, int reminderMins, int busyStatus,
            long startTime, long endTime) throws IOException {
        ContentValues cv = new ContentValues();
        cv.put(Events.CALENDAR_ID, mCalendarId);

        // It appears that these values have to be copied from the parent if they are to appear
        // Note that they can be overridden below
        cv.put(Events.ORGANIZER, parentCv.getAsString(Events.ORGANIZER));
        cv.put(Events.TITLE, parentCv.getAsString(Events.TITLE));
        cv.put(Events.DESCRIPTION, parentCv.getAsString(Events.DESCRIPTION));
        cv.put(Events.ORIGINAL_ALL_DAY, parentCv.getAsInteger(Events.ALL_DAY));
        cv.put(Events.EVENT_LOCATION, parentCv.getAsString(Events.EVENT_LOCATION));
        cv.put(Events.ACCESS_LEVEL, parentCv.getAsString(Events.ACCESS_LEVEL));
        cv.put(Events.EVENT_TIMEZONE, parentCv.getAsString(Events.EVENT_TIMEZONE));
        // Exceptions should always have this set to zero, since EAS has no concept of
        // separate attendee lists for exceptions; if we fail to do this, then the UI will
        // allow the user to change attendee data, and this change would never get reflected
        // on the server.
        cv.put(Events.HAS_ATTENDEE_DATA, 0);

        int allDayEvent = 0;

        // This column is the key that links the exception to the serverId
        cv.put(Events.ORIGINAL_SYNC_ID, parentCv.getAsString(Events._SYNC_ID));

        String exceptionStartTime = "_noStartTime";
        while (nextTag(Tags.CALENDAR_EXCEPTION) != END) {
            switch (tag) {
                case Tags.CALENDAR_ATTACHMENTS:
                    attachmentsParser();
                    break;
                case Tags.CALENDAR_EXCEPTION_START_TIME:
                    final String valueStr = getValue();
                    try {
                        cv.put(Events.ORIGINAL_INSTANCE_TIME,
                                Utility.parseDateTimeToMillis(valueStr));
                        exceptionStartTime = valueStr;
                    } catch (ParseException e) {
                        LogUtils.w(TAG, "Parse error for CALENDAR_EXCEPTION_START_TIME tag.", e);
                    }
                    break;
                case Tags.CALENDAR_EXCEPTION_IS_DELETED:
                    if (getValueInt() == 1) {
                        cv.put(Events.STATUS, Events.STATUS_CANCELED);
                    }
                    break;
                case Tags.CALENDAR_ALL_DAY_EVENT:
                    allDayEvent = getValueInt();
                    cv.put(Events.ALL_DAY, allDayEvent);
                    break;
                case Tags.BASE_BODY:
                    cv.put(Events.DESCRIPTION, bodyParser());
                    break;
                case Tags.CALENDAR_BODY:
                    cv.put(Events.DESCRIPTION, getValue());
                    break;
                case Tags.CALENDAR_START_TIME:
                    try {
                        startTime = Utility.parseDateTimeToMillis(getValue());
                    } catch (ParseException e) {
                        LogUtils.w(TAG, "Parse error for CALENDAR_START_TIME tag.", e);
                    }
                    break;
                case Tags.CALENDAR_END_TIME:
                    try {
                        endTime = Utility.parseDateTimeToMillis(getValue());
                    } catch (ParseException e) {
                        LogUtils.w(TAG, "Parse error for CALENDAR_END_TIME tag.", e);
                    }
                    break;
                case Tags.CALENDAR_LOCATION:
                    cv.put(Events.EVENT_LOCATION, getValue());
                    break;
                case Tags.CALENDAR_RECURRENCE:
                    String rrule = recurrenceParser();
                    if (rrule != null) {
                        cv.put(Events.RRULE, rrule);
                    }
                    break;
                case Tags.CALENDAR_SUBJECT:
                    cv.put(Events.TITLE, getValue());
                    break;
                case Tags.CALENDAR_SENSITIVITY:
                    cv.put(Events.ACCESS_LEVEL, encodeVisibility(getValueInt()));
                    break;
                case Tags.CALENDAR_BUSY_STATUS:
                    busyStatus = getValueInt();
                    // Don't set selfAttendeeStatus or CalendarProvider will create a duplicate
                    // attendee!
                    break;
                    // TODO How to handle these items that are linked to event id!
//                case Tags.CALENDAR_DTSTAMP:
//                    ops.newExtendedProperty("dtstamp", getValue());
//                    break;
//                case Tags.CALENDAR_REMINDER_MINS_BEFORE:
//                    ops.newReminder(getValueInt());
//                    break;
                default:
                    skipTag();
            }
        }

        // We need a _sync_id, but it can't be the parent's id, so we generate one
        cv.put(Events._SYNC_ID, parentCv.getAsString(Events._SYNC_ID) + '_' +
                exceptionStartTime);

        // Enforce CalendarProvider required properties
        setTimeRelatedValues(cv, startTime, endTime, allDayEvent);

        // Don't insert an invalid exception event
        if (!isValidEventValues(cv)) return;

        // Add the exception insert
        int exceptionStart = ops.mCount;
        ops.newException(cv);
        // Also add the attendees, because they need to be copied over from the parent event
        boolean attendeesRedacted = false;
        if (attendeeValues != null) {
            for (ContentValues attValues: attendeeValues) {
                // If this is the user, use his busy status for attendee status
                String attendeeEmail = attValues.getAsString(Attendees.ATTENDEE_EMAIL);
                // Note that the exception at which we surpass the redaction limit might have
                // any number of attendees shown; since this is an edge case and a workaround,
                // it seems to be an acceptable implementation
                if (mAccount.mEmailAddress.equalsIgnoreCase(attendeeEmail)) {
                    attValues.put(Attendees.ATTENDEE_STATUS,
                            CalendarUtilities.attendeeStatusFromBusyStatus(busyStatus));
                    ops.newAttendee(attValues, exceptionStart);
                } else if (ops.size() < MAX_OPS_BEFORE_EXCEPTION_ATTENDEE_REDACTION) {
                    ops.newAttendee(attValues, exceptionStart);
                } else {
                    attendeesRedacted = true;
                }
            }
        }
        // And add the parent's reminder value
        if (reminderMins > 0) {
            ops.newReminder(reminderMins, exceptionStart);
        }
        if (attendeesRedacted) {
            LogUtils.d(TAG, "Attendees redacted in this exception");
        }
    }

    private static int encodeVisibility(int easVisibility) {
        int visibility = 0;
        switch(easVisibility) {
            case 0:
                visibility = Events.ACCESS_DEFAULT;
                break;
            case 1:
                visibility = Events.ACCESS_PUBLIC;
                break;
            case 2:
                visibility = Events.ACCESS_PRIVATE;
                break;
            case 3:
                visibility = Events.ACCESS_CONFIDENTIAL;
                break;
        }
        return visibility;
    }

    private void exceptionsParser(CalendarOperations ops, ContentValues cv,
            ArrayList<ContentValues> attendeeValues, int reminderMins, int busyStatus,
            long startTime, long endTime) throws IOException {
        while (nextTag(Tags.CALENDAR_EXCEPTIONS) != END) {
            switch (tag) {
                case Tags.CALENDAR_EXCEPTION:
                    exceptionParser(ops, cv, attendeeValues, reminderMins, busyStatus,
                            startTime, endTime);
                    break;
                default:
                    skipTag();
            }
        }
    }

    private String categoriesParser() throws IOException {
        StringBuilder categories = new StringBuilder();
        while (nextTag(Tags.CALENDAR_CATEGORIES) != END) {
            switch (tag) {
                case Tags.CALENDAR_CATEGORY:
                    // TODO Handle categories (there's no similar concept for gdata AFAIK)
                    // We need to save them and spit them back when we update the event
                    categories.append(getValue());
                    categories.append(CATEGORY_TOKENIZER_DELIMITER);
                    break;
                default:
                    skipTag();
            }
        }
        return categories.toString();
    }

    /**
     * For now, we ignore (but still have to parse) event attachments; these are new in EAS 14
     */
    private void attachmentsParser() throws IOException {
        while (nextTag(Tags.CALENDAR_ATTACHMENTS) != END) {
            switch (tag) {
                case Tags.CALENDAR_ATTACHMENT:
                    skipParser(Tags.CALENDAR_ATTACHMENT);
                    break;
                default:
                    skipTag();
            }
        }
    }

    private ArrayList<ContentValues> attendeesParser()
            throws IOException {
        int attendeeCount = 0;
        ArrayList<ContentValues> attendeeValues = new ArrayList<ContentValues>();
        while (nextTag(Tags.CALENDAR_ATTENDEES) != END) {
            switch (tag) {
                case Tags.CALENDAR_ATTENDEE:
                    ContentValues cv = attendeeParser();
                    // If we're going to redact these attendees anyway, let's avoid unnecessary
                    // memory pressure, and not keep them around
                    // We still need to parse them all, however
                    attendeeCount++;
                    // Allow one more than MAX_ATTENDEES, so that the check for "too many" will
                    // succeed in addEvent
                    if (attendeeCount <= (MAX_SYNCED_ATTENDEES+1)) {
                        attendeeValues.add(cv);
                    }
                    break;
                default:
                    skipTag();
            }
        }
        return attendeeValues;
    }

    private ContentValues attendeeParser()
            throws IOException {
        ContentValues cv = new ContentValues();
        while (nextTag(Tags.CALENDAR_ATTENDEE) != END) {
            switch (tag) {
                case Tags.CALENDAR_ATTENDEE_EMAIL:
                    cv.put(Attendees.ATTENDEE_EMAIL, getValue());
                    break;
                case Tags.CALENDAR_ATTENDEE_NAME:
                    cv.put(Attendees.ATTENDEE_NAME, getValue());
                    break;
                case Tags.CALENDAR_ATTENDEE_STATUS:
                    int status = getValueInt();
                    cv.put(Attendees.ATTENDEE_STATUS,
                            (status == 2) ? Attendees.ATTENDEE_STATUS_TENTATIVE :
                            (status == 3) ? Attendees.ATTENDEE_STATUS_ACCEPTED :
                            (status == 4) ? Attendees.ATTENDEE_STATUS_DECLINED :
                            (status == 5) ? Attendees.ATTENDEE_STATUS_INVITED :
                                Attendees.ATTENDEE_STATUS_NONE);
                    break;
                case Tags.CALENDAR_ATTENDEE_TYPE:
                    int type = Attendees.TYPE_NONE;
                    // EAS types: 1 = req'd, 2 = opt, 3 = resource
                    switch (getValueInt()) {
                        case 1:
                            type = Attendees.TYPE_REQUIRED;
                            break;
                        case 2:
                            type = Attendees.TYPE_OPTIONAL;
                            break;
                    }
                    cv.put(Attendees.ATTENDEE_TYPE, type);
                    break;
                default:
                    skipTag();
            }
        }
        cv.put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ATTENDEE);
        return cv;
    }

    private String bodyParser() throws IOException {
        String body = null;
        while (nextTag(Tags.BASE_BODY) != END) {
            switch (tag) {
                case Tags.BASE_DATA:
                    body = getValue();
                    break;
                default:
                    skipTag();
            }
        }

        // Handle null data without error
        if (body == null) return "";
        // Remove \r's from any body text
        return body.replace("\r\n", "\n");
    }

    public void addParser(CalendarOperations ops) throws IOException {
        String serverId = null;
        while (nextTag(Tags.SYNC_ADD) != END) {
            switch (tag) {
                case Tags.SYNC_SERVER_ID: // same as
                    serverId = getValue();
                    break;
                case Tags.SYNC_APPLICATION_DATA:
                    addEvent(ops, serverId, false);
                    break;
                default:
                    skipTag();
            }
        }
    }

    private Cursor getServerIdCursor(String serverId) {
        return mContentResolver.query(Events.CONTENT_URI, ID_PROJECTION,
                SERVER_ID_AND_CALENDAR_ID, new String[] {serverId, Long.toString(mCalendarId)},
                null);
    }

    private Cursor getClientIdCursor(String clientId) {
        mBindArgument[0] = clientId;
        return mContentResolver.query(Events.CONTENT_URI, ID_PROJECTION, CLIENT_ID_SELECTION,
                mBindArgument, null);
    }

    public void deleteParser(CalendarOperations ops) throws IOException {
        while (nextTag(Tags.SYNC_DELETE) != END) {
            switch (tag) {
                case Tags.SYNC_SERVER_ID:
                    String serverId = getValue();
                    // Find the event with the given serverId
                    Cursor c = getServerIdCursor(serverId);
                    try {
                        if (c.moveToFirst()) {
                            userLog("Deleting ", serverId);
                            ops.delete(c.getLong(0), serverId);
                        }
                    } finally {
                        c.close();
                    }
                    break;
                default:
                    skipTag();
            }
        }
    }

    /**
     * A change is handled as a delete (including all exceptions) and an add
     * This isn't as efficient as attempting to traverse the original and all of its exceptions,
     * but changes happen infrequently and this code is both simpler and easier to maintain
     * @param ops the array of pending ContactProviderOperations.
     * @throws IOException
     */
    public void changeParser(CalendarOperations ops) throws IOException {
        String serverId = null;
        while (nextTag(Tags.SYNC_CHANGE) != END) {
            switch (tag) {
                case Tags.SYNC_SERVER_ID:
                    serverId = getValue();
                    break;
                case Tags.SYNC_APPLICATION_DATA:
                    userLog("Changing " + serverId);
                    addEvent(ops, serverId, true);
                    break;
                default:
                    skipTag();
            }
        }
    }

    @Override
    public void commandsParser() throws IOException {
        while (nextTag(Tags.SYNC_COMMANDS) != END) {
            if (tag == Tags.SYNC_ADD) {
                addParser(mOps);
            } else if (tag == Tags.SYNC_DELETE) {
                deleteParser(mOps);
            } else if (tag == Tags.SYNC_CHANGE) {
                changeParser(mOps);
            } else
                skipTag();
        }
    }

    @Override
    public void commit() throws IOException {
        userLog("Calendar SyncKey saved as: ", mMailbox.mSyncKey);
        // Save the syncKey here, using the Helper provider by Calendar provider
        mOps.add(new Operation(SyncStateContract.Helpers.newSetOperation(
                asSyncAdapter(SyncState.CONTENT_URI, mAccount.mEmailAddress,
                        Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE),
                mAccountManagerAccount,
                mMailbox.mSyncKey.getBytes())));

        // Execute our CPO's safely
        try {
            safeExecute(mContentResolver, CalendarContract.AUTHORITY, mOps);
        } catch (RemoteException e) {
            throw new IOException("Remote exception caught; will retry");
        }
    }

    public void addResponsesParser() throws IOException {
        String serverId = null;
        String clientId = null;
        int status = -1;
        ContentValues cv = new ContentValues();
        while (nextTag(Tags.SYNC_ADD) != END) {
            switch (tag) {
                case Tags.SYNC_SERVER_ID:
                    serverId = getValue();
                    break;
                case Tags.SYNC_CLIENT_ID:
                    clientId = getValue();
                    break;
                case Tags.SYNC_STATUS:
                    status = getValueInt();
                    if (status != 1) {
                        userLog("Attempt to add event failed with status: " + status);
                    }
                    break;
                default:
                    skipTag();
            }
        }

        if (clientId == null) return;
        if (serverId == null) {
            // TODO Reconsider how to handle this
            serverId = "FAIL:" + status;
        }

        Cursor c = getClientIdCursor(clientId);
        try {
            if (c.moveToFirst()) {
                cv.put(Events._SYNC_ID, serverId);
                cv.put(Events.SYNC_DATA2, clientId);
                long id = c.getLong(0);
                // Write the serverId into the Event
                mOps.add(new Operation(ContentProviderOperation
                        .newUpdate(ContentUris.withAppendedId(mAsSyncAdapterEvents, id))
                        .withValues(cv)));
                userLog("New event " + clientId + " was given serverId: " + serverId);
            }
        } finally {
            c.close();
        }
    }

    public void changeResponsesParser() throws IOException {
        String serverId = null;
        String status = null;
        while (nextTag(Tags.SYNC_CHANGE) != END) {
            switch (tag) {
                case Tags.SYNC_SERVER_ID:
                    serverId = getValue();
                    break;
                case Tags.SYNC_STATUS:
                    status = getValue();
                    break;
                default:
                    skipTag();
            }
        }
        if (serverId != null && status != null) {
            userLog("Changed event " + serverId + " failed with status: " + status);
        }
    }


    @Override
    public void responsesParser() throws IOException {
        // Handle server responses here (for Add and Change)
        while (nextTag(Tags.SYNC_RESPONSES) != END) {
            if (tag == Tags.SYNC_ADD) {
                addResponsesParser();
            } else if (tag == Tags.SYNC_CHANGE) {
                changeResponsesParser();
            } else
                skipTag();
        }
    }

    /**
     * We apply the batch of CPO's here.  We synchronize on the service to avoid thread-nasties,
     * and we just return quickly if the service has already been stopped.
     */
    private static ContentProviderResult[] execute(final ContentResolver contentResolver,
            final String authority, final ArrayList<ContentProviderOperation> ops)
            throws RemoteException, OperationApplicationException {
        if (!ops.isEmpty()) {
            try {
                ContentProviderResult[] result = contentResolver.applyBatch(authority, ops);
                //mService.userLog("Results: " + result.length);
                return result;
            } catch (IllegalArgumentException e) {
                // Thrown when Calendar Provider is disabled
                LogUtils.e(TAG, "Error executing operation; provider is disabled.", e);
            }
        }
        return new ContentProviderResult[0];
    }

    /**
     * Convert an Operation to a CPO; if the Operation has a back reference, apply it with the
     * passed-in offset
     */
    @VisibleForTesting
    static ContentProviderOperation operationToContentProviderOperation(Operation op, int offset) {
        if (op.mOp != null) {
            return op.mOp;
        } else if (op.mBuilder == null) {
            throw new IllegalArgumentException("Operation must have CPO.Builder");
        }
        ContentProviderOperation.Builder builder = op.mBuilder;
        if (op.mColumnName != null) {
            builder.withValueBackReference(op.mColumnName, op.mOffset - offset);
        }
        return builder.build();
    }

    /**
     * Create a list of CPOs from a list of Operations, and then apply them in a batch
     */
    private static ContentProviderResult[] applyBatch(final ContentResolver contentResolver,
            final String authority, final ArrayList<Operation> ops, final int offset)
            throws RemoteException, OperationApplicationException {
        // Handle the empty case
        if (ops.isEmpty()) {
            return new ContentProviderResult[0];
        }
        ArrayList<ContentProviderOperation> cpos = new ArrayList<ContentProviderOperation>();
        for (Operation op: ops) {
            cpos.add(operationToContentProviderOperation(op, offset));
        }
        return execute(contentResolver, authority, cpos);
    }

    /**
     * Apply the list of CPO's in the provider and copy the "mini" result into our full result array
     */
    private static void applyAndCopyResults(final ContentResolver contentResolver,
            final String authority, final ArrayList<Operation> mini,
            final ContentProviderResult[] result, final int offset) throws RemoteException {
        // Empty lists are ok; we just ignore them
        if (mini.isEmpty()) return;
        try {
            ContentProviderResult[] miniResult = applyBatch(contentResolver, authority, mini,
                    offset);
            // Copy the results from this mini-batch into our results array
            System.arraycopy(miniResult, 0, result, offset, miniResult.length);
        } catch (OperationApplicationException e) {
            // Not possible since we're building the ops ourselves
        }
    }

    /**
     * Called by a sync adapter to execute a list of Operations in the ContentProvider handling
     * the passed-in authority.  If the attempt to apply the batch fails due to a too-large
     * binder transaction, we split the Operations as directed by separators.  If any of the
     * "mini" batches fails due to a too-large transaction, we're screwed, but this would be
     * vanishingly rare.  Other, possibly transient, errors are handled by throwing a
     * RemoteException, which the caller will likely re-throw as an IOException so that the sync
     * can be attempted again.
     *
     * Callers MAY leave a dangling separator at the end of the list; note that the separators
     * themselves are only markers and are not sent to the provider.
     */
    protected static ContentProviderResult[] safeExecute(final ContentResolver contentResolver,
            final String authority, final ArrayList<Operation> ops) throws RemoteException {
        //mService.userLog("Try to execute ", ops.size(), " CPO's for " + authority);
        ContentProviderResult[] result = null;
        try {
            // Try to execute the whole thing
            return applyBatch(contentResolver, authority, ops, 0);
        } catch (TransactionTooLargeException e) {
            // Nope; split into smaller chunks, demarcated by the separator operation
            //mService.userLog("Transaction too large; spliting!");
            ArrayList<Operation> mini = new ArrayList<Operation>();
            // Build a result array with the total size we're sending
            result = new ContentProviderResult[ops.size()];
            int count = 0;
            int offset = 0;
            for (Operation op: ops) {
                if (op.mSeparator) {
                    //mService.userLog("Try mini-batch of ", mini.size(), " CPO's");
                    applyAndCopyResults(contentResolver, authority, mini, result, offset);
                    mini.clear();
                    // Save away the offset here; this will need to be subtracted out of the
                    // value originally set by the adapter
                    offset = count + 1; // Remember to add 1 for the separator!
                } else {
                    mini.add(op);
                }
                count++;
            }
            // Check out what's left; if it's more than just a separator, apply the batch
            int miniSize = mini.size();
            if ((miniSize > 0) && !(miniSize == 1 && mini.get(0).mSeparator)) {
                applyAndCopyResults(contentResolver, authority, mini, result, offset);
            }
        } catch (RemoteException e) {
            throw e;
        } catch (OperationApplicationException e) {
            // Not possible since we're building the ops ourselves
        }
        return result;
    }

    /**
     * Called by a sync adapter to indicate a relatively safe place to split a batch of CPO's
     */
    protected static void addSeparatorOperation(ArrayList<Operation> ops, Uri uri) {
        Operation op = new Operation(
                ContentProviderOperation.newDelete(ContentUris.withAppendedId(uri, SEPARATOR_ID)));
        op.mSeparator = true;
        ops.add(op);
    }

    @Override
    protected void wipe() {
        LogUtils.w(TAG, "Wiping calendar for account %d", mAccount.mId);
        EasSyncCalendar.wipeAccountFromContentProvider(mContext,
                mAccount.mEmailAddress);
    }
}
