"""
"""

# Created on 2014.05.31
#
# Author: Giovanni Cannata
#
# Copyright 2014 - 2018 Giovanni Cannata
#
# This file is part of ldap3.
#
# ldap3 is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# ldap3 is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with ldap3 in the COPYING and COPYING.LESSER files.
# If not, see <http://www.gnu.org/licenses/>.

import socket
from threading import Lock
from datetime import datetime, MINYEAR

from .. import DSA, SCHEMA, ALL, BASE, get_config_parameter, OFFLINE_EDIR_8_8_8, OFFLINE_AD_2012_R2, OFFLINE_SLAPD_2_4, OFFLINE_DS389_1_3_3, SEQUENCE_TYPES, IP_SYSTEM_DEFAULT, IP_V4_ONLY, IP_V6_ONLY, IP_V4_PREFERRED, IP_V6_PREFERRED, STRING_TYPES
from .exceptions import LDAPInvalidServerError, LDAPDefinitionError, LDAPInvalidPortError, LDAPInvalidTlsSpecificationError, LDAPSocketOpenError
from ..protocol.formatters.standard import format_attribute_values
from ..protocol.rfc4511 import LDAP_MAX_INT
from ..protocol.rfc4512 import SchemaInfo, DsaInfo
from .tls import Tls
from ..utils.log import log, log_enabled, ERROR, BASIC, PROTOCOL
from ..utils.conv import to_unicode

try:
    from urllib.parse import unquote  # Python 3
except ImportError:
    from urllib import unquote  # Python 2

try:  # try to discover if unix sockets are available for LDAP over IPC (ldapi:// scheme)
    # noinspection PyUnresolvedReferences
    from socket import AF_UNIX
    unix_socket_available = True
except ImportError:
    unix_socket_available = False


