/*
 * Copyright 2006-2019 DLR, Germany
 * 
 * SPDX-License-Identifier: EPL-1.0
 * 
 * http://www.rcenvironment.de/
 */

package de.rcenvironment.core.component.execution.internal;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Queue;
import java.util.Set;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import de.rcenvironment.core.component.datamanagement.api.ComponentHistoryDataItem;
import de.rcenvironment.core.component.execution.api.Component;
import de.rcenvironment.core.component.execution.api.ComponentContext;
import de.rcenvironment.core.component.execution.api.ComponentExecutionException;
import de.rcenvironment.core.component.execution.api.ComponentExecutionIdentifier;
import de.rcenvironment.core.component.execution.api.ComponentState;
import de.rcenvironment.core.component.execution.api.ConsoleRow;
import de.rcenvironment.core.component.execution.api.ConsoleRow.Type;
import de.rcenvironment.core.component.execution.api.WorkflowGraphHop;
import de.rcenvironment.core.component.model.endpoint.api.EndpointDatum;
import de.rcenvironment.core.component.model.endpoint.api.EndpointDescription;
import de.rcenvironment.core.datamodel.api.DataType;
import de.rcenvironment.core.datamodel.api.DataTypeException;
import de.rcenvironment.core.datamodel.api.TypedDatum;
import de.rcenvironment.core.datamodel.api.TypedDatumConverter;
import de.rcenvironment.core.datamodel.api.TypedDatumService;
import de.rcenvironment.core.datamodel.types.api.NotAValueTD;
import de.rcenvironment.core.utils.common.StringUtils;

/**
 * Processes method callbacks from {@link ComponentContext} performed by the {@link Component} during execution.
 *
 * @author Doreen Seider
 */
public class ComponentContextBridge {

    private static final String NOT_CONVERTIBLE_MESSAGE = "Datum of type '%s' is not convertible to data type '%s' expected by input '%s'";

    private static final Log LOG = LogFactory.getLog(ComponentContextBridge.class);

    private static TypedDatumService typedDatumService;

    private final Map<String, DataType> inputDataTypes = new HashMap<>();

    private ComponentExecutionRelatedInstances compExeRelatedInstances;

    private Map<String, EndpointDatum> endpointDatumsForExecution = Collections.synchronizedMap(new HashMap<String, EndpointDatum>());

    private Map<String, Boolean> closedOutputs = Collections.synchronizedMap(new HashMap<String, Boolean>());

    private List<OutputHolder> outputsOnHold = Collections.synchronizedList(new ArrayList<OutputHolder>());

    private final Object historyDataLock = new Object();

    @Deprecated
    public ComponentContextBridge() {}

    public ComponentContextBridge(ComponentExecutionRelatedInstances compExeRelatedInstances) {
        this.compExeRelatedInstances = compExeRelatedInstances;
        for (EndpointDescription epDesc : compExeRelatedInstances.compExeCtx.getComponentDescription().getInputDescriptionsManager()
            .getEndpointDescriptions()) {
            inputDataTypes.put(epDesc.getName(), epDesc.getDataType());
        }
    }

    /**
     * Writes given {@link TypedDatum} to the output. If the output is connected to inputs, the {@link TypedDatum} will be delivered to all
     * inputs.
     * 
     * @param outputName name of the output to write
     * @param datumToSend {@link TypedDatum} to be sent
     */
    public synchronized void writeOutput(String outputName, TypedDatum datumToSend) {
        EndpointDescription endpointDescription =
            compExeRelatedInstances.compExeCtx.getComponentDescription().getOutputDescriptionsManager()
                .getEndpointDescription(outputName);
        Long outputDmId = null;
        try {
            outputDmId = compExeRelatedInstances.compExeStorageBridge.addOutput(outputName,
                typedDatumService.getSerializer().serialize(datumToSend));
        } catch (ComponentExecutionException e) {
            throw new RuntimeException(e);
        }
        if (endpointDescription.isConnected()) {
            validateOutputDataType(outputName, endpointDescription.getDataType(), datumToSend);
            if (ComponentExecutionUtils.isManualOutputVerificationRequired(
                compExeRelatedInstances.compExeCtx.getComponentDescription().getConfigurationDescription())) {
                holdOutput(outputName, datumToSend, outputDmId);
            } else {
                sendOutput(outputName, datumToSend, outputDmId);
            }
        }
        closedOutputs.put(outputName, false);
    }

