/*******************************************************************************
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 *******************************************************************************/

package org.apache.ofbiz.workeffort.workeffort;

import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.net.URISyntaxException;
import java.sql.Timestamp;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

import org.apache.ofbiz.base.util.DateRange;
import org.apache.ofbiz.base.util.Debug;
import org.apache.ofbiz.base.util.GeneralException;
import org.apache.ofbiz.base.util.ObjectType;
import org.apache.ofbiz.base.util.TimeDuration;
import org.apache.ofbiz.base.util.UtilGenerics;
import org.apache.ofbiz.base.util.UtilMisc;
import org.apache.ofbiz.base.util.UtilProperties;
import org.apache.ofbiz.base.util.UtilValidate;
import org.apache.ofbiz.entity.Delegator;
import org.apache.ofbiz.entity.GenericEntityException;
import org.apache.ofbiz.entity.GenericValue;
import org.apache.ofbiz.entity.util.EntityQuery;
import org.apache.ofbiz.service.GenericServiceException;
import org.apache.ofbiz.service.LocalDispatcher;
import org.apache.ofbiz.service.ModelParam;
import org.apache.ofbiz.service.ModelService;
import org.apache.ofbiz.service.ServiceUtil;
import org.apache.ofbiz.service.calendar.TemporalExpression;
import org.apache.ofbiz.service.calendar.TemporalExpressionWorker;
import org.apache.ofbiz.workeffort.workeffort.ICalWorker.ResponseProperties;

import net.fortuna.ical4j.data.CalendarBuilder;
import net.fortuna.ical4j.data.ParserException;
import net.fortuna.ical4j.model.Calendar;
import net.fortuna.ical4j.model.Component;
import net.fortuna.ical4j.model.ComponentList;
import net.fortuna.ical4j.model.DateTime;
import net.fortuna.ical4j.model.Dur;
import net.fortuna.ical4j.model.Parameter;
import net.fortuna.ical4j.model.ParameterList;
import net.fortuna.ical4j.model.Property;
import net.fortuna.ical4j.model.PropertyList;
import net.fortuna.ical4j.model.TimeZoneRegistry;
import net.fortuna.ical4j.model.TimeZoneRegistryFactory;
import net.fortuna.ical4j.model.ValidationException;
import net.fortuna.ical4j.model.component.VAlarm;
import net.fortuna.ical4j.model.component.VEvent;
import net.fortuna.ical4j.model.component.VToDo;
import net.fortuna.ical4j.model.parameter.Cn;
import net.fortuna.ical4j.model.parameter.PartStat;
import net.fortuna.ical4j.model.parameter.XParameter;
import net.fortuna.ical4j.model.property.Action;
import net.fortuna.ical4j.model.property.Attendee;
import net.fortuna.ical4j.model.property.CalScale;
import net.fortuna.ical4j.model.property.Clazz;
import net.fortuna.ical4j.model.property.Completed;
import net.fortuna.ical4j.model.property.Created;
import net.fortuna.ical4j.model.property.Description;
import net.fortuna.ical4j.model.property.DtEnd;
import net.fortuna.ical4j.model.property.DtStamp;
import net.fortuna.ical4j.model.property.DtStart;
import net.fortuna.ical4j.model.property.Duration;
import net.fortuna.ical4j.model.property.LastModified;
import net.fortuna.ical4j.model.property.Location;
import net.fortuna.ical4j.model.property.Organizer;
import net.fortuna.ical4j.model.property.PercentComplete;
import net.fortuna.ical4j.model.property.Priority;
import net.fortuna.ical4j.model.property.ProdId;
import net.fortuna.ical4j.model.property.Status;
import net.fortuna.ical4j.model.property.Summary;
import net.fortuna.ical4j.model.property.Uid;
import net.fortuna.ical4j.model.property.Version;
import net.fortuna.ical4j.model.property.XProperty;

/** iCalendar converter class. This class uses the <a href="http://ical4j.sourceforge.net/index.html">
 * iCal4J</a> library.
 */
public class ICalConverter {

    protected static final String MODULE = ICalConverter.class.getName();
    protected static final String PARTY_ID_X_PARAM_NAME = "X-ORG-APACHE-OFBIZ-PARTY-ID";
    protected static final ProdId PROD_ID = new ProdId("-//Apache OFBiz//Work Effort Calendar//EN");
    protected static final String UID_PREFIX = "ORG-APACHE-OFBIZ-WE-";
    protected static final String WORKEFFORT_ID_X_PROP_NAME = "X-ORG-APACHE-OFBIZ-WORKEFFORT-ID";
    protected static final String REMINDER_X_PROP_NAME = "X-ORG-APACHE-OFBIZ-REMINDER-ID";
    protected static final Map<String, String> FROM_STATUS_MAP = UtilMisc.toMap("TENTATIVE", "CAL_TENTATIVE",
            "CONFIRMED", "CAL_CONFIRMED", "CANCELLED", "CAL_CANCELLED", "NEEDS-ACTION", "CAL_NEEDS_ACTION",
            "COMPLETED", "CAL_COMPLETED", "IN-PROCESS", "CAL_ACCEPTED");
    protected static final Map<String, Status> TO_STATUS_MAP = UtilMisc.toMap("CAL_TENTATIVE", Status.VEVENT_TENTATIVE,
            "CAL_CONFIRMED", Status.VEVENT_CONFIRMED, "CAL_CANCELLED", Status.VEVENT_CANCELLED,
            "CAL_NEEDS_ACTION", Status.VTODO_NEEDS_ACTION, "CAL_COMPLETED", Status.VTODO_COMPLETED,
            "CAL_ACCEPTED", Status.VTODO_IN_PROCESS);
    protected static final Map<String, PartStat> TO_PARTY_STATUS_MAP = UtilMisc.toMap(
            "PRTYASGN_OFFERED", PartStat.TENTATIVE, "PRTYASGN_ASSIGNED", PartStat.ACCEPTED);
    protected static final Map<String, String> FROM_PART_STATUS_MAP = UtilMisc.toMap(
            "TENTATIVE", "PRTYASGN_OFFERED", "ACCEPTED", "PRTYASGN_ASSIGNED");
    protected static final Map<String, String> FROM_ROLE_MAP = UtilMisc.toMap("ATTENDEE", "CAL_ATTENDEE",
            "CONTACT", "CONTACT", "ORGANIZER", "CAL_ORGANIZER");

