/*
 * Copyright (C) 2008 Google Inc.
 *
 * Licensed 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 com.google.inject;

import static com.google.inject.Asserts.assertContains;
import static com.google.inject.name.Names.named;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.inject.binder.AnnotatedBindingBuilder;
import com.google.inject.binder.ScopedBindingBuilder;
import com.google.inject.name.Named;
import com.google.inject.util.Providers;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import junit.framework.Test;
import junit.framework.TestCase;
import junit.framework.TestSuite;

/** @author jessewilson@google.com (Jesse Wilson) */
public class BinderTestSuite extends TestCase {

  public static Test suite() {
    TestSuite suite = new TestSuite();

    new Builder()
        .name("bind A")
        .module(
            new AbstractModule() {
              @Override
              protected void configure() {
                bind(A.class);
              }
            })
        .creationException("No implementation for BinderTestSuite$A was bound.")
        .addToSuite(suite);

    new Builder()
        .name("bind PlainA named apple")
        .module(
            new AbstractModule() {
              @Override
              protected void configure() {
                bind(PlainA.class).annotatedWith(named("apple"));
              }
            })
        .creationException(
            "No implementation for BinderTestSuite$PlainA annotated with "
                + shortNamed(named("apple"))
                + " was bound")
        .addToSuite(suite);

    new Builder()
        .name("bind A to new PlainA(1)")
        .module(
            new AbstractModule() {
              @Override
              protected void configure() {
                bind(A.class).toInstance(new PlainA(1));
              }
            })
        .creationTime(CreationTime.NONE)
        .expectedValues(new PlainA(1), new PlainA(1), new PlainA(1))
        .addToSuite(suite);

    new Builder()
        .name("no binding, AWithProvidedBy")
        .key(Key.get(AWithProvidedBy.class), InjectsAWithProvidedBy.class)
        .addToSuite(suite);

    new Builder()
        .name("no binding, AWithImplementedBy")
        .key(Key.get(AWithImplementedBy.class), InjectsAWithImplementedBy.class)
        .addToSuite(suite);

    new Builder()
        .name("no binding, ScopedA")
        .key(Key.get(ScopedA.class), InjectsScopedA.class)
        .expectedValues(new PlainA(201), new PlainA(201), new PlainA(202), new PlainA(202))
        .addToSuite(suite);

    new Builder()
        .name("no binding, AWithProvidedBy named apple")
        .key(Key.get(AWithProvidedBy.class, named("apple")), InjectsAWithProvidedByNamedApple.class)
        .configurationException(
            "No implementation for BinderTestSuite$AWithProvidedBy annotated with "
                + shortNamed(named("apple"))
                + " was bound")
        .addToSuite(suite);

    new Builder()
        .name("no binding, AWithImplementedBy named apple")
        .key(
            Key.get(AWithImplementedBy.class, named("apple")),
            InjectsAWithImplementedByNamedApple.class)
        .configurationException(
            "No implementation for BinderTestSuite$AWithImplementedBy annotated with "
                + shortNamed(named("apple"))
                + " was bound")
        .addToSuite(suite);

    new Builder()
        .name("no binding, ScopedA named apple")
        .key(Key.get(ScopedA.class, named("apple")), InjectsScopedANamedApple.class)
        .configurationException(
            "No implementation for BinderTestSuite$ScopedA annotated with "
                + shortNamed(named("apple"))
                + " was bound")
        .addToSuite(suite);

    for (final Scoper scoper : Scoper.values()) {
      new Builder()
          .name("bind PlainA")
          .key(Key.get(PlainA.class), InjectsPlainA.class)
          .module(
              new AbstractModule() {
                @Override
                protected void configure() {
                  AnnotatedBindingBuilder<PlainA> abb = bind(PlainA.class);
                  scoper.configure(abb);
                }
              })
          .scoper(scoper)
          .addToSuite(suite);

      new Builder()
          .name("bind A to PlainA")
          .module(
              new AbstractModule() {
                @Override
                protected void configure() {
                  ScopedBindingBuilder sbb = bind(A.class).to(PlainA.class);
                  scoper.configure(sbb);
                }
              })
          .scoper(scoper)
          .addToSuite(suite);

      new Builder()
          .name("bind A to PlainAProvider.class")
          .module(
              new AbstractModule() {
                @Override
                protected void configure() {
                  ScopedBindingBuilder sbb = bind(A.class).toProvider(PlainAProvider.class);
                  scoper.configure(sbb);
                }
              })
          .scoper(scoper)
          .addToSuite(suite);

      new Builder()
          .name("bind A to new PlainAProvider()")
          .module(
              new AbstractModule() {
                @Override
                protected void configure() {
                  ScopedBindingBuilder sbb = bind(A.class).toProvider(new PlainAProvider());
                  scoper.configure(sbb);
                }
              })
          .scoper(scoper)
          .addToSuite(suite);

      new Builder()
          .name("bind AWithProvidedBy")
          .key(Key.get(AWithProvidedBy.class), InjectsAWithProvidedBy.class)
          .module(
              new AbstractModule() {
                @Override
                protected void configure() {
                  ScopedBindingBuilder sbb = bind(AWithProvidedBy.class);
                  scoper.configure(sbb);
                }
              })
          .scoper(scoper)
          .addToSuite(suite);

      new Builder()
          .name("bind AWithImplementedBy")
          .key(Key.get(AWithImplementedBy.class), InjectsAWithImplementedBy.class)
          .module(
              new AbstractModule() {
                @Override
                protected void configure() {
                  ScopedBindingBuilder sbb = bind(AWithImplementedBy.class);
                  scoper.configure(sbb);
                }
              })
          .scoper(scoper)
          .addToSuite(suite);

      new Builder()
          .name("bind ScopedA")
          .key(Key.get(ScopedA.class), InjectsScopedA.class)
          .module(
              new AbstractModule() {
                @Override
                protected void configure() {
                  ScopedBindingBuilder sbb = bind(ScopedA.class);
                  scoper.configure(sbb);
                }
              })
          .expectedValues(new PlainA(201), new PlainA(201), new PlainA(202), new PlainA(202))
          .scoper(scoper)
          .addToSuite(suite);

      new Builder()
          .name("bind AWithProvidedBy named apple")
          .module(
              new AbstractModule() {
                @Override
                protected void configure() {
                  scoper.configure(bind(AWithProvidedBy.class).annotatedWith(named("apple")));
                }
              })
          .creationException(
              "No implementation for BinderTestSuite$AWithProvidedBy annotated with "
                  + shortNamed(named("apple"))
                  + " was bound")
          .scoper(scoper)
          .addToSuite(suite);

      new Builder()
          .name("bind AWithImplementedBy named apple")
          .module(
              new AbstractModule() {
                @Override
                protected void configure() {
                  scoper.configure(bind(AWithImplementedBy.class).annotatedWith(named("apple")));
                }
              })
          .creationException(
              "No implementation for BinderTestSuite$AWithImplementedBy annotated with "
                  + shortNamed(named("apple"))
                  + " was bound")
          .scoper(scoper)
          .addToSuite(suite);

      new Builder()
          .name("bind ScopedA named apple")
          .module(
              new AbstractModule() {
                @Override
                protected void configure() {
                  scoper.configure(bind(ScopedA.class).annotatedWith(named("apple")));
                }
              })
          .creationException(
              "No implementation for BinderTestSuite$ScopedA annotated with "
                  + shortNamed(named("apple"))
                  + " was bound")
          .scoper(scoper)
          .addToSuite(suite);
    }

    return suite;
  }

