"""Module that provides functionality for user manipulation."""

from AccessControl.Permission import getPermissions
from AccessControl.SecurityManagement import getSecurityManager
from contextlib import contextmanager
from plone.api import env
from plone.api import portal
from plone.api.exc import GroupNotFoundError
from plone.api.exc import InvalidParameterError
from plone.api.exc import MissingParameterError
from plone.api.exc import UserNotFoundError
from plone.api.validation import at_least_one_of
from plone.api.validation import mutually_exclusive_parameters
from plone.api.validation import required_parameters
from Products.CMFPlone.RegistrationTool import get_member_by_login_name
from Products.PlonePAS.interfaces.plugins import ILocalRolesPlugin

import random
import string


def create(
    email=None,
    username=None,
    password=None,
    roles=("Member",),
    properties=None,
):
    """Create a user.

    :param email: [required] Email for the new user.
    :type email: string
    :param username: Username for the new user. This is required if email
        is not used as a username.
    :type username: string
    :param password: Password for the new user. If it's not set we generate
        a random 8-char alphanumeric one.
    :type password: string
    :param properties: User properties to assign to the new user. The list of
        available properties is available in ``portal_memberdata`` through ZMI.
    :type properties: dict
    :returns: Newly created user
    :rtype: MemberData object
    :raises:
        MissingParameterError
        InvalidParameterError
    :Example: :ref:`user-create-example`
    """
    if properties is None:
        # Never use a dict as default for a keyword argument.
        properties = {}

    # it may happen that someone passes email in the properties dict, catch
    # that and set the email so the code below this works fine
    if not email and properties.get("email"):
        email = properties.get("email")

    if not email:
        raise MissingParameterError("You need to pass the new user's email.")

    use_email_as_username = portal.get_registry_record("plone.use_email_as_login")
    if not use_email_as_username and not username:
        raise InvalidParameterError(
            "The portal is configured to use username "
            "that is not email so you need to pass a username.",
        )

    # Generate user_id and login_name using the canonical implementation
    # from plone.app.users, respecting use_uuid_as_userid,
    # use_email_as_login, IUserIdGenerator, and ILoginNameGenerator.
    from plone.app.users.utils import generate_login_name
    from plone.app.users.utils import generate_user_id

    site = portal.get()
    data = {
        "username": username,
        "email": email,
        "fullname": properties.get("fullname", ""),
    }
    user_id = generate_user_id(site, data) or username or email
    login_name = generate_login_name(site, data) or username or email

    registration = portal.get_tool("portal_registration")

    # Generate a random 8-char password
    if not password:
        chars = string.ascii_letters + string.digits
        password = "".join(random.choice(chars) for char in range(8))

    properties.update(username=user_id, email=email)

    registration.addMember(
        user_id,
        password,
        roles,
        properties=properties,
    )

    # If user_id differs from login_name (e.g. UUID as user id with
    # email as login), update the login name accordingly.
    if user_id != login_name:
        pas = portal.get_tool("acl_users")
        pas.updateLoginName(user_id, login_name)

    return get(userid=user_id)


@mutually_exclusive_parameters("userid", "username")
@at_least_one_of("userid", "username")
def get(userid=None, username=None):
    """Get a user.

    Plone provides both a unique, unchanging identifier for a user (the
    userid) and a username, which is the value a user types into the login
    form. In many cases, the values for each will be the same, but under some
    circumstances they will differ. Known instances of this behavior include:

     * using content-based members via membrane
     * users changing their email address when using email as login is enabled

    We provide the ability to look up users by either.

    :param userid: Userid of the user we want to get.
    :type userid: string
    :param username: Username of the user we want to get.
    :type username: string
    :returns: User
    :rtype: MemberData object
    :raises:
        MissingParameterError
    :Example: :ref:`user-get-example`
    """
    if userid is not None:
        portal_membership = portal.get_tool("portal_membership")
        return portal_membership.getMemberById(userid)

    return get_member_by_login_name(
        portal.get(),
        username,
        raise_exceptions=False,
    )


def get_current():
    """Get the currently logged-in user.

    :returns: Currently logged-in user
    :rtype: MemberData object
    :Example: :ref:`user-get-current-example`
    """
    portal_membership = portal.get_tool("portal_membership")
    return portal_membership.getAuthenticatedMember()


