import threading
import time
from collections import deque
from datetime import datetime, timedelta, timezone
from typing import Deque, List, Optional, Set, Tuple, Union

from cachetools import TTLCache
from jose import JWTError, jwt
from loguru import logger
from passlib.context import CryptContext
from pydantic import BaseModel
from requests_oauthlib import OAuth2Session
from starlette.responses import RedirectResponse

from contaxy import config
from contaxy.config import settings
from contaxy.operations import AuthOperations, JsonDocumentOperations
from contaxy.schema import AuthorizedAccess, TokenType, User, UserInput
from contaxy.schema.auth import (
    USERS_KIND,
    AccessLevel,
    AccessToken,
    ApiToken,
    OAuth2Error,
    OAuth2TokenGrantTypes,
    OAuth2TokenRequestFormNew,
    OAuthToken,
    OAuthTokenIntrospection,
    TokenPurpose,
    UserRegistration,
)
from contaxy.schema.exceptions import (
    ClientValueError,
    PermissionDeniedError,
    ResourceAlreadyExistsError,
    ResourceNotFoundError,
    ResourceUpdateFailedError,
    UnauthenticatedError,
)
from contaxy.utils import auth_utils, id_utils
from contaxy.utils.state_utils import GlobalState, RequestState

PWD_CONTEXT = CryptContext(schemes=["bcrypt"], deprecated="auto")


class UserPassword(BaseModel):
    hashed_password: str


class LoginIdMapping(BaseModel):
    user_id: str


class ResourcePermissions(BaseModel):
    permissions: List[str] = []