    protected static VAlarm createAlarm(GenericValue workEffortEventReminder) {
        VAlarm alarm = null;
        Timestamp reminderStamp = workEffortEventReminder.getTimestamp("reminderDateTime");
        if (reminderStamp != null) {
            alarm = new VAlarm(new DateTime(reminderStamp));
        } else {
            TimeDuration duration = TimeDuration.fromNumber(workEffortEventReminder.getLong("reminderOffset"));
            alarm = new VAlarm(new Dur(duration.days(), duration.hours(), duration.minutes(), duration.seconds()));
        }
        return alarm;
    }

    protected static Attendee createAttendee(GenericValue partyValue, Map<String, Object> context) {
        Attendee attendee = new Attendee();
        loadPartyAssignment(attendee, partyValue, context);
        return attendee;
    }

    protected static Organizer createOrganizer(GenericValue partyValue, Map<String, Object> context) {
        Organizer organizer = new Organizer();
        loadPartyAssignment(organizer, partyValue, context);
        return organizer;
    }

    protected static ResponseProperties createWorkEffort(Component component, Map<String, Object> context) {
        Map<String, Object> serviceMap = new HashMap<>();
        setWorkEffortServiceMap(component, serviceMap);
        serviceMap.put("workEffortTypeId", "VTODO".equals(component.getName()) ? "TASK" : "EVENT");
        serviceMap.put("currentStatusId", "VTODO".equals(component.getName()) ? "CAL_NEEDS_ACTION" : "CAL_TENTATIVE");
        serviceMap.put("partyId", ((GenericValue) context.get("userLogin")).get("partyId"));
        serviceMap.put("roleTypeId", "CAL_OWNER");
        serviceMap.put("statusId", "PRTYASGN_ASSIGNED");
        Map<String, Object> serviceResult = invokeService("createWorkEffortAndPartyAssign", serviceMap, context);
        if (ServiceUtil.isError(serviceResult)) {
            return ICalWorker.createPartialContentResponse(ServiceUtil.getErrorMessage(serviceResult));
        }
        String workEffortId = (String) serviceResult.get("workEffortId");
        if (workEffortId != null) {
            replaceProperty(component.getProperties(), toXProperty(WORKEFFORT_ID_X_PROP_NAME, workEffortId));
            serviceMap.clear();
            serviceMap.put("workEffortIdFrom", context.get("workEffortId"));
            serviceMap.put("workEffortIdTo", workEffortId);
            serviceMap.put("workEffortAssocTypeId", "WORK_EFF_DEPENDENCY");
            serviceMap.put("fromDate", new Timestamp(System.currentTimeMillis()));
            serviceResult = invokeService("createWorkEffortAssoc", serviceMap, context);
            if (ServiceUtil.isError(serviceResult)) {
                return ICalWorker.createPartialContentResponse(ServiceUtil.getErrorMessage(serviceResult));
            }
            storePartyAssignments(workEffortId, component, context);
        }
        return null;
    }

    protected static String fromClazz(PropertyList propertyList) {
        Clazz iCalObj = (Clazz) propertyList.getProperty(Clazz.CLASS);
        if (iCalObj == null) {
            return null;
        }
        return "WES_".concat(iCalObj.getValue());
    }

    protected static Timestamp fromCompleted(PropertyList propertyList) {
        Completed iCalObj = (Completed) propertyList.getProperty(Completed.COMPLETED);
        if (iCalObj == null) {
            return null;
        }
        Date date = iCalObj.getDate();
        return new Timestamp(date.getTime());
    }

    protected static String fromDescription(PropertyList propertyList) {
        Description iCalObj = (Description) propertyList.getProperty(Description.DESCRIPTION);
        if (iCalObj == null) {
            return null;
        }
        return iCalObj.getValue();
    }

    protected static Timestamp fromDtEnd(PropertyList propertyList) {
        DtEnd iCalObj = (DtEnd) propertyList.getProperty(DtEnd.DTEND);
        if (iCalObj == null) {
            return null;
        }
        Date date = iCalObj.getDate();
        return new Timestamp(date.getTime());
    }

    protected static Timestamp fromDtStart(PropertyList propertyList) {
        DtStart iCalObj = (DtStart) propertyList.getProperty(DtStart.DTSTART);
        if (iCalObj == null) {
            return null;
        }
        Date date = iCalObj.getDate();
        return new Timestamp(date.getTime());
    }

    protected static Double fromDuration(PropertyList propertyList) {
        Duration iCalObj = (Duration) propertyList.getProperty(Duration.DURATION);
        if (iCalObj == null) {
            return null;
        }
        Dur dur = iCalObj.getDuration();
        TimeDuration td = new TimeDuration(0, 0, (dur.getWeeks() * 7) + dur.getDays(), dur.getHours(), dur.getMinutes(), dur.getSeconds(), 0);
        return (double) TimeDuration.toLong(td);
    }

    protected static Timestamp fromLastModified(PropertyList propertyList) {
        LastModified iCalObj = (LastModified) propertyList.getProperty(LastModified.LAST_MODIFIED);
        if (iCalObj == null) {
            return null;
        }
        Date date = iCalObj.getDate();
        return new Timestamp(date.getTime());
    }

