/*
 * 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.beam.runners.dataflow.worker.graph;

import com.google.common.base.Predicate;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableTable;
import com.google.common.collect.Iterables;
import com.google.common.graph.MutableNetwork;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import org.apache.beam.runners.dataflow.worker.graph.Edges.Edge;
import org.apache.beam.runners.dataflow.worker.graph.Nodes.ExecutionLocation;
import org.apache.beam.runners.dataflow.worker.graph.Nodes.Node;
import org.apache.beam.runners.dataflow.worker.graph.Nodes.ParallelInstructionNode;

/**
 * A function which sets the location for {@link FlattenInstruction}s by looking at the locations of
 * the nodes' predecessors and successors.
 *
 * <p>Locations for flatten nodes are chosen to minimize the amount of gRPC ports that data must be
 * transferred over. Thus for any select flatten node the location is deduced based on if its
 * predecessors and successors execute in the Runner, SDK harness, both, or neither. The final
 * location of the flatten node is chosen based on the following table.
 *
 * <p>(Predecessors along Y axis, Successors along X axis)
 *
 * <pre>{@code
 *         || SDK    | Runner | Both      | Neither |
 * ==================================================
 * SDK     || SDK    | Runner | SDK       | SDK     |
 * --------------------------------------------------
 * Runner  || Runner | Runner | Runner    | Runner  |
 * --------------------------------------------------
 * Both    || SDK    | Runner | Ambiguous | Runner  |
 * --------------------------------------------------
 * Neither || SDK    | Runner | Runner    | Runner  |
 * --------------------------------------------------
 * }</pre>
 *
 * <p>The ambiguous result means that executing the flatten in either the SDK or Runner is equally
 * inefficient, and thus it can execute in either one.
 */
