package com.airlock.iam.common.application.configuration.secretquestion;

import com.airlock.iam.common.domain.model.token.secretquestions.SecretQuestionsNormalization;
import com.airlock.iam.core.misc.persistency.token.TokenDataProvider;
import com.airlock.iam.core.misc.util.password.hash.PasswordHash;
import com.airlock.iam.core.misc.util.password.hash.SHA256Base64PasswordHash;
import com.airlock.iam.plugin.api.annotation.config.ConfigurationValidator;
import com.airlock.iam.plugin.api.annotation.config.Groups;
import com.airlock.iam.plugin.api.annotation.config.Plugin;
import com.airlock.iam.plugin.api.annotation.property.BooleanProperty;
import com.airlock.iam.plugin.api.annotation.property.EnumProperty;
import com.airlock.iam.plugin.api.annotation.property.GroupProperty;
import com.airlock.iam.plugin.api.annotation.property.IntegerProperty;
import com.airlock.iam.plugin.api.annotation.property.PluginProperty;
import com.airlock.iam.plugin.api.annotation.property.RegexProperty;
import com.airlock.iam.plugin.api.annotation.property.StringListProperty;
import com.airlock.iam.plugin.api.config.ConfigurationContext;
import com.airlock.iam.plugin.api.config.ConfigurationEnvironment;
import com.airlock.iam.plugin.api.config.Environment;
import com.airlock.iam.plugin.api.config.PluginConfiguration;
import com.airlock.iam.plugin.api.exception.ConfigurationException;
import com.airlock.iam.plugin.api.exception.PropertyConstraintViolationException;
import com.airlock.iam.plugin.api.valuereader.IntegerPropertyValueReader;
import com.airlock.iam.plugin.api.valuereader.StringListPropertyValueReader;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;