    protected static String fromLocation(PropertyList propertyList) {
        Location iCalObj = (Location) propertyList.getProperty(Location.LOCATION);
        if (iCalObj == null) {
            return null;
        }
        return iCalObj.getValue();
    }

    protected static String fromParticipationStatus(Parameter status) {
        if (status == null) {
            return null;
        }
        return FROM_PART_STATUS_MAP.get(status.getValue());
    }

    protected static Long fromPercentComplete(PropertyList propertyList) {
        PercentComplete iCalObj = (PercentComplete) propertyList.getProperty(PercentComplete.PERCENT_COMPLETE);
        if (iCalObj == null) {
            return null;
        }
        return (long) iCalObj.getPercentage();
    }

    protected static Double fromPriority(PropertyList propertyList) {
        Priority iCalObj = (Priority) propertyList.getProperty(Priority.PRIORITY);
        if (iCalObj == null) {
            return null;
        }
        return (double) iCalObj.getLevel();
    }

    protected static String fromStatus(PropertyList propertyList) {
        Status iCalObj = (Status) propertyList.getProperty(Status.STATUS);
        if (iCalObj == null) {
            return null;
        }
        return FROM_STATUS_MAP.get(iCalObj.getValue());
    }

    protected static String fromSummary(PropertyList propertyList) {
        Summary iCalObj = (Summary) propertyList.getProperty(Summary.SUMMARY);
        if (iCalObj == null) {
            return null;
        }
        return iCalObj.getValue();
    }

    protected static String fromUid(PropertyList propertyList) {
        Uid iCalObj = (Uid) propertyList.getProperty(Uid.UID);
        if (iCalObj == null) {
            return null;
        }
        return iCalObj.getValue();
    }

    protected static String fromXParameter(ParameterList parameterList, String parameterName) {
        if (parameterName == null) {
            return null;
        }
        Parameter parameter = parameterList.getParameter(parameterName);
        if (parameter != null) {
            return parameter.getValue();
        }
        return null;
    }

    protected static String fromXProperty(PropertyList propertyList, String propertyName) {
        if (propertyName == null) {
            return null;
        }
        Property property = propertyList.getProperty(propertyName);
        if (property != null) {
            return property.getValue();
        }
        return null;
    }

    protected static void getAlarms(GenericValue workEffort, ComponentList alarms) throws GenericEntityException {
        Description description = null;
        if (workEffort.get("description") != null) {
            description = new Description(workEffort.getString("description"));
        } else {
            description = new Description(workEffort.getString("workEffortName"));
        }
        Summary summary = new Summary(UtilProperties.getMessage("WorkEffortUiLabels", "WorkEffortEventReminder", Locale.getDefault()));
        Delegator delegator = workEffort.getDelegator();
        String workEffortId = workEffort.getString("workEffortId");
        List<GenericValue> reminderList = EntityQuery.use(delegator).from("WorkEffortEventReminder").where("workEffortId",
                workEffort.get("workEffortId")).queryList();
        for (GenericValue reminder : reminderList) {
            String reminderId = workEffortId + "-" + reminder.getString("sequenceId");
            VAlarm alarm = null;
            PropertyList alarmProps = null;
            boolean newAlarm = true;
            Iterator<VAlarm> i = UtilGenerics.cast(alarms.iterator());
            while (i.hasNext()) {
                alarm = i.next();
                Property xProperty = alarm.getProperty(REMINDER_X_PROP_NAME);
                if (xProperty != null && reminderId.equals(xProperty.getValue())) {
                    newAlarm = false;
                    alarmProps = alarm.getProperties();
                    // TODO: Write update code. For now, just re-create
                    alarmProps.clear();
                    break;
                }
            }
            if (newAlarm) {
                alarm = createAlarm(reminder);
                alarms.add(alarm);
                alarmProps = alarm.getProperties();
                alarmProps.add(new XProperty(REMINDER_X_PROP_NAME, reminderId));
            }
            GenericValue contactMech = reminder.getRelatedOne("ContactMech", false);
            if (contactMech != null && "EMAIL_ADDRESS".equals(contactMech.get("contactMechTypeId"))) {
                try {
                    alarmProps.add(new Attendee(contactMech.getString("infoString")));
                    alarmProps.add(Action.EMAIL);
                    alarmProps.add(summary);
                    alarmProps.add(description);
                } catch (URISyntaxException e) {
                    alarmProps.add(Action.DISPLAY);
                    alarmProps.add(new Description("Error encountered while creating iCalendar: " + e));
                }
            } else {
                alarmProps.add(Action.DISPLAY);
                alarmProps.add(description);
            }
            if (Debug.verboseOn()) {
                try {
                    alarm.validate(true);
                    Debug.logVerbose("iCalendar alarm passes validation", MODULE);
                } catch (ValidationException e) {
                    if (Debug.verboseOn()) {
                        Debug.logVerbose("iCalendar alarm fails validation: " + e, MODULE);
                    }
                }
            }
        }
    }

