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

package com.flipkart.flux.resource;

import com.codahale.metrics.annotation.Timed;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.flipkart.flux.api.*;
import com.flipkart.flux.client.runtime.EventProxyConnector;
import com.flipkart.flux.controller.WorkFlowExecutionController;
import com.flipkart.flux.dao.ParallelScatterGatherQueryHelper;
import com.flipkart.flux.dao.iface.AuditDAO;
import com.flipkart.flux.dao.iface.EventsDAO;
import com.flipkart.flux.dao.iface.StateMachinesDAO;
import com.flipkart.flux.dao.iface.StatesDAO;
import com.flipkart.flux.domain.*;
import com.flipkart.flux.domain.Status;
import com.flipkart.flux.exception.IllegalEventException;
import com.flipkart.flux.impl.RAMContext;
import com.flipkart.flux.metrics.iface.MetricsClient;
import com.flipkart.flux.persistence.DataSourceType;
import com.flipkart.flux.persistence.SelectDataSource;
import com.flipkart.flux.persistence.Storage;
import com.flipkart.flux.representation.IllegalRepresentationException;
import com.flipkart.flux.representation.StateMachinePersistenceService;
import com.flipkart.flux.task.eventscheduler.EventSchedulerRegistry;
import com.flipkart.flux.utils.LoggingUtils;
import com.google.inject.Inject;
import org.hibernate.exception.ConstraintViolationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;

import javax.inject.Named;
import javax.inject.Singleton;
import javax.transaction.Transactional;
import javax.ws.rs.*;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.sql.Timestamp;
import java.util.*;
import java.util.stream.Collectors;

import static com.flipkart.flux.constant.RuntimeConstants.DEFAULT_ELB_ID;

/**
 * <code>StateMachineResource</code> exposes APIs to perform state machine related operations. Ex: Creating SM, receiving event for a SM
 *
 * @author shyam.akirala
 * @author yogesh
 * @author regunath.balasubramanian
 */
@Singleton
@Path("/api/machines")
@Named
public class StateMachineResource {

    /**
     * Single white space label to denote start of processing i.e. the Trigger
     */
    private static final String TRIGGER = " ";

    private static final String CORRELATION_ID = "correlationId";

    private StateMachinePersistenceService stateMachinePersistenceService;

    private WorkFlowExecutionController workFlowExecutionController;

    private StateMachinesDAO stateMachinesDAO;

    private StatesDAO statesDAO;

    private EventsDAO eventsDAO;

    private AuditDAO auditDAO;

    private EventSchedulerRegistry eventSchedulerRegistry;

    private ObjectMapper objectMapper;

    private MetricsClient metricsClient;

    private ParallelScatterGatherQueryHelper parallelScatterGatherQueryHelper;

    private EventProxyConnector eventProxyConnector;

    private String eventProxyEnabled;

    @Inject
    public StateMachineResource(EventsDAO eventsDAO, StateMachinePersistenceService stateMachinePersistenceService,
                                AuditDAO auditDAO, StateMachinesDAO stateMachinesDAO, StatesDAO statesDAO,
                                WorkFlowExecutionController workFlowExecutionController, MetricsClient metricsClient,
                                ParallelScatterGatherQueryHelper parallelScatterGatherQueryHelper,
                                EventSchedulerRegistry eventSchedulerRegistry,
                                EventProxyConnector eventProxyConnector,
                                @Named("eventProxyForMigration.enabled") String eventProxyEnabled) {
        this.eventsDAO = eventsDAO;
        this.stateMachinePersistenceService = stateMachinePersistenceService;
        this.stateMachinesDAO = stateMachinesDAO;
        this.statesDAO = statesDAO;
        this.auditDAO = auditDAO;
        this.eventSchedulerRegistry = eventSchedulerRegistry;
        this.workFlowExecutionController = workFlowExecutionController;
        this.objectMapper = new ObjectMapper();
        this.metricsClient = metricsClient;
        this.parallelScatterGatherQueryHelper = parallelScatterGatherQueryHelper;
        this.eventProxyConnector = eventProxyConnector;
        this.eventProxyEnabled = eventProxyEnabled;
    }

    /**
     * Logger instance for this class
     */
    private static final Logger logger = LoggerFactory.getLogger(StateMachineResource.class);

