package fr.prcaen.externalresources;

import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Color;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.support.annotation.AnyRes;
import android.support.annotation.ArrayRes;
import android.support.annotation.BoolRes;
import android.support.annotation.ColorInt;
import android.support.annotation.ColorRes;
import android.support.annotation.DimenRes;
import android.support.annotation.IdRes;
import android.support.annotation.IntegerRes;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.StringRes;
import android.util.DisplayMetrics;
import fr.prcaen.externalresources.converter.Converter;
import fr.prcaen.externalresources.converter.JsonConverter;
import fr.prcaen.externalresources.exception.ExternalResourceException;
import fr.prcaen.externalresources.exception.NotFoundException;
import fr.prcaen.externalresources.listener.OnExternalResourcesChangeListener;
import fr.prcaen.externalresources.listener.OnExternalResourcesLoadFailedListener;
import fr.prcaen.externalresources.model.DimensionResource;
import fr.prcaen.externalresources.model.Resource;
import fr.prcaen.externalresources.model.Resources;
import fr.prcaen.externalresources.url.DefaultUrl;
import fr.prcaen.externalresources.url.Url;
import java.util.ArrayList;

/**
 * Update your Android resources (strings, integers, booleans, ...) over the air.
 *
 * - Use native Android resources or default raw Json / XML files.
 * - Define your own URL builder which allow you calling your server with query strings or url
 * params
 * - Define if a config change through onConfigurationChanged if the library should call your
 * server.
 * - Cache based on Http last modified header.
 * - Event triggered when resources have changed.
 * - Event triggered when resources loading has fail.
 * - Define your own converter. Json is the default one. This library also provide a Xml converter.
 *
 * Use {@link #initialize(Context, String)} for the global singleton instance or construct your
 * own instance with {@link Builder}.
 */
public class ExternalResources {
  public static final String TAG = "ExternalResources";

  protected static volatile ExternalResources singleton = null;
  @NonNull protected final ArrayList<OnExternalResourcesChangeListener> listeners =
      new ArrayList<>();
  @NonNull private final Context context;
  @NonNull private final DisplayMetrics metrics;
  @NonNull private final Dispatcher dispatcher;
  @NonNull private final Options options;
  private final boolean useApplicationResources;
  @Nullable private OnExternalResourcesLoadFailedListener failedListener;
  @NonNull private Resources resources;
  @NonNull private Configuration configuration;

  private ExternalResources(@NonNull Context context, @NonNull Converter converter,
      @NonNull Url url, @NonNull Options options, @Cache.Policy int cachePolicy,
      @Logger.Level int logLevel, @NonNull Resources defaultResources,
      @Nullable OnExternalResourcesLoadFailedListener failedListener,
      boolean useApplicationResources) {
    Logger.setLevel(logLevel);

    this.context = context;
    this.dispatcher = new Dispatcher(context, new Downloader(context, converter, url, options),
        new ExternalResourcesHandler(this), cachePolicy);
    this.configuration = new Configuration(context.getResources().getConfiguration());
    this.metrics = context.getResources().getDisplayMetrics();
    this.resources = defaultResources;
    this.options = options;
    this.failedListener = failedListener;
    this.useApplicationResources = useApplicationResources;

    launch();
  }

  /**
   * Initialize ExternalResources instance with defaults parameters.
   *
   * @param context Any context, will not be retained.
   * @param url Url implementation
   * @return ExternalResources instance
   * @throws IllegalArgumentException if context is null or if url is null.
   * @see Url
   */
  @SuppressWarnings("ConstantConditions") public static ExternalResources initialize(
      @NonNull Context context, Url url) {
    if (null == context) {
      throw new IllegalArgumentException("Context must not be null.");
    }

    if (null == url) {
      throw new IllegalArgumentException("Path must not be null.");
    }

    synchronized (ExternalResources.class) {
      if (null != singleton) {
        throw new IllegalStateException("Singleton instance already exists.");
      }

      singleton = new Builder(context, url).build();
    }

    return singleton;
  }