    /** Returns a calendar derived from a Work Effort calendar publish point.
     * @param workEffortId ID of a work effort with <code>workEffortTypeId</code> equal to
     * <code>PUBLISH_PROPS</code>.
     * @param context The conversion context
     * @return An iCalendar as a <code>String</code>, or <code>null</code>
     * if <code>workEffortId</code> is invalid.
     * @throws GenericEntityException if communications with the database failed
     */
    public static ResponseProperties getICalendar(String workEffortId, Map<String, Object> context) throws GenericEntityException {
        Delegator delegator = (Delegator) context.get("delegator");
        GenericValue publishProperties = EntityQuery.use(delegator).from("WorkEffort").where("workEffortId", workEffortId).queryOne();
        if (!isCalendarPublished(publishProperties)) {
            Debug.logInfo("WorkEffort calendar is not published: " + workEffortId, MODULE);
            return ICalWorker.createNotFoundResponse(null);
        }
        if (!"WES_PUBLIC".equals(publishProperties.get("scopeEnumId"))) {
            if (context.get("userLogin") == null) {
                return ICalWorker.createNotAuthorizedResponse(null);
            }
            if (!hasPermission(workEffortId, "VIEW", context)) {
                return ICalWorker.createForbiddenResponse(null);
            }
        }
        Calendar calendar = makeCalendar(publishProperties, context);
        ComponentList components = calendar.getComponents();
        List<GenericValue> workEfforts = getRelatedWorkEfforts(publishProperties, context);
        if (workEfforts != null) {
            for (GenericValue workEffort : workEfforts) {
                ResponseProperties responseProps = toCalendarComponent(components, workEffort, context);
                if (responseProps != null) {
                    return responseProps;
                }
            }
        }
        if (Debug.verboseOn()) {
            try {
                calendar.validate(true);
                Debug.logVerbose("iCalendar passes validation", MODULE);
            } catch (ValidationException e) {
                if (Debug.verboseOn()) {
                    Debug.logVerbose("iCalendar fails validation: " + e, MODULE);
                }
            }
        }
        return ICalWorker.createOkResponse(calendar.toString());
    }

    protected static void getPartyUrl(Property property, GenericValue partyAssign, Map<String, Object> context) {
        Map<String, ? extends Object> serviceMap = UtilMisc.toMap("partyId", partyAssign.get("partyId"));
        Map<String, Object> resultMap = invokeService("getPartyICalUrl", serviceMap, context);
        String iCalUrl = (String) resultMap.get("iCalUrl");
        if (iCalUrl != null) {
            if (!iCalUrl.contains(":") && iCalUrl.contains("@")) {
                iCalUrl = "MAILTO:".concat(iCalUrl);
            }
            try {
                property.setValue(iCalUrl);
            } catch (Exception e) {
                Debug.logError(e, "Error while setting party URI: ", MODULE);
            }
        }
    }

    protected static List<GenericValue> getRelatedWorkEfforts(GenericValue workEffort, Map<String, Object> context) {
        Map<String, ? extends Object> serviceMap = UtilMisc.toMap("workEffortId", workEffort.getString("workEffortId"));
        Map<String, Object> resultMap = invokeService("getICalWorkEfforts", serviceMap, context);
        List<GenericValue> workEfforts = UtilGenerics.checkCollection(resultMap.get("workEfforts"), GenericValue.class);
        if (workEfforts != null) {
            return WorkEffortWorker.removeDuplicateWorkEfforts(workEfforts);
        }
        return null;
    }

    protected static boolean hasPermission(String workEffortId, String action, Map<String, Object> context) {
        if (context.get("userLogin") == null) {
            return false;
        }
        Map<String, ? extends Object> serviceMap = UtilMisc.toMap("workEffortId", workEffortId, "mainAction", action);
        Map<String, Object> serviceResult = invokeService("workEffortICalendarPermission", serviceMap, context);
        Boolean hasPermission = (Boolean) serviceResult.get("hasPermission");
        if (hasPermission != null) {
            return hasPermission;
        }
        return false;
    }

    protected static Map<String, Object> invokeService(String serviceName, Map<String, ? extends Object> serviceMap, Map<String, Object> context) {
        LocalDispatcher dispatcher = (LocalDispatcher) context.get("dispatcher");
        Locale locale = (Locale) context.get("locale");
        Map<String, Object> localMap = new HashMap<>();
        try {
            ModelService modelService = null;
            modelService = dispatcher.getDispatchContext().getModelService(serviceName);
            for (ModelParam modelParam: modelService.getInModelParamList()) {
                if (serviceMap.containsKey(modelParam.getName())) {
                    Object value = serviceMap.get(modelParam.getName());
                    if (UtilValidate.isNotEmpty(modelParam.getType())) {
                        value = ObjectType.simpleTypeOrObjectConvert(value, modelParam.getType(), null, null, null, true);
                    }
                    localMap.put(modelParam.getName(), value);
                }
            }
        } catch (GeneralException e) {
            String errMsg = UtilProperties.getMessage("WorkEffortUiLabels", "WorkeffortErrorWhileCreatingServiceMapForService",
                    UtilMisc.toMap("serviceName", serviceName), locale);
            Debug.logError(e, errMsg, MODULE);
            return ServiceUtil.returnError(errMsg + e);
        }
        if (context.get("userLogin") != null) {
            localMap.put("userLogin", context.get("userLogin"));
        }
        localMap.put("locale", context.get("locale"));
        try {
            Map<String, Object> result = dispatcher.runSync(serviceName, localMap);
            if (ServiceUtil.isError(result)) {
                return ServiceUtil.returnError(ServiceUtil.getErrorMessage(result));
            }
            return result;
        } catch (GenericServiceException e) {
            String errMsg = UtilProperties.getMessage("WorkEffortUiLabels", "WorkeffortErrorWhileInvokingService",
                    UtilMisc.toMap("serviceName", serviceName), locale);
            Debug.logError(e, errMsg, MODULE);
            return ServiceUtil.returnError(errMsg + e);
        }
    }

    protected static boolean isCalendarPublished(GenericValue publishProperties) {
        if (publishProperties == null || !"PUBLISH_PROPS".equals(publishProperties.get("workEffortTypeId"))) {
            return false;
        }
        DateRange range = new DateRange(publishProperties.getTimestamp("actualStartDate"), publishProperties.getTimestamp("actualCompletionDate"));
        return range.includesDate(new Date());
    }

