/*
 * Copyright (C) 2019 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.launcher3.icons;

import static com.android.launcher3.icons.IconProvider.ATLEAST_T;

import android.annotation.TargetApi;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.BlendMode;
import android.graphics.BlendModeColorFilter;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.AdaptiveIconDrawable;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.os.Build;
import android.os.Bundle;
import android.os.SystemClock;
import android.util.Log;
import android.util.TypedValue;

import androidx.annotation.Nullable;

import com.android.launcher3.icons.IconProvider.ThemeData;

import java.util.Calendar;
import java.util.concurrent.TimeUnit;
import java.util.function.IntFunction;

/**
 * Wrapper over {@link AdaptiveIconDrawable} to intercept icon flattening logic for dynamic
 * clock icons
 */
@TargetApi(Build.VERSION_CODES.O)
public class ClockDrawableWrapper extends AdaptiveIconDrawable implements BitmapInfo.Extender {

    private static final String TAG = "ClockDrawableWrapper";

    private static final boolean DISABLE_SECONDS = true;
    private static final int NO_COLOR = -1;

    // Time after which the clock icon should check for an update. The actual invalidate
    // will only happen in case of any change.
    public static final long TICK_MS = DISABLE_SECONDS ? TimeUnit.MINUTES.toMillis(1) : 200L;

    private static final String LAUNCHER_PACKAGE = "com.android.launcher3";
    private static final String ROUND_ICON_METADATA_KEY = LAUNCHER_PACKAGE
            + ".LEVEL_PER_TICK_ICON_ROUND";
    private static final String HOUR_INDEX_METADATA_KEY = LAUNCHER_PACKAGE + ".HOUR_LAYER_INDEX";
    private static final String MINUTE_INDEX_METADATA_KEY = LAUNCHER_PACKAGE
            + ".MINUTE_LAYER_INDEX";
    private static final String SECOND_INDEX_METADATA_KEY = LAUNCHER_PACKAGE
            + ".SECOND_LAYER_INDEX";
    private static final String DEFAULT_HOUR_METADATA_KEY = LAUNCHER_PACKAGE
            + ".DEFAULT_HOUR";
    private static final String DEFAULT_MINUTE_METADATA_KEY = LAUNCHER_PACKAGE
            + ".DEFAULT_MINUTE";
    private static final String DEFAULT_SECOND_METADATA_KEY = LAUNCHER_PACKAGE
            + ".DEFAULT_SECOND";

    /* Number of levels to jump per second for the second hand */
    private static final int LEVELS_PER_SECOND = 10;

    public static final int INVALID_VALUE = -1;

    private final AnimationInfo mAnimationInfo = new AnimationInfo();
    private AnimationInfo mThemeInfo = null;

    private ClockDrawableWrapper(AdaptiveIconDrawable base) {
        super(base.getBackground(), base.getForeground());
    }

    private void applyThemeData(ThemeData themeData) {
        if (!IconProvider.ATLEAST_T || mThemeInfo != null) {
            return;
        }
        try {
            TypedArray ta = themeData.mResources.obtainTypedArray(themeData.mResID);
            int count = ta.length();
            Bundle extras = new Bundle();
            for (int i = 0; i < count; i += 2) {
                TypedValue v = ta.peekValue(i + 1);
                extras.putInt(ta.getString(i), v.type >= TypedValue.TYPE_FIRST_INT
                        && v.type <= TypedValue.TYPE_LAST_INT
                        ? v.data : v.resourceId);
            }
            ta.recycle();
            ClockDrawableWrapper drawable = ClockDrawableWrapper.forExtras(extras, resId -> {
                Drawable bg = new ColorDrawable(Color.WHITE);
                Drawable fg = themeData.mResources.getDrawable(resId).mutate();
                return new AdaptiveIconDrawable(bg, fg);
            });
            if (drawable != null) {
                mThemeInfo = drawable.mAnimationInfo;
            }
        } catch (Exception e) {
            Log.e(TAG, "Error loading themed clock", e);
        }
    }

    @Override
    public Drawable getMonochrome() {
        if (mThemeInfo == null) {
            return null;
        }
        Drawable d = mThemeInfo.baseDrawableState.newDrawable().mutate();
        if (d instanceof AdaptiveIconDrawable) {
            Drawable mono = ((AdaptiveIconDrawable) d).getForeground();
            mThemeInfo.applyTime(Calendar.getInstance(), (LayerDrawable) mono);
            return mono;
        }
        return null;
    }

