/*
 * 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.cli.subcommands.blocks;

import static com.google.common.base.Preconditions.checkNotNull;
import static org.hyperledger.besu.cli.subcommands.blocks.BlocksSubCommand.COMMAND_NAME;

import org.hyperledger.besu.chainexport.RlpBlockExporter;
import org.hyperledger.besu.chainimport.JsonBlockImporter;
import org.hyperledger.besu.chainimport.RlpBlockImporter;
import org.hyperledger.besu.cli.BesuCommand;
import org.hyperledger.besu.cli.DefaultCommandValues;
import org.hyperledger.besu.cli.subcommands.blocks.BlocksSubCommand.ExportSubCommand;
import org.hyperledger.besu.cli.subcommands.blocks.BlocksSubCommand.ImportSubCommand;
import org.hyperledger.besu.cli.util.VersionProvider;
import org.hyperledger.besu.controller.BesuController;
import org.hyperledger.besu.datatypes.Address;
import org.hyperledger.besu.datatypes.Wei;
import org.hyperledger.besu.ethereum.ProtocolContext;
import org.hyperledger.besu.ethereum.blockcreation.IncrementingNonceGenerator;
import org.hyperledger.besu.ethereum.chain.Blockchain;
import org.hyperledger.besu.ethereum.core.ImmutableMiningConfiguration;
import org.hyperledger.besu.ethereum.core.ImmutableMiningConfiguration.MutableInitValues;
import org.hyperledger.besu.ethereum.core.MiningConfiguration;
import org.hyperledger.besu.metrics.MetricsService;
import org.hyperledger.besu.metrics.prometheus.MetricsConfiguration;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Supplier;

import jakarta.validation.constraints.NotBlank;
import org.apache.tuweni.bytes.Bytes;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.ExecutionException;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.ParameterException;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.ParentCommand;
import picocli.CommandLine.Spec;

/** Blocks related sub-command */
@Command(
    name = COMMAND_NAME,
    description = "This command provides blocks related actions.",
    mixinStandardHelpOptions = true,
    versionProvider = VersionProvider.class,
    subcommands = {ImportSubCommand.class, ExportSubCommand.class})
public class BlocksSubCommand implements Runnable {

  private static final Logger LOG = LoggerFactory.getLogger(BlocksSubCommand.class);

  /** The constant COMMAND_NAME. */
  public static final String COMMAND_NAME = "blocks";

  @SuppressWarnings("unused")
  @ParentCommand
  private BesuCommand parentCommand; // Picocli injects reference to parent command

  @SuppressWarnings("unused")
  @Spec
  private CommandSpec spec; // Picocli injects reference to command spec

  private final Supplier<RlpBlockImporter> rlpBlockImporter;
  private final Function<BesuController, JsonBlockImporter> jsonBlockImporterFactory;
  private final Function<Blockchain, RlpBlockExporter> rlpBlockExporterFactory;

  private final PrintWriter out;

  /**
   * Instantiates a new Blocks sub command.
   *
   * @param rlpBlockImporter the RLP block importer
   * @param jsonBlockImporterFactory the Json block importer factory
   * @param rlpBlockExporterFactory the RLP block exporter factory
   * @param out Instance of PrintWriter where command usage will be written.
   */
  public BlocksSubCommand(
      final Supplier<RlpBlockImporter> rlpBlockImporter,
      final Function<BesuController, JsonBlockImporter> jsonBlockImporterFactory,
      final Function<Blockchain, RlpBlockExporter> rlpBlockExporterFactory,
      final PrintWriter out) {
    this.rlpBlockImporter = rlpBlockImporter;
    this.rlpBlockExporterFactory = rlpBlockExporterFactory;
    this.jsonBlockImporterFactory = jsonBlockImporterFactory;
    this.out = out;
  }

  @Override
  public void run() {
    spec.commandLine().usage(out);
  }

  /**
   * blocks import sub-command
   *
   * <p>Imports blocks from a file into the database
   */
  @Command(
      name = "import",
      description = "This command imports blocks from a file into the database.",
      mixinStandardHelpOptions = true,
      versionProvider = VersionProvider.class)
  static class ImportSubCommand implements Runnable {
    @SuppressWarnings("unused")
    @ParentCommand
    private BlocksSubCommand parentCommand; // Picocli injects reference to parent command

    @Parameters(
        paramLabel = DefaultCommandValues.MANDATORY_FILE_FORMAT_HELP,
        description = "Files containing blocks to import.",
        arity = "0..*")
    private final List<Path> blockImportFiles = new ArrayList<>();

    @Option(
        names = "--from",
        paramLabel = DefaultCommandValues.MANDATORY_FILE_FORMAT_HELP,
        description = "File containing blocks to import.",
        arity = "0..*")
    private final List<Path> blockImportFileOption = new ArrayList<>();

    @Option(
        names = "--format",
        description =
            "The type of data to be imported, possible values are: ${COMPLETION-CANDIDATES} (default: ${DEFAULT-VALUE}).",
        arity = "1..1")
    private final BlockImportFormat format = BlockImportFormat.RLP;