    private void sendOutput(String outputName, TypedDatum datumToSend, Long outputDmId) {
        compExeRelatedInstances.typedDatumToOutputWriter.writeTypedDatumToOutput(outputName, datumToSend, outputDmId);
        if (datumToSend.getDataType().equals(DataType.NotAValue)) {
            compExeRelatedInstances.compExeScheduler.addNotAValueDatumSent(((NotAValueTD) datumToSend).getIdentifier());
        }
    }

    protected void holdOutput(String outputName, TypedDatum datumToSend, Long outputDmId) {
        outputsOnHold.add(new OutputHolder(outputName, datumToSend, outputDmId));
    }

    protected void flushOutputs() {
        // note: iterating over synchronized lists must be synchronized explicitly
        synchronized (outputsOnHold) {
            for (OutputHolder outputHolder : outputsOnHold) {
                sendOutput(outputHolder.outputName, outputHolder.datumToSend, outputHolder.outputDmId);
            }
        }
    }

    protected void setEndpointDatumsForExecution(Map<String, EndpointDatum> endpointDatumsForExecution) throws ComponentExecutionException {
        this.endpointDatumsForExecution.clear();
        this.endpointDatumsForExecution.putAll(endpointDatumsForExecution);
        validateEndpointDatumsForExecution();
    }

    protected Map<String, EndpointDatum> getEndpointDatumsForExecution() {
        return Collections.unmodifiableMap(endpointDatumsForExecution);
    }

    private void validateIfNestedLoopComponent() {
        if (!compExeRelatedInstances.isNestedLoopDriver) {
            throw new RuntimeException(getLogMessagesPrefix() + StringUtils.format(
                "Received reset datum for a non nested loop component. "
                    + "Reset datums are only allowed to send by nested loop components."));
        }
    }

    private void validateEndpointDatumsForExecution() throws ComponentExecutionException {
        for (EndpointDatum endpointDatum : endpointDatumsForExecution.values()) {
            DataType inputDataType = inputDataTypes.get(endpointDatum.getInputName());
            if (endpointDatum.getValue().getDataType() != inputDataType && endpointDatum.getValue().getDataType() != DataType.NotAValue) {
                if (!typedDatumService.getConverter().isConvertibleTo(endpointDatum.getValue(), inputDataType)) {
                    throw new ComponentExecutionException(
                        StringUtils.format(NOT_CONVERTIBLE_MESSAGE,
                            endpointDatum.getValue().getDataType(), inputDataType, endpointDatum.getInputName()));
                }
            }
        }
    }

    private void validateOutputDataType(String outputName, DataType dataType, TypedDatum datumToSent) {
        if (!datumToSent.getDataType().equals(DataType.NotAValue) && !datumToSent.getDataType().equals(DataType.Internal)) {
            if (datumToSent.getDataType() != dataType) {
                TypedDatumConverter converter = typedDatumService.getConverter();
                if (converter.isConvertibleTo(datumToSent, dataType)) {
                    try {
                        datumToSent = converter.castOrConvert(datumToSent, dataType);
                    } catch (DataTypeException e) {
                        // should not be reached because of isConvertibleTo check before
                        throw new RuntimeException(getLogMessagesPrefix() + StringUtils.format("Failed to convert "
                            + "the value for output '" + outputName + "' from type " + datumToSent.getDataType()
                            + " to required data type " + dataType));
                    }
                } else {
                    throw new RuntimeException(getLogMessagesPrefix() + StringUtils.format("Value for output '" + outputName
                        + "' has invalid data type. Output requires " + dataType + " or a convertible one, but it is of type "
                        + datumToSent.getDataType()));
                }
            }
        }
    }

    /**
     * @return all inputs with a value. I.e. {@link #readInput(String)} will be return a {@link TypedDatum}.
     */
    public Set<String> getInputsWithDatum() {
        return new HashSet<>(endpointDatumsForExecution.keySet());
    }

