"""plain hash digests"""

from __future__ import annotations

import re
from base64 import b64decode, b64encode
from hashlib import md5, sha1, sha256, sha512
from typing import TYPE_CHECKING

import passlib.utils.handlers as uh
from passlib.handlers.misc import plaintext
from passlib.utils import to_unicode, unix_crypt_schemes
from passlib.utils.decor import classproperty

if TYPE_CHECKING:
    from passlib._protocols import SHAFunc

__all__ = [
    "ldap_plaintext",
    "ldap_md5",
    "ldap_sha1",
    "ldap_salted_md5",
    "ldap_salted_sha1",
    "ldap_salted_sha256",
    "ldap_salted_sha512",
]


class _Base64DigestHelper(uh.StaticHandler):
    """helper for ldap_md5 / ldap_sha1"""

    # XXX: could combine this with hex digests in digests.py

    ident: str | None = None  # required - prefix identifier
    _hash_func: SHAFunc | None = None  # required - hash function
    _hash_regex: re.Pattern[str] | None = None  # required - regexp to recognize hash
    checksum_chars = uh.PADDED_BASE64_CHARS

    @classproperty
    def _hash_prefix(cls):
        """tell StaticHandler to strip ident from checksum"""
        return cls.ident

    def _calc_checksum(self, secret):
        if isinstance(secret, str):
            secret = secret.encode("utf-8")
        chk = self._hash_func(secret).digest()
        return b64encode(chk).decode("ascii")


class _SaltedBase64DigestHelper(uh.HasRawSalt, uh.HasRawChecksum, uh.GenericHandler):
    """helper for ldap_salted_md5 / ldap_salted_sha1"""

    setting_kwds = ("salt", "salt_size")
    checksum_chars = uh.PADDED_BASE64_CHARS

    ident: str | None = None  # required - prefix identifier
    _hash_func: SHAFunc | None = None  # required - hash function
    _hash_regex: re.Pattern[str] | None = None  # required - regexp to recognize hash
    min_salt_size = max_salt_size = 4

    # NOTE: openldap implementation uses 4 byte salt,
    # but it's been reported (issue 30) that some servers use larger salts.
    # the semi-related rfc3112 recommends support for up to 16 byte salts.
    min_salt_size = 4
    default_salt_size = 4
    max_salt_size = 16

    @classmethod
    def from_string(cls, hash):
        hash = to_unicode(hash, "ascii", "hash")
        m = cls._hash_regex.match(hash)
        if not m:
            raise uh.exc.InvalidHashError(cls)
        try:
            data = b64decode(m.group("tmp").encode("ascii"))
        except TypeError:
            raise uh.exc.MalformedHashError(cls)
        cs = cls.checksum_size
        assert cs
        return cls(checksum=data[:cs], salt=data[cs:])

    def to_string(self):
        data = self.checksum + self.salt
        return self.ident + b64encode(data).decode("ascii")

    def _calc_checksum(self, secret):
        if isinstance(secret, str):
            secret = secret.encode("utf-8")
        return self._hash_func(secret + self.salt).digest()


class ldap_md5(_Base64DigestHelper):
    """This class stores passwords using LDAP's plain MD5 format, and follows the :ref:`password-hash-api`.

    The :meth:`~passlib.ifc.PasswordHash.hash` and :meth:`~passlib.ifc.PasswordHash.genconfig` methods have no optional keywords.
    """

    name = "ldap_md5"
    ident = "{MD5}"
    _hash_func = md5
    _hash_regex = re.compile(r"^\{MD5\}(?P<chk>[+/a-zA-Z0-9]{22}==)$")


class ldap_sha1(_Base64DigestHelper):
    """This class stores passwords using LDAP's plain SHA1 format, and follows the :ref:`password-hash-api`.

    The :meth:`~passlib.ifc.PasswordHash.hash` and :meth:`~passlib.ifc.PasswordHash.genconfig` methods have no optional keywords.
    """

    name = "ldap_sha1"
    ident = "{SHA}"
    _hash_func = sha1
    _hash_regex = re.compile(r"^\{SHA\}(?P<chk>[+/a-zA-Z0-9]{27}=)$")


