/*
 * Copyright (C) 2020 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.view;

import static android.view.Gravity.BOTTOM;
import static android.view.Gravity.LEFT;
import static android.view.Gravity.RIGHT;
import static android.view.Gravity.TOP;

import static com.android.internal.annotations.VisibleForTesting.Visibility.PACKAGE;

import android.annotation.NonNull;
import android.annotation.Nullable;
import android.graphics.Insets;
import android.graphics.Matrix;
import android.graphics.Path;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.Region;
import android.text.TextUtils;
import android.util.Log;
import android.util.PathParser;

import com.android.internal.annotations.VisibleForTesting;

import java.util.Locale;
import java.util.Objects;

/**
 * In order to accept the cutout specification for all of edges in devices, the specification
 * parsing method is extracted from
 * {@link android.view.DisplayCutout#fromResourcesRectApproximation(Resources, int, int)} to be
 * the specified class for parsing the specification.
 * BNF definition:
 * <ul>
 *      <li>Cutouts Specification = ([Cutout Delimiter],Cutout Specification) {...}, [Dp] ; </li>
 *      <li>Cutout Specification  = [Vertical Position], (SVG Path Element), [Horizontal Position]
 *                                  [Bind Cutout] ;</li>
 *      <li>Vertical Position     = "@bottom" | "@center_vertical" ;</li>
 *      <li>Horizontal Position   = "@left" | "@right" ;</li>
 *      <li>Bind Cutout           = "@bind_left_cutout" | "@bind_right_cutout" ;</li>
 *      <li>Cutout Delimiter      = "@cutout" ;</li>
 *      <li>Dp                    = "@dp"</li>
 * </ul>
 *
 * <ul>
 *     <li>Vertical position is top by default if there is neither "@bottom" nor "@center_vertical"
 *     </li>
 *     <li>Horizontal position is center horizontal by default if there is neither "@left" nor
 *     "@right".</li>
 *     <li>@bottom make the cutout piece bind to bottom edge.</li>
 *     <li>both of @bind_left_cutout and @bind_right_cutout are use to claim the cutout belong to
 *     left or right edge cutout.</li>
 * </ul>
 *
 * @hide
 */
@VisibleForTesting(visibility = PACKAGE)
public class CutoutSpecification {
    private static final String TAG = "CutoutSpecification";
    private static final boolean DEBUG = false;

    private static final int MINIMAL_ACCEPTABLE_PATH_LENGTH = "H1V1Z".length();

    private static final char MARKER_START_CHAR = '@';
    private static final String DP_MARKER = MARKER_START_CHAR + "dp";

    private static final String BOTTOM_MARKER = MARKER_START_CHAR + "bottom";
    private static final String RIGHT_MARKER = MARKER_START_CHAR + "right";
    private static final String LEFT_MARKER = MARKER_START_CHAR + "left";
    private static final String CUTOUT_MARKER = MARKER_START_CHAR + "cutout";
    private static final String CENTER_VERTICAL_MARKER = MARKER_START_CHAR + "center_vertical";

    /* By default, it's top bound cutout. That's why TOP_BOUND_CUTOUT_MARKER is not defined */
    private static final String BIND_RIGHT_CUTOUT_MARKER = MARKER_START_CHAR + "bind_right_cutout";
    private static final String BIND_LEFT_CUTOUT_MARKER = MARKER_START_CHAR + "bind_left_cutout";

    private final Path mPath;
    private final Rect mLeftBound;
    private final Rect mTopBound;
    private final Rect mRightBound;
    private final Rect mBottomBound;
    private Insets mInsets;

    private CutoutSpecification(@NonNull Parser parser) {
        mPath = parser.mPath;
        mLeftBound = parser.mLeftBound;
        mTopBound = parser.mTopBound;
        mRightBound = parser.mRightBound;
        mBottomBound = parser.mBottomBound;
        mInsets = parser.mInsets;

        applyPhysicalPixelDisplaySizeRatio(parser.mPhysicalPixelDisplaySizeRatio);

        if (DEBUG) {
            Log.d(TAG, String.format(Locale.ENGLISH,
                    "left cutout = %s, top cutout = %s, right cutout = %s, bottom cutout = %s",
                    mLeftBound != null ? mLeftBound.toString() : "",
                    mTopBound != null ? mTopBound.toString() : "",
                    mRightBound != null ? mRightBound.toString() : "",
                    mBottomBound != null ? mBottomBound.toString() : ""));
        }
    }

