/*
 * Copyright The OpenTelemetry Authors
 * SPDX-License-Identifier: Apache-2.0
 */

package io.opentelemetry.contrib.jmxscraper.assertions;

import static io.opentelemetry.contrib.jmxscraper.assertions.DataPointAttributes.attributeGroup;

import com.google.errorprone.annotations.CanIgnoreReturnValue;
import io.opentelemetry.proto.common.v1.KeyValue;
import io.opentelemetry.proto.metrics.v1.Metric;
import io.opentelemetry.proto.metrics.v1.NumberDataPoint;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import org.assertj.core.api.AbstractAssert;
import org.assertj.core.internal.Integers;
import org.assertj.core.internal.Iterables;
import org.assertj.core.internal.Objects;

public class MetricAssert extends AbstractAssert<MetricAssert, Metric> {

  private static final Objects objects = Objects.instance();
  private static final Iterables iterables = Iterables.instance();
  private static final Integers integers = Integers.instance();

  private boolean strict;

  private boolean descriptionChecked;
  private boolean unitChecked;
  private boolean typeChecked;
  private boolean dataPointAttributesChecked;

  MetricAssert(Metric actual) {
    super(actual, MetricAssert.class);
  }

  public void setStrict(boolean strict) {
    this.strict = strict;
  }

  public void strictCheck() {
    strictCheck("description", /* expectedCheckStatus= */ true, descriptionChecked);
    strictCheck("unit", /* expectedCheckStatus= */ true, unitChecked);
    strictCheck("type", /* expectedCheckStatus= */ true, typeChecked);
    strictCheck(
        "data point attributes", /* expectedCheckStatus= */ true, dataPointAttributesChecked);
  }

  private void strictCheck(
      String metricProperty, boolean expectedCheckStatus, boolean actualCheckStatus) {
    if (!strict) {
      return;
    }
    String failMsgPrefix = expectedCheckStatus ? "Missing" : "Duplicate";
    info.description(
        "%s assertion on %s for metric '%s'", failMsgPrefix, metricProperty, actual.getName());
    objects.assertEqual(info, actualCheckStatus, expectedCheckStatus);
  }

  /**
   * Verifies metric description
   *
   * @param description expected description
   * @return this
   */
  @CanIgnoreReturnValue
  public MetricAssert hasDescription(String description) {
    isNotNull();

    info.description("unexpected description for metric '%s'", actual.getName());
    objects.assertEqual(info, actual.getDescription(), description);
    strictCheck("description", /* expectedCheckStatus= */ false, descriptionChecked);
    descriptionChecked = true;
    return this;
  }

  /**
   * Verifies metric unit
   *
   * @param unit expected unit
   * @return this
   */
  @CanIgnoreReturnValue
  public MetricAssert hasUnit(String unit) {
    isNotNull();

    info.description("unexpected unit for metric '%s'", actual.getName());
    objects.assertEqual(info, actual.getUnit(), unit);
    strictCheck("unit", /* expectedCheckStatus= */ false, unitChecked);
    unitChecked = true;
    return this;
  }

  /**
   * Verifies the metric is a gauge
   *
   * @return this
   */
  @CanIgnoreReturnValue
  public MetricAssert isGauge() {
    isNotNull();

    info.description("gauge expected for metric '%s'", actual.getName());
    objects.assertEqual(info, actual.hasGauge(), true);
    strictCheck("type", /* expectedCheckStatus= */ false, typeChecked);
    typeChecked = true;
    return this;
  }

  @CanIgnoreReturnValue
  private MetricAssert hasSum(boolean monotonic) {
    isNotNull();

    info.description("sum expected for metric '%s'", actual.getName());
    objects.assertEqual(info, actual.hasSum(), true);

    String sumType = monotonic ? "monotonic" : "non-monotonic";
    info.description("sum for metric '%s' is expected to be %s", actual.getName(), sumType);
    objects.assertEqual(info, actual.getSum().getIsMonotonic(), monotonic);
    return this;
  }

  /**
   * Verifies the metric is a counter
   *
   * @return this
   */
  @CanIgnoreReturnValue
  public MetricAssert isCounter() {
    // counters have a monotonic sum as their value can't decrease
    hasSum(true);
    strictCheck("type", /* expectedCheckStatus= */ false, typeChecked);
    typeChecked = true;
    return this;
  }