  enum Scoper {
    UNSCOPED {
      @Override
      void configure(ScopedBindingBuilder sbb) {}

      @Override
      void apply(Builder builder) {}
    },

    EAGER_SINGLETON {
      @Override
      void configure(ScopedBindingBuilder sbb) {
        sbb.asEagerSingleton();
      }

      @Override
      void apply(Builder builder) {
        builder.expectedValues(new PlainA(101), new PlainA(101), new PlainA(101));
        builder.creationTime(CreationTime.EAGER);
      }
    },

    SCOPES_SINGLETON {
      @Override
      void configure(ScopedBindingBuilder sbb) {
        sbb.in(Scopes.SINGLETON);
      }

      @Override
      void apply(Builder builder) {
        builder.expectedValues(new PlainA(201), new PlainA(201), new PlainA(201));
      }
    },

    SINGLETON_DOT_CLASS {
      @Override
      void configure(ScopedBindingBuilder sbb) {
        sbb.in(Singleton.class);
      }

      @Override
      void apply(Builder builder) {
        builder.expectedValues(new PlainA(201), new PlainA(201), new PlainA(201));
      }
    },

    TWO_AT_A_TIME_SCOPED_DOT_CLASS {
      @Override
      void configure(ScopedBindingBuilder sbb) {
        sbb.in(TwoAtATimeScoped.class);
      }

      @Override
      void apply(Builder builder) {
        builder.expectedValues(new PlainA(201), new PlainA(201), new PlainA(202), new PlainA(202));
      }
    },