    @Option(
        names = "--start-time",
        description =
            "The timestamp in seconds of the first block for JSON imports. Subsequent blocks will be 1 second later. (default: current time)",
        arity = "1..1")
    private final Long startTime = System.currentTimeMillis() / 1000;

    @Option(
        names = "--skip-pow-validation-enabled",
        description = "Skip proof of work validation when importing.")
    private final Boolean skipPow = false;

    @Option(names = "--run", description = "Start besu after importing.")
    private final Boolean runBesu = false;

    @Option(
        names = "--start-block",
        paramLabel = DefaultCommandValues.MANDATORY_LONG_FORMAT_HELP,
        description =
            "The starting index of the block, or block list to import.  If not specified all blocks before the end block will be imported",
        arity = "1..1")
    private final Long startBlock = 0L;

    @Option(
        names = "--end-block",
        paramLabel = DefaultCommandValues.MANDATORY_LONG_FORMAT_HELP,
        description =
            "The ending index of the block list to import (exclusive).  If not specified all blocks after the start block will be imported.",
        arity = "1..1")
    private final Long endBlock = Long.MAX_VALUE;

    @SuppressWarnings("unused")
    @Spec
    private CommandSpec spec;

    @Override
    public void run() {
      parentCommand.parentCommand.configureLogging(false);
      blockImportFiles.addAll(blockImportFileOption);

      checkCommand(parentCommand);
      checkNotNull(parentCommand.rlpBlockImporter);
      checkNotNull(parentCommand.jsonBlockImporterFactory);
      if (blockImportFiles.isEmpty()) {
        throw new ParameterException(spec.commandLine(), "No files specified to import.");
      }
      if (skipPow && format.equals(BlockImportFormat.JSON)) {
        throw new ParameterException(
            spec.commandLine(), "Can't skip proof of work validation for JSON blocks");
      }
      LOG.info("Import {} block data from {} files", format, blockImportFiles.size());
      final Optional<MetricsService> metricsService = initMetrics(parentCommand);

      try (final BesuController controller = createController()) {
        for (final Path path : blockImportFiles) {
          try {
            LOG.info("Importing from {}", path);
            switch (format) {
              case RLP:
                importRlpBlocks(controller, path);
                break;
              case JSON:
                importJsonBlocks(controller, path);
                break;
            }
          } catch (final FileNotFoundException e) {
            if (blockImportFiles.size() == 1) {
              throw new ExecutionException(
                  spec.commandLine(), "Could not find file to import: " + path);
            } else {
              LOG.error("Could not find file to import: {}", path);
            }
          } catch (final Exception e) {
            if (blockImportFiles.size() == 1) {
              throw new ExecutionException(
                  spec.commandLine(), "Unable to import blocks from " + path, e);
            } else {
              LOG.error("Unable to import blocks from " + path, e);
            }
          }
        }

        if (runBesu) {
          parentCommand.parentCommand.run();
        }
      } finally {
        metricsService.ifPresent(MetricsService::stop);
      }
    }

    private static void checkCommand(final BlocksSubCommand parentCommand) {
      checkNotNull(parentCommand);
      checkNotNull(parentCommand.parentCommand);
    }

    private BesuController createController() {
      try {
        // Set some defaults
        return parentCommand
            .parentCommand
            .setupControllerBuilder()
            // set to mainnet genesis block so validation rules won't reject it.
            .clock(Clock.fixed(Instant.ofEpochSecond(startTime), ZoneOffset.UTC))
            .miningParameters(getMiningParameters())
            .build();
      } catch (final Exception e) {
        throw new ExecutionException(parentCommand.spec.commandLine(), e.getMessage(), e);
      }
    }

    private MiningConfiguration getMiningParameters() {
      final Wei minTransactionGasPrice = Wei.ZERO;
      // Extradata and coinbase can be configured on a per-block level via the json file
      final Address coinbase = Address.ZERO;
      final Bytes extraData = Bytes.EMPTY;
      return ImmutableMiningConfiguration.builder()
          .mutableInitValues(
              MutableInitValues.builder()
                  .nonceGenerator(new IncrementingNonceGenerator(0))
                  .extraData(extraData)
                  .minTransactionGasPrice(minTransactionGasPrice)
                  .coinbase(coinbase)
                  .build())
          .build();
    }

    private void importJsonBlocks(final BesuController controller, final Path path)
        throws IOException {

      final JsonBlockImporter importer = parentCommand.jsonBlockImporterFactory.apply(controller);
      final String jsonData = Files.readString(path);
      importer.importChain(jsonData);
    }

    private void importRlpBlocks(final BesuController controller, final Path path)
        throws IOException {
      parentCommand
          .rlpBlockImporter
          .get()
          .importBlockchain(path, controller, skipPow, startBlock, endBlock);
    }
  }

  /**
   * blocks export sub-command
   *
   * <p>Export a block list from storage
   */
  @Command(
      name = "export",
      description = "This command exports a specific block, or list of blocks from storage.",
      mixinStandardHelpOptions = true,
      versionProvider = VersionProvider.class)
  static class ExportSubCommand implements Runnable {
    @SuppressWarnings("unused")
    @ParentCommand
    private BlocksSubCommand parentCommand; // Picocli injects reference to parent command

