/*
 * 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.messaging.datamodel.media;

import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.os.SystemClock;
import androidx.annotation.NonNull;
import android.util.SparseArray;

import com.android.messaging.Factory;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;

import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedList;

/**
 * A media cache that holds image resources, which doubles as a bitmap pool that allows the
 * consumer to optionally decode image resources using unused bitmaps stored in the cache.
 */
public class PoolableImageCache extends MediaCache<ImageResource> {
    private static final int MIN_TIME_IN_POOL = 5000;

    /** Encapsulates bitmap pool representation of the image cache */
    private final ReusableImageResourcePool mReusablePoolAccessor = new ReusableImageResourcePool();

    public PoolableImageCache(final int id, final String name) {
        this(DEFAULT_MEDIA_RESOURCE_CACHE_SIZE_IN_KILOBYTES, id, name);
    }

    public PoolableImageCache(final int maxSize, final int id, final String name) {
        super(maxSize, id, name);
    }

    /**
     * Creates a new BitmapFactory.Options for using the self-contained bitmap pool.
     */
    public static BitmapFactory.Options getBitmapOptionsForPool(final boolean scaled,
            final int inputDensity, final int targetDensity) {
        final BitmapFactory.Options options = new BitmapFactory.Options();
        options.inScaled = scaled;
        options.inDensity = inputDensity;
        options.inTargetDensity = targetDensity;
        options.inSampleSize = 1;
        options.inJustDecodeBounds = false;
        options.inMutable = true;
        return options;
    }

    @Override
    public synchronized ImageResource addResourceToCache(final String key,
            final ImageResource imageResource) {
        mReusablePoolAccessor.onResourceEnterCache(imageResource);
        return super.addResourceToCache(key, imageResource);
    }

    @Override
    protected synchronized void entryRemoved(final boolean evicted, final String key,
            final ImageResource oldValue, final ImageResource newValue) {
        mReusablePoolAccessor.onResourceLeaveCache(oldValue);
        super.entryRemoved(evicted, key, oldValue, newValue);
    }

    /**
     * Returns a representation of the image cache as a reusable bitmap pool.
     */
    public ReusableImageResourcePool asReusableBitmapPool() {
        return mReusablePoolAccessor;
    }

    /**
     * A bitmap pool representation built on top of the image cache. It treats the image resources
     * stored in the image cache as a self-contained bitmap pool and is able to create or
     * reclaim bitmap resource as needed.
     */
    public class ReusableImageResourcePool {
        private static final int MAX_SUPPORTED_IMAGE_DIMENSION = 0xFFFF;
        private static final int INVALID_POOL_KEY = 0;

        /**
         * Number of reuse failures to skip before reporting.
         * For debugging purposes, change to a lower number for more frequent reporting.
         */
        private static final int FAILED_REPORTING_FREQUENCY = 100;

        /**
         * Count of reuse failures which have occurred.
         */
        private volatile int mFailedBitmapReuseCount = 0;

        /**
         * Count of reuse successes which have occurred.
         */
        private volatile int mSucceededBitmapReuseCount = 0;

        /**
         * A sparse array from bitmap size to a list of image cache entries that match the
         * given size. This map is used to quickly retrieve a usable bitmap to be reused by an
         * incoming ImageRequest. We need to ensure that this sparse array always contains only
         * elements currently in the image cache with no other consumer.
         */
        private final SparseArray<LinkedList<ImageResource>> mImageListSparseArray;

        public ReusableImageResourcePool() {
            mImageListSparseArray = new SparseArray<LinkedList<ImageResource>>();
        }