public class DeduceFlattenLocationsFunction
    implements Function<MutableNetwork<Node, Edge>, MutableNetwork<Node, Edge>> {

  /** Represents the execution location of an group of connected nodes. */
  private enum AggregatedLocation {
    NEITHER, // None of the nodes have a set execution location.
    SDK_HARNESS, // All the nodes execute in the SDK harness.
    RUNNER_HARNESS, // All of the nodes execute in the runner harness.
    BOTH, // Some nodes execute in the SDK harness and some in the runner harness.
  }

  private static final ImmutableTable<AggregatedLocation, AggregatedLocation, ExecutionLocation>
      DEDUCTION_TABLE =
          new ImmutableTable.Builder<AggregatedLocation, AggregatedLocation, ExecutionLocation>()
              .put(
                  AggregatedLocation.SDK_HARNESS,
                  AggregatedLocation.SDK_HARNESS,
                  ExecutionLocation.SDK_HARNESS)
              .put(
                  AggregatedLocation.SDK_HARNESS,
                  AggregatedLocation.RUNNER_HARNESS,
                  ExecutionLocation.RUNNER_HARNESS)
              .put(
                  AggregatedLocation.SDK_HARNESS,
                  AggregatedLocation.BOTH,
                  ExecutionLocation.SDK_HARNESS)
              .put(
                  AggregatedLocation.SDK_HARNESS,
                  AggregatedLocation.NEITHER,
                  ExecutionLocation.SDK_HARNESS)
              .put(
                  AggregatedLocation.RUNNER_HARNESS,
                  AggregatedLocation.SDK_HARNESS,
                  ExecutionLocation.RUNNER_HARNESS)
              .put(
                  AggregatedLocation.RUNNER_HARNESS,
                  AggregatedLocation.RUNNER_HARNESS,
                  ExecutionLocation.RUNNER_HARNESS)
              .put(
                  AggregatedLocation.RUNNER_HARNESS,
                  AggregatedLocation.BOTH,
                  ExecutionLocation.RUNNER_HARNESS)
              .put(
                  AggregatedLocation.RUNNER_HARNESS,
                  AggregatedLocation.NEITHER,
                  ExecutionLocation.RUNNER_HARNESS)
              .put(
                  AggregatedLocation.BOTH,
                  AggregatedLocation.SDK_HARNESS,
                  ExecutionLocation.SDK_HARNESS)
              .put(
                  AggregatedLocation.BOTH,
                  AggregatedLocation.RUNNER_HARNESS,
                  ExecutionLocation.RUNNER_HARNESS)
              .put(AggregatedLocation.BOTH, AggregatedLocation.BOTH, ExecutionLocation.AMBIGUOUS)
              .put(
                  AggregatedLocation.BOTH,
                  AggregatedLocation.NEITHER,
                  ExecutionLocation.RUNNER_HARNESS)
              .put(
                  AggregatedLocation.NEITHER,
                  AggregatedLocation.SDK_HARNESS,
                  ExecutionLocation.SDK_HARNESS)
              .put(
                  AggregatedLocation.NEITHER,
                  AggregatedLocation.RUNNER_HARNESS,
                  ExecutionLocation.RUNNER_HARNESS)
              .put(
                  AggregatedLocation.NEITHER,
                  AggregatedLocation.BOTH,
                  ExecutionLocation.RUNNER_HARNESS)
              .put(
                  AggregatedLocation.NEITHER,
                  AggregatedLocation.NEITHER,
                  ExecutionLocation.RUNNER_HARNESS)
              .build();

  /**
   * Deduces an {@link ExecutionLocation} for each flatten by first checking the locations of all
   * the predecessors and successors to each node. These locations are aggregated to a single result
   * representing all successors/predecessors. Once the aggregated location for both successors and
   * predecessors are found they are used to determine the execution location of the flatten node
   * itself and the flattens are replaced by copies that include the updated {@link
   * ExecutionLocation}.
   */
  @Override
  public MutableNetwork<Node, Edge> apply(MutableNetwork<Node, Edge> network) {
    Map<Node, AggregatedLocation> predecessorLocationsMap = new HashMap<Node, AggregatedLocation>();
    Map<Node, AggregatedLocation> successorLocationsMap = new HashMap<Node, AggregatedLocation>();
    Map<Node, ExecutionLocation> deducedLocationsMap = new HashMap<Node, ExecutionLocation>();
    ImmutableList<Node> flattens =
        ImmutableList.copyOf(Iterables.filter(network.nodes(), IsFlatten.INSTANCE));

    // Find all predecessor and successor locations for every flatten.
    for (Node flatten : flattens) {
      AggregatedLocation predecessorLocations = AggregatedLocation.NEITHER;
      AggregatedLocation successorLocations = AggregatedLocation.NEITHER;
      predecessorLocations = getPredecessorLocations(flatten, network, predecessorLocationsMap);
      successorLocations = getSuccessorLocations(flatten, network, successorLocationsMap);

      deducedLocationsMap.put(
          flatten, DEDUCTION_TABLE.get(predecessorLocations, successorLocations));
    }

    // Actually set the locations of the flattens permanently.
    Networks.replaceDirectedNetworkNodes(
        network,
        (Node node) -> {
          if (!deducedLocationsMap.containsKey(node)) {
            return node;
          }

          ParallelInstructionNode castNode = ((ParallelInstructionNode) node);
          ExecutionLocation deducedLocation = deducedLocationsMap.get(node);
          return ParallelInstructionNode.create(castNode.getParallelInstruction(), deducedLocation);
        });

    return network;
  }

  /** Enum for {@link getConnectedNodeLocations} to specify which direction to search in. */
  private enum SearchDirection {
    PREDECESSORS,
    SUCCESSORS,
  }

  /**
   * Helper function to retrieve the aggregated location of a node's predecessors. See {@link
   * DeduceFlattenLocationsFunction#getConnectedNodeLocations} for details.
   */
  private AggregatedLocation getPredecessorLocations(
      Node node,
      MutableNetwork<Node, Edge> network,
      Map<Node, AggregatedLocation> predecessorLocationsMap) {
    return getConnectedNodeLocations(
        node, network, predecessorLocationsMap, SearchDirection.PREDECESSORS);
  }

  /**
   * Helper function to retrieve the aggregated location of a node's successors. See {@link
   * DeduceFlattenLocationsFunction#getConnectedNodeLocations} for details.
   */
  private AggregatedLocation getSuccessorLocations(
      Node node,
      MutableNetwork<Node, Edge> network,
      Map<Node, AggregatedLocation> successorLocationsMap) {
    return getConnectedNodeLocations(
        node, network, successorLocationsMap, SearchDirection.SUCCESSORS);
  }

  /**
   * A function which retrieves the aggregated location of a node's connecting nodes in one
   * direction, either checking the target node's successors or predecessors. This is done by
   * checking all the connected node's locations. For nodes that do not have locations embedded in
   * the actual node (they may have unknown location or might not even be {@link
   * ParallelInstructionNode}s) the location can be deduced by recursively checking that node's
   * predecessors. To prevent a large amount of needless recursion a map is used for memoization;
   * The results of this function will be stored in the map so that they can be retrieved later if
   * needed without having to perform the recursions again.
   */
  private AggregatedLocation getConnectedNodeLocations(
      Node node,
      MutableNetwork<Node, Edge> network,
      Map<Node, AggregatedLocation> connectedLocationsMap,
      SearchDirection direction) {
    // First check the map
    if (connectedLocationsMap.containsKey(node)) {
      return connectedLocationsMap.get(node);
    }

    boolean hasSdkConnections = false;
    boolean hasRunnerConnections = false;

    Set<Node> connectedNodes;
    if (direction == SearchDirection.SUCCESSORS) {
      connectedNodes = network.successors(node);
    } else {
      connectedNodes = network.predecessors(node);
    }

    // Get the location of each connected node by checking three different places for it. First
    // try checking the ExecutionLocation of the node directly if it's a ParallelInstructionNode.
    // If that doesn't work, try checking the map passed in as a parameter, and if that doesn't
    // work recurse this function to the unknown node.
    for (Node connectedNode : connectedNodes) {
      if (connectedNode instanceof ParallelInstructionNode
          && ((ParallelInstructionNode) connectedNode).getExecutionLocation()
              != ExecutionLocation.UNKNOWN) {
        ExecutionLocation executionLocation =
            ((ParallelInstructionNode) connectedNode).getExecutionLocation();
        switch (executionLocation) {
          case SDK_HARNESS:
            hasSdkConnections = true;
            break;
          case RUNNER_HARNESS:
            hasRunnerConnections = true;
            break;
          case AMBIGUOUS:
            hasSdkConnections = true;
            hasRunnerConnections = true;
            break;
          default:
            throw new IllegalStateException("Unknown case " + executionLocation);
        }
      } else {
        AggregatedLocation connectedLocation =
            getConnectedNodeLocations(connectedNode, network, connectedLocationsMap, direction);
        switch (connectedLocation) {
          case SDK_HARNESS:
            hasSdkConnections = true;
            break;
          case RUNNER_HARNESS:
            hasRunnerConnections = true;
            break;
          case BOTH:
            hasSdkConnections = true;
            hasRunnerConnections = true;
            break;
          case NEITHER:
            break;
          default:
            throw new IllegalStateException("Unknown case " + connectedLocation);
        }
      }

      // If nodes in the SDK and Runner have been found, the result for this node is "Both", so no
      // need to continue checking.
      if (hasSdkConnections && hasRunnerConnections) {
        break;
      }
    }

    // Return aggregated locations for this node's connections and store it in the map.
    AggregatedLocation aggregatedLocation;
    if (hasSdkConnections && hasRunnerConnections) {
      aggregatedLocation = AggregatedLocation.BOTH;
    } else if (hasSdkConnections) {
      aggregatedLocation = AggregatedLocation.SDK_HARNESS;
    } else if (hasRunnerConnections) {
      aggregatedLocation = AggregatedLocation.RUNNER_HARNESS;
    } else {
      aggregatedLocation = AggregatedLocation.NEITHER;
    }

    connectedLocationsMap.put(node, aggregatedLocation);
    return aggregatedLocation;
  }

  /**
   * A {@link Predicate} which returns true iff the {@link Node} represents a {@link
   * ParallelInstructionNode} with a {@link FlattenInstruction}.
   */
  private static class IsFlatten implements Predicate<Node> {
    private static final IsFlatten INSTANCE = new IsFlatten();

    @Override
    public boolean apply(Node node) {
      return node instanceof ParallelInstructionNode
          && ((ParallelInstructionNode) node).getParallelInstruction().getFlatten() != null;
    }

    // Hide visibility to prevent instantiation
    private IsFlatten() {}
  }
}