    /**
     * Will instantiate a state machine in the flux execution engine
     *
     * @param stateMachineDefinition User input for state machine
     * @return unique machineId of the instantiated state machine
     */
    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    @Timed
    public Response createStateMachine(StateMachineDefinition stateMachineDefinition) throws Exception {

        if (stateMachineDefinition == null)
            throw new IllegalRepresentationException("State machine definition is empty");

        StateMachine stateMachine = null;


        final String stateMachineInstanceId;
        if (stateMachineDefinition.getCorrelationId() != null && !stateMachineDefinition.getCorrelationId().isEmpty()) {
            stateMachineInstanceId = stateMachineDefinition.getCorrelationId();
        } else {
            stateMachineInstanceId = UUID.randomUUID().toString();
        }

        if (stateMachineDefinition.getClientElbId() == null) {
            stateMachineDefinition.setClientElbId(DEFAULT_ELB_ID);
        }

        try {
            stateMachine = createAndInitStateMachine(stateMachineInstanceId, stateMachineDefinition);
            metricsClient.markMeter(new StringBuilder().
                    append("stateMachine.").
                    append(stateMachine.getName()).
                    append(".started").toString());
        } catch (ConstraintViolationException ex) {
            //in case of Duplicate correlation key, return http code 409 conflict
            return Response.status(Response.Status.CONFLICT.getStatusCode()).entity(ex.getCause() != null ? ex.getCause().getMessage() : null).build();
        } catch (Exception ex) {
            logger.error("Failed During Creating or Initiating StateMachine with id {} {}", stateMachineInstanceId, ex.getStackTrace());
            return Response.status(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode()).entity(ex.getCause() != null ? ex.getCause().getMessage() : null).build();
        }

        return Response.status(Response.Status.CREATED.getStatusCode()).entity(stateMachine.getId()).build();
    }

    /**
     * Creates and starts the state machine. Keeping this method as "protected" so that Transactional interceptor can intercept the call.
     */

    protected StateMachine createAndInitStateMachine(String stateMachineInstanceId, StateMachineDefinition stateMachineDefinition) throws Exception {
        try {
            // 1. Convert to StateMachine (domain object) and save in DB
            StateMachine stateMachine = stateMachinePersistenceService.createStateMachine(stateMachineInstanceId, stateMachineDefinition);
            LoggingUtils.registerStateMachineIdForLogging(stateMachine.getId().toString());
            logger.info("Created state machine with Id: {}", stateMachine.getId());
            // 2. initialize and start State Machine
            workFlowExecutionController.initAndStart(stateMachine);
            return stateMachine;
        }
        finally {
            LoggingUtils.deRegisterStateMachineIdForLogging();
        }
    }