        /**
         * Load an input stream into a bitmap. Uses a bitmap from the pool if possible to reduce
         * memory turnover.
         * @param inputStream InputStream load. Cannot be null.
         * @param optionsTmp Should be the same options returned from getBitmapOptionsForPool().
         * Cannot be null.
         * @param width The width of the bitmap.
         * @param height The height of the bitmap.
         * @return The decoded Bitmap with the resource drawn in it.
         * @throws IOException
         */
        public Bitmap decodeSampledBitmapFromInputStream(@NonNull final InputStream inputStream,
                @NonNull final BitmapFactory.Options optionsTmp,
                final int width, final int height) throws IOException {
            if (width <= 0 || height <= 0) {
                // This is an invalid / corrupted image of zero size.
                LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "PoolableImageCache: Decoding bitmap with " +
                        "invalid size");
                throw new IOException("Invalid size / corrupted image");
            }
            Assert.notNull(inputStream);
            assignPoolBitmap(optionsTmp, width, height);
            Bitmap b = null;
            try {
                b = BitmapFactory.decodeStream(inputStream, null, optionsTmp);
                mSucceededBitmapReuseCount++;
            } catch (final IllegalArgumentException e) {
                // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap.
                if (optionsTmp.inBitmap != null) {
                    optionsTmp.inBitmap.recycle();
                    optionsTmp.inBitmap = null;
                    b = BitmapFactory.decodeStream(inputStream, null, optionsTmp);
                    onFailedToReuse();
                }
            } catch (final OutOfMemoryError e) {
                LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "Oom decoding inputStream");
                Factory.get().reclaimMemory();
            }
            return b;
        }

        /**
         * Turn encoded bytes into a bitmap. Uses a bitmap from the pool if possible to reduce
         * memory turnover.
         * @param bytes Encoded bytes to draw on the bitmap. Cannot be null.
         * @param optionsTmp The bitmap will set here and the input should be generated from
         * getBitmapOptionsForPool(). Cannot be null.
         * @param width The width of the bitmap.
         * @param height The height of the bitmap.
         * @return A Bitmap with the encoded bytes drawn in it.
         * @throws IOException
         */
        public Bitmap decodeByteArray(@NonNull final byte[] bytes,
                @NonNull final BitmapFactory.Options optionsTmp, final int width,
                final int height) throws OutOfMemoryError, IOException {
            if (width <= 0 || height <= 0) {
                // This is an invalid / corrupted image of zero size.
                LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "PoolableImageCache: Decoding bitmap with " +
                        "invalid size");
                throw new IOException("Invalid size / corrupted image");
            }
            Assert.notNull(bytes);
            Assert.notNull(optionsTmp);
            assignPoolBitmap(optionsTmp, width, height);
            Bitmap b = null;
            try {
                b = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, optionsTmp);
                mSucceededBitmapReuseCount++;
            } catch (final IllegalArgumentException e) {
                // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap.
                // (i.e. without the bitmap from the pool)
                if (optionsTmp.inBitmap != null) {
                    optionsTmp.inBitmap.recycle();
                    optionsTmp.inBitmap = null;
                    b = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, optionsTmp);
                    onFailedToReuse();
                }
            } catch (final OutOfMemoryError e) {
                LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "Oom decoding inputStream");
                Factory.get().reclaimMemory();
            }
            return b;
        }

        /**
         * Called when a new image resource is added to the cache. We add the resource to the
         * pool so it's properly keyed into the pool structure.
         */
        void onResourceEnterCache(final ImageResource imageResource) {
            if (getPoolKey(imageResource) != INVALID_POOL_KEY) {
                addResourceToPool(imageResource);
            }
        }

        /**
         * Called when an image resource is evicted from the cache. Bitmap pool's entries are
         * strictly tied to their presence in the image cache. Once an image is evicted from the
         * cache, it should be removed from the pool.
         */
        void onResourceLeaveCache(final ImageResource imageResource) {
            if (getPoolKey(imageResource) != INVALID_POOL_KEY) {
                removeResourceFromPool(imageResource);
            }
        }

        private void addResourceToPool(final ImageResource imageResource) {
            synchronized (PoolableImageCache.this) {
                final int poolKey = getPoolKey(imageResource);
                Assert.isTrue(poolKey != INVALID_POOL_KEY);
                LinkedList<ImageResource> imageList = mImageListSparseArray.get(poolKey);
                if (imageList == null) {
                    imageList = new LinkedList<ImageResource>();
                    mImageListSparseArray.put(poolKey, imageList);
                }
                imageList.addLast(imageResource);
            }
        }

        private void removeResourceFromPool(final ImageResource imageResource) {
            synchronized (PoolableImageCache.this) {
                final int poolKey = getPoolKey(imageResource);
                Assert.isTrue(poolKey != INVALID_POOL_KEY);
                final LinkedList<ImageResource> imageList = mImageListSparseArray.get(poolKey);
                if (imageList != null) {
                    imageList.remove(imageResource);
                }
            }
        }

        /**
         * Try to get a reusable bitmap from the pool with the given width and height. As a
         * result of this call, the caller will assume ownership of the returned bitmap.
         */
        private Bitmap getReusableBitmapFromPool(final int width, final int height) {
            synchronized (PoolableImageCache.this) {
                final int poolKey = getPoolKey(width, height);
                if (poolKey != INVALID_POOL_KEY) {
                    final LinkedList<ImageResource> images = mImageListSparseArray.get(poolKey);
                    if (images != null && images.size() > 0) {
                        // Try to reuse the first available bitmap from the pool list. We start from
                        // the least recently added cache entry of the given size.
                        ImageResource imageToUse = null;
                        for (int i = 0; i < images.size(); i++) {
                            final ImageResource image = images.get(i);
                            if (image.getRefCount() == 1) {
                                image.acquireLock();
                                if (image.getRefCount() == 1) {
                                    // The image is only used by the cache, so it's reusable.
                                    imageToUse = images.remove(i);
                                    break;
                                } else {
                                    // Logically, this shouldn't happen, because as soon as the
                                    // cache is the only user of this resource, it will not be
                                    // used by anyone else until the next cache access, but we
                                    // currently hold on to the cache lock. But technically
                                    // future changes may violate this assumption, so warn about
                                    // this.
                                    LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "Image refCount changed " +
                                            "from 1 in getReusableBitmapFromPool()");
                                    image.releaseLock();
                                }
                            }
                        }

                        if (imageToUse == null) {
                            return null;
                        }

                        try {
                            imageToUse.assertLockHeldByCurrentThread();

                            // Only reuse the bitmap if the last time we use was greater than 5s.
                            // This allows the cache a chance to reuse instead of always taking the
                            // oldest.
                            final long timeSinceLastRef = SystemClock.elapsedRealtime() -
                                    imageToUse.getLastRefAddTimestamp();
                            if (timeSinceLastRef < MIN_TIME_IN_POOL) {
                                if (LogUtil.isLoggable(LogUtil.BUGLE_IMAGE_TAG, LogUtil.VERBOSE)) {
                                    LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "Not reusing reusing " +
                                            "first available bitmap from the pool because it " +
                                            "has not been in the pool long enough. " +
                                            "timeSinceLastRef=" + timeSinceLastRef);
                                }
                                // Put back the image and return no reuseable bitmap.
                                images.addLast(imageToUse);
                                return null;
                            }

                            // Add a temp ref on the image resource so it won't be GC'd after
                            // being removed from the cache.
                            imageToUse.addRef();

                            // Remove the image resource from the image cache.
                            final ImageResource removed = remove(imageToUse.getKey());
                            Assert.isTrue(removed == imageToUse);

                            // Try to reuse the bitmap from the image resource. This will transfer
                            // ownership of the bitmap object to the caller of this method.
                            final Bitmap reusableBitmap = imageToUse.reuseBitmap();

                            imageToUse.release();
                            return reusableBitmap;
                        } finally {
                            // We are either done with the reuse operation, or decided not to use
                            // the image. Either way, release the lock.
                            imageToUse.releaseLock();
                        }
                    }
                }
            }
            return null;
        }

        /**
         * Try to locate and return a reusable bitmap from the pool, or create a new bitmap.
         * @param width desired bitmap width
         * @param height desired bitmap height
         * @return the created or reused mutable bitmap that has its background cleared to
         * {@value Color#TRANSPARENT}
         */
        public Bitmap createOrReuseBitmap(final int width, final int height) {
            return createOrReuseBitmap(width, height, Color.TRANSPARENT);
        }

        /**
         * Try to locate and return a reusable bitmap from the pool, or create a new bitmap.
         * @param width desired bitmap width
         * @param height desired bitmap height
         * @param backgroundColor the background color for the returned bitmap
         * @return the created or reused mutable bitmap with the requested background color
         */
        public Bitmap createOrReuseBitmap(final int width, final int height,
                final int backgroundColor) {
            Bitmap retBitmap = null;
            try {
                final Bitmap poolBitmap = getReusableBitmapFromPool(width, height);
                retBitmap = (poolBitmap != null) ? poolBitmap :
                        Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
                retBitmap.eraseColor(backgroundColor);
            } catch (final OutOfMemoryError e) {
                LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "PoolableImageCache:try to createOrReuseBitmap");
                Factory.get().reclaimMemory();
            }
            return retBitmap;
        }

        private void assignPoolBitmap(final BitmapFactory.Options optionsTmp, final int width,
                final int height) {
            if (optionsTmp.inJustDecodeBounds) {
                return;
            }
            optionsTmp.inBitmap = getReusableBitmapFromPool(width, height);
        }

        /**
         * @return The pool key for the provided image dimensions or 0 if either width or height is
         * greater than the max supported image dimension.
         */
        private int getPoolKey(final int width, final int height) {
            if (width > MAX_SUPPORTED_IMAGE_DIMENSION || height > MAX_SUPPORTED_IMAGE_DIMENSION) {
                return INVALID_POOL_KEY;
            }
            return (width << 16) | height;
        }

        /**
         * @return the pool key for a given image resource.
         */
        private int getPoolKey(final ImageResource imageResource) {
            if (imageResource.supportsBitmapReuse()) {
                final Bitmap bitmap = imageResource.getBitmap();
                if (bitmap != null && bitmap.isMutable()) {
                    final int width = bitmap.getWidth();
                    final int height = bitmap.getHeight();
                    if (width > 0 && height > 0) {
                        return getPoolKey(width, height);
                    }
                }
            }
            return INVALID_POOL_KEY;
        }

        /**
         * Called when bitmap reuse fails. Conditionally report the failure with statistics.
         */
        private void onFailedToReuse() {
            mFailedBitmapReuseCount++;
            if (mFailedBitmapReuseCount % FAILED_REPORTING_FREQUENCY == 0) {
                LogUtil.w(LogUtil.BUGLE_IMAGE_TAG,
                        "Pooled bitmap consistently not being reused. Failure count = " +
                                mFailedBitmapReuseCount + ", success count = " +
                                mSucceededBitmapReuseCount);
            }
        }
    }
}