    private void applyPhysicalPixelDisplaySizeRatio(float physicalPixelDisplaySizeRatio) {
        if (physicalPixelDisplaySizeRatio == 1f) {
            return;
        }

        if (mPath != null && !mPath.isEmpty()) {
            final Matrix matrix = new Matrix();
            matrix.postScale(physicalPixelDisplaySizeRatio, physicalPixelDisplaySizeRatio);
            mPath.transform(matrix);
        }

        scaleBounds(mLeftBound, physicalPixelDisplaySizeRatio);
        scaleBounds(mTopBound, physicalPixelDisplaySizeRatio);
        scaleBounds(mRightBound, physicalPixelDisplaySizeRatio);
        scaleBounds(mBottomBound, physicalPixelDisplaySizeRatio);
        mInsets = scaleInsets(mInsets, physicalPixelDisplaySizeRatio);
    }

    private void scaleBounds(Rect r, float ratio) {
        if (r != null && !r.isEmpty()) {
            r.scale(ratio);
        }
    }

    private Insets scaleInsets(Insets insets, float ratio) {
        return Insets.of(
                (int) (insets.left * ratio + 0.5f),
                (int) (insets.top * ratio + 0.5f),
                (int) (insets.right * ratio + 0.5f),
                (int) (insets.bottom * ratio + 0.5f));
    }

    @VisibleForTesting(visibility = PACKAGE)
    @Nullable
    public Path getPath() {
        return mPath;
    }

    @VisibleForTesting(visibility = PACKAGE)
    @Nullable
    public Rect getLeftBound() {
        return mLeftBound;
    }

    @VisibleForTesting(visibility = PACKAGE)
    @Nullable
    public Rect getTopBound() {
        return mTopBound;
    }

    @VisibleForTesting(visibility = PACKAGE)
    @Nullable
    public Rect getRightBound() {
        return mRightBound;
    }

    @VisibleForTesting(visibility = PACKAGE)
    @Nullable
    public Rect getBottomBound() {
        return mBottomBound;
    }

    /**
     * To count the safe inset according to the cutout bounds and waterfall inset.
     *
     * @return the safe inset.
     */
    @VisibleForTesting(visibility = PACKAGE)
    @NonNull
    public Rect getSafeInset() {
        return mInsets.toRect();
    }

    private static int decideWhichEdge(boolean isTopEdgeShortEdge,
            boolean isShortEdge, boolean isStart) {
        return (isTopEdgeShortEdge)
                ? ((isShortEdge) ? (isStart ? TOP : BOTTOM) : (isStart ? LEFT : RIGHT))
                : ((isShortEdge) ? (isStart ? LEFT : RIGHT) : (isStart ? TOP : BOTTOM));
    }

    /**
     * The CutoutSpecification Parser.
     */
    @VisibleForTesting(visibility = PACKAGE)
    public static class Parser {
        private final boolean mIsShortEdgeOnTop;
        private final float mStableDensity;
        private final int mPhysicalDisplayWidth;
        private final int mPhysicalDisplayHeight;
        private final float mPhysicalPixelDisplaySizeRatio;
        private final Matrix mMatrix;
        private Insets mInsets;
        private int mSafeInsetLeft;
        private int mSafeInsetTop;
        private int mSafeInsetRight;
        private int mSafeInsetBottom;

        private final Rect mTmpRect = new Rect();
        private final RectF mTmpRectF = new RectF();

        private boolean mInDp;

        private Path mPath;
        private Rect mLeftBound;
        private Rect mTopBound;
        private Rect mRightBound;
        private Rect mBottomBound;