  /**
   * Initialize ExternalResources instance with defaults parameters.
   * eg: If your base url is http://test.com/android-resources.json, it will be append by query
   * parameters:
   * http://test.com/android-resources.json?locale=fr_FR&amp;density_dpi=320&amp;screen_height_dp=100&amp;navigation_hidden=0...
   *
   * @param context Any context, will not be retained.
   * @param baseUrl URL string composed of a scheme, host, path and optionally port
   * @return ExternalResources instance
   * @throws IllegalArgumentException if context is null or if path is null.
   * @see DefaultUrl
   */
  @SuppressWarnings("ConstantConditions") public static ExternalResources initialize(
      @NonNull Context context, @NonNull String baseUrl) {
    if (null == context) {
      throw new IllegalArgumentException("Context must not be null.");
    }

    if (null == baseUrl) {
      throw new IllegalArgumentException("URL must not be null.");
    }

    synchronized (ExternalResources.class) {
      if (null != singleton) {
        throw new IllegalStateException("Singleton instance already exists.");
      }

      singleton = new Builder(context, new DefaultUrl(baseUrl)).build();
    }

    return singleton;
  }

  /**
   * Initialize ExternalResources singleton with external resources instance.
   *
   * @param externalResources instance of @ExternalResources.
   * @return ExternalResources instance
   * @throws IllegalArgumentException if singleton of ExternalResources already exists or if
   * externalResources is null.
   * @see DefaultUrl
   */
  @SuppressWarnings("ConstantConditions") public static ExternalResources initialize(
      @NonNull ExternalResources externalResources) {
    if (null == externalResources) {
      throw new IllegalArgumentException("ExternalResources must not be null.");
    }
    synchronized (ExternalResources.class) {
      if (null != singleton) {
        throw new IllegalStateException("Singleton instance already exists.");
      }

      singleton = externalResources;
    }

    return singleton;
  }

  /**
   * Get ExternalResources instance, initialized by #initialize method.
   *
   * @return ExternalResources instance
   * @throws IllegalArgumentException if #initialize method has not been called before.
   * @see #initialize
   */
  public static ExternalResources getInstance() {
    if (null == singleton) {
      throw new IllegalArgumentException("You should call initialize() before getInstance().");
    }

    return singleton;
  }

  /**
   * This method should be call on in callback Application#onConfigurationChanged
   * This allow to detected changes device configuration changes while your component is running.
   *
   * @param newConfig The new device configuration.
   */
  public void onConfigurationChanged(Configuration newConfig) {
    Logger.d(TAG, "onConfigurationChanged");

    if (shouldRelaunch(newConfig)) {
      Logger.v(TAG, "Relaunch");

      launch();
    }

    this.configuration = new Configuration(newConfig);
  }

  /**
   * Return a boolean associated with a particular resource ID. This resource can come from
   * resources you provided via the URL or via default resources.
   *
   * @param resId The desired resource identifier, as generated by the aapt
   * tool. This integer encodes the package, type, and resource
   * entry. The value 0 is an invalid identifier.
   * @return Returns the boolean value contained in the resource.
   * @throws NotFoundException Throws NotFoundException if the given ID does not exist.
   */
  public boolean getBoolean(@BoolRes int resId) throws NotFoundException {
    String key = getApplicationResourceEntryName(resId);

    if (null == key) {
      throw new NotFoundException("Boolean resource with resId: " + resId);
    }

    return getBoolean(key);
  }

  /**
   * Return a boolean associated with a particular resource key. This resource can come from
   * resources you provided via the URL or via default resources.
   *
   * @param key The desired resource key.
   * @return Returns the boolean value contained in the resource.
   * @throws NotFoundException Throws NotFoundException if the given key does not exist.
   */
  public boolean getBoolean(@NonNull String key) throws NotFoundException {
    Resource resource = resources.get(key);
    if (null != resource) {
      return resource.getAsBoolean();
    }

    @BoolRes int resId = getApplicationResourceIdentifier(key, "bool");

    if (0 != resId) {
      boolean value = context.getResources().getBoolean(resId);
      resources.add(key, new Resource(value));

      return value;
    }

    throw new NotFoundException("Boolean resource with key: " + key);
  }

