/*
    Licensed to the Apache Software Foundation (ASF) under one
    or more contributor license agreements.  See the NOTICE file
    distributed with this work for additional information
    regarding copyright ownership.  The ASF licenses this file
    to you 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 org.apache.cordova;

import android.annotation.SuppressLint;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.WebChromeClient;
import android.widget.FrameLayout;

import org.apache.cordova.engine.SystemWebViewEngine;
import org.json.JSONException;
import org.json.JSONObject;

import java.lang.reflect.Constructor;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Main class for interacting with a Cordova WebView. Manages plugins, events, and a CordovaWebViewEngine.
 * Class uses two-phase initialization. You must call init() before calling any other methods.
 */
public class CordovaWebViewImpl implements CordovaWebView {

    public static final String TAG = "CordovaWebViewImpl";

    private PluginManager pluginManager;

    protected final CordovaWebViewEngine engine;
    private CordovaInterface cordova;

    // Flag to track that a loadUrl timeout occurred
    private int loadUrlTimeout = 0;

    private CordovaResourceApi resourceApi;
    private CordovaPreferences preferences;
    private CoreAndroid appPlugin;
    private NativeToJsMessageQueue nativeToJsMessageQueue;
    private EngineClient engineClient = new EngineClient();
    private boolean hasPausedEver;

    // The URL passed to loadUrl(), not necessarily the URL of the current page.
    String loadedUrl;

    /** custom view created by the browser (a video player for example) */
    private View mCustomView;
    private WebChromeClient.CustomViewCallback mCustomViewCallback;

    private Set<Integer> boundKeyCodes = new HashSet<Integer>();

    public static CordovaWebViewEngine createEngine(Context context, CordovaPreferences preferences) {
        String className = preferences.getString("webview", SystemWebViewEngine.class.getCanonicalName());
        try {
            Class<?> webViewClass = Class.forName(className);
            Constructor<?> constructor = webViewClass.getConstructor(Context.class, CordovaPreferences.class);
            return (CordovaWebViewEngine) constructor.newInstance(context, preferences);
        } catch (Exception e) {
            throw new RuntimeException("Failed to create webview. ", e);
        }
    }

    public CordovaWebViewImpl(CordovaWebViewEngine cordovaWebViewEngine) {
        this.engine = cordovaWebViewEngine;
    }

    // Convenience method for when creating programmatically (not from Config.xml).
    public void init(CordovaInterface cordova) {
        init(cordova, new ArrayList<PluginEntry>(), new CordovaPreferences());
    }

    @SuppressLint("Assert")
    @Override
    public void init(CordovaInterface cordova, List<PluginEntry> pluginEntries, CordovaPreferences preferences) {
        if (this.cordova != null) {
            throw new IllegalStateException();
        }
        this.cordova = cordova;
        this.preferences = preferences;
        pluginManager = new PluginManager(this, this.cordova, pluginEntries);
        resourceApi = new CordovaResourceApi(engine.getView().getContext(), pluginManager);
        nativeToJsMessageQueue = new NativeToJsMessageQueue();
        nativeToJsMessageQueue.addBridgeMode(new NativeToJsMessageQueue.NoOpBridgeMode());
        nativeToJsMessageQueue.addBridgeMode(new NativeToJsMessageQueue.LoadUrlBridgeMode(engine, cordova));

        if (preferences.getBoolean("DisallowOverscroll", false)) {
            engine.getView().setOverScrollMode(View.OVER_SCROLL_NEVER);
        }
        engine.init(this, cordova, engineClient, resourceApi, pluginManager, nativeToJsMessageQueue);
        // This isn't enforced by the compiler, so assert here.
        assert engine.getView() instanceof CordovaWebViewEngine.EngineView;

        pluginManager.addService(CoreAndroid.PLUGIN_NAME, "org.apache.cordova.CoreAndroid", true);
        pluginManager.init();
    }

    @Override
    public boolean isInitialized() {
        return cordova != null;
    }

