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

/* import-globals-from ../calendar-management.js */
/* import-globals-from ../calendar-views-utils.js */

/* globals goUpdateCommand */

var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
var { CalTransactionManager } = ChromeUtils.import("resource:///modules/CalTransactionManager.jsm");

XPCOMUtils.defineLazyModuleGetters(this, {
  CalAddTransaction: "resource:///modules/CalTransactionManager.jsm",
  CalDeleteTransaction: "resource:///modules/CalTransactionManager.jsm",
  CalEvent: "resource:///modules/CalEvent.jsm",
  CalModifyTransaction: "resource:///modules/CalTransactionManager.jsm",
  CalTodo: "resource:///modules/CalTodo.jsm",
});

/* exported modifyEventWithDialog, undo, redo, setContextPartstat */

/**
 * The global calendar transaction manager.
 *
 * @type {CalTransactionManager}
 */
var gCalTransactionMgr = CalTransactionManager.getInstance();

/**
 * If a batch transaction is active, it is stored here.
 *
 * @type {CalBatchTransaction?}
 */
var gCalBatchTransaction = null;

/**
 * Sets the default values for new items, taking values from either the passed
 * parameters or the preferences.
 *
 * @param {calIItemBase} aItem - The item to set up.
 * @param {?calICalendar} aCalendar - The calendar to apply.
 * @param {?calIDateTime} aStartDate - The start date to set.
 * @param {?calIDateTime} aEndDate - The end date/due date to set.
 * @param {?calIDateTime} aInitialDate - The reference date for the date pickers.
 * @param {boolean} [aForceAllday=false] - Force the event/task to be an all-day item.
 * @param {calIAttendee[]} aAttendees - Attendees to add, if `aItem` is an event.
 */
