/*
 * 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.sysds.hops.fedplanner;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.sysds.api.DMLScript;
import org.apache.sysds.common.Types;
import org.apache.sysds.hops.fedplanner.FTypes.FType;
import org.apache.sysds.hops.DataOp;
import org.apache.sysds.hops.FunctionOp;
import org.apache.sysds.hops.Hop;
import org.apache.sysds.hops.OptimizerUtils;
import org.apache.sysds.hops.cost.HopRel;
import org.apache.sysds.hops.ipa.FunctionCallGraph;
import org.apache.sysds.hops.ipa.FunctionCallSizeInfo;
import org.apache.sysds.hops.rewrite.HopRewriteUtils;
import org.apache.sysds.parser.DMLProgram;
import org.apache.sysds.parser.DataIdentifier;
import org.apache.sysds.parser.ForStatement;
import org.apache.sysds.parser.ForStatementBlock;
import org.apache.sysds.parser.FunctionStatement;
import org.apache.sysds.parser.FunctionStatementBlock;
import org.apache.sysds.parser.IfStatement;
import org.apache.sysds.parser.IfStatementBlock;
import org.apache.sysds.parser.Statement;
import org.apache.sysds.parser.StatementBlock;
import org.apache.sysds.parser.WhileStatement;
import org.apache.sysds.parser.WhileStatementBlock;
import org.apache.sysds.runtime.DMLRuntimeException;
import org.apache.sysds.runtime.controlprogram.LocalVariableMap;
import org.apache.sysds.runtime.controlprogram.caching.CacheableData;
import org.apache.sysds.runtime.controlprogram.federated.FederationMap;
import org.apache.sysds.runtime.instructions.cp.Data;
import org.apache.sysds.runtime.instructions.cp.IntObject;
import org.apache.sysds.runtime.instructions.fed.FEDInstruction.FederatedOutput;
import org.apache.sysds.utils.Explain;
import org.apache.sysds.utils.Explain.ExplainType;

public class FederatedPlannerCostbased extends AFederatedPlanner {
	private static final Log LOG = LogFactory.getLog(FederatedPlannerCostbased.class.getName());

	private final MemoTable hopRelMemo = new MemoTable();
	/**
	 * IDs of hops for which the final fedout value has been set.
	 */
	private final Set<Long> hopRelUpdatedFinal = new HashSet<>();
	/**
	 * Terminal hops in DML program given to this rewriter.
	 */
	private final List<Hop> terminalHops = new ArrayList<>();
	private final Map<String, Hop> transientWrites = new HashMap<>();
	private LocalVariableMap localVariableMap = new LocalVariableMap();

	public List<Hop> getTerminalHops(){
		return terminalHops;
	}

	@Override
	public void rewriteProgram( DMLProgram prog, FunctionCallGraph fgraph, FunctionCallSizeInfo fcallSizes ) {
		enumeratePlans(prog);
		selectPlan();
		updateExplain();
		FederatedCompilationTimer.activate();
	}

	private void enumeratePlans(DMLProgram prog){
		FederatedCompilationTimer.startEnumerationTimer();
		prog.updateRepetitionEstimates();
		rewriteStatementBlocks(prog, prog.getStatementBlocks(), null);
		FederatedCompilationTimer.stopEnumerationTimer();
	}

	private void selectPlan(){
		FederatedCompilationTimer.startSelectPlanTimer();
		setFinalFedouts();
		FederatedCompilationTimer.stopSelectPlanTimer();
	}

	@Override
	public void rewriteFunctionDynamic(FunctionStatementBlock function, LocalVariableMap funcArgs) {
		localVariableMap = funcArgs;
		rewriteStatementBlock(function.getDMLProg(), function, null);
		setFinalFedouts();
		updateExplain();
	}

	/**
	 * Estimates cost and enumerates federated execution plans in hopRelMemo.
	 * The method calls the contained statement blocks recursively.
	 *
	 * @param prog dml program
	 * @param sbs  list of statement blocks
	 * @param paramMap map of parameters in function call
	 * @return list of statement blocks with the federated output value updated for each hop
	 */
	private ArrayList<StatementBlock> rewriteStatementBlocks(DMLProgram prog, List<StatementBlock> sbs, Map<String, Hop> paramMap) {
		ArrayList<StatementBlock> rewrittenStmBlocks = new ArrayList<>();
		for(StatementBlock stmBlock : sbs)
			rewrittenStmBlocks.addAll(rewriteStatementBlock(prog, stmBlock, paramMap));
		return rewrittenStmBlocks;
	}

	/**
	 * Estimates cost and enumerates federated execution plans in hopRelMemo.
	 * The method calls the contained statement blocks recursively.
	 *
	 * @param prog dml program
	 * @param sb   statement block
	 * @param paramMap map of parameters in function call
	 * @return list of statement blocks with the federated output value updated for each hop
	 */
	public ArrayList<StatementBlock> rewriteStatementBlock(DMLProgram prog, StatementBlock sb, Map<String, Hop> paramMap) {
		if(sb instanceof WhileStatementBlock)
			return rewriteWhileStatementBlock(prog, (WhileStatementBlock) sb, paramMap);
		else if(sb instanceof IfStatementBlock)
			return rewriteIfStatementBlock(prog, (IfStatementBlock) sb, paramMap);
		else if(sb instanceof ForStatementBlock) {
			// This also includes ParForStatementBlocks
			return rewriteForStatementBlock(prog, (ForStatementBlock) sb, paramMap);
		}
		else if(sb instanceof FunctionStatementBlock)
			return rewriteFunctionStatementBlock(prog, (FunctionStatementBlock) sb, paramMap);
		else {
			// StatementBlock type (no subclass)
			return rewriteDefaultStatementBlock(prog, sb, paramMap);
		}
	}

	private ArrayList<StatementBlock> rewriteWhileStatementBlock(DMLProgram prog, WhileStatementBlock whileSB, Map<String, Hop> paramMap) {
		Hop whilePredicateHop = whileSB.getPredicateHops();
		selectFederatedExecutionPlan(whilePredicateHop, paramMap);
		for(Statement stm : whileSB.getStatements()) {
			WhileStatement whileStm = (WhileStatement) stm;
			whileStm.setBody(rewriteStatementBlocks(prog, whileStm.getBody(), paramMap));
		}
		return new ArrayList<>(Collections.singletonList(whileSB));
	}

	private ArrayList<StatementBlock> rewriteIfStatementBlock(DMLProgram prog, IfStatementBlock ifSB, Map<String, Hop> paramMap) {
		selectFederatedExecutionPlan(ifSB.getPredicateHops(), paramMap);
		for(Statement statement : ifSB.getStatements()) {
			IfStatement ifStatement = (IfStatement) statement;
			ifStatement.setIfBody(rewriteStatementBlocks(prog, ifStatement.getIfBody(), paramMap));
			ifStatement.setElseBody(rewriteStatementBlocks(prog, ifStatement.getElseBody(), paramMap));
		}
		return new ArrayList<>(Collections.singletonList(ifSB));
	}

	private ArrayList<StatementBlock> rewriteForStatementBlock(DMLProgram prog, ForStatementBlock forSB, Map<String, Hop> paramMap) {
		selectFederatedExecutionPlan(forSB.getFromHops(), paramMap);
		selectFederatedExecutionPlan(forSB.getToHops(), paramMap);
		selectFederatedExecutionPlan(forSB.getIncrementHops(), paramMap);

		// add iter variable to local variable map allowing us to reason over transient reads in the HOP DAG
		DataIdentifier iterVar = ((ForStatement) forSB.getStatement(0)).getIterablePredicate().getIterVar();
		LocalVariableMap tmpLocalVariableMap = localVariableMap;
		localVariableMap = (LocalVariableMap) localVariableMap.clone();
		// value doesn't matter, localVariableMap is just used to check if the variable is federated
		localVariableMap.put(iterVar.getName(), new IntObject(-1));
		for(Statement statement : forSB.getStatements()) {
			ForStatement forStatement = ((ForStatement) statement);
			forStatement.setBody(rewriteStatementBlocks(prog, forStatement.getBody(), paramMap));
		}
		localVariableMap = tmpLocalVariableMap;
		return new ArrayList<>(Collections.singletonList(forSB));
	}

	private ArrayList<StatementBlock> rewriteFunctionStatementBlock(DMLProgram prog, FunctionStatementBlock funcSB, Map<String, Hop> paramMap) {
		for(Statement statement : funcSB.getStatements()) {
			FunctionStatement funcStm = (FunctionStatement) statement;
			funcStm.setBody(rewriteStatementBlocks(prog, funcStm.getBody(), paramMap));
		}
		return new ArrayList<>(Collections.singletonList(funcSB));
	}

	private ArrayList<StatementBlock> rewriteDefaultStatementBlock(DMLProgram prog, StatementBlock sb, Map<String, Hop> paramMap) {
		if(sb.hasHops()) {
			for(Hop sbHop : sb.getHops()) {
				selectFederatedExecutionPlan(sbHop, paramMap);
				if(sbHop instanceof FunctionOp) {
					String funcName = ((FunctionOp) sbHop).getFunctionName();
					String funcNamespace = ((FunctionOp) sbHop).getFunctionNamespace();
					Map<String, Hop> funcParamMap = FederatedPlannerUtils.getParamMap((FunctionOp) sbHop);
					if ( paramMap != null && funcParamMap != null)
						funcParamMap.putAll(paramMap);
					paramMap = funcParamMap;
					FunctionStatementBlock sbFuncBlock = prog.getFunctionDictionary(funcNamespace)
						.getFunction(funcName);
					rewriteStatementBlock(prog, sbFuncBlock, paramMap);

					FunctionStatement funcStatement = (FunctionStatement) sbFuncBlock.getStatement(0);
					FederatedPlannerUtils.mapFunctionOutputs((FunctionOp) sbHop, funcStatement, transientWrites);
				}
			}
		}
		return new ArrayList<>(Collections.singletonList(sb));
	}

	/**
	 * Set final fedouts of all hops starting from terminal hops.
	 */
	public void setFinalFedouts(){
		for ( Hop root : terminalHops)
			setFinalFedout(root);
	}

	/**
	 * Sets FederatedOutput field of all hops in DAG starting from given root.
	 * The FederatedOutput chosen for root is the minimum cost HopRel found in memo table for the given root.
	 * The FederatedOutput values chosen for the inputs to the root are chosen based on the input dependencies.
	 *
	 * @param root hop for which FederatedOutput needs to be set
	 */
	private void setFinalFedout(Hop root) {
		HopRel optimalRootHopRel = hopRelMemo.getMinCostAlternative(root);
		setFinalFedout(root, optimalRootHopRel);
	}

	/**
	 * Update the FederatedOutput value and cost based on information stored in given rootHopRel.
	 *
	 * @param root       hop for which FederatedOutput is set
	 * @param rootHopRel from which FederatedOutput value and cost is retrieved
	 */
	private void setFinalFedout(Hop root, HopRel rootHopRel) {
		if ( hopRelUpdatedFinal.contains(root.getHopID()) ){
			if((rootHopRel.hasLocalOutput() ^ root.hasLocalOutput()) && hopRelMemo.hasFederatedOutputAlternative(root)){
				// Update with FOUT alternative without visiting inputs
				updateFederatedOutput(root, hopRelMemo.getFederatedOutputAlternative(root));
				root.activatePrefetch();
			}
			else {
				// Update without visiting inputs
				updateFederatedOutput(root, rootHopRel);
			}
		}
		else {
			updateFederatedOutput(root, rootHopRel);
			visitInputDependency(rootHopRel);
		}
	}

	/**
	 * Sets FederatedOutput value for each of the inputs of rootHopRel
	 *
	 * @param rootHopRel which has its input values updated
	 */
	private void visitInputDependency(HopRel rootHopRel) {
		List<HopRel> hopRelInputs = rootHopRel.getInputDependency();
		for(HopRel input : hopRelInputs)
			setFinalFedout(input.getHopRef(), input);
	}

	/**
	 * Updates FederatedOutput value and cost estimate based on updateHopRel values.
	 *
	 * @param root         which has its values updated
	 * @param updateHopRel from which the values are retrieved
	 */
	private void updateFederatedOutput(Hop root, HopRel updateHopRel) {
		root.setFederatedOutput(updateHopRel.getFederatedOutput());
		root.setFederatedCost(updateHopRel.getCostObject());
		root.setForcedExecType(updateHopRel.getExecType());
		forceFixedFedOut(root);

		LOG.trace("Updated fedOut to " + updateHopRel.getFederatedOutput() + " for hop "
			+ root.getHopID() + " opcode: " + root.getOpString());
		hopRelUpdatedFinal.add(root.getHopID());
	}

	/**
	 * Set federated output to fixed value if FEDERATED_SPECS is activated for root hop.
	 * @param root hop set to fixed fedout value as loaded from FEDERATED_SPECS
	 */
	private void forceFixedFedOut(Hop root){
		if ( OptimizerUtils.FEDERATED_SPECS.containsKey(root.getBeginLine()) ){
			FederatedOutput fedOutSpec = OptimizerUtils.FEDERATED_SPECS.get(root.getBeginLine());
			root.setFederatedOutput(fedOutSpec);
			if ( fedOutSpec.isForcedFederated() )
				root.deactivatePrefetch();
		}
	}

	/**
	 * Select federated execution plan for every Hop in the DAG starting from given roots.
	 * The cost estimates of the hops are also updated when FederatedOutput is updated in the hops.
	 *
	 * @param roots starting point for going through the Hop DAG to update the FederatedOutput fields.
	 * @param paramMap map of parameters in function call
	 */
	@SuppressWarnings("unused")
	private void selectFederatedExecutionPlan(ArrayList<Hop> roots, Map<String, Hop> paramMap){
		for ( Hop root : roots )
			selectFederatedExecutionPlan(root, paramMap);
	}

	/**
	 * Select federated execution plan for every Hop in the DAG starting from given root.
	 *
	 * @param root starting point for going through the Hop DAG to update the federatedOutput fields
	 * @param paramMap map of parameters in function call
	 */
	private void selectFederatedExecutionPlan(Hop root, Map<String, Hop> paramMap) {
		if ( root != null ){
			visitFedPlanHop(root, paramMap);
			if ( HopRewriteUtils.isTerminalHop(root) )
				terminalHops.add(root);
		}
	}

	/**
	 * Go through the Hop DAG and set the FederatedOutput field and cost estimate for each Hop from leaf to given currentHop.
	 *
	 * @param currentHop the Hop from which the DAG is visited
	 * @param paramMap map of parameters in function call
	 */
	private void visitFedPlanHop(Hop currentHop, Map<String, Hop> paramMap) {
		// If the currentHop is in the hopRelMemo table, it means that it has been visited
		if(hopRelMemo.containsHop(currentHop))
			return;
		debugLog(currentHop);
		// If the currentHop has input, then the input should be visited depth-first
		for(Hop input : currentHop.getInput())
			visitFedPlanHop(input, paramMap);
		// Put FOUT and LOUT HopRels into the memo table
		ArrayList<HopRel> hopRels = getFedPlans(currentHop, paramMap);
		// Put NONE HopRel into memo table if no FOUT or LOUT HopRels were added
		if(hopRels.isEmpty())
			hopRels.add(getNONEHopRel(currentHop, paramMap));
		addTrace(hopRels);
		hopRelMemo.put(currentHop, hopRels);
	}

	private ArrayList<Hop> getHopInputs(Hop currentHop, Map<String, Hop> paramMap){
		if ( HopRewriteUtils.isData(currentHop, Types.OpOpData.TRANSIENTREAD) )
			return FederatedPlannerUtils.getTransientInputs(currentHop, paramMap, transientWrites, localVariableMap);
		else
			return currentHop.getInput();
	}

	private HopRel getNONEHopRel(Hop currentHop, Map<String, Hop> paramMap){
		ArrayList<Hop> inputs = getHopInputs(currentHop, paramMap);
		HopRel noneHopRel = new HopRel(currentHop, FederatedOutput.NONE, hopRelMemo, inputs);
		FType[] inputFType = noneHopRel.getInputDependency().stream().map(HopRel::getFType).toArray(FType[]::new);
		FType outputFType = getFederatedOut(currentHop, inputFType);
		noneHopRel.setFType(outputFType);
		return noneHopRel;
	}

	/**
	 * Get the alternative plans regarding the federated output for given currentHop.
	 * @param currentHop for which alternative federated plans are generated
	 * @param paramMap map of parameters in function call
	 * @return list of alternative plans
	 */
	private ArrayList<HopRel> getFedPlans(Hop currentHop, Map<String, Hop> paramMap){
		ArrayList<HopRel> hopRels = new ArrayList<>();
		ArrayList<Hop> inputHops = currentHop.getInput();
		if ( HopRewriteUtils.isData(currentHop, Types.OpOpData.TRANSIENTREAD) ) {
			inputHops = FederatedPlannerUtils.getTransientInputs(currentHop, paramMap, transientWrites, localVariableMap);
			if (inputHops == null) {
				// check if transient read on a runtime variable (only when planning during dynamic recompilation)
				return createHopRelsFromRuntimeVars(currentHop, hopRels);
			}
		}
		if ( HopRewriteUtils.isData(currentHop, Types.OpOpData.TRANSIENTWRITE) )
			transientWrites.put(currentHop.getName(), currentHop);
		if ( HopRewriteUtils.isData(currentHop, Types.OpOpData.FEDERATED) )
			hopRels.add(new HopRel(currentHop, FederatedOutput.FOUT, deriveFType((DataOp)currentHop), hopRelMemo, inputHops));
		else
			hopRels.addAll(generateHopRels(currentHop, inputHops));
		if ( isLOUTSupported(currentHop) )
			hopRels.add(new HopRel(currentHop, FederatedOutput.LOUT, hopRelMemo, inputHops));
		return hopRels;
	}

	private ArrayList<HopRel> createHopRelsFromRuntimeVars(Hop currentHop, ArrayList<HopRel> hopRels) {
		Data variable = localVariableMap.get(currentHop.getName());
		if (variable == null) {
			throw new DMLRuntimeException("Transient write not found for " + currentHop);
		}
		FederationMap fedMapping = null;
		if (variable instanceof CacheableData<?>) {
			CacheableData<?> cacheable = (CacheableData<?>) variable;
			fedMapping = cacheable.getFedMapping();
		}
		if(fedMapping != null)
			hopRels.add(new HopRel(currentHop, FederatedOutput.FOUT, fedMapping.getType(), hopRelMemo,
				new ArrayList<>()));
		else
			hopRels.add(new HopRel(currentHop, FederatedOutput.LOUT, hopRelMemo, new ArrayList<>()));
		return hopRels;
	}

	/**
	 * Generate a collection of FOUT HopRels representing the different possible FType outputs.
	 * For each FType output, only the minimum cost input combination is chosen.
	 * @param currentHop for which HopRels are generated
	 * @param inputHops to currentHop
	 * @return collection of FOUT HopRels with different FType outputs
	 */
	private Collection<HopRel> generateHopRels(Hop currentHop, List<Hop> inputHops){
		List<List<FType>> validFTypes = getValidFTypes(inputHops);
		List<List<FType>> inputFTypeCombinations = getAllCombinations(validFTypes);
		Map<FType,HopRel> foutHopRelMap = new HashMap<>();
		for ( List<FType> inputCombination : inputFTypeCombinations ){
			if ( allowsFederated(currentHop, inputCombination.toArray(FType[]::new)) ){
				FType outputFType = getFederatedOut(currentHop, inputCombination.toArray(new FType[0]));
				if ( outputFType != null ){
					HopRel alt = new HopRel(currentHop, FederatedOutput.FOUT, outputFType, hopRelMemo, inputHops, inputCombination);
					if ( foutHopRelMap.containsKey(alt.getFType()) ){
						foutHopRelMap.computeIfPresent(alt.getFType(),
							(key,currentVal) -> (currentVal.getCost() < alt.getCost()) ? currentVal : alt);
					} else {
						foutHopRelMap.put(outputFType, alt);
					}
				} else {
					LOG.trace("Allows federated, but FOUT is not allowed: " + currentHop + " input FTypes: " + inputCombination);
				}
			} else {
				LOG.trace("Does not allow federated: " + currentHop + " input FTypes: " + inputCombination);
			}
		}
		return foutHopRelMap.values();
	}

	private List<List<FType>> getValidFTypes(List<Hop> inputHops){
		List<List<FType>> validFTypes = new ArrayList<>();
		for ( Hop inputHop : inputHops )
			validFTypes.add(hopRelMemo.getFTypes(inputHop));
		return validFTypes;
	}

	public List<List<FType>> getAllCombinations(List<List<FType>> validFTypes){
		List<List<FType>> resultList = new ArrayList<>();
		buildCombinations(validFTypes, resultList, 0, new ArrayList<>());
		return resultList;
	}

	public void buildCombinations(List<List<FType>> validFTypes, List<List<FType>> result, int currentIndex, List<FType> currentResult){
		if ( currentIndex == validFTypes.size() ){
			result.add(currentResult);
		} else {
			for (FType currentType : validFTypes.get(currentIndex)){
				List<FType> currentPass = new ArrayList<>(currentResult);
				currentPass.add(currentType);
				buildCombinations(validFTypes, result, currentIndex+1, currentPass);
			}
		}
	}

	/**
	 * Add hopRelMemo to Explain class to get explain info related to federated enumeration.
	 */
	public void updateExplain(){
		if (DMLScript.EXPLAIN == ExplainType.HOPS)
			Explain.setMemo(hopRelMemo);
	}

	/**
	 * Write HOP visit to debug log if debug is activated.
	 * @param currentHop hop written to log
	 */
	private void debugLog(Hop currentHop){
		if ( LOG.isDebugEnabled() ){
			LOG.debug("Visiting HOP: " + currentHop + " Input size: " + currentHop.getInput().size());
			if (currentHop.getPrivacy() != null)
				LOG.debug(currentHop.getPrivacy());
			int index = 0;
			for ( Hop hop : currentHop.getInput()){
				if ( hop == null )
					LOG.debug("Input at index is null: " + index);
				else
					LOG.debug("HOP input: " + hop + " at index " + index + " of " + currentHop);
				index++;
			}
		}
	}

	private void addTrace(ArrayList<HopRel> hopRels){
		if (LOG.isTraceEnabled()){
			for(HopRel hr : hopRels){
				LOG.trace("Adding to memo: " + hr);
			}
		}
	}

	/**
	 * Checks to see if the associatedHop supports LOUT.
	 * It supports LOUT if the output has no privacy constraints.
	 *
	 * @param associatedHop for which LOUT support is checked.
	 * @return true if LOUT is supported by the associatedHop
	 */
	private boolean isLOUTSupported(Hop associatedHop) {
		return associatedHop.getPrivacy() == null || !associatedHop.getPrivacy().hasConstraints();
	}
}
