"""Contains base classes for laurelin.ldap"""
from __future__ import absolute_import

from . import controls
from . import rfc4511
from . import utils
from .constants import Scope, DerefAliases, DELETE_ALL, FilterSyntax
from .exceptions import *
from .extensible import add_extension
from .extensible.ldap_extensions import LDAPExtensions
from .filter import parse as parse_unified_filter, parse_standard_filter, parse_simple_filter
from .ldapobject import LDAPObject
from .modify import (
    Mod,
    Modlist,
    AddModlist,
    DeleteModlist,
)
from .net import LDAPSocket
from .protoutils import (
    V3,
    EMPTY_DN,
    RESULT_saslBindInProgress,
    RESULT_success,
    RESULT_noSuchObject,
    RESULT_compareTrue,
    RESULT_compareFalse,
    RESULT_referral,
    unpack,
    seq_to_list,
    get_string_component,
)
from .validation import Validator, DisabledValidationContext

import logging
import re
import six
import warnings
from base64 import b64decode
from collections import deque
from six.moves import range
from six.moves.urllib.parse import urlparse
from six.moves.urllib.request import urlopen
from warnings import warn

logger = logging.getLogger('laurelin.ldap')
logger.addHandler(logging.NullHandler())
logger.setLevel(logging.DEBUG)  # set to DEBUG to allow handler levels full discretion


_showwarning_default = warnings.showwarning


def _showwarning_disabled(message, category, filename, lineno, file=None, line=None):
    if not issubclass(category, LDAPWarning):
        _showwarning_default(message, category, filename, lineno, file, line)


def _showwarning_log(message, category, filename, lineno, file=None, line=None):
    if issubclass(category, LDAPWarning):
        logger.warning('{0}: {1}'.format(category.__name__, message))
    else:
        _showwarning_default(message, category, filename, lineno, file, line)


# for storing reusable sockets
_sockets = {}

# this gets automatically generated by the reserve_kwds.py script
_obj_kwds = set(['attrs_dict', 'dn', 'ldap_conn', 'rdn_attr', 'relative_search_scope', 'self', 'tag'])


def _check_obj_kwds(kwds):
    bad_kwds = set()
    for kwd in kwds:
        if kwd not in _obj_kwds:
            bad_kwds.add(kwd)
    if bad_kwds:
        raise TypeError('Unknown keyword arguments: {0}'.format(', '.join(bad_kwds)))


