/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 org.apache.cassandra.db.guardrails;

import java.io.IOException;
import java.io.RandomAccessFile; // checkstyle: permit this import
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

import javax.annotation.Nonnull;

import org.passay.CharacterCharacteristicsRule;
import org.passay.CharacterData;
import org.passay.CharacterRule;
import org.passay.CyrillicCharacterData;
import org.passay.CyrillicModernCharacterData;
import org.passay.CyrillicModernSequenceData;
import org.passay.CyrillicSequenceData;
import org.passay.CzechCharacterData;
import org.passay.CzechSequenceData;
import org.passay.DictionaryRule;
import org.passay.EnglishCharacterData;
import org.passay.EnglishSequenceData;
import org.passay.GermanCharacterData;
import org.passay.GermanSequenceData;
import org.passay.IllegalSequenceRule;
import org.passay.LengthRule;
import org.passay.PasswordData;
import org.passay.PasswordValidator;
import org.passay.PolishCharacterData;
import org.passay.PolishSequenceData;
import org.passay.Rule;
import org.passay.RuleResult;
import org.passay.RuleResultDetail;
import org.passay.RuleResultMetadata;
import org.passay.SequenceData;
import org.passay.WhitespaceRule;
import org.passay.dictionary.FileWordList;
import org.passay.dictionary.WordListDictionary;

import org.apache.cassandra.exceptions.ConfigurationException;

import static java.util.Optional.empty;
import static org.passay.EnglishCharacterData.Digit;

/**
 * The figures for each respective password characteristic and rule holds for the default configuration.
 * <p>
 * This password validator acts around 4 password characteristics which are:
 * <ul>
 *     <li>minimum number of lower-case characters (at least 2 to not emit warning, at least 1 to not emit failure)</li>
 *     <li>minimum number of upper-case characters (at least 2 to not emit warning, at least 1 to not emit failure)</li>
 *     <li>minimum number of special characters (at least 2 to not emit warning, at least 1 to not emit failure)</li>
 *     <li>minimum number of digits  (at least 2 to not emit warning, at least 1 to not emit failure)</li>
 * </ul>
 * To not emit warning, all characteristics have to be satisfied. A warning is emitted when 3 out of 4 are satistifed,
 * and it emits a failure when 2 (or less) out of 4 are satisfied.
 * <p>
 * Additionally, this validator uses these password rules which can not be violated in order to have a chance
 * to consider a password to be valid (in connection with above characteristics)
 * <ul>
 *     <li>a password has to be at least 12 characters long to not emit a warning and at least 8 characters
 *     long to not emit a failure</li>
 *     <li>a password can not contain whitespace characters</li>
 *     <li>a password can not contain illegal sequences</li>
 * </ul>
 * <p>
 * The default illegal-sequence length is 5 and its allowed minimum is 3.
 * <p>
 * Only some characters are contributing to the password characteristics above when it comes to
 * upper-cased and lower-cased ones. All other (unicode) characters do not contribute to these characteristics,
 * but they do contribute to the length rule.
 * <p>
 * For example, let's have this password: {@code 山13g.^Tg$棚Sd}. This password satisfies all 4 password characteristics
 * above:
 * <ul>
 *     <li>2 specials (contains 3)</li>
 *     <li>2 upper chars (contains 2)</li>
 *     <li>2 lower chars (contains 3)</li>
 *     <li>2 digits (contains 2)</li>
 * </ul>
 * Together 10 characters. That would not be enough but there are two more: {@code 山} and {@code 棚}
 * which is together 12. Not all (unicode) characters are possible to be categorized to upper-case and lower-case
 * categories so these characters do not contribute to these password characteristics but only to the length rule.
 * <p>
 * The characters which do contribute to lower-case and upper-case characteristics are:
 * <ul>
 *     <li>{@link EnglishCharacterData}</li>
 *     <li>{@link CyrillicCharacterData}</li>
 *     <li>{@link CyrillicModernCharacterData}</li>
 *     <li>{@link GermanCharacterData}</li>
 *     <li>{@link PolishCharacterData}</li>
 *     <li>{@link CzechCharacterData}</li>
 * </ul>
 * <p>
 * The characters which do contribute to {@link IllegalSequenceRule} rule are:
 * <ul>
 *     <li>{@link EnglishSequenceData#Alphabetical}</li>
 *     <li>{@link EnglishSequenceData#Numerical}</li>
 *     <li>{@link EnglishSequenceData#USQwerty}</li>
 *     <li>{@link CyrillicSequenceData#Alphabetical}</li>
 *     <li>{@link CyrillicModernSequenceData#Alphabetical}</li>
 *     <li>{@link CzechSequenceData#Alphabetical}</li>
 *     <li>{@link GermanSequenceData#Alphabetical}</li>
 *     <li>{@link PolishSequenceData#Alphabetical}</li>
 * </ul>
 * </p>
 * Passwords consisting of all characters belonging to unsupported character sets will be rejected and such
 * passwords will not be validated. For example, this password will be rejected: {@code 卡桑德拉-卡桑德拉山山羊棚}.
 * <p>
 * The password validator acts on two levels when used in a guardrail, executed in this order:
 * <ol>
 *    <li>tests if a password does not violate a failure threshold, if it does, failure is emitted and query failed</li>
 *    <li>tests if a password does not violate a warning threshold, if it does, warning is emitted</li>
 * </ol>
 * <p>
 * Passwords will be searched against a dictionary if "dictionary" configuration property is specified pointing to
 * a dictionary to read passwords from. This dictionary is all cached in a memory. Dictionary lookup is done
 * only upon {@link #shouldFail(String, boolean)} method call.
 * <p>
 *
 * @see CharacterCharacteristicsRule
 * @see CassandraPasswordConfiguration
 * @see WhitespaceRule
 * @see LengthRule
 * @see IllegalSequenceRule
 * @see ValueValidator#shouldWarn(Object, boolean)
 * @see ValueValidator#shouldFail(Object, boolean)
 */
