/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; 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.lwt;

import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;

import org.json.JSONObject;
import org.mozilla.gecko.AppConstants.Versions;
import org.mozilla.gecko.EventDispatcher;
import org.mozilla.gecko.GeckoSharedPrefs;
import org.mozilla.gecko.gfx.BitmapUtils;
import org.mozilla.gecko.util.BundleEventListener;
import org.mozilla.gecko.util.EventCallback;
import org.mozilla.gecko.util.GeckoBundle;
import org.mozilla.gecko.util.WindowUtils;
import org.mozilla.gecko.util.ThreadUtils;
import org.mozilla.gecko.util.ThreadUtils.AssertBehavior;

import android.app.Application;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.Shader;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.support.annotation.ColorInt;
import android.text.TextUtils;
import android.util.Log;
import android.view.Gravity;
import android.view.View;
import android.view.ViewParent;

public class LightweightTheme implements BundleEventListener {
    private static final String LOGTAG = "GeckoLightweightTheme";

    private static final String PREFS_URL = "lightweightTheme.headerURL";
    private static final String PREFS_COLOR = "lightweightTheme.color";

    private static final String ASSETS_PREFIX = "resource://android/assets/";

    private final Application mApplication;

    private Bitmap mBitmap;
    private @ColorInt int mColor;
    private boolean mIsLight;

    public static interface OnChangeListener {
        // The View should change its background/text color.
        public void onLightweightThemeChanged();

        // The View should reset to its default background/text color.
        public void onLightweightThemeReset();
    }

    private final List<OnChangeListener> mListeners;

    class LightweightThemeRunnable implements Runnable {
        private String mHeaderURL;
        private String mColor;

        private String mSavedURL;
        private String mSavedColor;

        LightweightThemeRunnable() {
        }

        LightweightThemeRunnable(final String headerURL, final String color) {
            mHeaderURL = headerURL;
            mColor = color;
        }

        private void loadFromPrefs() {
            SharedPreferences prefs = GeckoSharedPrefs.forProfile(mApplication);
            mSavedURL = prefs.getString(PREFS_URL, null);
            mSavedColor = prefs.getString(PREFS_COLOR, null);
        }

        private void saveToPrefs() {
            GeckoSharedPrefs.forProfile(mApplication)
                            .edit()
                            .putString(PREFS_URL, mHeaderURL)
                            .putString(PREFS_COLOR, mColor)
                            .apply();

            // Let's keep the saved data in sync.
            mSavedURL = mHeaderURL;
            mSavedColor = mColor;
        }

        @Override
        public void run() {
            // Load the data from preferences, if it exists.
            loadFromPrefs();

            if (TextUtils.isEmpty(mHeaderURL)) {
                // mHeaderURL is null is this is the early startup path. Use
                // the saved values, if we have any.
                mHeaderURL = mSavedURL;
                mColor = mSavedColor;
                if (TextUtils.isEmpty(mHeaderURL)) {
                    // We don't have any saved values, so we probably don't have
                    // any lightweight theme set yet.
                    return;
                }
            } else if (TextUtils.equals(mHeaderURL, mSavedURL)) {
                // If we are already using the given header, just return
                // without doing any work.
                return;
            } else {
                // mHeaderURL and mColor probably need to be saved if we get here.
                saveToPrefs();
            }

            String croppedURL = mHeaderURL;
            int mark = croppedURL.indexOf('?');
            if (mark != -1) {
                croppedURL = croppedURL.substring(0, mark);
            }

            if (croppedURL.startsWith(ASSETS_PREFIX)) {
                onBitmapLoaded(loadFromAssets(croppedURL));
            } else {
                onBitmapLoaded(BitmapUtils.decodeUrl(croppedURL));
            }
        }

        private Bitmap loadFromAssets(String url) {
            InputStream stream = null;

            try {
                stream = mApplication.getAssets().open(url.substring(ASSETS_PREFIX.length()));
                return BitmapFactory.decodeStream(stream);
            } catch (IOException e) {
                return null;
            } finally {
                if (stream != null) {
                    try {
                        stream.close();
                    } catch (IOException e) { }
                }
            }
        }

        private void onBitmapLoaded(final Bitmap bitmap) {
            ThreadUtils.postToUiThread(new Runnable() {
                @Override
                public void run() {
                    setLightweightTheme(bitmap, mColor);
                }
            });
        }
    }

    public LightweightTheme(Application application) {
        mApplication = application;
        mListeners = new ArrayList<OnChangeListener>();

        // unregister isn't needed as the lifetime is same as the application.
        EventDispatcher.getInstance().registerUiThreadListener(this,
            "LightweightTheme:Update",
            "LightweightTheme:Disable");

        ThreadUtils.postToBackgroundThread(new LightweightThemeRunnable());
    }

