"""Provides the application-internal class-based models for users and groups."""

from string import ascii_lowercase, ascii_uppercase, digits, whitespace
from base64 import b64encode, b64decode
import hashlib
from binascii import Error as Base64DecodeError
from pathlib import Path

from PIL import Image
from PIL.JpegImagePlugin import JpegImageFile
from flask import current_app

from lumi2.exceptions import InvalidStringFormatException, InvalidImageException

class User:
    """Class model for a user.

    Attributes
    ----------
    username : str
        The user's username.
    password_hash : str
        Base64-encoded SHA512 hash of the user's password.
    email : str
        The user's email address.
    first_name : str
        The user's first name.
    last_name : str
        The user's last name.
    display_name : str
        The user's display name (as required by some LDAP-enabled applications).
    picture : PIL.Image
        The user's profile picture as a PIL Image object.
    """


    @staticmethod
    def assert_is_valid_username(input_str: str) -> None:
        """Checks whether the input string is a valid username.

        Valid usernames can contain only uppercase/lowercase latin characters,
        numbers, hyphens, underscores and periods. A username must start with a
        latin character.
        Minimum length is 1, maximum length is 64 characters.

        Parameters
        ----------
        input_str : str
            The string whose validity as a username to check.

        Raises
        ------
        TypeError
            If input_str is not of type string.
        InvalidStringFormatException
            If input_str is not a valid username.
        """

        if not isinstance(input_str, str):
            raise TypeError(f"Expected a string but got: '{type(input_str)}'.")

        if not len(input_str):
            raise InvalidStringFormatException("Username must contain at least one character.")
        if len(input_str) > 64:
            raise InvalidStringFormatException("Username must not exceed 64 characters in length.")

        if input_str[0] not in ascii_lowercase + ascii_uppercase:
            raise InvalidStringFormatException("Username must start with a letter.")

        valid_chars = ascii_lowercase + ascii_uppercase + digits + "-_."
        for char in input_str:
            if char not in valid_chars:
                raise InvalidStringFormatException(
                    f"Invalid character in username: '{char}'."
                )


    @staticmethod
    def assert_is_valid_password(input_str: str) -> None:
        """Checks whether the input string is a valid password.

        A valid password may not contain any whitespace.
        They must be at least 8 characters long and at 64 characters at most.

        Parameters
        ----------
        input_str : str
            The string whose validity as a password to check.

        Raises
        ------
        TypeError
            If input_str is not of type string.
        InvalidStringFormatException
            If input_str is not a valid password.
        """

        if not isinstance(input_str, str):
            raise TypeError(f"Expected a string but got: '{type(input_str)}'.")

        if len(input_str) < 8:
            raise InvalidStringFormatException(
                "Password must be at least 8 characters in length."
            )
        if len(input_str) > 64:
            raise InvalidStringFormatException(
                "Password may not be longer than 8 characters."
            )

        for char in input_str:
            if char in whitespace:
                raise InvalidStringFormatException(
                    "Password my not contain any whitespace."
                )


    @staticmethod
    def assert_is_valid_password_hash(input_str: str) -> None:
        """Checks whether the input string is a valid password hash.

        A valid password hash is a non-empty string containing base64-decodeable
        text.

        Parameters
        ----------
        input_str : str
            The string whose validity as a password hash to check.

        Raises
        ------
        TypeError
            If input_str is not of type string.
        InvalidStringFormatException
            If input_str is not a valid password hash.
        """

        if not isinstance(input_str, str):
            raise TypeError(f"Expected a string but got: '{type(input_str)}'.")

        if not len(input_str):
            raise InvalidStringFormatException(
                "Password hash must be at least one character in length."
            )

        try:
            b64decode(input_str, validate=True)
        except Base64DecodeError as e:
            raise InvalidStringFormatException from e


    @staticmethod
    def assert_is_valid_email(input_str: str) -> None:
        """Checks whether the input string is a valid email address.

        Very rudimentary check, proper validation would require
        a validation email to be sent and confirmed by the user.
        A valid email address contains no whitespace, at least one '@' character,
        and a '.' character somewhere after the '@'.
        The maximum length for a valid email address is 64 characters.

        Parameters
        ----------
        input_str : str
            The string whose validity as an email address to check.

        Raises
        ------
        TypeError
            If input_str is not of type string.
        InvalidStringFormatException
            If input_str is not a valid email address.
        """

        if not isinstance(input_str, str):
            raise TypeError(f"Expected a string but got: '{type(input_str)}'.")

        if '@' not in input_str:
            raise InvalidStringFormatException(
                "Invalid email address: no '@' found."
            )
        if '.' not in input_str:
            raise InvalidStringFormatException(
                "Invalid email address: no top-level-domain found."
            )
        if '.' not in input_str.split('@')[1]:
            raise InvalidStringFormatException(
                "Invalid email address: no top-level-domain found."
            )

        if len(input_str) > 64:
            raise InvalidStringFormatException(
                "Invalid email address: may not be longer than 64 characters."
            )

        for char in input_str:
            if char in whitespace:
                raise InvalidStringFormatException(
                    "Invalid email address: no whitespace permitted."
                )


    @staticmethod
    def assert_is_valid_name(input_str: str) -> None:
        """Checks whether the input string is valid as a first/last/display name.

        Valid names cannot contain whitespace and must be at least one character
        long, and 64 characters at most.

        Parameters
        ----------
        input_str : str
            The string whose validity as a first-/last-/displayname to check.

        Raises
        ------
        TypeError
            If input_str is not of type string.
        InvalidStringFormatException
            If input_str is not a valid person's name.
        """

        if not isinstance(input_str, str):
            raise TypeError(f"Expected a string but got: '{type(input_str)}'.")

        if not len(input_str):
            raise InvalidStringFormatException(
                "Invalid name: must be at least 1 character in length."
            )
        if len(input_str) > 64:
            raise InvalidStringFormatException(
                "Invalid name: may not be longer than 64 characters."
            )

        for char in input_str:
            if char in whitespace:
                raise InvalidStringFormatException(
                    "Invalid name: may not contain whitespace."
                )


    @staticmethod
    def assert_is_valid_picture(input_image: JpegImageFile) -> None:
        """Checks whether the input image is a valid Image object.

        Valid images must be of type JpegImageFile.

        Parameters
        ----------
        input_image : PIL.Image.JpegImageFile
            The Image object whose validity to check.

        Raises
        ------
        TypeError
            If input_image is not of type PIL.Image.
        InvalidImageException
            If the input image's type is not PIL.Image.JpegImageFile.
        """

        if not isinstance(input_image, Image.Image):
            raise TypeError(f"Expected a PIL Image but got: '{type(input_image)}'.")
        if not isinstance(input_image, JpegImageFile):
            raise InvalidImageException(
                "User picture must be in JPEG format."
            )


    @staticmethod
    def generate_password_hash(password: str) -> str:
        """Generates a base64-encoded SHA512 hash of the input string.

        Parameters
        ----------
        password : str
            The plaintext password for which to generate a hash.

        Returns
        -------
        str
            A base64-encoded SHA512 hash digest of the input string.

        Raises
        ------
        TypeError
            If the input is not of type string.
        ValueError
            If the input string is empty.
        """

        if not isinstance(password, str):
            raise TypeError(f"Expected a string but got: '{type(password)}'.")
        if not len(password):
            raise ValueError("Input string cannot be empty.")

        hash_bytes = hashlib.sha512()
        hash_bytes.update(bytes(password, "UTF-8"))
        return b64encode(hash_bytes.digest()).decode("ASCII")


    @staticmethod
    def _get_default_picture() -> Image.Image:
        """Returns the default user picture as a PIL Image object.

        Returns
        -------
        PIL.Image.Image
            The default user profile picture.
        """

        image_path = f"{current_app.static_folder}/images/default/user.jpg"
        return Image.open(image_path)


    def __init__(
        self,
        username: str, password_hash: str, email: str,
        first_name: str, last_name: str, display_name = None,
        picture = None,
    ):
        """Constructor for User objects.

        Parameters
        ----------
        username : str
            The username, valid as described by User.assert_is_valid_username().
        password_hash : str
            The user's base64-encoded SHA512-hashed password (without the
            '{SHA512}'-prefix expected by LDAP).
            Must be valid as described by User.assert_is_valid_password_hash().
        email : str
            The User's email address.
            Must be valid as described by User.assert_is_valid_email().
        first_name : str
            The User's first name.
            Must be valid as described by User.assert_is_valid_name().
        last_name : str
            The User's last name.
            Must be valid as described by User.assert_is_valid_name().
        display_name : str = first_name
            The User's nickname. If unspecified, gets set to the User's first
            name.
            Must be valid as described by User.assert_is_valid_name().
        picture : PIL.Image.JpegImageFile
            The User's JPEG picture. If unspecified, a default user picture is
            used. Must be valid as described by User.asser_is_valid_picture().
        """

        try:
            User.assert_is_valid_username(username)
            User.assert_is_valid_password_hash(password_hash)
            User.assert_is_valid_email(email)
            User.assert_is_valid_name(first_name)
            User.assert_is_valid_name(last_name)
            if display_name is not None:
                User.assert_is_valid_name(display_name)
            if picture is not None:
                User.assert_is_valid_picture(picture)
        except (InvalidStringFormatException, InvalidImageException) as e:
            raise ValueError from e

        self.username = username
        self.password_hash = password_hash
        self.email = email
        self.first_name = first_name
        self.last_name = last_name
        self.display_name = display_name if display_name is not None else first_name
        self.picture = picture if picture is not None else User._get_default_picture()


    def _generate_static_images(self, force=False) -> None:
        """Generates the static images for this User's picture on disc.

        The user's full profile picture and a thumbnail are written to
        'static/images/user/<username>/full.jpg'
        and 'static/images/user/<username>/thumbnail.jpg' respectively.
        The thumbnail's fixed size is 512x512 px.

        If the parameter force is set to True, existing images are overwritten.
        Otherwise, if the images already exist on disk, image generation is skipped.

        Parameters
        ----------
        force : bool = False
            Whether or not existing images on disk should be regenerated.
        """

        path_to_image_folder = Path(current_app.static_folder) / "images" / "users" / self.username
        path_to_full_image = path_to_image_folder / "full.jpg"
        path_to_thumbnail = path_to_image_folder / "thumbnail.jpg"

        if not path_to_image_folder.is_dir():
            path_to_image_folder.mkdir(parents=True)

        if not path_to_full_image.is_file() or force:
            self.picture.save(path_to_full_image)

        if not path_to_thumbnail.is_file() or force:
            thumb = self.picture.copy()
            thumb.thumbnail((256, 256))
            thumb.save(path_to_thumbnail)


    def get_dn(self) -> str:
        """Returns the LDAP DN for the DIT entry representing this User.

        The method does not check whether or not an entry with this DN actually
        exists, it merely returns the DN as a string.

        Returns
        -------
        str
            The LDAP DN (distinguished name) uniquely identifying this User in
            the DIT.
        """

        return "uid=" + self.username + ',' + current_app.config['LDAP_USERS_OU']


    def get_picture_url(self):
        """Returns the URL to this user's static profile picture image file."""

        return f'/static/images/users/{self.username}/full.jpg'


    def get_thumbnail_url(self):
        """Returns the URL to this user's static profile thumbnail image file."""

        return f'/static/images/users/{self.username}/thumbnail.jpg'


    def __eq__(self, other):
        return self.username == other.username

    def __ne__(self, other):
        return self.username != other.username

    def __lt__(self, other):
        return self.username < other.username

    def __hash__(self):
        return hash(self.username)

    def __repr__(self):
        repr_str = f'User("{self.username}", "{self.password_hash}", ' \
                   f'"{self.email}", "{self.first_name}", "{self.last_name}", ' \
                   f'"{self.display_name}", "{self.picture}")'
        return repr_str