    /**
     * Used to post Data corresponding to an event.
     * This data may be a result of a task getting completed or independently posted (manually, for example)
     *
     * @param machineId machineId the event is to be submitted against
     * @param eventData Json representation of event
     */
    @POST
    @Path("/{machineId}/context/events")
    @Timed
    public Response submitEvent(@PathParam("machineId") String machineId,
                                @QueryParam("searchField") String searchField,
                                @QueryParam("triggerTime") Long triggerTime,
                                EventData eventData
    ) throws Exception {

        try {
            LoggingUtils.registerStateMachineIdForLogging(machineId);
            logger.info("Received event: {} for state machine: {}", eventData.getName(), machineId);
            StateMachine stateMachine = null;
            stateMachine = stateMachinesDAO.findById(machineId);
            if (stateMachine == null) {
                if (eventProxyEnabled.equalsIgnoreCase("yes")) {
                    logger.warn("StateMachine " + machineId + " not found in this cluster. Forwarding this event to the old cluster.");
                    if (triggerTime == null) {
                        try {
                            eventProxyConnector.submitEvent(eventData.getName(), eventData.getData(), machineId, eventData.getEventSource());
                        } catch (Exception ex) {
                            logger.error("Unable to forward event to old endpoint, error {}", ex.getStackTrace());
                        }

                    } else {
                        try {
                            eventProxyConnector.submitScheduledEvent(eventData.getName(), eventData.getData(), machineId, eventData.getEventSource(), triggerTime);
                        } catch (Exception ex) {
                            logger.error("Unable to forward scheduled event to old endpoint, error {}", ex.getStackTrace());
                        }
                    }
                    return Response.status(Response.Status.ACCEPTED.getStatusCode()).entity("State Machine with Id: " + machineId + " not found on this cluster. Forwarding the event to the old cluster").build();
                } else {
                    logger.error("StateMachine not found with id: {}, rejecting the event", machineId);
                    return Response.status(Response.Status.NOT_FOUND).entity("StateMachine not found with id: " + machineId + ", rejecting the event").build();
                }
            }

            if (stateMachine.getStatus() == StateMachineStatus.cancelled) {
                logger.info("Discarding event: {} as State machine: {} is in cancelled state", eventData.getName(), stateMachine.getId());
                return Response.status(Response.Status.ACCEPTED.getStatusCode()).entity("State machine with Id: " + stateMachine.getId() + " is in 'cancelled' state. Discarding the event.").build();
            }


            if (triggerTime == null) {
                logger.info("Received event: {} for state machine: {}", eventData.getName(), machineId);
                try {
                    if (eventData.getCancelled() != null && eventData.getCancelled()) {
                        workFlowExecutionController.handlePathCancellation(stateMachine.getId(), eventData);
                    } else {
                        workFlowExecutionController.postEvent(eventData, stateMachine.getId());
                    }
                    return Response.status(Response.Status.ACCEPTED).build();
                } catch (IllegalEventException ex) {
                    return Response.status(Response.Status.NOT_FOUND.getStatusCode()).entity(ex.getMessage()).build();
                }
            } else {
                logger.info("Received event: {} for state machine: {} with triggerTime: {}", eventData.getName(), machineId, triggerTime);
                if (searchField == null || !searchField.equals(CORRELATION_ID))
                    return Response.status(Response.Status.BAD_REQUEST).entity("searchField=correlationId is missing in the request").build();

                //if trigger time is more than below value, it means the value has been passed in milliseconds, convert it to seconds and register
                if (triggerTime > 9999999999L)
                    triggerTime = triggerTime / 1000;
                eventSchedulerRegistry.registerEvent(machineId, eventData.getName(), objectMapper.writeValueAsString(eventData), triggerTime);
                return Response.status(Response.Status.ACCEPTED).build();
            }
        } finally {
            LoggingUtils.deRegisterStateMachineIdForLogging();
        }
    }

    /**
     * Used to post Data corresponding to an event. This also updates the task status to completed which generated the event.
     *
     * @param machineId             machineId the event is to be submitted against
     * @param eventAndExecutionData Json representation of event and execution updation data
     */
    @POST
    @Path("/{machineId}/context/eventandstatus")
    @Timed
    public Response submitEvent(@PathParam("machineId") String machineId,
                                EventAndExecutionData eventAndExecutionData
    ) throws Exception {
        try {
            LoggingUtils.registerStateMachineIdForLogging(machineId);
            StateMachine stateMachine = null;
            stateMachine = stateMachinesDAO.findById(machineId);
            if (stateMachine == null) {
                return Response.status(Response.Status.NOT_FOUND.getStatusCode()).entity("State machine with Id: " + machineId + " not found").build();
            }
            EventData eventData = eventAndExecutionData.getEventData();
            ExecutionUpdateData executionUpdateData = eventAndExecutionData.getExecutionUpdateData();
            try {
                logger.info("Received event:{} from state: {} for state machine: {}", eventData.getName(), executionUpdateData.getTaskId(), machineId);
                if (eventData.getCancelled() != null && eventData.getCancelled()) {
                    workFlowExecutionController.updateTaskStatusAndHandlePathCancellation(machineId, eventAndExecutionData);
                } else {
                    workFlowExecutionController.updateTaskStatus(machineId, executionUpdateData.getTaskId(), executionUpdateData);
                    workFlowExecutionController.postEvent(eventData, stateMachine.getId());
                }
            } catch (IllegalEventException ex) {
                return Response.status(Response.Status.NOT_FOUND.getStatusCode()).entity(ex.getMessage()).build();
            }
        } finally {
            LoggingUtils.deRegisterStateMachineIdForLogging();
        }
        return Response.status(Response.Status.ACCEPTED).build();
    }