@mutually_exclusive_parameters("groupname", "group")
def get_users(groupname=None, group=None):
    """Get all users or all users filtered by group.

    Arguments ``group`` and ``groupname`` are mutually exclusive.
    You can either set one or the other, but not both.

    :param groupname: Groupname of the group of which to return users. If set,
        only return users that are member of this group.
    :type username: string
    :param group: Group of which to return users.
        If set, only return users that are member of this group.
    :type group: GroupData object
    :returns: All users (optionally filtered by group)
    :rtype: List of MemberData objects
    :Example: :ref:`user-get-all-users-example`,
        :ref:`user-get-groups-users-example`
    """
    if groupname:
        group_tool = portal.get_tool("portal_groups")
        group = group_tool.getGroupById(groupname)
        if not group:
            raise GroupNotFoundError

    portal_membership = portal.get_tool("portal_membership")

    if group:
        return group.getGroupMembers()
    else:
        return portal_membership.listMembers()


@mutually_exclusive_parameters("username", "user")
@at_least_one_of("username", "user")
def delete(username=None, user=None):
    """Delete a user.

    Arguments ``username`` and ``user`` are mutually exclusive. You can either
    set one or the other, but not both.

    :param username: Username of the user to be deleted.
    :type username: string
    :param user: User object to be deleted.
    :type user: MemberData object
    :raises:
        MissingParameterError
        InvalidParameterError
    :Example: :ref:`user-delete-example`
    """
    portal_membership = portal.get_tool("portal_membership")
    user_id = username or user.id
    portal_membership.deleteMembers((user_id,))


def is_anonymous():
    """Check if the currently logged-in user is anonymous.

    :returns: True if the current user is anonymous, False otherwise.
    :rtype: bool
    :Example: :ref:`user-is-anonymous-example`
    """
    return bool(portal.get_tool("portal_membership").isAnonymousUser())


@mutually_exclusive_parameters("username", "user")
def get_roles(username=None, user=None, obj=None, inherit=True):
    """Get user's site-wide or local roles.

    Arguments ``username`` and ``user`` are mutually exclusive. You
    can either set one or the other, but not both. if ``username`` and
    ``user`` are not given, the currently authenticated member will be used.

    :param username: Username of the user for which to get roles.
    :type username: string
    :param user: User object for which to get roles.
    :type user: MemberData object
    :param obj: If obj is set then return local roles on this context.
        If obj is not given, the site root local roles will be returned.
    :type obj: content object
    :param inherit: if obj is set and inherit is False, only return
        local roles
    :type inherit: bool
    :raises:
        MissingParameterError
    :Example: :ref:`user-get-roles-example`
    """
    portal_membership = portal.get_tool("portal_membership")

    if username is None:
        if user is None:
            username = portal_membership.getAuthenticatedMember().getId()
        else:
            username = user.getId()

    if username is None and user is None:
        user = portal_membership.getAuthenticatedMember()
    else:
        user = portal_membership.getMemberById(username)

    if user is None:
        raise UserNotFoundError(username)

    if obj is not None:
        if inherit:
            return user.getRolesInContext(obj)
        else:
            # Include roles inherited from being the member of a group
            # and from adapters granting local roles
            plone_user = user.getUser()
            principal_ids = list(plone_user.getGroups())
            principal_ids.insert(0, plone_user.getId())
            roles = set()
            pas = portal.get_tool("acl_users")
            for _, lrmanager in pas.plugins.listPlugins(ILocalRolesPlugin):
                for adapter in lrmanager._getAdapters(obj):
                    for principal_id in principal_ids:
                        roles.update(adapter.getRoles(principal_id))
            return list(roles)
    else:
        return user.getRoles()


@contextmanager
def _nop_context_manager():
    """Do nothing (trivial context manager)."""
    yield


@mutually_exclusive_parameters("username", "user")
def get_permissions(username=None, user=None, obj=None):
    """Get user's site-wide or local permissions.

    Arguments ``username`` and ``user`` are mutually exclusive. You
    can either set one or the other, but not both. if ``username`` and
    ``user`` are not given, the authenticated member will be used.

    :param username: Username of the user for which you want to check
        the permissions.
    :type username: string
    :param user: User object for which you want to check the permissions.
    :type user: MemberData object
    :param obj: If obj is set then check the permissions on this context.
        If obj is not given, the site root will be used.
    :type obj: content object
    :raises:
        InvalidParameterError
    :Example: :ref:`user-get-permissions-example`
    """
    if obj is None:
        obj = portal.get()

    if username is None and user is None:
        context = _nop_context_manager()
    else:
        context = env.adopt_user(username, user)

    with context:
        sm = getSecurityManager()
        pms = (record[0] for record in getPermissions())
        result = {pm: bool(sm.checkPermission(pm, obj)) for pm in pms}
    return result


