import logging
import traceback
from dataclasses import dataclass
from typing import List, Optional

from rhodecode.model.validators import Missing
from rhodecode.lib.exceptions import LdapConnectionError, LdapUsernameError, LdapPasswordError, LdapImportError
from rhodecode.authentication.base import AuthLdapBase
from rhodecode.lib.str_utils import safe_str

try:
    import ldap
except ImportError:
    # means that python-ldap is not installed, we use Missing object to mark
    # ldap lib is Missing
    ldap = Missing


class LdapDao(AuthLdapBase):
    default_tls_cert_dir = "/etc/openldap/cacerts"

    scope_labels = {
        ldap.SCOPE_BASE: "SCOPE_BASE",
        ldap.SCOPE_ONELEVEL: "SCOPE_ONELEVEL",
        ldap.SCOPE_SUBTREE: "SCOPE_SUBTREE",
    }

    def __init__(
        self,
        server,
        base_dn,
        port=389,
        bind_dn="",
        bind_pass="",
        tls_kind="PLAIN",
        tls_reqcert="DEMAND",
        tls_cert_file=None,
        tls_cert_dir=None,
        ldap_version=3,
        search_scope="SUBTREE",
        attr_login="uid",
        ldap_filter="",
        timeout=None,
    ):
        if ldap == Missing:
            raise LdapImportError("Missing or incompatible ldap library")

        self.log = logging.getLogger(LdapDao.__name__)

        self.debug = False
        self.timeout = timeout or 60 * 5
        self.ldap_version = ldap_version
        self.ldap_server_type = "ldap"

        self.TLS_KIND = tls_kind

        if self.TLS_KIND == "LDAPS":
            port = port or 636
            self.ldap_server_type += "s"

        OPT_X_TLS_DEMAND = 2
        self.TLS_REQCERT = getattr(ldap, "OPT_X_TLS_%s" % tls_reqcert, OPT_X_TLS_DEMAND)
        self.TLS_CERT_FILE = tls_cert_file or ""
        self.TLS_CERT_DIR = tls_cert_dir or self.default_tls_cert_dir

        # split server into list
        self.SERVER_ADDRESSES = self._get_server_list(server)
        self.LDAP_SERVER_PORT = port

        # USE FOR READ ONLY BIND TO LDAP SERVER
        self.attr_login = attr_login

        self.LDAP_BIND_DN = safe_str(bind_dn)
        self.LDAP_BIND_PASS = safe_str(bind_pass)

        self.SEARCH_SCOPE = getattr(ldap, "SCOPE_%s" % search_scope)
        self.BASE_DN = safe_str(base_dn)
        self.LDAP_FILTER = safe_str(ldap_filter)

    def _get_ldap_conn(self):
        if self.debug:
            ldap.set_option(ldap.OPT_DEBUG_LEVEL, 255)

        if self.TLS_CERT_FILE and hasattr(ldap, "OPT_X_TLS_CACERTFILE"):
            ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, self.TLS_CERT_FILE)

        elif hasattr(ldap, "OPT_X_TLS_CACERTDIR"):
            ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, self.TLS_CERT_DIR)

        if self.TLS_KIND != "PLAIN":
            ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, self.TLS_REQCERT)

        ldap.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF)
        ldap.set_option(ldap.OPT_RESTART, ldap.OPT_ON)

        # init connection now
        ldap_servers = self._build_servers(self.ldap_server_type, self.SERVER_ADDRESSES, self.LDAP_SERVER_PORT)
        self.log.debug("initializing LDAP connection to:%s", ldap_servers)
        ldap_conn = ldap.initialize(ldap_servers)
        ldap_conn.set_option(ldap.OPT_NETWORK_TIMEOUT, self.timeout)
        ldap_conn.set_option(ldap.OPT_TIMEOUT, self.timeout)
        ldap_conn.timeout = self.timeout

        if self.ldap_version == 2:
            ldap_conn.protocol = ldap.VERSION2
        else:
            ldap_conn.protocol = ldap.VERSION3

        if self.TLS_KIND == "START_TLS":
            ldap_conn.start_tls_s()

        if self.LDAP_BIND_DN and self.LDAP_BIND_PASS:
            self.log.debug("Trying simple_bind with password and given login DN: %r", self.LDAP_BIND_DN)
            ldap_conn.simple_bind_s(self.LDAP_BIND_DN, self.LDAP_BIND_PASS)
        self.log.debug("simple_bind successful")
        return ldap_conn

    def fetch_all(self, ldap_filter: str = "(objectClass=*)", attributes: List[str] = None) -> Optional[List[dict]]:
        ldap_conn = None
        try:
            if attributes is None:
                attributes = ["*", "+"]

            ldap_conn = self._get_ldap_conn()
            self.log.debug("fetching users for DN: %s", self.BASE_DN)
            ldap_objects = ldap_conn.search_ext_s(self.BASE_DN, self.SEARCH_SCOPE, ldap_filter, attrlist=attributes)
            return [attrs for _, attrs in ldap_objects]
        except Exception as e:
            self.log.error("Error fetching users for DN: %s. Error: %s", self.BASE_DN, str(e))
            return None
        finally:
            self._releease_connection(ldap_conn)

    def _releease_connection(self, ldap_conn):
        if ldap_conn:
            self.log.debug("ldap: connection release")
            try:
                ldap_conn.unbind_s()
            except Exception as e:
                # for any reason this can raise exception we must catch it
                # to not crush the server
                self.log.warning("unbind_s failed, error: %s", str(e))

    def _fetch_attrs_from_simple_bind(self, ldap_conn, dn, username, password):
        scope = ldap.SCOPE_BASE
        scope_label = self.scope_labels.get(scope)
        ldap_filter = "(objectClass=*)"

        try:
            self.log.debug(
                "Trying authenticated search bind with dn: %r SCOPE: %s (and filter: %s)", dn, scope_label, ldap_filter
            )
            ldap_conn.simple_bind_s(dn, safe_str(password))
            response = ldap_conn.search_ext_s(dn, scope, ldap_filter, attrlist=["*", "+"])

            if not response:
                self.log.error("search bind returned empty results: %r", response)
                return {}
            else:
                _dn, attrs = response[0]
                return attrs

        except ldap.INVALID_CREDENTIALS:
            self.log.debug("LDAP rejected password for user '%s': %s, org_exc:", username, dn, exc_info=True)

    def authenticate_ldap(self, username, password):
        """
        Authenticate a user via LDAP and return his/her LDAP properties.

        Raises AuthenticationError if the credentials are rejected, or
        EnvironmentError if the LDAP server can't be reached.

        :param username: username
        :param password: password
        """

        uid = self.get_uid(username, self.SERVER_ADDRESSES)

        self.validate_password(username, password)
        self.validate_username(username)
        scope_label = self.scope_labels.get(self.SEARCH_SCOPE)

        ldap_conn = None
        try:
            ldap_conn = self._get_ldap_conn()
            filter_ = "(&{}({}={}))".format(self.LDAP_FILTER, self.attr_login, username)
            self.log.debug("Authenticating %r filter %s and scope: %s", self.BASE_DN, filter_, scope_label)

            ldap_objects = ldap_conn.search_ext_s(self.BASE_DN, self.SEARCH_SCOPE, filter_, attrlist=["*", "+"])

            if not ldap_objects:
                self.log.debug("No matching LDAP objects for authentication of UID:'%s' username:(%s)", uid, username)
                raise ldap.NO_SUCH_OBJECT()

            self.log.debug(
                "Found %s matching ldap object[s], trying to authenticate on each one now...", len(ldap_objects)
            )
            for dn, _attrs in ldap_objects:
                if dn is None:
                    continue

                user_attrs = self._fetch_attrs_from_simple_bind(ldap_conn, dn, username, password)

                if user_attrs:
                    self.log.debug("Got authenticated user attributes from DN:%s", dn)
                    break
            else:
                raise LdapPasswordError(f"Failed to authenticate user `{username}` with given password")

        except ldap.NO_SUCH_OBJECT:
            self.log.debug("LDAP says no such user '%s' (%s), org_exc:", uid, username, exc_info=True)
            raise LdapUsernameError("Unable to find user")
        except ldap.SERVER_DOWN:
            org_exc = traceback.format_exc()
            raise LdapConnectionError("LDAP can't access authentication server, org_exc:%s" % org_exc)
        finally:
            self._releease_connection(ldap_conn)

        return dn, user_attrs