    /**
     * Update EventData of the specified Event name under the specified State machine
     *
     * @param machineId the state machine identifier
     * @return Response with execution status code
     * @throws Exception
     */
    @POST
    @Path("/{machineId}/context/eventupdate")
    @Timed
    public Response updateEvent(@PathParam("machineId") String machineId,
                                EventData eventData
    ) throws Exception {
        StateMachine stateMachine = stateMachinesDAO.findById(machineId);
        if (stateMachine == null) {
            logger.error("State Machine with input machineId {} doesn't exist.", machineId);
            return Response.status(Response.Status.NOT_FOUND.getStatusCode()).entity(
                    "State Machine with input machineId doesn't exist.").build();
        }
        try {
            LoggingUtils.registerStateMachineIdForLogging(machineId);
            if (eventData.getData() == null || eventData.getName() == null || eventData.getEventSource() == null) {
                return Response.status(Response.Status.BAD_REQUEST.getStatusCode()).entity(
                        "Event Data|Name|Source cannot be null.").build();
            }
            Event event = eventsDAO.findBySMIdAndName(machineId, eventData.getName());
            if (event == null) {
                logger.error("Event with input event Name {} doesn't exist.", eventData.getName());
                return Response.status(Response.Status.NOT_FOUND.getStatusCode()).entity(
                        "Event with input event Name doesn't exist.").build();
            }
            if (eventsDAO.findBySMIdAndName(machineId, eventData.getName()).getStatus() != Event.EventStatus.triggered) {
                return Response.status(Response.Status.FORBIDDEN.getStatusCode()).entity(
                        "Input event is not in triggered state.").build();
            }
            logger.info("Received event update request for event:{}", eventData.getName());
            /* List holds objects retrieved from States matching input machineId and eventName as [taskId, machineId, status] */
            List<Object[]> states = statesDAO.findStatesByDependentEvent(machineId, eventData.getName());
            if (validateEventUpdate(states)) {
                workFlowExecutionController.updateEventData(machineId, eventData);
                for (Object[] state : states) {
                    Status stateStatus = (Status) state[2];
                    if (stateStatus == Status.initialized || stateStatus == Status.errored || stateStatus == Status.sidelined) {
                        try {
                            workFlowExecutionController.unsidelineState((String) state[1], (Long) state[0]);
                        } catch (Exception ex) {
                            logger.warn("Unable to unsideline for stateId:{} msg:{}", state[0], ex.getMessage());
                        }
                    }
                }
                return Response.status(Response.Status.ACCEPTED).build();
            }
        } finally {
            LoggingUtils.deRegisterStateMachineIdForLogging();
        }
        return Response.status(Response.Status.CONFLICT.getStatusCode()).entity("Current stateMachine's state is not " +
                "eligible for this event's update, try after some time.").build();
    }

    public boolean validateEventUpdate(List<Object[]> states) {
        boolean canUpdateEventData = false;
        for (Object[] state : states) {
            Status status = (Status) state[2];
            if (status == Status.running) {
                canUpdateEventData = false;
                break;
            } else if (status == Status.initialized || status == Status.errored || status == Status.sidelined) {
                canUpdateEventData = true;
            }
        }
        return canUpdateEventData;
    }

    /**
     * Updates the status of the specified Task under the specified State machine
     *
     * @param machineId the state machine identifier
     * @param stateId   the task/state identifier
     * @return Response with execution status code
     * @throws Exception
     */
    @POST
    @Path("/{machineId}/{stateId}/status")
    @Timed
    public Response updateStatus(@PathParam("machineId") String machineId,
                                 @PathParam("stateId") Long stateId,
                                 ExecutionUpdateData executionUpdateData
    ) throws Exception {
        this.workFlowExecutionController.updateTaskStatus(machineId, stateId, executionUpdateData);
        return Response.status(Response.Status.ACCEPTED).build();
    }


    /**
     * Increments the retry count for the specified Task under the specified State machine
     *
     * @param machineId the state machine identifier
     * @param stateId   the task/state identifier
     * @return Response with execution status code
     * @throws Exception
     */
    @POST
    @Path("/{machineId}/{stateId}/retries/inc")
    @Transactional
    @SelectDataSource(type = DataSourceType.READ_WRITE, storage = Storage.SHARDED)
    public Response incrementRetry(@PathParam("machineId") String machineId,
                                   @PathParam("stateId") Long stateId
    ) throws Exception {
        this.workFlowExecutionController.incrementExecutionRetries(machineId, stateId);
        return Response.status(Response.Status.ACCEPTED).build();
    }

