/*
 * Copyright contributors to Hyperledger Besu.
 *
 * 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.
 *
 * SPDX-License-Identifier: Apache-2.0
 */
package org.hyperledger.besu.ethereum.trie.patricia;

import static com.google.common.base.Preconditions.checkArgument;

import org.hyperledger.besu.ethereum.trie.MerkleTrie;
import org.hyperledger.besu.ethereum.trie.Node;
import org.hyperledger.besu.ethereum.trie.NodeLoader;
import org.hyperledger.besu.ethereum.trie.NullNode;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Stream;

import com.google.common.collect.Streams;
import org.apache.tuweni.bytes.Bytes;
import org.apache.tuweni.bytes.Bytes32;

public class TrieNodeDecoder {
  private static final StoredNodeFactory<Bytes> emptyNodeFactory =
      new StoredNodeFactory<>((l, h) -> Optional.empty(), Function.identity(), Function.identity());

  // Hide constructor for static utility class
  private TrieNodeDecoder() {}

  /**
   * Decode an rlp-encoded trie node
   *
   * @param location The location in the trie.
   * @param rlp The rlp-encoded node
   * @return A {@code Node} representation of the rlp data
   */
  public static Node<Bytes> decode(final Bytes location, final Bytes rlp) {
    return emptyNodeFactory.decode(location, rlp);
  }

  /**
   * Flattens this node and all of its inlined nodes and node references into a list.
   *
   * @param location The location in the trie.
   * @param nodeRlp The bytes of the trie node to be decoded.
   * @return A list of nodes and node references embedded in the given rlp.
   */
  public static List<Node<Bytes>> decodeNodes(final Bytes location, final Bytes nodeRlp) {
    if (nodeRlp.equals(MerkleTrie.EMPTY_TRIE_NODE)) {
      return new ArrayList<>();
    }
    final Node<Bytes> node = decode(location, nodeRlp);
    final List<Node<Bytes>> nodes = new ArrayList<>();
    nodes.add(node);

    final List<Node<Bytes>> toProcess = new ArrayList<>(node.getChildren());
    while (!toProcess.isEmpty()) {
      final Node<Bytes> currentNode = toProcess.remove(0);
      if (Objects.equals(NullNode.instance(), currentNode)) {
        // Skip null nodes
        continue;
      }
      nodes.add(currentNode);

      if (!currentNode.isReferencedByHash()) {
        // If current node is inlined, that means we can process its children
        toProcess.addAll(currentNode.getChildren());
      }
    }

    return nodes;
  }

  /**
   * Walks the trie in a bread-first manner, returning the list of nodes encountered in order. If
   * any nodes are missing from the nodeLoader, those nodes are just skipped.
   *
   * @param nodeLoader The NodeLoader for looking up nodes by hash
   * @param rootHash The hash of the root node
   * @param maxDepth The maximum depth to traverse to. A value of zero will traverse the root node
   *     only.
   * @return A stream non-null nodes in the breadth-first traversal order.
   */
  public static Stream<Node<Bytes>> breadthFirstDecoder(
      final NodeLoader nodeLoader, final Bytes32 rootHash, final int maxDepth) {
    checkArgument(maxDepth >= 0);
    return Streams.stream(new BreadthFirstIterator(nodeLoader, rootHash, maxDepth));
  }

  /**
   * Walks the trie in a bread-first manner, returning the list of nodes encountered in order. If
   * any nodes are missing from the nodeLoader, those nodes are just skipped.
   *
   * @param nodeLoader The NodeLoader for looking up nodes by hash
   * @param rootHash The hash of the root node
   * @return A stream non-null nodes in the breadth-first traversal order.
   */
  public static Stream<Node<Bytes>> breadthFirstDecoder(
      final NodeLoader nodeLoader, final Bytes32 rootHash) {
    return breadthFirstDecoder(nodeLoader, rootHash, Integer.MAX_VALUE);
  }

  private static class BreadthFirstIterator implements Iterator<Node<Bytes>> {

    private final int maxDepth;
    private final StoredNodeFactory<Bytes> nodeFactory;

    private int currentDepth = 0;
    private final List<Node<Bytes>> currentNodes = new ArrayList<>();
    private final List<Node<Bytes>> nextNodes = new ArrayList<>();

    BreadthFirstIterator(final NodeLoader nodeLoader, final Bytes32 rootHash, final int maxDepth) {
      this.maxDepth = maxDepth;
      this.nodeFactory =
          new StoredNodeFactory<>(nodeLoader, Function.identity(), Function.identity());

      nodeLoader
          .getNode(Bytes.EMPTY, rootHash)
          .map(h -> TrieNodeDecoder.decode(Bytes.EMPTY, h))
          .ifPresent(currentNodes::add);
    }

    @Override
    public boolean hasNext() {
      return !currentNodes.isEmpty() && currentDepth <= maxDepth;
    }

    @Override
    public Node<Bytes> next() {
      if (!hasNext()) {
        throw new NoSuchElementException();
      }

      final Node<Bytes> nextNode = currentNodes.remove(0);

      final List<Node<Bytes>> children = new ArrayList<>(nextNode.getChildren());
      while (!children.isEmpty()) {
        Node<Bytes> child = children.remove(0);
        if (Objects.equals(child, NullNode.instance())) {
          // Ignore null nodes
          continue;
        }
        if (child.isReferencedByHash()) {
          // Retrieve hash-referenced child
          final Optional<Node<Bytes>> maybeChildNode = nodeFactory.retrieve(null, child.getHash());
          if (maybeChildNode.isEmpty()) {
            continue;
          }
          child = maybeChildNode.get();
        }
        nextNodes.add(child);
      }

      // Set up next level
      if (currentNodes.isEmpty()) {
        currentDepth += 1;
        currentNodes.addAll(nextNodes);
        nextNodes.clear();
      }

      return nextNode;
    }
  }
}