class Server(object):
    """
    LDAP Server definition class

    Allowed_referral_hosts can be None (default), or a list of tuples of
    allowed servers ip address or names to contact while redirecting
    search to referrals.

    The second element of the tuple is a boolean to indicate if
    authentication to that server is allowed; if False only anonymous
    bind will be used.

    Per RFC 4516. Use [('*', False)] to allow any host with anonymous
    bind, use [('*', True)] to allow any host with same authentication of
    Server.
    """

    _message_counter = 0
    _message_id_lock = Lock()  # global lock for message_id shared by all Server objects


    def __init__(self,
                 host,
                 port=None,
                 use_ssl=False,
                 allowed_referral_hosts=None,
                 get_info=SCHEMA,
                 tls=None,
                 formatter=None,
                 connect_timeout=None,
                 mode=IP_V6_PREFERRED,
                 validator=None):

        self.ipc = False
        url_given = False
        host = host.strip()
        if host.lower().startswith('ldap://'):
            self.host = host[7:]
            use_ssl = False
            url_given = True
        elif host.lower().startswith('ldaps://'):
            self.host = host[8:]
            use_ssl = True
            url_given = True
        elif host.lower().startswith('ldapi://') and unix_socket_available:
            self.ipc = True
            use_ssl = False
            url_given = True
        elif host.lower().startswith('ldapi://') and not unix_socket_available:
            raise LDAPSocketOpenError('LDAP over IPC not available - UNIX sockets non present')
        else:
            self.host = host

        if self.ipc:
            if str is bytes:  # Python 2
                self.host = unquote(host[7:]).decode('utf-8')
            else:  # Python 3
                self.host = unquote(host[7:])  # encoding defaults to utf-8 in python3
            self.port = None
        elif ':' in self.host and self.host.count(':') == 1:
            hostname, _, hostport = self.host.partition(':')
            try:
                port = int(hostport) or port
            except ValueError:
                if log_enabled(ERROR):
                    log(ERROR, 'port <%s> must be an integer', port)
                raise LDAPInvalidPortError('port must be an integer')
            self.host = hostname
        elif url_given and self.host.startswith('['):
            hostname, sep, hostport = self.host[1:].partition(']')
            if sep != ']' or not self._is_ipv6(hostname):
                if log_enabled(ERROR):
                    log(ERROR, 'invalid IPv6 server address for <%s>', self.host)
                raise LDAPInvalidServerError()
            if len(hostport):
                if not hostport.startswith(':'):
                    if log_enabled(ERROR):
                        log(ERROR, 'invalid URL in server name for <%s>', self.host)
                    raise LDAPInvalidServerError('invalid URL in server name')
                if not hostport[1:].isdecimal():
                    if log_enabled(ERROR):
                        log(ERROR, 'port must be an integer for <%s>', self.host)
                    raise LDAPInvalidPortError('port must be an integer')
                port = int(hostport[1:])
            self.host = hostname
        elif not url_given and self._is_ipv6(self.host):
            pass
        elif self.host.count(':') > 1:
            if log_enabled(ERROR):
                log(ERROR, 'invalid server address for <%s>', self.host)
            raise LDAPInvalidServerError()

        if not self.ipc:
            self.host.rstrip('/')
            if not use_ssl and not port:
                port = 389
            elif use_ssl and not port:
                port = 636

            if isinstance(port, int):
                if port in range(0, 65535):
                    self.port = port
                else:
                    if log_enabled(ERROR):
                        log(ERROR, 'port <%s> must be in range from 0 to 65535', port)
                    raise LDAPInvalidPortError('port must in range from 0 to 65535')
            else:
                if log_enabled(ERROR):
                    log(ERROR, 'port <%s> must be an integer', port)
                raise LDAPInvalidPortError('port must be an integer')

        if allowed_referral_hosts is None:  # defaults to any server with authentication
            allowed_referral_hosts = [('*', True)]

        if isinstance(allowed_referral_hosts, SEQUENCE_TYPES):
            self.allowed_referral_hosts = []
            for referral_host in allowed_referral_hosts:
                if isinstance(referral_host, tuple):
                    if isinstance(referral_host[1], bool):
                        self.allowed_referral_hosts.append(referral_host)
        elif isinstance(allowed_referral_hosts, tuple):
            if isinstance(allowed_referral_hosts[1], bool):
                self.allowed_referral_hosts = [allowed_referral_hosts]
        else:
            self.allowed_referral_hosts = []

        self.ssl = True if use_ssl else False
        if tls and not isinstance(tls, Tls):
            if log_enabled(ERROR):
                log(ERROR, 'invalid tls specification: <%s>', tls)
            raise LDAPInvalidTlsSpecificationError('invalid Tls object')

        self.tls = Tls() if self.ssl and not tls else tls

        if not self.ipc:
            if self._is_ipv6(self.host):
                self.name = ('ldaps' if self.ssl else 'ldap') + '://[' + self.host + ']:' + str(self.port)
            else:
                self.name = ('ldaps' if self.ssl else 'ldap') + '://' + self.host + ':' + str(self.port)
        else:
            self.name = host

        self.get_info = get_info
        self._dsa_info = None
        self._schema_info = None
        self.dit_lock = Lock()
        self.custom_formatter = formatter
        self.custom_validator = validator
        self._address_info = []  # property self.address_info resolved at open time (or when check_availability is called)
        self._address_info_resolved_time = datetime(MINYEAR, 1, 1)  # smallest date ever
        self.current_address = None
        self.connect_timeout = connect_timeout
        self.mode = mode

        self.get_info_from_server(None)  # load offline schema if needed

        if log_enabled(BASIC):
            log(BASIC, 'instantiated Server: <%r>', self)

    @staticmethod
    def _is_ipv6(host):
        try:
            socket.inet_pton(socket.AF_INET6, host)
        except (socket.error, AttributeError, ValueError):
            return False
        return True

    def __str__(self):
        if self.host:
            s = self.name + (' - ssl' if self.ssl else ' - cleartext') + (' - unix socket' if self.ipc else '')
        else:
            s = object.__str__(self)
        return s

    def __repr__(self):
        r = 'Server(host={0.host!r}, port={0.port!r}, use_ssl={0.ssl!r}'.format(self)
        r += '' if not self.allowed_referral_hosts else ', allowed_referral_hosts={0.allowed_referral_hosts!r}'.format(self)
        r += '' if self.tls is None else ', tls={0.tls!r}'.format(self)
        r += '' if not self.get_info else ', get_info={0.get_info!r}'.format(self)
        r += '' if not self.connect_timeout else ', connect_timeout={0.connect_timeout!r}'.format(self)
        r += '' if not self.mode else ', mode={0.mode!r}'.format(self)
        r += ')'

        return r

    @property
    def address_info(self):
        conf_refresh_interval = get_config_parameter('ADDRESS_INFO_REFRESH_TIME')
        if not self._address_info or (datetime.now() - self._address_info_resolved_time).seconds > conf_refresh_interval:
            # converts addresses tuple to list and adds a 6th parameter for availability (None = not checked, True = available, False=not available) and a 7th parameter for the checking time
            addresses = None
            try:
                if self.ipc:
                    addresses = [(socket.AF_UNIX, socket.SOCK_STREAM, 0, None, self.host, None)]
                else:
                    addresses = socket.getaddrinfo(self.host, self.port, socket.AF_UNSPEC, socket.SOCK_STREAM, socket.IPPROTO_TCP, socket.AI_ADDRCONFIG | socket.AI_V4MAPPED)
            except (socket.gaierror, AttributeError):
                pass

            if not addresses:  # if addresses not found or raised an exception (for example for bad flags) tries again without flags
                try:
                    addresses = socket.getaddrinfo(self.host, self.port, socket.AF_UNSPEC, socket.SOCK_STREAM, socket.IPPROTO_TCP)
                except socket.gaierror:
                    pass

            if addresses:
                self._address_info = [list(address) + [None, None] for address in addresses]
                self._address_info_resolved_time = datetime.now()
            else:
                self._address_info = []
                self._address_info_resolved_time = datetime(MINYEAR, 1, 1)  # smallest date

            if log_enabled(BASIC):
                for address in self._address_info:
                    log(BASIC, 'address for <%s> resolved as <%r>', self, address[:-2])
        return self._address_info

    def update_availability(self, address, available):
        cont = 0
        while cont < len(self._address_info):
            if self.address_info[cont] == address:
                self._address_info[cont][5] = True if available else False
                self._address_info[cont][6] = datetime.now()
                break
            cont += 1

    def reset_availability(self):
        for address in self._address_info:
            address[5] = None
            address[6] = None

    def check_availability(self):
        """
        Tries to open, connect and close a socket to specified address
        and port to check availability. Timeout in seconds is specified in CHECK_AVAILABITY_TIMEOUT if not specified in
        the Server object
        """
        conf_availability_timeout = get_config_parameter('CHECK_AVAILABILITY_TIMEOUT')
        available = False
        self.reset_availability()
        for address in self.candidate_addresses():
            available = True
            try:
                temp_socket = socket.socket(*address[:3])
                if self.connect_timeout:
                    temp_socket.settimeout(self.connect_timeout)
                else:
                    temp_socket.settimeout(conf_availability_timeout)  # set timeout for checking availability to default
                try:
                    temp_socket.connect(address[4])
                except socket.error:
                    available = False
                finally:
                    try:
                        temp_socket.shutdown(socket.SHUT_RDWR)
                    except socket.error:
                        available = False
                    finally:
                        temp_socket.close()
            except socket.gaierror:
                available = False

            if available:
                if log_enabled(BASIC):
                    log(BASIC, 'server <%s> available at <%r>', self, address)
                self.update_availability(address, True)
                break  # if an available address is found exits immediately
            else:
                self.update_availability(address, False)
                if log_enabled(ERROR):
                    log(ERROR, 'server <%s> not available at <%r>', self, address)

        return available

    @staticmethod
    def next_message_id():
        """
        LDAP messageId is unique for all connections to same server
        """
        with Server._message_id_lock:
            Server._message_counter += 1
            if Server._message_counter >= LDAP_MAX_INT:
                Server._message_counter = 1
            if log_enabled(PROTOCOL):
                log(PROTOCOL, 'new message id <%d> generated', Server._message_counter)

        return Server._message_counter

    def _get_dsa_info(self, connection):
        """
        Retrieve DSE operational attribute as per RFC4512 (5.1).
        """
        if connection.strategy.no_real_dsa:  # do not try for mock strategies
            return

        if not connection.strategy.pooled:  # in pooled strategies get_dsa_info is performed by the worker threads
            result = connection.search(search_base='',
                                       search_filter='(objectClass=*)',
                                       search_scope=BASE,
                                       attributes=['altServer',  # requests specific dsa info attributes
                                                   'namingContexts',
                                                   'supportedControl',
                                                   'supportedExtension',
                                                   'supportedFeatures',
                                                   'supportedCapabilities',
                                                   'supportedLdapVersion',
                                                   'supportedSASLMechanisms',
                                                   'vendorName',
                                                   'vendorVersion',
                                                   'subschemaSubentry',
                                                   '*',
                                                   '+'],  # requests all remaining attributes (other),
                                       get_operational_attributes=True)

            with self.dit_lock:
                if isinstance(result, bool):  # sync request
                    self._dsa_info = DsaInfo(connection.response[0]['attributes'], connection.response[0]['raw_attributes']) if result else self._dsa_info
                elif result:  # asynchronous request, must check if attributes in response
                    results, _ = connection.get_response(result)
                    if len(results) == 1 and 'attributes' in results[0] and 'raw_attributes' in results[0]:
                        self._dsa_info = DsaInfo(results[0]['attributes'], results[0]['raw_attributes'])

            if log_enabled(BASIC):
                log(BASIC, 'DSA info read for <%s> via <%s>', self, connection)

    def _get_schema_info(self, connection, entry=''):
        """
        Retrieve schema from subschemaSubentry DSE attribute, per RFC
        4512 (4.4 and 5.1); entry = '' means DSE.
        """
        if connection.strategy.no_real_dsa:  # do not try for mock strategies
            return

        schema_entry = None
        if self._dsa_info and entry == '':  # subschemaSubentry already present in dsaInfo
            if isinstance(self._dsa_info.schema_entry, SEQUENCE_TYPES):
                schema_entry = self._dsa_info.schema_entry[0] if self._dsa_info.schema_entry else None
            else:
                schema_entry = self._dsa_info.schema_entry if self._dsa_info.schema_entry else None
        else:
            result = connection.search(entry, '(objectClass=*)', BASE, attributes=['subschemaSubentry'], get_operational_attributes=True)
            if isinstance(result, bool):  # sync request
                if result and 'subschemaSubentry' in connection.response[0]['raw_attributes']:
                    if len(connection.response[0]['raw_attributes']['subschemaSubentry']) > 0:
                        schema_entry = connection.response[0]['raw_attributes']['subschemaSubentry'][0]
            else:  # asynchronous request, must check if subschemaSubentry in attributes
                results, _ = connection.get_response(result)
                if len(results) == 1 and 'raw_attributes' in results[0] and 'subschemaSubentry' in results[0]['attributes']:
                    if len(results[0]['raw_attributes']['subschemaSubentry']) > 0:
                        schema_entry = results[0]['raw_attributes']['subschemaSubentry'][0]

        if schema_entry and not connection.strategy.pooled:  # in pooled strategies get_schema_info is performed by the worker threads
            if isinstance(schema_entry, bytes) and str is not bytes:  # Python 3
                schema_entry = to_unicode(schema_entry, from_server=True)
            result = connection.search(schema_entry,
                                       search_filter='(objectClass=subschema)',
                                       search_scope=BASE,
                                       attributes=['objectClasses',  # requests specific subschema attributes
                                                   'attributeTypes',
                                                   'ldapSyntaxes',
                                                   'matchingRules',
                                                   'matchingRuleUse',
                                                   'dITContentRules',
                                                   'dITStructureRules',
                                                   'nameForms',
                                                   'createTimestamp',
                                                   'modifyTimestamp',
                                                   '*'],  # requests all remaining attributes (other)
                                       get_operational_attributes=True
                                       )
            with self.dit_lock:
                self._schema_info = None
                if result:
                    if isinstance(result, bool):  # sync request
                        self._schema_info = SchemaInfo(schema_entry, connection.response[0]['attributes'], connection.response[0]['raw_attributes']) if result else None
                    else:  # asynchronous request, must check if attributes in response
                        results, result = connection.get_response(result)
                        if len(results) == 1 and 'attributes' in results[0] and 'raw_attributes' in results[0]:
                            self._schema_info = SchemaInfo(schema_entry, results[0]['attributes'], results[0]['raw_attributes'])
                    if self._schema_info and not self._schema_info.is_valid():  # flaky servers can return an empty schema, checks if it is so and set schema to None
                        self._schema_info = None
                    if self._schema_info:  # if schema is valid tries to apply formatter to the "other" dict with raw values for schema and info
                        for attribute in self._schema_info.other:
                            self._schema_info.other[attribute] = format_attribute_values(self._schema_info, attribute, self._schema_info.raw[attribute], self.custom_formatter)
                        if self._dsa_info:  # try to apply formatter to the "other" dict with dsa info raw values
                            for attribute in self._dsa_info.other:
                                self._dsa_info.other[attribute] = format_attribute_values(self._schema_info, attribute, self._dsa_info.raw[attribute], self.custom_formatter)
            if log_enabled(BASIC):
                log(BASIC, 'schema read for <%s> via <%s>', self, connection)

    def get_info_from_server(self, connection):
        """
        reads info from DSE and from subschema
        """
        if connection and not connection.closed:
            if self.get_info in [DSA, ALL]:
                self._get_dsa_info(connection)
            if self.get_info in [SCHEMA, ALL]:
                    self._get_schema_info(connection)
        elif self.get_info == OFFLINE_EDIR_8_8_8:
            from ..protocol.schemas.edir888 import edir_8_8_8_schema, edir_8_8_8_dsa_info
            self.attach_schema_info(SchemaInfo.from_json(edir_8_8_8_schema))
            self.attach_dsa_info(DsaInfo.from_json(edir_8_8_8_dsa_info))
        elif self.get_info == OFFLINE_AD_2012_R2:
            from ..protocol.schemas.ad2012R2 import ad_2012_r2_schema, ad_2012_r2_dsa_info
            self.attach_schema_info(SchemaInfo.from_json(ad_2012_r2_schema))
            self.attach_dsa_info(DsaInfo.from_json(ad_2012_r2_dsa_info))
        elif self.get_info == OFFLINE_SLAPD_2_4:
            from ..protocol.schemas.slapd24 import slapd_2_4_schema, slapd_2_4_dsa_info
            self.attach_schema_info(SchemaInfo.from_json(slapd_2_4_schema))
            self.attach_dsa_info(DsaInfo.from_json(slapd_2_4_dsa_info))
        elif self.get_info == OFFLINE_DS389_1_3_3:
            from ..protocol.schemas.ds389 import ds389_1_3_3_schema, ds389_1_3_3_dsa_info
            self.attach_schema_info(SchemaInfo.from_json(ds389_1_3_3_schema))
            self.attach_dsa_info(DsaInfo.from_json(ds389_1_3_3_dsa_info))

    def attach_dsa_info(self, dsa_info=None):
        if isinstance(dsa_info, DsaInfo):
            self._dsa_info = dsa_info
            if log_enabled(BASIC):
                log(BASIC, 'attached DSA info to Server <%s>', self)

    def attach_schema_info(self, dsa_schema=None):
        if isinstance(dsa_schema, SchemaInfo):
            self._schema_info = dsa_schema
        if log_enabled(BASIC):
            log(BASIC, 'attached schema info to Server <%s>', self)

    @property
    def info(self):
        return self._dsa_info

    @property
    def schema(self):
        return self._schema_info

    @staticmethod
    def from_definition(host, dsa_info, dsa_schema, port=None, use_ssl=False, formatter=None, validator=None):
        """
        Define a dummy server with preloaded schema and info
        :param host: host name
        :param dsa_info: DsaInfo preloaded object or a json formatted string or a file name
        :param dsa_schema: SchemaInfo preloaded object or a json formatted string or a file name
        :param port: dummy port
        :param use_ssl: use_ssl
        :param formatter: custom formatter
        :return: Server object
        """
        if isinstance(host, SEQUENCE_TYPES):
            dummy = Server(host=host[0], port=port, use_ssl=use_ssl, formatter=formatter, validator=validator, get_info=ALL)  # for ServerPool object
        else:
            dummy = Server(host=host, port=port, use_ssl=use_ssl, formatter=formatter, validator=validator, get_info=ALL)
        if isinstance(dsa_info, DsaInfo):
            dummy._dsa_info = dsa_info
        elif isinstance(dsa_info, STRING_TYPES):
            try:
                dummy._dsa_info = DsaInfo.from_json(dsa_info)  # tries to use dsa_info as a json configuration string
            except Exception:
                dummy._dsa_info = DsaInfo.from_file(dsa_info)  # tries to use dsa_info as a file name

        if not dummy.info:
            if log_enabled(ERROR):
                log(ERROR, 'invalid DSA info for %s', host)
            raise LDAPDefinitionError('invalid dsa info')

        if isinstance(dsa_schema, SchemaInfo):
            dummy._schema_info = dsa_schema
        elif isinstance(dsa_schema, STRING_TYPES):
            try:
                dummy._schema_info = SchemaInfo.from_json(dsa_schema)
            except Exception:
                dummy._schema_info = SchemaInfo.from_file(dsa_schema)

        if not dummy.schema:
            if log_enabled(ERROR):
                log(ERROR, 'invalid schema info for %s', host)
            raise LDAPDefinitionError('invalid schema info')

        if log_enabled(BASIC):
            log(BASIC, 'created server <%s> from definition', dummy)

        return dummy

    def candidate_addresses(self):
        conf_reset_availability_timeout = get_config_parameter('RESET_AVAILABILITY_TIMEOUT')
        if self.ipc:
            candidates = self.address_info
            if log_enabled(BASIC):
                log(BASIC, 'candidate address for <%s>: <%s> with mode UNIX_SOCKET', self, self.name)
        else:
            # checks reset availability timeout
            for address in self.address_info:
                if address[6] and ((datetime.now() - address[6]).seconds > conf_reset_availability_timeout):
                    address[5] = None
                    address[6] = None

            # selects server address based on server mode and availability (in address[5])
            addresses = self.address_info[:]  # copy to avoid refreshing while searching candidates
            candidates = []
            if addresses:
                if self.mode == IP_SYSTEM_DEFAULT:
                    candidates.append(addresses[0])
                elif self.mode == IP_V4_ONLY:
                    candidates = [address for address in addresses if address[0] == socket.AF_INET and (address[5] or address[5] is None)]
                elif self.mode == IP_V6_ONLY:
                    candidates = [address for address in addresses if address[0] == socket.AF_INET6 and (address[5] or address[5] is None)]
                elif self.mode == IP_V4_PREFERRED:
                    candidates = [address for address in addresses if address[0] == socket.AF_INET and (address[5] or address[5] is None)]
                    candidates += [address for address in addresses if address[0] == socket.AF_INET6 and (address[5] or address[5] is None)]
                elif self.mode == IP_V6_PREFERRED:
                    candidates = [address for address in addresses if address[0] == socket.AF_INET6 and (address[5] or address[5] is None)]
                    candidates += [address for address in addresses if address[0] == socket.AF_INET and (address[5] or address[5] is None)]
                else:
                    if log_enabled(ERROR):
                        log(ERROR, 'invalid server mode for <%s>', self)
                    raise LDAPInvalidServerError('invalid server mode')

            if log_enabled(BASIC):
                for candidate in candidates:
                    log(BASIC, 'obtained candidate address for <%s>: <%r> with mode %s', self, candidate[:-2], self.mode)
        return candidates