function setDefaultItemValues(
  aItem,
  aCalendar = null,
  aStartDate = null,
  aEndDate = null,
  aInitialDate = null,
  aForceAllday = false,
  aAttendees = []
) {
  function endOfDay(aDate) {
    let eod = aDate ? aDate.clone() : cal.dtz.now();
    eod.hour = Services.prefs.getIntPref("calendar.view.dayendhour", 19);
    eod.minute = 0;
    eod.second = 0;
    return eod;
  }
  function startOfDay(aDate) {
    let sod = aDate ? aDate.clone() : cal.dtz.now();
    sod.hour = Services.prefs.getIntPref("calendar.view.daystarthour", 8);
    sod.minute = 0;
    sod.second = 0;
    return sod;
  }

  let initialDate = aInitialDate ? aInitialDate.clone() : cal.dtz.now();
  initialDate.isDate = true;

  if (aItem.isEvent()) {
    if (aStartDate) {
      aItem.startDate = aStartDate.clone();
      if (aStartDate.isDate && !aForceAllday) {
        // This is a special case where the date is specified, but the
        // time is not. To take care, we setup up the time to our
        // default event start time.
        aItem.startDate = cal.dtz.getDefaultStartDate(aItem.startDate);
      } else if (aForceAllday) {
        // If the event should be forced to be allday, then don't set up
        // any default hours and directly make it allday.
        aItem.startDate.isDate = true;
        aItem.startDate.timezone = cal.dtz.floating;
      }
    } else {
      // If no start date was passed, then default to the next full hour
      // of today, but with the date of the selected day
      aItem.startDate = cal.dtz.getDefaultStartDate(initialDate);
    }

    if (aEndDate) {
      aItem.endDate = aEndDate.clone();
      if (aForceAllday) {
        // XXX it is currently not specified, how callers that force all
        // day should pass the end date. Right now, they should make
        // sure that the end date is 00:00:00 of the day after.
        aItem.endDate.isDate = true;
        aItem.endDate.timezone = cal.dtz.floating;
      }
    } else {
      aItem.endDate = aItem.startDate.clone();
      if (aForceAllday) {
        // All day events need to go to the beginning of the next day.
        aItem.endDate.day++;
      } else {
        // If the event is not all day, then add the default event
        // length.
        aItem.endDate.minute += Services.prefs.getIntPref("calendar.event.defaultlength", 60);
      }
    }

    // Free/busy status is only valid for events, must not be set for tasks.
    aItem.setProperty("TRANSP", cal.item.getEventDefaultTransparency(aForceAllday));

    for (let attendee of aAttendees) {
      aItem.addAttendee(attendee);
    }
  } else if (aItem.isTodo()) {
    let now = cal.dtz.now();
    let initDate = initialDate ? initialDate.clone() : now;
    initDate.isDate = false;
    initDate.hour = now.hour;
    initDate.minute = now.minute;
    initDate.second = now.second;

    if (aStartDate) {
      aItem.entryDate = aStartDate.clone();
    } else {
      let defaultStart = Services.prefs.getStringPref("calendar.task.defaultstart", "none");
      if (
        Services.prefs.getIntPref("calendar.alarms.onfortodos", 0) == 1 &&
        defaultStart == "none"
      ) {
        // start date is required if we want to set an alarm
        defaultStart = "offsetcurrent";
      }

      let units = Services.prefs.getStringPref("calendar.task.defaultstartoffsetunits", "minutes");
      if (!["days", "hours", "minutes"].includes(units)) {
        units = "minutes";
      }
      let startOffset = cal.createDuration();
      startOffset[units] = Services.prefs.getIntPref("calendar.task.defaultstartoffset", 0);
      let start;

      switch (defaultStart) {
        case "none":
          break;
        case "startofday":
          start = startOfDay(initDate);
          break;
        case "tomorrow":
          start = startOfDay(initDate);
          start.day++;
          break;
        case "nextweek":
          start = startOfDay(initDate);
          start.day += 7;
          break;
        case "offsetcurrent":
          start = initDate.clone();
          start.addDuration(startOffset);
          break;
        case "offsetnexthour":
          start = initDate.clone();
          start.second = 0;
          start.minute = 0;
          start.hour++;
          start.addDuration(startOffset);
          break;
      }

      if (start) {
        aItem.entryDate = start;
      }
    }

    if (aEndDate) {
      aItem.dueDate = aEndDate.clone();
    } else {
      let defaultDue = Services.prefs.getStringPref("calendar.task.defaultdue", "none");

      let units = Services.prefs.getStringPref("calendar.task.defaultdueoffsetunits", "minutes");
      if (!["days", "hours", "minutes"].includes(units)) {
        units = "minutes";
      }
      let dueOffset = cal.createDuration();
      dueOffset[units] = Services.prefs.getIntPref("calendar.task.defaultdueoffset", 0);

      let start = aItem.entryDate ? aItem.entryDate.clone() : initDate.clone();
      let due;

      switch (defaultDue) {
        case "none":
          break;
        case "endofday":
          due = endOfDay(start);
          // go to tomorrow if we're past the end of today
          if (start.compare(due) > 0) {
            due.day++;
          }
          break;
        case "tomorrow":
          due = endOfDay(start);
          due.day++;
          break;
        case "nextweek":
          due = endOfDay(start);
          due.day += 7;
          break;
        case "offsetcurrent":
          due = start.clone();
          due.addDuration(dueOffset);
          break;
        case "offsetnexthour":
          due = start.clone();
          due.second = 0;
          due.minute = 0;
          due.hour++;
          due.addDuration(dueOffset);
          break;
      }

      if (aItem.entryDate && due && aItem.entryDate.compare(due) > 0) {
        // due can't be earlier than start date.
        due = aItem.entryDate;
      }

      if (due) {
        aItem.dueDate = due;
      }
    }
  }

  // Calendar
  aItem.calendar = aCalendar || getSelectedCalendar();

  // Alarms
  cal.alarms.setDefaultValues(aItem);
}

