package io.github.jsonSnapshot;

import java.io.IOException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.assertj.core.util.Arrays;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.jupiter.api.BeforeAll;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.PrettyPrinter;
import com.fasterxml.jackson.core.util.DefaultIndenter;
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter.Indenter;
import com.fasterxml.jackson.core.util.Separators;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;

public class SnapshotMatcher {

  private static Logger log = LoggerFactory.getLogger(SnapshotMatcher.class);

  private static Class clazz = null;
  private static SnapshotFile snapshotFile = null;
  private static List<Snapshot> calledSnapshots = new ArrayList<>();
  private static Function<Object, String> serializeFunction;
  private static SnapshotMatchingStrategy snapshotMatchingStrategy;

  public static void start() {
    start(new DefaultConfig(), defaultJsonFunction());
  }

  public static void start(SnapshotConfig config) {
    start(config, defaultJsonFunction());
  }

  public static void start(Function<Object, String> serializeFunction) {
    start(new DefaultConfig(), serializeFunction);
  }

  /**
   * @param serializeFunction invoked to create the actual snapshot string. Note that it needs to be
   *     able to handle {@code Object[]} and that it needs needs to correspond with the the given
   *     {@code config}'s {@link SnapshotMatchingStrategy}.
   */
  public static void start(SnapshotConfig config, Function<Object, String> serializeFunction) {
    SnapshotMatcher.serializeFunction = serializeFunction;
    try {
      StackTraceElement stackElement = findStackElement();
      clazz = Class.forName(stackElement.getClassName());
      snapshotFile =
          new SnapshotFile(
              config.getFilePath(), stackElement.getClassName().replaceAll("\\.", "/") + ".snap");
      snapshotMatchingStrategy = config.getSnapshotMatchingStrategy();
    } catch (ClassNotFoundException | IOException e) {
      throw new SnapshotMatchException(e.getMessage());
    }
  }

  public static void validateSnapshots() {
    SnapshotData storedSnapshots = snapshotFile.getStoredSnapshots();
    List<String> snapshotNames =
        calledSnapshots.stream().map(Snapshot::getSnapshotName).collect(Collectors.toList());
    List<SnapshotDataItem> unusedRawSnapshots = new ArrayList<>();

    for (SnapshotDataItem storedSnapshot : storedSnapshots.getItems()) {
      boolean foundSnapshot = false;
      for (String snapshotName : snapshotNames) {

        if (storedSnapshot.getName().equals(snapshotName)) {
          foundSnapshot = true;
        }
      }
      if (!foundSnapshot) {
        unusedRawSnapshots.add(storedSnapshot);
      }
    }
    if (unusedRawSnapshots.size() > 0) {
      log.warn(
          "All unused Snapshots: "
              + StringUtils.join(unusedRawSnapshots, "\n")
              + ". Consider deleting the snapshot file to recreate it!");
    }
  }

  public static Snapshot expect(Object firstObject, Object... others) {

    if (clazz == null) {
      throw new SnapshotMatchException(
          "SnapshotTester not yet started! Start it on @BeforeClass/@BeforeAll with SnapshotMatcher.start()");
    }
    Object[] objects = mergeObjects(firstObject, others);
    StackTraceElement stackElement = findStackElement();
    Method method = getMethod(clazz, stackElement.getMethodName());
    Snapshot snapshot =
        new Snapshot(
            snapshotFile, clazz, method, serializeFunction, snapshotMatchingStrategy, objects);
    validateExpectCall(snapshot);
    calledSnapshots.add(snapshot);
    return snapshot;
  }

  static Function<Object, String> defaultJsonFunction() {

    ObjectMapper objectMapper = buildObjectMapper();

    PrettyPrinter pp = buildDefaultPrettyPrinter();

    return (object) -> {
      try {
        return objectMapper.writer(pp).writeValueAsString(object);
      } catch (Exception e) {
        throw new SnapshotMatchException(e.getMessage());
      }
    };
  }

  private static PrettyPrinter buildDefaultPrettyPrinter() {
    DefaultPrettyPrinter pp =
        new DefaultPrettyPrinter("") {
          @Override
          public DefaultPrettyPrinter withSeparators(Separators separators) {
            this._separators = separators;
            this._objectFieldValueSeparatorWithSpaces =
                separators.getObjectFieldValueSeparator() + " ";
            return this;
          }
        };
    Indenter lfOnlyIndenter = new DefaultIndenter("  ", "\n");
    pp.indentArraysWith(lfOnlyIndenter);
    pp.indentObjectsWith(lfOnlyIndenter);
    return pp;
  }

  private static ObjectMapper buildObjectMapper() {
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
    objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

    objectMapper.setVisibility(
        objectMapper
            .getSerializationConfig()
            .getDefaultVisibilityChecker()
            .withFieldVisibility(JsonAutoDetect.Visibility.ANY)
            .withGetterVisibility(JsonAutoDetect.Visibility.NONE)
            .withSetterVisibility(JsonAutoDetect.Visibility.NONE)
            .withCreatorVisibility(JsonAutoDetect.Visibility.NONE));
    return objectMapper;
  }

  private static void validateExpectCall(Snapshot snapshot) {
    for (Snapshot eachSnapshot : calledSnapshots) {
      if (eachSnapshot.getSnapshotName().equals(snapshot.getSnapshotName())) {
        throw new SnapshotMatchException(
            "You can only call 'expect' once per test method. Try using array of arguments on a single 'expect' call");
      }
    }
  }

  private static Method getMethod(Class<?> clazz, String methodName) {
    try {
      return Stream.of(clazz.getDeclaredMethods())
          .filter(method -> method.getName().equals(methodName))
          .findFirst()
          .orElseThrow(() -> new NoSuchMethodException("Not Found"));
    } catch (NoSuchMethodException e) {
      return Optional.ofNullable(clazz.getSuperclass())
          .map(superclass -> getMethod(superclass, methodName))
          .orElseThrow(
              () ->
                  new SnapshotMatchException(
                      "Could not find method "
                          + methodName
                          + " on class "
                          + clazz
                          + "\nPlease annotate your test method with @Test and make it without any parameters!"));
    }
  }

  private static StackTraceElement findStackElement() {
    StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace();
    int elementsToSkip = 1; // Start after stackTrace
    while (SnapshotMatcher.class
        .getName()
        .equals(stackTraceElements[elementsToSkip].getClassName())) {
      elementsToSkip++;
    }

    return Stream.of(stackTraceElements)
        .skip(elementsToSkip)
        .filter(
            stackTraceElement ->
                hasTestAnnotation(
                    getMethod(
                        getClass(stackTraceElement.getClassName()),
                        stackTraceElement.getMethodName())))
        .findFirst()
        .orElseThrow(
            () ->
                new SnapshotMatchException(
                    "Could not locate a method with one of supported test annotations"));
  }

  private static boolean hasTestAnnotation(Method method) {
    return method.isAnnotationPresent(Test.class)
        || method.isAnnotationPresent(BeforeClass.class)
        || method.isAnnotationPresent(org.junit.jupiter.api.Test.class)
        || method.isAnnotationPresent(BeforeAll.class);
  }

  private static Object[] mergeObjects(Object firstObject, Object[] others) {
    Object[] objects = new Object[1];
    objects[0] = firstObject;
    if (!Arrays.isNullOrEmpty(others)) {
      objects = ArrayUtils.addAll(objects, others);
    }
    return objects;
  }

  private static Class<?> getClass(String className) {
    try {
      return Class.forName(className);
    } catch (ClassNotFoundException e) {
      throw new IllegalArgumentException(e);
    }
  }
}