/*
 * 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;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

import java.io.IOException;
import java.security.PrivilegedAction;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hbase.ClusterMetrics.Option;
import org.apache.hadoop.hbase.Waiter.Predicate;
import org.apache.hadoop.hbase.client.Admin;
import org.apache.hadoop.hbase.client.AsyncAdmin;
import org.apache.hadoop.hbase.client.AsyncConnection;
import org.apache.hadoop.hbase.client.Connection;
import org.apache.hadoop.hbase.client.ConnectionFactory;
import org.apache.hadoop.hbase.client.Get;
import org.apache.hadoop.hbase.client.Put;
import org.apache.hadoop.hbase.client.RegionInfoBuilder;
import org.apache.hadoop.hbase.client.RegionStatesCount;
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.coprocessor.CoprocessorHost;
import org.apache.hadoop.hbase.coprocessor.MasterCoprocessor;
import org.apache.hadoop.hbase.coprocessor.MasterCoprocessorEnvironment;
import org.apache.hadoop.hbase.coprocessor.MasterObserver;
import org.apache.hadoop.hbase.coprocessor.ObserverContext;
import org.apache.hadoop.hbase.filter.FilterAllFilter;
import org.apache.hadoop.hbase.master.HMaster;
import org.apache.hadoop.hbase.monitoring.TaskMonitor;
import org.apache.hadoop.hbase.regionserver.HRegionServer;
import org.apache.hadoop.hbase.regionserver.MetricsUserAggregateFactory;
import org.apache.hadoop.hbase.security.User;
import org.apache.hadoop.hbase.security.UserProvider;
import org.apache.hadoop.hbase.testclassification.MediumTests;
import org.apache.hadoop.hbase.util.Bytes;
import org.apache.hadoop.hbase.util.EnvironmentEdgeManager;
import org.apache.hadoop.hbase.util.JVMClusterUtil.MasterThread;
import org.apache.hadoop.hbase.util.JVMClusterUtil.RegionServerThread;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

@Tag(MediumTests.TAG)
public class TestClientClusterMetrics {

  private static HBaseTestingUtility UTIL;
  private static Admin ADMIN;
  private final static int SLAVES = 5;
  private final static int MASTERS = 3;
  private static MiniHBaseCluster CLUSTER;
  private static HRegionServer DEAD;
  private static final TableName TABLE_NAME = TableName.valueOf("test");
  private static final byte[] CF = Bytes.toBytes("cf");

  // We need to promote the visibility of tryRegionServerReport for this test
  public static class MyRegionServer extends MiniHBaseCluster.MiniHBaseClusterRegionServer {
    public MyRegionServer(Configuration conf) throws IOException, InterruptedException {
      super(conf);
    }

    @Override
    public void tryRegionServerReport(long reportStartTime, long reportEndTime) throws IOException {
      super.tryRegionServerReport(reportStartTime, reportEndTime);
    }
  }

  @BeforeAll
  public static void setUpBeforeClass() throws Exception {
    Configuration conf = HBaseConfiguration.create();
    conf.set(CoprocessorHost.MASTER_COPROCESSOR_CONF_KEY, MyObserver.class.getName());
    UTIL = new HBaseTestingUtility(conf);
    StartMiniClusterOption option =
      StartMiniClusterOption.builder().rsClass(TestClientClusterMetrics.MyRegionServer.class)
        .numMasters(MASTERS).numRegionServers(SLAVES).numDataNodes(SLAVES).build();
    UTIL.startMiniCluster(option);
    CLUSTER = UTIL.getHBaseCluster();
    CLUSTER.waitForActiveAndReadyMaster();
    ADMIN = UTIL.getAdmin();
    // Kill one region server
    List<RegionServerThread> rsts = CLUSTER.getLiveRegionServerThreads();
    RegionServerThread rst = rsts.get(rsts.size() - 1);
    DEAD = rst.getRegionServer();
    DEAD.stop("Test dead servers metrics");
    while (rst.isAlive()) {
      Thread.sleep(500);
    }
  }

  @Test
  public void testDefaults() throws Exception {
    ClusterMetrics origin = ADMIN.getClusterMetrics();
    ClusterMetrics defaults = ADMIN.getClusterMetrics(EnumSet.allOf(Option.class));
    assertEquals(origin.getHBaseVersion(), defaults.getHBaseVersion());
    assertEquals(origin.getClusterId(), defaults.getClusterId());
    assertEquals(origin.getAverageLoad(), defaults.getAverageLoad(), 0);
    assertEquals(origin.getBackupMasterNames().size(), defaults.getBackupMasterNames().size());
    assertEquals(origin.getDeadServerNames().size(), defaults.getDeadServerNames().size());
    assertEquals(origin.getRegionCount(), defaults.getRegionCount());
    assertEquals(origin.getLiveServerMetrics().size(), defaults.getLiveServerMetrics().size());
    assertEquals(origin.getMasterInfoPort(), defaults.getMasterInfoPort());
    assertEquals(origin.getServersName().size(), defaults.getServersName().size());
    assertEquals(ADMIN.getRegionServers().size(), defaults.getServersName().size());
  }

  @Test
  public void testAsyncClient() throws Exception {
    try (AsyncConnection asyncConnect =
      ConnectionFactory.createAsyncConnection(UTIL.getConfiguration()).get()) {
      AsyncAdmin asyncAdmin = asyncConnect.getAdmin();
      CompletableFuture<ClusterMetrics> originFuture = asyncAdmin.getClusterMetrics();
      CompletableFuture<ClusterMetrics> defaultsFuture =
        asyncAdmin.getClusterMetrics(EnumSet.allOf(Option.class));
      ClusterMetrics origin = originFuture.get();
      ClusterMetrics defaults = defaultsFuture.get();
      assertEquals(origin.getHBaseVersion(), defaults.getHBaseVersion());
      assertEquals(origin.getClusterId(), defaults.getClusterId());
      assertEquals(origin.getHBaseVersion(), defaults.getHBaseVersion());
      assertEquals(origin.getClusterId(), defaults.getClusterId());
      assertEquals(origin.getAverageLoad(), defaults.getAverageLoad(), 0);
      assertEquals(origin.getBackupMasterNames().size(), defaults.getBackupMasterNames().size());
      assertEquals(origin.getDeadServerNames().size(), defaults.getDeadServerNames().size());
      assertEquals(origin.getRegionCount(), defaults.getRegionCount());
      assertEquals(origin.getLiveServerMetrics().size(), defaults.getLiveServerMetrics().size());
      assertEquals(origin.getMasterInfoPort(), defaults.getMasterInfoPort());
      assertEquals(origin.getServersName().size(), defaults.getServersName().size());
      origin.getTableRegionStatesCount().forEach(((tableName, regionStatesCount) -> {
        RegionStatesCount defaultRegionStatesCount =
          defaults.getTableRegionStatesCount().get(tableName);
        assertEquals(defaultRegionStatesCount, regionStatesCount);
      }));
    }
  }

  @Test
  public void testLiveAndDeadServersStatus() throws Exception {
    // Count the number of live regionservers
    List<RegionServerThread> regionserverThreads = CLUSTER.getLiveRegionServerThreads();
    int numRs = 0;
    int len = regionserverThreads.size();
    for (int i = 0; i < len; i++) {
      if (regionserverThreads.get(i).isAlive()) {
        numRs++;
      }
    }
    // Depending on the (random) order of unit execution we may run this unit before the
    // minicluster is fully up and recovered from the RS shutdown done during test init.
    Waiter.waitFor(CLUSTER.getConfiguration(), 10 * 1000, 100, new Predicate<Exception>() {
      @Override
      public boolean evaluate() throws Exception {
        ClusterMetrics metrics = ADMIN.getClusterMetrics(EnumSet.of(Option.LIVE_SERVERS));
        assertNotNull(metrics);
        return metrics.getRegionCount() > 0;
      }
    });
    // Retrieve live servers and dead servers info.
    EnumSet<Option> options =
      EnumSet.of(Option.LIVE_SERVERS, Option.DEAD_SERVERS, Option.SERVERS_NAME);
    ClusterMetrics metrics = ADMIN.getClusterMetrics(options);
    assertNotNull(metrics);
    // exclude a dead region server
    assertEquals(SLAVES - 1, numRs);
    // live servers = nums of regionservers
    // By default, HMaster don't carry any regions so it won't report its load.
    // Hence, it won't be in the server list.
    assertEquals(numRs, metrics.getLiveServerMetrics().size());
    assertTrue(metrics.getRegionCount() > 0);
    assertNotNull(metrics.getDeadServerNames());
    assertEquals(1, metrics.getDeadServerNames().size());
    ServerName deadServerName = metrics.getDeadServerNames().iterator().next();
    assertEquals(DEAD.getServerName(), deadServerName);
    assertNotNull(metrics.getServersName());
    assertEquals(numRs, metrics.getServersName().size());
  }

  @Test
  public void testRegionStatesCount() throws Exception {
    Table table = UTIL.createTable(TABLE_NAME, CF);
    table.put(new Put(Bytes.toBytes("k1")).addColumn(CF, Bytes.toBytes("q1"), Bytes.toBytes("v1")));
    table.put(new Put(Bytes.toBytes("k2")).addColumn(CF, Bytes.toBytes("q2"), Bytes.toBytes("v2")));
    table.put(new Put(Bytes.toBytes("k3")).addColumn(CF, Bytes.toBytes("q3"), Bytes.toBytes("v3")));

    ClusterMetrics metrics = ADMIN.getClusterMetrics();
    assertEquals(metrics.getTableRegionStatesCount().size(), 3);
    assertEquals(
      metrics.getTableRegionStatesCount().get(TableName.META_TABLE_NAME).getRegionsInTransition(),
      0);
    assertEquals(
      metrics.getTableRegionStatesCount().get(TableName.META_TABLE_NAME).getOpenRegions(), 1);
    assertEquals(
      metrics.getTableRegionStatesCount().get(TableName.META_TABLE_NAME).getTotalRegions(), 1);
    assertEquals(
      metrics.getTableRegionStatesCount().get(TableName.META_TABLE_NAME).getClosedRegions(), 0);
    assertEquals(
      metrics.getTableRegionStatesCount().get(TableName.META_TABLE_NAME).getSplitRegions(), 0);
    assertEquals(metrics.getTableRegionStatesCount().get(TABLE_NAME).getRegionsInTransition(), 0);
    assertEquals(metrics.getTableRegionStatesCount().get(TABLE_NAME).getOpenRegions(), 1);
    assertEquals(metrics.getTableRegionStatesCount().get(TABLE_NAME).getTotalRegions(), 1);

    UTIL.deleteTable(TABLE_NAME);
  }

  @Test
  public void testMasterAndBackupMastersStatus() throws Exception {
    // get all the master threads
    List<MasterThread> masterThreads = CLUSTER.getMasterThreads();
    int numActive = 0;
    int activeIndex = 0;
    ServerName activeName = null;
    HMaster active = null;
    for (int i = 0; i < masterThreads.size(); i++) {
      if (masterThreads.get(i).getMaster().isActiveMaster()) {
        numActive++;
        activeIndex = i;
        active = masterThreads.get(activeIndex).getMaster();
        activeName = active.getServerName();
      }
    }
    assertNotNull(active);
    assertEquals(1, numActive);
    assertEquals(MASTERS, masterThreads.size());
    // Retrieve master and backup masters infos only.
    EnumSet<Option> options = EnumSet.of(Option.MASTER, Option.BACKUP_MASTERS);
    ClusterMetrics metrics = ADMIN.getClusterMetrics(options);
    assertTrue(metrics.getMasterName().equals(activeName));
    assertEquals(MASTERS - 1, metrics.getBackupMasterNames().size());
  }

  @Test
  public void testUserMetrics() throws Exception {
    Configuration conf = UTIL.getConfiguration();
    // If metrics for users is not enabled, this test doesn't make sense.
    if (
      !conf.getBoolean(MetricsUserAggregateFactory.METRIC_USER_ENABLED_CONF,
        MetricsUserAggregateFactory.DEFAULT_METRIC_USER_ENABLED_CONF)
    ) {
      return;
    }
    User userFoo = User.createUserForTesting(conf, "FOO_USER_METRIC_TEST", new String[0]);
    User userBar = User.createUserForTesting(conf, "BAR_USER_METRIC_TEST", new String[0]);
    User userTest = User.createUserForTesting(conf, "TEST_USER_METRIC_TEST", new String[0]);
    UTIL.createTable(TABLE_NAME, CF);
    waitForUsersMetrics(0);
    long writeMetaMetricBeforeNextuser = getMetaMetrics().getWriteRequestCount();
    userFoo.runAs(new PrivilegedAction<Void>() {
      @Override
      public Void run() {
        try {
          doPut();
        } catch (IOException e) {
          fail("Exception:" + e.getMessage());
        }
        return null;
      }
    });
    waitForUsersMetrics(1);
    long writeMetaMetricForUserFoo =
      getMetaMetrics().getWriteRequestCount() - writeMetaMetricBeforeNextuser;
    long readMetaMetricBeforeNextuser = getMetaMetrics().getReadRequestCount();
    userBar.runAs(new PrivilegedAction<Void>() {
      @Override
      public Void run() {
        try {
          doGet();
        } catch (IOException e) {
          fail("Exception:" + e.getMessage());
        }
        return null;
      }
    });
    waitForUsersMetrics(2);
    long readMetaMetricForUserBar =
      getMetaMetrics().getReadRequestCount() - readMetaMetricBeforeNextuser;
    long filteredMetaReqeust = getMetaMetrics().getFilteredReadRequestCount();
    userTest.runAs(new PrivilegedAction<Void>() {
      @Override
      public Void run() {
        try {
          Table table = createConnection(UTIL.getConfiguration()).getTable(TABLE_NAME);
          for (Result result : table.getScanner(new Scan().setFilter(new FilterAllFilter()))) {
            fail("Should have filtered all rows");
          }
        } catch (IOException e) {
          fail("Exception:" + e.getMessage());
        }
        return null;
      }
    });
    waitForUsersMetrics(3);
    long filteredMetaReqeustForTestUser =
      getMetaMetrics().getFilteredReadRequestCount() - filteredMetaReqeust;
    Map<byte[], UserMetrics> userMap = ADMIN.getClusterMetrics(EnumSet.of(Option.LIVE_SERVERS))
      .getLiveServerMetrics().values().iterator().next().getUserMetrics();
    for (byte[] user : userMap.keySet()) {
      switch (Bytes.toString(user)) {
        case "FOO_USER_METRIC_TEST":
          assertEquals(1, userMap.get(user).getWriteRequestCount() - writeMetaMetricForUserFoo);
          break;
        case "BAR_USER_METRIC_TEST":
          assertEquals(1, userMap.get(user).getReadRequestCount() - readMetaMetricForUserBar);
          assertEquals(0, userMap.get(user).getWriteRequestCount());
          break;
        case "TEST_USER_METRIC_TEST":
          assertEquals(1,
            userMap.get(user).getFilteredReadRequests() - filteredMetaReqeustForTestUser);
          assertEquals(0, userMap.get(user).getWriteRequestCount());
          break;
        default:
          // current user
          assertEquals(UserProvider.instantiate(conf).getCurrent().getName(), Bytes.toString(user));
          // Read/write count because of Meta operations
          assertTrue(userMap.get(user).getReadRequestCount() > 1);
          break;
      }
    }
    UTIL.deleteTable(TABLE_NAME);
  }

  @Test
  public void testServerTasks() throws Exception {
    // TaskMonitor is a singleton per VM, so will be shared among all minicluster "servers",
    // so we only need to look at the first live server's results to find it.
    final String testTaskName = "TEST TASK";
    TaskMonitor.get().createStatus(testTaskName).setStatus("Testing 1... 2... 3...");
    // Of course, first we must trigger regionserver reports.
    final long now = EnvironmentEdgeManager.currentTime();
    final long last = now - 1000; // fake a period, or someone might div by zero
    for (RegionServerThread rs : CLUSTER.getRegionServerThreads()) {
      ((MyRegionServer) rs.getRegionServer()).tryRegionServerReport(last, now);
    }
    // Get status now
    ClusterMetrics clusterMetrics = ADMIN.getClusterMetrics(EnumSet.of(Option.TASKS));
    // The test task will be in the master metrics list
    boolean found = false;
    for (ServerTask task : clusterMetrics.getMasterTasks()) {
      if (testTaskName.equals(task.getDescription())) {
        // Found it
        found = true;
        break;
      }
    }
    assertTrue(found, "Expected task not found in master task list");
    // Get the tasks information (carried in server metrics)
    found = false;
    for (ServerMetrics serverMetrics : clusterMetrics.getLiveServerMetrics().values()) {
      if (serverMetrics.getTasks() != null) {
        for (ServerTask task : serverMetrics.getTasks()) {
          if (testTaskName.equals(task.getDescription())) {
            // Found it
            found = true;
            break;
          }
        }
      }
    }
    // We will fall through here if getClusterMetrics(TASKS) did not correctly process the
    // task list.
    assertTrue(found, "Expected task not found in server load");
  }

  private RegionMetrics getMetaMetrics() throws IOException {
    for (ServerMetrics serverMetrics : ADMIN.getClusterMetrics(EnumSet.of(Option.LIVE_SERVERS))
      .getLiveServerMetrics().values()) {
      RegionMetrics metaMetrics = serverMetrics.getRegionMetrics()
        .get(RegionInfoBuilder.FIRST_META_REGIONINFO.getRegionName());
      if (metaMetrics != null) {
        return metaMetrics;
      }
    }
    fail("Should have find meta metrics");
    return null;
  }

  private void waitForUsersMetrics(int noOfUsers) throws Exception {
    // Sleep for metrics to get updated on master
    Thread.sleep(5000);
    Waiter.waitFor(CLUSTER.getConfiguration(), 10 * 1000, 100, new Predicate<Exception>() {
      @Override
      public boolean evaluate() throws Exception {
        Map<byte[], UserMetrics> metrics = ADMIN.getClusterMetrics(EnumSet.of(Option.LIVE_SERVERS))
          .getLiveServerMetrics().values().iterator().next().getUserMetrics();
        assertNotNull(metrics);
        // including current user + noOfUsers
        return metrics.keySet().size() > noOfUsers;
      }
    });
  }

  private void doPut() throws IOException {
    try (Connection conn = createConnection(UTIL.getConfiguration())) {
      Table table = conn.getTable(TABLE_NAME);
      table
        .put(new Put(Bytes.toBytes("a")).addColumn(CF, Bytes.toBytes("col1"), Bytes.toBytes("1")));
    }
  }

  private void doGet() throws IOException {
    try (Connection conn = createConnection(UTIL.getConfiguration())) {
      Table table = conn.getTable(TABLE_NAME);
      table.get(new Get(Bytes.toBytes("a")).addColumn(CF, Bytes.toBytes("col1")));
    }
  }

  private Connection createConnection(Configuration conf) throws IOException {
    User user = UserProvider.instantiate(conf).getCurrent();
    return ConnectionFactory.createConnection(conf, user);
  }

  @Test
  public void testOtherStatusInfos() throws Exception {
    EnumSet<Option> options = EnumSet.of(Option.MASTER_COPROCESSORS, Option.HBASE_VERSION,
      Option.CLUSTER_ID, Option.BALANCER_ON);
    ClusterMetrics metrics = ADMIN.getClusterMetrics(options);
    assertEquals(1, metrics.getMasterCoprocessorNames().size());
    assertNotNull(metrics.getHBaseVersion());
    assertNotNull(metrics.getClusterId());
    assertTrue(metrics.getAverageLoad() == 0.0);
    assertNotNull(metrics.getBalancerOn());
  }

  @AfterAll
  public static void tearDownAfterClass() throws Exception {
    if (ADMIN != null) {
      ADMIN.close();
    }
    UTIL.shutdownMiniCluster();
  }

  @Test
  public void testObserver() throws IOException {
    int preCount = MyObserver.PRE_COUNT.get();
    int postCount = MyObserver.POST_COUNT.get();
    assertTrue(ADMIN.getClusterMetrics().getMasterCoprocessorNames().stream()
      .anyMatch(s -> s.equals(MyObserver.class.getSimpleName())));
    assertEquals(preCount + 1, MyObserver.PRE_COUNT.get());
    assertEquals(postCount + 1, MyObserver.POST_COUNT.get());
  }

  private static void insertData(final TableName tableName, int startRow, int rowCount)
    throws IOException {
    Table t = UTIL.getConnection().getTable(tableName);
    Put p;
    for (int i = 0; i < rowCount; i++) {
      p = new Put(Bytes.toBytes("" + (startRow + i)));
      p.addColumn(CF, Bytes.toBytes("val1"), Bytes.toBytes(i));
      t.put(p);
    }
  }

  public static class MyObserver implements MasterCoprocessor, MasterObserver {
    private static final AtomicInteger PRE_COUNT = new AtomicInteger(0);
    private static final AtomicInteger POST_COUNT = new AtomicInteger(0);

    @Override
    public Optional<MasterObserver> getMasterObserver() {
      return Optional.of(this);
    }

    @Override
    public void preGetClusterMetrics(ObserverContext<MasterCoprocessorEnvironment> ctx)
      throws IOException {
      PRE_COUNT.incrementAndGet();
    }

    @Override
    public void postGetClusterMetrics(ObserverContext<MasterCoprocessorEnvironment> ctx,
      ClusterMetrics metrics) throws IOException {
      POST_COUNT.incrementAndGet();
    }
  }
}