@mutually_exclusive_parameters("username", "user")
def has_permission(permission, username=None, user=None, obj=None):
    """Check whether this user has the given permission.

    Arguments ``username`` and ``user`` are mutually exclusive. You
    can either set one or the other, but not both. if ``username`` and
    ``user`` are not given, the authenticated member will be used.

    :param permission: The permission you wish to check
    :type permission: string
    :param username: Username of the user for which you want to check
        the permission.
    :type username: string
    :param user: User object for which you want to check the permission.
    :type user: MemberData object
    :param obj: If obj is set then check the permission on this context.
        If obj is not given, the site root will be used.
    :type obj: content object
    :raises:
        InvalidParameterError
    :returns: True if the user has the permission, False otherwise.
    :rtype: bool
    """
    if obj is None:
        obj = portal.get()

    if username is None and user is None:
        context = _nop_context_manager()
    else:
        context = env.adopt_user(username, user)

    with context:
        return_value = bool(getSecurityManager().checkPermission(permission, obj))
        if not return_value:
            names = [x[0] for x in getPermissions()]
            if permission not in names:
                raise InvalidParameterError(
                    "Cannot find a permission with name '{permission}'\n"
                    "Available permissions are:\n"
                    "{names}".format(
                        permission=permission, names="\n".join(sorted(names))
                    )
                )
        return return_value


@required_parameters("roles")
@mutually_exclusive_parameters("username", "user")
def grant_roles(username=None, user=None, obj=None, roles=None):
    """Grant roles to a user.

    Arguments ``username`` and ``user`` are mutually exclusive. You
    can either set one or the other, but not both. if ``username`` and
    ``user`` are not given, the authenticated member will be used.

    :param username: Username of the user that will receive the granted roles.
    :type username: string
    :param user: User object that will receive the granted roles.
    :type user: MemberData object
    :param obj: If obj is set then grant roles on this context. If obj is not
        given, the site root will be used.
    :type obj: content object
    :param roles: List of roles to grant
    :type roles: list of strings
    :raises:
        InvalidParameterError
        MissingParameterError
    :Example: :ref:`user-grant-roles-example`
    """
    if user is None:
        user = get(username=username)
    # check we got a user
    if user is None:
        raise InvalidParameterError("User could not be found")

    if isinstance(roles, tuple):
        roles = list(roles)

    # These roles cannot be granted
    if "Anonymous" in roles or "Authenticated" in roles:
        raise InvalidParameterError

    if obj is None:
        actual_roles = get_roles(user=user)
    else:
        # only roles persistent on the object, not from other providers
        actual_roles = obj.get_local_roles_for_userid(username)
    roles = list(set(actual_roles) | set(roles))

    if obj is None:
        user.setSecurityProfile(roles=roles)
    else:
        obj.manage_setLocalRoles(user.getId(), roles)


@required_parameters("roles")
@mutually_exclusive_parameters("username", "user")
def revoke_roles(username=None, user=None, obj=None, roles=None):
    """Revoke roles from a user.

    Arguments ``username`` and ``user`` are mutually exclusive. You
    can either set one or the other, but not both. if ``username`` and
    ``user`` are not given, the authenticated member will be used.

    :param username: Username of the user that will receive the revoked roles.
    :type username: string
    :param user: User object that will receive the revoked roles.
    :type user: MemberData object
    :param obj: If obj is set then revoke roles on this context. If obj is not
        given, the site root will be used.
    :type obj: content object
    :param roles: List of roles to revoke
    :type roles: list of strings
    :raises:
        InvalidParameterError
    :Example: :ref:`user-revoke-roles-example`
    """
    if user is None:
        user = get(username=username)
    # check we got a user
    if user is None:
        raise InvalidParameterError("User could not be found")

    roles = set(roles)

    if "Anonymous" in roles or "Authenticated" in roles:
        raise InvalidParameterError

    inherit = True
    if obj is not None:
        # if obj, get only a list of local roles, without inherited ones
        inherit = False

    actual_roles = {
        role
        for role in get_roles(user=user, obj=obj, inherit=inherit)
        if role not in ["Anonymous", "Authenticated"]
    }

    roles = list(actual_roles - roles)

    if obj is None:
        user.setSecurityProfile(roles=roles)
    elif not roles:
        obj.manage_delLocalRoles([user.getId()])
    else:
        obj.manage_setLocalRoles(user.getId(), roles)
