/*
 * 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.hadoop.hbase.backup;

import static org.apache.hadoop.hbase.backup.BackupInfo.BackupState.COMPLETE;
import static org.apache.hadoop.hbase.backup.BackupTestUtil.enableBackup;
import static org.apache.hadoop.hbase.backup.BackupTestUtil.verifyBackup;
import static org.apache.hadoop.hbase.backup.BackupType.FULL;
import static org.apache.hadoop.hbase.backup.BackupType.INCREMENTAL;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Stream;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.hbase.Cell;
import org.apache.hadoop.hbase.HBaseConfiguration;
import org.apache.hadoop.hbase.HBaseParameterizedTestTemplate;
import org.apache.hadoop.hbase.KeyValue;
import org.apache.hadoop.hbase.TableName;
import org.apache.hadoop.hbase.backup.impl.BackupAdminImpl;
import org.apache.hadoop.hbase.client.Admin;
import org.apache.hadoop.hbase.client.ColumnFamilyDescriptorBuilder;
import org.apache.hadoop.hbase.client.Connection;
import org.apache.hadoop.hbase.client.ConnectionFactory;
import org.apache.hadoop.hbase.client.Put;
import org.apache.hadoop.hbase.client.Result;
import org.apache.hadoop.hbase.client.Scan;
import org.apache.hadoop.hbase.client.Table;
import org.apache.hadoop.hbase.client.TableDescriptorBuilder;
import org.apache.hadoop.hbase.io.hfile.HFile;
import org.apache.hadoop.hbase.io.hfile.HFileContextBuilder;
import org.apache.hadoop.hbase.testclassification.LargeTests;
import org.apache.hadoop.hbase.testing.TestingHBaseCluster;
import org.apache.hadoop.hbase.testing.TestingHBaseClusterOption;
import org.apache.hadoop.hbase.tool.BulkLoadHFiles;
import org.apache.hadoop.hbase.util.Bytes;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.params.provider.Arguments;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Tag(LargeTests.TAG)
@HBaseParameterizedTestTemplate(name = "{index}: useBulkLoad={0}")
public class TestIncrementalBackupMergeWithBulkLoad {

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

  public boolean useBulkLoad;

  public TestIncrementalBackupMergeWithBulkLoad(boolean useBulkLoad) {
    this.useBulkLoad = useBulkLoad;
  }

  public static Stream<Arguments> parameters() {
    return Stream.of(Arguments.of(true), Arguments.of(false));
  }

  private TableName sourceTable;
  private TableName targetTable;

  private List<TableName> allTables;
  private static TestingHBaseCluster cluster;
  private static final Path BACKUP_ROOT_DIR = new Path("backupIT");
  private static final byte[] COLUMN_FAMILY = Bytes.toBytes("0");

  @BeforeAll
  public static void beforeClass() throws Exception {
    Configuration conf = HBaseConfiguration.create();
    enableBackup(conf);
    cluster = TestingHBaseCluster.create(TestingHBaseClusterOption.builder().conf(conf).build());
    cluster.start();
  }

  @AfterAll
  public static void afterClass() throws Exception {
    cluster.stop();
  }

  @BeforeEach
  public void setUp() throws Exception {
    sourceTable = TableName.valueOf("table-" + useBulkLoad);
    targetTable = TableName.valueOf("another-table-" + useBulkLoad);
    allTables = Arrays.asList(sourceTable, targetTable);
    createTable(sourceTable);
    createTable(targetTable);
  }

  @TestTemplate
  public void testMergeContainingBulkloadedHfiles() throws Exception {
    Instant timestamp = Instant.now();

    String backupId = backup(FULL, allTables);
    BackupInfo backupInfo = verifyBackup(cluster.getConf(), backupId, FULL, COMPLETE);
    assertTrue(backupInfo.getTables().contains(sourceTable));

    // load some data
    load(sourceTable, timestamp, "data");

    String backupId1 = backup(INCREMENTAL, allTables);
    backupInfo = verifyBackup(cluster.getConf(), backupId1, INCREMENTAL, COMPLETE);
    assertTrue(backupInfo.getTables().contains(sourceTable));

    String backupId2 = backup(INCREMENTAL, allTables);
    backupInfo = verifyBackup(cluster.getConf(), backupId2, INCREMENTAL, COMPLETE);
    assertTrue(backupInfo.getTables().contains(sourceTable));

    merge(new String[] { backupId1, backupId2 });
    backupInfo = verifyBackup(cluster.getConf(), backupId2, INCREMENTAL, COMPLETE);
    assertTrue(backupInfo.getTables().contains(sourceTable));
    validateDataEquals(sourceTable, "data");
  }

  private void createTable(TableName tableName) throws IOException {
    TableDescriptorBuilder builder = TableDescriptorBuilder.newBuilder(tableName)
      .setColumnFamily(ColumnFamilyDescriptorBuilder.of(COLUMN_FAMILY));
    try (Connection connection = ConnectionFactory.createConnection(cluster.getConf());
      Admin admin = connection.getAdmin()) {
      admin.createTable(builder.build());
    }
  }

  private void load(TableName tableName, Instant timestamp, String data) throws IOException {
    if (useBulkLoad) {
      hFileBulkLoad(tableName, timestamp, data);
    } else {
      putLoad(tableName, timestamp, data);
    }
  }

  private void putLoad(TableName tableName, Instant timestamp, String data) throws IOException {
    LOG.info("Writing new data to HBase using normal Puts: {}", data);
    try (Connection connection = ConnectionFactory.createConnection(cluster.getConf())) {
      Table table = connection.getTable(sourceTable);
      List<Put> puts = new ArrayList<>();
      for (int i = 0; i < 10; i++) {
        Put put = new Put(Bytes.toBytes(i), timestamp.toEpochMilli());
        put.addColumn(COLUMN_FAMILY, Bytes.toBytes("data"), Bytes.toBytes(data));
        puts.add(put);

        if (i % 100 == 0) {
          table.put(puts);
          puts.clear();
        }
      }
      if (!puts.isEmpty()) {
        table.put(puts);
      }
      connection.getAdmin().flush(tableName);
    }
  }

  private void hFileBulkLoad(TableName tableName, Instant timestamp, String data)
    throws IOException {
    FileSystem fs = FileSystem.get(cluster.getConf());
    LOG.info("Writing new data to HBase using BulkLoad: {}", data);
    // HFiles require this strict directory structure to allow to load them
    Path hFileRootPath = new Path("/tmp/hfiles_" + UUID.randomUUID());
    fs.mkdirs(hFileRootPath);
    Path hFileFamilyPath = new Path(hFileRootPath, Bytes.toString(COLUMN_FAMILY));
    fs.mkdirs(hFileFamilyPath);
    try (HFile.Writer writer = HFile.getWriterFactoryNoCache(cluster.getConf())
      .withPath(fs, new Path(hFileFamilyPath, "hfile_" + UUID.randomUUID()))
      .withFileContext(new HFileContextBuilder().withTableName(tableName.toBytes())
        .withColumnFamily(COLUMN_FAMILY).build())
      .create()) {
      for (int i = 0; i < 10; i++) {
        writer.append(new KeyValue(Bytes.toBytes(i), COLUMN_FAMILY, Bytes.toBytes("data"),
          timestamp.toEpochMilli(), Bytes.toBytes(data)));
      }
    }
    Map<BulkLoadHFiles.LoadQueueItem, ByteBuffer> result =
      BulkLoadHFiles.create(cluster.getConf()).bulkLoad(tableName, hFileRootPath);
    assertFalse(result.isEmpty());
  }

  private String backup(BackupType backupType, List<TableName> tables) throws IOException {
    LOG.info("Creating the backup ...");

    try (Connection connection = ConnectionFactory.createConnection(cluster.getConf());
      BackupAdmin backupAdmin = new BackupAdminImpl(connection)) {
      BackupRequest backupRequest =
        new BackupRequest.Builder().withTargetRootDir(BACKUP_ROOT_DIR.toString())
          .withTableList(new ArrayList<>(tables)).withBackupType(backupType).build();
      return backupAdmin.backupTables(backupRequest);
    }
  }

  private void merge(String[] backupIds) throws IOException {
    LOG.info("Merging the backups ...");

    try (Connection connection = ConnectionFactory.createConnection(cluster.getConf());
      BackupAdmin backupAdmin = new BackupAdminImpl(connection)) {
      backupAdmin.mergeBackups(backupIds);
    }
  }

  private void validateDataEquals(TableName tableName, String expectedData) throws IOException {
    try (Connection connection = ConnectionFactory.createConnection(cluster.getConf());
      Table table = connection.getTable(tableName)) {
      Scan scan = new Scan();
      scan.readAllVersions();
      scan.setRaw(true);
      scan.setBatch(100);

      for (Result sourceResult : table.getScanner(scan)) {
        List<Cell> sourceCells = sourceResult.listCells();
        for (Cell cell : sourceCells) {
          assertEquals(expectedData, Bytes.toStringBinary(cell.getValueArray(),
            cell.getValueOffset(), cell.getValueLength()));
        }
      }
    }
  }

}