class LDAP(LDAPExtensions):
    """Provides the connection to the LDAP DB. All constructor parameters have a matching global default as a class
    property on :class:`LDAP`

    :param server: URI string to connect to or an :class:`LDAPSocket` to reuse
    :type server: str or LDAPSocket
    :param str base_dn: The DN of the base object
    :param bool reuse_connection: Allows the socket connection to be reused and reuse an existing socket if
                                  possible.
    :param int connect_timeout: Number of seconds to wait for connection to be accepted.
    :param int search_timeout: Number of seconds to wait for a search to complete. Partial results will be returned
                               when the timeout is reached. Can be overridden on a per-search basis by setting the
                               ``search_timeout`` keyword on :meth:`LDAP.search`.
    :param DerefAliases deref_aliases: One of the :class:`DerefAliases` constants. Instructs the server how to handle
                                       alias objects in search results. Can be overridden on a per-search basis by
                                       setting the ``deref_aliases`` keyword on :meth:`LDAP.search`.
    :param bool strict_modify: Use the strict modify strategy. If set to True, guarantees that another search will not
                               take place before a modify operation. May potentially produce more server errors.
    :param bool ssl_verify: Validate the certificate and hostname on an SSL/TLS connection
    :param str ssl_ca_file: Path to PEM-formatted concatenated CA certficates file
    :param str ssl_ca_path: Path to directory with CA certs under hashed file names. See
                            https://www.openssl.org/docs/man1.1.0/ssl/SSL_CTX_load_verify_locations.html for more
                            information about the format of this directory.
    :param ssl_ca_data: An ASCII string of one or more PEM-encoded certs or a bytes object containing DER-encoded
                        certificates.
    :type ssl_ca_data: str or bytes
    :param bool fetch_result_refs: Fetch searchResultRef responses in search results. Can be overridden on a per-search
                                   basis by setting the ``fetch_result_refs`` keyword on :meth:`LDAP.search`.
    :param str default_sasl_mech: Name of the default SASL mechanism. Bind will fail if the server does not support the
                                  mechanism. (Examples: DIGEST-MD5, GSSAPI)
    :param bool sasl_fatal_downgrade_check: Set to False to make potential downgrade attack check non-fatal.
    :param bool default_criticality: Set to True to make controls critical by default, set to False to make non-critical
    :param bool follow_referrals: Automatically follow referral results
    :param list[Validator] validators: A list of :class:`Validator` instances to apply to this connection.
    :param bool warn_empty_list: Default False. Set to True to emit a warning when an empty value list is passed to
                                 :meth:`.LDAP.modify`, :meth:`.LDAP.replace_attrs`, or :meth:`.LDAP.delete_attrs` or
                                 their LDAPObject counterparts.
    :param bool error_empty_list: Default False. Set to True to raise an exception when an empty value list is passed to
                                  :meth:`.LDAP.modify`, :meth:`.LDAP.replace_attrs`, or :meth:`.LDAP.delete_attrs` or
                                  their LDAPObject counterparts.
    :param bool ignore_empty_list: Default False. Set to True to ignore empty value lists passed to
                                   :meth:`.LDAP.modify`, :meth:`.LDAP.replace_attrs`, or :meth:`.LDAP.delete_attrs` or
                                   their LDAPObject counterparts. This will be default True in a future release.
    :param FilterSyntax filter_syntax: The default search filter syntax selection. Must be one of the
                                       :class:`.FilterSyntax` constants. Can be overridden on a per-search basis
                                       by setting the ``filter_syntax`` keyword on :meth:`LDAP.search`. Defaults
                                       to ``FilterSyntax.STANDARD`` for RFC4515-compliant filter string syntax.
    :param bool built_in_extensions_only: Set to True to raise an error when attempting to use a 3rd-party extension

    The class can be used as a context manager, which will automatically unbind and close the connection when the
    context manager exits.

    Example::

        with LDAP() as ldap:
            raise Exception()
        # ldap is closed and unbound

        with LDAP() as ldap:
            print('hello')
        # ldap is closed and unbound
    """

    DELETE_ALL = DELETE_ALL
    """Use with modify replace/delete in place of an attribute list to delete all values for the attribute"""

    # global defaults
    DEFAULT_SERVER = 'ldap://localhost'
    DEFAULT_BASE_DN = None
    DEFAULT_FILTER = '(objectClass=*)'
    DEFAULT_DEREF_ALIASES = DerefAliases.ALWAYS
    DEFAULT_SEARCH_TIMEOUT = 0
    DEFAULT_CONNECT_TIMEOUT = 5
    DEFAULT_STRICT_MODIFY = False
    DEFAULT_REUSE_CONNECTION = True
    DEFAULT_SSL_VERIFY = True
    DEFAULT_SSL_CA_FILE = None
    DEFAULT_SSL_CA_PATH = None
    DEFAULT_SSL_CA_DATA = None
    DEFAULT_FETCH_RESULT_REFS = True
    DEFAULT_FOLLOW_REFERRALS = True
    DEFAULT_SASL_MECH = None
    DEFAULT_SASL_FATAL_DOWNGRADE_CHECK = True
    DEFAULT_CRITICALITY = False
    DEFAULT_VALIDATORS = None
    DEFAULT_WARN_EMPTY_LIST = False
    DEFAULT_ERROR_EMPTY_LIST = False
    DEFAULT_IGNORE_EMPTY_LIST = True
    DEFAULT_FILTER_SYNTAX = FilterSyntax.UNIFIED
    DEFAULT_BUILT_IN_EXTENSIONS_ONLY = False

    # spec constants
    NO_ATTRS = '1.1'

    # logging config
    LOG_FORMAT = '[%(asctime)s] %(name)s %(levelname)s : %(message)s'

    # OIDs
    OID_OBJ_CLASS_ATTR = '1.3.6.1.4.1.4203.1.5.2'  # RFC 4529 Requesting Attributes by Object Class
    OID_WHOAMI = '1.3.6.1.4.1.4203.1.11.3'  # RFC 4532 "Who am I?" Operation
    OID_STARTTLS = '1.3.6.1.4.1.1466.20037'  # RFC 4511 StartTLS Operation

    ## logging and warning controls

    @staticmethod
    def enable_logging(level=logging.DEBUG):
        """Enable logging output to stderr"""
        stderr_handler = logging.StreamHandler()
        stderr_handler.setFormatter(logging.Formatter(LDAP.LOG_FORMAT))
        stderr_handler.setLevel(level)
        logger.addHandler(stderr_handler)
        return stderr_handler

    @staticmethod
    def disable_warnings():
        """Prevent all LDAP warnings from being shown - default action for others"""
        warnings.showwarning = _showwarning_disabled

    @staticmethod
    def log_warnings():
        """Log all LDAP warnings rather than showing them - default action for others"""
        warnings.showwarning = _showwarning_log

    @staticmethod
    def default_warnings():
        """Always take the default action for warnings"""
        warnings.showwarning = _showwarning_default

    add_extension = staticmethod(add_extension)

    ## basic methods

    def __enter__(self):
        return self

    def __exit__(self, etype, e, trace):
        self.close()

    def __init__(self, server=None, base_dn=None, reuse_connection=None, connect_timeout=None, search_timeout=None,
                 deref_aliases=None, strict_modify=None, ssl_verify=None, ssl_ca_file=None, ssl_ca_path=None,
                 ssl_ca_data=None, fetch_result_refs=None, default_sasl_mech=None, sasl_fatal_downgrade_check=None,
                 default_criticality=None, follow_referrals=None, validators=None, warn_empty_list=None,
                 error_empty_list=None, ignore_empty_list=None, filter_syntax=None, built_in_extensions_only=None):

        LDAPExtensions.__init__(self)

        # setup
        if server is None:
            server = LDAP.DEFAULT_SERVER
        if base_dn is None:
            base_dn = LDAP.DEFAULT_BASE_DN
        if reuse_connection is None:
            reuse_connection = LDAP.DEFAULT_REUSE_CONNECTION
        if connect_timeout is None:
            connect_timeout = LDAP.DEFAULT_CONNECT_TIMEOUT
        if search_timeout is None:
            search_timeout = LDAP.DEFAULT_SEARCH_TIMEOUT
        if deref_aliases is None:
            deref_aliases = LDAP.DEFAULT_DEREF_ALIASES
        if strict_modify is None:
            strict_modify = LDAP.DEFAULT_STRICT_MODIFY
        if ssl_verify is None:
            ssl_verify = LDAP.DEFAULT_SSL_VERIFY
        if ssl_ca_file is None:
            ssl_ca_file = LDAP.DEFAULT_SSL_CA_FILE
        if ssl_ca_path is None:
            ssl_ca_path = LDAP.DEFAULT_SSL_CA_PATH
        if ssl_ca_data is None:
            ssl_ca_data = LDAP.DEFAULT_SSL_CA_DATA
        if fetch_result_refs is None:
            fetch_result_refs = LDAP.DEFAULT_FETCH_RESULT_REFS
        if default_sasl_mech is None:
            default_sasl_mech = LDAP.DEFAULT_SASL_MECH
        if sasl_fatal_downgrade_check is None:
            sasl_fatal_downgrade_check = LDAP.DEFAULT_SASL_FATAL_DOWNGRADE_CHECK
        if default_criticality is None:
            default_criticality = LDAP.DEFAULT_CRITICALITY
        if follow_referrals is None:
            follow_referrals = LDAP.DEFAULT_FOLLOW_REFERRALS
        if validators is None:
            validators = LDAP.DEFAULT_VALIDATORS
        if warn_empty_list is None:
            warn_empty_list = LDAP.DEFAULT_WARN_EMPTY_LIST
        if error_empty_list is None:
            error_empty_list = LDAP.DEFAULT_ERROR_EMPTY_LIST
        if ignore_empty_list is None:
            ignore_empty_list = LDAP.DEFAULT_IGNORE_EMPTY_LIST
        if filter_syntax is None:
            filter_syntax = LDAP.DEFAULT_FILTER_SYNTAX
        if built_in_extensions_only is None:
            built_in_extensions_only = LDAP.DEFAULT_BUILT_IN_EXTENSIONS_ONLY

        self.default_search_timeout = search_timeout
        self.default_deref_aliases = deref_aliases
        self.default_fetch_result_refs = fetch_result_refs
        self.default_follow_referrals = follow_referrals
        self.default_sasl_mech = default_sasl_mech
        self.default_criticality = default_criticality
        self.default_filter_syntax = filter_syntax

        self.strict_modify = strict_modify
        self.sasl_fatal_downgrade_check = sasl_fatal_downgrade_check
        self.warn_empty_list = warn_empty_list
        self.error_empty_list = error_empty_list
        self.ignore_empty_list = ignore_empty_list

        self._tagged_objects = {}
        self._sasl_mechs = None

        self._built_in_only = built_in_extensions_only

        self.sock_params = (connect_timeout, ssl_verify, ssl_ca_file, ssl_ca_path, ssl_ca_data)
        self.ssl_verify = ssl_verify
        self.ssl_ca_file = ssl_ca_file
        self.ssl_ca_path = ssl_ca_path
        self.ssl_ca_data = ssl_ca_data

        # connect
        if isinstance(server, six.string_types):
            self.host_uri = server
            if reuse_connection:
                if self.host_uri not in _sockets:
                    _sockets[self.host_uri] = LDAPSocket(self.host_uri, *self.sock_params)
                self.sock = _sockets[self.host_uri]
            else:
                self.sock = LDAPSocket(self.host_uri, *self.sock_params)
            logger.info('Connected to {0} (#{1})'.format(self.host_uri, self.sock.ID))
        elif isinstance(server, LDAPSocket):
            self.sock = server
            self.host_uri = server.uri
            logger.info('Using existing socket {0} (#{1})'.format(self.host_uri, self.sock.ID))
        else:
            raise TypeError('Must supply URI string or LDAPSocket for server')
        self.sock.refcount += 1

        # Validation setup
        self.validators = []
        if validators is None:
            validators = []
        for validator in validators:
            if isinstance(validator, six.class_types):
                validator = validator()
            if not isinstance(validator, Validator):
                raise TypeError('Validators must subclass laurelin.ldap.Validator')
            logger.info('Using validator {0}'.format(validator.__class__.__name__))
            validator.ldap_conn = self
            self.validators.append(validator)

        # find base_dn
        self.root_dse = None
        self.refresh_root_dse()
        if base_dn is None:
            if 'defaultNamingContext' in self.root_dse:
                base_dn = self.root_dse['defaultNamingContext'][0]
            else:
                ncs = self.root_dse.get_attr('namingContexts')
                n = len(ncs)
                if n == 0:
                    raise LDAPError('base_dn must be provided - no namingContexts')
                elif n == 1:
                    base_dn = ncs[0]
                else:
                    raise LDAPError('base_dn must be provided - multiple namingContexts')
        self.base_dn = base_dn

        if self.default_sasl_mech is None and self.host_uri.startswith('ldapi:'):
            self.default_sasl_mech = 'EXTERNAL'

        logger.debug('Creating base object for {0}'.format(self.base_dn))
        self.base = self.obj(self.base_dn, relative_search_scope=Scope.SUBTREE)

    def refresh_root_dse(self):
        """Update the local copy of the root DSE, containing metadata about the directory server. The root DSE is an
        :class:`LDAPObject` stored on the `root_dse` attribute.
        """
        self.root_dse = self.get('', ['*', '+'])
        self._sasl_mechs = self.root_dse.get_attr('supportedSASLMechanisms')

    def _process_ctrl_kwds(self, method, kwds, final=False):
        supported_ctrls = self.root_dse.get_attr('supportedControl')
        default_crit = self.default_criticality
        return controls.process_kwds(method, kwds, supported_ctrls, default_crit, final)

    def _success_result(self, message_id, operation):
        """Receive an object from the socket and raise an LDAPError if its not a success result.

        :param int message_id: The message ID a response is expected for
        :param str operation: The name of the protocol operation expected, e.g. bindResponse, searchResEntry
        :return: A response object
        :rtype: LDAPResponse
        :raises LDAPError: if the response recieved does not indicate a success
        """
        mid, obj, res_ctrls = unpack(operation, self.sock.recv_one(message_id))
        res = obj.getComponentByName('resultCode')
        if res == RESULT_success:
            logger.debug('LDAP operation (ID {0}) was successful'.format(mid))
            ret = LDAPResponse()
            controls.handle_response(ret, res_ctrls)
            return ret
        else:
            msg = obj.getComponentByName('diagnosticMessage')
            raise LDAPError('Got {0} for {1} (ID {2}) ({3})'.format(repr(res), operation, mid, msg))

    def simple_bind(self, username='', password='', **ctrl_kwds):
        """Performs a simple bind operation

        Leave arguments as their default (empty strings) to attempt an anonymous simple bind

        Additional keywords are used as :doc:`/controls`.

        :param str username: Bind DN/username or empty string for anonymous
        :param str password: Password to bind with or empty string for anonymous
        :return: A response object
        :rtype: LDAPResponse
        :raises ConnectionUnbound: if the connection has been unbound/closed
        :raises ConnectionAlreadyBound: if the connection has already been bound
        """
        if self.sock.unbound:
            raise ConnectionUnbound()
        if self.sock.bound:
            raise ConnectionAlreadyBound()

        br = rfc4511.BindRequest()
        br.setComponentByName('version', V3)
        br.setComponentByName('name', rfc4511.LDAPDN(username))
        ac = rfc4511.AuthenticationChoice()
        ac.setComponentByName('simple', rfc4511.Simple(password))
        br.setComponentByName('authentication', ac)

        req_ctrls = self._process_ctrl_kwds('bind', ctrl_kwds, final=True)

        mid = self.sock.send_message('bindRequest', br, req_ctrls)
        logger.debug('Sent bind request (ID {0}) on connection #{1} for {2}'.format(mid, self.sock.ID, username))
        ret = self._success_result(mid, 'bindResponse')
        self.sock.bound = True
        logger.info('Simple bind successful')
        return ret

    def get_sasl_mechs(self):
        """Query root DSE for supported SASL mechanisms.

        :return: The list of server-supported mechanism names.
        :rtype: list[str]
        """

        if self._sasl_mechs is None:
            logger.debug('Querying server to find supported SASL mechs')
            o = self.get('', ['supportedSASLMechanisms'])
            self._sasl_mechs = o.get_attr('supportedSASLMechanisms')
            logger.debug('Server supported SASL mechs = {0}'.format(','.join(self._sasl_mechs)))
        return self._sasl_mechs

    def recheck_sasl_mechs(self):
        """Query the root DSE again after performing a SASL bind to check for a downgrade attack.

        :raises LDAPError: If the downgrade attack check fails and sasl_fatal_downgrade_check has not been set to False.
        """

        if self._sasl_mechs is None:
            raise LDAPError('SASL mechs have not yet been queried')
        else:
            orig_mechs = set(self._sasl_mechs)
            self._sasl_mechs = None
            self.get_sasl_mechs()
            if orig_mechs != set(self._sasl_mechs):
                msg = 'Supported SASL mechs differ on recheck, possible downgrade attack'
                if self.sasl_fatal_downgrade_check:
                    raise LDAPError(msg)
                else:
                    warn(msg, LDAPWarning)
            else:
                logger.debug('No evidence of downgrade attack')

    def sasl_bind(self, mech=None, **props):
        """Perform a SASL bind operation.

        Keywords are first taken as :doc:`/controls`.
        Required keyword args are dependent on the mechanism chosen.

        :param str mech: The SASL mechanism name to use or None to negotiate best mutually supported mechanism.
        :return: A response object
        :rtype: LDAPResponse
        :raises ConnectionUnbound: if the connection has been unbound/closed
        :raises ConnectionAlreadyBound: if the connection has already been bound
        :raises LDAPSupportError: if the given mech is not supported by the server
        :raises LDAPError: if an error occurs during the bind process
        """
        if self.sock.unbound:
            raise ConnectionUnbound()
        if self.sock.bound:
            raise ConnectionAlreadyBound()

        req_ctrls = self._process_ctrl_kwds('bind', props)

        mechs = self.get_sasl_mechs()
        if mech is None:
            mech = self.default_sasl_mech
        if mech is not None:
            if mech not in mechs:
                raise LDAPSupportError('SASL mech "{0}" is not supported by the server'.format(mech))
            else:
                mechs = [mech]
        self.sock.sasl_init(mechs, **props)
        logger.debug('Selected SASL mech = {0}'.format(self.sock.sasl_mech))

        challenge_response = None
        while True:
            br = rfc4511.BindRequest()
            br.setComponentByName('version', V3)
            br.setComponentByName('name', EMPTY_DN)
            ac = rfc4511.AuthenticationChoice()
            sasl = rfc4511.SaslCredentials()
            sasl.setComponentByName('mechanism', six.text_type(self.sock.sasl_mech))
            if challenge_response is not None:
                sasl.setComponentByName('credentials', rfc4511.Credentials(challenge_response))
                challenge_response = None
            ac.setComponentByName('sasl', sasl)
            br.setComponentByName('authentication', ac)

            mid = self.sock.send_message('bindRequest', br, req_ctrls)
            logger.debug('Sent SASL bind request (ID {0}) on connection #{1}'.format(mid, self.sock.ID))

            mid, res, res_ctrls = unpack('bindResponse', self.sock.recv_one(mid))
            status = res.getComponentByName('resultCode')
            if status == RESULT_saslBindInProgress:
                challenge_response = self.sock.sasl_process_auth_challenge(res.getComponentByName('serverSaslCreds'))
                continue
            elif status == RESULT_success:
                logger.info('SASL bind successful')
                logger.debug('Negotiated SASL QoP = {0}'.format(self.sock.sasl_qop))
                self.sock.bound = True
                self.recheck_sasl_mechs()

                ret = LDAPResponse()
                controls.handle_response(ret, res_ctrls)
                return ret
            else:
                msg = res.getComponentByName('diagnosticMessage')
                raise LDAPError('Got {0} during SASL bind ({1})'.format(repr(status), msg))
        raise LDAPError('Programming error - reached end of saslBind')

    def unbind(self, force=False):
        """Send an unbind request and close the socket.

        :param bool force: Unbind and close the socket even if other objects still hold a reference to it.
        :raises ConnectionUnbound: if the connection has already been unbound
        """
        if self.sock.unbound:
            raise ConnectionUnbound()

        self.sock.refcount -= 1
        if force or self.sock.refcount == 0:
            self.sock.send_message('unbindRequest', rfc4511.UnbindRequest())
            self.sock.close()
            self.sock.unbound = True
            logger.info('Unbound on {0} (#{1})'.format(self.sock.uri, self.sock.ID))
            try:
                del _sockets[self.sock.uri]
            except KeyError:
                pass
        else:
            logger.debug('Socket still in use')

    close = unbind

    def tag(self, tag):
        """Get a tagged object.

        :param str tag: The tag name to retrieve
        :return: The object created with the given tag
        :rtype: LDAPObject
        :raises TagError: if the given tag is not defined
        """
        try:
            return self._tagged_objects[tag]
        except KeyError:
            raise TagError('tag {0} does not exist'.format(tag))

    def obj(self, dn, attrs_dict=None, tag=None, **kwds):
        """Factory for LDAPObjects bound to this connection.

        Note that this does not query the server. Use :meth:`LDAP.get` to query the server for a particular DN.

        :param str dn: The DN of the object.
        :param attrs_dict: Optional. The object's attributes and values.
        :type attrs_dict: dict(str, list[str or bytes]) or AttrsDict or None
        :param tag: Optional. The tag for this object. Tagged objects can be retrieved with :meth:`LDAP.tag`.
        :type tag: str or None
        :return: The new object bound to this connection.
        :rtype: LDAPObject
        :raises TagError: if the tag parameter is already defined

        Additional keywords are passed through into the :class:`LDAPObject` constructor.
        """
        obj = LDAPObject(dn, attrs_dict=attrs_dict, ldap_conn=self, **kwds)
        if tag is not None:
            if tag in self._tagged_objects:
                raise TagError('tag {0} already exists'.format(tag))
            else:
                self._tagged_objects[tag] = obj
        return obj

    def get(self, dn, attrs=None, **kwds):
        """Get a specific object by DN.

        Performs a search with :attr:`Scope.BASE` and ensures we get exactly one result.

        :param str dn: The DN of the object to query
        :param attrs: Optional. A list of attribute names to get, defaults to all user attributes
        :type attrs: list[str] or None
        :return: The LDAP object
        :rtype: LDAPObject
        :raises ConnectionUnbound: if the connection has been unbound
        :raises NoSearchResults: if no results are returned
        :raises MultipleSearchResults: if more than one result is returned

        Additional keyword arguments are passed through into :meth:`LDAP.search`.
        """
        if self.sock.unbound:
            raise ConnectionUnbound()
        results = list(self.search(dn, Scope.BASE, attrs=attrs, limit=2, **kwds))
        return utils.get_one_result(results)

    def exists(self, dn):
        """Simply check if a DN exists.

        :param str dn: The DN to check
        :return: True if the object exists, False if not
        :rtype: bool
        """
        if self.sock.unbound:
            raise ConnectionUnbound()
        try:
            self.get(dn, [LDAP.NO_ATTRS])
            return True
        except NoSearchResults:
            return False
        except MultipleSearchResults:
            return True

    def search(self, base_dn, scope=Scope.SUBTREE, filter=None, attrs=None, search_timeout=None, limit=0,
               deref_aliases=None, attrs_only=False, fetch_result_refs=None, follow_referrals=None,
               filter_syntax=None, **kwds):
        """Sends search and return an iterator over results.

        :param str base_dn: The DN of the base object of the search
        :param Scope scope: One of the :class:`Scope` constants, default :attr:`Scope.SUB`. Controls the maximum depth
                            of the search.
        :param str filter: A filter string. Objects must match the filter to be included in results. Default includes
                           all objects and can be overridden globally by defining :attr:`.LDAP.DEFAULT_FILTER`.
        :param list[str] attrs: A list of attribute names to include for each object. Default includes all user
                                attributes. Use `['*', '+']` to get all user and all operational attributes.
        :param int search_timeout: The number of seconds the server should spend performing the search. Partial results
                                   will be returned if the server times out. The default can be set per connection by
                                   passing the ``search_timeout`` keyword to the :class:`LDAP` constructor, or set the
                                   global default by defining :attr:`LDAP.DEFAULT_SEARCH_TIMEOUT`.
        :param int limit: The maximum number of objects to return.
        :param DerefAliases deref_aliases: One of the :class:`DerefAliases` constants. This instructs the server what to
                                           do when it encounters an alias object. The default can be set per connection
                                           by passing the ``deref_aliases`` keyword to the :class:`LDAP` constructor, or
                                           set the global default by defining :attr:`LDAP.DEFAULT_DEREF_ALIASES`.
        :param bool attrs_only: Default False. Set to True to only obtain attribute names and not any attribute values.
        :param bool fetch_result_refs: When the server returns a result which is a reference to an object on another
                                       server, automatically attempt to fetch the remote object and include it in the
                                       iterated results. The default can be set per connection by passing the
                                       ``fetch_result_refs`` keyword to the :class:`LDAP` constructor, or set the global
                                       default by defining :attr:`LDAP.DEFAULT_FETCH_RESULT_REFS`.
        :param bool follow_referrals: When the server knows that the base object is present on another server, follow
                                      the referral and perform the search on the other server. The default can be set
                                      per connection by passing the `follow_referrals` keyword to the :class:`LDAP`
                                      constructor, or set the global default by defining
                                      :attr:`LDAP.DEFAULT_FOLLOW_REFERRALS`.
        :param FilterSyntax filter_syntax: Select which filter syntax to use to parse the ``filter``. The default can be
                                           set per connection by passing the ``default_filter_syntax`` keyword to the
                                           :class:`LDAP` constructor, or set the global default by defining
                                           :attr:`LDAP.DEFAULT_FILTER_SYNTAX`.
        :return: An iterator over the results of the search. May yield :class:`LDAPObject` or possibly
                 :class:`SearchReferenceHandle` if ``fetch_result_refs`` is False.

        Additional keywords are handled as :doc:`/controls` first and then passed through into :meth:`.LDAP.obj`.

        This method may also be used as a context manager. If all results have not been read, the operation will
        automatically be abandoned when the context manager exits. You can also raise :exc:`Abandon` to abandon
        all results immediately and cleanly exit the context manager. You can also call
        :meth:`SearchResultHandle.abandon` to abandon results.

        Example::

            # Dump the whole tree
            with LDAP() as ldap:
                with ldap.base.search() as search:
                    for result in search:
                        print(result.format_ldif())
        """
        if self.sock.unbound:
            raise ConnectionUnbound()

        if filter is None:
            filter = LDAP.DEFAULT_FILTER
            filter_syntax = FilterSyntax.STANDARD
        if search_timeout is None:
            search_timeout = self.default_search_timeout
        if deref_aliases is None:
            deref_aliases = self.default_deref_aliases
        if fetch_result_refs is None:
            fetch_result_refs = self.default_fetch_result_refs
        if follow_referrals is None:
            follow_referrals = self.default_follow_referrals
        if filter_syntax is None:
            filter_syntax = self.default_filter_syntax
        req = rfc4511.SearchRequest()
        req.setComponentByName('baseObject', rfc4511.LDAPDN(base_dn))
        req.setComponentByName('scope', scope)
        req.setComponentByName('derefAliases', deref_aliases)
        req.setComponentByName('sizeLimit', rfc4511.Integer0ToMax(limit))
        req.setComponentByName('timeLimit', rfc4511.Integer0ToMax(search_timeout))
        req.setComponentByName('typesOnly', rfc4511.TypesOnly(attrs_only))
        if filter_syntax is FilterSyntax.UNIFIED:
            rfc4511_filter = parse_unified_filter(filter)
        elif filter_syntax is FilterSyntax.STANDARD:
            rfc4511_filter = parse_standard_filter(filter)
        elif filter_syntax is FilterSyntax.SIMPLE:
            rfc4511_filter = parse_simple_filter(filter)
        else:
            raise LDAPError('Invalid filter_syntax')
        req.setComponentByName('filter', rfc4511_filter)

        _attrs = rfc4511.AttributeSelection()
        i = 0
        if attrs is None:
            attrs = ['*']
        if not isinstance(attrs, list):
            attrs = [attrs]
        for desc in attrs:
            if desc[0] == '@':
                if LDAP.OID_OBJ_CLASS_ATTR not in self.root_dse.get_attr('supportedFeatures'):
                    raise LDAPSupportError('Server does not support RFC 4529 @objectClass attribute requests')
            _attrs.setComponentByPosition(i, rfc4511.LDAPString(desc))
            i += 1
        req.setComponentByName('attributes', _attrs)

        # check here because we need to do a search to get the root DSE, which is required by
        # _process_ctrl_kwds, other methods don't need to check
        if kwds:
            ctrls = self._process_ctrl_kwds('search', kwds)
        else:
            ctrls = None

        # check for allowed object keywords now that any control keywords have been removed from the dict
        _check_obj_kwds(kwds)

        mid = self.sock.send_message('searchRequest', req, ctrls)
        logger.info('Sent search request (ID {0}): base_dn={1}, scope={2}, filter={3}'.format(
                    mid, base_dn, scope, filter))
        return SearchResultHandle(self, mid, fetch_result_refs, follow_referrals, kwds)

    def compare(self, dn, attr, value, **ctrl_kwds):
        """Ask the server if a particular DN has a matching attribute value. The comparison will take place following
        the schema-defined matching rules and syntax rules.

        :param str dn: The DN of the object
        :param str attr: The attribute name
        :param str value: The assertion value
        :return: A response object, :func:`bool` evaluating to the result of the comparison
        :rtype: CompareResponse
        :raises ConnectionUnbound: if the connection has been unbound
        :raises LDAPError: if we got a result other than compareTrue or compareFalse

        Additional keyword arguments are handled as :doc:`/controls`.
        """
        if self.sock.unbound:
            raise ConnectionUnbound()

        cr = rfc4511.CompareRequest()
        cr.setComponentByName('entry', rfc4511.LDAPDN(six.text_type(dn)))
        ava = rfc4511.AttributeValueAssertion()
        ava.setComponentByName('attributeDesc', rfc4511.AttributeDescription(six.text_type(attr)))
        ava.setComponentByName('assertionValue', rfc4511.AssertionValue(six.text_type(value)))
        cr.setComponentByName('ava', ava)

        req_ctrls = self._process_ctrl_kwds('compare', ctrl_kwds, final=True)

        message_id = self.sock.send_message('compareRequest', cr, req_ctrls)
        logger.info('Sent compare request (ID {0}): {1} ({2} = {3})'.format(message_id, dn, attr, value))
        msg = self.sock.recv_one(message_id)
        mid, res, res_ctrls = unpack('compareResponse', msg)
        res = res.getComponentByName('resultCode')
        if res == RESULT_compareTrue:
            logger.debug('Compared True (ID {0})'.format(mid))
            compare_result = True
        elif res == RESULT_compareFalse:
            logger.debug('Compared False (ID {0})'.format(mid))
            compare_result = False
        else:
            raise LDAPError('Got compare result {0} (ID {1})'.format(repr(res), mid))
        ret = CompareResponse(compare_result)
        controls.handle_response(ret, res_ctrls)
        return ret

    def add(self, dn, attrs_dict, **kwds):
        """Add new object and return corresponding LDAPObject on success.

        :param str dn: The new object's DN
        :param attrs_dict: The new attributes for the object
        :type attrs_dict: dict(str, list[str or bytes]) or AttrsDict
        :return: The new object
        :rtype: LDAPObject
        :raises ConnectionUnbound: if the connection has been unbound
        :raises TypeError: if arguments are of invalid type
        :raises LDAPValidationError: if the object fails any configured validator
        :raises LDAPError: if we get a non-success result

        Additional keyword arguments are handled as :doc:`/controls` and then passed through into :meth:`.LDAP.obj`.
        """
        if self.sock.unbound:
            raise ConnectionUnbound()

        if not isinstance(dn, six.string_types):
            raise TypeError('DN must be string type')
        if not isinstance(attrs_dict, dict):
            raise TypeError('attrs_dict must be dict')

        req_ctrls = self._process_ctrl_kwds('add', kwds)
        obj = self.obj(dn, attrs_dict, **kwds)

        self.validate_object(obj)

        ar = rfc4511.AddRequest()
        ar.setComponentByName('entry', rfc4511.LDAPDN(dn))
        al = rfc4511.AttributeList()
        i = 0
        for attr_type, attr_vals in six.iteritems(attrs_dict):
            attr = rfc4511.Attribute()
            attr.setComponentByName('type', rfc4511.AttributeDescription(attr_type))
            vals = rfc4511.Vals()
            j = 0
            for val in attr_vals:
                vals.setComponentByPosition(j, rfc4511.AttributeValue(val))
                j += 1
            attr.setComponentByName('vals', vals)
            al.setComponentByPosition(i, attr)
            i += 1
        ar.setComponentByName('attributes', al)

        mid = self.sock.send_message('addRequest', ar, req_ctrls)
        logger.info('Sent add request (ID {0}) for DN {1}'.format(mid, dn))

        lm = self.sock.recv_one(mid)
        mid, res, res_ctrls = unpack('addResponse', lm)
        res = res.getComponentByName('resultCode')
        if res == RESULT_success:
            logger.debug('LDAP operation (ID {0}) was successful'.format(mid))
            controls.handle_response(obj, res_ctrls)
            return obj
        else:
            raise LDAPError('Got {0} for add (ID {1})'.format(repr(res), mid))

    ## search+add patterns

    def add_or_mod_add_if_exists(self, dn, attrs_dict):
        """Add object if it doesn't exist, otherwise add_attrs

        * If the object at DN exists, perform an add modification using the attrs dictionary.
          Otherwise, create the object using the attrs dictionary.
        * This ensures that, for the attributes mentioned in attrs, AT LEAST those values will
          exist on the given DN, regardless of prior state of the DB.
        * Always returns an :class:`LDAPObject` corresponding to the final state of the DB

        :param str dn: The object DN
        :param attrs_dict: The objects minimum attributes
        :type attrs_dict: dict(str, list[str or bytes]) or AttrsDict
        :return: The new or modified object
        :rtype: LDAPObject
        """
        try:
            cur = self.get(dn)
            cur.add_attrs(attrs_dict, )
            return cur
        except NoSearchResults:
            return self.add(dn, attrs_dict)

    def add_or_mod_replace_if_exists(self, dn, attrs_dict):
        """Add object if it doesn't exist, otherwise replace_attrs

        * If the object at DN exists, perform a replace modification using the attrs dictionary
          Otherwise, create the object using the attrs dictionary
        * This ensures that, for the attributes mentioned in attrs, ONLY those values will exist on
          the given DN regardless of prior state of the DB.
        * Always returns an :class:`LDAPObject` corresponding to the final state of the DB

        :param str dn: The object DN
        :param attrs_dict: The objects new required attributes
        :type attrs_dict: dict(str, list[str or bytes]) or AttrsDict
        :return: The new or modified object
        :rtype: LDAPObject
        """
        try:
            cur = self.get(dn)
            cur.replace_attrs(attrs_dict, )
            return cur
        except NoSearchResults:
            return self.add(dn, attrs_dict)

    def add_if_not_exists(self, dn, attrs_dict):
        """Add object if it doesn't exist

        * Gets and returns the object at DN if it exists, otherwise create the object using the
          attrs dictionary
        * Always returns an LDAPObject corresponding to the final state of the DB

        :param str dn: The object DN
        :param attrs_dict: The attributes to use if adding the object
        :type attrs_dict: dict(str, list[str or bytes]) or AttrsDict
        :return: The new or existing object
        :rtype: LDAPObject
        """
        try:
            cur = self.get(dn)
            logger.debug('Object {0} already exists on addIfNotExists'.format(dn))
            return cur
        except NoSearchResults:
            return self.add(dn, attrs_dict)

    ## delete an object

    def delete(self, dn, **ctrl_kwds):
        """Delete an object.

        :param str dn: The DN of the object to delete
        :return: A response object
        :rtype: LDAPResponse
        :raises ConnectionUnbound: if the connection has been unbound

        Additional keyword arguments are handled as :doc:`/controls`.
        """
        if self.sock.unbound:
            raise ConnectionUnbound()
        controls = self._process_ctrl_kwds('delete', ctrl_kwds, final=True)
        mid = self.sock.send_message('delRequest', rfc4511.DelRequest(dn), controls)
        logger.info('Sent delete request (ID {0}) for DN {1}'.format(mid, dn))
        return self._success_result(mid, 'delResponse')

    ## change object DN

    def mod_dn(self, dn, new_rdn, clean_attr=True, new_parent=None, **ctrl_kwds):
        """Change the DN and possibly the location of an object in the tree. Exposes all options of the protocol-level
        rfc4511.ModifyDNRequest

        :param str dn: The current DN of the object
        :param str new_rdn: The new RDN of the object, e.g. cn=foo
        :param bool clean_attr: Remove the old RDN attribute from the object when changing
        :param new_parent: The DN of the new parent object, or None to leave the location unchanged
        :type new_parent: str or None
        :return: A response object
        :rtype: LDAPResponse
        :raises ConnectionUnbound: if the connection has been unbound

        Additional keyword arguments are handled as :doc:`/controls`.
        """
        if self.sock.unbound:
            raise ConnectionUnbound()
        mdr = rfc4511.ModifyDNRequest()
        mdr.setComponentByName('entry', rfc4511.LDAPDN(dn))
        mdr.setComponentByName('newrdn', rfc4511.RelativeLDAPDN(new_rdn))
        mdr.setComponentByName('deleteoldrdn', clean_attr)
        if new_parent is not None:
            mdr.setComponentByName('newSuperior', rfc4511.NewSuperior(new_parent))
        controls = self._process_ctrl_kwds('mod_dn', ctrl_kwds, final=True)
        mid = self.sock.send_message('modDNRequest', mdr, controls)
        logger.info('Sent modDN request (ID {0}) for DN {1} newRDN="{2}" newParent="{3}"'.format(
                    mid, dn, new_rdn, new_parent))
        return self._success_result(mid, 'modDNResponse')

    def rename(self, dn, new_rdn, clean_attr=True, **ctrl_kwds):
        """Specify a new RDN for an object without changing its location in the tree.

        :param str dn: The current DN of the object
        :param str new_rdn: The new RDN of the object, e.g. cn=foo
        :param bool clean_attr: Remove the old RDN attribute from the object when changing
        :return: A response object
        :rtype: LDAPResponse

        Additional keyword arguments are handled as :doc:`/controls`.
        """
        return self.mod_dn(dn, new_rdn, clean_attr, **ctrl_kwds)

    def move(self, dn, new_dn, clean_attr=True, **ctrl_kwds):
        """Specify a new absolute DN for an object.

        :param str dn: The current DN of the object
        :param str new_dn: The new absolute DN of the object, e.g. cn=foo,dc=example,dc=org
        :param bool clean_attr: Remove the old RDN attribute from the object when changing
        :return: A response object
        :rtype: LDAPResponse

        Additional keyword arguments are handled as :doc:`/controls`.
        """
        rdn, parent = re.split(r'(?<!\\),', new_dn, 1)
        return self.mod_dn(dn, rdn, clean_attr, parent, **ctrl_kwds)

    ## change attributes on an object

    def modify(self, dn, modlist, current=None, **ctrl_kwds):
        """Perform a series of modify operations on an object atomically

        :param str dn: The DN of the object to modify
        :param list[Mod] modlist: A list of :class:`Mod` instances,
                                  e.g. [Mod(Mod.ADD, 'someAttr', ['value1', 'value2'])]
        :param current: The current known state of the object for use in validation
        :type current: LDAPObject or None
        :return: A response object
        :rtype: LDAPResponse
        :raises ConnectionUnbound: if the connection has been unbound
        :raises LDAPValidationError: if the operation fails and configured validator

        Additional keyword arguments are handled as :doc:`/controls`.
        """
        if len(modlist) > 0:
            if self.sock.unbound:
                raise ConnectionUnbound()

            self.validate_modify(dn, modlist, current)

            mr = rfc4511.ModifyRequest()
            mr.setComponentByName('object', rfc4511.LDAPDN(dn))
            cl = rfc4511.Changes()
            i = 0
            logger.debug('Modifying DN {0}'.format(dn))
            for mod in modlist:
                if mod.op in (Mod.REPLACE, Mod.DELETE):
                    if isinstance(mod.vals, list) and not mod.vals:
                        if self.error_empty_list:
                            raise LDAPError('empty list in replace/delete modify for attr {0}'.format(mod.attr))
                        if self.ignore_empty_list:
                            logger.debug('> Ignoring Mod with empty value list: {0} {1}'.format(mod.op, mod.attr))
                            continue
                        if self.warn_empty_list:
                            warn('empty list in replace/delete modify, will delete all values for attribute {0}'.format(
                                mod.attr), LDAPWarning)
                    if mod.vals is DELETE_ALL:
                        mod.vals = []
                logger.debug('> {0}'.format(mod))

                c = rfc4511.Change()
                c.setComponentByName('operation', mod.op)
                pa = rfc4511.PartialAttribute()
                pa.setComponentByName('type', rfc4511.AttributeDescription(mod.attr))
                vals = rfc4511.Vals()
                j = 0
                for v in mod.vals:
                    vals.setComponentByPosition(j, rfc4511.AttributeValue(v))
                    j += 1
                pa.setComponentByName('vals', vals)
                c.setComponentByName('modification', pa)

                cl.setComponentByPosition(i, c)
                i += 1
            if i > 0:
                mr.setComponentByName('changes', cl)
                controls = self._process_ctrl_kwds('modify', ctrl_kwds, final=True)
                mid = self.sock.send_message('modifyRequest', mr, controls)
                logger.info('Sent modify request (ID {0}) for DN {1}'.format(mid, dn))
                return self._success_result(mid, 'modifyResponse')
            else:
                logger.debug('All modlist items have been skipped for DN {0}'.format(dn))
                return LDAPResponse()
        else:
            logger.debug('Not sending 0-length modlist for DN {0}'.format(dn))
            return LDAPResponse()

    def add_attrs(self, dn, attrs_dict, current=None, **ctrl_kwds):
        """Add new attribute values to existing object.

        :param str dn: The DN of the object to modify
        :param attrs_dict: The new attributes to add to the object
        :type attrs_dict: dict(str, list[str or bytes]) or AttrsDict
        :param current: The current known state of the object. Used for ensuring we don't send duplicate attributes to
                        the server and for validation.
        :type current: LDAPObject or None
        :return: A response object
        :rtype: LDAPResponse

        Additional keyword arguments are handled as :doc:`/controls`.
        """
        if current is not None:
            modlist = AddModlist(current, attrs_dict)
        elif not self.strict_modify:
            current = self.get(dn, list(attrs_dict.keys()))
            modlist = AddModlist(current, attrs_dict)
        else:
            modlist = Modlist(Mod.ADD, attrs_dict)
        return self.modify(dn, modlist, current, **ctrl_kwds)

    def delete_attrs(self, dn, attrs_dict, current=None, **ctrl_kwds):
        """Delete specific attribute values from ``attrs_dict``.

        Specifying a 0-length entry will delete all values.

        :param str dn: The DN of the object to modify
        :param attrs_dict: The attributes to remove from the object. Specify an empty list for a value to delete all
                           values.
        :type attrs_dict: dict(str, list[str or bytes]) or AttrsDict
        :param current: The current known state of the object. Used to ensure we don't request that the server delete
                        attribute values that don't exist and for validation.
        :type current: LDAPObject or None
        :return: A response object
        :rtype: LDAPResponse

        Additional keyword arguments are handled as :doc:`/controls`.
        """
        if current is not None:
            modlist = DeleteModlist(current, attrs_dict)
        elif not self.strict_modify:
            current = self.get(dn, list(attrs_dict.keys()))
            modlist = DeleteModlist(current, attrs_dict)
        else:
            modlist = Modlist(Mod.DELETE, attrs_dict)
        return self.modify(dn, modlist, current, **ctrl_kwds)

    def replace_attrs(self, dn, attrs_dict, current=None, **ctrl_kwds):
        """Replace all values on given attributes with the passed values

        * Attributes not mentioned in attrsDict are not touched
        * Attributes will be created if they do not exist
        * Specifying a 0-length entry will delete all values for that attribute

        :param str dn: The DN of the object to modify
        :param attrs_dict: The new attributes to set on the object
        :type attrs_dict: dict(str, list[str or bytes]) or AttrsDict
        :param current: The current known state of the object for use in validation
        :type current: LDAPObject or None
        :return: A response object
        :rtype: LDAPResponse

        Additional keyword arguments are handled as :doc:`/controls`.
        """

        # Only query for the current object if there are validators present and
        # strict modify is disabled
        if current is None and self.validators and not self.strict_modify:
            current = self.get(dn, list(attrs_dict.keys()))

        return self.modify(dn, Modlist(Mod.REPLACE, attrs_dict), current, **ctrl_kwds)

    ## Extension methods

    def send_extended_request(self, oid, value=None, **kwds):
        """Send an extended request, returns instance of :class:`ExtendedResponseHandle`

        This is mainly meant to be called by other built-in methods and client extensions. Requires
        handling of raw pyasn1 protocol objects.

        :param str oid: The OID of the extension. Must be declared as supported by the server in the root DSE.
        :param value: The request value (optional)
        :type value: str or bytes or None
        :return: An iterator yielding tuples of the form (:class:`rfc4511.IntermediateResponse`,
                 :class:`rfc4511.Controls`) or (:class:`rfc4511.ExtendedResponse`, :class:`rfc4511.Controls`).
        :rtype: ExtendedResponseHandle
        :raises LDAPSupportError: if the OID is not listed in the supportedExtension attribute of the root DSE
        :raises TypeError: if the `value` parameter is not a valid type

        Additional keyword arguments are handled as :doc:`/controls` and then passed through into the
        :class:`ExtendedResponseHandle` constructor.
        """
        if oid not in self.root_dse.get_attr('supportedExtension'):
            raise LDAPSupportError('Extended operation is not supported by the server')
        xr = rfc4511.ExtendedRequest()
        xr.setComponentByName('requestName', rfc4511.RequestName(oid))
        if value is not None:
            if not isinstance(value, six.string_types) and not isinstance(value, six.binary_type):
                raise TypeError('extendedRequest value must be string or bytes')
            xr.setComponentByName('requestValue', rfc4511.RequestValue(value))
        req_ctrls = self._process_ctrl_kwds('ext', kwds)
        mid = self.sock.send_message('extendedReq', xr, req_ctrls)
        logger.info('Sent extended request ID={0} OID={1}'.format(mid, oid))
        return ExtendedResponseHandle(mid=mid, ldap_conn=self, **kwds)

    def who_am_i(self, **ctrl_kwds):
        """Perform the "Who Am I?" extended operation. This will confirm the identity that the connection is bound to.

        :return: A string describing the bound identity. One common form is "dn:cn=foo,dc=example,dc=org" but this will
                 vary by server configuration and bind type/parameters.
        :rtype: str

        Additional keyword arguments are handled as :doc:`/controls`.
        """
        handle = self.send_extended_request(LDAP.OID_WHOAMI, require_success=True, **ctrl_kwds)
        xr, res_ctrls = handle.recv_response()
        return get_string_component(xr, 'responseValue')

    def start_tls(self, verify=None, ca_file=None, ca_path=None, ca_data=None):
        """Perform the StartTLS extended operation. This will instruct the server to begin encrypting this socket
        connection with TLS/SSL.

        :param bool verify: Set to False to disable verification of the remote certificate. You can set the default
                            per-connection by passing the `ssl_verify` keyword to the :class:`LDAP` constructor, or set
                            the global default by defining :attr:`LDAP.DEFAULT_SSL_VERIFY`.
        :param str ca_file: Path to PEM-formatted concatenated CA certficates file. You can set the default
                            per-connection by passing the `ssl_ca_file` keyword to the :class:`LDAP` constructor, or set
                            the global default by defining :attr:`LDAP.DEFAULT_SSL_CA_FILE`.
        :param str ca_path: Path to directory with CA certs under hashed file names. See
                            https://www.openssl.org/docs/man1.1.0/ssl/SSL_CTX_load_verify_locations.html for more
                            information about the format of this directory. You can set the default per-connection by
                            passing the `ssl_ca_path` keyword to the :class:`LDAP` constructor, or set the global
                            default by defining :attr:`LDAP.DEFAULT_SSL_CA_PATH`.
        :param ca_data: An ASCII string of one or more PEM-encoded certs or a bytes object containing DER-encoded
                        certificates. You can set the default per-connection by passing the `ssl_ca_data` keyword to the
                        :class:`LDAP` constructor, or set the global default by defining
                        :attr:`LDAP_DEFAULT_SSL_CA_DATA`.
        :type ca_data: str or bytes
        :rtype: None
        """
        if self.sock.started_tls:
            raise LDAPError('TLS layer already installed')
        if verify is None:
            verify = self.ssl_verify
        if ca_file is None:
            ca_file = self.ssl_ca_file
        if ca_path is None:
            ca_path = self.ssl_ca_path
        if ca_data is None:
            ca_data = self.ssl_ca_data
        handle = self.send_extended_request(LDAP.OID_STARTTLS, require_success=True)
        handle.recv_response()
        self.sock.start_tls(verify, ca_file, ca_path, ca_data)
        self.refresh_root_dse()
        logger.info('StartTLS complete')

    ## validation methods

    def _run_validation(self, method, *args):
        for validator in self.validators:
            try:
                getattr(validator, method)(*args)
            except AttributeError:
                logger.debug('validator {0} has no method {1}'.format(validator.__class__.__name__, method))

    def validate_object(self, obj, write=True):
        """Run all configured validators for the given object.

        :param LDAPObject obj: The object to validate
        :param bool write: True if this is for a write operation (e.g. an add)
        :rtype: None
        :raises LDAPValidationError: if any validator fails the object
        """
        self._run_validation('validate_object', obj, write)

    def validate_modify(self, dn, modlist, current=None):
        """Run all configured validators for the given modify operation

        :param str dn: The DN of the object being modified
        :param list[Mod] modlist: The sequence of changes to be performed
        :param LDAPObject current: The current known state of the object
        :rtype: None
        :raises LDAPValidationError: if any validator fails the operation
        """
        self._run_validation('validate_modify', dn, modlist, current)

    ## misc

    def process_ldif(self, ldif_str):
        """Process a basic LDIF

        TODO: full RFC 2849 implementation. Missing:

        * attribute options

        :param str ldif_str: An RFC 2849 complying LDIF string
        :return: A list with elements corresponding to the return of each described operation
        :rtype: list[LDAPResponse or LDAPObject]
        :raises ValueError: if the LDIF is malformed
        :raises LDAPError: if an unimplemented feature is used
        :raises LDAPSupportError: if a version other than 1 is specified or a critical control is undefined
        """
        # stores returns of individual method calls in order for return
        ldap_responses = []

        # unfold lines by removing newline followed by a space
        ldif_str = re.sub(r'(\n|\r\n) ', '', ldif_str)

        # fetch URL values
        def fetch_url(m):
            with urlopen(m.group(1).strip()) as u:
                return ': ' + u.read()
        ldif_str = re.sub(r':<([^\n]+)', fetch_url, ldif_str)

        def clear_comments(lines):
            while len(lines) >= 1 and lines[0].strip().startswith('#'):
                lines.popleft()

        def handle_base64(b64value):
            b64value = b64value[1:].strip()  # remove leading colon and whitespace
            binvalue = b64decode(b64value)
            try:
                return binvalue.decode('utf-8')
            except UnicodeDecodeError:
                return binvalue

        # check version
        ldif_lines = deque(ldif_str.splitlines(True))  # keepends=True
        clear_comments(ldif_lines)

        token = 'version:'
        if ldif_lines[0].startswith(token):
            version = ldif_lines.popleft()[len(token):].strip()
            if version != '1':
                raise LDAPSupportError('Unsupported LDIF version {0}'.format(version))

        # TODO refactor to avoid re-joining this
        ldif_str = ''.join(ldif_lines)

        # split into operations based on double newline
        ldif_operations = re.split(r'(?:\n|\r\n){2,}', ldif_str)

        # iterate operations
        for ldif_op in ldif_operations:
            if not ldif_op:
                continue
            ldif_lines = deque(ldif_op.splitlines())
            clear_comments(ldif_lines)

            # get dn
            token = 'dn:'
            if len(ldif_lines) < 1 or not ldif_lines[0].startswith(token):
                raise ValueError('Missing dn')
            dn = ldif_lines.popleft()[len(token):].strip()
            if dn[0] == ':':
                dn = handle_base64(dn)
            clear_comments(ldif_lines)

            # get controls
            token = 'control:'
            ctrl_kwds = {}
            while len(ldif_lines) > 1 and ldif_lines[0].startswith(token):
                control = ldif_lines.popleft()[len(token):].strip()
                m = re.match(r'^(?P<oid>[0-9]+(\.[0-9]+)+)(?P<crit> (true|false))?(?P<value>:.+)$', control)
                if not m:
                    raise ValueError('Invalid control specification')
                value = m.group('value')
                if not value:
                    value = ''
                if value and value[0] == ':':
                    value = handle_base64(value)
                crit_str = m.group('crit')
                if crit_str:
                    crit_str = crit_str.strip()
                    if crit_str == 'true':
                        value = controls.critical(value)
                        criticality = True
                    else:
                        value = controls.optional(value)
                        criticality = False
                else:
                    criticality = self.default_criticality
                oid = m.group('oid')
                try:
                    keyword = controls.get_control(oid).keyword
                except KeyError:
                    if criticality:
                        raise LDAPSupportError('Unsupported critical control {0}'.format(oid))
                    keyword = None
                if keyword:
                    ctrl_kwds[keyword] = value

            # get changetype
            token = 'changetype:'
            if len(ldif_lines) < 1 or not ldif_lines[0].startswith(token):
                raise ValueError('Missing changetype')
            changetype = ldif_lines.popleft()[len(token):].strip()
            clear_comments(ldif_lines)

            # handle changetypes
            if changetype == 'add':
                attrs = {}
                for line in ldif_lines:
                    line = line.strip()
                    if line.startswith('#'):
                        # ignore comments
                        continue

                    # get new object attributes
                    attr, val = line.split(':', 1)

                    # handle base64 values
                    if val[0] == ':':
                        val = handle_base64(val)

                    # check for options
                    opts = attr.split(';')
                    if len(opts) > 1:
                        # TODO support attribute options
                        raise LDAPError('LDIF attribute options are not yet implemented')

                    # store attribute values
                    if attr not in attrs:
                        attrs[attr] = []
                    attrs[attr].append(val.strip())
                ldap_responses.append(self.add(dn, attrs, **ctrl_kwds))

            elif changetype == 'delete':
                ldap_responses.append(self.delete(dn, **ctrl_kwds))

            elif changetype == 'modify':
                mod_op = None
                mod_attr = None
                vals = []
                modlist = []
                for line in ldif_lines:
                    line = line.strip()
                    if line.startswith('#'):
                        # ignore comments
                        continue
                    elif mod_op is None:
                        # get new mod operation and attribute
                        _mod_op, _mod_attr = line.split(':')
                        mod_op = Mod.string(_mod_op)
                        mod_attr = _mod_attr.strip()
                    elif line == '-':
                        # end mod operation and add to modlist
                        if mod_op == 'add' and len(vals) == 0:
                            raise ValueError('no attribute values to add')
                        modlist += Modlist(mod_op, {mod_attr: vals})
                        mod_op = None
                        vals = []
                    else:
                        # get new modify value
                        if line.startswith(mod_attr):
                            attr, val = line.split(':')

                            # check for options
                            opts = attr.split(';')
                            if len(opts) > 1:
                                # TODO support attribute options
                                raise LDAPError('LDIF attribute options are not yet implemented')

                            # handle base64 values
                            if val[0] == ':':
                                val = handle_base64(val)

                            # store value
                            vals.append(val.strip())
                        else:
                            raise ValueError('Unexpected attribute')

                # add any trailing values
                if mod_op is not None:
                    modlist += Modlist(mod_op, {mod_attr: vals})

                # send modify operation
                ldap_responses.append(self.modify(dn, modlist, **ctrl_kwds))

            elif changetype == 'modrdn' or changetype == 'moddn':
                if len(ldif_lines) < 2:
                    raise ValueError('Insufficient parameters for {0} - must supply newrdn and deleteoldrdn'.format(
                                     changetype))

                # get new rdn
                token = 'newrdn:'
                if len(ldif_lines) >= 1 and ldif_lines[0].startswith(token):
                    new_rdn = ldif_lines.popleft()[len(token):].strip()
                    if new_rdn[0] == ':':
                        new_rdn = handle_base64(new_rdn)
                else:
                    raise ValueError('missing newrdn')
                clear_comments(ldif_lines)

                # check whether to remove old rdn attribute
                token = 'deleteoldrdn:'
                if len(ldif_lines) >= 1 and ldif_lines[0].startswith(token):
                    del_old_str = ldif_lines.popleft()[len(token):].strip()
                    if del_old_str == '0':
                        clean_attr = False
                    elif del_old_str == '1':
                        clean_attr = True
                    else:
                        raise ValueError('invalid deleteoldrdn, must be 0 or 1')
                else:
                    raise ValueError('missing deleteoldrdn')
                clear_comments(ldif_lines)

                # get optional new parent DN
                token = 'newsuperior:'
                if len(ldif_lines) >= 1 and ldif_lines[0].startswith(token):
                    new_parent = ldif_lines.popleft()[len(token):].strip()
                    if new_parent[0] == ':':
                        new_parent = handle_base64(new_parent)
                else:
                    new_parent = None

                # send mod_dn
                ldap_responses.append(self.mod_dn(dn, new_rdn, clean_attr, new_parent, **ctrl_kwds))

            else:
                raise ValueError('changetype {0} unknown'.format(changetype))

        return ldap_responses

    def disable_validation(self, disabled_validators=None):
        """Returns a context manager which temporarily disables validation. If any server errors are generated, they
        will still be propagated.

        Example::

            from laurelin.ldap import LDAP
            from laurelin.ldap.exceptions import LDAPValidationError
            from laurelin.ldap.schema import SchemaValidator

            with LDAP(validators=[SchemaValidator()]) as ldap:
                # make validated queries
                ldap.base.add_child('cn=foo', {<valid object>})

                try:
                    ldap.base.add_child('cn=bar', {<invalid object>})
                except LDAPValidationError:
                    pass

                with ldap.disable_validation(['SchemaValidator']):
                    # make queries without validation
                    ldap.base.add_child('cn=bar', {<invalid object>})
                    # NOTE: if the object is actually invalid, a server error may still occur

                # carry on with validation restored...

        :param disabled_validators: Optional, a list of string class names or Validator classes to disable. By default
                                    all validators will be disabled.
        :return: A context manager which temporarily disables validation
        :rtype: DisabledValidationContext
        """
        return DisabledValidationContext(self, disabled_validators)