    /**
     * Triggers task execution if the task is stalled and no.of retries are not exhausted.
     *
     * @param taskId the task/state identifier
     */
    @POST
    @Produces(MediaType.APPLICATION_JSON)
    @Path("/redrivetask/{machineId}/taskId/{taskId}")
    @Timed
    public Response redriveTask(@PathParam("machineId") String machineId, @PathParam("taskId") Long taskId) throws Exception {

        this.workFlowExecutionController.redriveTask(machineId, taskId);

        return Response.status(Response.Status.ACCEPTED).build();
    }

    /**
     * Provides json data to build fsm status graph.
     *
     * @param machineId
     * @return json representation of fsm
     * @throws JsonProcessingException
     */
    @GET
    @Path("/{machineId}/fsmdata")
    @Produces(MediaType.APPLICATION_JSON)
    public Response getFsmGraphData(@PathParam("machineId") String machineId) throws IOException {
        return Response.status(200).entity(getGraphData(machineId)).build();
    }

    /**
     * Retrieves all errored states for the given range of time for a particular state machine name.
     *
     * @param fromTime starting time for the range
     * @param toTime   ending time (inclusive)
     * @return json containing list of [state machine id,  state id, status]
     */
    @GET
    @Path("/{stateMachineName}/states/errored")
    @Produces(MediaType.APPLICATION_JSON)
    public Response getErroredStates(@PathParam("stateMachineName") String stateMachineName,
                                     @QueryParam("fromTime") String fromTime,
                                     @QueryParam("toTime") String toTime) {
        if (fromTime == null || toTime == null) {
            return Response.status(Response.Status.BAD_REQUEST).entity("Required params fromTime/toTime are not provided").build();
        }

        Timestamp fromTimestamp = Timestamp.valueOf(fromTime);
        Timestamp toTimestamp = Timestamp.valueOf(toTime);

        if (fromTimestamp.after(toTimestamp)) {
            return Response.status(Response.Status.BAD_REQUEST).entity("fromTime: " + fromTime + " should be before toTime: " + toTime).build();
        }

        return Response.status(200).entity(parallelScatterGatherQueryHelper.findErroredStates(stateMachineName, fromTimestamp, toTimestamp)).build();
    }

    /**
     * Retrieves all states for the given range of time for a particular state machine name.
     * Will also filter by status if it is given.
     *
     * @return json containing list of [state machine id, state id, status]
     */
    @GET
    @Path("/{stateMachineName}/states/listbytime")
    @Produces(MediaType.APPLICATION_JSON)
    public Response getStatesByTime(@PathParam("stateMachineName") String stateMachineName,
                                    @QueryParam("fromTime") String fromTime,
                                    @QueryParam("toTime") String toTime,
                                    @QueryParam("stateName") String stateName,
                                    @QueryParam("statuses") final List<String> statusStrings) throws Exception {
        if (fromTime == null || toTime == null) {
            return Response.status(Response.Status.BAD_REQUEST).entity("Required params fromTime/toTime are not provided").build();
        }

        Timestamp fromTimestamp = Timestamp.valueOf(fromTime);
        Timestamp toTimestamp = Timestamp.valueOf(toTime);

        if (fromTimestamp.after(toTimestamp)) {
            return Response.status(Response.Status.BAD_REQUEST).entity("fromTime: " + fromTime + " should be before toTime: " + toTime).build();
        }
        List<Status> statuses = new ArrayList<Status>();
        if (statusStrings != null && !statusStrings.isEmpty()) {
            for (String status : statusStrings) {
                try {
                    statuses.add(Status.valueOf(status));
                } catch (IllegalArgumentException e) {
                    return Response.status(Response.Status.BAD_REQUEST).entity("status: " + status + " must be one of initialized, running, completed, cancelled, errored, sidelined, unsidelined").build();
                }
            }
        }
        return Response.status(200).entity(parallelScatterGatherQueryHelper.findStatesByStatus(stateMachineName, fromTimestamp, toTimestamp, stateName, statuses)).build();
    }

