/*
 * Copyright 2010-2020 Alfresco Software, Ltd.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */


package org.activiti.engine.impl.bpmn.helper;

import org.activiti.bpmn.model.*;
import org.activiti.bpmn.model.Error;
import org.activiti.bpmn.model.Process;
import org.activiti.engine.ActivitiException;
import org.activiti.engine.delegate.BpmnError;
import org.activiti.engine.delegate.DelegateExecution;
import org.activiti.engine.delegate.event.ActivitiEventType;
import org.activiti.engine.delegate.event.impl.ActivitiEventBuilder;
import org.activiti.engine.impl.context.Context;
import org.activiti.engine.impl.persistence.entity.ExecutionEntity;
import org.activiti.engine.impl.persistence.entity.ExecutionEntityManager;
import org.activiti.engine.impl.util.CollectionUtil;
import org.activiti.engine.impl.util.ProcessDefinitionUtil;
import org.activiti.engine.impl.util.ReflectUtil;
import org.apache.commons.lang3.StringUtils;

import java.util.*;

/**
 * This class is responsible for finding and executing error handlers for BPMN Errors.
 *
 * Possible error handlers include Error Intermediate Events and Error Event Sub-Processes.
 *


 */
public class ErrorPropagation {

  public static void propagateError(BpmnError error, DelegateExecution execution) {
    propagateError(error.getErrorCode(), execution);
  }

  public static void propagateError(String errorRef, DelegateExecution execution) {
    boolean isCatchExecutedForProcess = false;
    boolean isCatchExecutedForCallActivity = false;

    try {
        Map<String, List<Event>> eventMap = findCatchingEventsForProcess(execution.getProcessDefinitionId(), errorRef);

        if (!eventMap.isEmpty()) {
            isCatchExecutedForProcess = executeCatch(eventMap, execution, errorRef);
        }

        if (isCallActivity(execution)) {
            isCatchExecutedForCallActivity = findCatchingEventsAndExecuteCatchForCallActivity(errorRef, execution);
        }
    } finally {
        if (!isCatchExecutedForProcess && !isCatchExecutedForCallActivity) {
            throw new BpmnError(errorRef, "No catching boundary event found for error with errorCode '" + errorRef + "', neither in same process nor in parent process");
        }
    }
  }

  protected static boolean executeCatch(Map<String, List<Event>> eventMap, DelegateExecution delegateExecution, String errorId) {
    Event matchingEvent = null;
    ExecutionEntity currentExecution = (ExecutionEntity) delegateExecution;
    ExecutionEntity parentExecution = null;

    if (eventMap.containsKey(currentExecution.getActivityId())) {
      matchingEvent = eventMap.get(currentExecution.getActivityId()).get(0);

      // Check for multi instance
      if (currentExecution.getParentId() != null && currentExecution.getParent().isMultiInstanceRoot()) {
        parentExecution = currentExecution.getParent();
      } else {
        parentExecution = currentExecution;
      }

    } else {
      parentExecution = currentExecution.getParent();

      // Traverse parents until one is found that is a scope and matches the activity the boundary event is defined on
      while (matchingEvent == null && parentExecution != null) {
        FlowElementsContainer currentContainer = null;
        if (parentExecution.getCurrentFlowElement() instanceof FlowElementsContainer) {
          currentContainer = (FlowElementsContainer) parentExecution.getCurrentFlowElement();
        } else if (parentExecution.getId().equals(parentExecution.getProcessInstanceId())) {
          currentContainer = ProcessDefinitionUtil.getProcess(parentExecution.getProcessDefinitionId());
        }

        for (String refId : eventMap.keySet()) {
          List<Event> events = eventMap.get(refId);
          if (CollectionUtil.isNotEmpty(events) && events.get(0) instanceof StartEvent) {
            if (currentContainer.getFlowElement(refId) != null) {
              matchingEvent = events.get(0);
            }
          }
        }

        if (matchingEvent == null) {
          if (eventMap.containsKey(parentExecution.getActivityId())) {
            matchingEvent = eventMap.get(parentExecution.getActivityId()).get(0);

            // Check for multi instance
            if (parentExecution.getParentId() != null && parentExecution.getParent().isMultiInstanceRoot()) {
              parentExecution = parentExecution.getParent();
            }

          } else if (StringUtils.isNotEmpty(parentExecution.getParentId())) {
            parentExecution = parentExecution.getParent();
          } else {
            parentExecution = null;
          }
        }
      }
    }

    if (matchingEvent != null && parentExecution != null) {
      executeEventHandler(matchingEvent, parentExecution, currentExecution, errorId);
      return true;
    } else {
      return false;
    }

  }

    private static boolean isCallActivity(DelegateExecution delegateExecution) {
        return !delegateExecution.getProcessInstanceId()
            .equals(delegateExecution.getRootProcessInstanceId());
    }