    @Option(
        names = "--start-block",
        paramLabel = DefaultCommandValues.MANDATORY_LONG_FORMAT_HELP,
        description = "The starting index of the block, or block list to export.",
        arity = "1..1")
    private final Long startBlock = null;

    @Option(
        names = "--end-block",
        paramLabel = DefaultCommandValues.MANDATORY_LONG_FORMAT_HELP,
        description =
            "The ending index of the block list to export (exclusive). If not specified a single block will be exported.",
        arity = "1..1")
    private final Long endBlock = null;

    @Option(
        names = "--format",
        hidden = true,
        description =
            "The format to export, possible values are: ${COMPLETION-CANDIDATES} (default: ${DEFAULT-VALUE}).",
        arity = "1..1")
    private final BlockExportFormat format = BlockExportFormat.RLP;

    @NotBlank
    @Option(
        names = "--to",
        required = true,
        paramLabel = DefaultCommandValues.MANDATORY_FILE_FORMAT_HELP,
        description = "File to write the block list to.",
        arity = "1..1")
    private final File blocksExportFile = null;

    @SuppressWarnings("unused")
    @Spec
    private CommandSpec spec;

    @Override
    public void run() {
      parentCommand.parentCommand.configureLogging(false);
      LOG.info("Export {} block data to file {}", format, blocksExportFile.toPath());

      checkCommand(this, startBlock, endBlock);
      final Optional<MetricsService> metricsService = initMetrics(parentCommand);

      final BesuController controller = createBesuController();
      try {
        if (format == BlockExportFormat.RLP) {
          exportRlpFormat(controller);
        } else {
          throw new ParameterException(
              spec.commandLine(), "Unsupported format: " + format.toString());
        }
      } catch (final IOException e) {
        throw new ExecutionException(
            spec.commandLine(), "An error occurred while exporting blocks.", e);
      } finally {
        metricsService.ifPresent(MetricsService::stop);
      }
    }

    private BesuController createBesuController() {
      return parentCommand
          .parentCommand
          .setupControllerBuilder()
          .miningParameters(MiningConfiguration.newDefault())
          .build();
    }

    private void exportRlpFormat(final BesuController controller) throws IOException {
      final ProtocolContext context = controller.getProtocolContext();
      final RlpBlockExporter exporter =
          parentCommand.rlpBlockExporterFactory.apply(context.getBlockchain());
      exporter.exportBlocks(blocksExportFile, getStartBlock(), getEndBlock());
    }

    private void checkCommand(
        final ExportSubCommand exportSubCommand, final Long startBlock, final Long endBlock) {
      checkNotNull(exportSubCommand.parentCommand);

      final Optional<Long> maybeStartBlock = getStartBlock();
      final Optional<Long> maybeEndBlock = getEndBlock();

      maybeStartBlock
          .filter(blockNum -> blockNum < 0)
          .ifPresent(
              (blockNum) -> {
                throw new CommandLine.ParameterException(
                    spec.commandLine(),
                    "Parameter --start-block ("
                        + blockNum
                        + ") must be greater than or equal to zero.");
              });

      maybeEndBlock
          .filter(blockNum -> blockNum < 0)
          .ifPresent(
              (blockNum) -> {
                throw new CommandLine.ParameterException(
                    spec.commandLine(),
                    "Parameter --end-block ("
                        + blockNum
                        + ") must be greater than or equal to zero.");
              });

      if (maybeStartBlock.isPresent() && maybeEndBlock.isPresent()) {
        if (endBlock <= startBlock) {
          throw new CommandLine.ParameterException(
              spec.commandLine(),
              "Parameter --end-block ("
                  + endBlock
                  + ") must be greater start block ("
                  + startBlock
                  + ").");
        }
      }

      // Error if data directory is empty
      final Path databasePath =
          Paths.get(
              parentCommand.parentCommand.dataDir().toAbsolutePath().toString(),
              BesuController.DATABASE_PATH);
      final File databaseDirectory = new File(databasePath.toString());
      if (!databaseDirectory.isDirectory() || databaseDirectory.list().length == 0) {
        // Empty data directory, nothing to export
        throw new CommandLine.ParameterException(
            spec.commandLine(),
            "Chain is empty.  Unable to export blocks from specified data directory: "
                + databaseDirectory.toString());
      }
    }

    private Optional<Long> getStartBlock() {
      return Optional.ofNullable(startBlock);
    }

    private Optional<Long> getEndBlock() {
      return Optional.ofNullable(endBlock);
    }
  }

  private static Optional<MetricsService> initMetrics(final BlocksSubCommand parentCommand) {
    final MetricsConfiguration metricsConfiguration =
        parentCommand.parentCommand.metricsConfiguration();

    Optional<MetricsService> metricsService =
        MetricsService.create(metricsConfiguration, parentCommand.parentCommand.getMetricsSystem());
    metricsService.ifPresent(MetricsService::start);
    return metricsService;
  }
}