class ldap_salted_md5(_SaltedBase64DigestHelper):
    """This class stores passwords using LDAP's salted MD5 format, and follows the :ref:`password-hash-api`.

    It supports a 4-16 byte salt.

    The :meth:`~passlib.ifc.PasswordHash.using` method accepts the following optional keywords:

    :type salt: bytes
    :param salt:
        Optional salt string.
        If not specified, one will be autogenerated (this is recommended).
        If specified, it may be any 4-16 byte string.

    :type salt_size: int
    :param salt_size:
        Optional number of bytes to use when autogenerating new salts.
        Defaults to 4 bytes for compatibility with the LDAP spec,
        but some systems use larger salts, and Passlib supports
        any value between 4-16.

    :type relaxed: bool
    :param relaxed:
        By default, providing an invalid value for one of the other
        keywords will result in a :exc:`ValueError`. If ``relaxed=True``,
        and the error can be corrected, a :exc:`~passlib.exc.PasslibHashWarning`
        will be issued instead. Correctable errors include
        ``salt`` strings that are too long.

        .. versionadded:: 1.6

    .. versionchanged:: 1.6
        This format now supports variable length salts, instead of a fix 4 bytes.
    """

    name = "ldap_salted_md5"
    ident = "{SMD5}"
    checksum_size = 16
    _hash_func = md5
    _hash_regex = re.compile(r"^\{SMD5\}(?P<tmp>[+/a-zA-Z0-9]{27,}={0,2})$")


class ldap_salted_sha1(_SaltedBase64DigestHelper):
    """
    This class stores passwords using LDAP's "Salted SHA1" format,
    and follows the :ref:`password-hash-api`.

    It supports a 4-16 byte salt.

    The :meth:`~passlib.ifc.PasswordHash.using` method accepts the following optional keywords:

    :type salt: bytes
    :param salt:
        Optional salt string.
        If not specified, one will be autogenerated (this is recommended).
        If specified, it may be any 4-16 byte string.

    :type salt_size: int
    :param salt_size:
        Optional number of bytes to use when autogenerating new salts.
        Defaults to 4 bytes for compatibility with the LDAP spec,
        but some systems use larger salts, and Passlib supports
        any value between 4-16.

    :type relaxed: bool
    :param relaxed:
        By default, providing an invalid value for one of the other
        keywords will result in a :exc:`ValueError`. If ``relaxed=True``,
        and the error can be corrected, a :exc:`~passlib.exc.PasslibHashWarning`
        will be issued instead. Correctable errors include
        ``salt`` strings that are too long.

        .. versionadded:: 1.6

    .. versionchanged:: 1.6
        This format now supports variable length salts, instead of a fix 4 bytes.
    """

    name = "ldap_salted_sha1"
    ident = "{SSHA}"
    checksum_size = 20
    _hash_func = sha1
    # NOTE: 32 = ceil((20 + 4) * 4/3)
    _hash_regex = re.compile(r"^\{SSHA\}(?P<tmp>[+/a-zA-Z0-9]{32,}={0,2})$")


class ldap_salted_sha256(_SaltedBase64DigestHelper):
    """
    This class stores passwords using LDAP's "Salted SHA2-256" format,
    and follows the :ref:`password-hash-api`.

    It supports a 4-16 byte salt.

    The :meth:`~passlib.ifc.PasswordHash.using` method accepts the following optional keywords:

    :type salt: bytes
    :param salt:
        Optional salt string.
        If not specified, one will be autogenerated (this is recommended).
        If specified, it may be any 4-16 byte string.

    :type salt_size: int
    :param salt_size:
        Optional number of bytes to use when autogenerating new salts.
        Defaults to 8 bytes for compatibility with the LDAP spec,
        but Passlib supports any value between 4-16.

    :type relaxed: bool
    :param relaxed:
        By default, providing an invalid value for one of the other
        keywords will result in a :exc:`ValueError`. If ``relaxed=True``,
        and the error can be corrected, a :exc:`~passlib.exc.PasslibHashWarning`
        will be issued instead. Correctable errors include
        ``salt`` strings that are too long.

    .. versionadded:: 1.7.3
    """

    name = "ldap_salted_sha256"
    ident = "{SSHA256}"
    checksum_size = 32
    default_salt_size = 8
    _hash_func = sha256
    # NOTE: 48 = ceil((32 + 4) * 4/3)
    _hash_regex = re.compile(r"^\{SSHA256\}(?P<tmp>[+/a-zA-Z0-9]{48,}={0,2})$")