    /**
     * Reads input with the given name.
     * 
     * @param inputName name of the input to read
     * @return {@link TypedDatum} currently available at the input
     * @throws NoSuchElementException if there is no {@link TypedDatum} available
     */
    public TypedDatum readInput(String inputName) {
        if (endpointDatumsForExecution.containsKey(inputName)) {
            final EndpointDatum endpointDatum = endpointDatumsForExecution.get(inputName);
            DataType inputDataType = inputDataTypes.get(endpointDatum.getInputName());

            if (endpointDatum.getValue().getDataType() == inputDataType || endpointDatum.getValue().getDataType() == DataType.NotAValue) {
                return endpointDatum.getValue();
            } else {
                try {
                    return typedDatumService.getConverter().castOrConvert(endpointDatum.getValue(), inputDataType);
                } catch (DataTypeException e) { // should not happen due to early validation by validateEndpointDatumsForExecution()
                    throw new RuntimeException(
                        StringUtils.format(NOT_CONVERTIBLE_MESSAGE, endpointDatum.getValue().getDataType(), inputDataType, inputName));
                }
            }
        } else {
            throw new NoSuchElementException(getLogMessagesPrefix() + StringUtils.format("No datum at input '%s'", inputName));
        }
    }

    /**
     * Prints given line to the workflow console. It is temporarily also used to send timeline events.
     * 
     * @param line line to print
     * @param consoleRowType type of the console row. Must be one of {@link ConsoleRow.Type.STDOUT} or {@link ConsoleRow.Type.STDERR} are
     *        allowed
     */
    public void printConsoleRow(String line, Type consoleRowType) {
        if (consoleRowType.equals(Type.LIFE_CYCLE_EVENT)) {
            compExeRelatedInstances.consoleRowsSender.sendTimelineEventAsConsoleRow(consoleRowType, line);
        } else {
            compExeRelatedInstances.consoleRowsSender.sendLogMessageAsConsoleRow(consoleRowType, line,
                compExeRelatedInstances.compExeRelatedStates.executionCount.get());
        }
    }

    /**
     * Closes all outputs.
     */
    public void closeAllOutputs() {
        for (EndpointDescription output : compExeRelatedInstances.compExeCtx.getComponentDescription()
            .getOutputDescriptionsManager().getEndpointDescriptions()) {
            closeOutput(output.getName());
        }
    }

    /**
     * Closes an output with given name.
     * 
     * @param outputName name of output to close.
     */
    public synchronized void closeOutput(String outputName) {
        if (!closedOutputs.containsKey(outputName) || !closedOutputs.get(outputName)) {
            closedOutputs.put(outputName, true);
            compExeRelatedInstances.typedDatumToOutputWriter.writeTypedDatumToOutput(outputName,
                new InternalTDImpl(InternalTDImpl.InternalTDType.WorkflowFinish));
        } else {
            printConsoleRow(StringUtils.format("Output '%s' already closed. "
                + "Ignored further closing request.", outputName), ConsoleRow.Type.COMPONENT_WARN);
        }
    }

    /**
     * @param outputName name of output
     * @return <code>true</code> if output is closed, otherwise <code>false</code>
     */
    public synchronized boolean isOutputClosed(String outputName) {
        return closedOutputs.containsKey(outputName) && closedOutputs.get(outputName);
    }

    /**
     * Resets an output with given name.
     */
    public void resetOutputs() {
        validateIfNestedLoopComponent();
        writeResetOutputData();
    }

    /**
     * Calculates for a sequence of {@link WorkflowGraphHop}s, if they form a circle.
     */
    private boolean circular(Queue<WorkflowGraphHop> hops) {

        WorkflowGraphHop firstElement = hops.peek();
        WorkflowGraphHop lastElement = null;

        for (WorkflowGraphHop hop : hops) {
            lastElement = hop;
        }

        if (lastElement == null) {
            return false;
        }

        return firstElement.getHopExecutionIdentifier().equals(lastElement.getTargetExecutionIdentifier());
    }

    /**
     * This method should only be called, if the given output has the SAME LOOP characteristic.
     */
    private void writeResetOutputData() {

        Set<Deque<WorkflowGraphHop>> hopsSet = compExeRelatedInstances.compExeCtx.getWorkflowGraph()
            .getHopsToTraverseWhenResetting(new ComponentExecutionIdentifier(compExeRelatedInstances.compExeCtx.getExecutionIdentifier()));
        
        for (Queue<WorkflowGraphHop> hops : hopsSet) {
            boolean circular = circular(hops);

            WorkflowGraphHop firstHop = hops.poll();
            InternalTDImpl resetDatum = new InternalTDImpl(InternalTDImpl.InternalTDType.NestedLoopReset, hops);

            // only the circular reset signal should be added here, as this triggers the reset of the loop driver itself when it arrives
            // again at the loop driver
            if (circular) {
                compExeRelatedInstances.compExeScheduler.addResetDataIdSent(resetDatum.getIdentifier());
            }

            compExeRelatedInstances.typedDatumToOutputWriter.writeTypedDatumToOutputConsideringOnlyCertainInputs(
                firstHop.getHopOuputName(), resetDatum,
                firstHop.getTargetExecutionIdentifier().toString(), firstHop.getTargetInputName());
        }
    }