    protected static void loadPartyAssignment(Property property, GenericValue partyAssign, Map<String, Object> context) {
        getPartyUrl(property, partyAssign, context);
        if (UtilValidate.isEmpty(property.getValue())) {
            try {
                // RFC 2445 4.8.4.1 and 4.8.4.3 Value must be a URL
                property.setValue("MAILTO:ofbiz-test@example.com");
            } catch (Exception e) {
                Debug.logError(e, "Error while setting Property value: ", MODULE);
            }
        }
        ParameterList parameterList = property.getParameters();
        replaceParameter(parameterList, toXParameter(PARTY_ID_X_PARAM_NAME, partyAssign.getString("partyId")));
        replaceParameter(parameterList, new Cn(makePartyName(partyAssign)));
        replaceParameter(parameterList, toParticipationStatus(partyAssign.getString("assignmentStatusId")));
    }

    protected static void loadRelatedParties(List<GenericValue> relatedParties, PropertyList componentProps, Map<String, Object> context) {
        PropertyList attendees = componentProps.getProperties("ATTENDEE");
        for (GenericValue partyValue : relatedParties) {
            if ("CAL_ORGANIZER~CAL_OWNER".contains(partyValue.getString("roleTypeId"))) {
                // RFC 2445 4.6.1, 4.6.2, and 4.6.3 ORGANIZER can appear only once
                replaceProperty(componentProps, createOrganizer(partyValue, context));
            } else {
                String partyId = partyValue.getString("partyId");
                boolean newAttendee = true;
                Attendee attendee = null;
                Iterator<Attendee> i = UtilGenerics.cast(attendees.iterator());
                while (i.hasNext()) {
                    attendee = i.next();
                    Parameter xParameter = attendee.getParameter(PARTY_ID_X_PARAM_NAME);
                    if (xParameter != null && partyId.equals(xParameter.getValue())) {
                        loadPartyAssignment(attendee, partyValue, context);
                        newAttendee = false;
                        break;
                    }
                }
                if (newAttendee) {
                    attendee = createAttendee(partyValue, context);
                    componentProps.add(attendee);
                }
            }
        }
    }

    protected static void loadWorkEffort(PropertyList componentProps, GenericValue workEffort) {
        replaceProperty(componentProps, new DtStamp()); // iCalendar object created date/time
        replaceProperty(componentProps, toClazz(workEffort.getString("scopeEnumId")));
        replaceProperty(componentProps, toCreated(workEffort.getTimestamp("createdDate")));
        replaceProperty(componentProps, toDescription(workEffort.getString("description")));
        replaceProperty(componentProps, toDtStart(workEffort.getTimestamp("estimatedStartDate")));
        replaceProperty(componentProps, toLastModified(workEffort.getTimestamp("lastModifiedDate")));
        replaceProperty(componentProps, toPriority(workEffort.getLong("priority")));
        replaceProperty(componentProps, toLocation(workEffort.getString("locationDesc")));
        replaceProperty(componentProps, toStatus(workEffort.getString("currentStatusId")));
        replaceProperty(componentProps, toSummary(workEffort.getString("workEffortName")));
        Property uid = componentProps.getProperty(Uid.UID);
        if (uid == null) {
            // Don't overwrite UIDs created by calendar clients
            replaceProperty(componentProps, toUid(workEffort.getString("workEffortId")));
        }
        replaceProperty(componentProps, toXProperty(WORKEFFORT_ID_X_PROP_NAME, workEffort.getString("workEffortId")));
    }

    protected static Calendar makeCalendar(GenericValue workEffort, Map<String, Object> context) throws GenericEntityException {
        String iCalData = null;
        GenericValue iCalValue = workEffort.getRelatedOne("WorkEffortIcalData", false);
        if (iCalValue != null) {
            iCalData = iCalValue.getString("icalData");
        }
        boolean newCalendar = true;
        Calendar calendar = null;
        if (iCalData == null) {
            if (Debug.verboseOn()) {
                Debug.logVerbose("iCalendar Data not found, creating new Calendar", MODULE);
            }
            calendar = new Calendar();
        } else {
            if (Debug.verboseOn()) {
                Debug.logVerbose("iCalendar Data found, using saved Calendar", MODULE);
            }
            try (StringReader reader = new StringReader(iCalData)) {
                CalendarBuilder builder = new CalendarBuilder();
                calendar = builder.build(reader);
                newCalendar = false;
            } catch (Exception e) {
                Debug.logError(e, "Error while parsing saved iCalendar, creating new iCalendar: ", MODULE);
                calendar = new Calendar();
            }
        }
        PropertyList propList = calendar.getProperties();
        replaceProperty(propList, PROD_ID);
        replaceProperty(propList, new XProperty(WORKEFFORT_ID_X_PROP_NAME, workEffort.getString("workEffortId")));
        if (newCalendar) {
            propList.add(Version.VERSION_2_0);
            propList.add(CalScale.GREGORIAN);
            // TODO: Get time zone from publish properties value
            java.util.TimeZone tz = java.util.TimeZone.getDefault();
            TimeZoneRegistry registry = TimeZoneRegistryFactory.getInstance().createRegistry();
            net.fortuna.ical4j.model.TimeZone timezone = registry.getTimeZone(tz.getID());
            calendar.getComponents().add(timezone.getVTimeZone());
        }
        return calendar;
    }

    protected static String makePartyName(GenericValue partyAssign) {
        String partyName = partyAssign.getString("groupName");
        if (UtilValidate.isEmpty(partyName)) {
            partyName = partyAssign.getString("firstName") + " " + partyAssign.getString("lastName");
        }
        return partyName;
    }

    protected static void replaceParameter(ParameterList parameterList, Parameter parameter) {
        if (parameter == null) {
            return;
        }
        Parameter existingParam = parameterList.getParameter(parameter.getName());
        if (existingParam != null) {
            parameterList.remove(existingParam);
        }
        parameterList.add(parameter);
    }

