/*
 * Copyright (C) 2009 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 android.media;

import static android.media.MediaMetadataRetriever.METADATA_KEY_DURATION;
import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT;
import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH;
import static android.media.MediaMetadataRetriever.OPTION_CLOSEST_SYNC;
import static android.os.Environment.MEDIA_UNKNOWN;

import android.annotation.NonNull;
import android.annotation.Nullable;
import android.compat.annotation.UnsupportedAppUsage;
import android.content.ContentResolver;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.ImageDecoder;
import android.graphics.ImageDecoder.ImageInfo;
import android.graphics.ImageDecoder.Source;
import android.graphics.Matrix;
import android.graphics.Rect;
import android.media.MediaMetadataRetriever.BitmapParams;
import android.net.Uri;
import android.os.Build;
import android.os.CancellationSignal;
import android.os.Environment;
import android.os.ParcelFileDescriptor;
import android.provider.MediaStore;
import android.util.Log;
import android.util.Size;

import com.android.internal.util.ArrayUtils;

import libcore.io.IoUtils;

import java.io.File;
import java.io.IOException;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Objects;
import java.util.function.ToIntFunction;

/**
 * Utilities for generating visual thumbnails from files.
 */
public class ThumbnailUtils {
    private static final String TAG = "ThumbnailUtils";

    /** @hide */
    @Deprecated
    @UnsupportedAppUsage
    public static final int TARGET_SIZE_MICRO_THUMBNAIL = 96;

    /* Options used internally. */
    private static final int OPTIONS_NONE = 0x0;
    private static final int OPTIONS_SCALE_UP = 0x1;

    /**
     * Constant used to indicate we should recycle the input in
     * {@link #extractThumbnail(Bitmap, int, int, int)} unless the output is the input.
     */
    public static final int OPTIONS_RECYCLE_INPUT = 0x2;

    private static Size convertKind(int kind) {
        return MediaStore.Images.Thumbnails.getKindSize(kind);
    }

    private static class Resizer implements ImageDecoder.OnHeaderDecodedListener {
        private final Size size;
        private final CancellationSignal signal;

        public Resizer(Size size, CancellationSignal signal) {
            this.size = size;
            this.signal = signal;
        }

        @Override
        public void onHeaderDecoded(ImageDecoder decoder, ImageInfo info, Source source) {
            // One last-ditch check to see if we've been canceled.
            if (signal != null) signal.throwIfCanceled();

            // We don't know how clients will use the decoded data, so we have
            // to default to the more flexible "software" option.
            decoder.setAllocator(ImageDecoder.ALLOCATOR_SOFTWARE);

            // We requested a rough thumbnail size, but the remote size may have
            // returned something giant, so defensively scale down as needed.
            final int widthSample = info.getSize().getWidth() / size.getWidth();
            final int heightSample = info.getSize().getHeight() / size.getHeight();
            final int sample = Math.max(widthSample, heightSample);
            if (sample > 1) {
                decoder.setTargetSampleSize(sample);
            }
        }
    }

    /**
     * Create a thumbnail for given audio file.
     *
     * @param filePath The audio file.
     * @param kind The desired thumbnail kind, such as
     *            {@link android.provider.MediaStore.Images.Thumbnails#MINI_KIND}.
     * @deprecated Callers should migrate to using
     *             {@link #createAudioThumbnail(File, Size, CancellationSignal)},
     *             as it offers more control over resizing and cancellation.
     */
    @Deprecated
    public static @Nullable Bitmap createAudioThumbnail(@NonNull String filePath, int kind) {
        try {
            return createAudioThumbnail(new File(filePath), convertKind(kind), null);
        } catch (IOException e) {
            Log.w(TAG, e);
            return null;
        }
    }