/**
 * Creates an event with the calendar event dialog.
 *
 * @param {?calICalendar} calendar - The calendar to create the event in
 * @param {?calIDateTime} startDate - The event's start date.
 * @param {?calIDateTime} endDate - The event's end date.
 * @param {?string} summary - The event's title.
 * @param {?calIEvent} event - A template event to show in the dialog
 * @param {?boolean} forceAllDay - Make sure the event shown in the dialog is an all-day event.
 * @param {?calIAttendee} attendees - Attendees to add to the event.
 */
function createEventWithDialog(
  calendar,
  startDate,
  endDate,
  summary,
  event,
  forceAllDay,
  attendees
) {
  let onNewEvent = function (item, opcalendar, originalItem, listener, extresponse = null) {
    if (item.id) {
      // If the item already has an id, then this is the result of
      // saving the item without closing, and then saving again.
      doTransaction("modify", item, opcalendar, originalItem, listener, extresponse);
    } else {
      // Otherwise, this is an addition
      doTransaction("add", item, opcalendar, null, listener, extresponse);
    }
  };

  if (event) {
    if (!event.isMutable) {
      event = event.clone();
    }
    // If the event should be created from a template, then make sure to
    // remove the id so that the item obtains a new id when doing the
    // transaction
    event.id = null;

    if (forceAllDay) {
      event.startDate.isDate = true;
      event.endDate.isDate = true;
      if (event.startDate.compare(event.endDate) == 0) {
        // For a one day all day event, the end date must be 00:00:00 of
        // the next day.
        event.endDate.day++;
      }
    }

    if (!event.calendar) {
      event.calendar = calendar || getSelectedCalendar();
    }
  } else {
    event = new CalEvent();

    let refDate = currentView().selectedDay?.clone();
    setDefaultItemValues(event, calendar, startDate, endDate, refDate, forceAllDay, attendees);
    if (summary) {
      event.title = summary;
    }
  }
  openEventDialog(event, event.calendar, "new", onNewEvent);
}

/**
 * Creates a task with the calendar event dialog.
 *
 * @param calendar      (optional) The calendar to create the task in
 * @param dueDate       (optional) The task's due date.
 * @param summary       (optional) The task's title.
 * @param todo          (optional) A template task to show in the dialog.
 * @param initialDate   (optional) The initial date for new task datepickers
 */
function createTodoWithDialog(calendar, dueDate, summary, todo, initialDate) {
  let onNewItem = function (item, opcalendar, originalItem, listener, extresponse = null) {
    if (item.id) {
      // If the item already has an id, then this is the result of
      // saving the item without closing, and then saving again.
      doTransaction("modify", item, opcalendar, originalItem, listener, extresponse);
    } else {
      // Otherwise, this is an addition
      doTransaction("add", item, opcalendar, null, listener, extresponse);
    }
  };

  if (todo) {
    // If the todo should be created from a template, then make sure to
    // remove the id so that the item obtains a new id when doing the
    // transaction
    if (todo.id) {
      todo = todo.clone();
      todo.id = null;
    }

    if (!todo.calendar) {
      todo.calendar = calendar || getSelectedCalendar();
    }
  } else {
    todo = new CalTodo();
    setDefaultItemValues(todo, calendar, null, dueDate, initialDate);

    if (summary) {
      todo.title = summary;
    }
  }

  openEventDialog(todo, calendar, "new", onNewItem, initialDate);
}

/**
 * Opens the passed event item for viewing. This enables the modify callback in
 * openEventDialog so invitation responses can be edited.
 *
 * @param {calIItemBase} item - The calendar item to view.
 */
function openEventDialogForViewing(item) {
  function onDialogComplete(newItem, calendar, originalItem, listener, extresponse) {
    doTransaction("modify", newItem, calendar, originalItem, listener, extresponse);
  }
  openEventDialog(item, item.calendar, "view", onDialogComplete);
}