@Plugin(
   description = "Secret Questions are a common way to check if a user is allowed to reset his password. This plugin centrally configures the details concerning set-up of secret answers and how to use them for recovery. <p>The 'questions' are part of this configuration, represented as resource keys. Their translation &ndash; the question as displayed to the user &ndash; are in the string resource files, like other translations. </p><p>In the set-up or provisioning phase a user answers some of the predefined 'secret questions'. These secret answers are persisted as secret-answer tokens in the IAM persistency model, so they can be verified later.</p>"
)
@Groups(groupSequence = {"Basic Settings", "Advanced Settings"}, openGroups = "Basic Settings")
public class SecretQuestionsSettings implements GeneralSecretQuestionsSettings {
   @StringListProperty(
      description = "List of resource keys of the available questions. Each key represents one question. If you remove a question (resource key) from this list, all answers to that question become invalid. <p>Ensure that no new question with the same key is introduced later. Any user's answer to the previous question would not match the new question.</p><p>The keys must contain a period \".\" somewhere to avoid name clashes in the REST API.</p>",
      optional = false,
      exampleElements = {"secretquestions.question.example", "secretquestions.question.pet-name"}
   )
   @GroupProperty(groupIdentifier = "Basic Settings")
   private List<String> questionResourceKeys;
   private static final String QUESTION_RESOURCE_KEYS_FIELD = "questionResourceKeys";
   private static final int QUESTION_RESOURCE_KEY_MAX_LENGTH = 50;
   private static final Pattern QUESTION_RESOURCE_KEY_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9.-]*\\.[a-zA-Z0-9.-]*[a-zA-Z0-9]");
   @PluginProperty(
      description = "The provider for token data takes care of persisting the secret answers.",
      optional = false,
      globalPluginReference = "globalTokenDataProvider"
   )
   @GroupProperty(groupIdentifier = "Basic Settings")
   private TokenDataProvider tokenDataProvider;
   @PluginProperty(
      description = "It is recommended not to store the secret answers in plain text. The hash algorithm configured here is used to hash the answers.<p><b>NOTE:</b> Some password hashes, such as SHA 256 Password Hash or Scrypt Password Hash, produce binary output. If one of these is used, make sure the persistence layer supports binary data in the hash field and the corresponding persistence plugins (e.g. Database User Store or Ldap Connector) are configured to treat hash values as binary values.<br/>In case the persistence layer expects a string, encode the password hash by wrapping it with an encoder. To achieve this, use the Password Hash Configuration plugin and specify the hash function (such as Scrypt Password Hash) together with the desired encoder. We recommend using the Base64 Password Hash Encoder.</p>",
      optional = true,
      defaultValue = SHA256Base64PasswordHash.class
   )
   @GroupProperty(groupIdentifier = "Advanced Settings")
   private PasswordHash hashFunctionPlugin;
   @IntegerProperty(
      description = "Defines how many questions a user has to answer during the provisioning phase. If not enough questions are answered yet, the user must answer additional questions upon login and the user cannot use Secret Questions for recovery.",
      optional = true,
      minValue = 1,
      defaultValue = 2,
      exampleValues = {1, 2, 3}
   )
   @GroupProperty(groupIdentifier = "Basic Settings")
   private Integer requiredNumberOfProvisionedAnswers;
   private static final String REQUIRED_NUMBER_OF_PROVISIONED_ANSWERS_FIELD = "requiredNumberOfProvisionedAnswers";
   @IntegerProperty(
      description = "Defines the number of allowed failed attempts until a Secret answer token is blocked. When a question is answered correctly the counter on the specific answer-token will be reset. A blocked answer can be unblocked by an administrator.",
      optional = true,
      minValue = 1,
      defaultValue = 2,
      exampleValues = {2, 5, 10}
   )
   @GroupProperty(groupIdentifier = "Basic Settings")
   private int allowedNumberOfAttempts;
   @EnumProperty(
      description = "Normalization is a string-transformation applied to answers before they are persisted, and before they are verified. Therefore, an answer can be accepted even if it has minor differences to the provisioned answer. Currently, the following options exist: <ul> \t<li><b>OFF</b>:<br /> \t\tNo normalization. Provisioned and challenged answers must match exactly.</li> \t<li><b>TRIM</b>:<br /> \t\tRemoves whitespaces at the beginning and end of the answer string.</li> \t<li><b>TRIM_CASEINSENSITIVE</b>:<br /> \t\tDoes the same as <em>TRIM</em> and additionally converts all characters to lower case.</li> \t<li><b>TRIM_CASEINSENSITIVE_NOWHITESPACE</b>:<br /> \t\tDoes the same as <em>CASEINSENSITIVE_TRIM</em> and additionally removes all whitespace.</li> \t<li><b>TRIM_CASEINSENSITIVE_NOWHITESPACE_NOSPECIALCHARS</b>:<br /> \t\tDoes the same as <em>CASEINSENSITIVE_TRIM_NOWHITESPACE</em> and additionally removes all      non-word characters (all characters except letters, digits and the underscore).</li> </ul>",
      optional = true,
      defaultValue = "TRIM_CASEINSENSITIVE"
   )
   @GroupProperty(groupIdentifier = "Basic Settings")
   private SecretQuestionsNormalization normalization;
   @IntegerProperty(description = "Defines the mininum length of an answer.", optional = true, minValue = 1, defaultValue = 2, exampleValues = {2, 5, 10})
   @GroupProperty(groupIdentifier = "Basic Settings")
   private int minLength;
   private static final String MIN_LENGTH_FIELD = "minLength";
   @IntegerProperty(description = "Defines the maximum length of an answer.", optional = true, minValue = 1, defaultValue = 100, exampleValues = {35, 100})
   @GroupProperty(groupIdentifier = "Basic Settings")
   private int maxLength;
   private static final String MAX_LENGTH_FIELD = "maxLength";
   @IntegerProperty(
      description = "Verifying if a user knows his provisioned secret answers involves two steps:<ol><li>Display some of the questions to the user (challenge).</li><li>Check the user's answers to these questions.</li></ol>This property defines the number of questions which are selected to challenge the user. If this field is empty, the challenge will show the \"Required Number Of Provisioned Answers\"",
      optional = true,
      minValue = 1,
      exampleValues = 2
   )
   @GroupProperty(groupIdentifier = "Advanced Settings")
   private Integer numberOfChallengeQuestions;
   private static final String NUMBER_OF_CHALLENGE_QUESTIONS_FIELD = "numberOfChallengeQuestions";
   @IntegerProperty(
      description = "Defines how many of the questions from the challenge must be answered. A question is only unanswered if the answer string is empty. <br>Example: Show two questions to the user, but only one has to be answered.",
      optional = true,
      minValue = 1,
      exampleValues = 1
   )
   @GroupProperty(groupIdentifier = "Advanced Settings")
   private Integer numberOfChallengeAnswers;
   private static final String NUMBER_OF_CHALLENGE_ANSWERS_FIELD = "numberOfChallengeAnswers";
   @RegexProperty(description = "Regex pattern to check the given answer (after normalization).", optional = true)
   @GroupProperty(groupIdentifier = "Advanced Settings")
   private Pattern answerRegexPattern;
   @BooleanProperty(description = "Allow admins to check answers of a user in the adminapp.", defaultValue = "false", optional = true)
   @GroupProperty(groupIdentifier = "Advanced Settings")
   private boolean allowAdminAnswerCheck;
   @BooleanProperty(description = "Forbid the same answer for more than one question per user.", defaultValue = "true", optional = true)
   @GroupProperty(groupIdentifier = "Advanced Settings")
   private boolean duplicateAnswersForbidden;
   @BooleanProperty(
      description = "<p>If enabled, answers containing special characters stored by IAM earlier than 6.3 are still accepted. This option does not have to be activated if all answers were set using IAM 6.3 or later or if all answers were set via webservices or REST.</p><p>To support legacy answers, those with special characters are additionally checked using their legacy encoding in latin1.</p>",
      optional = true,
      defaultValue = "false"
   )
   @GroupProperty(groupIdentifier = "Advanced Settings")
   private boolean checkUsingLatin1Encoding;

   @Override
   public int getRequiredNumberOfProvisionedAnswers() {
      return this.requiredNumberOfProvisionedAnswers;
   }

   @Deprecated
   @Override
   public Optional<Integer> getNumberOfChallengeQuestions() {
      return Optional.ofNullable(this.numberOfChallengeQuestions);
   }

   @Deprecated
   @Override
   public Optional<Integer> getNumberOfChallengeAnswers() {
      return Optional.ofNullable(this.numberOfChallengeAnswers);
   }

   @Override
   public Optional<Pattern> getAnswerRegexPattern() {
      return Optional.ofNullable(this.answerRegexPattern);
   }

   @Override
   public boolean areDuplicateAnswersForbidden() {
      return this.duplicateAnswersForbidden;
   }

   @Override
   public List<String> getQuestionResourceKeys() {
      return this.questionResourceKeys;
   }

   @Override
   public TokenDataProvider getTokenDataProvider() {
      return this.tokenDataProvider;
   }

   @Override
   public PasswordHash getHashFunctionPlugin() {
      return this.hashFunctionPlugin;
   }

   @Override
   public int getAllowedNumberOfAttempts() {
      return this.allowedNumberOfAttempts;
   }

   @Override
   public SecretQuestionsNormalization getNormalization() {
      return this.normalization;
   }

   @Override
   public int getMinLength() {
      return this.minLength;
   }

   @Override
   public int getMaxLength() {
      return this.maxLength;
   }

   public boolean isAllowAdminAnswerCheck() {
      return this.allowAdminAnswerCheck;
   }

   @Override
   public boolean isCheckUsingLatin1Encoding() {
      return this.checkUsingLatin1Encoding;
   }

   @ConfigurationValidator
   public static void validateConfiguration(PluginConfiguration config, ConfigurationEnvironment env, ConfigurationContext context, Environment valEnv) throws ConfigurationException {
      List<String> resourceKeys = StringListPropertyValueReader.effectiveStringListConfigValue(config, "questionResourceKeys", env, context);
      Integer requiredNumberOfAnswers = IntegerPropertyValueReader.effectiveIntegerConfigValue(config, "requiredNumberOfProvisionedAnswers", env, context);
      Integer challengeQuestions = IntegerPropertyValueReader.effectiveIntegerConfigValue(config, "numberOfChallengeQuestions", env, context);
      Integer challengeAnswers = IntegerPropertyValueReader.effectiveIntegerConfigValue(config, "numberOfChallengeAnswers", env, context);
      Integer minLength = IntegerPropertyValueReader.effectiveIntegerConfigValue(config, "minLength", env, context);
      Integer maxLength = IntegerPropertyValueReader.effectiveIntegerConfigValue(config, "maxLength", env, context);
      if (requiredNumberOfAnswers > resourceKeys.size()) {
         throw new PropertyConstraintViolationException(
            "requiredNumberOfProvisionedAnswers", config, env, context, "The value is too high. There are fewer question resource keys configured."
         );
      }

      if (challengeQuestions != null && challengeQuestions > requiredNumberOfAnswers) {
         throw new PropertyConstraintViolationException(
            "numberOfChallengeQuestions", config, env, context, "The value is too high. There are fewer required provisioned answers configured."
         );
      }

      if (challengeQuestions != null && challengeAnswers != null && challengeAnswers > challengeQuestions) {
         throw new PropertyConstraintViolationException(
            "numberOfChallengeAnswers", config, env, context, "The value is too high. There are fewer challenge questions than challenge answers."
         );
      }

      if (minLength >= maxLength) {
         throw new PropertyConstraintViolationException("maxLength", config, env, context, "The value is too small. Max Length must be bigger than Min Length.");
      }

      for (String resourceKey : resourceKeys) {
         if (!QUESTION_RESOURCE_KEY_PATTERN.matcher(resourceKey).matches()) {
            throw new PropertyConstraintViolationException(
               "questionResourceKeys",
               config,
               env,
               context,
               "The question resource keys must contain only standard upper- and lower case letters, numbers, the '-' character and must contain a '.'."
            );
         }

         if (StringUtils.length(resourceKey) > 50) {
            throw new PropertyConstraintViolationException(
               "questionResourceKeys", config, env, context, "The question resource keys must not be longer than 50 characters."
            );
         }
      }
   }

   @SuppressFBWarnings(justification = "generated code")
   public static SecretQuestionsSettings.SecretQuestionsSettingsBuilder builder() {
      return new SecretQuestionsSettings.SecretQuestionsSettingsBuilder();
   }

   @SuppressFBWarnings(justification = "generated code")
   public SecretQuestionsSettings() {
   }

   @SuppressFBWarnings(justification = "generated code")
   public SecretQuestionsSettings(
      List<String> questionResourceKeys,
      TokenDataProvider tokenDataProvider,
      PasswordHash hashFunctionPlugin,
      Integer requiredNumberOfProvisionedAnswers,
      int allowedNumberOfAttempts,
      SecretQuestionsNormalization normalization,
      int minLength,
      int maxLength,
      Integer numberOfChallengeQuestions,
      Integer numberOfChallengeAnswers,
      Pattern answerRegexPattern,
      boolean allowAdminAnswerCheck,
      boolean duplicateAnswersForbidden,
      boolean checkUsingLatin1Encoding
   ) {
      this.questionResourceKeys = questionResourceKeys;
      this.tokenDataProvider = tokenDataProvider;
      this.hashFunctionPlugin = hashFunctionPlugin;
      this.requiredNumberOfProvisionedAnswers = requiredNumberOfProvisionedAnswers;
      this.allowedNumberOfAttempts = allowedNumberOfAttempts;
      this.normalization = normalization;
      this.minLength = minLength;
      this.maxLength = maxLength;
      this.numberOfChallengeQuestions = numberOfChallengeQuestions;
      this.numberOfChallengeAnswers = numberOfChallengeAnswers;
      this.answerRegexPattern = answerRegexPattern;
      this.allowAdminAnswerCheck = allowAdminAnswerCheck;
      this.duplicateAnswersForbidden = duplicateAnswersForbidden;
      this.checkUsingLatin1Encoding = checkUsingLatin1Encoding;
   }

   @SuppressFBWarnings(justification = "generated code")
   public static class SecretQuestionsSettingsBuilder {
      @SuppressFBWarnings(justification = "generated code")
      private ArrayList<String> questionResourceKeys;
      @SuppressFBWarnings(justification = "generated code")
      private TokenDataProvider tokenDataProvider;
      @SuppressFBWarnings(justification = "generated code")
      private PasswordHash hashFunctionPlugin;
      @SuppressFBWarnings(justification = "generated code")
      private Integer requiredNumberOfProvisionedAnswers;
      @SuppressFBWarnings(justification = "generated code")
      private int allowedNumberOfAttempts;
      @SuppressFBWarnings(justification = "generated code")
      private SecretQuestionsNormalization normalization;
      @SuppressFBWarnings(justification = "generated code")
      private int minLength;
      @SuppressFBWarnings(justification = "generated code")
      private int maxLength;
      @SuppressFBWarnings(justification = "generated code")
      private Integer numberOfChallengeQuestions;
      @SuppressFBWarnings(justification = "generated code")
      private Integer numberOfChallengeAnswers;
      @SuppressFBWarnings(justification = "generated code")
      private Pattern answerRegexPattern;
      @SuppressFBWarnings(justification = "generated code")
      private boolean allowAdminAnswerCheck;
      @SuppressFBWarnings(justification = "generated code")
      private boolean duplicateAnswersForbidden;
      @SuppressFBWarnings(justification = "generated code")
      private boolean checkUsingLatin1Encoding;

      @SuppressFBWarnings(justification = "generated code")
      SecretQuestionsSettingsBuilder() {
      }

      @SuppressFBWarnings(justification = "generated code")
      public SecretQuestionsSettings.SecretQuestionsSettingsBuilder questionResourceKey(String questionResourceKey) {
         if (this.questionResourceKeys == null) {
            this.questionResourceKeys = new ArrayList<>();
         }

         this.questionResourceKeys.add(questionResourceKey);
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public SecretQuestionsSettings.SecretQuestionsSettingsBuilder questionResourceKeys(Collection<? extends String> questionResourceKeys) {
         if (questionResourceKeys == null) {
            throw new NullPointerException("questionResourceKeys cannot be null");
         }

         if (this.questionResourceKeys == null) {
            this.questionResourceKeys = new ArrayList<>();
         }

         this.questionResourceKeys.addAll(questionResourceKeys);
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public SecretQuestionsSettings.SecretQuestionsSettingsBuilder clearQuestionResourceKeys() {
         if (this.questionResourceKeys != null) {
            this.questionResourceKeys.clear();
         }

         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public SecretQuestionsSettings.SecretQuestionsSettingsBuilder tokenDataProvider(TokenDataProvider tokenDataProvider) {
         this.tokenDataProvider = tokenDataProvider;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public SecretQuestionsSettings.SecretQuestionsSettingsBuilder hashFunctionPlugin(PasswordHash hashFunctionPlugin) {
         this.hashFunctionPlugin = hashFunctionPlugin;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public SecretQuestionsSettings.SecretQuestionsSettingsBuilder requiredNumberOfProvisionedAnswers(Integer requiredNumberOfProvisionedAnswers) {
         this.requiredNumberOfProvisionedAnswers = requiredNumberOfProvisionedAnswers;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public SecretQuestionsSettings.SecretQuestionsSettingsBuilder allowedNumberOfAttempts(int allowedNumberOfAttempts) {
         this.allowedNumberOfAttempts = allowedNumberOfAttempts;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public SecretQuestionsSettings.SecretQuestionsSettingsBuilder normalization(SecretQuestionsNormalization normalization) {
         this.normalization = normalization;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public SecretQuestionsSettings.SecretQuestionsSettingsBuilder minLength(int minLength) {
         this.minLength = minLength;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public SecretQuestionsSettings.SecretQuestionsSettingsBuilder maxLength(int maxLength) {
         this.maxLength = maxLength;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public SecretQuestionsSettings.SecretQuestionsSettingsBuilder numberOfChallengeQuestions(Integer numberOfChallengeQuestions) {
         this.numberOfChallengeQuestions = numberOfChallengeQuestions;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public SecretQuestionsSettings.SecretQuestionsSettingsBuilder numberOfChallengeAnswers(Integer numberOfChallengeAnswers) {
         this.numberOfChallengeAnswers = numberOfChallengeAnswers;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public SecretQuestionsSettings.SecretQuestionsSettingsBuilder answerRegexPattern(Pattern answerRegexPattern) {
         this.answerRegexPattern = answerRegexPattern;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public SecretQuestionsSettings.SecretQuestionsSettingsBuilder allowAdminAnswerCheck(boolean allowAdminAnswerCheck) {
         this.allowAdminAnswerCheck = allowAdminAnswerCheck;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public SecretQuestionsSettings.SecretQuestionsSettingsBuilder duplicateAnswersForbidden(boolean duplicateAnswersForbidden) {
         this.duplicateAnswersForbidden = duplicateAnswersForbidden;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public SecretQuestionsSettings.SecretQuestionsSettingsBuilder checkUsingLatin1Encoding(boolean checkUsingLatin1Encoding) {
         this.checkUsingLatin1Encoding = checkUsingLatin1Encoding;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public SecretQuestionsSettings build() {
         return new SecretQuestionsSettings(
            switch (this.questionResourceKeys == null ? 0 : this.questionResourceKeys.size()) {
               case 0 -> Collections.emptyList();
               case 1 -> Collections.singletonList(this.questionResourceKeys.get(0));
               default -> Collections.unmodifiableList(new ArrayList<>(this.questionResourceKeys));
            },
            this.tokenDataProvider,
            this.hashFunctionPlugin,
            this.requiredNumberOfProvisionedAnswers,
            this.allowedNumberOfAttempts,
            this.normalization,
            this.minLength,
            this.maxLength,
            this.numberOfChallengeQuestions,
            this.numberOfChallengeAnswers,
            this.answerRegexPattern,
            this.allowAdminAnswerCheck,
            this.duplicateAnswersForbidden,
            this.checkUsingLatin1Encoding
         );
      }

      @SuppressFBWarnings(justification = "generated code")
      @Override
      public String toString() {
         return "SecretQuestionsSettings.SecretQuestionsSettingsBuilder(questionResourceKeys="
            + this.questionResourceKeys
            + ", tokenDataProvider="
            + this.tokenDataProvider
            + ", hashFunctionPlugin="
            + this.hashFunctionPlugin
            + ", requiredNumberOfProvisionedAnswers="
            + this.requiredNumberOfProvisionedAnswers
            + ", allowedNumberOfAttempts="
            + this.allowedNumberOfAttempts
            + ", normalization="
            + this.normalization
            + ", minLength="
            + this.minLength
            + ", maxLength="
            + this.maxLength
            + ", numberOfChallengeQuestions="
            + this.numberOfChallengeQuestions
            + ", numberOfChallengeAnswers="
            + this.numberOfChallengeAnswers
            + ", answerRegexPattern="
            + this.answerRegexPattern
            + ", allowAdminAnswerCheck="
            + this.allowAdminAnswerCheck
            + ", duplicateAnswersForbidden="
            + this.duplicateAnswersForbidden
            + ", checkUsingLatin1Encoding="
            + this.checkUsingLatin1Encoding
            + ")";
      }
   }
}