        private boolean mPositionFromLeft = false;
        private boolean mPositionFromRight = false;
        private boolean mPositionFromBottom = false;
        private boolean mPositionFromCenterVertical = false;

        private boolean mBindLeftCutout = false;
        private boolean mBindRightCutout = false;
        private boolean mBindBottomCutout = false;

        private boolean mIsTouchShortEdgeStart;
        private boolean mIsTouchShortEdgeEnd;
        private boolean mIsCloserToStartSide;

        @VisibleForTesting(visibility = PACKAGE)
        public Parser(float stableDensity, int physicalDisplayWidth,
                int physicalDisplayHeight) {
            this(stableDensity, physicalDisplayWidth, physicalDisplayHeight, 1f);
        }

        /**
         * The constructor of the CutoutSpecification parser to parse the specification of cutout.
         * @param stableDensity the display density.
         * @param physicalDisplayWidth the display width.
         * @param physicalDisplayHeight the display height.
         * @param physicalPixelDisplaySizeRatio the display size ratio based on stable display size.
         */
        Parser(float stableDensity, int physicalDisplayWidth, int physicalDisplayHeight,
                float physicalPixelDisplaySizeRatio) {
            mStableDensity = stableDensity;
            mPhysicalDisplayWidth = physicalDisplayWidth;
            mPhysicalDisplayHeight = physicalDisplayHeight;
            mPhysicalPixelDisplaySizeRatio = physicalPixelDisplaySizeRatio;
            mMatrix = new Matrix();
            mIsShortEdgeOnTop = mPhysicalDisplayWidth < mPhysicalDisplayHeight;
        }

        private void computeBoundsRectAndAddToRegion(Path p, Region inoutRegion, Rect inoutRect) {
            mTmpRectF.setEmpty();
            p.computeBounds(mTmpRectF, false /* unused */);
            mTmpRectF.round(inoutRect);
            inoutRegion.op(inoutRect, Region.Op.UNION);
        }

        private void resetStatus(StringBuilder sb) {
            sb.setLength(0);
            mPositionFromBottom = false;
            mPositionFromLeft = false;
            mPositionFromRight = false;
            mPositionFromCenterVertical = false;

            mBindLeftCutout = false;
            mBindRightCutout = false;
            mBindBottomCutout = false;
        }

        private void translateMatrix() {
            final float offsetX;
            if (mPositionFromRight) {
                offsetX = mPhysicalDisplayWidth;
            } else if (mPositionFromLeft) {
                offsetX = 0;
            } else {
                offsetX = mPhysicalDisplayWidth / 2f;
            }

            final float offsetY;
            if (mPositionFromBottom) {
                offsetY = mPhysicalDisplayHeight;
            } else if (mPositionFromCenterVertical) {
                offsetY = mPhysicalDisplayHeight / 2f;
            } else {
                offsetY = 0;
            }

            mMatrix.reset();
            if (mInDp) {
                mMatrix.postScale(mStableDensity, mStableDensity);
            }
            mMatrix.postTranslate(offsetX, offsetY);
        }

        private int computeSafeInsets(int gravity, Rect rect) {
            if (gravity == LEFT && rect.right > 0 && rect.right < mPhysicalDisplayWidth) {
                return rect.right;
            } else if (gravity == TOP && rect.bottom > 0 && rect.bottom < mPhysicalDisplayHeight) {
                return rect.bottom;
            } else if (gravity == RIGHT && rect.left > 0 && rect.left < mPhysicalDisplayWidth) {
                return mPhysicalDisplayWidth - rect.left;
            } else if (gravity == BOTTOM && rect.top > 0 && rect.top < mPhysicalDisplayHeight) {
                return mPhysicalDisplayHeight - rect.top;
            }
            return 0;
        }

        private void setSafeInset(int gravity, int inset) {
            if (gravity == LEFT) {
                mSafeInsetLeft = inset;
            } else if (gravity == TOP) {
                mSafeInsetTop = inset;
            } else if (gravity == RIGHT) {
                mSafeInsetRight = inset;
            } else if (gravity == BOTTOM) {
                mSafeInsetBottom = inset;
            }
        }