class LDAPResponse(object):
    """Empty object for storing response control values"""
    pass


class CompareResponse(LDAPResponse):
    """Stores boolean compare result and any response control values. The :func:`bool` of this object gives the
    compare result.
    """
    def __init__(self, compare_result):
        self.compare_result = compare_result

    def __bool__(self):
        return self.compare_result

    def __nonzero__(self):
        return self.compare_result


class ResponseHandle(LDAPResponse):
    """Base for return from methods with multiple response messages."""
    def __init__(self, ldap_conn, mid):
        self.ldap_conn = ldap_conn
        self.message_id = mid
        self.abandoned = False
        self.done = False

    def __enter__(self):
        return self

    def __exit__(self, etype, e, trace):
        if not self.done:
            self.abandon()
        if etype == Abandon:
            return True

    def abandon(self):
        """Request to abandon an operation in progress"""
        if not self.abandoned:
            logger.info('Abandoning ID={0}'.format(self.message_id))
            self.ldap_conn.sock.send_message('abandonRequest', rfc4511.AbandonRequest(self.message_id))
            self.abandoned = True
            self.ldap_conn.sock.abandonedMIDs.append(self.message_id)
        else:
            logger.debug('ID={0} already abandoned'.format(self.message_id))


class SearchResultHandle(ResponseHandle):
    def __init__(self, ldap_conn, message_id, fetch_result_refs, follow_referrals, obj_kwds):
        ResponseHandle.__init__(self, ldap_conn, message_id)
        self.fetch_result_refs = fetch_result_refs
        self.follow_referrals = follow_referrals
        self.obj_kwds = obj_kwds

    def __iter__(self):
        if self.abandoned:
            logger.debug('ID={0} has been abandoned'.format(self.message_id))
            return
        for msg in self.ldap_conn.sock.recv_messages(self.message_id):
            try:
                mid, entry, res_ctrls = unpack('searchResEntry', msg)
                dn = get_string_component(entry, 'objectName')
                attrs = {}
                _attrs = entry.getComponentByName('attributes')
                for i in range(0, len(_attrs)):
                    _attr = _attrs.getComponentByPosition(i)
                    attr_type = six.text_type(_attr.getComponentByName('type'))
                    vals = _attr.getComponentByName('vals')
                    attrs[attr_type] = seq_to_list(vals)
                logger.debug('Got search result entry (ID {0}) {1}'.format(mid, dn))
                ret = self.ldap_conn.obj(dn, attrs, **self.obj_kwds)
                controls.handle_response(ret, res_ctrls)
                yield ret
            except UnexpectedResponseType:
                try:
                    mid, resobj, res_ctrls = unpack('searchResDone', msg)
                    self.done = True
                    res = resobj.getComponentByName('resultCode')
                    if res == RESULT_success or res == RESULT_noSuchObject:
                        logger.debug('Got all search results for ID={0}, result is {1}'.format(
                            mid, repr(res)
                        ))
                        controls.handle_response(self, res_ctrls)
                        return
                    elif res == RESULT_referral:
                        if self.follow_referrals:
                            logger.info('Following referral for ID={0}'.format(mid))
                            ref = resobj.getComponentByName('referral')
                            uris = seq_to_list(ref)
                            for obj in SearchReferenceHandle(uris, self.obj_kwds).fetch():
                                yield obj
                        else:
                            logger.debug('Ignoring referral for ID={0}'.format(mid))
                            return
                    else:
                        raise LDAPError('Got {0} for search results (ID {1})'.format(repr(res), mid))
                except UnexpectedResponseType:
                    mid, resref, res_ctrls = unpack('searchResRef', msg)
                    s = seq_to_list(resref)
                    logger.debug('Got search result reference (ID {0}) to: {1}'.format(mid, ' | '.join(s)))
                    ref = SearchReferenceHandle(s, self.obj_kwds)
                    if self.fetch_result_refs:
                        if res_ctrls:
                            warn('Unhandled response controls on searchResRef message', LDAPWarning)
                        for obj in ref.fetch():
                            yield obj
                    else:
                        controls.handle_response(ref, res_ctrls)
                        yield ref