public class CassandraPasswordValidator extends ValueValidator<String> implements PasswordDictionaryAware<CassandraPasswordConfiguration>
{
    private static final RuleResult VALID = new RuleResult(true);

    protected final PasswordValidator warnValidator;
    protected final PasswordValidator failValidator;

    protected final CassandraPasswordConfiguration configuration;
    private final UnsupportedCharsetRule unsupportedCharsetRule = new UnsupportedCharsetRule();
    private final DictionaryRule dictionaryRule;
    private final boolean provideDetailedMessages;

    public CassandraPasswordValidator(CustomGuardrailConfig config)
    {
        super(config);
        configuration = new CassandraPasswordConfiguration(config);
        provideDetailedMessages = configuration.detailedMessages;

        dictionaryRule = initializeDictionaryRule(configuration);

        warnValidator = new PasswordValidator(getRules(configuration.lengthWarn,
                                                       configuration.maxLength,
                                                       configuration.characteristicsWarn,
                                                       configuration.illegalSequenceLength,
                                                       getCharacterValidationRules(configuration.upperCaseWarn,
                                                                                   configuration.lowerCaseWarn,
                                                                                   configuration.digitsWarn,
                                                                                   configuration.specialsWarn)));
        failValidator = new PasswordValidator(getRules(configuration.lengthFail,
                                                       configuration.maxLength,
                                                       configuration.characteristicsFail,
                                                       configuration.illegalSequenceLength,
                                                       getCharacterValidationRules(configuration.upperCaseFail,
                                                                                   configuration.lowerCaseFail,
                                                                                   configuration.digitsFail,
                                                                                   configuration.specialsFail)));
    }

    @Nonnull
    @Override
    public CustomGuardrailConfig getParameters()
    {
        return configuration.asCustomGuardrailConfig();
    }

    @Override
    public Optional<ValidationViolation> shouldWarn(String password, boolean calledBySuperuser)
    {
        return executeValidation(warnValidator, password, calledBySuperuser, true);
    }

    @Override
    public Optional<ValidationViolation> shouldFail(String password, boolean calledBySuperUser)
    {
        return executeValidation(failValidator, password, calledBySuperUser, false);
    }

    private Optional<ValidationViolation> executeValidation(PasswordValidator validator,
                                                            String passwordToValidate,
                                                            boolean calledBySuperUser,
                                                            boolean toWarn)
    {
        PasswordData passwordData = new PasswordData(passwordToValidate);
        if (!unsupportedCharsetRule.validate(passwordData).isValid())
        {
            String message = (calledBySuperUser || provideDetailedMessages) ? "Unsupported language / character set for password validator"
                                                                            : "Password complexity policy not met.";
            return Optional.of(new ValidationViolation(message, UnsupportedCharsetRule.ERROR_CODE));
        }
        else
        {
            if (!toWarn && configuration.dictionary != null) // for shouldFail
            {
                RuleResult result = foundInDictionary(passwordData);
                if (!result.isValid())
                    return Optional.of(getValidationMessage(calledBySuperUser, validator, false, result));
            }
            RuleResult result = validator.validate(passwordData);
            return result.isValid() ? empty() : Optional.of(getValidationMessage(calledBySuperUser, validator, toWarn, result));
        }
    }