    /**
     * This api unsidelines a single state and triggers its execution.
     *
     * @param stateMachineId
     * @param stateId
     * @return
     */
    @PUT
    @Path("/{stateMachineId}/{stateId}/unsideline")
    @Produces(MediaType.APPLICATION_JSON)
    @Transactional
    @SelectDataSource(type = DataSourceType.READ_WRITE, storage = Storage.SHARDED)
    public Response unsidelineState(@PathParam("stateMachineId") String stateMachineId, @PathParam("stateId") Long stateId) {

        workFlowExecutionController.unsidelineState(stateMachineId, stateId);
        return Response.status(Response.Status.ACCEPTED).build();
    }

    @PUT
    @Path("/{stateMachineId}/cancel")
    @Produces(MediaType.APPLICATION_JSON)
    @Transactional
    @SelectDataSource(type = DataSourceType.READ_WRITE, storage = Storage.SHARDED)
    public Response cancelWorkflow(@PathParam("stateMachineId") String stateMachineId,
                                   @QueryParam("searchField") String searchField) {

        StateMachine stateMachine = null;
        stateMachine = stateMachinesDAO.findById(stateMachineId);

        if (stateMachine == null) {
            return Response.status(Response.Status.NOT_FOUND).entity("State machine with id: " + stateMachineId + " not found").build();
        }

        workFlowExecutionController.cancelWorkflow(stateMachine);

        return Response.status(Response.Status.ACCEPTED).build();
    }

    @GET
    @Path("/{stateMachineId}/info")
    @Produces(MediaType.APPLICATION_JSON)
    @Transactional
    @SelectDataSource(type = DataSourceType.READ_WRITE, storage = Storage.SHARDED)
    public Response getStateMachine(@PathParam("stateMachineId") String stateMachineId,
                                    @QueryParam("searchField") String searchField) {
        StateMachine stateMachine = null;
        stateMachine = stateMachinesDAO.findById(stateMachineId);

        if (stateMachine == null) {
            return Response.status(Response.Status.NOT_FOUND).entity("State machine with id: " + stateMachineId + " not found").build();
        }

        List<Event> events = eventsDAO.findBySMInstanceId(stateMachine.getId());
        List<AuditRecord> auditRecords = auditDAO.findBySMInstanceId(stateMachine.getId());

        Map<String, Object> stateMachineInfo = objectMapper.convertValue(stateMachine, Map.class);
        stateMachineInfo.put("events", events);
        stateMachineInfo.put("auditrecords", auditRecords);

        return Response.status(Response.Status.OK).entity(stateMachineInfo).build();
    }