    protected static boolean findCatchingEventsAndExecuteCatchForCallActivity(String errorRef, DelegateExecution execution) {
      ExecutionEntityManager executionEntityManager = Context.getCommandContext().getExecutionEntityManager();
      ExecutionEntity processInstanceExecution = executionEntityManager.findById(execution.getProcessInstanceId());

      Map<String, List<Event>> eventMap = Collections.emptyMap();
      if (processInstanceExecution != null) {

          ExecutionEntity parentExecution = processInstanceExecution.getSuperExecution();

          Set<String> toDeleteProcessInstanceIds = new HashSet<>();
          toDeleteProcessInstanceIds.add(execution.getProcessInstanceId());

          while (!parentExecution.isRootExecution() && eventMap.isEmpty()) {
              eventMap = findCatchingEventsForProcess(parentExecution.getProcessDefinitionId(), errorRef);
              if (!eventMap.isEmpty()) {
                  for (String processInstanceId : toDeleteProcessInstanceIds) {
                      deleteProcessInstanceEntity(errorRef, execution, executionEntityManager, processInstanceId);
                  }
                  return executeCatch(eventMap, parentExecution, errorRef);
              } else {
                  toDeleteProcessInstanceIds.add(parentExecution.getProcessInstanceId());
                  ExecutionEntity superExecution = parentExecution.getSuperExecution();
                  if (superExecution != null) {
                      parentExecution = superExecution;
                  } else {
                      parentExecution = parentExecution.getProcessInstance();
                  }
              }
          }
      }

      return false;
  }

    private static void deleteProcessInstanceEntity(String errorRef, DelegateExecution execution,
        ExecutionEntityManager executionEntityManager, String processInstanceId) {
        ExecutionEntity processInstanceEntity = executionEntityManager.findById(processInstanceId);

        executionEntityManager.deleteProcessInstanceExecutionEntity(processInstanceEntity.getId(),
                                                                    execution.getCurrentFlowElement() != null ? execution.getCurrentFlowElement().getId() : null,
                                                                    "ERROR_EVENT " + errorRef,
                                                                    false, false);
        dispatchProcessErroredEvent(processInstanceEntity);
    }

    private static void dispatchProcessErroredEvent(ExecutionEntity processInstanceEntity) {
        if (Context.getProcessEngineConfiguration() != null && Context.getProcessEngineConfiguration().getEventDispatcher().isEnabled()) {
            Context.getProcessEngineConfiguration().getEventDispatcher()
                   .dispatchEvent(ActivitiEventBuilder.createEntityEvent(ActivitiEventType.PROCESS_COMPLETED_WITH_ERROR_END_EVENT,
                       processInstanceEntity));
        }
    }

    protected static void executeEventHandler(Event event, ExecutionEntity parentExecution, ExecutionEntity currentExecution, String errorId) {
    if (Context.getProcessEngineConfiguration() != null && Context.getProcessEngineConfiguration().getEventDispatcher().isEnabled()) {
      BpmnModel bpmnModel = ProcessDefinitionUtil.getBpmnModel(parentExecution.getProcessDefinitionId());
      if (bpmnModel != null) {

        String errorCode = Optional.ofNullable(bpmnModel.getErrors().get(errorId))
                .map(Error::getErrorCode).orElse(errorId);

        Context.getProcessEngineConfiguration().getEventDispatcher().dispatchEvent(
          ActivitiEventBuilder.createErrorEvent(ActivitiEventType.ACTIVITY_ERROR_RECEIVED, event.getId(), errorId, errorCode, parentExecution.getId(),
              parentExecution.getProcessInstanceId(), parentExecution.getProcessDefinitionId()));
      }
    }

    if (event instanceof StartEvent) {
      ExecutionEntityManager executionEntityManager = Context.getCommandContext().getExecutionEntityManager();

      if (!currentExecution.getParentId().equals(parentExecution.getId())) {
        Context.getAgenda().planDestroyScopeOperation(currentExecution);
      } else {
        executionEntityManager.deleteExecutionAndRelatedData(currentExecution, null);
      }

      ExecutionEntity eventSubProcessExecution = executionEntityManager.createChildExecution(parentExecution);
      eventSubProcessExecution.setCurrentFlowElement(event);
      Context.getAgenda().planContinueProcessOperation(eventSubProcessExecution);

    } else {
      ExecutionEntity boundaryExecution = null;
      List<? extends ExecutionEntity> childExecutions = parentExecution.getExecutions();
      for (ExecutionEntity childExecution : childExecutions) {
        if (childExecution.getActivityId().equals(event.getId())) {
          boundaryExecution = childExecution;
        }
      }

      Context.getAgenda().planTriggerExecutionOperation(boundaryExecution);
    }
  }