  /**
   * Returns a color associated with a particular resource ID and styled for
   * the current theme. This resource can come from resources you provided via the URL or
   * via default resources.
   *
   * @param resId The desired resource identifier, as generated by the aapt
   * tool. This integer encodes the package, type, and resource
   * entry. The value 0 is an invalid identifier.
   * @return A single color value in the form 0xAARRGGBB.
   * @throws NotFoundException if the given ID does not exist.
   */
  @ColorInt public int getColor(@ColorRes int resId) throws NotFoundException {
    String key = getApplicationResourceEntryName(resId);

    if (null == key) {
      throw new NotFoundException("Color resource with resId: " + resId);
    }

    Resource resource = resources.get(key);
    if (null != resource && null != resource.getAsString()) {
      return Color.parseColor(resource.getAsString());
    }

    int value = Utils.getColor(context, resId);
    resources.add(key, new Resource(value));

    return value;
  }

  /**
   * Return a color associated with a particular resource key. This resource can come from
   * resources you provided via the URL or via default resources.
   *
   * @param key The desired resource key.
   * @return A single color value in the form 0xAARRGGBB.
   * @throws NotFoundException Throws NotFoundException if the given key does not exist.
   */
  @ColorInt public int getColor(@NonNull String key) throws NotFoundException {
    Resource resource = resources.get(key);
    if (null != resource) {
      try {
        return Color.parseColor(resource.getAsString());
      } catch (IllegalArgumentException ignored) {
      }
    }

    @ColorRes int resId = getApplicationResourceIdentifier(key, "color");

    if (0 != resId) {
      return Utils.getColor(context, resId);
    }

    throw new NotFoundException("Color resource with key: " + key);
  }

  /**
   * Retrieve a dimensional for a particular resource ID. Unit
   * conversions are based on the current {@link DisplayMetrics} associated
   * with the resources.
   * This resource can come from resources you provided via the URL or via default resources.
   *
   * @param resId The desired resource identifier, as generated by the aapt
   * tool. This integer encodes the package, type, and resource
   * entry. The value 0 is an invalid identifier.
   * @return Resource dimension value multiplied by the appropriate
   * metric.
   * @throws NotFoundException Throws NotFoundException if the given ID does not exist.
   */
  public float getDimension(@DimenRes int resId) throws NotFoundException {
    String key = getApplicationResourceEntryName(resId);

    if (null == key) {
      throw new NotFoundException("Dimension resource with resId: " + resId);
    }

    Resource resource = resources.get(key);
    if (null != resource && null != resource.getAsString()) {
      return DimensionResource.fromString(resource.getAsString()).toFloat(metrics);
    }

    return context.getResources().getDimension(resId);
  }

  /**
   * Retrieve a dimensional for a particular resource key. Unit
   * conversions are based on the current {@link DisplayMetrics} associated
   * with the resources.
   * This resource can come from resources you provided via the URL or via default resources.
   *
   * @param key The desired resource key,
   * @return Resource dimension value multiplied by the appropriate
   * metric.
   * @throws NotFoundException Throws NotFoundException if the given key does not exist.
   */
  public float getDimension(@NonNull String key) throws NotFoundException {
    Resource resource = resources.get(key);
    if (null != resource && null != resource.getAsString()) {
      try {
        return DimensionResource.fromString(resource.getAsString()).toFloat(metrics);
      } catch (IllegalArgumentException ignored) {
      }
    }

    @DimenRes int resId = getApplicationResourceIdentifier(key, "dimen");

    if (0 != resId) {
      float value = context.getResources().getDimension(resId);
      resources.add(key, new Resource(value));

      return value;
    }

    throw new NotFoundException("String resource with key: " + key);
  }

  /**
   * Return the string value associated with a particular resource ID. It
   * will be stripped of any styled text information.
   * This resource can come from resources you provided via the URL or via default resources.
   *
   * @param resId The desired resource identifier, as generated by the aapt
   * tool. This integer encodes the package, type, and resource
   * entry. The value 0 is an invalid identifier.
   * @return String The string data associated with the resource,
   * stripped of styled text information.
   * @throws NotFoundException Throws NotFoundException if the given ID does not exist.
   */
  public String getString(@StringRes int resId) throws NotFoundException {
    String key = getApplicationResourceEntryName(resId);

    if (null == key) {
      throw new NotFoundException("String resource with resId: " + resId);
    }

    return getString(key);
  }

