# Copyright (C) 2020 GlodoUK <https://www.glodo.uk>
# Copyright (C) 2010-2016 XCG Consulting <http://odoo.consulting>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

import logging
from typing import Set

import passlib

from odoo import SUPERUSER_ID, _, api, fields, models, registry, tools
from odoo.exceptions import AccessDenied, ValidationError

from .ir_config_parameter import ALLOW_SAML_UID_AND_PASSWORD

_logger = logging.getLogger(__name__)


class ResUser(models.Model):
    """
    Add SAML login capabilities to Odoo users.
    """

    _inherit = "res.users"

    saml_ids = fields.One2many("res.users.saml", "user_id")

    def _auth_saml_validate(self, provider_id: int, token: str, base_url: str = None):
        provider = self.env["auth.saml.provider"].sudo().browse(provider_id)
        return provider._validate_auth_response(token, base_url)

    def _auth_saml_signin(self, provider: int, validation: dict, saml_response) -> str:
        """Sign in Odoo user corresponding to provider and validated access token.

        :param provider: SAML provider id
        :param validation: result of validation of access token
        :param saml_response: saml parameters response from the IDP
        :return: user login
        :raise: odoo.exceptions.AccessDenied if signin failed

        This method can be overridden to add alternative signin methods.
        """
        saml_uid = validation["user_id"]
        user_saml = self.env["res.users.saml"].search(
            [("saml_uid", "=", saml_uid), ("saml_provider_id", "=", provider)],
            limit=1,
        )
        user = user_saml.user_id
        if len(user) != 1:
            raise AccessDenied()

        with registry(self.env.cr.dbname).cursor() as new_cr:
            new_env = api.Environment(new_cr, self.env.uid, self.env.context)
            # Update the token. Need to be committed, otherwise the token is not visible
            # to other envs, like the one used in login_and_redirect
            user_saml.with_env(new_env).write({"saml_access_token": saml_response})

        if validation.get("mapped_attrs", {}):
            user.write(validation.get("mapped_attrs", {}))

        return user.login

    @api.model
    def auth_saml(self, provider: int, saml_response: str, base_url: str = None):
        validation = self._auth_saml_validate(provider, saml_response, base_url)

        # required check
        if not validation.get("user_id"):
            raise AccessDenied()

        # retrieve and sign in user
        login = self._auth_saml_signin(provider, validation, saml_response)

        if not login:
            raise AccessDenied()

        # return user credentials
        return self.env.cr.dbname, login, saml_response

    def _check_credentials(self, password, env):
        """Override to handle SAML auths.

        The token can be a password if the user has used the normal form...
        but we are more interested in the case when they are tokens
        and the interesting code is inside the "except" clause.
        """
        try:
            # Attempt a regular login (via other auth addons) first.
            return super()._check_credentials(password, env)

        except (AccessDenied, passlib.exc.PasswordSizeError):
            passwd_allowed = (
                env["interactive"] or not self.env.user._rpc_api_keys_only()
            )
            if passwd_allowed and self.env.user.active:
                # since normal auth did not succeed we now try to find if the user
                # has an active token attached to his uid
                token = (
                    self.env["res.users.saml"]
                    .sudo()
                    .search(
                        [
                            ("user_id", "=", self.env.user.id),
                            ("saml_access_token", "=", password),
                        ]
                    )
                )
                if token:
                    return
            raise AccessDenied() from None

    @api.model
    def _saml_allowed_user_ids(self) -> Set[int]:
        """Users that can have a password even if the option to disallow it is set.

        It includes superuser and the admin if it exists.
        """
        allowed_users = {SUPERUSER_ID}
        user_admin = self.env.ref("base.user_admin", False)
        if user_admin:
            allowed_users.add(user_admin.id)
        return allowed_users

    @api.model
    def allow_saml_and_password(self) -> bool:
        """Can both SAML and local password auth methods coexist."""
        return tools.str2bool(
            self.env["ir.config_parameter"]
            .sudo()
            .get_param(ALLOW_SAML_UID_AND_PASSWORD)
        )

    def _set_password(self):
        """Inverse method of the password field."""
        # Redefine base method to block setting password on users with SAML ids
        # And also to be able to set password to a blank value
        if not self.allow_saml_and_password():
            saml_users = self.filtered(
                lambda user: user.sudo().saml_ids
                and user.id not in self._saml_allowed_user_ids()
                and user.password
            )
            if saml_users:
                # same error as an api.constrains because it is a constraint.
                # a standard constrains would require the use of SQL as the password
                # field is obfuscated by the base module.
                raise ValidationError(
                    _(
                        "This database disallows users to "
                        "have both passwords and SAML IDs. "
                        "Error for logins %s"
                    )
                    % saml_users.mapped("login")
                )
        # handle setting password to NULL
        blank_password_users = self.filtered(lambda user: user.password is False)
        non_blank_password_users = self - blank_password_users
        if non_blank_password_users:
            # pylint: disable=protected-access
            super(ResUser, non_blank_password_users)._set_password()
        if blank_password_users:
            # similar to what Odoo does in Users._set_encrypted_password
            self.env.cr.execute(
                "UPDATE res_users SET password = NULL WHERE id IN %s",
                (tuple(blank_password_users.ids),),
            )
            blank_password_users.invalidate_recordset(fnames=["password"])
        return

    def allow_saml_and_password_changed(self):
        """Called after the parameter is changed."""
        if not self.allow_saml_and_password():
            # sudo because the user doing the parameter change might not have the right
            # to search or write users
            users_to_blank_password = self.sudo().search(
                [
                    "&",
                    ("saml_ids", "!=", False),
                    ("id", "not in", list(self._saml_allowed_user_ids())),
                ]
            )
            _logger.debug(
                "Removing password from %s user(s)", len(users_to_blank_password)
            )
            users_to_blank_password.write({"password": False})