/**
 * Modifies the passed event in the event dialog.
 *
 * @param aItem                 The item to modify.
 * @param aPromptOccurrence     If the user should be prompted to select if the
 *                                parent item or occurrence should be modified.
 * @param initialDate           (optional) The initial date for new task datepickers
 * @param aCounterProposal      (optional) An object representing the counterproposal
 *        {
 *            {JsObject} result: {
 *                type: {String} "OK"|"OUTDATED"|"NOTLATESTUPDATE"|"ERROR"|"NODIFF"
 *                descr: {String} a technical description of the problem if type is ERROR or NODIFF,
 *                                otherwise an empty string
 *            },
 *            (empty if result.type = "ERROR"|"NODIFF"){Array} differences: [{
 *                property: {String} a property that is subject to the proposal
 *                proposed: {String} the proposed value
 *                original: {String} the original value
 *            }]
 *        }
 */
function modifyEventWithDialog(aItem, aPromptOccurrence, initialDate = null, aCounterProposal) {
  let dlg = cal.item.findWindow(aItem);
  if (dlg) {
    dlg.focus();
    return;
  }

  let onModifyItem = function (item, calendar, originalItem, listener, extresponse = null) {
    doTransaction("modify", item, calendar, originalItem, listener, extresponse);
  };

  let item = aItem;
  let response;
  if (aPromptOccurrence !== false) {
    [item, , response] = promptOccurrenceModification(aItem, true, "edit");
  }

  if (item && (response || response === undefined)) {
    openEventDialog(item, item.calendar, "modify", onModifyItem, initialDate, aCounterProposal);
  }
}

/**
 * @callback onDialogComplete
 *
 * @param {calIItemBase} newItem
 * @param {calICalendar} calendar
 * @param {calIItemBase} originalItem
 * @param {?calIOperationListener} listener
 * @param {?object} extresponse
 */

/**
 * Opens the event dialog with the given item (task OR event).
 *
 * @param {calIItemBase} calendarItem - The item to open the dialog with.
 * @param {calICalendar} calendar - The calendar to open the dialog with.
 * @param {string} mode - The operation the dialog should do
 *                                       ("new", "view", "modify").
 * @param {onDialogComplete} callback - The callback to call when the dialog
 *                                       has completed.
 * @param {?calIDateTime} initialDate - The initial date for new task
 *                                       datepickers.
 * @param {?object} counterProposal - An object representing the
 *                                       counterproposal - see description
 *                                       for modifyEventWithDialog().
 */