    TWO_AT_A_TIME_SCOPE {
      @Override
      void configure(ScopedBindingBuilder sbb) {
        sbb.in(new TwoAtATimeScope());
      }

      @Override
      void apply(Builder builder) {
        builder.expectedValues(new PlainA(201), new PlainA(201), new PlainA(202), new PlainA(202));
      }
    };

    abstract void configure(ScopedBindingBuilder sbb);

    abstract void apply(Builder builder);
  }

  /** When Guice creates a value, directly or via a provider */
  enum CreationTime {
    NONE,
    EAGER,
    LAZY
  }

  public static class Builder {
    private String name = "test";
    private Key<?> key = Key.get(A.class);
    private Class<? extends Injectable> injectsKey = InjectsA.class;
    private List<Module> modules =
        Lists.<Module>newArrayList(
            new AbstractModule() {
              @Override
              protected void configure() {
                bindScope(TwoAtATimeScoped.class, new TwoAtATimeScope());
              }
            });
    private List<Object> expectedValues =
        Lists.<Object>newArrayList(new PlainA(201), new PlainA(202), new PlainA(203));
    private CreationTime creationTime = CreationTime.LAZY;
    private String creationException;
    private String configurationException;

    public Builder module(Module module) {
      this.modules.add(module);
      return this;
    }

    public Builder creationTime(CreationTime creationTime) {
      this.creationTime = creationTime;
      return this;
    }

    public Builder name(String name) {
      this.name = name;
      return this;
    }

    public Builder key(Key<?> key, Class<? extends Injectable> injectsKey) {
      this.key = key;
      this.injectsKey = injectsKey;
      return this;
    }

    private Builder creationException(String message, Object... args) {
      this.creationException = String.format(message, args);
      return this;
    }

    private Builder configurationException(String message, Object... args) {
      configurationException = String.format(message, args);
      return this;
    }

    private Builder scoper(Scoper scoper) {
      name(name + " in " + scoper);
      scoper.apply(this);
      return this;
    }

    private <T> Builder expectedValues(T... values) {
      this.expectedValues.clear();
      Collections.addAll(this.expectedValues, values);
      return this;
    }

    public void addToSuite(TestSuite suite) {
      if (creationException != null) {
        suite.addTest(new CreationExceptionTest(this));

      } else if (configurationException != null) {
        suite.addTest(new ConfigurationExceptionTest(this));

      } else {
        suite.addTest(new SuccessTest(this));
        if (creationTime != CreationTime.NONE) {
          suite.addTest(new UserExceptionsTest(this));
        }
      }
    }
  }

  public static class SuccessTest extends TestCase {
    final String name;
    final Key<?> key;
    final Class<? extends Injectable> injectsKey;
    final ImmutableList<Module> modules;
    final ImmutableList<Object> expectedValues;

    public SuccessTest(Builder builder) {
      super("test");
      name = builder.name;
      key = builder.key;
      injectsKey = builder.injectsKey;
      modules = ImmutableList.copyOf(builder.modules);
      expectedValues = ImmutableList.copyOf(builder.expectedValues);
    }

    @Override
    public String getName() {
      return name;
    }

    Injector newInjector() {
      nextId.set(101);
      return Guice.createInjector(modules);
    }

    public void test() throws IllegalAccessException, InstantiationException {
      Injector injector = newInjector();
      nextId.set(201);
      for (Object value : expectedValues) {
        assertEquals(value, injector.getInstance(key));
      }

      Provider<?> provider = newInjector().getProvider(key);
      nextId.set(201);
      for (Object value : expectedValues) {
        assertEquals(value, provider.get());
      }

      Provider<?> bindingProvider = newInjector().getBinding(key).getProvider();
      nextId.set(201);
      for (Object value : expectedValues) {
        assertEquals(value, bindingProvider.get());
      }

      injector = newInjector();
      nextId.set(201);
      for (Object value : expectedValues) {
        Injectable instance = injector.getInstance(injectsKey);
        assertEquals(value, instance.value);
      }

      injector = newInjector();
      nextId.set(201);
      for (Object value : expectedValues) {
        Injectable injectable = injectsKey.newInstance();
        injector.injectMembers(injectable);
        assertEquals(value, injectable.value);
      }

      Injector injector1 = newInjector();
      nextId.set(201);
      Injectable hasProvider = injector1.getInstance(injectsKey);
      hasProvider.provider.get();
      nextId.set(201);
      for (Object value : expectedValues) {
        assertEquals(value, hasProvider.provider.get());
      }
    }
  }

  public static class CreationExceptionTest extends TestCase {
    final String name;
    final Key<?> key;
    final ImmutableList<Module> modules;
    final String creationException;

