package com.google.inject.throwingproviders;

import static com.google.inject.throwingproviders.ProviderChecker.checkInterface;

import com.google.common.base.Optional;
import com.google.inject.TypeLiteral;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.Arrays;
import java.util.List;
import javax.annotation.Nullable;

/**
 * Static utility methods for creating and working with instances of {@link CheckedProvider}.
 *
 * @author eatnumber1@google.com (Russ Harmon)
 * @since 4.2
 */
public final class CheckedProviders {
  private abstract static class CheckedProviderInvocationHandler<T> implements InvocationHandler {
    private boolean isGetMethod(Method method) {
      // Since Java does not allow multiple methods with the same name and number & type of
      // arguments, this is all we need to check to see if it is an overriding method of
      // CheckedProvider#get().
      return method.getName().equals("get") && method.getParameterTypes().length == 0;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
      if (method.getDeclaringClass() == Object.class) {
        return method.invoke(this, args);
      } else if (isGetMethod(method)) {
        return invokeGet(proxy, method);
      }
      throw new UnsupportedOperationException(
          String.format(
              "Unsupported method <%s> with args <%s> invoked on <%s>",
              method, Arrays.toString(args), proxy));
    }

    protected abstract T invokeGet(Object proxy, Method method) throws Throwable;

    @Override
    public abstract String toString();
  }

  private static final class ReturningHandler<T> extends CheckedProviderInvocationHandler<T> {
    private final T returned;

    ReturningHandler(T returned) {
      this.returned = returned;
    }

    @Override
    protected T invokeGet(Object proxy, Method method) throws Throwable {
      return returned;
    }

    @Override
    public String toString() {
      return String.format("generated CheckedProvider returning <%s>", returned);
    }
  }

  private static final class ThrowingHandler extends CheckedProviderInvocationHandler<Object> {
    private final Constructor<? extends Throwable> throwableCtor;
    private final String typeName;

    private ThrowingHandler(Constructor<? extends Throwable> throwableCtor, String typeName) {
      this.throwableCtor = throwableCtor;
      this.typeName = typeName;

      this.throwableCtor.setAccessible(true);
    }

    static ThrowingHandler forClass(Class<? extends Throwable> throwable) {
      try {
        return new ThrowingHandler(throwable.getDeclaredConstructor(), throwable.getName());
      } catch (NoSuchMethodException e) {
        // This should have been caught by checkThrowable
        throw new AssertionError(
            String.format(
                "Throwable <%s> does not have a no-argument constructor", throwable.getName()));
      }
    }

    @Override
    protected Object invokeGet(Object proxy, Method method) throws Throwable {
      throw this.throwableCtor.newInstance();
    }

    @Override
    public String toString() {
      return String.format("generated CheckedProvider throwing <%s>", this.typeName);
    }
  }

  private CheckedProviders() {}

  private static <T, P extends CheckedProvider<? super T>> P generateProvider(
      Class<P> providerType, Optional<T> value, InvocationHandler handler) {
    checkInterface(providerType, getClassOptional(value));
    Object proxy =
        Proxy.newProxyInstance(
            providerType.getClassLoader(), new Class<?>[] {providerType}, handler);
    @SuppressWarnings("unchecked") // guaranteed by the newProxyInstance API
    P proxyP = (P) proxy;
    return proxyP;
  }

  private static <T, P extends CheckedProvider<? super T>> P generateProvider(
      TypeLiteral<P> providerType, Optional<T> value, InvocationHandler handler) {
    // TODO(eatnumber1): Understand why TypeLiteral#getRawType returns a Class<? super T> rather
    // than a Class<T> and remove this unsafe cast.
    Class<P> providerRaw = (Class) providerType.getRawType();
    return generateProvider(providerRaw, value, handler);
  }

  private static Optional<Class<?>> getClassOptional(Optional<?> value) {
    if (!value.isPresent()) {
      return Optional.absent();
    }
    return Optional.<Class<?>>of(value.get().getClass());
  }