function openEventDialog(
  calendarItem,
  calendar,
  mode,
  callback,
  initialDate = null,
  counterProposal
) {
  let dlg = cal.item.findWindow(calendarItem);
  if (dlg) {
    dlg.focus();
    return;
  }

  // Set up some defaults
  mode = mode || "new";
  calendar = calendar || getSelectedCalendar();
  let calendars = cal.manager.getCalendars();
  calendars = calendars.filter(cal.acl.isCalendarWritable);

  let isItemSupported;
  if (calendarItem.isTodo()) {
    isItemSupported = function (aCalendar) {
      return aCalendar.getProperty("capabilities.tasks.supported") !== false;
    };
  } else if (calendarItem.isEvent()) {
    isItemSupported = function (aCalendar) {
      return aCalendar.getProperty("capabilities.events.supported") !== false;
    };
  }

  // Filter out calendars that don't support the given calendar item
  calendars = calendars.filter(isItemSupported);

  // Filter out calendar/items that we cannot write to/modify
  if (mode == "new") {
    calendars = calendars.filter(cal.acl.userCanAddItemsToCalendar);
  } else if (mode == "modify") {
    calendars = calendars.filter(aCalendar => {
      /* If the calendar is the item calendar, we check that the item
       * can be modified. If the calendar is NOT the item calendar, we
       * check that the user can remove items from that calendar and
       * add items to the current one.
       */
      let isSameCalendar = calendarItem.calendar == aCalendar;
      let canModify = cal.acl.userCanModifyItem(calendarItem);
      let canMoveItems =
        cal.acl.userCanDeleteItemsFromCalendar(calendarItem.calendar) &&
        cal.acl.userCanAddItemsToCalendar(aCalendar);

      return isSameCalendar ? canModify : canMoveItems;
    });
  }

  if (
    mode == "new" &&
    (!cal.acl.isCalendarWritable(calendar) ||
      !cal.acl.userCanAddItemsToCalendar(calendar) ||
      !isItemSupported(calendar))
  ) {
    if (calendars.length < 1) {
      // There are no writable calendars or no calendar supports the given
      // item. Don't show the dialog.
      return;
    }
    // Pick the first calendar that supports the item and is writable
    calendar = calendars[0];
    if (calendarItem) {
      // XXX The dialog currently uses the items calendar as a first
      // choice. Since we are shortly before a release to keep
      // regression risk low, explicitly set the item's calendar here.
      calendarItem.calendar = calendars[0];
    }
  }

  // Setup the window arguments
  let args = {};
  args.calendarEvent = calendarItem;
  args.calendar = calendar;
  args.mode = mode;
  args.onOk = callback;
  args.initialStartDateValue = initialDate || cal.dtz.getDefaultStartDate();
  args.counterProposal = counterProposal;
  args.inTab = Services.prefs.getBoolPref("calendar.item.editInTab", false);
  // this will be called if file->new has been selected from within the dialog
  args.onNewEvent = function (opcalendar) {
    createEventWithDialog(opcalendar, null, null);
  };
  args.onNewTodo = function (opcalendar) {
    createTodoWithDialog(opcalendar);
  };

  // the dialog will reset this to auto when it is done loading.
  window.setCursor("wait");

  // Ask the provider if this item is an invitation. If this is the case,
  // we'll open the summary dialog since the user is not allowed to change
  // the details of the item.
  let isInvitation =
    calendar.supportsScheduling && calendar.getSchedulingSupport().isInvitation(calendarItem);

  // open the dialog modeless
  let url;
  let isEditable = mode == "modify" && !isInvitation && cal.acl.userCanModifyItem(calendarItem);

  if (cal.acl.isCalendarWritable(calendar) && (mode == "new" || isEditable)) {
    // Currently the read-only summary dialog is never opened in a tab.
    if (args.inTab) {
      url = "chrome://calendar/content/calendar-item-iframe.xhtml";
    } else {
      url = "chrome://calendar/content/calendar-event-dialog.xhtml";
    }
  } else {
    url = "chrome://calendar/content/calendar-summary-dialog.xhtml";
    args.inTab = false;
    args.isInvitation = isInvitation;
  }

  if (args.inTab) {
    args.url = url;
    let tabmail = document.getElementById("tabmail");
    let tabtype = args.calendarEvent.isEvent() ? "calendarEvent" : "calendarTask";
    tabmail.openTab(tabtype, args);
  } else {
    // open in a window
    openDialog(url, "_blank", "chrome,titlebar,toolbar,resizable", args);
  }
}

/**
 * Prompts the user how the passed item should be modified. If the item is an
 * exception or already a parent item, the item is returned without prompting.
 * If "all occurrences" is specified, the parent item is returned. If "this
 * occurrence only" is specified, then aItem is returned. If "this and following
 * occurrences" is selected, aItem's parentItem is modified so that the
 * recurrence rules end (UNTIL) just before the given occurrence. If
 * aNeedsFuture is specified, a new item is made from the part that was stripped
 * off the passed item.
 *
 * EXDATEs and RDATEs that do not fit into the items recurrence are removed. If
 * the modified item or the future item only consist of a single occurrence,
 * they are changed to be single items.
 *
 * @param aItem                         The item or array of items to check.
 * @param aNeedsFuture                  If true, the future item is parsed.
 *                                        This parameter can for example be
 *                                        false if a deletion is being made.
 * @param aAction                       Either "edit" or "delete". Sets up
 *                                          the labels in the occurrence prompt
 * @returns [modifiedItem, futureItem, promptResponse]
 *                                      modifiedItem is a single item or array
 *                                        of items depending on the past aItem
 *
 *                                        If "this and all following" was chosen,
 *                                        an array containing the item *until*
 *                                        the given occurrence (modifiedItem),
 *                                        and the item *after* the given
 *                                        occurrence (futureItem).
 *
 *                                        If any other option was chosen,
 *                                        futureItem is null  and the
 *                                        modifiedItem is either the parent item
 *                                        or the passed occurrence, or null if
 *                                        the dialog was canceled.
 *
 *                                        The promptResponse parameter gives the
 *                                        response of the dialog as a constant.
 */