    /**
     * Loads and returns the wrapper from the provided package, or returns null
     * if it is unable to load.
     */
    public static ClockDrawableWrapper forPackage(Context context, String pkg, int iconDpi,
            @Nullable ThemeData themeData) {
        try {
            PackageManager pm = context.getPackageManager();
            ApplicationInfo appInfo =  pm.getApplicationInfo(pkg,
                    PackageManager.MATCH_UNINSTALLED_PACKAGES | PackageManager.GET_META_DATA);
            Resources res = pm.getResourcesForApplication(appInfo);
            ClockDrawableWrapper wrapper = forExtras(appInfo.metaData,
                    resId -> res.getDrawableForDensity(resId, iconDpi));
            if (wrapper != null && themeData != null) {
                wrapper.applyThemeData(themeData);
            }
            return wrapper;
        } catch (Exception e) {
            Log.d(TAG, "Unable to load clock drawable info", e);
        }
        return null;
    }

    @TargetApi(Build.VERSION_CODES.TIRAMISU)
    private static ClockDrawableWrapper forExtras(
            Bundle metadata, IntFunction<Drawable> drawableProvider) {
        if (metadata == null) {
            return null;
        }
        int drawableId = metadata.getInt(ROUND_ICON_METADATA_KEY, 0);
        if (drawableId == 0) {
            return null;
        }

        Drawable drawable = drawableProvider.apply(drawableId).mutate();
        if (!(drawable instanceof AdaptiveIconDrawable)) {
            return null;
        }
        AdaptiveIconDrawable aid = (AdaptiveIconDrawable) drawable;

        ClockDrawableWrapper wrapper = new ClockDrawableWrapper(aid);
        AnimationInfo info = wrapper.mAnimationInfo;

        info.baseDrawableState = drawable.getConstantState();
        info.hourLayerIndex = metadata.getInt(HOUR_INDEX_METADATA_KEY, INVALID_VALUE);
        info.minuteLayerIndex = metadata.getInt(MINUTE_INDEX_METADATA_KEY, INVALID_VALUE);
        info.secondLayerIndex = metadata.getInt(SECOND_INDEX_METADATA_KEY, INVALID_VALUE);

        info.defaultHour = metadata.getInt(DEFAULT_HOUR_METADATA_KEY, 0);
        info.defaultMinute = metadata.getInt(DEFAULT_MINUTE_METADATA_KEY, 0);
        info.defaultSecond = metadata.getInt(DEFAULT_SECOND_METADATA_KEY, 0);

        LayerDrawable foreground = (LayerDrawable) wrapper.getForeground();
        int layerCount = foreground.getNumberOfLayers();
        if (info.hourLayerIndex < 0 || info.hourLayerIndex >= layerCount) {
            info.hourLayerIndex = INVALID_VALUE;
        }
        if (info.minuteLayerIndex < 0 || info.minuteLayerIndex >= layerCount) {
            info.minuteLayerIndex = INVALID_VALUE;
        }
        if (info.secondLayerIndex < 0 || info.secondLayerIndex >= layerCount) {
            info.secondLayerIndex = INVALID_VALUE;
        } else if (DISABLE_SECONDS) {
            foreground.setDrawable(info.secondLayerIndex, null);
            info.secondLayerIndex = INVALID_VALUE;
        }

        if (ATLEAST_T && aid.getMonochrome() instanceof LayerDrawable) {
            wrapper.mThemeInfo = info.copyForIcon(new AdaptiveIconDrawable(
                    new ColorDrawable(Color.WHITE), aid.getMonochrome().mutate()));
        }
        info.applyTime(Calendar.getInstance(), foreground);
        return wrapper;
    }

    @Override
    public ClockBitmapInfo getExtendedInfo(Bitmap bitmap, int color,
            BaseIconFactory iconFactory, float normalizationScale) {
        AdaptiveIconDrawable background = new AdaptiveIconDrawable(
                getBackground().getConstantState().newDrawable(), null);
        Bitmap flattenBG = iconFactory.createScaledBitmap(background,
                BaseIconFactory.MODE_HARDWARE_WITH_SHADOW);

        // Only pass theme info if mono-icon is enabled
        AnimationInfo themeInfo = iconFactory.mMonoIconEnabled ? mThemeInfo : null;
        Bitmap themeBG = themeInfo == null ? null : iconFactory.getWhiteShadowLayer();
        return new ClockBitmapInfo(bitmap, color, normalizationScale,
                mAnimationInfo, flattenBG, themeInfo, themeBG);
    }

    @Override
    public void drawForPersistence(Canvas canvas) {
        LayerDrawable foreground = (LayerDrawable) getForeground();
        resetLevel(foreground, mAnimationInfo.hourLayerIndex);
        resetLevel(foreground, mAnimationInfo.minuteLayerIndex);
        resetLevel(foreground, mAnimationInfo.secondLayerIndex);
        draw(canvas);
        mAnimationInfo.applyTime(Calendar.getInstance(), (LayerDrawable) getForeground());
    }

    private void resetLevel(LayerDrawable drawable, int index) {
        if (index != INVALID_VALUE) {
            drawable.getDrawable(index).setLevel(0);
        }
    }