  /**
   * Returns a {@link CheckedProvider} which always provides {@code instance}.
   *
   * <p>The provider type passed as {@code providerType} must be an interface. Calls to methods
   * other than {@link CheckedProvider#get} will throw {@link UnsupportedOperationException}.
   *
   * @param providerType the type of the {@link CheckedProvider} to return
   * @param instance the instance that should always be provided
   */
  public static <T, P extends CheckedProvider<? super T>> P of(
      TypeLiteral<P> providerType, @Nullable T instance) {
    return generateProvider(
        providerType, Optional.fromNullable(instance), new ReturningHandler<T>(instance));
  }

  /**
   * Returns a {@link CheckedProvider} which always provides {@code instance}.
   *
   * @param providerType the type of the {@link CheckedProvider} to return
   * @param instance the instance that should always be provided
   * @see #of(TypeLiteral, T)
   */
  public static <T, P extends CheckedProvider<? super T>> P of(
      Class<P> providerType, @Nullable T instance) {
    return of(TypeLiteral.get(providerType), instance);
  }

  /**
   * Returns a {@link CheckedProvider} which always throws exceptions.
   *
   * <p>This method uses the nullary (no argument) constructor of {@code throwable} to create a new
   * instance of the given {@link Throwable} on each method invocation which is then thrown
   * immediately.
   *
   * <p>See {@link #of(TypeLiteral, T)} for more information.
   *
   * @param providerType the type of the {@link CheckedProvider} to return
   * @param throwable the type of the {@link Throwable} to throw
   * @see #of(TypeLiteral, T)
   */
  public static <T, P extends CheckedProvider<? super T>> P throwing(
      TypeLiteral<P> providerType, Class<? extends Throwable> throwable) {
    // TODO(eatnumber1): Understand why TypeLiteral#getRawType returns a Class<? super T> rather
    // than a Class<T> and remove this unsafe cast.
    Class<P> providerRaw = (Class) providerType.getRawType();
    checkThrowable(providerRaw, throwable);
    return generateProvider(
        providerType, Optional.<T>absent(), ThrowingHandler.forClass(throwable));
  }

  /**
   * Returns a {@link CheckedProvider} which always throws exceptions.
   *
   * @param providerType the type of the {@link CheckedProvider} to return
   * @param throwable the type of the {@link Throwable} to throw
   * @see #throwing(TypeLiteral, Class)
   */
  public static <T, P extends CheckedProvider<? super T>> P throwing(
      Class<P> providerType, Class<? extends Throwable> throwable) {
    return throwing(TypeLiteral.get(providerType), throwable);
  }

  private static boolean isCheckedException(Class<? extends Throwable> thrownType) {
    return Exception.class.isAssignableFrom(thrownType)
        && !RuntimeException.class.isAssignableFrom(thrownType);
  }

  private static void checkThrowable(
      Class<? extends CheckedProvider<?>> providerType, Class<? extends Throwable> thrownType) {
    try {
      thrownType.getDeclaredConstructor();
    } catch (NoSuchMethodException e) {
      throw new IllegalArgumentException(
          String.format(
              "Thrown exception <%s> must have a no-argument constructor", thrownType.getName()),
          e);
    }

    if (!isCheckedException(thrownType)) {
      return;
    }

    Method getMethod;
    try {
      getMethod = providerType.getMethod("get");
    } catch (NoSuchMethodException e) {
      throw new IllegalArgumentException(
          String.format("Provider class <%s> must have a get() method", providerType.getName()), e);
    }

    @SuppressWarnings("unchecked") // guaranteed by getExceptionTypes
    List<Class<? extends Throwable>> exceptionTypes =
        Arrays.asList((Class<? extends Throwable>[]) getMethod.getExceptionTypes());

    if (exceptionTypes.size() == 0) {
      return;
    }

    // Check if the thrown exception is declared to be thrown in the method signature.
    for (Class<? extends Throwable> exceptionType : exceptionTypes) {
      if (exceptionType.isAssignableFrom(thrownType)) {
        return;
      }
    }

    throw new IllegalArgumentException(
        String.format(
            "Thrown exception <%s> is not declared to be thrown by <%s>",
            thrownType.getName(), getMethod));
  }
}