class Group:
    """Class model for a group of users.

    Attributes
    ----------
    groupname : str
        The name (cn) of this group.
    members : set[User]
        The set of Users who are a member of this group.
    """

    @staticmethod
    def assert_is_valid_groupname(input_str: str) -> None:
        """Checks whether the input string is a valid group name.

        A valid group name consists of only alphabetic characters, has minimum
        length 1 and maximum length 64.

        Parameters
        ----------
        input_str : str
            The string to check for validity as a group name.

        Raises
        ------
        TypeError
            If input_str is not of type string.
        InvalidStringFormatException
            If input_str is not a valid group name.
        """

        if not isinstance(input_str, str):
            raise TypeError(f"Expected a string but got: '{type(input_str)}'.")

        if not len(input_str):
            raise InvalidStringFormatException(
                "Invalid group name: must contain at least one character."
            )
        for char in input_str:
            if not char in ascii_uppercase + ascii_lowercase:
                raise InvalidStringFormatException(
                    f"Invalid character in group name: '{char}'."
                )


    def __init__(self, groupname: str, members: set[User]):
        """Constructor for Group objects.

        Groups must always have at least one member (an LDAP limitation).

        Parameters
        ----------
        groupname : str
            The Group's name.
            Must be valid as described by Group.assert_is_valid_groupname().
        members : set[User]
            A set conaining Users who are members of this Group.
            The set must contain at least one member.
        """
        try:
            Group.assert_is_valid_groupname(groupname)
        except InvalidStringFormatException as e:
            raise ValueError from e
        self.groupname = groupname

        if not isinstance(members, set):
            raise TypeError(f"Expected a set but got: '{type(members)}'.")
        for member in members:
            if not isinstance(member, User):
                raise TypeError(f"Expected only Users in members set but got: '{type(member)}'.")
        if not len(members):
            raise ValueError("Expected at least one group member.")
        self.members = members


    def get_dn(self) -> str:
        """Returns the LDAP DN for the DIT entry representing this Group.

        The method does not check whether or not an entry with this DN actually
        exists, it merely returns the DN as a string.

        Returns
        -------
        str
            The LDAP DN (distinguished name) uniquely identifying this Group in
            the DIT.
        """

        return "cn=" + self.groupname + ',' + current_app.config['LDAP_GROUPS_OU']


    def __eq__(self, other):
        return self.groupname == other.groupname

    def __ne__(self, other):
        return self.groupname != other.groupname

    def __lt__(self, other):
        return self.groupname < other.groupname

    def __hash__(self):
        return hash(self.groupname)

    def __repr__(self):
        repr_str = f'Group("{self.groupname}", "{self.members}")'
        return repr_str