    /**
     * Create a thumbnail for given audio file.
     * <p>
     * This method should only be used for files that you have direct access to;
     * if you'd like to work with media hosted outside your app, consider using
     * {@link ContentResolver#loadThumbnail(Uri, Size, CancellationSignal)}
     * which enables remote providers to efficiently cache and invalidate
     * thumbnails.
     *
     * @param file The audio file.
     * @param size The desired thumbnail size.
     * @throws IOException If any trouble was encountered while generating or
     *             loading the thumbnail, or if
     *             {@link CancellationSignal#cancel()} was invoked.
     */
    public static @NonNull Bitmap createAudioThumbnail(@NonNull File file, @NonNull Size size,
            @Nullable CancellationSignal signal) throws IOException {
        // Checkpoint before going deeper
        if (signal != null) signal.throwIfCanceled();

        final Resizer resizer = new Resizer(size, signal);
        try (MediaMetadataRetriever retriever = new MediaMetadataRetriever()) {
            retriever.setDataSource(file.getAbsolutePath());
            final byte[] raw = retriever.getEmbeddedPicture();
            if (raw != null) {
                return ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw), resizer);
            }
        } catch (RuntimeException e) {
            throw new IOException("Failed to create thumbnail", e);
        }

        // Only poke around for files on external storage
        if (MEDIA_UNKNOWN.equals(Environment.getExternalStorageState(file))) {
            throw new IOException("No embedded album art found");
        }

        // Ignore "Downloads" or top-level directories
        final File parent = file.getParentFile();
        final File grandParent = parent != null ? parent.getParentFile() : null;
        if (parent != null
                && parent.getName().equals(Environment.DIRECTORY_DOWNLOADS)) {
            throw new IOException("No thumbnails in Downloads directories");
        }
        if (grandParent != null
                && MEDIA_UNKNOWN.equals(Environment.getExternalStorageState(grandParent))) {
            throw new IOException("No thumbnails in top-level directories");
        }

        // If no embedded image found, look around for best standalone file
        final File[] found = ArrayUtils
                .defeatNullable(file.getParentFile().listFiles((dir, name) -> {
                    final String lower = name.toLowerCase();
                    return (lower.endsWith(".jpg") || lower.endsWith(".png"));
                }));

        final ToIntFunction<File> score = (f) -> {
            final String lower = f.getName().toLowerCase();
            if (lower.equals("albumart.jpg")) return 4;
            if (lower.startsWith("albumart") && lower.endsWith(".jpg")) return 3;
            if (lower.contains("albumart") && lower.endsWith(".jpg")) return 2;
            if (lower.endsWith(".jpg")) return 1;
            return 0;
        };
        final Comparator<File> bestScore = (a, b) -> {
            return score.applyAsInt(a) - score.applyAsInt(b);
        };

        final File bestFile = Arrays.asList(found).stream().max(bestScore).orElse(null);
        if (bestFile == null) {
            throw new IOException("No album art found");
        }

        // Checkpoint before going deeper
        if (signal != null) signal.throwIfCanceled();

        return ImageDecoder.decodeBitmap(ImageDecoder.createSource(bestFile), resizer);
    }

    /**
     * Create a thumbnail for given image file.
     *
     * @param filePath The image file.
     * @param kind The desired thumbnail kind, such as
     *            {@link android.provider.MediaStore.Images.Thumbnails#MINI_KIND}.
     * @deprecated Callers should migrate to using
     *             {@link #createImageThumbnail(File, Size, CancellationSignal)},
     *             as it offers more control over resizing and cancellation.
     */
    @Deprecated
    public static @Nullable Bitmap createImageThumbnail(@NonNull String filePath, int kind) {
        try {
            return createImageThumbnail(new File(filePath), convertKind(kind), null);
        } catch (IOException e) {
            Log.w(TAG, e);
            return null;
        }
    }

    /**
     * Create a thumbnail for given image file.
     * <p>
     * This method should only be used for files that you have direct access to;
     * if you'd like to work with media hosted outside your app, consider using
     * {@link ContentResolver#loadThumbnail(Uri, Size, CancellationSignal)}
     * which enables remote providers to efficiently cache and invalidate
     * thumbnails.
     *
     * @param file The audio file.
     * @param size The desired thumbnail size.
     * @throws IOException If any trouble was encountered while generating or
     *             loading the thumbnail, or if
     *             {@link CancellationSignal#cancel()} was invoked.
     */
    public static @NonNull Bitmap createImageThumbnail(@NonNull File file, @NonNull Size size,
            @Nullable CancellationSignal signal) throws IOException {
        // Checkpoint before going deeper
        if (signal != null) signal.throwIfCanceled();

        final Resizer resizer = new Resizer(size, signal);
        final String mimeType = MediaFile.getMimeTypeForFile(file.getName());
        Bitmap bitmap = null;
        ExifInterface exif = null;
        int orientation = 0;

        // get orientation
        if (MediaFile.isExifMimeType(mimeType)) {
            exif = new ExifInterface(file);
            switch (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0)) {
                case ExifInterface.ORIENTATION_ROTATE_90:
                    orientation = 90;
                    break;
                case ExifInterface.ORIENTATION_ROTATE_180:
                    orientation = 180;
                    break;
                case ExifInterface.ORIENTATION_ROTATE_270:
                    orientation = 270;
                    break;
            }
        }

        if (mimeType.equals("image/heif")
                || mimeType.equals("image/heif-sequence")
                || mimeType.equals("image/heic")
                || mimeType.equals("image/heic-sequence")
                || mimeType.equals("image/avif")) {
            try (MediaMetadataRetriever retriever = new MediaMetadataRetriever()) {
                retriever.setDataSource(file.getAbsolutePath());
                bitmap = retriever.getThumbnailImageAtIndex(-1,
                        new MediaMetadataRetriever.BitmapParams(), size.getWidth(),
                        size.getWidth() * size.getHeight());
            } catch (RuntimeException e) {
                throw new IOException("Failed to create thumbnail", e);
            }
        }

        if (bitmap == null && exif != null) {
            final byte[] raw = exif.getThumbnailBytes();
            if (raw != null) {
                try {
                    bitmap = ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw), resizer);
                } catch (ImageDecoder.DecodeException e) {
                    Log.w(TAG, e);
                }
            }
        }

        // Checkpoint before going deeper
        if (signal != null) signal.throwIfCanceled();

        if (bitmap == null) {
            bitmap = ImageDecoder.decodeBitmap(ImageDecoder.createSource(file), resizer);
            // Use ImageDecoder to do full file decoding, we don't need to handle the orientation
            return bitmap;
        }

        // Transform the bitmap if the orientation of the image is not 0.
        if (orientation != 0 && bitmap != null) {
            final int width = bitmap.getWidth();
            final int height = bitmap.getHeight();

            final Matrix m = new Matrix();
            m.setRotate(orientation, width / 2, height / 2);
            bitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, m, false);
        }

        return bitmap;
    }

    /**
     * Create a thumbnail for given video file.
     *
     * @param filePath The video file.
     * @param kind The desired thumbnail kind, such as
     *            {@link android.provider.MediaStore.Images.Thumbnails#MINI_KIND}.
     * @deprecated Callers should migrate to using
     *             {@link #createVideoThumbnail(File, Size, CancellationSignal)},
     *             as it offers more control over resizing and cancellation.
     */
    @Deprecated
    public static @Nullable Bitmap createVideoThumbnail(@NonNull String filePath, int kind) {
        try {
            return createVideoThumbnail(new File(filePath), convertKind(kind), null);
        } catch (IOException e) {
            Log.w(TAG, e);
            return null;
        }
    }

    /**
     * Create a thumbnail for given video file.
     * <p>
     * This method should only be used for files that you have direct access to;
     * if you'd like to work with media hosted outside your app, consider using
     * {@link ContentResolver#loadThumbnail(Uri, Size, CancellationSignal)}
     * which enables remote providers to efficiently cache and invalidate
     * thumbnails.
     *
     * @param file The video file.
     * @param size The desired thumbnail size.
     * @throws IOException If any trouble was encountered while generating or
     *             loading the thumbnail, or if
     *             {@link CancellationSignal#cancel()} was invoked.
     */
    public static @NonNull Bitmap createVideoThumbnail(@NonNull File file, @NonNull Size size,
            @Nullable CancellationSignal signal) throws IOException {
        // Checkpoint before going deeper
        if (signal != null) signal.throwIfCanceled();

        final Resizer resizer = new Resizer(size, signal);
        try (MediaMetadataRetriever mmr = new MediaMetadataRetriever()) {
            mmr.setDataSource(file.getAbsolutePath());

            // Try to retrieve thumbnail from metadata
            final byte[] raw = mmr.getEmbeddedPicture();
            if (raw != null) {
                return ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw), resizer);
            }

            final BitmapParams params = new BitmapParams();
            params.setPreferredConfig(Bitmap.Config.ARGB_8888);

            final int width = Integer.parseInt(mmr.extractMetadata(METADATA_KEY_VIDEO_WIDTH));
            final int height = Integer.parseInt(mmr.extractMetadata(METADATA_KEY_VIDEO_HEIGHT));
            // Fall back to middle of video
            // Note: METADATA_KEY_DURATION unit is in ms, not us.
            final long thumbnailTimeUs =
                    Long.parseLong(mmr.extractMetadata(METADATA_KEY_DURATION)) * 1000 / 2;

            // If we're okay with something larger than native format, just
            // return a frame without up-scaling it
            if (size.getWidth() > width && size.getHeight() > height) {
                return Objects.requireNonNull(
                        mmr.getFrameAtTime(thumbnailTimeUs, OPTION_CLOSEST_SYNC, params));
            } else {
                return Objects.requireNonNull(
                        mmr.getScaledFrameAtTime(thumbnailTimeUs, OPTION_CLOSEST_SYNC,
                        size.getWidth(), size.getHeight(), params));
            }
        } catch (RuntimeException e) {
            throw new IOException("Failed to create thumbnail", e);
        }
    }

    /**
     * Creates a centered bitmap of the desired size.
     *
     * @param source original bitmap source
     * @param width targeted width
     * @param height targeted height
     */
    public static Bitmap extractThumbnail(
            Bitmap source, int width, int height) {
        return extractThumbnail(source, width, height, OPTIONS_NONE);
    }

    /**
     * Creates a centered bitmap of the desired size.
     *
     * @param source original bitmap source
     * @param width targeted width
     * @param height targeted height
     * @param options options used during thumbnail extraction
     */
    public static Bitmap extractThumbnail(
            Bitmap source, int width, int height, int options) {
        if (source == null) {
            return null;
        }

        float scale;
        if (source.getWidth() < source.getHeight()) {
            scale = width / (float) source.getWidth();
        } else {
            scale = height / (float) source.getHeight();
        }
        Matrix matrix = new Matrix();
        matrix.setScale(scale, scale);
        Bitmap thumbnail = transform(matrix, source, width, height,
                OPTIONS_SCALE_UP | options);
        return thumbnail;
    }

    @Deprecated
    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023)
    private static int computeSampleSize(BitmapFactory.Options options,
            int minSideLength, int maxNumOfPixels) {
        return 1;
    }

    @Deprecated
    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023)
    private static int computeInitialSampleSize(BitmapFactory.Options options,
            int minSideLength, int maxNumOfPixels) {
        return 1;
    }

    @Deprecated
    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023)
    private static void closeSilently(ParcelFileDescriptor c) {
        IoUtils.closeQuietly(c);
    }

    @Deprecated
    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023)
    private static ParcelFileDescriptor makeInputStream(
            Uri uri, ContentResolver cr) {
        try {
            return cr.openFileDescriptor(uri, "r");
        } catch (IOException ex) {
            return null;
        }
    }

    /**
     * Transform source Bitmap to targeted width and height.
     */
    @Deprecated
    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023)
    private static Bitmap transform(Matrix scaler,
            Bitmap source,
            int targetWidth,
            int targetHeight,
            int options) {
        boolean scaleUp = (options & OPTIONS_SCALE_UP) != 0;
        boolean recycle = (options & OPTIONS_RECYCLE_INPUT) != 0;

        int deltaX = source.getWidth() - targetWidth;
        int deltaY = source.getHeight() - targetHeight;
        if (!scaleUp && (deltaX < 0 || deltaY < 0)) {
            /*
            * In this case the bitmap is smaller, at least in one dimension,
            * than the target.  Transform it by placing as much of the image
            * as possible into the target and leaving the top/bottom or
            * left/right (or both) black.
            */
            Bitmap b2 = Bitmap.createBitmap(targetWidth, targetHeight,
            Bitmap.Config.ARGB_8888);
            Canvas c = new Canvas(b2);

            int deltaXHalf = Math.max(0, deltaX / 2);
            int deltaYHalf = Math.max(0, deltaY / 2);
            Rect src = new Rect(
            deltaXHalf,
            deltaYHalf,
            deltaXHalf + Math.min(targetWidth, source.getWidth()),
            deltaYHalf + Math.min(targetHeight, source.getHeight()));
            int dstX = (targetWidth  - src.width())  / 2;
            int dstY = (targetHeight - src.height()) / 2;
            Rect dst = new Rect(
                    dstX,
                    dstY,
                    targetWidth - dstX,
                    targetHeight - dstY);
            c.drawBitmap(source, src, dst, null);
            if (recycle) {
                source.recycle();
            }
            c.setBitmap(null);
            return b2;
        }
        float bitmapWidthF = source.getWidth();
        float bitmapHeightF = source.getHeight();

        float bitmapAspect = bitmapWidthF / bitmapHeightF;
        float viewAspect   = (float) targetWidth / targetHeight;

        if (bitmapAspect > viewAspect) {
            float scale = targetHeight / bitmapHeightF;
            if (scale < .9F || scale > 1F) {
                scaler.setScale(scale, scale);
            } else {
                scaler = null;
            }
        } else {
            float scale = targetWidth / bitmapWidthF;
            if (scale < .9F || scale > 1F) {
                scaler.setScale(scale, scale);
            } else {
                scaler = null;
            }
        }

        Bitmap b1;
        if (scaler != null) {
            // this is used for minithumb and crop, so we want to filter here.
            b1 = Bitmap.createBitmap(source, 0, 0,
            source.getWidth(), source.getHeight(), scaler, true);
        } else {
            b1 = source;
        }

        if (recycle && b1 != source) {
            source.recycle();
        }

        int dx1 = Math.max(0, b1.getWidth() - targetWidth);
        int dy1 = Math.max(0, b1.getHeight() - targetHeight);

        Bitmap b2 = Bitmap.createBitmap(
                b1,
                dx1 / 2,
                dy1 / 2,
                targetWidth,
                targetHeight);

        if (b2 != b1) {
            if (recycle || b1 != source) {
                b1.recycle();
            }
        }

        return b2;
    }

    @Deprecated
    private static class SizedThumbnailBitmap {
        public byte[] mThumbnailData;
        public Bitmap mBitmap;
        public int mThumbnailWidth;
        public int mThumbnailHeight;
    }

    @Deprecated
    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023)
    private static void createThumbnailFromEXIF(String filePath, int targetSize,
            int maxPixels, SizedThumbnailBitmap sizedThumbBitmap) {
    }
}