  /**
   * Return the string value associated with a particular resource key. It
   * will be stripped of any styled text information.
   * This resource can come from resources you provided via the URL or via default resources.
   *
   * @param key The desired resource key
   * @return String The string data associated with the resource,
   * stripped of styled text information.
   * @throws NotFoundException Throws NotFoundException if the given key does not exist.
   */
  public String getString(@NonNull String key) throws NotFoundException {
    Resource resource = resources.get(key);
    if (null != resource) {
      return resource.getAsString();
    }

    @StringRes int resId = getApplicationResourceIdentifier(key, "string");

    if (0 != resId) {
      String value = context.getResources().getString(resId);
      resources.add(key, new Resource(value));

      return value;
    }

    throw new NotFoundException("String resource with key: " + key);
  }

  /**
   * Return the string value associated with a particular resource ID,
   * substituting the format arguments as defined in {@link java.util.Formatter}
   * and {@link java.lang.String#format}. It will be stripped of any styled text
   * information.
   * This resource can come from resources you provided via the URL or via default resources.
   *
   * @param resId The desired resource identifier, as generated by the aapt
   * tool. This integer encodes the package, type, and resource
   * entry. The value 0 is an invalid identifier.
   * @param formatArgs The format arguments that will be used for substitution.
   * @return String The string data associated with the resource,
   * stripped of styled text information.
   * @throws NotFoundException Throws NotFoundException if the given ID does not exist.
   */
  public String getString(@StringRes int resId, Object... formatArgs) throws NotFoundException {
    String key = getApplicationResourceEntryName(resId);
    if (null == key) {
      throw new NotFoundException("String resource with resId: " + resId);
    }
    return getString(key, formatArgs);
  }

  /**
   * Return the string value associated with a particular resource key,
   * substituting the format arguments as defined in {@link java.util.Formatter}
   * and {@link java.lang.String#format}. It will be stripped of any styled text
   * information.
   * This resource can come from resources you provided via the URL or via default resources.
   *
   * @param key The desired resource key.
   * @param formatArgs The format arguments that will be used for substitution.
   * @return String The string data associated with the resource,
   * stripped of styled text information.
   * @throws NotFoundException Throws NotFoundException if the given key does not exist.
   */
  public String getString(@NonNull String key, Object... formatArgs) throws NotFoundException {
    String raw = getString(key);
    return String.format(configuration.locale, raw, formatArgs);
  }

  /**
   * Return the string array associated with a particular resource ID.
   * This resource can come from resources you provided via the URL or via default resources.
   *
   * @param resId The desired resource identifier, as generated by the aapt
   * tool. This integer encodes the package, type, and resource
   * entry. The value 0 is an invalid identifier.
   * @return The string array associated with the resource.
   * @throws NotFoundException Throws NotFoundException if the given ID does not exist.
   */
  public String[] getStringArray(@ArrayRes int resId) throws NotFoundException {
    String key = getApplicationResourceEntryName(resId);

    if (null == key) {
      throw new NotFoundException("String array resource with resId: " + resId);
    }

    return getStringArray(key);
  }

  /**
   * Return the string array associated with a particular resource key.
   * This resource can come from resources you provided via the URL or via default resources.
   *
   * @param key The desired resource identifier, as generated by the aapt
   * tool. This integer encodes the package, type, and resource
   * entry. The value 0 is an invalid identifier.
   * @return The string array associated with the resource.
   * @throws NotFoundException Throws NotFoundException if the given key does not exist.
   */
  public String[] getStringArray(@NonNull String key) throws NotFoundException {
    Resource resource = resources.get(key);
    if (null != resource) {
      return resource.getAsStringArray();
    }

    @ArrayRes int resId = getApplicationResourceIdentifier(key, "array");

    if (0 != resId) {
      String[] values = context.getResources().getStringArray(resId);
      Resource[] stringResources = new Resource[values.length];

      for (int i = 0; i < values.length; i++) {
        stringResources[i] = new Resource(values[i]);
      }

      resources.add(key, new Resource(stringResources));

      return values;
    }

    throw new NotFoundException("String array resource with key: " + key);
  }

