/*
 * Copyright (C) 2016 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.app.admin;

import static android.app.admin.DevicePolicyManager.PASSWORD_COMPLEXITY_HIGH;
import static android.app.admin.DevicePolicyManager.PASSWORD_COMPLEXITY_LOW;
import static android.app.admin.DevicePolicyManager.PASSWORD_COMPLEXITY_MEDIUM;
import static android.app.admin.DevicePolicyManager.PASSWORD_COMPLEXITY_NONE;
import static android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_ALPHABETIC;
import static android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_ALPHANUMERIC;
import static android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_COMPLEX;
import static android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_NUMERIC;
import static android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_SOMETHING;
import static android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_UNSPECIFIED;

import static com.android.internal.widget.LockPatternUtils.CREDENTIAL_TYPE_PASSWORD;
import static com.android.internal.widget.LockPatternUtils.CREDENTIAL_TYPE_PATTERN;

import android.annotation.IntDef;
import android.annotation.NonNull;
import android.app.admin.DevicePolicyManager.PasswordComplexity;
import android.os.Parcel;
import android.os.Parcelable;

import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.util.Preconditions;
import com.android.internal.widget.LockPatternUtils.CredentialType;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

/**
 * A class that represents the metrics of a credential that are used to decide whether or not a
 * credential meets the requirements. If the credential is a pattern, only quality matters.
 *
 * {@hide}
 */
public class PasswordMetrics implements Parcelable {
    // Maximum allowed number of repeated or ordered characters in a sequence before we'll
    // consider it a complex PIN/password.
    public static final int MAX_ALLOWED_SEQUENCE = 3;

    public int quality = DevicePolicyManager.PASSWORD_QUALITY_UNSPECIFIED;
    public int length = 0;
    public int letters = 0;
    public int upperCase = 0;
    public int lowerCase = 0;
    public int numeric = 0;
    public int symbols = 0;
    public int nonLetter = 0;

    public PasswordMetrics() {}

    public PasswordMetrics(int quality) {
        this.quality = quality;
    }

    public PasswordMetrics(int quality, int length) {
        this.quality = quality;
        this.length = length;
    }

    public PasswordMetrics(int quality, int length, int letters, int upperCase, int lowerCase,
            int numeric, int symbols, int nonLetter) {
        this(quality, length);
        this.letters = letters;
        this.upperCase = upperCase;
        this.lowerCase = lowerCase;
        this.numeric = numeric;
        this.symbols = symbols;
        this.nonLetter = nonLetter;
    }

    private PasswordMetrics(Parcel in) {
        quality = in.readInt();
        length = in.readInt();
        letters = in.readInt();
        upperCase = in.readInt();
        lowerCase = in.readInt();
        numeric = in.readInt();
        symbols = in.readInt();
        nonLetter = in.readInt();
    }

    /** Returns the min quality allowed by {@code complexityLevel}. */
    public static int complexityLevelToMinQuality(@PasswordComplexity int complexityLevel) {
        // this would be the quality of the first metrics since mMetrics is sorted in ascending
        // order of quality
        return PasswordComplexityBucket
                .complexityLevelToBucket(complexityLevel).mMetrics[0].quality;
    }

    /**
     * Returns a merged minimum {@link PasswordMetrics} requirements that a new password must meet
     * to fulfil {@code requestedQuality}, {@code requiresNumeric} and {@code
     * requiresLettersOrSymbols}, which are derived from {@link DevicePolicyManager} requirements,
     * and {@code complexityLevel}.
     *
     * <p>Note that we are taking {@code userEnteredPasswordQuality} into account because there are
     * more than one set of metrics to meet the minimum complexity requirement and inspecting what
     * the user has entered can help determine whether the alphabetic or alphanumeric set of metrics
     * should be used. For example, suppose minimum complexity requires either ALPHABETIC(8+), or
     * ALPHANUMERIC(6+). If the user has entered "a", the length requirement displayed on the UI
     * would be 8. Then the user appends "1" to make it "a1". We now know the user is entering
     * an alphanumeric password so we would update the min complexity required min length to 6.
     */
    public static PasswordMetrics getMinimumMetrics(@PasswordComplexity int complexityLevel,
            int userEnteredPasswordQuality, int requestedQuality, boolean requiresNumeric,
            boolean requiresLettersOrSymbols) {
        int targetQuality = Math.max(
                userEnteredPasswordQuality,
                getActualRequiredQuality(
                        requestedQuality, requiresNumeric, requiresLettersOrSymbols));
        return getTargetQualityMetrics(complexityLevel, targetQuality);
    }

