/*
 * Copyright (C) 2015 The Android Open Source Project
 *
 * 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 com.android.tv.util.images;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.media.tv.TvInputInfo;
import android.os.AsyncTask;
import android.os.Handler;
import android.os.Looper;
import android.support.annotation.Nullable;
import android.support.annotation.UiThread;
import android.support.annotation.WorkerThread;
import android.util.ArraySet;
import android.util.Log;

import androidx.tvprovider.media.tv.TvContractCompat.PreviewPrograms;

import com.android.tv.R;
import com.android.tv.common.concurrent.NamedThreadFactory;
import com.android.tv.util.images.BitmapUtils.ScaledBitmapInfo;

import java.lang.ref.WeakReference;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Executor;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * This class wraps up completing some arbitrary long running work when loading a bitmap. It handles
 * things like using a memory cache, running the work in a background thread.
 */
public final class ImageLoader {
    private static final String TAG = "ImageLoader";
    private static final boolean DEBUG = false;

    private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
    // We want at least 2 threads and at most 4 threads in the core pool,
    // preferring to have 1 less than the CPU count to avoid saturating
    // the CPU with background work
    private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4));
    private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1;
    private static final int KEEP_ALIVE_SECONDS = 30;

    private static final ThreadFactory sThreadFactory = new NamedThreadFactory("ImageLoader");

    private static final BlockingQueue<Runnable> sPoolWorkQueue = new LinkedBlockingQueue<>(128);

    /**
     * An private {@link Executor} that can be used to execute tasks in parallel.
     *
     * <p>{@code IMAGE_THREAD_POOL_EXECUTOR} setting are copied from {@link AsyncTask} Since we do a
     * lot of concurrent image loading we can exhaust a thread pool. ImageLoader catches the error,
     * and just leaves the image blank. However other tasks will fail and crash the application.
     *
     * <p>Using a separate thread pool prevents image loading from causing other tasks to fail.
     */
    private static final Executor IMAGE_THREAD_POOL_EXECUTOR;

    static {
        ThreadPoolExecutor threadPoolExecutor =
                new ThreadPoolExecutor(
                        CORE_POOL_SIZE,
                        MAXIMUM_POOL_SIZE,
                        KEEP_ALIVE_SECONDS,
                        TimeUnit.SECONDS,
                        sPoolWorkQueue,
                        sThreadFactory);
        threadPoolExecutor.allowCoreThreadTimeOut(true);
        IMAGE_THREAD_POOL_EXECUTOR = threadPoolExecutor;
    }

    private static Handler sMainHandler;

    /**
     * Handles when image loading is finished.
     *
     * <p>Use this to prevent leaking an Activity or other Context while image loading is still
     * pending. When you extend this class you <strong>MUST NOT</strong> use a non static inner
     * class, or the containing object will still be leaked.
     */
    @UiThread
    public abstract static class ImageLoaderCallback<T> {
        private final WeakReference<T> mWeakReference;

        /**
         * Creates an callback keeping a weak reference to {@code referent}.
         *
         * <p>If the "referent" is no longer valid, it no longer makes sense to run the callback.
         * The referent is the View, or Activity or whatever that actually needs to receive the
         * Bitmap. If the referent has been GC, then no need to run the callback.
         */
        public ImageLoaderCallback(T referent) {
            mWeakReference = new WeakReference<>(referent);
        }

        /** Called when bitmap is loaded. */
        private void onBitmapLoaded(@Nullable Bitmap bitmap) {
            T referent = mWeakReference.get();
            if (referent != null) {
                onBitmapLoaded(referent, bitmap);
            } else {
                if (DEBUG) Log.d(TAG, "onBitmapLoaded not called because weak reference is gone");
            }
        }

        /** Called when bitmap is loaded if the weak reference is still valid. */
        public abstract void onBitmapLoaded(T referent, @Nullable Bitmap bitmap);
    }

    private static final Map<String, LoadBitmapTask> sPendingListMap = new HashMap<>();

    /**
     * Preload a bitmap image into the cache.
     *
     * <p>Not to make heavy CPU load, AsyncTask.SERIAL_EXECUTOR is used for the image loading.
     *
     * <p>This method is thread safe.
     */
    public static void prefetchBitmap(
            Context context, final String uriString, final int maxWidth, final int maxHeight) {
        if (DEBUG) Log.d(TAG, "prefetchBitmap() " + uriString);
        if (Looper.getMainLooper() == Looper.myLooper()) {
            doLoadBitmap(context, uriString, maxWidth, maxHeight, null, AsyncTask.SERIAL_EXECUTOR);
        } else {
            final Context appContext = context.getApplicationContext();
            getMainHandler()
                    .post(
                            () ->
                                    doLoadBitmap(
                                            appContext,
                                            uriString,
                                            maxWidth,
                                            maxHeight,
                                            null,
                                            AsyncTask.SERIAL_EXECUTOR));
        }
    }

    /**
     * Load a bitmap image with the cache using a ContentResolver.
     *
     * <p><b>Note</b> that the callback will be called synchronously if the bitmap already is in the
     * cache.
     *
     * @return {@code true} if the load is complete and the callback is executed.
     */
    @UiThread
    public static <T> boolean loadBitmap(
            Context context, String uriString, ImageLoaderCallback<T> callback) {
        return loadBitmap(context, uriString, Integer.MAX_VALUE, Integer.MAX_VALUE, callback);
    }

    /**
     * Load a bitmap image with the cache and resize it with given params.
     *
     * <p><b>Note</b> that the callback will be called synchronously if the bitmap already is in the
     * cache.
     *
     * @return {@code true} if the load is complete and the callback is executed.
     */
    @UiThread
    public static <T> boolean loadBitmap(
            Context context,
            String uriString,
            int maxWidth,
            int maxHeight,
            ImageLoaderCallback<T> callback) {
        if (DEBUG) {
            Log.d(TAG, "loadBitmap() " + uriString);
        }
        return doLoadBitmap(
                context, uriString, maxWidth, maxHeight, callback, IMAGE_THREAD_POOL_EXECUTOR);
    }

    private static <T> boolean doLoadBitmap(
            Context context,
            String uriString,
            int maxWidth,
            int maxHeight,
            ImageLoaderCallback<T> callback,
            Executor executor) {
        // Check the cache before creating a Task.  The cache will be checked again in doLoadBitmap
        // but checking a cache is much cheaper than creating an new task.
        ImageCache imageCache = ImageCache.getInstance();
        ScaledBitmapInfo bitmapInfo = imageCache.get(uriString);
        if (bitmapInfo != null && !bitmapInfo.needToReload(maxWidth, maxHeight)) {
            if (callback != null) {
                callback.onBitmapLoaded(bitmapInfo.bitmap);
            }
            return true;
        }
        return doLoadBitmap(
                callback,
                executor,
                new LoadBitmapFromUriTask(context, imageCache, uriString, maxWidth, maxHeight));
    }

    /**
     * Load a bitmap image with the cache and resize it with given params.
     *
     * <p>The LoadBitmapTask will be executed on a non ui thread.
     *
     * @return {@code true} if the load is complete and the callback is executed.
     */
    @UiThread
    public static <T> boolean loadBitmap(
            ImageLoaderCallback<T> callback, LoadBitmapTask<T> loadBitmapTask) {
        if (DEBUG) {
            Log.d(TAG, "loadBitmap() " + loadBitmapTask);
        }
        return doLoadBitmap(callback, IMAGE_THREAD_POOL_EXECUTOR, loadBitmapTask);
    }

    /** @return {@code true} if the load is complete and the callback is executed. */
    @UiThread
    private static <T> boolean doLoadBitmap(
            ImageLoaderCallback<T> callback, Executor executor, LoadBitmapTask<T> loadBitmapTask) {
        ScaledBitmapInfo bitmapInfo = loadBitmapTask.getFromCache();
        boolean needToReload = loadBitmapTask.isReloadNeeded();
        if (bitmapInfo != null && !needToReload) {
            if (callback != null) {
                callback.onBitmapLoaded(bitmapInfo.bitmap);
            }
            return true;
        }
        LoadBitmapTask existingTask = sPendingListMap.get(loadBitmapTask.getKey());
        if (existingTask != null && !loadBitmapTask.isReloadNeeded(existingTask)) {
            // The image loading is already scheduled and is large enough.
            if (callback != null) {
                existingTask.mCallbacks.add(callback);
            }
        } else {
            if (callback != null) {
                loadBitmapTask.mCallbacks.add(callback);
            }
            sPendingListMap.put(loadBitmapTask.getKey(), loadBitmapTask);
            try {
                loadBitmapTask.executeOnExecutor(executor);
            } catch (RejectedExecutionException e) {
                Log.e(TAG, "Failed to create new image loader", e);
                sPendingListMap.remove(loadBitmapTask.getKey());
            }
        }
        return false;
    }

    /**
     * Loads and caches a a possibly scaled down version of a bitmap.
     *
     * <p>Implement {@link #doGetBitmapInBackground} to do the actual loading.
     */
    public abstract static class LoadBitmapTask<T> extends AsyncTask<Void, Void, ScaledBitmapInfo> {
        protected final Context mAppContext;
        protected final int mMaxWidth;
        protected final int mMaxHeight;
        private final Set<ImageLoaderCallback<T>> mCallbacks = new ArraySet<>();
        private final ImageCache mImageCache;
        private final String mKey;

        /**
         * Returns true if a reload is needed compared to current results in the cache or false if
         * there is not match in the cache.
         */
        private boolean isReloadNeeded() {
            ScaledBitmapInfo bitmapInfo = getFromCache();
            boolean needToReload =
                    bitmapInfo != null && bitmapInfo.needToReload(mMaxWidth, mMaxHeight);
            if (DEBUG) {
                if (needToReload) {
                    Log.d(
                            TAG,
                            "Bitmap needs to be reloaded. {"
                                    + "originalWidth="
                                    + bitmapInfo.bitmap.getWidth()
                                    + ", originalHeight="
                                    + bitmapInfo.bitmap.getHeight()
                                    + ", reqWidth="
                                    + mMaxWidth
                                    + ", reqHeight="
                                    + mMaxHeight
                                    + "}");
                }
            }
            return needToReload;
        }

        /** Checks if a reload would be needed if the results of other was available. */
        private boolean isReloadNeeded(LoadBitmapTask other) {
            return (other.mMaxHeight != Integer.MAX_VALUE && mMaxHeight >= other.mMaxHeight * 2)
                    || (other.mMaxWidth != Integer.MAX_VALUE && mMaxWidth >= other.mMaxWidth * 2);
        }

        @Nullable
        public final ScaledBitmapInfo getFromCache() {
            return mImageCache.get(mKey);
        }

        public LoadBitmapTask(
                Context context, ImageCache imageCache, String key, int maxHeight, int maxWidth) {
            if (maxWidth == 0 || maxHeight == 0) {
                throw new IllegalArgumentException(
                        "Image size should not be 0. {width="
                                + maxWidth
                                + ", height="
                                + maxHeight
                                + "}");
            }
            mAppContext = context.getApplicationContext();
            mKey = key;
            mImageCache = imageCache;
            mMaxHeight = maxHeight;
            mMaxWidth = maxWidth;
        }

        /** Loads the bitmap returning a possibly scaled down version. */
        @Nullable
        @WorkerThread
        public abstract ScaledBitmapInfo doGetBitmapInBackground();

        @Override
        @Nullable
        public final ScaledBitmapInfo doInBackground(Void... params) {
            ScaledBitmapInfo bitmapInfo = getFromCache();
            if (bitmapInfo != null && !isReloadNeeded()) {
                return bitmapInfo;
            }
            bitmapInfo = doGetBitmapInBackground();
            if (bitmapInfo != null) {
                mImageCache.putIfNeeded(bitmapInfo);
            }
            return bitmapInfo;
        }

        @Override
        public final void onPostExecute(ScaledBitmapInfo scaledBitmapInfo) {
            if (DEBUG) Log.d(ImageLoader.TAG, "Bitmap is loaded " + mKey);

            for (ImageLoader.ImageLoaderCallback<T> callback : mCallbacks) {
                callback.onBitmapLoaded(scaledBitmapInfo == null ? null : scaledBitmapInfo.bitmap);
            }
            ImageLoader.sPendingListMap.remove(mKey);
        }

        public final String getKey() {
            return mKey;
        }

        @Override
        public String toString() {
            return this.getClass().getSimpleName()
                    + "("
                    + mKey
                    + " "
                    + mMaxWidth
                    + "x"
                    + mMaxHeight
                    + ")";
        }
    }

    private static final class LoadBitmapFromUriTask<T> extends LoadBitmapTask<T> {
        private LoadBitmapFromUriTask(
                Context context,
                ImageCache imageCache,
                String uriString,
                int maxWidth,
                int maxHeight) {
            super(context, imageCache, uriString, maxHeight, maxWidth);
        }

        @Override
        @Nullable
        public final ScaledBitmapInfo doGetBitmapInBackground() {
            return BitmapUtils.decodeSampledBitmapFromUriString(
                    mAppContext, getKey(), mMaxWidth, mMaxHeight);
        }
    }

    /** Loads and caches the logo for a given {@link TvInputInfo} */
    public static final class LoadTvInputLogoTask<T> extends LoadBitmapTask<T> {
        private final TvInputInfo mInfo;

        public LoadTvInputLogoTask(Context context, ImageCache cache, TvInputInfo info) {
            super(
                    context,
                    cache,
                    getTvInputLogoKey(info.getId()),
                    context.getResources()
                            .getDimensionPixelSize(R.dimen.channel_banner_input_logo_size),
                    context.getResources()
                            .getDimensionPixelSize(R.dimen.channel_banner_input_logo_size));
            mInfo = info;
        }

        @Nullable
        @Override
        public ScaledBitmapInfo doGetBitmapInBackground() {
            Drawable drawable = mInfo.loadIcon(mAppContext);
            Bitmap bm =
                    drawable instanceof BitmapDrawable
                            ? ((BitmapDrawable) drawable).getBitmap()
                            : BitmapUtils.drawableToBitmap(drawable);
            return bm == null
                    ? null
                    : BitmapUtils.createScaledBitmapInfo(getKey(), bm, mMaxWidth, mMaxHeight);
        }

        /** Returns key of TV input logo. */
        public static String getTvInputLogoKey(String inputId) {
            return inputId + "-logo";
        }
    }

    /**
     * Calculates Aspect Ratio of Poster Art from Uri.
     *
     * <p><b>Note</b> the function will check the cache before loading the bitmap
     *
     * @return the Aspect Ratio of the Poster Art.
     */
    public static int getAspectRatioFromPosterArtUri(Context context, String uriString) {
        // Check the cache before loading the bitmap.
        ImageCache imageCache = ImageCache.getInstance();
        ScaledBitmapInfo bitmapInfo = imageCache.get(uriString);
        int bitmapWidth;
        int bitmapHeight;
        float bitmapAspectRatio;
        int aspectRatio;
        if (bitmapInfo == null) {
            bitmapInfo =
                    BitmapUtils.decodeSampledBitmapFromUriString(
                            context, uriString, Integer.MAX_VALUE, Integer.MAX_VALUE);
        }
        bitmapWidth = bitmapInfo.bitmap.getWidth();
        bitmapHeight = bitmapInfo.bitmap.getHeight();
        bitmapAspectRatio = (float) bitmapWidth / bitmapHeight;
        /* Assign nearest aspect ratio from the defined values in Preview Programs */
        if (bitmapAspectRatio > 0 && bitmapAspectRatio <= 0.6803) {
            aspectRatio = PreviewPrograms.ASPECT_RATIO_2_3;
        } else if (bitmapAspectRatio > 0.6803 && bitmapAspectRatio <= 0.8469) {
            aspectRatio = PreviewPrograms.ASPECT_RATIO_MOVIE_POSTER;
        } else if (bitmapAspectRatio > 0.8469 && bitmapAspectRatio <= 1.1667) {
            aspectRatio = PreviewPrograms.ASPECT_RATIO_1_1;
        } else if (bitmapAspectRatio > 1.1667 && bitmapAspectRatio <= 1.4167) {
            aspectRatio = PreviewPrograms.ASPECT_RATIO_4_3;
        } else if (bitmapAspectRatio > 1.4167 && bitmapAspectRatio <= 1.6389) {
            aspectRatio = PreviewPrograms.ASPECT_RATIO_3_2;
        } else {
            aspectRatio = PreviewPrograms.ASPECT_RATIO_16_9;
        }
        return aspectRatio;
    }

    private static synchronized Handler getMainHandler() {
        if (sMainHandler == null) {
            sMainHandler = new Handler(Looper.getMainLooper());
        }
        return sMainHandler;
    }

    private ImageLoader() {}
}