class ExtendedResponseHandle(ResponseHandle):
    """Obtains rfc4511.ExtendedResponse or rfc4511.IntermediateResponse instances from the server for a particular
    message ID
    """

    def __init__(self, mid, ldap_conn, require_success=False):
        ResponseHandle.__init__(self, ldap_conn, mid)
        self.require_success = require_success
        self._recvr = ldap_conn.sock.recv_messages(mid)

    def _handle_msg(self, lm):
        try:
            mid, ir, res_ctrls = unpack('intermediateResponse', lm)
            res_name = get_string_component(ir, 'responseName')
            logger.debug('Got name={0} intermediate response for ID={1}'.format(res_name, mid))
            return ir, res_ctrls
        except UnexpectedResponseType:
            mid, xr, res_ctrls = unpack('extendedResp', lm)
            self.done = True
            res_name = get_string_component(xr, 'responseName')
            logger.debug('Got name={0} extended response for ID={1}'.format(res_name, mid))
            if self.require_success:
                res = xr.getComponentByName('resultCode')
                if res != RESULT_success:
                    raise LDAPError('Got {0} for ID={1}'.format(repr(res), mid))
            return xr, res_ctrls

    def __iter__(self):
        for lm in self._recvr:
            yield self._handle_msg(lm)
            if self.done:
                break

    def recv_response(self):
        return self._handle_msg(next(self._recvr))


