/*
 * Copyright ConsenSys AG.
 *
 * 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.api.jsonrpc.websocket.subscription.blockheaders;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyZeroInteractions;
import static org.mockito.Mockito.when;

import org.hyperledger.besu.crypto.SECP256K1.KeyPair;
import org.hyperledger.besu.ethereum.api.jsonrpc.internal.results.BlockResult;
import org.hyperledger.besu.ethereum.api.jsonrpc.internal.results.BlockResultFactory;
import org.hyperledger.besu.ethereum.api.jsonrpc.internal.results.JsonRpcResult;
import org.hyperledger.besu.ethereum.api.jsonrpc.websocket.subscription.SubscriptionManager;
import org.hyperledger.besu.ethereum.api.query.BlockWithMetadata;
import org.hyperledger.besu.ethereum.api.query.BlockchainQueries;
import org.hyperledger.besu.ethereum.api.query.TransactionWithMetadata;
import org.hyperledger.besu.ethereum.chain.BlockAddedEvent;
import org.hyperledger.besu.ethereum.core.Block;
import org.hyperledger.besu.ethereum.core.BlockBody;
import org.hyperledger.besu.ethereum.core.BlockHeader;
import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture;
import org.hyperledger.besu.ethereum.core.Difficulty;
import org.hyperledger.besu.ethereum.core.Hash;
import org.hyperledger.besu.ethereum.core.TransactionTestFixture;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;

import com.google.common.collect.Lists;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;

@RunWith(MockitoJUnitRunner.class)
public class NewBlockHeadersSubscriptionServiceTest {

  private NewBlockHeadersSubscriptionService newBlockHeadersSubscriptionService;

  @Captor ArgumentCaptor<Long> subscriptionIdCaptor;
  @Captor ArgumentCaptor<JsonRpcResult> responseCaptor;

  @Mock private SubscriptionManager subscriptionManager;
  @Mock private BlockchainQueries blockchainQueries;

  private final BlockHeaderTestFixture blockHeaderTestFixture = new BlockHeaderTestFixture();
  private final TransactionTestFixture txTestFixture = new TransactionTestFixture();
  private final BlockHeader blockHeader = blockHeaderTestFixture.buildHeader();
  private final BlockResultFactory blockResultFactory = new BlockResultFactory();

  @Before
  public void before() {
    newBlockHeadersSubscriptionService =
        new NewBlockHeadersSubscriptionService(subscriptionManager, blockchainQueries);
  }

  @Test
  public void shouldSendMessageWhenBlockAddedOnCanonicalChain() {
    final NewBlockHeadersSubscription subscription = createSubscription(false);
    mockSubscriptionManagerNotifyMethod(subscription);
    final BlockResult expectedNewBlock = expectedBlockWithTransactions(Collections.emptyList());

    simulateAddingBlockOnCanonicalChain();

    verify(subscriptionManager)
        .sendMessage(subscriptionIdCaptor.capture(), responseCaptor.capture());
    assertThat(subscriptionIdCaptor.getValue()).isEqualTo(subscription.getSubscriptionId());
    assertThat(responseCaptor.getValue())
        .isEqualToComparingFieldByFieldRecursively(expectedNewBlock);
  }

  @Test
  public void shouldNotSendMessageWhenBlockAddedIsNotOnCanonicalChain() {
    simulateAddingBlockOnNonCanonicalChain();

    verifyZeroInteractions(subscriptionManager);
  }

  @Test
  public void shouldReturnTxHashesWhenIncludeTransactionsFalse() {
    final NewBlockHeadersSubscription subscription = createSubscription(false);
    mockSubscriptionManagerNotifyMethod(subscription);
    final List<Hash> txHashList = transactionsWithHashOnly();
    final BlockResult expectedNewBlock = expectedBlockWithTransactions(txHashList);

    simulateAddingBlockOnCanonicalChain();

    verify(subscriptionManager)
        .sendMessage(subscriptionIdCaptor.capture(), responseCaptor.capture());
    assertThat(subscriptionIdCaptor.getValue()).isEqualTo(subscription.getSubscriptionId());
    final Object actualBlock = responseCaptor.getValue();
    assertThat(actualBlock).isInstanceOf(BlockResult.class);
    assertThat(((BlockResult) actualBlock).getTransactions()).hasSize(txHashList.size());
    assertThat(actualBlock).isEqualToComparingFieldByFieldRecursively(expectedNewBlock);

    verify(blockchainQueries, times(1)).blockByHashWithTxHashes(any());
    verify(blockchainQueries, times(0)).blockByHash(any());
  }

  @Test
  public void shouldReturnCompleteTxWhenParameterTrue() {
    final NewBlockHeadersSubscription subscription = createSubscription(true);
    mockSubscriptionManagerNotifyMethod(subscription);
    final List<TransactionWithMetadata> txHashList = transactionsWithMetadata();
    final BlockWithMetadata<TransactionWithMetadata, Hash> testBlockWithMetadata =
        new BlockWithMetadata<>(
            blockHeader, txHashList, Collections.emptyList(), blockHeader.getDifficulty(), 0);
    final BlockResult expectedNewBlock =
        blockResultFactory.transactionComplete(testBlockWithMetadata);
    when(blockchainQueries.blockByHash(testBlockWithMetadata.getHeader().getHash()))
        .thenReturn(Optional.of(testBlockWithMetadata));

    simulateAddingBlockOnCanonicalChain();

    verify(subscriptionManager)
        .sendMessage(subscriptionIdCaptor.capture(), responseCaptor.capture());

    assertThat(subscriptionIdCaptor.getValue()).isEqualTo(subscription.getSubscriptionId());

    final Object actualBlock = responseCaptor.getValue();
    assertThat(actualBlock).isInstanceOf(BlockResult.class);
    assertThat(((BlockResult) actualBlock).getTransactions()).hasSize(txHashList.size());
    assertThat(actualBlock).isEqualToComparingFieldByFieldRecursively(expectedNewBlock);

    verify(subscriptionManager, times(1)).sendMessage(any(), any());
    verify(blockchainQueries, times(0)).blockByHashWithTxHashes(any());
    verify(blockchainQueries, times(1)).blockByHash(any());
  }

  @Test
  public void shouldOnlyCreateResponsesOnce() {
    final NewBlockHeadersSubscription subscription1 = createSubscription(true);
    final NewBlockHeadersSubscription subscription2 = createSubscription(false);
    final NewBlockHeadersSubscription subscription3 = createSubscription(true);
    final NewBlockHeadersSubscription subscription4 = createSubscription(false);
    mockSubscriptionManagerNotifyMethod(subscription1, subscription2, subscription3, subscription4);

    simulateAddingBlockOnCanonicalChain();

    verify(subscriptionManager, times(4))
        .sendMessage(subscriptionIdCaptor.capture(), responseCaptor.capture());
    assertThat(subscriptionIdCaptor.getAllValues())
        .containsExactly(
            subscription1.getSubscriptionId(),
            subscription2.getSubscriptionId(),
            subscription3.getSubscriptionId(),
            subscription4.getSubscriptionId());

    verify(blockchainQueries, times(1)).blockByHashWithTxHashes(any());
    verify(blockchainQueries, times(1)).blockByHash(any());
  }

  private BlockResult expectedBlockWithTransactions(final List<Hash> objects) {
    final BlockWithMetadata<Hash, Hash> testBlockWithMetadata =
        new BlockWithMetadata<>(blockHeader, objects, Collections.emptyList(), Difficulty.ONE, 1);
    final BlockResult expectedNewBlock = blockResultFactory.transactionHash(testBlockWithMetadata);

    when(blockchainQueries.blockByHashWithTxHashes(testBlockWithMetadata.getHeader().getHash()))
        .thenReturn(Optional.of(testBlockWithMetadata));
    return expectedNewBlock;
  }

  private void mockSubscriptionManagerNotifyMethod(
      final NewBlockHeadersSubscription... subscriptions) {
    doAnswer(
            invocation -> {
              Consumer<List<NewBlockHeadersSubscription>> consumer = invocation.getArgument(2);
              consumer.accept(List.of(subscriptions));
              return null;
            })
        .when(subscriptionManager)
        .notifySubscribersOnWorkerThread(any(), any(), any());
  }

  private void simulateAddingBlockOnCanonicalChain() {
    final BlockBody blockBody = new BlockBody(Collections.emptyList(), Collections.emptyList());
    final Block testBlock = new Block(blockHeader, blockBody);
    newBlockHeadersSubscriptionService.onBlockAdded(
        BlockAddedEvent.createForHeadAdvancement(testBlock, Collections.emptyList()),
        blockchainQueries.getBlockchain());
    verify(blockchainQueries, times(1)).getBlockchain();
  }

  private void simulateAddingBlockOnNonCanonicalChain() {
    final BlockBody blockBody = new BlockBody(Collections.emptyList(), Collections.emptyList());
    final Block testBlock = new Block(blockHeader, blockBody);
    newBlockHeadersSubscriptionService.onBlockAdded(
        BlockAddedEvent.createForFork(testBlock), blockchainQueries.getBlockchain());
    verify(blockchainQueries, times(1)).getBlockchain();
  }

  private List<TransactionWithMetadata> transactionsWithMetadata() {
    final TransactionWithMetadata t1 =
        new TransactionWithMetadata(
            txTestFixture.createTransaction(KeyPair.generate()), 0L, Hash.ZERO, 0);
    final TransactionWithMetadata t2 =
        new TransactionWithMetadata(
            txTestFixture.createTransaction(KeyPair.generate()), 1L, Hash.ZERO, 1);
    return Lists.newArrayList(t1, t2);
  }

  private List<Hash> transactionsWithHashOnly() {
    final List<Hash> hashes = new ArrayList<>();
    for (final TransactionWithMetadata transactionWithMetadata : transactionsWithMetadata()) {
      hashes.add(transactionWithMetadata.getTransaction().getHash());
    }
    return hashes;
  }

  private NewBlockHeadersSubscription createSubscription(final boolean includeTransactions) {
    return new NewBlockHeadersSubscription(1L, "conn", includeTransactions);
  }
}