    public CreationExceptionTest(Builder builder) {
      super("test");
      name = builder.name;
      key = builder.key;
      modules = ImmutableList.copyOf(builder.modules);
      creationException = builder.creationException;
    }

    @Override
    public String getName() {
      return "creation errors:" + name;
    }

    public void test() {
      try {
        Guice.createInjector(modules);
        fail();
      } catch (CreationException expected) {
        assertContains(expected.getMessage(), creationException);
      }
    }
  }

  public static class ConfigurationExceptionTest extends TestCase {
    final String name;
    final Key<?> key;
    final Class<? extends Injectable> injectsKey;
    final ImmutableList<Module> modules;
    final String configurationException;

    public ConfigurationExceptionTest(Builder builder) {
      super("test");
      name = builder.name;
      key = builder.key;
      injectsKey = builder.injectsKey;
      modules = ImmutableList.copyOf(builder.modules);
      configurationException = builder.configurationException;
    }

    @Override
    public String getName() {
      return "provision errors:" + name;
    }

    Injector newInjector() {
      return Guice.createInjector(modules);
    }

    public void test() throws IllegalAccessException, InstantiationException {
      try {
        newInjector().getProvider(key);
        fail();
      } catch (ConfigurationException expected) {
        assertContains(expected.getMessage(), configurationException);
      }

      try {
        newInjector().getBinding(key).getProvider();
        fail();
      } catch (ConfigurationException expected) {
        assertContains(expected.getMessage(), configurationException);
      }

      try {
        newInjector().getInstance(key);
        fail();
      } catch (ConfigurationException expected) {
        assertContains(expected.getMessage(), configurationException);
      }

      try {
        newInjector().getInstance(injectsKey);
        fail();
      } catch (ConfigurationException expected) {
        assertContains(
            expected.getMessage(),
            configurationException,
            injectsKey.getSimpleName() + ".inject",
            injectsKey.getSimpleName() + ".inject",
            "1 error");
      }

      try {
        Injectable injectable = injectsKey.newInstance();
        newInjector().injectMembers(injectable);
        fail();
      } catch (ConfigurationException expected) {
        assertContains(
            expected.getMessage(),
            configurationException,
            injectsKey.getSimpleName() + ".inject",
            injectsKey.getSimpleName() + ".inject",
            "1 error");
      }
    }
  }

  public static class UserExceptionsTest extends TestCase {
    final String name;
    final Key<?> key;
    final Class<? extends Injectable> injectsKey;
    final ImmutableList<Module> modules;
    final ImmutableList<Object> expectedValues;
    final CreationTime creationTime;

    public UserExceptionsTest(Builder builder) {
      super("test");
      name = builder.name;
      key = builder.key;
      injectsKey = builder.injectsKey;
      modules = ImmutableList.copyOf(builder.modules);
      expectedValues = ImmutableList.copyOf(builder.expectedValues);
      creationTime = builder.creationTime;
    }

    @Override
    public String getName() {
      return "provision errors:" + name;
    }

    Injector newInjector() {
      return Guice.createInjector(modules);
    }

    public void test() throws IllegalAccessException, InstantiationException {
      nextId.set(-1);
      try {
        newInjector();
        assertEquals(CreationTime.LAZY, creationTime);
      } catch (CreationException expected) {
        assertEquals(CreationTime.EAGER, creationTime);
        assertContains(expected.getMessage(), "Illegal value: -1");
        return;
      }

      Provider<?> provider = newInjector().getProvider(key);
      Provider<?> bindingProvider = newInjector().getBinding(key).getProvider();

      nextId.set(-1);
      try {
        newInjector().getInstance(key);
        fail();
      } catch (ProvisionException expected) {
        assertContains(expected.getMessage(), "Illegal value: -1");
      }

      nextId.set(-1);
      try {
        provider.get();
        fail();
      } catch (ProvisionException expected) {
        assertContains(expected.getMessage(), "Illegal value: -1");
      }

      nextId.set(-1);
      try {
        bindingProvider.get();
        fail();
      } catch (ProvisionException expected) {
        assertContains(expected.getMessage(), "Illegal value: -1");
      }

      try {
        nextId.set(-1);
        newInjector().getInstance(injectsKey);
        fail("Expected ProvisionException");
      } catch (ProvisionException expected) {
        assertContains(expected.getMessage(), "Illegal value: -1", "for 1st parameter");
      }

      nextId.set(201);
      Injectable injectable = injectsKey.newInstance();
      try {
        nextId.set(-1);
        newInjector().injectMembers(injectable);
      } catch (ProvisionException expected) {
        assertContains(expected.getMessage(), "Illegal value: -1", "for 1st parameter");
      }

      nextId.set(201);
      Injectable hasProvider = newInjector().getInstance(injectsKey);
      hasProvider.provider.get();
      try {
        nextId.set(-1);
        hasProvider.provider.get();
        // TODO(lukes): insert fail() call here
      } catch (ProvisionException expected) {
        assertContains(expected.getMessage(), "Illegal value: -1");
      }
    }
  }