    @Override
    public void validateParameters() throws ConfigurationException
    {
        configuration.validateParameters();
    }

    protected List<CharacterRule> getCharacterValidationRules(int upper, int lower, int digits, int special)
    {
        return Arrays.asList(new CharacterRule(new CustomUpperCaseCharacterData(), upper),
                             new CharacterRule(new CustomLowerCaseCharacterData(), lower),
                             new CharacterRule(Digit, digits),
                             new CharacterRule(specialCharacters, special));
    }

    protected List<Rule> getRules(int minLength,
                                  int maxLength,
                                  int characteristics,
                                  int illegalSequenceLength,
                                  List<CharacterRule> characterRules)
    {
        List<Rule> rules = new ArrayList<>();

        rules.add(new LengthRule(minLength, maxLength));

        CharacterCharacteristicsRule characteristicsRule = new CharacterCharacteristicsRule();
        characteristicsRule.setNumberOfCharacteristics(characteristics + 1);
        characteristicsRule.getRules().addAll(characterRules);
        rules.add(characteristicsRule);

        rules.add(new WhitespaceRule());

        for (SequenceData sequenceData : getSequenceData())
            rules.add(new IllegalSequenceRule(sequenceData, illegalSequenceLength, false));

        return rules;
    }

    private ValidationViolation getValidationMessage(boolean calledBySuperuser,
                                                     PasswordValidator validator,
                                                     boolean toWarn,
                                                     RuleResult result)
    {
        Set<String> errorCodes = new HashSet<>();
        for (RuleResultDetail ruleResultDetail : result.getDetails())
            errorCodes.add(ruleResultDetail.getErrorCode());

        String redactedMessage = errorCodes.toString();

        if (calledBySuperuser || provideDetailedMessages)
        {
            String type = toWarn ? "warning" : "error";
            StringBuilder sb = new StringBuilder();
            sb.append("Password was")
              .append(toWarn ? " set, however it might not be strong enough according to the " +
                               "configured password strength policy. "
                             : " not set as it violated configured password strength policy. ")
              .append("To fix this ")
              .append(type)
              .append(", the following has to be resolved: ");

            for (String message : validator.getMessages(result))
                sb.append(message).append(' ');

            sb.append("You may also use 'GENERATED PASSWORD' upon role creation or alteration.");

            String message = sb.toString();

            return new ValidationViolation(message, redactedMessage);
        }
        else
        {
            if (toWarn)
            {
                return new ValidationViolation("Password was set, however it might not be strong enough " +
                                               "according to the configured password strength policy.",
                                               redactedMessage);
            }
            else
            {
                return new ValidationViolation("Password was not set as it violated configured password " +
                                               "strength policy. You may also use 'GENERATED PASSWORD' upon role " +
                                               "creation or alteration.",
                                               redactedMessage);
            }
        }
    }

    @Override
    public RuleResult foundInDictionary(String password)
    {
        if (dictionaryRule == null)
            return VALID;

        return dictionaryRule.validate(new PasswordData(password));
    }

    @Override
    public RuleResult foundInDictionary(PasswordData passwordData)
    {
        if (dictionaryRule == null)
            return VALID;

        return dictionaryRule.validate(passwordData);
    }

    protected static class CustomLowerCaseCharacterData implements CharacterData
    {
        @Override
        public String getErrorCode()
        {
            return "INSUFFICIENT_LOWERCASE";
        }

        @Override
        public String getCharacters()
        {
            return EnglishCharacterData.LowerCase.getCharacters() +
                   CyrillicCharacterData.LowerCase.getCharacters() +
                   CyrillicModernCharacterData.LowerCase.getCharacters() +
                   CzechCharacterData.LowerCase.getCharacters() +
                   GermanCharacterData.LowerCase.getCharacters() +
                   PolishCharacterData.LowerCase.getCharacters();
        }
    }

    protected static class CustomUpperCaseCharacterData implements CharacterData
    {
        @Override
        public String getErrorCode()
        {
            return "INSUFFICIENT_UPPERCASE";
        }

        @Override
        public String getCharacters()
        {
            return EnglishCharacterData.UpperCase.getCharacters() +
                   CyrillicCharacterData.UpperCase.getCharacters() +
                   CyrillicModernCharacterData.UpperCase.getCharacters() +
                   CzechCharacterData.UpperCase.getCharacters() +
                   GermanCharacterData.UpperCase.getCharacters() +
                   PolishCharacterData.UpperCase.getCharacters();
        }
    }