  /**
   * Verifies the metric is an up-down counter
   *
   * @return this
   */
  @CanIgnoreReturnValue
  public MetricAssert isUpDownCounter() {
    // up down counters are non-monotonic as their value can increase & decrease
    hasSum(false);
    strictCheck("type", /* expectedCheckStatus= */ false, typeChecked);
    typeChecked = true;
    return this;
  }

  /**
   * Verifies that there is no attribute in any of data points.
   *
   * @return this
   */
  @CanIgnoreReturnValue
  public MetricAssert hasDataPointsWithoutAttributes() {
    isNotNull();

    return checkDataPoints(
        dataPoints -> {
          dataPointsCommonCheck(dataPoints);

          // all data points must not have any attribute
          for (NumberDataPoint dataPoint : dataPoints) {
            info.description(
                "no attribute expected on data point for metric '%s'", actual.getName());
            iterables.assertEmpty(info, dataPoint.getAttributesList());
          }
        });
  }

  @CanIgnoreReturnValue
  private MetricAssert checkDataPoints(Consumer<List<NumberDataPoint>> listConsumer) {
    // in practice usually one set of data points is provided but the
    // protobuf does not enforce that, so we have to ensure checking at least one
    int count = 0;
    if (actual.hasGauge()) {
      count++;
      listConsumer.accept(actual.getGauge().getDataPointsList());
    }
    if (actual.hasSum()) {
      count++;
      listConsumer.accept(actual.getSum().getDataPointsList());
    }
    info.description("at least one set of data points expected for metric '%s'", actual.getName());
    integers.assertGreaterThan(info, count, 0);

    strictCheck(
        "data point attributes", /* expectedCheckStatus= */ false, dataPointAttributesChecked);
    dataPointAttributesChecked = true;
    return this;
  }

  private void dataPointsCommonCheck(List<NumberDataPoint> dataPoints) {
    info.description("unable to retrieve data points from metric '%s'", actual.getName());
    objects.assertNotNull(info, dataPoints);

    // at least one data point must be reported
    info.description("at least one data point expected for metric '%s'", actual.getName());
    iterables.assertNotEmpty(info, dataPoints);
  }

  /**
   * Verifies that all metric data points have the same expected one attribute
   *
   * @param expectedAttribute attribute matcher to validate data points attributes
   * @return this
   */
  @CanIgnoreReturnValue
  public final MetricAssert hasDataPointsWithOneAttribute(AttributeMatcher expectedAttribute) {
    return hasDataPointsWithAttributes(attributeGroup(expectedAttribute));
  }

  /**
   * Verifies that every data point attributes is matched exactly by one of the matcher groups
   * provided. Also, each matcher group must match at least one data point attributes set. Data
   * point attributes are matched by matcher group if each attribute is matched by one matcher and
   * each matcher matches one attribute. In other words: number of attributes is the same as number
   * of matchers and there is 1:1 matching between them.
   *
   * @param matcherGroups array of attribute matcher groups
   * @return this
   */
  @CanIgnoreReturnValue
  public final MetricAssert hasDataPointsWithAttributes(AttributeMatcherGroup... matcherGroups) {
    return checkDataPoints(
        dataPoints -> {
          dataPointsCommonCheck(dataPoints);

          boolean[] matchedSets = new boolean[matcherGroups.length];

          // validate each datapoint attributes match exactly one of the provided attributes sets
          for (NumberDataPoint dataPoint : dataPoints) {
            Map<String, String> dataPointAttributes =
                dataPoint.getAttributesList().stream()
                    .collect(
                        Collectors.toMap(KeyValue::getKey, kv -> kv.getValue().getStringValue()));
            int matchCount = 0;
            for (int i = 0; i < matcherGroups.length; i++) {
              if (matcherGroups[i].matches(dataPointAttributes)) {
                matchedSets[i] = true;
                matchCount++;
              }
            }

            info.description(
                "data point attributes '%s' for metric '%s' must match exactly one of the attribute sets '%s'.\nActual data points: %s",
                dataPointAttributes, actual.getName(), Arrays.asList(matcherGroups), dataPoints);
            integers.assertEqual(info, matchCount, 1);
          }

          // check that all attribute sets matched at least once
          for (int i = 0; i < matchedSets.length; i++) {
            info.description(
                "no data point matched attribute set '%s' for metric '%s'",
                matcherGroups[i], actual.getName());
            objects.assertEqual(info, matchedSets[i], true);
          }
        });
  }
}