function promptOccurrenceModification(aItem, aNeedsFuture, aAction) {
  const CANCEL = 0;
  const MODIFY_OCCURRENCE = 1;
  const MODIFY_FOLLOWING = 2;
  const MODIFY_PARENT = 3;

  let futureItems = false;
  let pastItems = [];
  let returnItem = null;
  let type = CANCEL;
  let items = Array.isArray(aItem) ? aItem : [aItem];

  // Check if this actually is an instance of a recurring event
  if (items.every(item => item == item.parentItem)) {
    type = MODIFY_PARENT;
  } else if (aItem && items.length) {
    // Prompt the user. Setting modal blocks the dialog until it is closed. We
    // use rv to pass our return value.
    let rv = { value: CANCEL, items, action: aAction };
    window.openDialog(
      "chrome://calendar/content/calendar-occurrence-prompt.xhtml",
      "PromptOccurrenceModification",
      "centerscreen,chrome,modal,titlebar",
      rv
    );
    type = rv.value;
  }

  switch (type) {
    case MODIFY_PARENT:
      pastItems = items.map(item => item.parentItem);
      break;
    case MODIFY_FOLLOWING:
      // TODO tbd in a different bug
      throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
    case MODIFY_OCCURRENCE:
      pastItems = items;
      break;
    case CANCEL:
      // Since we have not set past or futureItem, the return below will
      // take care.
      break;
  }
  if (aItem) {
    returnItem = Array.isArray(aItem) ? pastItems : pastItems[0];
  }
  return [returnItem, futureItems, type];
}

// Undo/Redo code

/**
 * Create and commit a transaction with the given arguments to the transaction
 * manager. Also updates the undo/redo menu.
 *
 * @param action       The action to do.
 * @param item         The new item to add/modify/delete
 * @param calendar     The calendar to do the transaction on
 * @param oldItem      (optional) some actions require an old item
 * @param observer     (optional) the observer to call when complete.
 * @param extResponse  (optional) JS object with additional parameters for sending itip messages
 *                                (see also description of checkAndSend in calItipUtils.jsm)
 */
async function doTransaction(action, item, calendar, oldItem, observer, extResponse = null) {
  // This is usually a user-initiated transaction, so make sure the calendar
  // this transaction is happening on is visible.
  top.ensureCalendarVisible(calendar);

  let manager = gCalBatchTransaction || gCalTransactionMgr;
  let trn;
  switch (action) {
    case "add":
      trn = new CalAddTransaction(item, calendar, oldItem, extResponse);
      break;
    case "modify":
      trn = new CalModifyTransaction(item, calendar, oldItem, extResponse);
      break;
    case "delete":
      trn = new CalDeleteTransaction(item, calendar, oldItem, extResponse);
      break;
    default:
      throw new Components.Exception(
        `Invalid action specified "${action}"`,
        Cr.NS_ERROR_ILLEGAL_VALUE
      );
  }

  await manager.commit(trn);

  // If a batch transaction is active, do not update the menu as
  // endBatchTransaction() will take care of that.
  if (gCalBatchTransaction) {
    return;
  }

  observer?.onTransactionComplete(trn.item, trn.oldItem);
  updateUndoRedoMenu();
}

/**
 * Undo the last operation done through the transaction manager.
 */
function undo() {
  if (canUndo()) {
    gCalTransactionMgr.undo();
    updateUndoRedoMenu();
  }
}

/**
 * Redo the last undone operation in the transaction manager.
 */