    /**
     * Retrieves fsm graph data based on FSM Id or correlation id
     */
    private FsmGraph getGraphData(String machineId) throws IOException {
        String fsmId = machineId;

        StateMachine stateMachine;
        stateMachine = stateMachinesDAO.findById(fsmId);

        if (stateMachine == null) {
            throw new WebApplicationException(Response.status(Response.Status.NOT_FOUND).entity("State machine with Id: " + machineId + " not found").build());
        }
        final FsmGraph fsmGraph = new FsmGraph();

        List<Long> erroredStateIds = new LinkedList<>();

        for (State state : stateMachine.getStates()) {
            if (state.getStatus() == Status.errored || state.getStatus() == Status.sidelined) {
                erroredStateIds.add(state.getId());
            }
        }

        Collections.sort(erroredStateIds);
        fsmGraph.setErroredStateIds(erroredStateIds);
        fsmGraph.setStateMachineId(stateMachine.getId());
        fsmGraph.setFsmVersion(stateMachine.getVersion());
        fsmGraph.setFsmName(stateMachine.getName());
        fsmGraph.setFsmStatus(stateMachine.getStatus());

        Map<String, Event> stateMachineEvents = eventsDAO.findBySMInstanceId(stateMachine.getId()).stream().collect(
                Collectors.<Event, String, Event>toMap(Event::getName, (event -> event)));
        Set<String> allOutputEventNames = new HashSet<>();

        final RAMContext ramContext = new RAMContext(System.currentTimeMillis(), null, stateMachine);
        /* After this operation, we'll have nodes for each state and its corresponding output event along with the output event's dependencies mapped out*/
        for (State state : stateMachine.getStates()) {
            final FsmGraphVertex vertex = new FsmGraphVertex(state.getId(), this.getStateDisplayName(state.getName()), state.getStatus().name());
            if (state.getOutputEvent() != null) {
                EventDefinition eventDefinition = objectMapper.readValue(state.getOutputEvent(), EventDefinition.class);
                final Event outputEvent = stateMachineEvents.get(eventDefinition.getName());
                fsmGraph.addVertex(vertex,
                        new FsmGraphEdge(getEventDisplayName(outputEvent.getName()), outputEvent.getStatus().name(), outputEvent.getEventSource(), outputEvent.getEventData(), outputEvent.getUpdatedAt()));
                final Set<State> dependantStates = ramContext.getDependantStates(outputEvent.getName());
                dependantStates.forEach((aState) -> fsmGraph.addOutgoingEdge(vertex, aState.getId()));
                allOutputEventNames.add(outputEvent.getName()); // we collect all output event names and use them below.
            } else {
                fsmGraph.addVertex(vertex,
                        new FsmGraphEdge(null, null, null, null, null));
            }
        }

        /* Handle states with no dependencies, i.e the states that can be triggered as soon as we execute the state machine */
        final Set<State> initialStates = ramContext.getInitialStates(Collections.emptySet());// hackety hack.  We're fooling the context to give us only events that depend on nothing
        if (!initialStates.isEmpty()) {
            final FsmGraphEdge initEdge = new FsmGraphEdge(TRIGGER, Event.EventStatus.triggered.name(), TRIGGER, null, null);
            initialStates.forEach((state) -> {
                initEdge.addOutgoingVertex(state.getId());
            });
            fsmGraph.addInitStateEdge(initEdge);
        }
        /* Now we handle events that were not "output-ed" by any state, which means that they were given to the workflow at the time of invocation or supplied externally*/
        final HashSet<String> eventsGivenOnWorkflowTrigger = new HashSet<>(stateMachineEvents.keySet());
        eventsGivenOnWorkflowTrigger.removeAll(allOutputEventNames);
        eventsGivenOnWorkflowTrigger.forEach((workflowTriggeredEventName) -> {
            final Event correspondingEvent = stateMachineEvents.get(workflowTriggeredEventName);
            final FsmGraphEdge initEdge = new FsmGraphEdge(this.getEventDisplayName(workflowTriggeredEventName), correspondingEvent.getStatus().name(), correspondingEvent.getEventSource(), correspondingEvent.getEventData(), correspondingEvent.getUpdatedAt());
            final Set<State> dependantStates = ramContext.getDependantStates(workflowTriggeredEventName);
            dependantStates.forEach((state) -> initEdge.addOutgoingVertex(state.getId()));
            fsmGraph.addInitStateEdge(initEdge);
        });

        fsmGraph.setAuditData(auditDAO.findBySMInstanceId(stateMachine.getId()));

        return fsmGraph;
    }

    /**
     * Helper method to return a display friendly name for the specified event name.
     * Returns just the name part from the Event FQN
     */
    private String getEventDisplayName(String eventName) {
        return (eventName == null) ? null : this.getDisplayName(eventName.substring(eventName.lastIndexOf(".") + 1));
    }

    /**
     * Helper method to return a display friendly name for state names
     * Returns {@link #getDisplayName(String)}
     */
    private String getStateDisplayName(String stateName) {
        return this.getDisplayName(stateName);
    }

    /**
     * Helper method to return a display friendly name for the specified label.
     * Returns a phrase containing single-space separated words that were split at Camel Case boundaries
     */
    private String getDisplayName(String label) {
        if (label == null) {
            return null;
        }
        String words = label.replaceAll( // Based on http://stackoverflow.com/questions/2559759/how-do-i-convert-camelcase-into-human-readable-names-in-java
                String.format("%s|%s|%s",
                        "(?<=[A-Z])(?=[A-Z][a-z])",
                        "(?<=[^A-Z])(?=[A-Z])",
                        "(?<=[A-Za-z])(?=[^A-Za-z])"
                ), " ");
        StringBuffer sb = new StringBuffer();
        for (String s : words.split(" ")) {
            sb.append(Character.toUpperCase(s.charAt(0)));
            if (s.length() > 1) {
                sb.append(s.substring(1, s.length()).toLowerCase());
                sb.append(" "); // add the single space back. Used for wrapping words onto next line in the display
            }
        }
        return sb.toString().trim();
    }

}