/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.gecko.javaaddons;

import android.content.Context;
import android.util.Log;
import android.util.Pair;
import dalvik.system.DexClassLoader;
import org.json.JSONException;
import org.json.JSONObject;
import org.mozilla.gecko.GeckoAppShell;
import org.mozilla.gecko.sync.Utils;
import org.mozilla.gecko.util.GeckoJarReader;
import org.mozilla.gecko.util.GeckoRequest;
import org.mozilla.gecko.util.NativeEventListener;
import org.mozilla.gecko.util.NativeJSObject;
import org.mozilla.javaaddons.JavaAddonInterfaceV1;

import java.io.File;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.Map;

public class JavaAddonManagerV1 implements NativeEventListener {
    private static final String LOGTAG = "GeckoJavaAddonMgrV1";
    public static final String MESSAGE_LOAD = "JavaAddonManagerV1:Load";
    public static final String MESSAGE_UNLOAD = "JavaAddonManagerV1:Unload";

    private static JavaAddonManagerV1 sInstance;

    // Protected by static synchronized.
    private Context mApplicationContext;

    private final org.mozilla.gecko.EventDispatcher mDispatcher;

    // Protected by synchronized (this).
    private final Map<String, EventDispatcherImpl> mGUIDToDispatcherMap = new HashMap<>();

    public static synchronized JavaAddonManagerV1 getInstance() {
        if (sInstance == null) {
            sInstance = new JavaAddonManagerV1();
        }
        return sInstance;
    }

    private JavaAddonManagerV1() {
        mDispatcher = org.mozilla.gecko.EventDispatcher.getInstance();
    }

    public synchronized void init(Context applicationContext) {
        if (mApplicationContext != null) {
            // We've already registered; don't register again.
            return;
        }
        mApplicationContext = applicationContext;
        mDispatcher.registerGeckoThreadListener(this,
                MESSAGE_LOAD,
                MESSAGE_UNLOAD);
    }

    protected String getExtension(String filename) {
        if (filename == null) {
            return "";
        }
        final int last = filename.lastIndexOf(".");
        if (last < 0) {
            return "";
        }
        return filename.substring(last);
    }

    protected synchronized EventDispatcherImpl registerNewInstance(String classname, String filename)
            throws ClassNotFoundException, InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException, IOException {
        Log.d(LOGTAG, "Attempting to instantiate " + classname + "from filename " + filename);

        // It's important to maintain the extension, either .dex, .apk, .jar.
        final String extension = getExtension(filename);
        final File dexFile = GeckoJarReader.extractStream(mApplicationContext, filename, mApplicationContext.getCacheDir(), "." + extension);
        try {
            if (dexFile == null) {
                throw new IOException("Could not find file " + filename);
            }
            final File tmpDir = mApplicationContext.getDir("dex", 0); // We'd prefer getCodeCacheDir but it's API 21+.
            final DexClassLoader loader = new DexClassLoader(dexFile.getAbsolutePath(), tmpDir.getAbsolutePath(), null, mApplicationContext.getClassLoader());
            final Class<?> c = loader.loadClass(classname);
            final Constructor<?> constructor = c.getDeclaredConstructor(Context.class, JavaAddonInterfaceV1.EventDispatcher.class);
            final String guid = Utils.generateGuid();
            final EventDispatcherImpl dispatcher = new EventDispatcherImpl(guid, filename);
            final Object instance = constructor.newInstance(mApplicationContext, dispatcher);
            mGUIDToDispatcherMap.put(guid, dispatcher);
            return dispatcher;
        } finally {
            // DexClassLoader writes an optimized version, so we can get rid of our temporary extracted version.
            if (dexFile != null) {
                dexFile.delete();
            }
        }
    }

    @Override
    public synchronized void handleMessage(String event, NativeJSObject message, org.mozilla.gecko.util.EventCallback callback) {
        try {
            switch (event) {
                case MESSAGE_LOAD: {
                    if (callback == null) {
                        throw new IllegalArgumentException("callback must not be null");
                    }
                    final String classname = message.getString("classname");
                    final String filename = message.getString("filename");
                    final EventDispatcherImpl dispatcher = registerNewInstance(classname, filename);
                    callback.sendSuccess(dispatcher.guid);
                }
                break;
                case MESSAGE_UNLOAD: {
                    if (callback == null) {
                        throw new IllegalArgumentException("callback must not be null");
                    }
                    final String guid = message.getString("guid");
                    final EventDispatcherImpl dispatcher = mGUIDToDispatcherMap.remove(guid);
                    if (dispatcher == null) {
                        Log.w(LOGTAG, "Attempting to unload addon with unknown associated dispatcher; ignoring.");
                        callback.sendSuccess(false);
                    }
                    dispatcher.unregisterAllEventListeners();
                    callback.sendSuccess(true);
                }
                break;
            }
        } catch (Exception e) {
            Log.e(LOGTAG, "Exception handling message [" + event + "]", e);
            if (callback != null) {
                callback.sendError("Exception handling message [" + event + "]: " + e.toString());
            }
        }
    }