    protected static final CharacterData specialCharacters = new CharacterData()
    {
        @Override
        public String getErrorCode()
        {
            return "INSUFFICIENT_SPECIAL";
        }

        @Override
        public String getCharacters()
        {
            return "!\"#$%&()*+,-./:;<=>?@[\\]^_`{|}~";
        }
    };

    protected List<SequenceData> getSequenceData()
    {
        return List.of(EnglishSequenceData.Alphabetical,
                       EnglishSequenceData.Numerical,
                       EnglishSequenceData.USQwerty,
                       CyrillicSequenceData.Alphabetical,
                       CyrillicModernSequenceData.Alphabetical,
                       CzechSequenceData.Alphabetical,
                       GermanSequenceData.Alphabetical,
                       PolishSequenceData.Alphabetical);
    }

    public static class UnsupportedCharsetRule implements Rule
    {
        private static final char[] supportedAlphabeticChars = UnsupportedCharsetRule.supportedChars(true);
        private static final char[] allSupportedCharsChars = UnsupportedCharsetRule.supportedChars(false);

        public static final String ERROR_CODE = "UNSUPPORTED_CHARSET";

        @Override
        public RuleResult validate(final PasswordData passwordData)
        {
            final String text = passwordData.getPassword();

            final RuleResult result = new RuleResult();
            if (text.isEmpty())
                return result;

            int unsupportedChars = 0;
            int supportedNonAlphabeticChars = 0;
            for (char c : text.toCharArray())
            {
                if (Arrays.binarySearch(allSupportedCharsChars, c) < 0)
                    unsupportedChars++;
                else if (Arrays.binarySearch(supportedAlphabeticChars, c) < 0)
                    supportedNonAlphabeticChars++;
            }

            if (unsupportedChars > 0)
            {
                if (unsupportedChars + supportedNonAlphabeticChars == text.length())
                {
                    result.setValid(false);
                    result.addError(ERROR_CODE, Map.of());
                }

                result.setMetadata(new RuleResultMetadata(RuleResultMetadata.CountCategory.Illegal, unsupportedChars));
            }

            return result;
        }

        private static char[] supportedChars(boolean onlyAlphabetic)
        {
            char[] charArray = getChars(onlyAlphabetic);

            Set<Character> charactersSet = new HashSet<>();

            // filter out the duplicates
            for (int i = 0; i < charArray.length; i++)
                charactersSet.add(charArray[i]);

            char[] result = new char[charactersSet.size()];

            Iterator<Character> characterIterator = charactersSet.iterator();
            int i = 0;
            while (characterIterator.hasNext())
                result[i++] = characterIterator.next();

            Arrays.sort(result); // important for binary search
            return result;
        }

        private static char[] getChars(boolean onlyAlphabetic)
        {
            if (onlyAlphabetic)
                return (new CassandraPasswordValidator.CustomUpperCaseCharacterData().getCharacters() +
                        new CassandraPasswordValidator.CustomLowerCaseCharacterData().getCharacters()).toCharArray();
            else
                return (new CassandraPasswordValidator.CustomUpperCaseCharacterData().getCharacters() +
                        new CassandraPasswordValidator.CustomLowerCaseCharacterData().getCharacters() +
                        CassandraPasswordValidator.specialCharacters.getCharacters() +
                        "0123456789").toCharArray();
        }
    }

    @Override
    public DictionaryRule initializeDictionaryRule(CassandraPasswordConfiguration configuration)
    {
        if (configuration.dictionary == null)
            return null;

        try
        {
            RandomAccessFile raf = new RandomAccessFile(configuration.dictionary, "r");
            FileWordList fileWordList = new FileWordList(raf, true, 100);
            WordListDictionary wordListDictionary = new WordListDictionary(fileWordList);
            return new DictionaryRule(wordListDictionary);
        }
        catch (IllegalArgumentException ex)
        {
            // improve message a little bit
            if ("File is not sorted correctly for this comparator".equals(ex.getMessage()))
                throw new ConfigurationException("Dictionary file " + configuration.dictionary + " is not correctly " +
                                                 "sorted for case-sensitive comparator according to String's " +
                                                 "compareTo contract.");
            else
                throw new ConfigurationException(ex.getMessage());
        }
        catch (IOException ex)
        {
            throw new ConfigurationException(ex.getMessage());
        }
    }
}