    private static class AnimationInfo {

        public ConstantState baseDrawableState;

        public int hourLayerIndex;
        public int minuteLayerIndex;
        public int secondLayerIndex;
        public int defaultHour;
        public int defaultMinute;
        public int defaultSecond;

        public AnimationInfo copyForIcon(Drawable icon) {
            AnimationInfo result = new AnimationInfo();
            result.baseDrawableState = icon.getConstantState();
            result.defaultHour = defaultHour;
            result.defaultMinute = defaultMinute;
            result.defaultSecond = defaultSecond;
            result.hourLayerIndex = hourLayerIndex;
            result.minuteLayerIndex = minuteLayerIndex;
            result.secondLayerIndex = secondLayerIndex;
            return result;
        }

        boolean applyTime(Calendar time, LayerDrawable foregroundDrawable) {
            time.setTimeInMillis(System.currentTimeMillis());

            // We need to rotate by the difference from the default time if one is specified.
            int convertedHour = (time.get(Calendar.HOUR) + (12 - defaultHour)) % 12;
            int convertedMinute = (time.get(Calendar.MINUTE) + (60 - defaultMinute)) % 60;
            int convertedSecond = (time.get(Calendar.SECOND) + (60 - defaultSecond)) % 60;

            boolean invalidate = false;
            if (hourLayerIndex != INVALID_VALUE) {
                final Drawable hour = foregroundDrawable.getDrawable(hourLayerIndex);
                if (hour.setLevel(convertedHour * 60 + time.get(Calendar.MINUTE))) {
                    invalidate = true;
                }
            }

            if (minuteLayerIndex != INVALID_VALUE) {
                final Drawable minute = foregroundDrawable.getDrawable(minuteLayerIndex);
                if (minute.setLevel(time.get(Calendar.HOUR) * 60 + convertedMinute)) {
                    invalidate = true;
                }
            }

            if (secondLayerIndex != INVALID_VALUE) {
                final Drawable second = foregroundDrawable.getDrawable(secondLayerIndex);
                if (second.setLevel(convertedSecond * LEVELS_PER_SECOND)) {
                    invalidate = true;
                }
            }

            return invalidate;
        }
    }

    static class ClockBitmapInfo extends BitmapInfo {

        public final float boundsOffset;

        public final AnimationInfo animInfo;
        public final Bitmap mFlattenedBackground;

        public final AnimationInfo themeData;
        public final Bitmap themeBackground;

        ClockBitmapInfo(Bitmap icon, int color, float scale,
                AnimationInfo animInfo, Bitmap background,
                AnimationInfo themeInfo, Bitmap themeBackground) {
            super(icon, color);
            this.boundsOffset = Math.max(ShadowGenerator.BLUR_FACTOR, (1 - scale) / 2);
            this.animInfo = animInfo;
            this.mFlattenedBackground = background;
            this.themeData = themeInfo;
            this.themeBackground = themeBackground;
        }

        @Override
        @TargetApi(Build.VERSION_CODES.TIRAMISU)
        public FastBitmapDrawable newIcon(Context context,
                @DrawableCreationFlags  int creationFlags) {
            AnimationInfo info;
            Bitmap bg;
            int themedFgColor;
            ColorFilter bgFilter;
            if ((creationFlags & FLAG_THEMED) != 0 && themeData != null) {
                int[] colors = ThemedIconDrawable.getColors(context);
                Drawable tintedDrawable = themeData.baseDrawableState.newDrawable().mutate();
                themedFgColor = colors[1];
                tintedDrawable.setTint(colors[1]);
                info = themeData.copyForIcon(tintedDrawable);
                bg = themeBackground;
                bgFilter = new BlendModeColorFilter(colors[0], BlendMode.SRC_IN);
            } else {
                info = animInfo;
                themedFgColor = NO_COLOR;
                bg = mFlattenedBackground;
                bgFilter = null;
            }
            if (info == null) {
                return super.newIcon(context, creationFlags);
            }
            ClockIconDrawable.ClockConstantState cs = new ClockIconDrawable.ClockConstantState(
                    icon, color, themedFgColor, boundsOffset, info, bg, bgFilter);
            FastBitmapDrawable d = cs.newDrawable();
            applyFlags(context, d, creationFlags);
            return d;
        }

        @Override
        public boolean canPersist() {
            return false;
        }

        @Override
        public BitmapInfo clone() {
            return copyInternalsTo(new ClockBitmapInfo(icon, color, 1 - 2 * boundsOffset, animInfo,
                    mFlattenedBackground, themeData, themeBackground));
        }
    }

    private static class ClockIconDrawable extends FastBitmapDrawable implements Runnable {

        private final Calendar mTime = Calendar.getInstance();

        private final float mBoundsOffset;
        private final AnimationInfo mAnimInfo;