        private int getSafeInset(int gravity) {
            if (gravity == LEFT) {
                return mSafeInsetLeft;
            } else if (gravity == TOP) {
                return mSafeInsetTop;
            } else if (gravity == RIGHT) {
                return mSafeInsetRight;
            } else if (gravity == BOTTOM) {
                return mSafeInsetBottom;
            }
            return 0;
        }

        @NonNull
        private Rect onSetEdgeCutout(boolean isStart, boolean isShortEdge, @NonNull Rect rect) {
            final int gravity;
            if (isShortEdge) {
                gravity = decideWhichEdge(mIsShortEdgeOnTop, true, isStart);
            } else {
                if (mIsTouchShortEdgeStart && mIsTouchShortEdgeEnd) {
                    gravity = decideWhichEdge(mIsShortEdgeOnTop, false, isStart);
                } else if (mIsTouchShortEdgeStart || mIsTouchShortEdgeEnd) {
                    gravity = decideWhichEdge(mIsShortEdgeOnTop, true,
                            mIsCloserToStartSide);
                } else {
                    gravity = decideWhichEdge(mIsShortEdgeOnTop, isShortEdge, isStart);
                }
            }

            int oldSafeInset = getSafeInset(gravity);
            int newSafeInset = computeSafeInsets(gravity, rect);
            if (oldSafeInset < newSafeInset) {
                setSafeInset(gravity, newSafeInset);
            }

            return new Rect(rect);
        }

        private void setEdgeCutout(@NonNull Path newPath) {
            if (mBindRightCutout && mRightBound == null) {
                mRightBound = onSetEdgeCutout(false, !mIsShortEdgeOnTop, mTmpRect);
            } else if (mBindLeftCutout && mLeftBound == null) {
                mLeftBound = onSetEdgeCutout(true, !mIsShortEdgeOnTop, mTmpRect);
            } else if (mBindBottomCutout && mBottomBound == null) {
                mBottomBound = onSetEdgeCutout(false, mIsShortEdgeOnTop, mTmpRect);
            } else if (!(mBindBottomCutout || mBindLeftCutout || mBindRightCutout)
                    && mTopBound == null) {
                mTopBound = onSetEdgeCutout(true, mIsShortEdgeOnTop, mTmpRect);
            } else {
                return;
            }

            if (mPath != null) {
                mPath.addPath(newPath);
            } else {
                mPath = newPath;
            }
        }

        private void parseSvgPathSpec(Region region, String spec) {
            if (TextUtils.length(spec) < MINIMAL_ACCEPTABLE_PATH_LENGTH) {
                Log.e(TAG, "According to SVG definition, it shouldn't happen");
                return;
            }
            translateMatrix();

            final Path newPath = PathParser.createPathFromPathData(spec);
            newPath.transform(mMatrix);
            computeBoundsRectAndAddToRegion(newPath, region, mTmpRect);

            if (DEBUG) {
                Log.d(TAG, String.format(Locale.ENGLISH,
                        "hasLeft = %b, hasRight = %b, hasBottom = %b, hasCenterVertical = %b",
                        mPositionFromLeft, mPositionFromRight, mPositionFromBottom,
                        mPositionFromCenterVertical));
                Log.d(TAG, "region = " + region);
                Log.d(TAG, "spec = \"" + spec + "\" rect = " + mTmpRect + " newPath = " + newPath);
            }

            if (mTmpRect.isEmpty()) {
                return;
            }

            if (mIsShortEdgeOnTop) {
                mIsTouchShortEdgeStart = mTmpRect.top <= 0;
                mIsTouchShortEdgeEnd = mTmpRect.bottom >= mPhysicalDisplayHeight;
                mIsCloserToStartSide = mTmpRect.centerY() < mPhysicalDisplayHeight / 2;
            } else {
                mIsTouchShortEdgeStart = mTmpRect.left <= 0;
                mIsTouchShortEdgeEnd = mTmpRect.right >= mPhysicalDisplayWidth;
                mIsCloserToStartSide = mTmpRect.centerX() < mPhysicalDisplayWidth / 2;
            }

            setEdgeCutout(newPath);
        }