    public void addListener(final OnChangeListener listener) {
        // Don't inform the listeners that attached late.
        // Their onLayout() will take care of them before their onDraw();
        mListeners.add(listener);
    }

    public void removeListener(OnChangeListener listener) {
        mListeners.remove(listener);
    }

    @Override
    public void handleMessage(String event, GeckoBundle message, EventCallback callback) {
        if (event.equals("LightweightTheme:Update")) {
            GeckoBundle lightweightTheme = message.getBundle("data");
            final String headerURL = lightweightTheme.getString("headerURL");
            final String color = lightweightTheme.getString("accentcolor", "");

            ThreadUtils.postToBackgroundThread(new LightweightThemeRunnable(headerURL, color));

        } else if (event.equals("LightweightTheme:Disable")) {
            // Clear the saved data when a theme is disabled.
            // Called on the Gecko thread, but should be very lightweight.
            clearPrefs();
            resetLightweightTheme();
        }
    }

    /**
     * Clear the data stored in preferences for fast path loading during startup
     */
    private void clearPrefs() {
        GeckoSharedPrefs.forProfile(mApplication)
                        .edit()
                        .remove(PREFS_URL)
                        .remove(PREFS_COLOR)
                        .apply();
    }

    /**
     * Set a new lightweight theme with the given bitmap.
     * Note: This should be called on the UI thread to restrict accessing the
     * bitmap to a single thread.
     *
     * @param bitmap The bitmap used for the lightweight theme.
     * @param color  The background/accent color used for the lightweight theme.
     */
    private void setLightweightTheme(Bitmap bitmap, String color) {
        if (bitmap == null || bitmap.getWidth() == 0 || bitmap.getHeight() == 0) {
            mBitmap = null;
            return;
        }

        // Get the max display dimension so we can crop or expand the theme.
        final int maxWidth = WindowUtils.getLargestDimension(mApplication);

        // The lightweight theme image's width and height.
        final int bitmapWidth = bitmap.getWidth();
        final int bitmapHeight = bitmap.getHeight();

        try {
            mColor = Color.parseColor(color);
        } catch (Exception e) {
            // Malformed or missing color.
            // We attempt calculating an accent colour ourselves, falling back to TRANSPARENT.
            mColor = BitmapUtils.getDominantColor(bitmap, Color.TRANSPARENT);
        }

        // Calculate the luminance to determine if it's a light or a dark theme.
        double luminance = (0.2125 * ((mColor & 0x00FF0000) >> 16)) +
                           (0.7154 * ((mColor & 0x0000FF00) >> 8)) +
                           (0.0721 * (mColor & 0x000000FF));
        mIsLight = luminance > 110;

        // The bitmap image might be smaller than the device's width.
        // If it's smaller, fill the extra space on the left with the dominant color.
        if (bitmapWidth >= maxWidth) {
            mBitmap = Bitmap.createBitmap(bitmap, bitmapWidth - maxWidth, 0, maxWidth, bitmapHeight);
        } else {
            Paint paint = new Paint();
            paint.setAntiAlias(true);

            // Create a bigger image that can fill the device width.
            // By creating a canvas for the bitmap, anything drawn on the canvas
            // will be drawn on the bitmap.
            mBitmap = Bitmap.createBitmap(maxWidth, bitmapHeight, Bitmap.Config.ARGB_8888);
            Canvas canvas = new Canvas(mBitmap);

            // Fill the canvas with dominant color.
            canvas.drawColor(mColor);

            // The image should be top-right aligned.
            Rect rect = new Rect();
            Gravity.apply(Gravity.TOP | Gravity.RIGHT,
                          bitmapWidth,
                          bitmapHeight,
                          new Rect(0, 0, maxWidth, bitmapHeight),
                          rect);

            // Draw the bitmap.
            canvas.drawBitmap(bitmap, null, rect, paint);
        }

        for (OnChangeListener listener : mListeners) {
            listener.onLightweightThemeChanged();
        }
    }

    /**
     * Reset the lightweight theme.
     * Note: This should be called on the UI thread to restrict accessing the
     * bitmap to a single thread.
     */
    private void resetLightweightTheme() {
        ThreadUtils.assertOnUiThread(AssertBehavior.NONE);
        if (mBitmap == null) {
            return;
        }

        // Reset the bitmap.
        mBitmap = null;

        for (OnChangeListener listener : mListeners) {
            listener.onLightweightThemeReset();
        }
    }

    /**
     * A lightweight theme is enabled only if there is an active bitmap.
     *
     * @return True if the theme is enabled.
     */
    public boolean isEnabled() {
        return (mBitmap != null);
    }