  /**
   * Return an integer associated with a particular resource ID.
   * This resource can come from resources you provided via the URL or via default resources.
   *
   * @param resId The desired resource identifier, as generated by the aapt
   * tool. This integer encodes the package, type, and resource
   * entry. The value 0 is an invalid identifier.
   * @return Returns the integer value contained in the resource.
   * @throws NotFoundException Throws NotFoundException if the given ID does not exist.
   */
  public int getInteger(@IntegerRes int resId) throws NotFoundException {
    String key = getApplicationResourceEntryName(resId);

    if (null != key) {
      Resource resource = resources.get(key);
      if (null != resource && null != resource.getAsInt()) {
        return resource.getAsInt();
      }
    }

    throw new NotFoundException("Integer resource with resId: " + resId);
  }

  /**
   * Return an integer associated with a particular resource key.
   * This resource can come from resources you provided via the URL or via default resources.
   *
   * @param key The desired resource key.
   * @return Returns the integer value contained in the resource.
   * @throws NotFoundException Throws NotFoundException if the given key does not exist.
   */
  public int getInteger(@NonNull String key) throws NotFoundException {
    Resource resource = resources.get(key);
    if (null != resource && null != resource.getAsInt()) {
      return resource.getAsInt();
    }

    @IntegerRes int resId = getApplicationResourceIdentifier(key, "integer");

    if (0 != resId) {
      int value = context.getResources().getInteger(resId);
      resources.add(key, new Resource(value));

      return value;
    }

    throw new NotFoundException("Integer resource with key: " + key);
  }

  /**
   * Return the int array associated with a particular resource ID.
   * * This resource can come from resources you provided via the URL or via default resources.
   *
   * @param resId The desired resource identifier, as generated by the aapt
   * tool. This integer encodes the package, type, and resource
   * entry. The value 0 is an invalid identifier.
   * @return The int array associated with the resource.
   * @throws NotFoundException Throws NotFoundException if the given ID does not exist.
   */
  public int[] getIntArray(@ArrayRes int resId) throws NotFoundException {
    String key = getApplicationResourceEntryName(resId);

    if (null == key) {
      throw new NotFoundException("Int array resource with resId: " + resId);
    }

    return getIntArray(key);
  }

  /**
   * Return the int array associated with a particular resource key.
   * * This resource can come from resources you provided via the URL or via default resources.
   *
   * @param key The desired resource identifier.
   * @return The int array associated with the resource.
   * @throws NotFoundException Throws NotFoundException if the given key does not exist.
   */
  public int[] getIntArray(@NonNull String key) throws NotFoundException {
    Resource resource = resources.get(key);
    if (null != resource && null != resource.getAsIntegerArray()) {
      return resource.getAsIntegerArray();
    }

    @ArrayRes int resId = getApplicationResourceIdentifier(key, "array");

    if (0 != resId) {
      int[] values = context.getResources().getIntArray(resId);

      Resource[] intResources = new Resource[values.length];

      for (int i = 0; i < values.length; i++) {
        intResources[i] = new Resource(values[i]);
      }

      resources.add(key, new Resource(intResources));

      return values;
    }

    throw new NotFoundException("Int array resource with key: " + key);
  }

  /**
   * Register a listener which is trigger when resources are loaded or have changed
   *
   * @param listener receiver callback
   * @see OnExternalResourcesChangeListener#onExternalResourcesChange(ExternalResources)
   */
  public void register(OnExternalResourcesChangeListener listener) {
    Logger.v(TAG, "Register listener:" + listener.getClass().getSimpleName());
    listeners.add(listener);
  }

  /**
   * Unregister a listener which is trigger when resources are loaded or have changed
   *
   * @param listener receiver callback
   * @see OnExternalResourcesChangeListener#onExternalResourcesChange(ExternalResources)
   */
  public void unregister(OnExternalResourcesChangeListener listener) {
    Logger.v(TAG, "Unregister listener:" + listener.getClass().getSimpleName());
    listeners.remove(listener);
  }

