package com.airlock.iam.base.misc.adapt;

import com.airlock.iam.base.api.domain.model.exception.FailureException;
import com.google.common.base.Preconditions;
import com.google.common.collect.MoreCollectors;
import com.google.common.reflect.TypeToken;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;
import org.apache.commons.lang3.reflect.MethodUtils;

public final class Adapter {
   private static final Map<Adapter.AdapterSpec, Adapter.AdapterFunction> CACHE = Collections.synchronizedMap(new HashMap<>());
   private final Object object;

   public static Adapter adapt(Object object) {
      return new Adapter(object);
   }

   private Adapter(Object object) {
      this.object = Preconditions.checkNotNull(object);
   }

   public boolean isAdaptableTo(Class<?> contractClass) {
      return contractClass.isAssignableFrom(this.object.getClass()) || this.getAdapterFunction(contractClass).isPresent();
   }

   public <T> Optional<T> ifPossibleTo(Class<T> contractClass) {
      return contractClass.isAssignableFrom(this.object.getClass())
         ? Optional.of(contractClass.cast(this.object))
         : this.getAdapterFunction(contractClass).map(f -> (T)f.adapt(this.object));
   }

   public <T> T to(Class<T> contractClass) {
      return this.ifPossibleTo(contractClass)
         .orElseThrow(() -> new FailureException("Missing adapter from " + this.object.getClass() + " to " + contractClass));
   }

   private Optional<Adapter.AdapterFunction> getAdapterFunction(Class<?> contractClass) {
      return getTypes(this.object.getClass()).stream().flatMap(c -> {
         Adapter.AdapterSpec spec = new Adapter.AdapterSpec((Class<?>)c, contractClass);
         Adapter.AdapterFunction function = CACHE.computeIfAbsent(spec, s -> this.getAdapterFunction(s).orElse(null));
         return Stream.ofNullable(function);
      }).findFirst();
   }

   private Optional<Adapter.AdapterFunction> getAdapterFunction(Adapter.AdapterSpec spec) {
      return Stream.of(getAdaptFromMethod(spec), getAdaptToMethod(spec)).flatMap(Optional::stream).collect(MoreCollectors.toOptional());
   }

   private static Optional<Adapter.AdapterFunction> getAdaptFromMethod(Adapter.AdapterSpec spec) {
      return Stream.of(MethodUtils.getMethodsWithAnnotation(spec.to, AdaptFrom.class))
         .peek(Adapter::typeCheckAdaptFromMethod)
         .filter(m -> m.getParameterTypes()[0].isAssignableFrom(spec.from))
         .map(m -> o -> {
            try {
               return m.invoke(null, o);
            } catch (ReflectiveOperationException e) {
               throw new FailureException(e);
            }
         })
         .collect(MoreCollectors.toOptional());
   }

   private static void typeCheckAdaptFromMethod(Method method) {
      if (!Modifier.isStatic(method.getModifiers())) {
         throw new FailureException("@AdaptFrom method must be static: " + method);
      }

      if (method.getParameterCount() != 1) {
         throw new FailureException("@AdaptFrom method must accept exactly one argument: " + method);
      }

      if (!method.getDeclaringClass().isAssignableFrom(method.getReturnType())) {
         throw new FailureException("@AdaptFrom method must return an instance of the declaring type: " + method);
      }
   }

   private static Optional<Adapter.AdapterFunction> getAdaptToMethod(Adapter.AdapterSpec spec) {
      return Stream.of(MethodUtils.getMethodsWithAnnotation(spec.from, AdaptTo.class))
         .peek(Adapter::typeCheckAdaptToMethod)
         .filter(m -> spec.to.isAssignableFrom(m.getReturnType()))
         .map(m -> o -> {
            try {
               return m.invoke(o);
            } catch (ReflectiveOperationException e) {
               throw new FailureException(e);
            }
         })
         .collect(MoreCollectors.toOptional());
   }

   private static void typeCheckAdaptToMethod(Method method) {
      if (Modifier.isStatic(method.getModifiers())) {
         throw new FailureException("@AdaptTo method must not be static: " + method);
      }

      if (method.getParameterCount() != 0) {
         throw new FailureException("@AdaptTo method must not accept any arguments: " + method);
      }
   }

   private static Set<Class<?>> getTypes(Class<?> type) {
      return TypeToken.of(type).getTypes().rawTypes();
   }

   @FunctionalInterface
   private interface AdapterFunction {
      Object adapt(Object var1);
   }

   private static final class AdapterSpec {
      private final Class<?> from;
      private final Class<?> to;

      @SuppressFBWarnings(justification = "generated code")
      public AdapterSpec(Class<?> from, Class<?> to) {
         this.from = from;
         this.to = to;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Class<?> getFrom() {
         return this.from;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Class<?> getTo() {
         return this.to;
      }

      @SuppressFBWarnings(justification = "generated code")
      @Override
      public boolean equals(Object o) {
         if (o == this) {
            return true;
         } else if (!(o instanceof Adapter.AdapterSpec other)) {
            return false;
         } else {
            Object this$from = this.from;
            Object other$from = other.from;
            if (this$from == null ? other$from == null : this$from.equals(other$from)) {
               Object this$to = this.to;
               Object other$to = other.to;
               return this$to == null ? other$to == null : this$to.equals(other$to);
            } else {
               return false;
            }
         }
      }

      @SuppressFBWarnings(justification = "generated code")
      @Override
      public int hashCode() {
         int PRIME = 59;
         int result = 1;
         Object $from = this.from;
         result = result * 59 + ($from == null ? 43 : $from.hashCode());
         Object $to = this.to;
         return result * 59 + ($to == null ? 43 : $to.hashCode());
      }

      @SuppressFBWarnings(justification = "generated code")
      @Override
      public String toString() {
         return "Adapter.AdapterSpec(from=" + this.getFrom() + ", to=" + this.getTo() + ")";
      }
   }
}