    protected static void replaceProperty(PropertyList propertyList, Property property) {
        if (property == null) {
            return;
        }
        Property existingProp = propertyList.getProperty(property.getName());
        if (existingProp != null) {
            propertyList.remove(existingProp);
        }
        propertyList.add(property);
    }

    protected static void setMapElement(Map<String, Object> map, String key, Object value) {
        if (map == null || key == null || value == null) {
            return;
        }
        map.put(key, value);
    }

    protected static void setPartyIdFromUrl(Property property, Map<String, Object> context) {
        Map<String, ? extends Object> serviceMap = UtilMisc.toMap("address", property.getValue(), "caseInsensitive", "Y");
        Map<String, Object> resultMap = invokeService("findPartyFromEmailAddress", serviceMap, context);
        String partyId = (String) resultMap.get("partyId");
        if (partyId != null) {
            replaceParameter(property.getParameters(), toXParameter(PARTY_ID_X_PARAM_NAME, partyId));
        }
    }

    protected static void setWorkEffortServiceMap(Component component, Map<String, Object> serviceMap) {
        PropertyList propertyList = component.getProperties();
        setMapElement(serviceMap, "scopeEnumId", fromClazz(propertyList));
        setMapElement(serviceMap, "description", fromDescription(propertyList));
        setMapElement(serviceMap, "locationDesc", fromLocation(propertyList));
        setMapElement(serviceMap, "priority", fromPriority(propertyList));
        setMapElement(serviceMap, "currentStatusId", fromStatus(propertyList));
        setMapElement(serviceMap, "workEffortName", fromSummary(propertyList));
        setMapElement(serviceMap, "universalId", fromUid(propertyList));
        // Set some fields to null so calendar clients can revert changes
        serviceMap.put("estimatedStartDate", null);
        serviceMap.put("estimatedCompletionDate", null);
        serviceMap.put("estimatedMilliSeconds", null);
        serviceMap.put("lastModifiedDate", null);
        serviceMap.put("actualCompletionDate", null);
        serviceMap.put("percentComplete", null);
        setMapElement(serviceMap, "estimatedStartDate", fromDtStart(propertyList));
        setMapElement(serviceMap, "estimatedMilliSeconds", fromDuration(propertyList));
        setMapElement(serviceMap, "lastModifiedDate", fromLastModified(propertyList));
        if ("VTODO".equals(component.getName())) {
            setMapElement(serviceMap, "actualCompletionDate", fromCompleted(propertyList));
            setMapElement(serviceMap, "percentComplete", fromPercentComplete(propertyList));
        } else {
            setMapElement(serviceMap, "estimatedCompletionDate", fromDtEnd(propertyList));
        }
    }

    /**
     * Updates work efforts from an incoming iCalendar request.
     * @param is the input feeding the calendar parser
     * @param context parameters from the execution environment
     * @return the response from the ICalWorker
     * @throws IOException if there is an issue with {@code is}
     * @throws ParserException if the calendar build process failed
     * @throws GenericEntityException if communications with the database failed
     * @throws GenericServiceException if {@code createWorkEffortICalData} or {@code updateWorkEffortICalData}
     *         service invocation failed
     */
    public static ResponseProperties storeCalendar(InputStream is, Map<String, Object> context)
            throws IOException, ParserException, GenericEntityException, GenericServiceException {
        CalendarBuilder builder = new CalendarBuilder();
        Calendar calendar = null;
        try {
            calendar = builder.build(is);
        } finally {
            if (is != null) {
                is.close();
            }
        }
        if (Debug.verboseOn()) {
            Debug.logVerbose("Processing calendar:\r\n" + calendar, MODULE);
        }
        String workEffortId = fromXProperty(calendar.getProperties(), WORKEFFORT_ID_X_PROP_NAME);
        if (workEffortId == null) {
            workEffortId = (String) context.get("workEffortId");
        }
        if (!workEffortId.equals(context.get("workEffortId"))) {
            Debug.logWarning("Spoof attempt: received calendar workEffortId " + workEffortId
                    + " on URL workEffortId " + context.get("workEffortId"), MODULE);
            return ICalWorker.createForbiddenResponse(null);
        }
        Delegator delegator = (Delegator) context.get("delegator");
        GenericValue publishProperties = EntityQuery.use(delegator).from("WorkEffort").where("workEffortId", workEffortId).queryOne();
        if (!isCalendarPublished(publishProperties)) {
            Debug.logInfo("WorkEffort calendar is not published: " + workEffortId, MODULE);
            return ICalWorker.createNotFoundResponse(null);
        }
        if (context.get("userLogin") == null) {
            return ICalWorker.createNotAuthorizedResponse(null);
        }
        if (!hasPermission(workEffortId, "UPDATE", context)) {
            return ICalWorker.createForbiddenResponse(null);
        }
        boolean hasCreatePermission = hasPermission(workEffortId, "CREATE", context);
        List<GenericValue> workEfforts = getRelatedWorkEfforts(publishProperties, context);
        Set<String> validWorkEfforts = new HashSet<>();
        if (UtilValidate.isNotEmpty(workEfforts)) {
            // Security issue: make sure only related work efforts get updated
            for (GenericValue workEffort : workEfforts) {
                validWorkEfforts.add(workEffort.getString("workEffortId"));
            }
        }
        List<Component> components = UtilGenerics.checkCollection(calendar.getComponents(), Component.class);
        ResponseProperties responseProps = null;
        for (Component component : components) {
            if (Component.VEVENT.equals(component.getName()) || Component.VTODO.equals(component.getName())) {
                workEffortId = fromXProperty(component.getProperties(), WORKEFFORT_ID_X_PROP_NAME);
                if (workEffortId == null) {
                    Property uid = component.getProperty(Uid.UID);
                    if (uid != null) {
                        GenericValue workEffort = EntityQuery.use(delegator).from("WorkEffort").where("universalId", uid.getValue()).queryFirst();
                        if (workEffort != null) {
                            workEffortId = workEffort.getString("workEffortId");
                        }
                    }
                }
                if (workEffortId != null) {
                    if (validWorkEfforts.contains(workEffortId)) {
                        replaceProperty(component.getProperties(), toXProperty(WORKEFFORT_ID_X_PROP_NAME, workEffortId));
                        responseProps = storeWorkEffort(component, context);
                    } else {
                        Debug.logWarning("Spoof attempt: unrelated workEffortId " + workEffortId
                                + " on URL workEffortId " + context.get("workEffortId"), MODULE);
                        responseProps = ICalWorker.createForbiddenResponse(null);
                    }
                } else if (hasCreatePermission) {
                    responseProps = createWorkEffort(component, context);
                }
                if (responseProps != null) {
                    return responseProps;
                }
            }
        }
        Map<String, ? extends Object> serviceMap = UtilMisc.toMap("workEffortId", context.get("workEffortId"), "icalData", calendar.toString());
        GenericValue iCalData = publishProperties.getRelatedOne("WorkEffortIcalData", false);
        Map<String, Object> serviceResult = null;
        if (iCalData == null) {
            serviceResult = invokeService("createWorkEffortICalData", serviceMap, context);
        } else {
            serviceResult = invokeService("updateWorkEffortICalData", serviceMap, context);
        }
        if (ServiceUtil.isError(serviceResult)) {
            return ICalWorker.createPartialContentResponse(ServiceUtil.getErrorMessage(serviceResult));
        }
        return ICalWorker.createOkResponse(null);
    }