class LDAPURI(object):
    """Represents a parsed LDAP URI as specified in RFC4516

    Supported extensions:

    * "StartTLS"

    :var str scheme: urlparse standard
    :var str netloc: urlparse standard
    :var str host_uri: scheme://netloc for use with LDAPSocket
    :var str dn: Distinguished name
    :var list[str] attrs: list
    :var Scope scope: one of the :class:`Scope` constants
    :var str filter: The filter string
    :var bool starttls: True if StartTLS was requested
    """

    DEFAULT_ATTRS = ['*']
    DEFAULT_SCOPE = Scope.BASE
    DEFAULT_FILTER = LDAP.DEFAULT_FILTER
    DEFAULT_STARTTLS = False

    def __init__(self, uri):
        self._orig = uri
        parsed_uri = urlparse(uri)
        self.scheme = parsed_uri.scheme
        self.netloc = parsed_uri.netloc
        self.host_uri = '{0}://{1}'.format(self.scheme, self.netloc)
        self.dn = parsed_uri.path[1:]
        params = parsed_uri.query.split('?')
        nparams = len(params)
        if (nparams > 0) and (len(params[0]) > 0):
            self.attrs = params[0].split(',')
        else:
            self.attrs = LDAPURI.DEFAULT_ATTRS
        if (nparams > 1) and (len(params[1]) > 0):
            self.scope = Scope.string(params[1])
        else:
            self.scope = LDAPURI.DEFAULT_SCOPE
        if (nparams > 2) and (len(params[2]) > 0):
            self.filter = params[2]
        else:
            self.filter = LDAPURI.DEFAULT_FILTER
        if (nparams > 3) and (len(params[3]) > 0):
            extensions = params[3].split(',')
            for ext in extensions:
                if ext.startswith('!'):
                    critical = True
                    ext = ext[1:]
                else:
                    critical = False
                if ext == 'StartTLS':
                    self.starttls = True
                else:
                    if critical:
                        raise LDAPError('Unsupported critical URI extension {0}'.format(ext))
                    else:
                        warn('Unsupported URI extension {0}'.format(ext), LDAPWarning)
        else:
            self.starttls = LDAPURI.DEFAULT_STARTTLS

    def search(self, **kwds):
        """Perform the search operation described by the parsed URI

         First opens a new connection with connection reuse disabled, then performs the search, and
         unbinds the connection. Server must allow anonymous read.

         Additional keyword arguments are passed through into :meth:`LDAP.search`.
        """
        ldap = LDAP(self.host_uri, reuse_connection=False)
        if self.starttls:
            ldap.start_tls()
        ret = ldap.search(self.dn, self.scope, filter=self.filter, attrs=self.attrs, **kwds)
        ldap.unbind()
        return ret

    def __str__(self):
        return self._orig

    def __repr__(self):
        return "LDAPURI('{0}')".format(self._orig)


class SearchReferenceHandle(object):
    """Returned when the server returns a SearchResultReference"""
    def __init__(self, uris, obj_kwds):
        self.uris = []
        self.obj_kwds = obj_kwds
        for uri in uris:
            self.uris.append(LDAPURI(uri))

    def fetch(self):
        """Perform the reference search and return an iterator over results"""

        # If multiple URIs are present, the client assumes that any supported URI
        # may be used to progress the operation. ~ RFC4511 sec 4.5.3 p28
        for uri in self.uris:
            try:
                return uri.search(**self.obj_kwds)
            except LDAPConnectionError as e:
                warn('Error connecting to URI {0} ({1})'.format(uri, six.text_type(e)), LDAPWarning)
        raise LDAPError('Could not complete reference URI search with any supplied URIs')