    /**
     * Returns the {@link PasswordMetrics} at {@code complexityLevel} which the metrics quality
     * is the same as {@code targetQuality}.
     *
     * <p>If {@code complexityLevel} does not allow {@code targetQuality}, returns the metrics
     * with the min quality at {@code complexityLevel}.
     */
    // TODO(bernardchau): update tests to test getMinimumMetrics and change this to be private
    @VisibleForTesting
    public static PasswordMetrics getTargetQualityMetrics(
            @PasswordComplexity int complexityLevel, int targetQuality) {
        PasswordComplexityBucket targetBucket =
                PasswordComplexityBucket.complexityLevelToBucket(complexityLevel);
        for (PasswordMetrics metrics : targetBucket.mMetrics) {
            if (targetQuality == metrics.quality) {
                return metrics;
            }
        }
        // none of the metrics at complexityLevel has targetQuality, return metrics with min quality
        // see test case testGetMinimumMetrics_actualRequiredQualityStricter for an example, where
        // min complexity allows at least NUMERIC_COMPLEX, user has not entered anything yet, and
        // requested quality is NUMERIC
        return targetBucket.mMetrics[0];
    }

    /**
     * Finds out the actual quality requirement based on whether quality is {@link
     * DevicePolicyManager#PASSWORD_QUALITY_COMPLEX} and whether digits, letters or symbols are
     * required.
     */
    @VisibleForTesting
    // TODO(bernardchau): update tests to test getMinimumMetrics and change this to be private
    public static int getActualRequiredQuality(
            int requestedQuality, boolean requiresNumeric, boolean requiresLettersOrSymbols) {
        if (requestedQuality != PASSWORD_QUALITY_COMPLEX) {
            return requestedQuality;
        }

        // find out actual password quality from complex requirements
        if (requiresNumeric && requiresLettersOrSymbols) {
            return PASSWORD_QUALITY_ALPHANUMERIC;
        }
        if (requiresLettersOrSymbols) {
            return PASSWORD_QUALITY_ALPHABETIC;
        }
        if (requiresNumeric) {
            // cannot specify numeric complex using complex quality so this must be numeric
            return PASSWORD_QUALITY_NUMERIC;
        }

        // reaching here means dpm sets quality to complex without specifying any requirements
        return PASSWORD_QUALITY_UNSPECIFIED;
    }

    /**
     * Returns {@code complexityLevel} or {@link DevicePolicyManager#PASSWORD_COMPLEXITY_NONE}
     * if {@code complexityLevel} is not valid.
     */
    @PasswordComplexity
    public static int sanitizeComplexityLevel(@PasswordComplexity int complexityLevel) {
        return PasswordComplexityBucket.complexityLevelToBucket(complexityLevel).mComplexityLevel;
    }

    public boolean isDefault() {
        return quality == DevicePolicyManager.PASSWORD_QUALITY_UNSPECIFIED
                && length == 0 && letters == 0 && upperCase == 0 && lowerCase == 0
                && numeric == 0 && symbols == 0 && nonLetter == 0;
    }