    /**
     * Based on the luminance of the domanint color, a theme is classified as light or dark.
     *
     * @return True if the theme is light.
     */
    public boolean isLightTheme() {
        return mIsLight;
    }

    /**
     * @return The accent color of the theme.
     */
    public @ColorInt int getColor() {
        return mColor;
    }

    /**
     * Crop the image based on the position of the view on the window.
     * Either the View or one of its ancestors might have scrolled or translated.
     * This value should be taken into account while mapping the View to the Bitmap.
     *
     * @param view The view requesting a cropped bitmap.
     */
    private Bitmap getCroppedBitmap(View view) {
        if (mBitmap == null || view == null) {
            return null;
        }

        // Get the global position of the view on the entire screen.
        Rect rect = new Rect();
        view.getGlobalVisibleRect(rect);

        // Get the activity's window position. This does an IPC call, may be expensive.
        Rect window = new Rect();
        view.getWindowVisibleDisplayFrame(window);

        // Calculate the coordinates for the cropped bitmap.
        int screenWidth = view.getContext().getResources().getDisplayMetrics().widthPixels;
        int left = mBitmap.getWidth() - screenWidth + rect.left;
        int right = mBitmap.getWidth() - screenWidth + rect.right;
        int top = rect.top - window.top;
        int bottom = rect.bottom - window.top;

        int offsetX = 0;
        int offsetY = 0;

        // Find if this view or any of its ancestors has been translated or scrolled.
        ViewParent parent;
        View curView = view;
        do {
            offsetX += (int) curView.getTranslationX() - curView.getScrollX();
            offsetY += (int) curView.getTranslationY() - curView.getScrollY();

            parent = curView.getParent();

            if (parent instanceof View) {
                curView = (View) parent;
            }

        } while (parent instanceof View);

        // Adjust the coordinates for the offset.
        left -= offsetX;
        right -= offsetX;
        top -= offsetY;
        bottom -= offsetY;

        // The either the required height may be less than the available image height or more than it.
        // If the height required is more, crop only the available portion on the image.
        int width = right - left;
        int height = (bottom > mBitmap.getHeight() ? mBitmap.getHeight() - top : bottom - top);

        // There is a chance that the view is not visible or doesn't fall within the phone's size.
        // In this case, 'rect' will have all values as '0'. Hence 'top' and 'bottom' may be negative,
        // and createBitmap() will fail.
        // The view will get a background in its next layout pass.
        try {
            return Bitmap.createBitmap(mBitmap, left, top, width, height);
        } catch (Exception e) {
            return null;
        }
    }

    /**
     * Converts the cropped bitmap to a BitmapDrawable and returns the same.
     *
     * @param view The view for which a background drawable is required.
     * @return Either the cropped bitmap as a Drawable or null.
     */
    public Drawable getDrawable(View view) {
        Bitmap bitmap = getCroppedBitmap(view);
        if (bitmap == null) {
            return null;
        }

        BitmapDrawable drawable = new BitmapDrawable(view.getContext().getResources(), bitmap);
        drawable.setGravity(Gravity.TOP | Gravity.RIGHT);
        drawable.setTileModeXY(Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
        return drawable;
    }

    /**
     * Converts the cropped bitmap to a LightweightThemeDrawable, placing it over the dominant color.
     *
     * @param view The view for which a background drawable is required.
     * @return Either the cropped bitmap as a Drawable or null.
     */
     public LightweightThemeDrawable getColorDrawable(View view) {
         return getColorDrawable(view, mColor, false);
     }

    /**
     * Converts the cropped bitmap to a LightweightThemeDrawable, placing it over the required color.
     *
     * @param view The view for which a background drawable is required.
     * @param color The color over which the drawable should be drawn.
     * @return Either the cropped bitmap as a Drawable or null.
     */
    public LightweightThemeDrawable getColorDrawable(View view, int color) {
        return getColorDrawable(view, color, false);
    }

    /**
     * Converts the cropped bitmap to a LightweightThemeDrawable, placing it over the required color.
     *
     * @param view The view for which a background drawable is required.
     * @param color The color over which the drawable should be drawn.
     * @param needsDominantColor A layer of dominant color is needed or not.
     * @return Either the cropped bitmap as a Drawable or null.
     */
    public LightweightThemeDrawable getColorDrawable(View view, int color, boolean needsDominantColor) {
        Bitmap bitmap = getCroppedBitmap(view);
        if (bitmap == null) {
            return null;
        }

        LightweightThemeDrawable drawable = new LightweightThemeDrawable(view.getContext().getResources(), bitmap);
        if (needsDominantColor) {
            drawable.setColorWithFilter(color, (mColor & 0x22FFFFFF));
        } else {
            drawable.setColor(color);
        }

        return drawable;
    }
}