        private final Bitmap mBG;
        private final Paint mBgPaint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG);
        private final ColorFilter mBgFilter;
        private final int mThemedFgColor;

        private final AdaptiveIconDrawable mFullDrawable;
        private final LayerDrawable mFG;
        private final float mCanvasScale;

        ClockIconDrawable(ClockConstantState cs) {
            super(cs.mBitmap, cs.mIconColor);
            mBoundsOffset = cs.mBoundsOffset;
            mAnimInfo = cs.mAnimInfo;

            mBG = cs.mBG;
            mBgFilter = cs.mBgFilter;
            mBgPaint.setColorFilter(cs.mBgFilter);
            mThemedFgColor = cs.mThemedFgColor;

            mFullDrawable =
                    (AdaptiveIconDrawable) mAnimInfo.baseDrawableState.newDrawable().mutate();
            mFG = (LayerDrawable) mFullDrawable.getForeground();

            // Time needs to be applied here since drawInternal is NOT guaranteed to be called
            // before this foreground drawable is shown on the screen.
            mAnimInfo.applyTime(mTime, mFG);
            mCanvasScale = 1 - 2 * mBoundsOffset;
        }

        @Override
        public void setAlpha(int alpha) {
            super.setAlpha(alpha);
            mBgPaint.setAlpha(alpha);
            mFG.setAlpha(alpha);
        }

        @Override
        protected void onBoundsChange(Rect bounds) {
            super.onBoundsChange(bounds);

            // b/211896569 AdaptiveIcon does not work properly when bounds
            // are not aligned to top/left corner
            mFullDrawable.setBounds(0, 0, bounds.width(), bounds.height());
        }

        @Override
        public void drawInternal(Canvas canvas, Rect bounds) {
            if (mAnimInfo == null) {
                super.drawInternal(canvas, bounds);
                return;
            }
            canvas.drawBitmap(mBG, null, bounds, mBgPaint);

            // prepare and draw the foreground
            mAnimInfo.applyTime(mTime, mFG);
            int saveCount = canvas.save();
            canvas.translate(bounds.left, bounds.top);
            canvas.scale(mCanvasScale, mCanvasScale, bounds.width() / 2, bounds.height() / 2);
            canvas.clipPath(mFullDrawable.getIconMask());
            mFG.draw(canvas);
            canvas.restoreToCount(saveCount);

            reschedule();
        }

        @Override
        public boolean isThemed() {
            return mBgPaint.getColorFilter() != null;
        }

        @Override
        protected void updateFilter() {
            super.updateFilter();
            int alpha = mIsDisabled ? (int) (mDisabledAlpha * FULLY_OPAQUE) : FULLY_OPAQUE;
            setAlpha(alpha);
            mBgPaint.setColorFilter(mIsDisabled ? getDisabledColorFilter() : mBgFilter);
            mFG.setColorFilter(mIsDisabled ? getDisabledColorFilter() : null);
        }

        @Override
        public int getIconColor() {
            return isThemed() ? mThemedFgColor : super.getIconColor();
        }

        @Override
        public void run() {
            if (mAnimInfo.applyTime(mTime, mFG)) {
                invalidateSelf();
            } else {
                reschedule();
            }
        }

        @Override
        public boolean setVisible(boolean visible, boolean restart) {
            boolean result = super.setVisible(visible, restart);
            if (visible) {
                reschedule();
            } else {
                unscheduleSelf(this);
            }
            return result;
        }

        private void reschedule() {
            if (!isVisible()) {
                return;
            }

            unscheduleSelf(this);
            final long upTime = SystemClock.uptimeMillis();
            final long step = TICK_MS; /* tick every 200 ms */
            scheduleSelf(this, upTime - ((upTime % step)) + step);
        }

        @Override
        public FastBitmapConstantState newConstantState() {
            return new ClockConstantState(mBitmap, mIconColor, mThemedFgColor, mBoundsOffset,
                    mAnimInfo, mBG, mBgPaint.getColorFilter());
        }

        private static class ClockConstantState extends FastBitmapConstantState {

            private final float mBoundsOffset;
            private final AnimationInfo mAnimInfo;
            private final Bitmap mBG;
            private final ColorFilter mBgFilter;
            private final int mThemedFgColor;

            ClockConstantState(Bitmap bitmap, int color, int themedFgColor,
                    float boundsOffset, AnimationInfo animInfo, Bitmap bg, ColorFilter bgFilter) {
                super(bitmap, color);
                mBoundsOffset = boundsOffset;
                mAnimInfo = animInfo;
                mBG = bg;
                mBgFilter = bgFilter;
                mThemedFgColor = themedFgColor;
            }

            @Override
            public FastBitmapDrawable createDrawable() {
                return new ClockIconDrawable(this);
            }
        }
    }
}
