# -*- coding: utf-8 -*-

# Copyright (c) 2011 - 2022 Detlev Offenbach <detlev@die-offenbachs.de>
#

"""
Module implementing a checker for password strength.
"""

import re


class PasswordChecker:
    """
    Class implementing a checker for password strength.
    """
    Complexity_VeryWeak = 0
    Complexity_Weak = 1
    Complexity_Good = 2
    Complexity_Strong = 3
    Complexity_VeryStrong = 4
    
    Status_Failed = 0
    Status_Passed = 1
    Status_Exceeded = 2
    
    def __init__(self):
        """
        Constructor
        """
        self.score = {
            "count": 0,
            "adjusted": 0,
            "beforeRedundancy": 0
        }
        
        # complexity index
        self.complexity = {
            "limits": [20, 50, 60, 80, 100],
            "value": self.Complexity_VeryWeak
        }
        
        # check categories follow
        
        # length of the password
        self.passwordLength = {
            "count": 0,
            "minimum": 6,
            "status": self.Status_Failed,
            "rating": 0,
            "factor": 0.5,      # per character bonus
            "bonus": 10,        # minimum reached? Get a bonus.
            "penalty": -20,     # if we stay under minimum, we get punished
        }
        
        # recommended password length
        self.recommendedPasswordLength = {
            "count": 0,
            "minimum": 8,
            "status": self.Status_Failed,
            "rating": 0,
            "factor": 1.2,
            "bonus": 10,
            "penalty": -10,
        }
        
        # Basic requirements are:
        # 1) Password Length
        # 2) Uppercase letter use
        # 3) Lowercase letter use
        # 4) Numeric character use
        # 5) Symbol use
        self.basicRequirements = {
            "count": 0,
            "minimum": 3,       # have to be matched to get the bonus
            "status": self.Status_Failed,
            "rating": 0,
            "factor": 1.0,
            "bonus": 10,
            "penalty": -10,
        }
        
        # how much redundancy is permitted, if the password is
        # long enough. we will skip the redudancy penalty if this
        # number is not exceeded (meaning redundancy < this number)
        self.redundancy = {
            "value": 1,         # 1 means, not double characters,
                                # default to start
            "permitted": 2.0,   # 2 means, in average every character
                                # can occur twice
            "status": self.Status_Failed,
            "rating": 0,
        }
        
        # number of uppercase letters, such as A-Z
        self.uppercaseLetters = {
            "count": 0,
            "minimum": 1,
            "status": self.Status_Failed,
            "rating": 0,
            "factor": 0.0,
            "bonus": 10,
            "penalty": -10,
        }
        
        # number of lowercase letters, such as a-z
        self.lowercaseLetters = {
            "count": 0,
            "minimum": 1,
            "status": self.Status_Failed,
            "rating": 0,
            "factor": 0.0,
            "bonus": 10,
            "penalty": -10,
        }
        
        # number of numeric characters
        self.numerics = {
            "count": 0,
            "minimum": 1,
            "status": self.Status_Failed,
            "rating": 0,
            "factor": 0.0,
            "bonus": 10,
            "penalty": -10,
        }
        
        # number of symbol characters
        self.symbols = {
            "count": 0,
            "minimum": 1,
            "status": self.Status_Failed,
            "rating": 0,
            "factor": 0.0,
            "bonus": 10,
            "penalty": -10,
        }
        
        # number of dedicated symbols in the middle
        self.middleSymbols = {
            "count": 0,
            "minimum": 1,
            "status": self.Status_Failed,
            "rating": 0,
            "factor": 0.0,
            "bonus": 10,
            "penalty": -10,
        }
        
        # number of dedicated numbers in the middle
        self.middleNumerics = {
            "count": 0,
            "minimum": 1,
            "status": self.Status_Failed,
            "rating": 0,
            "factor": 0.0,
            "bonus": 10,
            "penalty": -10,
        }
        
        # how many sequential characters should be checked
        # such as "abc" or "MNO" to be not part of the password
        self.sequentialLetters = {
            "data": "abcdefghijklmnopqrstuvwxyz",
            "length": 3,
            
            "count": 0,
            "status": self.Status_Failed,
            "rating": 0,
            "factor": -1.0,
            "bonus": 0,
            "penalty": -10,
        }
        
        # how many sequential characters should be checked
        # such as "123" to be not part of the password
        self.sequentialNumerics = {
            "data": "0123456789",
            "length": 3,
            
            "count": 0,
            "status": self.Status_Failed,
            "rating": 0,
            "factor": -1.0,
            "bonus": 0,
            "penalty": -10,
        }
        
        # keyboard patterns to check, typical sequences from your
        # keyboard
        self.keyboardPatterns = {
            # German and English keyboard text
            "data": [
                "qwertzuiop", "asdfghjkl", "yxcvbnm", "!\"§$%&/()=",    # de
                "1234567890",   # de numbers
                "qaywsxedcrfvtgbzhnujmik,ol.pö-üä+#",   # de up-down
                
                "qwertyuiop", "asdfghjkl", "zyxcvbnm", "!@#$%^&*()_",   # en
                "1234567890",   # en numbers
                "qazwsxedcrfvtgbyhnujmik,ol.p;/[']\\",  # en up-down
            ],
            "length": 4,    # how long is the pattern to check and blame for?
            
            "count": 0,     # how many of these pattern can be found
            "status": self.Status_Failed,
            "rating": 0,
            "factor": -1.0,     # each occurrence is punished with that factor
            "bonus": 0,
            "penalty": -10,
        }
        
        # check for repeated sequences, like in catcat
        self.repeatedSequences = {
            "length": 3,
            
            "count": 0,
            "status": self.Status_Failed,
            "rating": 0,
            "factor": 0.0,
            "bonus": 0,
            "penalty": -10,
        }
        
        # check for repeated mirrored sequences, like in tactac
        self.mirroredSequences = {
            "length": 3,
            
            "count": 0,
            "status": self.Status_Failed,
            "rating": 0,
            "factor": 0.0,
            "bonus": 0,
            "penalty": -10,
        }
        
        self.uppercaseRe = re.compile("[A-Z]")
        self.lowercaseRe = re.compile("[a-z]")
        self.numberRe = re.compile("[0-9]")
        self.symbolRe = re.compile("[^a-zA-Z0-9]")
    
    def __strReverse(self, string):
        """
        Private method to reverse a string.
        
        @param string string to be reversed (string)
        @return reversed string (string)
        """
        return "".join(reversed(string))
    
    def __determineStatus(self, value):
        """
        Private method to determine the status.
        
        @param value value to check (integer)
        @return status (Status_Failed, Status_Passed, Status_Exceeded)
        """
        if value == 0:
            return self.Status_Passed
        elif value > 0:
            return self.Status_Exceeded
        else:
            return self.Status_Failed
    
    def __determineBinaryStatus(self, value):
        """
        Private method to determine a binary status.
        
        @param value value to check (integer)
        @return status (Status_Failed, Status_Passed)
        """
        if value == 0:
            return self.Status_Passed
        else:
            return self.Status_Failed
    
    def checkPassword(self, password):
        """
        Public method to check a given password.
        
        @param password password to be checked (string)
        @return indication for the password strength (Complexity_VeryWeak,
            Complexity_Weak, Complexity_Good, Complexity_Strong,
            Complexity_VeryStrong)
        """
        # how long is the password?
        self.passwordLength["count"] = len(password)
        self.recommendedPasswordLength["count"] = len(password)
        
        # Loop through password to check for Symbol, Numeric, Lowercase
        # and Uppercase pattern matches
        for index in range(len(password)):
            if self.uppercaseRe.match(password[index]):
                self.uppercaseLetters["count"] += 1
            elif self.lowercaseRe.match(password[index]):
                self.lowercaseLetters["count"] += 1
            elif self.numberRe.match(password[index]):
                if index > 0 and index < len(password) - 1:
                    self.middleNumerics["count"] += 1
                self.numerics["count"] += 1
            elif self.symbolRe.match(password[index]):
                if index > 0 and index < len(password) - 1:
                    self.middleSymbols["count"] += 1
                self.symbols["count"] += 1
        
        # check the variance of symbols or better the redundancy
        # makes only sense for at least two characters
        if len(password) > 1:
            uniqueCharacters = []
            for index1 in range(len(password)):
                found = False
                for index2 in range(index1 + 1, len(password)):
                    if password[index1] == password[index2]:
                        found = True
                        break
                if not found:
                    uniqueCharacters.append(password[index1])
            
            # calculate a redundancy number
            self.redundancy["value"] = len(password) / len(uniqueCharacters)
        
        # Check for sequential alpha string patterns (forward and reverse)
        # but only, if the string has already a length to check for, does
        # not make sense to check the password "ab" for the sequential data
        # "abc"
        lowercasedPassword = password.lower()
        
        if self.passwordLength["count"] >= self.sequentialLetters["length"]:
            for index in range(len(self.sequentialLetters["data"]) -
                               self.sequentialLetters["length"] + 1):
                fwd = self.sequentialLetters["data"][
                    index:index + self.sequentialLetters["length"]]
                rev = self.__strReverse(fwd)
                if lowercasedPassword.find(fwd) != -1:
                    self.sequentialLetters["count"] += 1
                if lowercasedPassword.find(rev) != -1:
                    self.sequentialLetters["count"] += 1
        
        # Check for sequential numeric string patterns (forward and reverse)
        if self.passwordLength["count"] >= self.sequentialNumerics["length"]:
            for index in range(len(self.sequentialNumerics["data"]) -
                               self.sequentialNumerics["length"] + 1):
                fwd = self.sequentialNumerics["data"][
                    index:index + self.sequentialNumerics["length"]]
                rev = self.__strReverse(fwd)
                if lowercasedPassword.find(fwd) != -1:
                    self.sequentialNumerics["count"] += 1
                if lowercasedPassword.find(rev) != -1:
                    self.sequentialNumerics["count"] += 1
        
        # Check common keyboard patterns
        patternsMatched = []
        if self.passwordLength["count"] >= self.keyboardPatterns["length"]:
            for pattern in self.keyboardPatterns["data"]:
                for index in range(
                        len(pattern) - self.keyboardPatterns["length"] + 1):
                    fwd = pattern[index:index +
                                  self.keyboardPatterns["length"]]
                    rev = self.__strReverse(fwd)
                    if (
                        lowercasedPassword.find(fwd) != -1 and
                        fwd not in patternsMatched
                    ):
                        self.keyboardPatterns["count"] += 1
                        patternsMatched.append(fwd)
                    if (
                        lowercasedPassword.find(rev) != -1 and
                        fwd not in patternsMatched
                    ):
                        self.keyboardPatterns["count"] += 1
                        patternsMatched.append(rev)
        
        # Try to find repeated sequences of characters.
        if self.passwordLength["count"] >= self.repeatedSequences["length"]:
            for index in range(len(lowercasedPassword) -
                               self.repeatedSequences["length"] + 1):
                fwd = lowercasedPassword[
                    index:index + self.repeatedSequences["length"]]
                if lowercasedPassword.find(
                   fwd, index + self.repeatedSequences["length"]) != -1:
                    self.repeatedSequences["count"] += 1
        
        # Try to find mirrored sequences of characters.
        if self.passwordLength["count"] >= self.mirroredSequences["length"]:
            for index in range(len(lowercasedPassword) -
                               self.mirroredSequences["length"] + 1):
                fwd = lowercasedPassword[
                    index:index + self.mirroredSequences["length"]]
                rev = self.__strReverse(fwd)
                if lowercasedPassword.find(
                   fwd, index + self.mirroredSequences["length"]) != -1:
                    self.mirroredSequences["count"] += 1
        
        # Initial score based on length
        self.score["count"] = (
            self.passwordLength["count"] * self.passwordLength["factor"]
        )
        
        # passwordLength
        # credit additional length or punish "under" length
        if self.passwordLength["count"] >= self.passwordLength["minimum"]:
            # credit additional characters over minimum
            self.passwordLength["rating"] = (
                self.passwordLength["bonus"] +
                (self.passwordLength["count"] -
                 self.passwordLength["minimum"]) *
                self.passwordLength["factor"]
            )
        else:
            self.passwordLength["rating"] = self.passwordLength["penalty"]
        self.score["count"] += self.passwordLength["rating"]
        
        # recommendedPasswordLength
        # Credit reaching the recommended password length or put a
        # penalty on it
        if (
            self.passwordLength["count"] >=
            self.recommendedPasswordLength["minimum"]
        ):
            self.recommendedPasswordLength["rating"] = (
                self.recommendedPasswordLength["bonus"] +
                (self.passwordLength["count"] -
                 self.recommendedPasswordLength["minimum"]) *
                self.recommendedPasswordLength["factor"]
            )
        else:
            self.recommendedPasswordLength["rating"] = (
                self.recommendedPasswordLength["penalty"]
            )
        self.score["count"] += self.recommendedPasswordLength["rating"]
        
        # lowercaseLetters
        # Honor or punish the lowercase letter use
        if self.lowercaseLetters["count"] > 0:
            self.lowercaseLetters["rating"] = (
                self.lowercaseLetters["bonus"] +
                self.lowercaseLetters["count"] *
                self.lowercaseLetters["factor"]
            )
        else:
            self.lowercaseLetters["rating"] = self.lowercaseLetters["penalty"]
        self.score["count"] += self.lowercaseLetters["rating"]
        
        # uppercaseLetters
        # Honor or punish the lowercase letter use
        if self.uppercaseLetters["count"] > 0:
            self.uppercaseLetters["rating"] = (
                self.uppercaseLetters["bonus"] +
                self.uppercaseLetters["count"] *
                self.uppercaseLetters["factor"]
            )
        else:
            self.uppercaseLetters["rating"] = self.uppercaseLetters["penalty"]
        self.score["count"] += self.uppercaseLetters["rating"]
        
        # numerics
        # Honor or punish the numerics use
        if self.numerics["count"] > 0:
            self.numerics["rating"] = (
                self.numerics["bonus"] +
                self.numerics["count"] * self.numerics["factor"]
            )
        else:
            self.numerics["rating"] = self.numerics["penalty"]
        self.score["count"] += self.numerics["rating"]
        
        # symbols
        # Honor or punish the symbols use
        if self.symbols["count"] > 0:
            self.symbols["rating"] = (
                self.symbols["bonus"] +
                self.symbols["count"] * self.symbols["factor"]
            )
        else:
            self.symbols["rating"] = self.symbols["penalty"]
        self.score["count"] += self.symbols["rating"]
        
        # middleSymbols
        # Honor or punish the middle symbols use
        if self.middleSymbols["count"] > 0:
            self.middleSymbols["rating"] = (
                self.middleSymbols["bonus"] +
                self.middleSymbols["count"] * self.middleSymbols["factor"]
            )
        else:
            self.middleSymbols["rating"] = self.middleSymbols["penalty"]
        self.score["count"] += self.middleSymbols["rating"]
        
        # middleNumerics
        # Honor or punish the middle numerics use
        if self.middleNumerics["count"] > 0:
            self.middleNumerics["rating"] = (
                self.middleNumerics["bonus"] +
                self.middleNumerics["count"] * self.middleNumerics["factor"]
            )
        else:
            self.middleNumerics["rating"] = self.middleNumerics["penalty"]
        self.score["count"] += self.middleNumerics["rating"]
        
        # sequentialLetters
        # Honor or punish the sequential letter use
        if self.sequentialLetters["count"] == 0:
            self.sequentialLetters["rating"] = (
                self.sequentialLetters["bonus"] +
                self.sequentialLetters["count"] *
                self.sequentialLetters["factor"]
            )
        else:
            self.sequentialLetters["rating"] = (
                self.sequentialLetters["penalty"]
            )
        self.score["count"] += self.sequentialLetters["rating"]
        
        # sequentialNumerics
        # Honor or punish the sequential numerics use
        if self.sequentialNumerics["count"] == 0:
            self.sequentialNumerics["rating"] = (
                self.sequentialNumerics["bonus"] +
                self.sequentialNumerics["count"] *
                self.sequentialNumerics["factor"]
            )
        else:
            self.sequentialNumerics["rating"] = (
                self.sequentialNumerics["penalty"]
            )
        self.score["count"] += self.sequentialNumerics["rating"]
        
        # keyboardPatterns
        # Honor or punish the keyboard patterns use
        if self.keyboardPatterns["count"] == 0:
            self.keyboardPatterns["rating"] = (
                self.keyboardPatterns["bonus"] +
                self.keyboardPatterns["count"] *
                self.keyboardPatterns["factor"]
            )
        else:
            self.keyboardPatterns["rating"] = self.keyboardPatterns["penalty"]
        self.score["count"] += self.keyboardPatterns["rating"]
        
        # Count our basicRequirements and set the status
        self.basicRequirements["count"] = 0
        
        # password length
        self.passwordLength["status"] = self.__determineStatus(
            self.passwordLength["count"] - self.passwordLength["minimum"])
        if self.passwordLength["status"] != self.Status_Failed:
            # requirement met
            self.basicRequirements["count"] += 1
        
        # uppercase letters
        self.uppercaseLetters["status"] = self.__determineStatus(
            self.uppercaseLetters["count"] - self.uppercaseLetters["minimum"])
        if self.uppercaseLetters["status"] != self.Status_Failed:
            # requirement met
            self.basicRequirements["count"] += 1
        
        # lowercase letters
        self.lowercaseLetters["status"] = self.__determineStatus(
            self.lowercaseLetters["count"] - self.lowercaseLetters["minimum"])
        if self.lowercaseLetters["status"] != self.Status_Failed:
            # requirement met
            self.basicRequirements["count"] += 1
        
        # numerics
        self.numerics["status"] = self.__determineStatus(
            self.numerics["count"] - self.numerics["minimum"])
        if self.numerics["status"] != self.Status_Failed:
            # requirement met
            self.basicRequirements["count"] += 1
        
        # symbols
        self.symbols["status"] = self.__determineStatus(
            self.symbols["count"] - self.symbols["minimum"])
        if self.symbols["status"] != self.Status_Failed:
            # requirement met
            self.basicRequirements["count"] += 1
        
        # judge the requirement status
        self.basicRequirements["status"] = self.__determineStatus(
            self.basicRequirements["count"] -
            self.basicRequirements["minimum"])
        if self.basicRequirements["status"] != self.Status_Failed:
            self.basicRequirements["rating"] = (
                self.basicRequirements["bonus"] +
                self.basicRequirements["factor"] *
                self.basicRequirements["count"]
            )
        else:
            self.basicRequirements["rating"] = (
                self.basicRequirements["penalty"]
            )
        self.score["count"] += self.basicRequirements["rating"]
        
        # beyond basic requirements
        self.recommendedPasswordLength["status"] = self.__determineStatus(
            self.recommendedPasswordLength["count"] -
            self.recommendedPasswordLength["minimum"])
        self.middleNumerics["status"] = self.__determineStatus(
            self.middleNumerics["count"] -
            self.middleNumerics["minimum"])
        self.middleSymbols["status"] = self.__determineStatus(
            self.middleSymbols["count"] -
            self.middleSymbols["minimum"])
        self.sequentialLetters["status"] = self.__determineBinaryStatus(
            self.sequentialLetters["count"])
        self.sequentialNumerics["status"] = self.__determineBinaryStatus(
            self.sequentialNumerics["count"])
        self.keyboardPatterns["status"] = self.__determineBinaryStatus(
            self.keyboardPatterns["count"])
        self.repeatedSequences["status"] = self.__determineBinaryStatus(
            self.repeatedSequences["count"])
        self.mirroredSequences["status"] = self.__determineBinaryStatus(
            self.mirroredSequences["count"])
        
        # we apply them only, if the length is not awesome
        if self.recommendedPasswordLength["status"] != self.Status_Exceeded:
            # repeatedSequences
            # Honor or punish the use of repeated sequences
            if self.repeatedSequences["count"] == 0:
                self.repeatedSequences["rating"] = (
                    self.repeatedSequences["bonus"]
                )
            else:
                self.repeatedSequences["rating"] = (
                    self.repeatedSequences["penalty"] +
                    self.repeatedSequences["count"] *
                    self.repeatedSequences["factor"]
                )
            
            # mirroredSequences
            # Honor or punish the use of mirrored sequences
            if self.mirroredSequences["count"] == 0:
                self.mirroredSequences["rating"] = (
                    self.mirroredSequences["bonus"]
                )
            else:
                self.mirroredSequences["rating"] = (
                    self.mirroredSequences["penalty"] +
                    self.mirroredSequences["count"] *
                    self.mirroredSequences["factor"]
                )
        
        # save value before redundancy
        self.score["beforeRedundancy"] = self.score["count"]
        
        # apply the redundancy
        # is the password length requirement fulfilled?
        if (
            self.recommendedPasswordLength[
                "status"] != self.Status_Exceeded and
            self.score["count"] > 0
        ):
            # full penalty, because password is not long enough, only for
            # a positive score
            self.score["count"] *= 1.0 / self.redundancy["value"]
        
        # level it out
        if self.score["count"] > 100:
            self.score["adjusted"] = 100
        elif self.score["count"] < 0:
            self.score["adjusted"] = 0
        else:
            self.score["adjusted"] = self.score["count"]
        
        # judge it
        for index in range(len(self.complexity["limits"])):
            if self.score["adjusted"] <= self.complexity["limits"][index]:
                self.complexity["value"] = index
                break
        
        return self.complexity["value"]