  /**
   * Unregister fail listener, initialised by Builder#failListener
   */
  public void removeFailListener() {
    this.failedListener = null;
  }

  private void onResourcesLoadSuccess(Resources resources) {
    Logger.i(TAG, "onResourcesLoadSuccess");
    this.resources.merge(resources);

    triggerChange();
  }

  private void onResourcesLoadFailed(ExternalResourceException exception) {
    Logger.e(TAG, "onResourcesLoadFailed", exception);

    if (null != failedListener) {
      failedListener.onExternalResourcesLoadFailed(exception);
    }
  }

  private void launch() {
    Logger.v(TAG, "Launch");

    dispatcher.dispatchLaunch();
  }

  @Nullable private String getApplicationResourceEntryName(@AnyRes int resId)
      throws IllegalStateException {
    if (!useApplicationResources) {
      throw new IllegalStateException(
          "You have set the useApplicationResources to false, using application resource is an error.");
    }

    return Utils.getAndroidResourceEntryName(context, resId);
  }

  @IdRes private int getApplicationResourceIdentifier(String key, String defType) {
    return useApplicationResources ? Utils.getAndroidResourceIdentifier(context, key, defType) : 0;
  }

  @SuppressWarnings("ConstantConditions") private boolean shouldRelaunch(Configuration newConfig) {
    return configuration.fontScale != newConfig.fontScale && options.isUseFontScale()
        || configuration.hardKeyboardHidden != newConfig.hardKeyboardHidden
        && options.isUseHardKeyboardHidden()
        || configuration.keyboard != newConfig.keyboard && options.isUseKeyboard()
        || configuration.keyboardHidden != newConfig.keyboardHidden && options.isUseKeyboardHidden()
        || configuration.mcc != newConfig.mcc && options.isUseMcc()
        || configuration.mnc != newConfig.mnc && options.isUseMnc()
        || configuration.navigation != newConfig.navigation && options.isUseNavigation()
        || configuration.navigationHidden != newConfig.navigationHidden
        && options.isUseNavigationHidden()
        || configuration.orientation != newConfig.orientation && options.isUseOrientation()
        || configuration.screenLayout != newConfig.screenLayout && options.isUseScreenLayout()
        || configuration.touchscreen != newConfig.touchscreen && options.isUseTouchscreen()
        || configuration.uiMode != newConfig.uiMode && options.isUseUiMode()
        || !configuration.locale.equals(newConfig.locale) && options.isUseLocale()
        || Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1
        && configuration.densityDpi != newConfig.densityDpi
        && options.isUseDensityDpi()
        || Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR2
        && configuration.screenWidthDp != newConfig.screenWidthDp
        && options.isUseScreenWidthDp()
        || Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR2
        && configuration.screenHeightDp != newConfig.screenHeightDp
        && options.isUseScreenHeightDp()
        || Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR2
        && configuration.smallestScreenWidthDp != newConfig.smallestScreenWidthDp
        && options.isUseSmallestScreenWidthDp();
  }

  private void triggerChange() {
    for (OnExternalResourcesChangeListener listener : listeners) {
      if (null != listener) {
        Logger.v(TAG, "Trigger change for listener: " + listener.getClass().getSimpleName());
        listener.onExternalResourcesChange(this);
      }
    }
  }

  @SuppressWarnings("ConstantConditions") public static class Builder {
    private final Context context;
    private final Url url;

    @Cache.Policy private int cachePolicy = Cache.POLICY_ALL;
    @Logger.Level private int logLevel = Logger.LEVEL_ERROR;
    @Nullable private Resources defaultResources;
    @Nullable private OnExternalResourcesLoadFailedListener listener;
    @Nullable private Options options;
    @Nullable private Converter converter;
    private boolean useApplicationResources = true;

    /**
     * Initialize builder with mandatory parameters.
     *
     * @param context Any context, will not be retained.
     * @param url Url implementation.
     */
    public Builder(@NonNull Context context, @NonNull Url url) {
      if (null == context) {
        throw new IllegalArgumentException("Context must not be null.");
      }

      if (null == url) {
        throw new IllegalArgumentException("Url must not be null.");
      }

      this.context = context.getApplicationContext();
      this.url = url;
    }