        private void parseSpecWithoutDp(@NonNull String specWithoutDp) {
            Region region = Region.obtain();
            StringBuilder sb = null;
            int currentIndex = 0;
            int lastIndex = 0;
            while ((currentIndex = specWithoutDp.indexOf(MARKER_START_CHAR, lastIndex)) != -1) {
                if (sb == null) {
                    sb = new StringBuilder(specWithoutDp.length());
                }
                sb.append(specWithoutDp, lastIndex, currentIndex);

                if (specWithoutDp.startsWith(LEFT_MARKER, currentIndex)) {
                    if (!mPositionFromRight) {
                        mPositionFromLeft = true;
                    }
                    currentIndex += LEFT_MARKER.length();
                } else if (specWithoutDp.startsWith(RIGHT_MARKER, currentIndex)) {
                    if (!mPositionFromLeft) {
                        mPositionFromRight = true;
                    }
                    currentIndex += RIGHT_MARKER.length();
                } else if (specWithoutDp.startsWith(BOTTOM_MARKER, currentIndex)) {
                    parseSvgPathSpec(region, sb.toString());
                    currentIndex += BOTTOM_MARKER.length();

                    /* prepare to parse the rest path */
                    resetStatus(sb);
                    mBindBottomCutout = true;
                    mPositionFromBottom = true;
                } else if (specWithoutDp.startsWith(CENTER_VERTICAL_MARKER, currentIndex)) {
                    parseSvgPathSpec(region, sb.toString());
                    currentIndex += CENTER_VERTICAL_MARKER.length();

                    /* prepare to parse the rest path */
                    resetStatus(sb);
                    mPositionFromCenterVertical = true;
                } else if (specWithoutDp.startsWith(CUTOUT_MARKER, currentIndex)) {
                    parseSvgPathSpec(region, sb.toString());
                    currentIndex += CUTOUT_MARKER.length();

                    /* prepare to parse the rest path */
                    resetStatus(sb);
                } else if (specWithoutDp.startsWith(BIND_LEFT_CUTOUT_MARKER, currentIndex)) {
                    mBindBottomCutout = false;
                    mBindRightCutout = false;
                    mBindLeftCutout = true;

                    currentIndex += BIND_LEFT_CUTOUT_MARKER.length();
                } else if (specWithoutDp.startsWith(BIND_RIGHT_CUTOUT_MARKER, currentIndex)) {
                    mBindBottomCutout = false;
                    mBindLeftCutout = false;
                    mBindRightCutout = true;

                    currentIndex += BIND_RIGHT_CUTOUT_MARKER.length();
                } else {
                    currentIndex += 1;
                }

                lastIndex = currentIndex;
            }

            if (sb == null) {
                parseSvgPathSpec(region, specWithoutDp);
            } else {
                sb.append(specWithoutDp, lastIndex, specWithoutDp.length());
                parseSvgPathSpec(region, sb.toString());
            }

            region.recycle();
        }

        /**
         * To parse specification string as the CutoutSpecification.
         *
         * @param originalSpec the specification string
         * @return the CutoutSpecification instance
         */
        @VisibleForTesting(visibility = PACKAGE)
        public CutoutSpecification parse(@NonNull String originalSpec) {
            Objects.requireNonNull(originalSpec);

            int dpIndex = originalSpec.lastIndexOf(DP_MARKER);
            mInDp = (dpIndex != -1);
            final String spec;
            if (dpIndex != -1) {
                spec = originalSpec.substring(0, dpIndex)
                        + originalSpec.substring(dpIndex + DP_MARKER.length());
            } else {
                spec = originalSpec;
            }

            parseSpecWithoutDp(spec);
            mInsets = Insets.of(mSafeInsetLeft, mSafeInsetTop, mSafeInsetRight, mSafeInsetBottom);
            return new CutoutSpecification(this);
        }
    }
}