    @Override
    public int describeContents() {
        return 0;
    }

    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeInt(quality);
        dest.writeInt(length);
        dest.writeInt(letters);
        dest.writeInt(upperCase);
        dest.writeInt(lowerCase);
        dest.writeInt(numeric);
        dest.writeInt(symbols);
        dest.writeInt(nonLetter);
    }

    public static final @android.annotation.NonNull Parcelable.Creator<PasswordMetrics> CREATOR
            = new Parcelable.Creator<PasswordMetrics>() {
        public PasswordMetrics createFromParcel(Parcel in) {
            return new PasswordMetrics(in);
        }

        public PasswordMetrics[] newArray(int size) {
            return new PasswordMetrics[size];
        }
    };

    /**
     * Returnsthe {@code PasswordMetrics} for a given credential.
     *
     * If the credential is a pin or a password, equivalent to {@link #computeForPassword(byte[])}.
     * {@code credential} cannot be null when {@code type} is
     * {@link com.android.internal.widget.LockPatternUtils#CREDENTIAL_TYPE_PASSWORD}.
     */
    public static PasswordMetrics computeForCredential(
            @CredentialType int type, byte[] credential) {
        if (type == CREDENTIAL_TYPE_PASSWORD) {
            Preconditions.checkNotNull(credential, "credential cannot be null");
            return PasswordMetrics.computeForPassword(credential);
        } else if (type == CREDENTIAL_TYPE_PATTERN)  {
            return new PasswordMetrics(PASSWORD_QUALITY_SOMETHING);
        } else /* if (type == CREDENTIAL_TYPE_NONE) */ {
            return new PasswordMetrics(PASSWORD_QUALITY_UNSPECIFIED);
        }
    }

    /**
     * Returns the {@code PasswordMetrics} for a given password
     */
    public static PasswordMetrics computeForPassword(@NonNull byte[] password) {
        // Analyse the characters used
        int letters = 0;
        int upperCase = 0;
        int lowerCase = 0;
        int numeric = 0;
        int symbols = 0;
        int nonLetter = 0;
        final int length = password.length;
        for (byte b : password) {
            switch (categoryChar((char) b)) {
                case CHAR_LOWER_CASE:
                    letters++;
                    lowerCase++;
                    break;
                case CHAR_UPPER_CASE:
                    letters++;
                    upperCase++;
                    break;
                case CHAR_DIGIT:
                    numeric++;
                    nonLetter++;
                    break;
                case CHAR_SYMBOL:
                    symbols++;
                    nonLetter++;
                    break;
            }
        }

        // Determine the quality of the password
        final boolean hasNumeric = numeric > 0;
        final boolean hasNonNumeric = (letters + symbols) > 0;
        final int quality;
        if (hasNonNumeric && hasNumeric) {
            quality = DevicePolicyManager.PASSWORD_QUALITY_ALPHANUMERIC;
        } else if (hasNonNumeric) {
            quality = DevicePolicyManager.PASSWORD_QUALITY_ALPHABETIC;
        } else if (hasNumeric) {
            quality = maxLengthSequence(password) > MAX_ALLOWED_SEQUENCE
                    ? DevicePolicyManager.PASSWORD_QUALITY_NUMERIC
                    : DevicePolicyManager.PASSWORD_QUALITY_NUMERIC_COMPLEX;
        } else {
            quality = DevicePolicyManager.PASSWORD_QUALITY_UNSPECIFIED;
        }

        return new PasswordMetrics(
                quality, length, letters, upperCase, lowerCase, numeric, symbols, nonLetter);
    }

    @Override
    public boolean equals(Object other) {
        if (!(other instanceof PasswordMetrics)) {
            return false;
        }
        PasswordMetrics o = (PasswordMetrics) other;
        return this.quality == o.quality
                && this.length == o.length
                && this.letters == o.letters
                && this.upperCase == o.upperCase
                && this.lowerCase == o.lowerCase
                && this.numeric == o.numeric
                && this.symbols == o.symbols
                && this.nonLetter == o.nonLetter;
    }

    private boolean satisfiesBucket(PasswordMetrics... bucket) {
        for (PasswordMetrics metrics : bucket) {
            if (this.quality == metrics.quality) {
                return this.length >= metrics.length;
            }
        }
        return false;
    }

    /**
     * Returns the maximum length of a sequential characters. A sequence is defined as
     * monotonically increasing characters with a constant interval or the same character repeated.
     *
     * For example:
     * maxLengthSequence("1234") == 4
     * maxLengthSequence("13579") == 5
     * maxLengthSequence("1234abc") == 4
     * maxLengthSequence("aabc") == 3
     * maxLengthSequence("qwertyuio") == 1
     * maxLengthSequence("@ABC") == 3
     * maxLengthSequence(";;;;") == 4 (anything that repeats)
     * maxLengthSequence(":;<=>") == 1  (ordered, but not composed of alphas or digits)
     *
     * @param bytes the pass
     * @return the number of sequential letters or digits
     */
    public static int maxLengthSequence(@NonNull byte[] bytes) {
        if (bytes.length == 0) return 0;
        char previousChar = (char) bytes[0];
        @CharacterCatagory int category = categoryChar(previousChar); //current sequence category
        int diff = 0; //difference between two consecutive characters
        boolean hasDiff = false; //if we are currently targeting a sequence
        int maxLength = 0; //maximum length of a sequence already found
        int startSequence = 0; //where the current sequence started
        for (int current = 1; current < bytes.length; current++) {
            char currentChar = (char) bytes[current];
            @CharacterCatagory int categoryCurrent = categoryChar(currentChar);
            int currentDiff = (int) currentChar - (int) previousChar;
            if (categoryCurrent != category || Math.abs(currentDiff) > maxDiffCategory(category)) {
                maxLength = Math.max(maxLength, current - startSequence);
                startSequence = current;
                hasDiff = false;
                category = categoryCurrent;
            }
            else {
                if(hasDiff && currentDiff != diff) {
                    maxLength = Math.max(maxLength, current - startSequence);
                    startSequence = current - 1;
                }
                diff = currentDiff;
                hasDiff = true;
            }
            previousChar = currentChar;
        }
        maxLength = Math.max(maxLength, bytes.length - startSequence);
        return maxLength;
    }

    @Retention(RetentionPolicy.SOURCE)
    @IntDef(prefix = { "CHAR_" }, value = {
            CHAR_UPPER_CASE,
            CHAR_LOWER_CASE,
            CHAR_DIGIT,
            CHAR_SYMBOL
    })
    private @interface CharacterCatagory {}
    private static final int CHAR_LOWER_CASE = 0;
    private static final int CHAR_UPPER_CASE = 1;
    private static final int CHAR_DIGIT = 2;
    private static final int CHAR_SYMBOL = 3;

    @CharacterCatagory
    private static int categoryChar(char c) {
        if ('a' <= c && c <= 'z') return CHAR_LOWER_CASE;
        if ('A' <= c && c <= 'Z') return CHAR_UPPER_CASE;
        if ('0' <= c && c <= '9') return CHAR_DIGIT;
        return CHAR_SYMBOL;
    }

    private static int maxDiffCategory(@CharacterCatagory int category) {
        switch (category) {
            case CHAR_LOWER_CASE:
            case CHAR_UPPER_CASE:
                return 1;
            case CHAR_DIGIT:
                return 10;
            default:
                return 0;
        }
    }

    /** Determines the {@link PasswordComplexity} of this {@link PasswordMetrics}. */
    @PasswordComplexity
    public int determineComplexity() {
        for (PasswordComplexityBucket bucket : PasswordComplexityBucket.BUCKETS) {
            if (satisfiesBucket(bucket.mMetrics)) {
                return bucket.mComplexityLevel;
            }
        }
        return PASSWORD_COMPLEXITY_NONE;
    }

    /**
     * Requirements in terms of {@link PasswordMetrics} for each {@link PasswordComplexity}.
     */
    private static class PasswordComplexityBucket {
        /**
         * Definition of {@link DevicePolicyManager#PASSWORD_COMPLEXITY_HIGH} in terms of
         * {@link PasswordMetrics}.
         */
        private static final PasswordComplexityBucket HIGH =
                new PasswordComplexityBucket(
                        PASSWORD_COMPLEXITY_HIGH,
                        new PasswordMetrics(
                                DevicePolicyManager.PASSWORD_QUALITY_NUMERIC_COMPLEX, /* length= */
                                8),
                        new PasswordMetrics(
                                DevicePolicyManager.PASSWORD_QUALITY_ALPHABETIC, /* length= */ 6),
                        new PasswordMetrics(
                                DevicePolicyManager.PASSWORD_QUALITY_ALPHANUMERIC, /* length= */
                                6));

        /**
         * Definition of {@link DevicePolicyManager#PASSWORD_COMPLEXITY_MEDIUM} in terms of
         * {@link PasswordMetrics}.
         */
        private static final PasswordComplexityBucket MEDIUM =
                new PasswordComplexityBucket(
                        PASSWORD_COMPLEXITY_MEDIUM,
                        new PasswordMetrics(
                                DevicePolicyManager.PASSWORD_QUALITY_NUMERIC_COMPLEX, /* length= */
                                4),
                        new PasswordMetrics(
                                DevicePolicyManager.PASSWORD_QUALITY_ALPHABETIC, /* length= */ 4),
                        new PasswordMetrics(
                                DevicePolicyManager.PASSWORD_QUALITY_ALPHANUMERIC, /* length= */
                                4));

        /**
         * Definition of {@link DevicePolicyManager#PASSWORD_COMPLEXITY_LOW} in terms of
         * {@link PasswordMetrics}.
         */
        private static final PasswordComplexityBucket LOW =
                new PasswordComplexityBucket(
                        PASSWORD_COMPLEXITY_LOW,
                        new PasswordMetrics(DevicePolicyManager.PASSWORD_QUALITY_SOMETHING),
                        new PasswordMetrics(DevicePolicyManager.PASSWORD_QUALITY_NUMERIC),
                        new PasswordMetrics(DevicePolicyManager.PASSWORD_QUALITY_NUMERIC_COMPLEX),
                        new PasswordMetrics(DevicePolicyManager.PASSWORD_QUALITY_ALPHABETIC),
                        new PasswordMetrics(DevicePolicyManager.PASSWORD_QUALITY_ALPHANUMERIC));

        /**
         * A special bucket to represent {@link DevicePolicyManager#PASSWORD_COMPLEXITY_NONE}.
         */
        private static final PasswordComplexityBucket NONE =
                new PasswordComplexityBucket(PASSWORD_COMPLEXITY_NONE, new PasswordMetrics());

        /** Array containing all buckets from high to low. */
        private static final PasswordComplexityBucket[] BUCKETS =
                new PasswordComplexityBucket[] {HIGH, MEDIUM, LOW};

        @PasswordComplexity
        private final int mComplexityLevel;
        private final PasswordMetrics[] mMetrics;

        /**
         * @param metricsArray must be sorted in ascending order of {@link #quality}.
         */
        private PasswordComplexityBucket(@PasswordComplexity int complexityLevel,
                PasswordMetrics... metricsArray) {
            int previousQuality = PASSWORD_QUALITY_UNSPECIFIED;
            for (PasswordMetrics metrics : metricsArray) {
                if (metrics.quality < previousQuality) {
                    throw new IllegalArgumentException("metricsArray must be sorted in ascending"
                            + " order of quality");
                }
                previousQuality = metrics.quality;
            }

            this.mMetrics = metricsArray;
            this.mComplexityLevel = complexityLevel;

        }

        /** Returns the bucket that {@code complexityLevel} represents. */
        private static PasswordComplexityBucket complexityLevelToBucket(
                @PasswordComplexity int complexityLevel) {
            for (PasswordComplexityBucket bucket : BUCKETS) {
                if (bucket.mComplexityLevel == complexityLevel) {
                    return bucket;
                }
            }
            return NONE;
        }
    }
}