function redo() {
  if (canRedo()) {
    gCalTransactionMgr.redo();
    updateUndoRedoMenu();
  }
}

/**
 * Start a batch transaction on the transaction manager.
 */
function startBatchTransaction() {
  gCalBatchTransaction = gCalTransactionMgr.beginBatch();
}

/**
 * End a previously started batch transaction. NOTE: be sure to call this in a
 * try-catch-finally-block in case you have code that could fail between
 * startBatchTransaction and this call.
 */
function endBatchTransaction() {
  gCalBatchTransaction = null;
  updateUndoRedoMenu();
}

/**
 * Checks if the last operation can be undone (or if there is a last operation
 * at all).
 */
function canUndo() {
  return gCalTransactionMgr.canUndo();
}

/**
 * Checks if the last undone operation can be redone.
 */
function canRedo() {
  return gCalTransactionMgr.canRedo();
}

/**
 * Update the undo and redo commands.
 */
function updateUndoRedoMenu() {
  goUpdateCommand("cmd_undo");
  goUpdateCommand("cmd_redo");
}

/**
 * Updates the partstat of the calendar owner for specified items triggered by a
 * context menu operation
 *
 * For a documentation of the expected bahaviours for  different use cases of
 * dealing with context menu partstat actions, see also setupAttendanceMenu(...)
 * in calendar-ui-utils.js
 *
 * @param {EventTarget}  aTarget   the target of the triggering event
 * @param {Array}        aItems    an array of calEvent or calIToDo items
 */
function setContextPartstat(aTarget, aItems) {
  /**
   * Provides the participation representing the user for a provided item
   *
   * @param   {calEvent|calTodo}  aItem  The calendar item to inspect
   * @returns {?calIAttendee} An calIAttendee object or null if no
   *                                       participant was detected
   */
  function getParticipant(aItem) {
    let party = null;
    if (cal.itip.isInvitation(aItem)) {
      party = cal.itip.getInvitedAttendee(aItem);
    } else if (aItem.organizer && aItem.getAttendees().length) {
      let calOrgId = aItem.calendar.getProperty("organizerId");
      if (calOrgId.toLowerCase() == aItem.organizer.id.toLowerCase()) {
        party = aItem.organizer;
      }
    }
    return party;
  }

  startBatchTransaction();
  try {
    // TODO: make sure we overwrite the partstat of all occurrences in
    // the selection, if the partstat of the respective master item is
    // changed - see matrix in the doc block of setupAttendanceMenu(...)
    // in calendar-ui-utils.js

    for (let oldItem of aItems) {
      // Skip this item if its calendar is read only.
      if (oldItem.calendar.readOnly) {
        continue;
      }
      if (aTarget.getAttribute("scope") == "all-occurrences") {
        oldItem = oldItem.parentItem;
      }
      let attendee = getParticipant(oldItem);
      if (attendee) {
        // skip this item if the partstat for the participant hasn't
        // changed. otherwise we would always perform update operations
        // for recurring events on both, the master and the occurrence
        // item
        let partStat = aTarget.getAttribute("respvalue");
        if (attendee.participationStatus == partStat) {
          continue;
        }

        let newItem = oldItem.clone();
        let newAttendee = attendee.clone();
        newAttendee.participationStatus = partStat;
        if (newAttendee.isOrganizer) {
          newItem.organizer = newAttendee;
        } else {
          newItem.removeAttendee(attendee);
          newItem.addAttendee(newAttendee);
        }

        let extResponse = null;
        if (aTarget.hasAttribute("respmode")) {
          let mode = aTarget.getAttribute("respmode");
          let itipMode = Ci.calIItipItem[mode];
          extResponse = { responseMode: itipMode };
        }

        doTransaction("modify", newItem, newItem.calendar, oldItem, null, extResponse);
      }
    }
  } catch (e) {
    cal.ERROR("Error setting partstat: " + e + "\r\n");
  } finally {
    endBatchTransaction();
  }
}