  protected static Map<String, List<Event>> findCatchingEventsForProcess(String processDefinitionId, String errorRef) {
    Map<String, List<Event>> eventMap = new HashMap<String, List<Event>>();
    Process process = ProcessDefinitionUtil.getProcess(processDefinitionId);
    BpmnModel bpmnModel = ProcessDefinitionUtil.getBpmnModel(processDefinitionId);

    String compareErrorCode = retrieveErrorCode(bpmnModel, errorRef);

    List<EventSubProcess> subProcesses = process.findFlowElementsOfType(EventSubProcess.class, true);
    for (EventSubProcess eventSubProcess : subProcesses) {
      for (FlowElement flowElement : eventSubProcess.getFlowElements()) {
        if (flowElement instanceof StartEvent) {
          StartEvent startEvent = (StartEvent) flowElement;
          if (CollectionUtil.isNotEmpty(startEvent.getEventDefinitions()) && startEvent.getEventDefinitions().get(0) instanceof ErrorEventDefinition) {
            ErrorEventDefinition errorEventDef = (ErrorEventDefinition) startEvent.getEventDefinitions().get(0);
            String eventErrorCode = retrieveErrorCode(bpmnModel, errorEventDef.getErrorRef());

            if (eventErrorCode == null || compareErrorCode == null || eventErrorCode.equals(compareErrorCode)) {
              List<Event> startEvents = new ArrayList<Event>();
              startEvents.add(startEvent);
              eventMap.put(eventSubProcess.getId(), startEvents);
            }
          }
        }
      }
    }

    List<BoundaryEvent> boundaryEvents = process.findFlowElementsOfType(BoundaryEvent.class, true);
    for (BoundaryEvent boundaryEvent : boundaryEvents) {
      if (boundaryEvent.getAttachedToRefId() != null && CollectionUtil.isNotEmpty(boundaryEvent.getEventDefinitions()) && boundaryEvent.getEventDefinitions().get(0) instanceof ErrorEventDefinition) {

        ErrorEventDefinition errorEventDef = (ErrorEventDefinition) boundaryEvent.getEventDefinitions().get(0);
        String eventErrorCode = retrieveErrorCode(bpmnModel, errorEventDef.getErrorRef());

        if (eventErrorCode == null || compareErrorCode == null || eventErrorCode.equals(compareErrorCode)) {
          List<Event> elementBoundaryEvents = null;
          if (!eventMap.containsKey(boundaryEvent.getAttachedToRefId())) {
            elementBoundaryEvents = new ArrayList<Event>();
            eventMap.put(boundaryEvent.getAttachedToRefId(), elementBoundaryEvents);
          } else {
            elementBoundaryEvents = eventMap.get(boundaryEvent.getAttachedToRefId());
          }
          elementBoundaryEvents.add(boundaryEvent);
        }
      }
    }
    return eventMap;
  }

  public static boolean mapException(Exception e, ExecutionEntity execution, List<MapExceptionEntry> exceptionMap) {
    String errorCode = findMatchingExceptionMapping(e, exceptionMap);
    if (errorCode != null) {
      propagateError(errorCode, execution);
      return true;
    } else {
      ExecutionEntity callActivityExecution = null;
      ExecutionEntity parentExecution = execution.getParent();
      while (parentExecution != null && callActivityExecution == null) {
        if (parentExecution.getId().equals(parentExecution.getProcessInstanceId())) {
          if (parentExecution.getSuperExecution() != null) {
            callActivityExecution = parentExecution.getSuperExecution();
          } else {
            parentExecution = null;
          }
        } else {
          parentExecution = parentExecution.getParent();
        }
      }

      if (callActivityExecution != null) {
        CallActivity callActivity = (CallActivity) callActivityExecution.getCurrentFlowElement();
        if (CollectionUtil.isNotEmpty(callActivity.getMapExceptions())) {
          errorCode = findMatchingExceptionMapping(e, callActivity.getMapExceptions());
          if (errorCode != null) {
            propagateError(errorCode, callActivityExecution);
            return true;
          }
        }
      }

      return false;
    }
  }

  protected static String findMatchingExceptionMapping(Exception e, List<MapExceptionEntry> exceptionMap) {
    String defaultExceptionMapping = null;

    for (MapExceptionEntry me : exceptionMap) {
      String exceptionClass = me.getClassName();
      String errorCode = me.getErrorCode();

      // save the first mapping with no exception class as default map
      if (StringUtils.isNotEmpty(errorCode) && StringUtils.isEmpty(exceptionClass) && defaultExceptionMapping == null) {
        defaultExceptionMapping = errorCode;
        continue;
      }

      // ignore if error code or class are not defined
      if (StringUtils.isEmpty(errorCode) || StringUtils.isEmpty(exceptionClass)) {
        continue;
      }

      if (e.getClass().getName().equals(exceptionClass)) {
        return errorCode;
      }
      if (me.isAndChildren()) {
        Class<?> exceptionClassClass = ReflectUtil.loadClass(exceptionClass);
        if (exceptionClassClass.isAssignableFrom(e.getClass())) {
          return errorCode;
        }
      }
    }

    return defaultExceptionMapping;
  }

  protected static String retrieveErrorCode(BpmnModel bpmnModel, String errorRef) {
    return Optional.ofNullable(errorRef).map(ref -> {
      if(bpmnModel.containsErrorRef(errorRef)){
        return Optional.ofNullable(bpmnModel.getErrors().get(errorRef))
                .map(Error::getErrorCode).orElse(errorRef);
      }
      return errorRef;
    }).orElse(errorRef);
  }
}