    protected static ResponseProperties storePartyAssignments(String workEffortId, Component component, Map<String, Object> context) {
        ResponseProperties responseProps = null;
        Map<String, Object> serviceMap = new HashMap<>();
        List<Property> partyList = new LinkedList<>();
        partyList.addAll(UtilGenerics.checkCollection(component.getProperties("ATTENDEE"), Property.class));
        partyList.addAll(UtilGenerics.checkCollection(component.getProperties("CONTACT"), Property.class));
        partyList.addAll(UtilGenerics.checkCollection(component.getProperties("ORGANIZER"), Property.class));
        for (Property property : partyList) {
            String partyId = fromXParameter(property.getParameters(), PARTY_ID_X_PARAM_NAME);
            if (partyId == null) {
                serviceMap.clear();
                String address = property.getValue();
                if (address.toUpperCase(Locale.getDefault()).startsWith("MAILTO:")) {
                    address = address.substring(7);
                }
                serviceMap.put("address", address);
                Map<String, Object> result = invokeService("findPartyFromEmailAddress", serviceMap, context);
                partyId = (String) result.get("partyId");
                if (partyId == null) {
                    continue;
                }
                replaceParameter(property.getParameters(), toXParameter(PARTY_ID_X_PARAM_NAME, partyId));
            }
            serviceMap.clear();
            serviceMap.put("workEffortId", workEffortId);
            serviceMap.put("partyId", partyId);
            serviceMap.put("roleTypeId", FROM_ROLE_MAP.get(property.getName()));
            Delegator delegator = (Delegator) context.get("delegator");
            List<GenericValue> assignments = null;
            try {
                assignments = EntityQuery.use(delegator).from("WorkEffortPartyAssignment").where(serviceMap).filterByDate().queryList();
                if (assignments.isEmpty()) {
                    serviceMap.put("statusId", "PRTYASGN_OFFERED");
                    serviceMap.put("fromDate", new Timestamp(System.currentTimeMillis()));
                    invokeService("assignPartyToWorkEffort", serviceMap, context);
                }
            } catch (GenericEntityException e) {
                responseProps = ICalWorker.createPartialContentResponse(e.getMessage());
                break;
            }
        }
        return responseProps;
    }

    protected static ResponseProperties storeWorkEffort(Component component, Map<String, Object> context) throws GenericEntityException {
        PropertyList propertyList = component.getProperties();
        String workEffortId = fromXProperty(propertyList, WORKEFFORT_ID_X_PROP_NAME);
        Delegator delegator = (Delegator) context.get("delegator");
        GenericValue workEffort = EntityQuery.use(delegator).from("WorkEffort").where("workEffortId", workEffortId).queryOne();
        if (workEffort == null) {
            return ICalWorker.createNotFoundResponse(null);
        }
        if (!hasPermission(workEffortId, "UPDATE", context)) {
            return null;
        }
        Map<String, Object> serviceMap = new HashMap<>();
        serviceMap.put("workEffortId", workEffortId);
        setWorkEffortServiceMap(component, serviceMap);
        invokeService("updateWorkEffort", serviceMap, context);
        return storePartyAssignments(workEffortId, component, context);
    }

