/*
 * Copyright 2017 Google Inc. All Rights Reserved.
 *
 * 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 pub.devrel.bundler;

import android.content.Intent;
import android.os.Bundle;

import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;

/**
 * Main entry point for automatically bundling (and un-bundling) classes annotated with
 * {@link BundlerClass}.
 *
 * Example state class:
 * <pre>
 *     {@literal @}BundlerClass
 *     public class MyState {
 *
 *          public String field;
 *
 *          public MyState() {}
 *
 *     }
 * </pre>
 *
 * To turn a state object into a Bundle:
 * <pre>
 *     MyState state = new MyState();
 *     Bundle stateBundle = EasyBundler.toBundle(state);
 * </pre>
 *
 * To turn a Bundle into a state object:
 * <pre>
 *     MyState state = EasyBundler.fromBundle(stateBundle, MyState.class)
 * </pre>
 *
 * For convenience, methods are provided to use the above techniques to pack and unpack
 * objects from an Intent. To add a state object to an Intent:
 * <pre>
 *     Intent intent = new Intent();
 *     intent = EasyBundler.putExtra(intent, state);
 * </pre>
 *
 * To retrieve an object from an Intent:
 * <pre>
 *     MyState state = EasyBundler.fromIntent(intent, MyState.class);
 * </pre>
 */
public class EasyBundler {

    private static final Map<Class<?>, Class<?>> BUNDLER_CACHE = new HashMap<>();

    /**
     * Determines if a class can be automatically bundled by EasyBundler.
     * @param clazz the {@link Class} to bundle.
     * @return {@code true} if the class has a generated Bundler class, {@code false} otherwise.
     */
    public static boolean hasBundler(Class<?> clazz) {
        return (getBundlerClass(clazz) != null);
    }

    /**
     * Convert an object to a {@link Bundle}.
     * @param target object to bundle. Should be an instance of a class annotated with
     *               {@link BundlerClass}.
     * @return a {@link Bundle} containing all of the object's fields.
     */
    public static Bundle toBundle(Object target) {
        Class<?> bundlerClass = getBundlerClass(target.getClass());
        if (bundlerClass == null) {
            throw new RuntimeException("Could not find Bundler class for " + target.getClass());
        }

        try {
            Method method = bundlerClass.getMethod("toBundle", target.getClass());
            return (Bundle) method.invoke(null, target);
        } catch (Exception e) {
            throw new RuntimeException("Could not invoke toBundle on class " + bundlerClass, e);
        }
    }

    /**
     * Conver a {@link Bundle} to an Object,
     * @param bundle the {@link Bundle}, should be produced be {@link #toBundle(Object)}.
     * @param clazz the {@link Class} of the desired result object,
     * @param <T> the type of the result object, should be same type as the Class parameter.
     * @return an object instance of type {@code T}.
     */
    @SuppressWarnings("unchecked")
    public static <T> T fromBundle(Bundle bundle, Class<T> clazz) {
        Class<?> bundlerClass = getBundlerClass(clazz);
        if (bundlerClass == null) {
            throw new RuntimeException("Could not find Bundler class for " + clazz);
        }

        try {
            Method method = bundlerClass.getMethod("fromBundle", Bundle.class);
            return (T) method.invoke(null, bundle);
        } catch (Exception e) {
            throw new RuntimeException("Could not invoke fromBundle on class " + bundlerClass, e);
        }
    }

    /**
     * Convenience method to bundle an object and put the entire bundle into an Intent.
     * @param intent the {@link Intent} to pack the object into.
     * @param target the object to pack into the intent, see {@link #toBundle(Object)}.
     * @return the modified {@link Intent}.
     */
    public static Intent putExtra(Intent intent, Object target) {
        Bundle bundle = toBundle(target);
        return intent.putExtra(getClassKey(target.getClass()), bundle);
    }

    /**
     * Retrieve an object that was packed into an {@link Intent} via
     * {@link #putExtra(Intent, Object)}.
     * @param intent the {@link Intent} to unpack.
     * @param clazz the {@link Class} of the object to deserialize from the {@link Intent}.
     * @return an object of type {@code T}, or {@code null} if no object was found.
     */
    public static <T> T fromIntent(Intent intent, Class<T> clazz) {
        String key = getClassKey(clazz);
        Bundle bundle = intent.getBundleExtra(key);
        if (bundle == null) {
            return null;
        }

        return fromBundle(bundle, clazz);
    }

    /**
     * Find the Bundler class for a given {@link Class}, or {@code null} if none exists.
     */
    private static Class<?> getBundlerClass(Class<?> clazz) {
        // Check cache for hit
        Class<?> fromMap = BUNDLER_CACHE.get(clazz);
        if (fromMap != null) {
            return fromMap;
        }

        String bundlerClassName = clazz.getName() + "Bundler";
        try {
            // Cache and return
            Class<?> bundlerClass = Class.forName(bundlerClassName);
            BUNDLER_CACHE.put(clazz, bundlerClass);
            return bundlerClass;
        } catch (ClassNotFoundException e) {
            return null;
        }
    }

    /**
     * Get a unique key for putting a class into a Bundle/Intent.
     */
    private static String getClassKey(Class<?> clazz) {
        return "KEY_" + clazz.getCanonicalName() + "_bundle";
    }

}