class AuthManager(AuthOperations):

    _USER_PASSWORD_COLLECTION = "passwords"
    _PERMISSION_COLLECTION = "permission"
    _API_TOKEN_COLLECTION = "tokens"
    _USER_COLLECTION = "users"
    _LOGIN_ID_MAPPING_COLLECTION = "login-id-mapping"

    def __init__(
        self,
        global_state: GlobalState,
        request_state: RequestState,
        json_db_manager: JsonDocumentOperations,
    ):
        """Initializes the Auth Manager.

        Args:
            global_state: The global state of the app instance.
            request_state: The state for the current request.
            json_db_manager: JSON DB Manager instance to store structured data.
        """
        self._global_state = global_state
        self._request_state = request_state
        self._json_db_manager = json_db_manager
        # TODO: move down?
        self._lock = threading.Lock()

    def _get_verify_access_cache(self) -> TTLCache:
        """Returns a TTL (time to live) cache used by the access verification."""
        state_namespace = self._global_state[AuthManager]
        cache = state_namespace.verify_access_cache
        if cache is not None:
            return cache
        else:
            with self._lock:
                state_namespace.verify_access_cache = TTLCache(
                    maxsize=self._global_state.settings.VERIFY_ACCESS_CACHE_SIZE,
                    ttl=self._global_state.settings.VERIFY_ACCESS_CACHE_EXPIRY,
                )
                return state_namespace.verify_access_cache

    def _get_api_token_cache(self) -> TTLCache:
        """Returns a TTL (time to live) cache used for caching API token metadata."""
        state_namespace = self._global_state[AuthManager]
        cache = state_namespace.api_token_cache
        if cache is not None:
            return cache
        else:
            with self._lock:
                state_namespace.api_token_cache = TTLCache(
                    maxsize=self._global_state.settings.API_TOKEN_CACHE_SIZE,
                    ttl=self._global_state.settings.API_TOKEN_CACHE_EXPIRY,
                )
                return state_namespace.api_token_cache

    def _get_resource_permissions_cache(self) -> TTLCache:
        """Returns a TTL (time to live) cache used for caching permissions associated with resources."""
        state_namespace = self._global_state[AuthManager]
        cache = state_namespace.resource_permissions_cache
        if cache is not None:
            return cache
        else:
            with self._lock:
                state_namespace.resource_permissions_cache = TTLCache(
                    maxsize=self._global_state.settings.RESOURCE_PERMISSIONS_CACHE_SIZE,
                    ttl=self._global_state.settings.RESOURCE_PERMISSIONS_CACHE_EXPIRY,
                )
                return state_namespace.resource_permissions_cache

    def login_page(self) -> RedirectResponse:
        pass

    def logout_session(self) -> RedirectResponse:
        # TODO: where to redirect to
        rr = RedirectResponse("/welcome", status_code=307)
        rr.delete_cookie(config.API_TOKEN_NAME)
        rr.delete_cookie(config.AUTHORIZED_USER_COOKIE)
        return rr

    def _create_session_token(
        self,
        token_subject: str,
        scopes: List[str],
        expiry_minutes: Optional[timedelta] = None,
    ) -> str:

        if expiry_minutes:
            expire = datetime.now(timezone.utc) + expiry_minutes
        else:
            expire = datetime.now(timezone.utc) + timedelta(
                minutes=config.settings.JWT_TOKEN_EXPIRY_MINUTES
            )

        return jwt.encode(
            claims={
                "sub": token_subject,
                # TODO: "iss"
                "exp": expire,
                "iat": datetime.now(timezone.utc),
                "scope": scopes,
            },
            key=config.settings.JWT_TOKEN_SECRET,
            algorithm=config.settings.JWT_ALGORITHM,
        )

    def create_token(
        self,
        scopes: List[str],
        token_type: TokenType,
        description: Optional[str] = None,
        token_purpose: Optional[TokenPurpose] = None,
        token_subject: Optional[str] = None,
    ) -> str:
        if not token_subject:
            if (
                self._request_state.authorized_access
                and self._request_state.authorized_access.authorized_subject
            ):
                # Get token subject from authorized subject info
                token_subject = self._request_state.authorized_access.authorized_subject
            else:
                raise UnauthenticatedError("No token subject found for token creation.")

        if token_type is token_type.SESSION_TOKEN:
            # Create session token if selected
            return self._create_session_token(token_subject, scopes)

        token = id_utils.generate_token(config.settings.API_TOKEN_LENGTH)
        api_token = ApiToken(
            token=token,
            token_type=token_type,
            subject=token_subject,
            scopes=scopes,
            created_at=datetime.now(timezone.utc),
            description=description,
            token_purpose=token_purpose,
            # TODO: created_by
            # TODO: expires_at
        )

        self._json_db_manager.create_json_document(
            project_id=config.SYSTEM_INTERNAL_PROJECT,
            collection_id=self._API_TOKEN_COLLECTION,
            key=token,
            json_document=api_token.json(),
        )
        return token

    def list_api_tokens(self, token_subject: Optional[str] = None) -> List[ApiToken]:
        # Filter all resources for the provided permission
        filtered_token_docs = self._json_db_manager.list_json_documents(
            config.SYSTEM_INTERNAL_PROJECT,
            self._API_TOKEN_COLLECTION,
            filter=f'$.subject ==  "{token_subject}"',  # TODO: $.subject ==  "{token_subject}"  inmemory: $[? (@.subject ==  "{token_subject}")]'
        )

        api_tokens: List[ApiToken] = []
        for token_doc in filtered_token_docs:
            api_token = ApiToken.parse_raw(token_doc.json_value)
            # Check here again for subject
            if api_token.subject == token_subject:
                api_tokens.append(api_token)

        return api_tokens

    def _get_api_token_from_db(self, token: str) -> ApiToken:
        """Returns the API token metadata from the database.

        Args:
            token: The API token.

        Raises:
            ResourceNotFoundError: If the token does not exist in the DB.
        """
        token_doc = self._json_db_manager.get_json_document(
            config.SYSTEM_INTERNAL_PROJECT,
            self._API_TOKEN_COLLECTION,
            token,
        )
        return ApiToken.parse_raw(token_doc.json_value)

    def _resolve_token(self, token: str, use_cache: bool) -> AccessToken:
        """Resolves the provided token to its metadata.

        Args:
            token: An API or session token.
            use_cache: If `False`, the token resolving will not use any cache.

        Raises:
            UnauthenticatedError: If the token is not valid
        """
        if auth_utils.is_jwt_token(token):
            try:
                payload = jwt.decode(
                    token,
                    config.settings.JWT_TOKEN_SECRET,
                    algorithms=[config.settings.JWT_ALGORITHM],
                )
                return AccessToken(
                    token=token,
                    token_type=TokenType.SESSION_TOKEN,
                    subject=payload.get("sub"),
                    scopes=payload.get("scope"),
                    expires_at=datetime.fromtimestamp(
                        payload.get("exp"), tz=timezone.utc
                    ),
                    created_at=datetime.fromtimestamp(
                        payload.get("iat"), tz=timezone.utc
                    ),
                )

            except JWTError as ex:
                raise UnauthenticatedError("Session token is not valid.") from ex

        if not use_cache or not self._global_state.settings.API_TOKEN_CACHE_ENABLED:
            # Do not use cache
            try:
                return self._get_api_token_from_db(token)
            except ResourceNotFoundError as ex:
                raise UnauthenticatedError(
                    message="The provided API token does not exist in the database."
                ) from ex

        cache = self._get_api_token_cache()
        cache_key = token
        token_metadata: Optional[AccessToken] = None
        if cache_key in cache:
            token_metadata = cache[cache_key]
        else:
            # lock thread if item is updated
            with self._lock:
                try:
                    # Add the verification result to the cache
                    token_metadata = self._get_api_token_from_db(token)
                except ResourceNotFoundError:
                    token_metadata = None
                # Store token in cache, even if None
                cache[cache_key] = token_metadata

        if not token_metadata:
            raise UnauthenticatedError(
                message="The provided API token does not exist in the database."
            )

        return token_metadata

    def _verify_access_via_db(
        self, token: AccessToken, permission: str, use_cache: bool
    ) -> AuthorizedAccess:
        scope_grants_access = False
        for scope in token.scopes:

            if not auth_utils.is_valid_permission(scope):
                logger.warning(f"The token scope ({scope}) is not valid.")
                continue

            # Check if the token scope grants access to the requested permission
            if auth_utils.is_permission_granted(scope, permission):
                scope_grants_access = True
                break

        if not scope_grants_access:
            raise PermissionDeniedError(
                "The authorized token does not have the required scope."
            )

        # Check if the token subject has the required permission
        token_subject_permissions = self.list_permissions(
            token.subject, resolve_roles=True, use_cache=use_cache
        )

        for token_subject_permission in token_subject_permissions:
            if auth_utils.is_permission_granted(token_subject_permission, permission):
                # The token subject (= usually user) is granted the requested permission
                resource_name, access_level = auth_utils.parse_permission(permission)
                return AuthorizedAccess(
                    authorized_subject=token.subject,
                    resource_name=resource_name,
                    access_level=access_level,
                    access_token=token,
                )

        raise PermissionDeniedError(
            "The token subject does not have the required permission."
        )

    def verify_access(
        self, token: str, permission: Optional[str] = None, use_cache: bool = True
    ) -> AuthorizedAccess:
        # This will thows UnauthenticatedError if the token is not valid or does not existx
        resolved_token = self._resolve_token(token, use_cache=use_cache)
        if not permission or settings.DEBUG_DEACTIVATE_VERIFICATION:
            # no permissions to check -> return granted permission
            return AuthorizedAccess(
                authorized_subject=resolved_token.subject, access_token=resolved_token
            )
        if not use_cache or not self._global_state.settings.VERIFY_ACCESS_CACHE_ENABLED:
            # Do not use cache
            return self._verify_access_via_db(
                resolved_token, permission, use_cache=use_cache
            )

        cache = self._get_verify_access_cache()
        cache_key = token + "-perm-" + str(permission)
        if cache_key in cache:
            return cache[cache_key]
        else:
            # lock thread if item is updated
            with self._lock:
                # Add the verification result to the cache
                verification_result = self._verify_access_via_db(
                    resolved_token, permission, use_cache=use_cache
                )
                cache[cache_key] = verification_result
                return verification_result

    def change_password(
        self,
        user_id: str,
        password: str,
    ) -> None:
        user_password = UserPassword(hashed_password=PWD_CONTEXT.hash(password))

        # TODO: salt and hash the user id to make it more complicated to link the password to a user

        # This method creteas or overwrites the
        self._json_db_manager.create_json_document(
            config.SYSTEM_INTERNAL_PROJECT,
            self._USER_PASSWORD_COLLECTION,
            user_id,
            user_password.json(),
        )

    def verify_password(
        self,
        user_id: str,
        password: str,
    ) -> bool:
        """Verifies a password of a specified user.

        The password is stored as a hash

        Args:
            user_id: The ID of the user.
            password: The password to check. This can also be specified as a hash.

        Returns:
            bool: `True` if the password matches the stored password.
        """
        password_document = self._json_db_manager.get_json_document(
            config.SYSTEM_INTERNAL_PROJECT, self._USER_PASSWORD_COLLECTION, user_id
        )

        user_password = UserPassword.parse_raw(password_document.json_value)
        return PWD_CONTEXT.verify(password, user_password.hashed_password)

    # Permission Operations

    def _get_resource_permissions_from_db(
        self, resource_name: str
    ) -> ResourcePermissions:
        permission_doc = self._json_db_manager.get_json_document(
            config.SYSTEM_INTERNAL_PROJECT,
            self._PERMISSION_COLLECTION,
            resource_name,
        )
        return ResourcePermissions.parse_raw(permission_doc.json_value)

    def add_permission(
        self,
        resource_name: str,
        permission: str,
    ) -> None:
        """Grants a permission to the specified resource.

        Args:
            resource_name: The resource name that the permission is granted to.
            permission: The permission to grant to the specified resource.

        Raises:
            ResourceUpdateFailedError: If the resource update could not be applied successfully.
        """
        resource_permission = ResourcePermissions()
        try:
            # Try to get the permission document
            resource_permission = self._get_resource_permissions_from_db(resource_name)
        except ResourceNotFoundError:
            # Ignore error, create a new resource
            pass

        # Add permissions to the document
        resource_permission.permissions.append(permission)
        # TODO: at this point there - theoretically - could be a data inconsitency if the same item is updated at the exact same time
        # Create/or update document
        self._json_db_manager.create_json_document(
            config.SYSTEM_INTERNAL_PROJECT,
            self._PERMISSION_COLLECTION,
            resource_name,
            resource_permission.json(),
            upsert=True,
        )

        # Test if permission was applied with short timeout (to wait for conflicting updates)
        # It is very unlikely that the resource update fails
        time.sleep(0.005)
        try:
            resource_permissions = self._get_resource_permissions_from_db(resource_name)

            if permission not in resource_permissions.permissions:
                raise ResourceUpdateFailedError(
                    message=f"Unable to add permission ({permission}) for {resource_name}. Try again.",
                    explanation="The permission was not added to the resource.",
                    resource=resource_name,
                )
        except ResourceNotFoundError as ex:
            raise ResourceUpdateFailedError(
                message=f"Unable to add permission ({permission}) for {resource_name}. Try again. 2",
                explanation="The resource did not exist anymore after the update.",
                resource=resource_name,
            ) from ex

    def remove_permission(
        self, resource_name: str, permission: str, remove_sub_permissions: bool = False
    ) -> None:
        """Revokes a permission from the specified resource.

        Args:
            resource_name: The resource name that the permission should be revoked from.
            permission: The permission to revoke from the specified resource.
            remove_sub_permissions: If `True`, the permission is used as prefix, and all permissions that start with this prefix will be revoked. Defaults to `False`.
        """
        try:
            # Try to get the permission document
            resource_permission = self._get_resource_permissions_from_db(resource_name)
            updated_permissions = []
            removed_permissions = []
            # Iterate all permissions granted to the resource
            for granted_permission in resource_permission.permissions:
                if permission == granted_permission:
                    # Permission matched granted permission -> Ignore/remove this permission
                    removed_permissions.append(permission)
                    continue

                if (
                    remove_sub_permissions
                    and auth_utils.is_valid_permission(
                        granted_permission
                    )  # Only if it is a valid permission and not a role
                    and auth_utils.is_permission_granted(permission, granted_permission)
                ):
                    # Ignore/remove this permission since it is a subpermission
                    removed_permissions.append(permission)
                    continue
                updated_permissions.append(granted_permission)

            # Create/or update document
            self._json_db_manager.create_json_document(
                config.SYSTEM_INTERNAL_PROJECT,
                self._PERMISSION_COLLECTION,
                resource_name,
                ResourcePermissions(permissions=updated_permissions).json(),
                upsert=True,
            )

            # Test if permission was applied with short timeout (to wait for conflicting updates)
            # It is very unlikely that the resource update fails
            time.sleep(0.001)
            try:
                resource_permissions = self._get_resource_permissions_from_db(
                    resource_name
                )
                for removed_permission in removed_permissions:
                    if removed_permission in resource_permissions.permissions:
                        raise ResourceUpdateFailedError(
                            message=f"Unable to remove permission ({permission}) for {resource_name}. Try again.",
                            explanation=f"The permission {removed_permission} was not removed during the update.",
                            resource=resource_name,
                        )
            except ResourceNotFoundError:
                # Ignore this
                pass

        except ResourceNotFoundError as ex:
            # Ignore error, create a new resource
            raise ResourceUpdateFailedError(
                message=f"Unable to remove permission ({permission}) for {resource_name}. Try again.",
                explanation="The resource does not have any permissions.",
                resource=resource_name,
            ) from ex

    def _list_permissions_from_db(
        self, resource_name: str, resolve_roles: bool = True
    ) -> List[str]:
        try:
            resource_permissions = self._get_resource_permissions_from_db(resource_name)
            permissions = resource_permissions.permissions

            if not permissions:
                return []

            if not resolve_roles:
                return permissions

            # resolve roles: Permissions can have a hierachy based on roles which needs to be resolved
            checked_permissions: Set[str] = set()  # used to prevent recursive loops
            permissions_to_resolve: Deque[str] = deque()  # queu of permissions to check
            resolved_permissions: Set[str] = set()  # all resolved base permissions

            permissions_to_resolve.extend(permissions)
            while permissions_to_resolve:
                permission = permissions_to_resolve.popleft()
                if permission in checked_permissions:
                    continue

                checked_permissions.add(permission)
                if auth_utils.is_valid_permission(permission):
                    resolved_permissions.add(permission)
                else:
                    try:
                        # Probably a role / permission collection -> resolve permissions and add to list
                        permissions_to_resolve.extend(
                            self._get_resource_permissions_from_db(
                                permission
                            ).permissions
                        )
                    except ResourceNotFoundError:
                        logger.warning(
                            f"Failed to resolve permission {permission}",
                        )
            return list(resolved_permissions)

        except ResourceNotFoundError:
            # TODO raise error?
            return []

    def list_permissions(
        self, resource_name: str, resolve_roles: bool = True, use_cache: bool = False
    ) -> List[str]:
        """Returns all permissions granted to the specified resource.

        Args:
            resource_name: The name of the resource (relative URI).
            resolve_roles: If `True`, all roles of the resource will be resolved to the associated permissions. Defaults to `True`.

        Returns:
            List[str]: List of permissions granted to the given resource.
        """
        if not use_cache or not config.settings.RESOURCE_PERMISSIONS_CACHE_ENABLED:
            return self._list_permissions_from_db(resource_name, resolve_roles)

        # Load via cache
        cache = self._get_resource_permissions_cache()
        cache_key = resource_name
        if cache_key in cache:
            return cache[cache_key]
        else:
            # lock thread if item is updated
            with self._lock:
                # Add the verification result to the cache
                resource_permissions = self._list_permissions_from_db(
                    resource_name, resolve_roles
                )
                cache[cache_key] = resource_permissions
                return resource_permissions

    def list_resources_with_permission(
        self, permission: str, resource_name_prefix: Optional[str] = None
    ) -> List[str]:
        """Returns all resources that are granted for the specified permission.

        Args:
            permission: The permission to use. If the permission is specified without the access level, it will filter for all access levels.
            resource_name_prefix: Only return resources that match with this prefix.

        Returns:
            List[str]: List of resources names (relative URIs).
        """
        # Filter all resources for the provided permission
        filtered_resource_docs = self._json_db_manager.list_json_documents(
            config.SYSTEM_INTERNAL_PROJECT,
            self._PERMISSION_COLLECTION,
            filter=f'$.permissions[*] ? (@=="{permission}")',  # TODO: - inmemory: $[*].permissions[?(@=="{permission}")]
        )

        resource_names: Set[str] = set()
        for resource_doc in filtered_resource_docs:
            # The key of the resource doc is supposed to be the resource name
            resource_name = resource_doc.key
            if (
                permission
                not in ResourcePermissions.parse_raw(
                    resource_doc.json_value
                ).permissions
            ):
                continue
            if resource_name_prefix and resource_name.startswith(resource_name_prefix):
                resource_names.add(resource_name)
            else:
                resource_names.add(resource_name)
        return list(resource_names)

    # OAuth Opertions
    def request_token(
        self, token_request_form: OAuth2TokenRequestFormNew
    ) -> OAuthToken:
        if token_request_form.grant_type != OAuth2TokenGrantTypes.PASSWORD:
            # Only the password grant type is currently implemented.
            raise OAuth2Error(error="invalid_grant")

        if not token_request_form.username or not token_request_form.password:
            raise OAuth2Error("invalid_request")
        try:
            user_id = self._get_user_id_by_login_id(token_request_form.username)
            if not self.verify_password(user_id, token_request_form.password):
                raise OAuth2Error("unauthorized_client")

            return self._generate_token(user_id, token_request_form.scope)

        except ResourceNotFoundError as ex:
            # The user was not found in the system
            raise OAuth2Error("invalid_request") from ex

    def _generate_token(
        self, user_id: str, scopes: Union[str, List[str], None] = None
    ) -> OAuthToken:
        if not scopes:
            # Default user token is allowed to do everything
            scopes = [auth_utils.construct_permission("*", AccessLevel.ADMIN)]
        elif isinstance(scopes, str):
            scopes = scopes.split()
        token = self.create_token(
            token_subject="users/" + user_id,
            scopes=scopes,
            token_type=TokenType.API_TOKEN,
            description="Login Token.",
        )

        # TODO: change this here if we validated token scopes
        granted_scopes = " ".join(scopes)

        return OAuthToken(token_type="bearer", access_token=token, scope=granted_scopes)

    def revoke_token(
        self,
        token: str,
        # token_type_hint: Optional[str] = None,
    ) -> None:
        if auth_utils.is_jwt_token(token):
            raise OAuth2Error(error="unsupported_token_type")

        try:
            self._json_db_manager.delete_json_document(
                project_id=config.SYSTEM_INTERNAL_PROJECT,
                collection_id=self._API_TOKEN_COLLECTION,
                key=token,
            )
            return
        except ResourceNotFoundError:
            # Based on the Oauth standard, nothing needs to be done here.
            logger.warning("The token does not exist in the database.")
        return

    def introspect_token(
        self,
        token: str,
        # token_type_hint: Optional[str] = None,
    ) -> OAuthTokenIntrospection:
        try:
            resolved_token = self._resolve_token(token, use_cache=False)
            iat = None
            if resolved_token.created_at:
                iat = int(resolved_token.created_at.timestamp())  # TODO: check timezone

            exp = None
            if resolved_token.expires_at:
                exp = int(resolved_token.expires_at.timestamp())  # TODO: check timezone

            return OAuthTokenIntrospection(
                active=True,
                scope=" ".join(resolved_token.scopes),
                sub=resolved_token.subject,
                iat=iat,
                exp=exp,
                # TODO: fill other fields
            )
        except UnauthenticatedError:
            # Based on the standard, return the introspection object as inactive
            return OAuthTokenIntrospection(active=False)

    def login_callback(
        self,
        code: str,
        redirect_uri: str,
        state: Optional[str] = None,
    ) -> Tuple[OAuthToken, Optional[User]]:
        """Implements the OAuth2 / OICD callback to finish the login process.

        The authorization `code` is exchanged for an access and ID token.
        The ID token contains all relevant user information and is used to login the user.
        If the user does not exist, a new user will be created with the information from the ID token.

        This operation implements the [Authorization Response](https://tools.ietf.org/html/rfc6749#section-4.1.2) from RFC6749.

        Args:
            code: The authorization code generated by the authorization server.
            redirect_uri: The redirect uri used for the OICD authorization flow.
            state (optional): An opaque value used by the client to maintain state between the request and callback. The parameter SHOULD be used for preventing cross-site request forgery.

        Raises:
            UnauthenticatedError: If the `code` could not be used to get an ID token.

        Returns:
            RedirectResponse: A redirect to the webapp that has valid access tokens attached.
        """
        session = OAuth2Session(
            config.settings.OIDC_CLIENT_ID,
            state=state,
            redirect_uri=redirect_uri,
        )

        token = session.fetch_token(
            config.settings.OIDC_TOKEN_URL,
            code,
            client_secret=config.settings.OIDC_CLIENT_SECRET,
        )

        token_payload = jwt.get_unverified_claims(token.get("id_token"))
        email = token_payload.get("email")
        assert email  # TODO
        if not token_payload.get("email_verified"):
            # ? Prevent
            logger.warning(f"The email {email} is not verified")
        user = None
        try:
            user_id = self._get_user_id_by_login_id(email)
        except ResourceNotFoundError:
            if config.settings.USER_REGISTRATION_ENABLED:
                user = self.create_user(UserRegistration(email=email))
                user_id = user.id
            else:
                raise PermissionDeniedError(
                    f"No existing account for {email} found! Please contact an administrator."
                )

        return self._generate_token(user_id), user

    # User Operations

    def list_users(self) -> List[User]:
        """Lists all users.

        TODO: Filter based on authenticated user?

        Returns:
            List[User]: List of users.
        """
        user_list: List[User] = []
        for json_document in self._json_db_manager.list_json_documents(
            config.SYSTEM_INTERNAL_PROJECT, self._USER_COLLECTION
        ):
            user_list.append(User.parse_raw(json_document.json_value))
        return user_list

    def _create_login_id_mapping(self, login_id: str, user_id: str) -> None:
        if self._login_id_exists(login_id):
            raise ResourceAlreadyExistsError(
                f"A login id mapping with {login_id} already exists."
            )
        processed_login_id = login_id.lower().strip()
        try:
            self._json_db_manager.create_json_document(
                project_id=config.SYSTEM_INTERNAL_PROJECT,
                collection_id=self._LOGIN_ID_MAPPING_COLLECTION,
                key=processed_login_id,
                json_document=LoginIdMapping(user_id=user_id).json(),
                upsert=False,  # Only create, no updates
            )
        except ResourceAlreadyExistsError:
            pass

    def _get_user_id_by_login_id(self, login_id: str) -> str:
        processed_login_id = login_id.lower().strip()
        login_id_mapping_doc = self._json_db_manager.get_json_document(
            project_id=config.SYSTEM_INTERNAL_PROJECT,
            collection_id=self._LOGIN_ID_MAPPING_COLLECTION,
            key=processed_login_id,
        )

        return LoginIdMapping.parse_raw(login_id_mapping_doc.json_value).user_id

    def _login_id_exists(self, login_id: str) -> bool:
        try:
            self._get_user_id_by_login_id(login_id)
            return True
        except ResourceNotFoundError:
            return False

    def create_user(
        self, user_input: UserRegistration, technical_user: bool = False
    ) -> User:
        """Creates a user.

        If only the email is given then a username will be derived based on the email address.

        Args:
            user_input: The user data to create the new user.
            technical_user: If `True`, the created user will be marked as technical user. Defaults to `False`.

        Raises:
            ResourceAlreadyExistsError: If a user with the same username or email already exists.

        Returns:
            User: The created user information.
        """
        user_id = id_utils.generate_short_uuid()

        if user_input.password:
            self.change_password(user_id, user_input.password)  # .get_secret_value()
            del user_input.password

        if user_input.username and id_utils.is_email(user_input.username):
            raise ClientValueError(
                f"The username ({user_input.username}) is not allowed to contain an email address."
            )

        if user_input.email and id_utils.is_email(user_input.email) is False:
            raise ClientValueError(
                f"The email ({user_input.email}) MUST contain a valid email address."
            )

        user = User(
            id=user_id,
            technical_user=technical_user,
            created_at=datetime.now(timezone.utc),
            **user_input.dict(exclude_unset=True),
        )

        # Check if username already exists
        if user.username and self._login_id_exists(user.username):
            raise ResourceAlreadyExistsError(
                f"The user with username {user.username} already exists."
            )

        # Check if email already exists
        if user.email and self._login_id_exists(user.email):
            raise ResourceAlreadyExistsError(
                f"The user with email {user.email} already exists."
            )

        if user.email and not user.username:
            user.username = self._propose_username(user.email)

        # TODO: roll back mappings if user creation fails?
        if user.username:
            self._create_login_id_mapping(user.username, user_id)

        if user.email:
            self._create_login_id_mapping(user.email, user_id)

        created_document = self._json_db_manager.create_json_document(
            project_id=config.SYSTEM_INTERNAL_PROJECT,
            collection_id=self._USER_COLLECTION,
            key=user_id,
            json_document=user.json(),
        )

        # TODO: get resource name from object
        user_resource_name = USERS_KIND + "/" + user_id
        self.add_permission(
            user_resource_name,
            auth_utils.construct_permission(user_resource_name, AccessLevel.ADMIN),
        )
        # User needs to know which images are allowed for deployments
        self.add_permission(
            user_resource_name,
            auth_utils.construct_permission("system/allowed-images", AccessLevel.READ),
        )

        return User.parse_raw(created_document.json_value)

    def get_user(self, user_id: str) -> User:
        """Returns the user metadata for a single user.

        Args:
            user_id: The ID of the user.

        Raises:
            ResourceNotFoundError: If no user with the specified ID exists.

        Returns:
            User: The user information.
        """
        json_document = self._json_db_manager.get_json_document(
            project_id=config.SYSTEM_INTERNAL_PROJECT,
            collection_id=self._USER_COLLECTION,
            key=user_id,
        )
        return User.parse_raw(json_document.json_value)

    def update_user(self, user_id: str, user_input: UserInput) -> User:
        """Updates the user metadata.

        This will update only the properties that are explicitly set in the `user_input`.
        The patching is based on the JSON Merge Patch Standard [RFC7396](https://tools.ietf.org/html/rfc7396).

        Args:
            user_id (str): The ID of the user.
            user_input (UserInput): The user data used to update the user.

        Raises:
            ResourceNotFoundError: If no user with the specified ID exists.

        Returns:
            User: The updated user information.
        """
        updated_document = self._json_db_manager.update_json_document(
            project_id=config.SYSTEM_INTERNAL_PROJECT,
            collection_id=self._USER_COLLECTION,
            key=user_id,
            json_document=user_input.json(exclude_unset=True),
        )
        return User.parse_raw(updated_document.json_value)

    def delete_user(self, user_id: str) -> None:
        """Deletes a user.

        Args:
            user_id (str): The ID of the user.

        Raises:
            ResourceNotFoundError: If no user with the specified ID exists.
        """
        self._json_db_manager.delete_json_document(
            config.SYSTEM_INTERNAL_PROJECT, self._USER_COLLECTION, user_id
        )

    def _propose_username(self, email: str) -> str:
        MAX_RETRIES = 10000
        username = email.split("@")[0]
        if not self._login_id_exists(username):
            return username

        for i in range(1, MAX_RETRIES):
            username = f"{username}-{i}"
            if not self._login_id_exists(username):
                return username

        logger.critical(
            f"Damn! Username cannot be inferred from email {email}. {MAX_RETRIES} combinations tried."
        )
        return ""
