package org.robolectric.annotation;

import android.app.Application;

import javax.annotation.Nonnull;

import java.lang.annotation.Annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Properties;
import java.util.Set;

/**
 * Configuration settings that can be used on a per-class or per-test basis.
 */
@Documented
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface Config {
  /**
   * TODO(vnayar): Create named constants for default values instead of magic numbers.
   * Array named contants must be avoided in order to dodge a JDK 1.7 bug.
   *   error: annotation Config is missing value for the attribute <clinit>
   * See <a href="https://bugs.openjdk.java.net/browse/JDK-8013485">JDK-8013485</a>.
   */
  String NONE = "--none";
  String DEFAULT_VALUE_STRING = "--default";
  int DEFAULT_VALUE_INT = -1;

  String DEFAULT_MANIFEST_NAME = "AndroidManifest.xml";
  Class<? extends Application> DEFAULT_APPLICATION = DefaultApplication.class;
  String DEFAULT_PACKAGE_NAME = "";
  String DEFAULT_ABI_SPLIT = "";
  String DEFAULT_QUALIFIERS = "";
  String DEFAULT_RES_FOLDER = "res";
  String DEFAULT_ASSET_FOLDER = "assets";
  String DEFAULT_BUILD_FOLDER = "build";

  int ALL_SDKS = -2;
  int TARGET_SDK = -3;
  int OLDEST_SDK = -4;
  int NEWEST_SDK = -5;

  /**
   * The Android SDK level to emulate. This value will also be set as Build.VERSION.SDK_INT.
   */
  int[] sdk() default {};  // DEFAULT_SDK

  /**
   * The minimum Android SDK level to emulate when running tests on multiple API versions.
   */
  int minSdk() default -1;

  /**
   * The maximum Android SDK level to emulate when running tests on multiple API versions.
   */
  int maxSdk() default -1;

  /**
   * The Android manifest file to load; Robolectric will look relative to the current directory.
   * Resources and assets will be loaded relative to the manifest.
   *
   * If not specified, Robolectric defaults to {@code AndroidManifest.xml}.
   *
   * If your project has no manifest or resources, use {@link Config#NONE}.
   *
   * @return The Android manifest file to load.
   */
  String manifest() default DEFAULT_VALUE_STRING;

  /**
   * Reference to the BuildConfig class created by the Gradle build system.
   *
   * @return Reference to BuildConfig class.
   */
  Class<?> constants() default Void.class;  // DEFAULT_CONSTANTS

  /**
   * The {@link android.app.Application} class to use in the test, this takes precedence over any application
   * specified in the AndroidManifest.xml.
   *
   * @return The {@link android.app.Application} class to use in the test.
   */
  Class<? extends Application> application() default DefaultApplication.class;  // DEFAULT_APPLICATION

  /**
   * Java package name where the "R.class" file is located. This only needs to be specified if you define
   * an {@code applicationId} associated with {@code productFlavors} or specify {@code applicationIdSuffix}
   * in your build.gradle.
   *
   * If not specified, Robolectric defaults to the {@code applicationId}.
   *
   * @return The java package name for R.class.
   */
  String packageName() default DEFAULT_PACKAGE_NAME;

  /**
   * The ABI split to use when locating resources and AndroidManifest.xml
   *
   * You do not typically have to set this, unless you are utilizing the ABI split feature.
   *
   * @return The ABI split to test with
   */
  String abiSplit() default DEFAULT_ABI_SPLIT;

  /**
   * Qualifiers for the resource resolution, such as "fr-normal-port-hdpi".
   *
   * @return Qualifiers used for resource resolution.
   */
  String qualifiers() default DEFAULT_QUALIFIERS;

  /**
   * The directory from which to load resources.  This should be relative to the directory containing AndroidManifest.xml.
   *
   * If not specified, Robolectric defaults to {@code res}.
   *
   * @return Android resource directory.
   */
  String resourceDir() default DEFAULT_RES_FOLDER;

  /**
   * The directory from which to load assets. This should be relative to the directory containing AndroidManifest.xml.
   *
   * If not specified, Robolectric defaults to {@code assets}.
   *
   * @return Android asset directory.
   */
  String assetDir() default DEFAULT_ASSET_FOLDER;

  /**
   * The directory where application files are created during the application build process.
   *
   * If not specified, Robolectric defaults to {@code build}.
   *
   * @return Android build directory.
   */
  String buildDir() default DEFAULT_BUILD_FOLDER;

  /**
   * A list of shadow classes to enable, in addition to those that are already present.
   *
   * @return A list of additional shadow classes to enable.
   */
  Class<?>[] shadows() default {};  // DEFAULT_SHADOWS

  /**
   * A list of instrumented packages, in addition to those that are already instrumented.
   *
   * @return A list of additional instrumented packages.
   */
  String[] instrumentedPackages() default {};  // DEFAULT_INSTRUMENTED_PACKAGES

  /**
   * A list of folders containing Android Libraries on which this project depends.
   *
   * @return A list of Android Libraries.
   */
  String[] libraries() default {};  // DEFAULT_LIBRARIES;

  class Implementation implements Config {
    private final int[] sdk;
    private final int minSdk;
    private final int maxSdk;
    private final String manifest;
    private final String qualifiers;
    private final String resourceDir;
    private final String assetDir;
    private final String buildDir;
    private final String packageName;
    private final String abiSplit;
    private final Class<?> constants;
    private final Class<?>[] shadows;
    private final String[] instrumentedPackages;
    private final Class<? extends Application> application;
    private final String[] libraries;

    public static Config fromProperties(Properties properties) {
      if (properties == null || properties.size() == 0) return null;
      return new Implementation(
          parseSdkArrayProperty(properties.getProperty("sdk", "")),
          parseSdkInt(properties.getProperty("minSdk", "-1")),
          parseSdkInt(properties.getProperty("maxSdk", "-1")),
          properties.getProperty("manifest", DEFAULT_VALUE_STRING),
          properties.getProperty("qualifiers", DEFAULT_QUALIFIERS),
          properties.getProperty("packageName", DEFAULT_PACKAGE_NAME),
          properties.getProperty("abiSplit", DEFAULT_ABI_SPLIT),
          properties.getProperty("resourceDir", DEFAULT_RES_FOLDER),
          properties.getProperty("assetDir", DEFAULT_ASSET_FOLDER),
          properties.getProperty("buildDir", DEFAULT_BUILD_FOLDER),
          parseClasses(properties.getProperty("shadows", "")),
          parseStringArrayProperty(properties.getProperty("instrumentedPackages", "")),
          parseApplication(properties.getProperty("application", DEFAULT_APPLICATION.getCanonicalName())),
          parseStringArrayProperty(properties.getProperty("libraries", "")),
          parseClass(properties.getProperty("constants", ""))
      );
    }

    private static Class<?> parseClass(String className) {
      if (className.isEmpty()) return null;
      try {
        return Implementation.class.getClassLoader().loadClass(className);
      } catch (ClassNotFoundException e) {
        throw new RuntimeException("Could not load class: " + className);
      }
    }

    private static Class<?>[] parseClasses(String input) {
      if (input.isEmpty()) return new Class[0];
      final String[] classNames = input.split("[, ]+");
      final Class[] classes = new Class[classNames.length];
      for (int i = 0; i < classNames.length; i++) {
        classes[i] = parseClass(classNames[i]);
      }
      return classes;
    }

    @SuppressWarnings("unchecked")
    private static <T extends Application> Class<T> parseApplication(String className) {
      return (Class<T>) parseClass(className);
    }

    private static String[] parseStringArrayProperty(String property) {
      if (property.isEmpty()) return new String[0];
      return property.split("[, ]+");
    }

    private static int[] parseSdkArrayProperty(String property) {
      String[] parts = parseStringArrayProperty(property);
      int[] result = new int[parts.length];
      for (int i = 0; i < parts.length; i++) {
        result[i] = parseSdkInt(parts[i]);
      }

      return result;
    }

    private static int parseSdkInt(String part) {
      String spec = part.trim();
      switch (spec) {
        case "ALL_SDKS":
          return Config.ALL_SDKS;
        case "TARGET_SDK":
          return Config.TARGET_SDK;
        case "OLDEST_SDK":
          return Config.OLDEST_SDK;
        case "NEWEST_SDK":
          return Config.NEWEST_SDK;
        default:
          return Integer.parseInt(spec);
      }
    }

    private static void validate(Config config) {
      //noinspection ConstantConditions
      if (config.sdk() != null && config.sdk().length > 0 &&
          (config.minSdk() != DEFAULT_VALUE_INT || config.maxSdk() != DEFAULT_VALUE_INT)) {
        throw new IllegalArgumentException("sdk and minSdk/maxSdk may not be specified together" +
            " (sdk=" + Arrays.toString(config.sdk()) + ", minSdk=" + config.minSdk() + ", maxSdk=" + config.maxSdk() + ")");
      }

      if (config.minSdk() > DEFAULT_VALUE_INT && config.maxSdk() > DEFAULT_VALUE_INT && config.minSdk() > config.maxSdk()) {
        throw new IllegalArgumentException("minSdk may not be larger than maxSdk" +
            " (minSdk=" + config.minSdk() + ", maxSdk=" + config.maxSdk() + ")");
      }
    }

    public Implementation(int[] sdk, int minSdk, int maxSdk, String manifest, String qualifiers, String packageName, String abiSplit, String resourceDir, String assetDir, String buildDir, Class<?>[] shadows, String[] instrumentedPackages, Class<? extends Application> application, String[] libraries, Class<?> constants) {
      this.sdk = sdk;
      this.minSdk = minSdk;
      this.maxSdk = maxSdk;
      this.manifest = manifest;
      this.qualifiers = qualifiers;
      this.packageName = packageName;
      this.abiSplit = abiSplit;
      this.resourceDir = resourceDir;
      this.assetDir = assetDir;
      this.buildDir = buildDir;
      this.shadows = shadows;
      this.instrumentedPackages = instrumentedPackages;
      this.application = application;
      this.libraries = libraries;
      this.constants = constants;

      validate(this);
    }

    @Override
    public int[] sdk() {
      return sdk;
    }

    @Override
    public int minSdk() {
      return minSdk;
    }

    @Override
    public int maxSdk() {
      return maxSdk;
    }

    @Override
    public String manifest() {
      return manifest;
    }

    @Override
    public Class<?> constants() {
      return constants;
    }

    @Override
    public Class<? extends Application> application() {
      return application;
    }

    @Override
    public String qualifiers() {
      return qualifiers;
    }

    @Override
    public String packageName() {
      return packageName;
    }

    @Override
    public String abiSplit() {
      return abiSplit;
    }

    @Override
    public String resourceDir() {
      return resourceDir;
    }

    @Override
    public String assetDir() {
      return assetDir;
    }

    @Override
    public String buildDir() {
      return buildDir;
    }

    @Override
    public Class<?>[] shadows() {
      return shadows;
    }

    @Override
    public String[] instrumentedPackages() {
      return instrumentedPackages;
    }

    @Override
    public String[] libraries() {
      return libraries;
    }

    @Nonnull @Override
    public Class<? extends Annotation> annotationType() {
      return Config.class;
    }
  }

  class Builder {
    protected int[] sdk = new int[0];
    protected int minSdk = -1;
    protected int maxSdk = -1;
    protected String manifest = Config.DEFAULT_VALUE_STRING;
    protected String qualifiers = Config.DEFAULT_QUALIFIERS;
    protected String packageName = Config.DEFAULT_PACKAGE_NAME;
    protected String abiSplit = Config.DEFAULT_ABI_SPLIT;
    protected String resourceDir = Config.DEFAULT_RES_FOLDER;
    protected String assetDir = Config.DEFAULT_ASSET_FOLDER;
    protected String buildDir = Config.DEFAULT_BUILD_FOLDER;
    protected Class<?>[] shadows = new Class[0];
    protected String[] instrumentedPackages = new String[0];
    protected Class<? extends Application> application = DEFAULT_APPLICATION;
    protected String[] libraries = new String[0];
    protected Class<?> constants = Void.class;

    public Builder() {
    }

    public Builder(Config config) {
      sdk = config.sdk();
      minSdk = config.minSdk();
      maxSdk = config.maxSdk();
      manifest = config.manifest();
      qualifiers = config.qualifiers();
      packageName = config.packageName();
      abiSplit = config.abiSplit();
      resourceDir = config.resourceDir();
      assetDir = config.assetDir();
      buildDir = config.buildDir();
      shadows = config.shadows();
      instrumentedPackages = config.instrumentedPackages();
      application = config.application();
      libraries = config.libraries();
      constants = config.constants();
    }

    public Builder setSdk(int... sdk) {
      this.sdk = sdk;
      return this;
    }

    public Builder setMinSdk(int minSdk) {
      this.minSdk = minSdk;
      return this;
    }

    public Builder setMaxSdk(int maxSdk) {
      this.maxSdk = maxSdk;
      return this;
    }

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

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

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

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

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

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

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

    public Builder setShadows(Class<?>[] shadows) {
      this.shadows = shadows;
      return this;
    }

    public Builder setInstrumentedPackages(String[] instrumentedPackages) {
      this.instrumentedPackages = instrumentedPackages;
      return this;
    }

    public Builder setApplication(Class<? extends Application> application) {
      this.application = application;
      return this;
    }

    public Builder setLibraries(String[] libraries) {
      this.libraries = libraries;
      return this;
    }

    public Builder setConstants(Class<?> constants) {
      this.constants = constants;
      return this;
    }

    /**
     * This returns actual default values where they exist, in the sense that we could use
     * the values, rather than markers like {@code -1} or {@code --default}.
     */
    public static Builder defaults() {
      return new Builder()
          .setManifest(DEFAULT_MANIFEST_NAME)
          .setResourceDir(DEFAULT_RES_FOLDER)
          .setAssetDir(DEFAULT_ASSET_FOLDER);
    }

    public Builder overlay(Config overlayConfig) {
      int[] overlaySdk = overlayConfig.sdk();
      int overlayMinSdk = overlayConfig.minSdk();
      int overlayMaxSdk = overlayConfig.maxSdk();

      //noinspection ConstantConditions
      if (overlaySdk != null && overlaySdk.length > 0) {
        this.sdk = overlaySdk;
        this.minSdk = overlayMinSdk;
        this.maxSdk = overlayMaxSdk;
      } else {
        if (overlayMinSdk != DEFAULT_VALUE_INT || overlayMaxSdk != DEFAULT_VALUE_INT) {
          this.sdk = new int[0];
        } else {
          this.sdk = pickSdk(this.sdk, overlaySdk, new int[0]);
        }
        this.minSdk = pick(this.minSdk, overlayMinSdk, DEFAULT_VALUE_INT);
        this.maxSdk = pick(this.maxSdk, overlayMaxSdk, DEFAULT_VALUE_INT);
      }
      this.manifest = pick(this.manifest, overlayConfig.manifest(), DEFAULT_VALUE_STRING);
      this.qualifiers = pick(this.qualifiers, overlayConfig.qualifiers(), "");
      this.packageName = pick(this.packageName, overlayConfig.packageName(), "");
      this.abiSplit = pick(this.abiSplit, overlayConfig.abiSplit(), "");
      this.resourceDir = pick(this.resourceDir, overlayConfig.resourceDir(), Config.DEFAULT_RES_FOLDER);
      this.assetDir = pick(this.assetDir, overlayConfig.assetDir(), Config.DEFAULT_ASSET_FOLDER);
      this.buildDir = pick(this.buildDir, overlayConfig.buildDir(), Config.DEFAULT_BUILD_FOLDER);
      this.constants = pick(this.constants, overlayConfig.constants(), Void.class);

      Set<Class<?>> shadows = new HashSet<>();
      shadows.addAll(Arrays.asList(this.shadows));
      shadows.addAll(Arrays.asList(overlayConfig.shadows()));
      this.shadows = shadows.toArray(new Class[shadows.size()]);

      Set<String> instrumentedPackages = new HashSet<>();
      instrumentedPackages.addAll(Arrays.asList(this.instrumentedPackages));
      instrumentedPackages.addAll(Arrays.asList(overlayConfig.instrumentedPackages()));
      this.instrumentedPackages = instrumentedPackages.toArray(new String[instrumentedPackages.size()]);

      this.application = pick(this.application, overlayConfig.application(), DEFAULT_APPLICATION);

      Set<String> libraries = new HashSet<>();
      libraries.addAll(Arrays.asList(this.libraries));
      libraries.addAll(Arrays.asList(overlayConfig.libraries()));
      this.libraries = libraries.toArray(new String[libraries.size()]);

      return this;
    }

    private <T> T pick(T baseValue, T overlayValue, T nullValue) {
      return overlayValue != null ? (overlayValue.equals(nullValue) ? baseValue : overlayValue) : null;
    }

    private int[] pickSdk(int[] baseValue, int[] overlayValue, int[] nullValue) {
      return Arrays.equals(overlayValue, nullValue) ? baseValue : overlayValue;
    }

    public Implementation build() {
      return new Implementation(sdk, minSdk, maxSdk, manifest, qualifiers, packageName, abiSplit, resourceDir, assetDir, buildDir, shadows, instrumentedPackages, application, libraries, constants);
    }

    public static boolean isDefaultApplication(Class<? extends Application> clazz) {
      return clazz == null || clazz.getCanonicalName().equals(DEFAULT_APPLICATION.getCanonicalName());
    }
  }
}