    /**
     * An event dispatcher is tied to a single Java Addon instance.  It serves to prefix all
     * messages with its unique GUID.
     * <p/>
     * Curiously, the dispatcher does not hold a direct reference to its add-on instance.  It will
     * likely hold indirect instances through its wrapping map, since the instance will probably
     * register event listeners that hold a reference to itself.  When these listeners are
     * unregistered, any link will be broken, allowing the instances to be garbage collected.
     */
    private class EventDispatcherImpl implements JavaAddonInterfaceV1.EventDispatcher {
        private final String guid;
        private final String dexFileName;

        // Protected by synchronized (this).
        private final Map<JavaAddonInterfaceV1.EventListener, Pair<NativeEventListener, String[]>> mListenerToWrapperMap = new IdentityHashMap<>();

        public EventDispatcherImpl(String guid, String dexFileName) {
            this.guid = guid;
            this.dexFileName = dexFileName;
        }

        protected class ListenerWrapper implements NativeEventListener {
            private final JavaAddonInterfaceV1.EventListener listener;

            public ListenerWrapper(JavaAddonInterfaceV1.EventListener listener) {
                this.listener = listener;
            }

            @Override
            public void handleMessage(String prefixedEvent, NativeJSObject message, final org.mozilla.gecko.util.EventCallback callback) {
                if (!prefixedEvent.startsWith(guid + ":")) {
                    return;
                }
                final String event = prefixedEvent.substring(guid.length() + 1); // Skip "guid:".
                try {
                    JavaAddonInterfaceV1.EventCallback callbackAdapter = null;
                    if (callback != null) {
                        callbackAdapter = new JavaAddonInterfaceV1.EventCallback() {
                            @Override
                            public void sendSuccess(Object response) {
                                callback.sendSuccess(response);
                            }

                            @Override
                            public void sendError(Object response) {
                                callback.sendError(response);
                            }
                        };
                    }
                    final JSONObject json = new JSONObject(message.toString());
                    listener.handleMessage(mApplicationContext, event, json, callbackAdapter);
                } catch (Exception e) {
                    Log.e(LOGTAG, "Exception handling message [" + prefixedEvent + "]", e);
                    if (callback != null) {
                        callback.sendError("Got exception handling message [" + prefixedEvent + "]: " + e.toString());
                    }
                }
            }
        }

        @Override
        public synchronized void registerEventListener(final JavaAddonInterfaceV1.EventListener listener, String... events) {
            if (mListenerToWrapperMap.containsKey(listener)) {
                Log.e(LOGTAG, "Attempting to register listener which is already registered; ignoring.");
                return;
            }

            final NativeEventListener listenerWrapper = new ListenerWrapper(listener);

            final String[] prefixedEvents = new String[events.length];
            for (int i = 0; i < events.length; i++) {
                prefixedEvents[i] = this.guid + ":" + events[i];
            }
            mDispatcher.registerGeckoThreadListener(listenerWrapper, prefixedEvents);
            mListenerToWrapperMap.put(listener, new Pair<>(listenerWrapper, prefixedEvents));
        }

        @Override
        public synchronized void unregisterEventListener(final JavaAddonInterfaceV1.EventListener listener) {
            final Pair<NativeEventListener, String[]> pair = mListenerToWrapperMap.remove(listener);
            if (pair == null) {
                Log.e(LOGTAG, "Attempting to unregister listener which is not registered; ignoring.");
                return;
            }
            mDispatcher.unregisterGeckoThreadListener(pair.first, pair.second);
        }


        protected synchronized void unregisterAllEventListeners() {
            // Unregister everything, then forget everything.
            for (Pair<NativeEventListener, String[]> pair : mListenerToWrapperMap.values()) {
                 mDispatcher.unregisterGeckoThreadListener(pair.first, pair.second);
            }
            mListenerToWrapperMap.clear();
        }

        @Override
        public void sendRequestToGecko(final String event, final JSONObject message, final JavaAddonInterfaceV1.RequestCallback callback) {
            final String prefixedEvent = guid + ":" + event;
            GeckoAppShell.sendRequestToGecko(new GeckoRequest(prefixedEvent, message) {
                @Override
                public void onResponse(NativeJSObject nativeJSObject) {
                    if (callback == null) {
                        // Nothing to do.
                        return;
                    }
                    try {
                        final JSONObject json = new JSONObject(nativeJSObject.toString());
                        callback.onResponse(GeckoAppShell.getContext(), json);
                    } catch (JSONException e) {
                        // No way to report failure.
                        Log.e(LOGTAG, "Exception handling response to request [" + event + "]:", e);
                    }
                }
            });
        }
    }
}