  /** negative to throw, 101... for eager singletons, 201... for everything else */
  static final AtomicInteger nextId = new AtomicInteger();

  @ProvidedBy(PlainAProvider.class)
  interface AWithProvidedBy {}

  static class InjectsAWithProvidedBy extends Injectable {
    @Inject
    public void inject(
        AWithProvidedBy aWithProvidedBy, Provider<AWithProvidedBy> aWithProvidedByProvider) {
      this.value = aWithProvidedBy;
      this.provider = aWithProvidedByProvider;
    }
  }

  static class InjectsAWithProvidedByNamedApple extends Injectable {
    @Inject
    public void inject(
        @Named("apple") AWithProvidedBy aWithProvidedBy,
        @Named("apple") Provider<AWithProvidedBy> aWithProvidedByProvider) {
      this.value = aWithProvidedBy;
      this.provider = aWithProvidedByProvider;
    }
  }

  @ImplementedBy(PlainA.class)
  interface AWithImplementedBy {}

  static class InjectsAWithImplementedBy extends Injectable {
    @Inject
    public void inject(
        AWithImplementedBy aWithImplementedBy,
        Provider<AWithImplementedBy> aWithImplementedByProvider) {
      this.value = aWithImplementedBy;
      this.provider = aWithImplementedByProvider;
    }
  }

  static class InjectsAWithImplementedByNamedApple extends Injectable {
    @Inject
    public void inject(
        @Named("apple") AWithImplementedBy aWithImplementedBy,
        @Named("apple") Provider<AWithImplementedBy> aWithImplementedByProvider) {
      this.value = aWithImplementedBy;
      this.provider = aWithImplementedByProvider;
    }
  }

  interface A extends AWithProvidedBy, AWithImplementedBy {}

  static class InjectsA extends Injectable {
    @Inject
    public void inject(A a, Provider<A> aProvider) {
      this.value = a;
      this.provider = aProvider;
    }
  }

  static class PlainA implements A {
    final int value;

    PlainA() {
      value = nextId.getAndIncrement();
      if (value < 0) {
        throw new RuntimeException("Illegal value: " + value);
      }
    }

    PlainA(int value) {
      this.value = value;
    }

    @Override
    public boolean equals(Object obj) {
      return obj instanceof PlainA && value == ((PlainA) obj).value;
    }

    @Override
    public int hashCode() {
      return value;
    }

    @Override
    public String toString() {
      return "PlainA#" + value;
    }
  }

  static class PlainAProvider implements Provider<A> {
    @Override
    public A get() {
      return new PlainA();
    }
  }

  static class InjectsPlainA extends Injectable {
    @Inject
    public void inject(PlainA plainA, Provider<PlainA> plainAProvider) {
      this.value = plainA;
      this.provider = plainAProvider;
    }
  }

  /** This scope hands out each value exactly twice */
  static class TwoAtATimeScope implements Scope {
    @Override
    public <T> Provider<T> scope(Key<T> key, final Provider<T> unscoped) {
      return new Provider<T>() {
        T instance;

        @Override
        public T get() {
          if (instance == null) {
            instance = unscoped.get();
            return instance;
          } else {
            T result = instance;
            instance = null;
            return result;
          }
        }
      };
    }
  }

  @Target({TYPE, METHOD})
  @Retention(RUNTIME)
  @ScopeAnnotation
  public @interface TwoAtATimeScoped {}

  @TwoAtATimeScoped
  static class ScopedA extends PlainA {}

  static class InjectsScopedA extends Injectable {
    @Inject
    public void inject(ScopedA scopedA, Provider<ScopedA> scopedAProvider) {
      this.value = scopedA;
      this.provider = scopedAProvider;
    }
  }

  static class InjectsScopedANamedApple extends Injectable {
    @Inject
    public void inject(
        @Named("apple") ScopedA scopedA, @Named("apple") Provider<ScopedA> scopedAProvider) {
      this.value = scopedA;
      this.provider = scopedAProvider;
    }
  }

  static class Injectable {
    Object value = new Object();
    Provider<?> provider = Providers.of(new Object());
  }

  private static String shortNamed(Named named) {
    return named.toString().replace("com.google.inject.name.", "");
  }
}