    /**
     * @return current execution count of the component. Count starts with 1. It is 1 within {@link Component#start(ComponentContext)} and
     *         is 1 within {@link Component#processInputs()} if {@link Component#start(ComponentContext)} returns <code>false</code> or 2
     *         otherwise.
     */
    public int getExecutionCount() {
        return compExeRelatedInstances.compExeRelatedStates.executionCount.get();
    }

    /**
     * Writes intermediate history data. Each new intermediate history data will overwrite a previous one.
     * 
     * @param compHistoryDataItem {@link ComponentHistoryDataItem} to write
     */
    public void writeIntermediateHistoryData(ComponentHistoryDataItem compHistoryDataItem) {
        if (writeHistoryDataItem(compHistoryDataItem)) {
            compExeRelatedInstances.compExeRelatedStates.intermediateHistoryDataWritten.set(true);
        }
    }

    /**
     * Writes final history data. It will overwrite any intermediate ones.
     * 
     * @param compHistoryDataItem {@link ComponentHistoryDataItem} to write
     */
    public void writeFinalHistoryDataItem(ComponentHistoryDataItem compHistoryDataItem) {
        synchronized (historyDataLock) {
            if (writeHistoryDataItem(compHistoryDataItem)) {
                compExeRelatedInstances.compExeRelatedStates.finalHistoryDataItemWritten.set(true);
            }
        }
    }

    /**
     * @return data management id of latest component execution
     */
    public Long getComponentExecutionDataManagementId() {
        ComponentState compState = compExeRelatedInstances.compStateMachine.getState();
        if ((compState.equals(ComponentState.STARTING) || compState.equals(ComponentState.PROCESSING_INPUTS))
            && compExeRelatedInstances.compExeRelatedStates.isComponentCancelled.get()) {
            return null;
        }
        return compExeRelatedInstances.compExeStorageBridge.getComponentExecutionDataManagementId();
    }

    private boolean writeHistoryDataItem(ComponentHistoryDataItem componentHistoryDataItem) {
        if (componentHistoryDataItem == null) {
            printConsoleRow("Failed to store additional workflow data, because data item was 'null'"
                + " which is a developer error", Type.COMPONENT_ERROR);
            LOG.error(StringUtils.format("Failed to store history data item for component '%s' (%s) of workflow '%s' (%s)"
                + " because it was null", compExeRelatedInstances.compExeCtx.getInstanceName(),
                compExeRelatedInstances.compExeCtx.getExecutionIdentifier(),
                compExeRelatedInstances.compExeCtx.getWorkflowInstanceName(),
                compExeRelatedInstances.compExeCtx.getWorkflowExecutionIdentifier()));
            return false;
        }
        try {
            compExeRelatedInstances.compExeStorageBridge.setOrUpdateHistoryDataItem(componentHistoryDataItem.serialize(
                typedDatumService.getSerializer()));
            return true;
        } catch (IOException | ComponentExecutionException e) {
            throw new RuntimeException(e);
        }

    }

    private String getLogMessagesPrefix() {
        return StringUtils.format("Component '%s' (%s) of workflow '%s' (%s): ",
            compExeRelatedInstances.compExeCtx.getInstanceName(),
            compExeRelatedInstances.compExeCtx.getExecutionIdentifier(),
            compExeRelatedInstances.compExeCtx.getWorkflowInstanceName(),
            compExeRelatedInstances.compExeCtx.getWorkflowExecutionIdentifier());
    }

    /**
     * Holder class for outputs that are on hold because verification needs to be requested first.
     * 
     * @author Doreen Seider
     */
    class OutputHolder {

        private final String outputName;

        private final TypedDatum datumToSend;

        private final Long outputDmId;

        OutputHolder(String outputName, TypedDatum datumToSend, Long outputDmId) {
            this.outputName = outputName;
            this.datumToSend = datumToSend;
            this.outputDmId = outputDmId;
        }
    }

    protected void bindTypedDatumService(TypedDatumService newService) {
        ComponentContextBridge.typedDatumService = newService;
    }

}