    /**
     * Set cache policy for requesting resources.
     *
     * @param cachePolicy POLICY_NONE no cache, POLICY_OFFLINE to force cache, POLICY_ALL to cache
     * all
     * @return Builder instance.
     */
    public Builder cachePolicy(@Cache.Policy int cachePolicy) {
      this.cachePolicy = cachePolicy;

      return this;
    }

    /**
     * Set default resources before using resources from the web.
     *
     * @param defaultResources default instance of resources.
     * @return Builder instance.
     */
    public Builder defaultResources(@NonNull Resources defaultResources) {
      if (null == defaultResources) {
        throw new IllegalArgumentException("Default resources must not be null.");
      }

      if (null != this.defaultResources) {
        throw new IllegalStateException("Default resources already set.");
      }

      this.defaultResources = defaultResources;

      return this;
    }

    /**
     * Set a listener which will be trigger if it's impossible to load external resources.
     *
     * @param listener OnExternalResourcesLoadFailedListener.
     * @return Builder instance.
     */
    public Builder failListener(@NonNull OnExternalResourcesLoadFailedListener listener) {
      if (null == listener) {
        throw new IllegalArgumentException("Listener must not be null.");
      }

      if (null != this.listener) {
        throw new IllegalStateException("Listener already set.");
      }

      this.listener = listener;

      return this;
    }

    /**
     * Allow to set which configuration should be take into account when configuration change.
     *
     * @param options Options
     * @return Builder instance.
     * @see Options
     */
    public Builder options(@NonNull Options options) {
      if (null == options) {
        throw new IllegalArgumentException("Options must not be null.");
      }

      if (null != this.options) {
        throw new IllegalStateException("Options already set.");
      }

      this.options = options;

      return this;
    }

    /**
     * Set log level.
     *
     * @param logLevel LEVEL_OFF, LEVEL_ERROR, LEVEL_WARN, LEVEL_INFO, LEVEL_DEBUG, LEVEL_VERBOSE
     * @return Builder instance.
     */
    public Builder logLevel(@Logger.Level int logLevel) {
      this.logLevel = logLevel;

      return this;
    }

    /**
     * Define a custom Converter implementation
     *
     * @param converter Converter implementation
     * @return Builder instance.
     */
    public Builder converter(@NonNull Converter converter) {
      if (null == converter) {
        throw new IllegalArgumentException("Converter must not be null.");
      }

      if (null != this.converter) {
        throw new IllegalStateException("Converter already set.");
      }

      this.converter = converter;

      return this;
    }

    /**
     * Allow to use application resources.
     *
     * @param useApplicationResources boolean true if you want to use them, false if not.
     * @return Builder instance.
     */
    public Builder useApplicationResources(boolean useApplicationResources) {
      this.useApplicationResources = useApplicationResources;

      return this;
    }

    /**
     * Build ExternalResources instance.
     *
     * @return ExternalResources instance.
     */
    public ExternalResources build() {
      if (null == defaultResources) {
        this.defaultResources = new Resources();
      }

      if (null == options) {
        this.options = Options.createDefault();
      }

      if (null == converter) {
        this.converter = new JsonConverter();
      }

      return new ExternalResources(context, converter, url, options, cachePolicy, logLevel,
          defaultResources, listener, useApplicationResources);
    }
  }

  private static class ExternalResourcesHandler extends Handler {

    private final ExternalResources externalResources;

    public ExternalResourcesHandler(ExternalResources externalResources) {
      super(Looper.getMainLooper());

      this.externalResources = externalResources;
    }

    @Override public void handleMessage(Message message) {
      super.handleMessage(message);

      switch (message.what) {
        case Dispatcher.REQUEST_DONE:
          externalResources.onResourcesLoadSuccess((Resources) message.obj);
          break;
        case Dispatcher.REQUEST_FAILED:
          externalResources.onResourcesLoadFailed((ExternalResourceException) message.obj);
          break;
        default:
          Logger.e(ExternalResources.TAG, "Unknown message: " + message.what);
          break;
      }
    }
  }
}