    @Override
    public void loadUrlIntoView(final String url, boolean recreatePlugins) {
        LOG.d(TAG, ">>> loadUrl(" + url + ")");
        if (url.equals("about:blank") || url.startsWith("javascript:")) {
            engine.loadUrl(url, false);
            return;
        }

        recreatePlugins = recreatePlugins || (loadedUrl == null);

        if (recreatePlugins) {
            // Don't re-initialize on first load.
            if (loadedUrl != null) {
                appPlugin = null;
                pluginManager.init();
            }
            loadedUrl = url;
        }

        // Create a timeout timer for loadUrl
        final int currentLoadUrlTimeout = loadUrlTimeout;
        final int loadUrlTimeoutValue = preferences.getInteger("LoadUrlTimeoutValue", 20000);

        // Timeout error method
        final Runnable loadError = new Runnable() {
            @Override
            public void run() {
                stopLoading();
                LOG.e(TAG, "CordovaWebView: TIMEOUT ERROR!");

                // Handle other errors by passing them to the WebView in JS
                JSONObject data = new JSONObject();
                try {
                    data.put("errorCode", -6);
                    data.put("description", "The connection to the server was unsuccessful.");
                    data.put("url", url);
                } catch (JSONException e) {
                    // Will never happen.
                }
                pluginManager.postMessage("onReceivedError", data);
            }
        };

        // Timeout timer method
        final Runnable timeoutCheck = new Runnable() {
            @Override
            public void run() {
                try {
                    synchronized (this) {
                        wait(loadUrlTimeoutValue);
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                // If timeout, then stop loading and handle error (if activity still exists)
                if (loadUrlTimeout == currentLoadUrlTimeout && cordova.getActivity() != null) {
                    cordova.getActivity().runOnUiThread(loadError);
                } else if (cordova.getActivity() == null) {
                    LOG.d(TAG, "Cordova activity does not exist.");
                }
            }
        };

        if (cordova.getActivity() != null) {
            final boolean _recreatePlugins = recreatePlugins;
            cordova.getActivity().runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    if (loadUrlTimeoutValue > 0) {
                        cordova.getThreadPool().execute(timeoutCheck);
                    }
                    engine.loadUrl(url, _recreatePlugins);
                }
            });
        } else {
            LOG.d(TAG, "Cordova activity does not exist.");
        }
    }


    @Override
    public void loadUrl(String url) {
        loadUrlIntoView(url, true);
    }

    @Override
    public void showWebPage(String url, boolean openExternal, boolean clearHistory, Map<String, Object> params) {
        LOG.d(TAG, "showWebPage(%s, %b, %b, HashMap)", url, openExternal, clearHistory);

        // If clearing history
        if (clearHistory) {
            engine.clearHistory();
        }

        // If loading into our WebView
        if (!openExternal) {
            // Make sure url is in allow list
            if (pluginManager.shouldAllowNavigation(url)) {
                // TODO: What about params?
                // Load new URL
                loadUrlIntoView(url, true);
                return;
            } else {
                LOG.w(TAG, "showWebPage: Refusing to load URL into webview since it is not in the <allow-navigation> allow list. URL=" + url);
                return;
            }
        }
        if (!pluginManager.shouldOpenExternalUrl(url)) {
            LOG.w(TAG, "showWebPage: Refusing to send intent for URL since it is not in the <allow-intent> allow list. URL=" + url);
            return;
        }

        Intent intent = null;
        try {
            if (url.startsWith("intent://")) {
                intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME);
            } else {
                intent = new Intent(Intent.ACTION_VIEW);
                // To send an intent without CATEGORY_BROWSER, a custom plugin should be used.
                intent.addCategory(Intent.CATEGORY_BROWSABLE);
                Uri uri = Uri.parse(url);
                // Omitting the MIME type for file: URLs causes "No Activity found to handle Intent".
                // Adding the MIME type to http: URLs causes them to not be handled by the downloader.
                if ("file".equals(uri.getScheme())) {
                    intent.setDataAndType(uri, resourceApi.getMimeType(uri));
                } else {
                    intent.setData(uri);
                }
            }
            if (cordova.getActivity() != null) {
                cordova.getActivity().startActivity(intent);
            } else {
                LOG.d(TAG, "Cordova activity does not exist.");
            }
        } catch (URISyntaxException e) {
            LOG.e(TAG, "Error parsing url " + url, e);
        } catch (ActivityNotFoundException e) {
            if (url.startsWith("intent://") && intent != null && intent.getStringExtra("browser_fallback_url") != null) {
                showWebPage(intent.getStringExtra("browser_fallback_url"), openExternal, clearHistory, params);
            } else {
                LOG.e(TAG, "Error loading url " + url, e);
            }
        }
    }

    private static class WrapperView extends FrameLayout {

        private final CordovaWebViewEngine engine;

        public WrapperView(Context context, CordovaWebViewEngine engine) {
            super(context);
            this.engine = engine;
        }

        @Override
        public boolean dispatchKeyEvent(KeyEvent event) {
            boolean ret = engine.getView().dispatchKeyEvent(event);
            if (!ret) {
                // If the engine didn't handle the event, handle it normally.
                ret = super.dispatchKeyEvent(event);
            }
            return ret;
        }
    }

    @Override
    @Deprecated
    public void showCustomView(View view, WebChromeClient.CustomViewCallback callback) {
        // This code is adapted from the original Android Browser code, licensed under the Apache License, Version 2.0
        LOG.d(TAG, "showing Custom View");
        // if a view already exists then immediately terminate the new one
        if (mCustomView != null) {
            callback.onCustomViewHidden();
            return;
        }

        WrapperView wrapperView = new WrapperView(getContext(), engine);
        wrapperView.addView(view);

        // Store the view and its callback for later (to kill it properly)
        mCustomView = wrapperView;
        mCustomViewCallback = callback;

        // Add the custom view to its container.
        ViewGroup parent = (ViewGroup) engine.getView().getParent();
        parent.addView(wrapperView, new FrameLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT,
                Gravity.CENTER));

        // Hide the content view.
        engine.getView().setVisibility(View.GONE);

        // Finally show the custom view container.
        parent.setVisibility(View.VISIBLE);
        parent.bringToFront();
    }

    @Override
    @Deprecated
    public void hideCustomView() {
        // This code is adapted from the original Android Browser code, licensed under the Apache License, Version 2.0
        if (mCustomView == null) return;
        LOG.d(TAG, "Hiding Custom View");

        // Hide the custom view.
        mCustomView.setVisibility(View.GONE);

        // Remove the custom view from its container.
        ViewGroup parent = (ViewGroup) engine.getView().getParent();
        parent.removeView(mCustomView);
        mCustomView = null;
        mCustomViewCallback.onCustomViewHidden();

        // Show the content view.
        engine.getView().setVisibility(View.VISIBLE);
        engine.getView().requestFocus();
    }

    @Override
    @Deprecated
    public boolean isCustomViewShowing() {
        return mCustomView != null;
    }

    @Override
    @Deprecated
    public void sendJavascript(String statement) {
        nativeToJsMessageQueue.addJavaScript(statement);
    }

    @Override
    public void sendPluginResult(PluginResult cr, String callbackId) {
        nativeToJsMessageQueue.addPluginResult(cr, callbackId);
    }

    @Override
    public PluginManager getPluginManager() {
        return pluginManager;
    }
    @Override
    public CordovaPreferences getPreferences() {
        return preferences;
    }
    @Override
    public ICordovaCookieManager getCookieManager() {
        return engine.getCookieManager();
    }
    @Override
    public CordovaResourceApi getResourceApi() {
        return resourceApi;
    }
    @Override
    public CordovaWebViewEngine getEngine() {
        return engine;
    }
    @Override
    public View getView() {
        return engine.getView();
    }
    @Override
    public Context getContext() {
        return engine.getView().getContext();
    }

    private void sendJavascriptEvent(String event) {
        if (appPlugin == null) {
            appPlugin = (CoreAndroid)pluginManager.getPlugin(CoreAndroid.PLUGIN_NAME);
        }

        if (appPlugin == null) {
            LOG.w(TAG, "Unable to fire event without existing plugin");
            return;
        }
        appPlugin.fireJavascriptEvent(event);
    }

    @Override
    public void setButtonPlumbedToJs(int keyCode, boolean override) {
        switch (keyCode) {
            case KeyEvent.KEYCODE_VOLUME_DOWN:
            case KeyEvent.KEYCODE_VOLUME_UP:
            case KeyEvent.KEYCODE_BACK:
            case KeyEvent.KEYCODE_MENU:
                // TODO: Why are search and menu buttons handled separately?
                if (override) {
                    boundKeyCodes.add(keyCode);
                } else {
                    boundKeyCodes.remove(keyCode);
                }
                return;
            default:
                throw new IllegalArgumentException("Unsupported keycode: " + keyCode);
        }
    }

    @Override
    public boolean isButtonPlumbedToJs(int keyCode) {
        return boundKeyCodes.contains(keyCode);
    }

    @Override
    public Object postMessage(String id, Object data) {
        return pluginManager.postMessage(id, data);
    }

    // Engine method proxies:
    @Override
    public String getUrl() {
        return engine.getUrl();
    }

    @Override
    public void stopLoading() {
        // Clear timeout flag
        loadUrlTimeout++;
    }

    @Override
    public boolean canGoBack() {
        return engine.canGoBack();
    }

    @Override
    public void clearCache() {
        engine.clearCache();
    }

    @Override
    @Deprecated
    public void clearCache(boolean b) {
        engine.clearCache();
    }

    @Override
    public void clearHistory() {
        engine.clearHistory();
    }

    @Override
    public boolean backHistory() {
        return engine.goBack();
    }

    /////// LifeCycle methods ///////
    @Override
    public void onNewIntent(Intent intent) {
        if (this.pluginManager != null) {
            this.pluginManager.onNewIntent(intent);
        }
    }
    @Override
    public void handlePause(boolean keepRunning) {
        if (!isInitialized()) {
            return;
        }
        hasPausedEver = true;
        pluginManager.onPause(keepRunning);
        sendJavascriptEvent("pause");

        // If app doesn't want to run in background
        if (!keepRunning) {
            // Pause JavaScript timers. This affects all WebViews within the app!
            engine.setPaused(true);
        }
    }
    @Override
    public void handleResume(boolean keepRunning) {
        if (!isInitialized()) {
            return;
        }

        // Resume JavaScript timers. This affects all WebViews within the app!
        engine.setPaused(false);
        this.pluginManager.onResume(keepRunning);

        // In order to match the behavior of the other platforms, we only send onResume after an
        // onPause has occurred. The resume event might still be sent if the Activity was killed
        // while waiting for the result of an external Activity once the result is obtained
        if (hasPausedEver) {
            sendJavascriptEvent("resume");
        }
    }
    @Override
    public void handleStart() {
        if (!isInitialized()) {
            return;
        }
        pluginManager.onStart();
    }
    @Override
    public void handleStop() {
        if (!isInitialized()) {
            return;
        }
        pluginManager.onStop();
    }
    @Override
    public void handleDestroy() {
        if (!isInitialized()) {
            return;
        }
        // Cancel pending timeout timer.
        loadUrlTimeout++;

        // Forward to plugins
        this.pluginManager.onDestroy();

        // TODO: about:blank is a bit special (and the default URL for new frames)
        // We should use a blank data: url instead so it's more obvious
        this.loadUrl("about:blank");

        // TODO: Should not destroy WebView until after about:blank is done loading.
        engine.destroy();
        hideCustomView();
    }

    protected class EngineClient implements CordovaWebViewEngine.Client {
        @Override
        public void clearLoadTimeoutTimer() {
            loadUrlTimeout++;
        }

        @Override
        public void onPageStarted(String newUrl) {
            LOG.d(TAG, "onPageDidNavigate(" + newUrl + ")");
            boundKeyCodes.clear();
            pluginManager.onReset();
            pluginManager.postMessage("onPageStarted", newUrl);
        }

        @Override
        public void onReceivedError(int errorCode, String description, String failingUrl) {
            clearLoadTimeoutTimer();
            JSONObject data = new JSONObject();
            try {
                data.put("errorCode", errorCode);
                data.put("description", description);
                data.put("url", failingUrl);
            } catch (JSONException e) {
                e.printStackTrace();
            }
            pluginManager.postMessage("onReceivedError", data);
        }

        @Override
        public void onPageFinishedLoading(String url) {
            LOG.d(TAG, "onPageFinished(" + url + ")");

            clearLoadTimeoutTimer();

            // Broadcast message that page has loaded
            pluginManager.postMessage("onPageFinished", url);

            // Make app visible after 2 sec in case there was a JS error and Cordova JS never initialized correctly
            if (engine.getView().getVisibility() != View.VISIBLE) {
                Thread t = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            Thread.sleep(2000);
                            if (cordova.getActivity() != null) {
                                cordova.getActivity().runOnUiThread(new Runnable() {
                                    @Override
                                    public void run() {
                                        pluginManager.postMessage("spinner", "stop");
                                    }
                                });
                            } else {
                                LOG.d(TAG, "Cordova activity does not exist.");
                            }
                        } catch (InterruptedException e) {
                        }
                    }
                });
                t.start();
            }

            // Shutdown if blank loaded
            if (url.equals("about:blank")) {
                pluginManager.postMessage("exit", null);
            }
        }

        @Override
        public Boolean onDispatchKeyEvent(KeyEvent event) {
            int keyCode = event.getKeyCode();
            boolean isBackButton = keyCode == KeyEvent.KEYCODE_BACK;
            if (event.getAction() == KeyEvent.ACTION_DOWN) {
                if (isBackButton && mCustomView != null) {
                    return true;
                } else if (boundKeyCodes.contains(keyCode)) {
                    return true;
                } else if (isBackButton) {
                    return engine.canGoBack();
                }
            } else if (event.getAction() == KeyEvent.ACTION_UP) {
                if (isBackButton && mCustomView != null) {
                    hideCustomView();
                    return true;
                } else if (boundKeyCodes.contains(keyCode)) {
                    String eventName = null;
                    switch (keyCode) {
                        case KeyEvent.KEYCODE_VOLUME_DOWN:
                            eventName = "volumedownbutton";
                            break;
                        case KeyEvent.KEYCODE_VOLUME_UP:
                            eventName = "volumeupbutton";
                            break;
                        case KeyEvent.KEYCODE_SEARCH:
                            eventName = "searchbutton";
                            break;
                        case KeyEvent.KEYCODE_MENU:
                            eventName = "menubutton";
                            break;
                        case KeyEvent.KEYCODE_BACK:
                            eventName = "backbutton";
                            break;
                    }
                    if (eventName != null) {
                        sendJavascriptEvent(eventName);
                        return true;
                    }
                } else if (isBackButton) {
                    return engine.goBack();
                }
            }
            return null;
        }

        @Override
        public boolean onNavigationAttempt(String url) {
            // Give plugins the chance to handle the url
            if (pluginManager.onOverrideUrlLoading(url)) {
                return true;
            } else if (pluginManager.shouldAllowNavigation(url)) {
                return false;
            } else if (pluginManager.shouldOpenExternalUrl(url)) {
                showWebPage(url, true, false, null);
                return true;
            }
            LOG.w(TAG, "Blocked (possibly sub-frame) navigation to non-allowed URL: " + url);
            return true;
        }
    }
}