class ldap_salted_sha512(_SaltedBase64DigestHelper):
    """
    This class stores passwords using LDAP's "Salted SHA2-512" format,
    and follows the :ref:`password-hash-api`.

    It supports a 4-16 byte salt.

    The :meth:`~passlib.ifc.PasswordHash.using` method accepts the following optional keywords:

    :type salt: bytes
    :param salt:
        Optional salt string.
        If not specified, one will be autogenerated (this is recommended).
        If specified, it may be any 4-16 byte string.

    :type salt_size: int
    :param salt_size:
        Optional number of bytes to use when autogenerating new salts.
        Defaults to 8 bytes for compatibility with the LDAP spec,
        but Passlib supports any value between 4-16.

    :type relaxed: bool
    :param relaxed:
        By default, providing an invalid value for one of the other
        keywords will result in a :exc:`ValueError`. If ``relaxed=True``,
        and the error can be corrected, a :exc:`~passlib.exc.PasslibHashWarning`
        will be issued instead. Correctable errors include
        ``salt`` strings that are too long.

    .. versionadded:: 1.7.3
    """

    name = "ldap_salted_sha512"
    ident = "{SSHA512}"
    checksum_size = 64
    default_salt_size = 8
    _hash_func = sha512
    # NOTE: 91 = ceil((64 + 4) * 4/3)
    _hash_regex = re.compile(r"^\{SSHA512\}(?P<tmp>[+/a-zA-Z0-9]{91,}={0,2})$")


class ldap_plaintext(plaintext):
    """This class stores passwords in plaintext, and follows the :ref:`password-hash-api`.

    This class acts much like the generic :class:`!passlib.hash.plaintext` handler,
    except that it will identify a hash only if it does NOT begin with the ``{XXX}`` identifier prefix
    used by RFC2307 passwords.

    The :meth:`~passlib.ifc.PasswordHash.hash`, :meth:`~passlib.ifc.PasswordHash.genhash`, and :meth:`~passlib.ifc.PasswordHash.verify` methods all require the
    following additional contextual keyword:

    :type encoding: str
    :param encoding:
        This controls the character encoding to use (defaults to ``utf-8``).

        This encoding will be used to encode :class:`!str` passwords
        under Python 2, and decode :class:`!bytes` hashes under Python 3.

    .. versionchanged:: 1.6
        The ``encoding`` keyword was added.
    """

    # NOTE: this subclasses plaintext, since all it does differently
    # is override identify()

    name = "ldap_plaintext"
    _2307_pat = re.compile(r"^\{\w+\}.*$")

    @uh.deprecated_method(deprecated="1.7", removed="2.0")
    @classmethod
    def genconfig(cls):
        # Overridding plaintext.genconfig() since it returns "",
        # but have to return non-empty value due to identify() below
        return "!"

    @classmethod
    def identify(cls, hash):
        # NOTE: identifies all strings EXCEPT those with {XXX} prefix
        hash = uh.to_unicode_for_identify(hash)
        return bool(hash) and cls._2307_pat.match(hash) is None


# =============================================================================
# {CRYPT} wrappers
# the following are wrappers around the base crypt algorithms,
# which add the ldap required {CRYPT} prefix
# =============================================================================
ldap_crypt_schemes = ["ldap_" + name for name in unix_crypt_schemes]


def _init_ldap_crypt_handlers():
    # NOTE: I don't like to implicitly modify globals() like this,
    #       but don't want to write out all these handlers out either :)
    g = globals()
    for wname in unix_crypt_schemes:
        name = "ldap_" + wname
        g[name] = uh.PrefixWrapper(name, wname, prefix="{CRYPT}", lazy=True)
    del g


_init_ldap_crypt_handlers()

##_lcn_host = None
##def get_host_ldap_crypt_schemes():
##    global _lcn_host
##    if _lcn_host is None:
##        from passlib.hosts import host_context
##        schemes = host_context.schemes()
##        _lcn_host = [
##            "ldap_" + name
##            for name in unix_crypt_names
##            if name in schemes
##        ]
##    return _lcn_host