    protected static ResponseProperties toCalendarComponent(ComponentList components, GenericValue workEffort, Map<String, Object> context)
            throws GenericEntityException {
        Delegator delegator = workEffort.getDelegator();
        String workEffortId = workEffort.getString("workEffortId");
        String workEffortUid = workEffort.getString("universalId");
        String workEffortTypeId = workEffort.getString("workEffortTypeId");
        GenericValue typeValue = EntityQuery.use(delegator).from("WorkEffortType").where("workEffortTypeId", workEffortTypeId).cache().queryOne();
        boolean isTask = false;
        boolean newComponent = true;
        ComponentList resultList = null;
        ComponentList alarms = null;
        Component result = null;
        if ("TASK".equals(workEffortTypeId) || (typeValue != null && "TASK".equals(typeValue.get("parentTypeId")))) {
            isTask = true;
            resultList = components.getComponents("VTODO");
        } else if ("EVENT".equals(workEffortTypeId) || (typeValue != null && "EVENT".equals(typeValue.get("parentTypeId")))) {
            resultList = components.getComponents("VEVENT");
        } else {
            return null;
        }
        Iterator<Component> i = UtilGenerics.cast(resultList.iterator());
        while (i.hasNext()) {
            result = i.next();
            Property xProperty = result.getProperty(WORKEFFORT_ID_X_PROP_NAME);
            if (xProperty != null && workEffortId.equals(xProperty.getValue())) {
                newComponent = false;
                break;
            }
            Property uid = result.getProperty(Uid.UID);
            if (uid != null && uid.getValue().equals(workEffortUid)) {
                newComponent = false;
                break;
            }
        }
        if (isTask) {
            VToDo toDo = null;
            if (newComponent) {
                toDo = new VToDo();
                result = toDo;
            } else {
                toDo = (VToDo) result;
            }
            alarms = toDo.getAlarms();
        } else {
            VEvent event = null;
            if (newComponent) {
                event = new VEvent();
                result = event;
            } else {
                event = (VEvent) result;
            }
            alarms = event.getAlarms();
        }
        if (newComponent) {
            components.add(result);
        }
        PropertyList componentProps = result.getProperties();
        loadWorkEffort(componentProps, workEffort);
        if (isTask) {
            replaceProperty(componentProps, toCompleted(workEffort.getTimestamp("actualCompletionDate")));
            replaceProperty(componentProps, toPercentComplete(workEffort.getLong("percentComplete")));
        } else {
            replaceProperty(componentProps, toDtEnd(workEffort.getTimestamp("estimatedCompletionDate")));
        }
        if (workEffort.get("estimatedCompletionDate") == null) {
            replaceProperty(componentProps, toDuration(workEffort.getDouble("estimatedMilliSeconds")));
        }
        List<GenericValue> relatedParties = EntityQuery.use(delegator).from("WorkEffortPartyAssignView").where("workEffortId",
                workEffortId).filterByDate().queryList();
        if (!relatedParties.isEmpty()) {
            loadRelatedParties(relatedParties, componentProps, context);
        }
        if (newComponent) {
            if (UtilValidate.isNotEmpty(workEffort.getString("tempExprId"))) {
                TemporalExpression tempExpr = TemporalExpressionWorker.getTemporalExpression(delegator, workEffort.getString("tempExprId"));
                if (tempExpr != null) {
                    try {
                        ICalRecurConverter.convert(tempExpr, componentProps);
                    } catch (Exception e) {
                        replaceProperty(componentProps, new Description("Error while converting recurrence: " + e));
                    }
                }
            }
            getAlarms(workEffort, alarms);
        }
        if (Debug.verboseOn()) {
            try {
                result.validate(true);
                Debug.logVerbose("iCalendar component passes validation", MODULE);
            } catch (ValidationException e) {
                Debug.logVerbose(e, "iCalendar component fails validation: ", MODULE);
            }
        }
        return null;
    }

    protected static Clazz toClazz(String javaObj) {
        if (javaObj == null) {
            return null;
        }
        return new Clazz(javaObj.replace("WES_", ""));
    }

    protected static Completed toCompleted(Timestamp javaObj) {
        if (javaObj == null) {
            return null;
        }
        return new Completed(new DateTime(javaObj));
    }

    protected static Created toCreated(Timestamp javaObj) {
        if (javaObj == null) {
            return null;
        }
        return new Created(new DateTime(javaObj));
    }

    protected static Description toDescription(String javaObj) {
        if (javaObj == null) {
            return null;
        }
        return new Description(javaObj);
    }

    protected static DtEnd toDtEnd(Timestamp javaObj) {
        if (javaObj == null) {
            return null;
        }
        return new DtEnd(new DateTime(javaObj));
    }

    protected static DtStart toDtStart(Timestamp javaObj) {
        if (javaObj == null) {
            return null;
        }
        return new DtStart(new DateTime(javaObj));
    }

    protected static Duration toDuration(Double javaObj) {
        if (javaObj == null) {
            return null;
        }
        TimeDuration duration = TimeDuration.fromNumber(javaObj);
        return new Duration(new Dur(duration.days(), duration.hours(), duration.minutes(), duration.seconds()));
    }

    protected static LastModified toLastModified(Timestamp javaObj) {
        if (javaObj == null) {
            return null;
        }
        return new LastModified(new DateTime(javaObj));
    }

    protected static Location toLocation(String javaObj) {
        if (javaObj == null) {
            return null;
        }
        return new Location(javaObj);
    }

    protected static PartStat toParticipationStatus(String statusId) {
        if (statusId == null) {
            return null;
        }
        return TO_PARTY_STATUS_MAP.get(statusId);
    }

    protected static PercentComplete toPercentComplete(Long javaObj) {
        if (javaObj == null) {
            return null;
        }
        return new PercentComplete(javaObj.intValue());
    }

    protected static Priority toPriority(Long javaObj) {
        if (javaObj == null) {
            return null;
        }
        return new Priority(javaObj.intValue());
    }

    protected static Status toStatus(String javaObj) {
        if (javaObj == null) {
            return null;
        }
        return TO_STATUS_MAP.get(javaObj);
    }

    protected static Summary toSummary(String javaObj) {
        if (javaObj == null) {
            return null;
        }
        return new Summary(javaObj);
    }

    protected static Uid toUid(String javaObj) {
        if (javaObj == null) {
            return null;
        }
        return new Uid(UID_PREFIX.concat(javaObj));
    }

    protected static XParameter toXParameter(String name, String value) {
        if (name == null || value == null) {
            return null;
        }
        return new XParameter(name, value);
    }

    protected static XProperty toXProperty(String name, String value) {
        if (name == null || value == null) {
            return null;
        }
        return new XProperty(name, value);
    }
}
