/*
 * 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.geode.metrics;

import static java.io.File.pathSeparatorChar;
import static java.util.concurrent.TimeUnit.NANOSECONDS;
import static java.util.stream.Collectors.toList;
import static org.apache.geode.cache.execute.FunctionService.onServer;
import static org.apache.geode.distributed.ConfigurationProperties.SECURITY_MANAGER;
import static org.apache.geode.internal.AvailablePortHelper.getRandomAvailableTCPPorts;
import static org.apache.geode.test.compiler.ClassBuilder.writeJarFromClasses;
import static org.apache.geode.util.internal.UncheckedUtils.uncheckedCast;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.Serializable;
import java.nio.file.Path;
import java.util.Collection;
import java.util.List;
import java.util.Properties;

import io.micrometer.core.instrument.Timer;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;

import org.apache.geode.cache.Region;
import org.apache.geode.cache.client.ClientCache;
import org.apache.geode.cache.client.ClientCacheFactory;
import org.apache.geode.cache.client.ClientRegionShortcut;
import org.apache.geode.cache.execute.Function;
import org.apache.geode.cache.execute.FunctionContext;
import org.apache.geode.distributed.DistributedMember;
import org.apache.geode.rules.ServiceJarRule;
import org.apache.geode.security.AuthInitialize;
import org.apache.geode.security.AuthenticationFailedException;
import org.apache.geode.security.NotAuthorizedException;
import org.apache.geode.security.ResourcePermission;
import org.apache.geode.test.junit.rules.FolderRule;
import org.apache.geode.test.junit.rules.gfsh.GfshRule;

public class CacheGetsTimerTest {

  private int locatorPort;
  private ClientCache clientCache;
  private Region<Object, Object> replicateRegion;
  private Region<Object, Object> partitionRegion;
  private Path rootFolder;

  @Rule(order = 0)
  public FolderRule folderRule = new FolderRule();
  @Rule(order = 1)
  public GfshRule gfshRule = new GfshRule(folderRule::getFolder);
  @Rule
  public ServiceJarRule serviceJarRule = new ServiceJarRule();

  @Before
  public void setUp() {
    rootFolder = folderRule.getFolder().toPath();
  }

  @After
  public void tearDown() {
    if (clientCache != null) {
      clientCache.close();
    }

    if (locatorPort != 0) {
      String connectToLocatorCommand = "connect --locator=localhost[" + locatorPort + "]";
      String shutdownCommand = "shutdown --include-locators";
      gfshRule.execute(connectToLocatorCommand, shutdownCommand);
    }
  }

  @Test
  public void hitTimerRecordsCountAndTotalTime_ifGetsPerformedOnReplicateRegionWithExistingKey()
      throws IOException {
    givenClusterWithTimeStatisticsEnabled();

    for (int i = 0; i < 5; i++) {
      replicateRegion.put(i, i);
      replicateRegion.get(i);
    }

    TimerValue hitTimerValue = hitTimerValueForRegion(replicateRegion);

    assertThat(hitTimerValue.region)
        .as("Cache gets hit timer region tag value")
        .isEqualTo(replicateRegion.getName());

    assertThat(hitTimerValue.result)
        .as("Cache gets hit timer result tag value")
        .isEqualTo("hit");

    assertThat(hitTimerValue.count)
        .as("Cache gets hit timer count for region " + replicateRegion.getName())
        .isEqualTo(5);

    assertThat(hitTimerValue.totalTime)
        .as("Cache gets hit timer total time for region " + replicateRegion.getName())
        .isGreaterThan(0);
  }

  @Test
  public void missTimerRecordsCountAndTotalTime_ifGetsPerformedOnReplicateRegionWithMissingKey()
      throws IOException {
    givenClusterWithTimeStatisticsEnabled();

    for (int i = 0; i < 5; i++) {
      replicateRegion.get(i);
    }

    TimerValue missTimerValue = missTimerValueForRegion(replicateRegion);

    assertThat(missTimerValue.region)
        .as("Cache gets miss timer region tag value")
        .isEqualTo(replicateRegion.getName());

    assertThat(missTimerValue.result)
        .as("Cache gets miss timer result tag value")
        .isEqualTo("miss");

    assertThat(missTimerValue.count)
        .as("Cache gets miss timer count for region " + replicateRegion.getName())
        .isEqualTo(5);

    assertThat(missTimerValue.totalTime)
        .as("Cache gets miss timer total time for region " + replicateRegion.getName())
        .isGreaterThan(0);
  }

  @Test
  public void hitTimerRecordsCountAndTotalTime_ifGetsPerformedOnPartitionRegionWithExistingKey()
      throws IOException {
    givenClusterWithTimeStatisticsEnabled();

    for (int i = 0; i < 5; i++) {
      partitionRegion.put(i, i);
      partitionRegion.get(i);
    }

    TimerValue hitTimerValue = hitTimerValueForRegion(partitionRegion);

    assertThat(hitTimerValue.region)
        .as("Cache gets hit timer region tag value")
        .isEqualTo(partitionRegion.getName());

    assertThat(hitTimerValue.result)
        .as("Cache gets hit timer result tag value")
        .isEqualTo("hit");

    assertThat(hitTimerValue.count)
        .as("Cache gets hit timer count for region " + partitionRegion.getName())
        .isEqualTo(5);

    assertThat(hitTimerValue.totalTime)
        .as("Cache gets hit timer total time for region " + partitionRegion.getName())
        .isGreaterThan(0);
  }

  @Test
  public void missTimerRecordsCountAndTotalTime_ifGetsPerformedOnPartitionRegionWithMissingKey()
      throws IOException {
    givenClusterWithTimeStatisticsEnabled();

    for (int i = 0; i < 5; i++) {
      partitionRegion.get(i);
    }

    TimerValue missTimerValue = missTimerValueForRegion(partitionRegion);

    assertThat(missTimerValue.region)
        .as("Cache gets miss timer region tag value")
        .isEqualTo(partitionRegion.getName());

    assertThat(missTimerValue.result)
        .as("Cache gets miss timer result tag value")
        .isEqualTo("miss");

    assertThat(missTimerValue.count)
        .as("Cache gets miss timer count for region " + partitionRegion.getName())
        .isEqualTo(5);

    assertThat(missTimerValue.totalTime)
        .as("Cache gets miss timer total time for region " + partitionRegion.getName())
        .isGreaterThan(0);
  }

  @Test
  public void timersExistWithInitialValues_ifNoGetsPerformedOnReplicateRegion() throws IOException {
    givenClusterWithTimeStatisticsEnabled();

    assertThat(allTimerValuesForRegion(replicateRegion))
        .as("All timer values for region " + replicateRegion.getName())
        .hasSize(2)
        .anyMatch(tv -> tv.result.equals("hit"))
        .anyMatch(tv -> tv.result.equals("miss"))
        .allMatch(tv -> tv.count == 0, "All timers have count of zero")
        .allMatch(tv -> tv.totalTime == 0, "All timers have total time of zero");
  }

  @Test
  public void allTimersRemoved_ifReplicateRegionDestroyed() throws IOException {
    givenClusterWithTimeStatisticsEnabled();

    assertThat(allTimerValuesForRegion(replicateRegion))
        .as("All timer values before destroying region " + replicateRegion.getName())
        .hasSize(2);

    replicateRegion.destroyRegion();

    assertThat(allTimerValuesForRegion(replicateRegion))
        .as("All timer values after destroying region " + replicateRegion.getName())
        .isEmpty();
  }

  @Test
  public void timersExistWithInitialValues_ifNoGetsPerformedOnPartitionRegion() throws IOException {
    givenClusterWithTimeStatisticsEnabled();

    assertThat(allTimerValuesForRegion(partitionRegion))
        .as("All timer values for region " + partitionRegion.getName())
        .hasSize(2)
        .anyMatch(tv -> tv.result.equals("hit"))
        .anyMatch(tv -> tv.result.equals("miss"))
        .allMatch(tv -> tv.count == 0, "All timers have count of zero")
        .allMatch(tv -> tv.totalTime == 0, "All timers have total time of zero");
  }

  @Test
  public void allTimersRemoved_ifPartitionRegionDestroyed() throws IOException {
    givenClusterWithTimeStatisticsEnabled();

    assertThat(allTimerValuesForRegion(partitionRegion))
        .as("All timer values before destroying region " + partitionRegion.getName())
        .hasSize(2);

    partitionRegion.destroyRegion();

    assertThat(allTimerValuesForRegion(partitionRegion))
        .as("All timer values after destroying region " + partitionRegion.getName())
        .isEmpty();
  }

  @Test
  public void timersRecordCountForReplicateRegion_ifTimeStatisticsDisabled() throws IOException {
    givenClusterWithTimeStatisticsDisabled();

    replicateRegion.put("existing-key", "existing-value");
    replicateRegion.get("existing-key");
    replicateRegion.get("missing-key");

    assertThat(allTimerValuesForRegion(replicateRegion))
        .as("All timer values for region " + replicateRegion.getName())
        .hasSize(2)
        .anyMatch(tv -> tv.result.equals("hit"))
        .anyMatch(tv -> tv.result.equals("miss"))
        .allMatch(tv -> tv.count == 1, "All timers have count of one")
        .allMatch(tv -> tv.totalTime == 0, "All timers have total time of zero");
  }

  @Test
  public void timersRecordCountForPartitionRegion_ifTimeStatisticsDisabled() throws IOException {
    givenClusterWithTimeStatisticsDisabled();

    partitionRegion.put("existing-key", "existing-value");
    partitionRegion.get("existing-key");
    partitionRegion.get("missing-key");

    assertThat(allTimerValuesForRegion(partitionRegion))
        .as("All timer values for region " + partitionRegion.getName())
        .hasSize(2)
        .anyMatch(tv -> tv.result.equals("hit"))
        .anyMatch(tv -> tv.result.equals("miss"))
        .allMatch(tv -> tv.count == 1, "All timers have count of one")
        .allMatch(tv -> tv.totalTime == 0, "All timers have total time of zero");
  }

  @Test
  public void timersDoNotRecord_ifGetThrows() throws IOException {
    givenClusterWithSecurityThatDeniesAllGetOperations();

    Throwable thrown = catchThrowable(() -> replicateRegion.get("key"));

    assertThat(thrown)
        .as("Exception from get operation")
        .hasCauseInstanceOf(NotAuthorizedException.class);

    assertThat(allTimerValuesForRegion(replicateRegion))
        .as("All timer values for region " + replicateRegion.getName())
        .hasSize(2)
        .allMatch(tv -> tv.count == 0, "All timers have count of zero");
  }

  private void givenClusterWithTimeStatisticsEnabled() throws IOException {
    startCluster(true, false);
  }

  private void givenClusterWithTimeStatisticsDisabled() throws IOException {
    startCluster(false, false);
  }

  private void givenClusterWithSecurityThatDeniesAllGetOperations() throws IOException {
    startCluster(true, true);
  }

  private void startCluster(boolean enableTimeStatistics, boolean enableSecurity)
      throws IOException {
    int[] availablePorts = getRandomAvailableTCPPorts(3);

    locatorPort = availablePorts[0];
    int locatorJmxPort = availablePorts[1];
    int serverPort = availablePorts[2];

    Path serviceJarPath = serviceJarRule.createJarFor("metrics-publishing-service.jar",
        MetricsPublishingService.class, SimpleMetricsPublishingService.class);

    Path helpersJarPath = rootFolder.resolve("helpers.jar");
    writeJarFromClasses(helpersJarPath.toFile(), TimerValue.class,
        FetchCacheGetsTimerValues.class, DenyAllDataRead.class, ClientSecurityConfig.class);

    String startLocatorCommand = String.join(" ",
        "start locator",
        "--name=" + "locator",
        "--dir=" + rootFolder.resolve("locator"),
        "--port=" + locatorPort,
        "--http-service-port=0",
        "--J=-Dgemfire.jmx-manager-port=" + locatorJmxPort,
        "--classpath=" + helpersJarPath);

    String serverName = "server";
    String startServerCommand = String.join(" ",
        "start server",
        "--name=" + serverName,
        "--dir=" + rootFolder.resolve(serverName),
        "--server-port=" + serverPort,
        "--locators=localhost[" + locatorPort + "]",
        "--classpath=" + serviceJarPath + pathSeparatorChar + helpersJarPath);

    if (enableTimeStatistics) {
      startServerCommand += " --enable-time-statistics";
    }

    if (enableSecurity) {
      File securityPropertiesFile = createSecurityPropertiesFile();
      String securityPropertiesFileOption =
          " --security-properties-file=" + securityPropertiesFile.getAbsolutePath();
      startLocatorCommand += securityPropertiesFileOption;
      startServerCommand += securityPropertiesFileOption;
    }

    String replicateRegionName = "ReplicateRegion";
    String createReplicateRegionCommand = String.join(" ",
        "create region",
        "--type=REPLICATE",
        "--name=" + replicateRegionName);

    String partitionRegionName = "PartitionRegion";
    String createPartitionRegionCommand = String.join(" ",
        "create region",
        "--type=PARTITION",
        "--name=" + partitionRegionName);

    gfshRule.execute(startLocatorCommand, startServerCommand, createReplicateRegionCommand,
        createPartitionRegionCommand);

    ClientCacheFactory clientCacheFactory = new ClientCacheFactory();
    if (enableSecurity) {
      clientCacheFactory.set("security-client-auth-init", ClientSecurityConfig.class.getName());
    }

    clientCache = clientCacheFactory
        .addPoolLocator("localhost", locatorPort)
        .create();

    replicateRegion = clientCache
        .createClientRegionFactory(ClientRegionShortcut.PROXY)
        .create(replicateRegionName);

    partitionRegion = clientCache
        .createClientRegionFactory(ClientRegionShortcut.PROXY)
        .create(partitionRegionName);
  }

  private File createSecurityPropertiesFile() throws IOException {
    Properties securityProperties = ClientSecurityConfig.securityProperties();
    File securityPropertiesFile = rootFolder.resolve("security.properties").toFile();
    securityProperties.store(new FileOutputStream(securityPropertiesFile), null);
    return securityPropertiesFile;
  }

  private TimerValue hitTimerValueForRegion(Region<?, ?> region) {
    return timerValueForRegionAndResult(region, "hit");
  }

  private TimerValue missTimerValueForRegion(Region<?, ?> region) {
    return timerValueForRegionAndResult(region, "miss");
  }

  private TimerValue timerValueForRegionAndResult(Region<?, ?> region, String resultTagValue) {
    List<TimerValue> cacheGetsTimerValues = allTimerValuesForRegion(region).stream()
        .filter(tv -> tv.result.equals(resultTagValue))
        .collect(toList());

    assertThat(cacheGetsTimerValues)
        .as("Timers for region " + region.getName() + " with result " + resultTagValue)
        .hasSize(1);

    return cacheGetsTimerValues.get(0);
  }

  private List<TimerValue> allTimerValuesForRegion(Region<?, ?> region) {
    List<List<TimerValue>> timerValuesFromAllServers = uncheckedCast(onServer(clientCache)
        .execute(new FetchCacheGetsTimerValues())
        .getResult());

    assertThat(timerValuesFromAllServers)
        .hasSize(1);

    return timerValuesFromAllServers.get(0).stream()
        .filter(tv -> tv.region.equals(region.getName()))
        .collect(toList());
  }

  static class TimerValue implements Serializable {
    private final long count;
    private final double totalTime;
    private final String region;
    private final String result;

    TimerValue(long count, double totalTime, String region, String result) {
      this.count = count;
      this.totalTime = totalTime;
      this.region = region;
      this.result = result;
    }

    @Override
    public String toString() {
      return "TimerValue{" +
          "count=" + count +
          ", totalTime=" + totalTime +
          ", region='" + region + '\'' +
          ", result='" + result + '\'' +
          '}';
    }
  }

  static class FetchCacheGetsTimerValues implements Function<Void> {
    private static final String ID = "FetchCacheGetsTimerValues";

    @Override
    public void execute(FunctionContext<Void> context) {
      Collection<Timer> timers = SimpleMetricsPublishingService.getRegistry()
          .find("geode.cache.gets")
          .timers();

      List<TimerValue> result = timers.stream()
          .map(FetchCacheGetsTimerValues::toTimerValues)
          .collect(toList());

      context.getResultSender().lastResult(result);
    }

    @Override
    public String getId() {
      return ID;
    }

    private static TimerValue toTimerValues(Timer t) {
      String region = t.getId().getTag("region");
      String result = t.getId().getTag("result");

      return new TimerValue(
          t.count(),
          t.totalTime(NANOSECONDS),
          region,
          result);
    }
  }

  public static class DenyAllDataRead implements org.apache.geode.security.SecurityManager {
    @Override
    public Object authenticate(Properties credentials) throws AuthenticationFailedException {
      String userName = credentials.getProperty(USER_NAME);
      if (userName == null) {
        throw new AuthenticationFailedException("No user name provided");
      }
      return userName;
    }

    @Override
    public boolean authorize(Object principal, ResourcePermission permission) {
      return !isDataRead(permission);
    }

    private static boolean isDataRead(ResourcePermission permission) {
      return ResourcePermission.Resource.DATA.equals(permission.getResource())
          && ResourcePermission.Operation.READ.equals(permission.getOperation());
    }
  }

  public static class ClientSecurityConfig implements AuthInitialize {
    @Override
    public Properties getCredentials(Properties securityProps, DistributedMember server,
        boolean isPeer) throws AuthenticationFailedException {
      return securityProperties();
    }

    private static Properties securityProperties() {
      Properties securityProperties = new Properties();
      securityProperties.setProperty(SECURITY_MANAGER, DenyAllDataRead.class.getName());
      securityProperties.setProperty("security-username", "user");
      securityProperties.setProperty("security-password", "pass");
      return securityProperties;
    }
  }
}
