<?php

// SPDX-FileCopyrightText: 2018 Icinga GmbH <https://icinga.com>
// SPDX-License-Identifier: GPL-3.0-or-later

namespace Icinga\Protocol\Ldap;

use ArrayIterator;
use Exception;
use Icinga\Data\Filter\FilterNot;
use LogicException;
use stdClass;
use Icinga\Application\Config;
use Icinga\Application\Logger;
use Icinga\Data\ConfigObject;
use Icinga\Data\Filter\Filter;
use Icinga\Data\Filter\FilterChain;
use Icinga\Data\Filter\FilterExpression;
use Icinga\Data\Inspectable;
use Icinga\Data\Inspection;
use Icinga\Data\Selectable;
use Icinga\Data\Sortable;
use Icinga\Exception\ProgrammingError;
use Icinga\Web\Url;

/**
 * Encapsulate LDAP connections and query creation
 */
class LdapConnection implements Selectable, Inspectable
{
    /**
     * Indicates that the target object cannot be found
     *
     * @var int
     */
    const LDAP_NO_SUCH_OBJECT = 32;

    /**
     * Indicates that in a search operation, the size limit specified by the client or the server has been exceeded
     *
     * @var int
     */
    const LDAP_SIZELIMIT_EXCEEDED = 4;

    /**
     * Indicates that an LDAP server limit set by an administrative authority has been exceeded
     *
     * @var int
     */
    const LDAP_ADMINLIMIT_EXCEEDED = 11;

    /**
     * Indicates that during a bind operation one of the following occurred: The client passed either an incorrect DN
     * or password, or the password is incorrect because it has expired, intruder detection has locked the account, or
     * another similar reason.
     *
     * @var int
     */
    const LDAP_INVALID_CREDENTIALS = 49;

    /**
     * The default page size to use for paged queries
     *
     * @var int
     */
    const PAGE_SIZE = 1000;

    /**
     * Encrypt connection using STARTTLS (upgrading a plain text connection)
     *
     * @var string
     */
    const STARTTLS = 'starttls';

    /**
     * Encrypt connection using LDAP over SSL (using a separate port)
     *
     * @var string
     */
    const LDAPS = 'ldaps';

    /** @var ConfigObject Connection configuration */
    protected $config;

    /**
     * Encryption for the connection if any
     *
     * @var string
     */
    protected $encryption;

    /**
     * The LDAP link identifier being used
     */
    protected $ds;

    /**
     * The ip address, hostname or ldap URI being used to connect with the LDAP server
     *
     * @var string
     */
    protected $hostname;

    /**
     * The port being used to connect with the LDAP server
     *
     * @var int
     */
    protected $port;

    /**
     * The distinguished name being used to bind to the LDAP server
     *
     * @var string
     */
    protected $bindDn;

    /**
     * The password being used to bind to the LDAP server
     *
     * @var string
     */
    protected $bindPw;

    /**
     * The distinguished name being used as the base path for queries which do not provide one theirselves
     *
     * @var string
     */
    protected $rootDn;

    /**
     * Whether the bind on this connection has already been performed
     *
     * @var bool
     */
    protected $bound;

    /**
     * The current connection's root node
     *
     * @var Root
     */
    protected $root;

    /**
     * LDAP_OPT_NETWORK_TIMEOUT for the LDAP connection
     *
     * @var int
     */
    protected $timeout;

    /**
     * The properties and capabilities of the LDAP server
     *
     * @var LdapCapabilities
     */
    protected $capabilities;

    /**
     * Whether discovery was successful
     *
     * @var bool
     */
    protected $discoverySuccess;

    /**
     * The cause of the discovery's failure
     *
     * @var Exception|null
     */
    private $discoveryError;

    /**
     * Whether the current connection is encrypted
     *
     * @var bool
     */
    protected $encrypted = null;

    /**
     * Create a new connection object
     *
     * @param   ConfigObject    $config
     */
    public function __construct(ConfigObject $config)
    {
        $this->config = $config;
        $this->hostname = $config->hostname;
        $this->bindDn = $config->bind_dn;
        $this->bindPw = $config->bind_pw;
        $this->rootDn = $config->root_dn;
        $this->port = (int) $config->get('port', 389);
        $this->timeout = (int) $config->get('timeout', 5);

        $this->encryption = $config->encryption;
        if ($this->encryption !== null) {
            $this->encryption = strtolower($this->encryption);
        }
    }

    /**
     * Return the ip address, hostname or ldap URI being used to connect with the LDAP server
     *
     * @return  string
     */
    public function getHostname()
    {
        return $this->hostname;
    }

    /**
     * Return the port being used to connect with the LDAP server
     *
     * @return  int
     */
    public function getPort()
    {
        return $this->port;
    }

    /**
     * Return the distinguished name being used as the base path for queries which do not provide one theirselves
     *
     * @return  string
     */
    public function getDn()
    {
        return $this->rootDn;
    }

    /**
     * Return the root node for this connection
     *
     * @return  Root
     */
    public function root()
    {
        if ($this->root === null) {
            $this->root = Root::forConnection($this);
        }

        return $this->root;
    }

    /**
     * Return the LDAP link identifier being used
     *
     * Establishes a connection if necessary.
     *
     * @throws LdapException
     */
    public function getConnection()
    {
        if ($this->ds === null) {
            $this->ds = $this->prepareNewConnection();
        }

        return $this->ds;
    }

    /**
     * Return the capabilities of the current connection
     *
     * @return  LdapCapabilities
     */
    public function getCapabilities()
    {
        if ($this->capabilities === null) {
            try {
                $this->capabilities = LdapCapabilities::discoverCapabilities($this);
                $this->discoverySuccess = true;
                $this->discoveryError = null;
            } catch (LdapException $e) {
                Logger::debug($e);
                Logger::warning('LADP discovery failed, assuming default LDAP capabilities.');
                $this->capabilities = new LdapCapabilities(); // create empty default capabilities
                $this->discoverySuccess = false;
                $this->discoveryError = $e;
            }
        }

        return $this->capabilities;
    }

    /**
     * Return whether discovery was successful
     *
     * @return  bool    true if the capabilities were successfully determined, false if the capabilities were guessed
     */
    public function discoverySuccessful()
    {
        if ($this->discoverySuccess === null) {
            $this->getCapabilities(); // Initializes self::$discoverySuccess
        }

        return $this->discoverySuccess;
    }

    /**
     * Get discovery error if any
     *
     * @return Exception|null
     */
    public function getDiscoveryError()
    {
        return $this->discoveryError;
    }

    /**
     * Return whether the current connection is encrypted
     *
     * @return  bool
     */
    public function isEncrypted()
    {
        if ($this->encrypted === null) {
            return false;
        }

        return $this->encrypted;
    }

    /**
     * Perform a LDAP bind on the current connection
     *
     * @throws  LdapException   In case the LDAP bind was unsuccessful or insecure
     */
    public function bind()
    {
        if ($this->bound) {
            return $this;
        }

        $ds = $this->getConnection();

        $success = @ldap_bind($ds, $this->bindDn, $this->bindPw);
        if (! $success) {
            throw new LdapException(
                'LDAP bind (%s / %s) to %s failed: %s',
                $this->bindDn,
                '***' /* $this->bindPw */,
                $this->normalizeHostname($this->hostname),
                ldap_error($ds)
            );
        }

        $this->bound = true;
        return $this;
    }

    /**
     * Provide a query on this connection
     *
     * @return  LdapQuery
     */
    public function select()
    {
        return new LdapQuery($this);
    }

    /**
     * Fetch and return all rows of the given query's result set using an iterator
     *
     * @param   LdapQuery   $query  The query returning the result set
     *
     * @return  ArrayIterator
     */
    public function query(LdapQuery $query)
    {
        return new ArrayIterator($this->fetchAll($query));
    }

    /**
     * Count all rows of the given query's result set
     *
     * @param   LdapQuery   $query  The query returning the result set
     *
     * @return  int
     */
    public function count(LdapQuery $query)
    {
        $this->bind();

        if (($unfoldAttribute = $query->getUnfoldAttribute()) !== null) {
            $desiredColumns = $query->getColumns();
            if (isset($desiredColumns[$unfoldAttribute])) {
                $fields = array($unfoldAttribute => $desiredColumns[$unfoldAttribute]);
            } elseif (in_array($unfoldAttribute, $desiredColumns, true)) {
                $fields = array($unfoldAttribute);
            } else {
                throw new ProgrammingError(
                    'The attribute used to unfold a query\'s result must be selected'
                );
            }

            $res = $this->runQuery($query, $fields);
            return count($res);
        }

        $ds = $this->getConnection();
        $results = $this->ldapSearch($query, array('dn'));

        if ($results === false) {
            if (ldap_errno($ds) !== self::LDAP_NO_SUCH_OBJECT) {
                throw new LdapException(
                    'LDAP count query "%s" (base %s) failed: %s',
                    (string) $query,
                    $query->getBase() ?: $this->getDn(),
                    ldap_error($ds)
                );
            }
        }

        return ldap_count_entries($ds, $results);
    }

    /**
     * Retrieve an array containing all rows of the result set
     *
     * @param LdapQuery $query The query returning the result set
     * @param ?array $fields Request these attributes instead of the ones registered in the given query
     *
     * @return array
     */
    public function fetchAll(LdapQuery $query, ?array $fields = null)
    {
        $this->bind();

        if ($query->getUsePagedResults() && $this->getCapabilities()->hasPagedResult()) {
            return $this->runPagedQuery($query, $fields);
        } else {
            return $this->runQuery($query, $fields);
        }
    }

    /**
     * Fetch the first row of the result set
     *
     * @param LdapQuery $query The query returning the result set
     * @param ?array $fields Request these attributes instead of the ones registered in the given query
     *
     * @return mixed
     */
    public function fetchRow(LdapQuery $query, ?array $fields = null)
    {
        $clonedQuery = clone $query;
        $clonedQuery->limit(1);
        $clonedQuery->setUsePagedResults(false);
        $results = $this->fetchAll($clonedQuery, $fields);
        return array_shift($results) ?: false;
    }

    /**
     * Fetch the first column of all rows of the result set as an array
     *
     * @param LdapQuery $query The query returning the result set
     * @param ?array $fields Request these attributes instead of the ones registered in the given query
     *
     * @return array
     *
     * @throws ProgrammingError In case no attribute is being requested
     */
    public function fetchColumn(LdapQuery $query, ?array $fields = null)
    {
        if ($fields === null) {
            $fields = $query->getColumns();
        }

        if (empty($fields)) {
            throw new ProgrammingError('You must request at least one attribute when fetching a single column');
        }

        $alias = key($fields);
        $results = $this->fetchAll($query, array($alias => current($fields)));
        $column = is_int($alias) ? current($fields) : $alias;
        $values = array();
        foreach ($results as $row) {
            if (isset($row->$column)) {
                $values[] = $row->$column;
            }
        }

        return $values;
    }

    /**
     * Fetch the first column of the first row of the result set
     *
     * @param LdapQuery $query The query returning the result set
     * @param ?array $fields Request these attributes instead of the ones registered in the given query
     *
     * @return string
     */
    public function fetchOne(LdapQuery $query, ?array $fields = null)
    {
        $row = $this->fetchRow($query, $fields);
        if ($row === false) {
            return false;
        }

        $values = get_object_vars($row);
        if (empty($values)) {
            return false;
        }

        if ($fields === null) {
            // Fetch the desired columns from the query if not explicitly overriden in the method's parameter
            $fields = $query->getColumns();
        }

        if (empty($fields)) {
            // The desired columns may be empty independently whether provided by the query or the method's parameter
            return array_shift($values);
        }

        $alias = key($fields);
        return $values[is_string($alias) ? $alias : $fields[$alias]];
    }

    /**
     * Fetch all rows of the result set as an array of key-value pairs
     *
     * The first column is the key, the second column is the value.
     *
     * @param LdapQuery $query The query returning the result set
     * @param ?array $fields Request these attributes instead of the ones registered in the given query
     *
     * @return array
     *
     * @throws ProgrammingError In case there are less than two attributes being requested
     */
    public function fetchPairs(LdapQuery $query, ?array $fields = null)
    {
        if ($fields === null) {
            $fields = $query->getColumns();
        }

        if (count($fields) < 2) {
            throw new ProgrammingError('You are required to request at least two attributes');
        }

        $columns = $desiredColumnNames = array();
        foreach ($fields as $alias => $column) {
            if (is_int($alias)) {
                $columns[] = $column;
                $desiredColumnNames[] = $column;
            } else {
                $columns[$alias] = $column;
                $desiredColumnNames[] = $alias;
            }

            if (count($desiredColumnNames) === 2) {
                break;
            }
        }

        $results = $this->fetchAll($query, $columns);
        $pairs = array();
        foreach ($results as $row) {
            $colOne = $desiredColumnNames[0];
            $colTwo = $desiredColumnNames[1];
            $pairs[$row->$colOne] = $row->$colTwo;
        }

        return $pairs;
    }

    /**
     * Fetch an LDAP entry by its DN
     *
     * @param string $dn
     * @param ?array $fields
     *
     * @return StdClass|bool
     */
    public function fetchByDn($dn, ?array $fields = null)
    {
        return $this->select()
            ->from('*', $fields)
            ->setBase($dn)
            ->setScope('base')
            ->fetchRow();
    }

    /**
     * Test the given LDAP credentials by establishing a connection and attempting a LDAP bind
     *
     * @param   string  $bindDn
     * @param   string  $bindPw
     *
     * @return  bool                Whether the given credentials are valid
     *
     * @throws  LdapException       In case an error occured while establishing the connection or attempting the bind
     */
    public function testCredentials($bindDn, $bindPw)
    {
        $ds = $this->getConnection();
        $success = @ldap_bind($ds, $bindDn, $bindPw);
        if (! $success) {
            if (ldap_errno($ds) === self::LDAP_INVALID_CREDENTIALS) {
                Logger::debug(
                    'Testing LDAP credentials (%s / %s) failed: %s',
                    $bindDn,
                    '***',
                    ldap_error($ds)
                );
                return false;
            }

            throw new LdapException(ldap_error($ds));
        }

        return true;
    }

    /**
     * Return whether an entry identified by the given distinguished name exists
     *
     * @param string $dn
     *
     * @return  bool
     *
     * @throws LdapException
     */
    public function hasDn($dn)
    {
        $ds = $this->getConnection();
        $this->bind();

        $result = ldap_read($ds, $dn, '(objectClass=*)', array('objectClass'));
        if ($result === false) {
            return false;
        }

        return ldap_count_entries($ds, $result) > 0;
    }

    /**
     * Delete a root entry and all of its children identified by the given distinguished name
     *
     * @param   string  $dn
     *
     * @return  bool
     *
     * @throws  LdapException   In case an error occured while deleting an entry
     */
    public function deleteRecursively($dn)
    {
        $ds = $this->getConnection();
        $this->bind();

        $result = @ldap_list($ds, $dn, '(objectClass=*)', array('objectClass'));
        if ($result === false) {
            if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) {
                return false;
            }

            throw new LdapException('LDAP list for "%s" failed: %s', $dn, ldap_error($ds));
        }

        $children = ldap_get_entries($ds, $result);
        if ($children === false) {
            return false;
        }

        for ($i = 0; $i < $children['count']; $i++) {
            $result = $this->deleteRecursively($children[$i]['dn']);
            if (! $result) {
                // TODO: return result code, if delete fails
                throw new LdapException('Recursively deleting "%s" failed', $dn);
            }
        }

        return $this->deleteDn($dn);
    }

    /**
     * Delete a single entry identified by the given distinguished name
     *
     * @param   string  $dn
     *
     * @return  bool
     *
     * @throws  LdapException   In case an error occured while deleting the entry
     */
    public function deleteDn($dn)
    {
        $ds = $this->getConnection();
        $this->bind();

        $result = @ldap_delete($ds, $dn);
        if ($result === false) {
            if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) {
                return false; // TODO: Isn't it a success if something i'd like to remove is not existing at all???
            }

            throw new LdapException('LDAP delete for "%s" failed: %s', $dn, ldap_error($ds));
        }

        return true;
    }

    /**
     * Fetch the distinguished name of the result of the given query
     *
     * @param   LdapQuery   $query  The query returning the result set
     *
     * @return  string              The distinguished name, or false when the given query yields no results
     *
     * @throws  LdapException       In case the query yields multiple results
     */
    public function fetchDn(LdapQuery $query)
    {
        $rows = $this->fetchAll($query, array());
        if (count($rows) > 1) {
            throw new LdapException('Cannot fetch single DN for %s', $query);
        }

        return key($rows);
    }

    /**
     * Run the given LDAP query and return the resulting entries
     *
     * @param LdapQuery $query The query to fetch results with
     * @param ?array $fields Request these attributes instead of the ones registered in the given query
     *
     * @return array
     *
     * @throws LdapException In case an error occured while fetching the results
     */
    protected function runQuery(LdapQuery $query, ?array $fields = null)
    {
        $limit = $query->getLimit();
        $offset = $query->hasOffset() ? $query->getOffset() : 0;

        if ($fields === null) {
            $fields = $query->getColumns();
        }

        $ds = $this->getConnection();

        $serverSorting = ! $this->config->disable_server_side_sort
            && $this->getCapabilities()->hasOid(LdapCapabilities::LDAP_SERVER_SORT_OID);

        if ($query->hasOrder()) {
            if ($serverSorting) {
                ldap_set_option($ds, LDAP_OPT_SERVER_CONTROLS, array(
                    array(
                        'oid'   => LdapCapabilities::LDAP_SERVER_SORT_OID,
                        'value' => $this->encodeSortRules($query->getOrder())
                    )
                ));
            } elseif (! empty($fields)) {
                foreach ($query->getOrder() as $rule) {
                    if (! in_array($rule[0], $fields, true)) {
                        $fields[] = $rule[0];
                    }
                }
            }
        }

        $unfoldAttribute = $query->getUnfoldAttribute();
        if ($unfoldAttribute) {
            foreach ($query->getFilter()->listFilteredColumns() as $filterColumn) {
                $fieldKey = array_search($filterColumn, $fields, true);
                if ($fieldKey === false || is_string($fieldKey)) {
                    $fields[] = $filterColumn;
                }
            }
        }

        $results = $this->ldapSearch(
            $query,
            array_values($fields),
            0,
            ($serverSorting || ! $query->hasOrder()) && $limit ? $offset + $limit : 0
        );
        if ($results === false) {
            if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) {
                return array();
            }

            throw new LdapException(
                'LDAP query "%s" (base %s) failed. Error: %s',
                $query,
                $query->getBase() ?: $this->rootDn,
                ldap_error($ds)
            );
        } elseif (ldap_count_entries($ds, $results) === 0) {
            return array();
        }

        $count = 0;
        $entries = array();
        $entry = ldap_first_entry($ds, $results);
        if ($entry === false) {
            return [];
        }

        do {
            if ($unfoldAttribute) {
                $rows = $this->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields, $unfoldAttribute);
                if (is_array($rows)) {
                    // TODO: Register the DN the same way as a section name in the ArrayDatasource!
                    foreach ($rows as $row) {
                        if ($query->getFilter()->matches($row)) {
                            $count += 1;
                            if (! $serverSorting || $offset === 0 || $offset < $count) {
                                $entries[] = $row;
                            }

                            if ($serverSorting && $limit > 0 && $limit === count($entries)) {
                                break;
                            }
                        }
                    }
                } else {
                    $count += 1;
                    if (! $serverSorting || $offset === 0 || $offset < $count) {
                        $entries[ldap_get_dn($ds, $entry)] = $rows;
                    }
                }
            } else {
                $count += 1;
                if (! $serverSorting || $offset === 0 || $offset < $count) {
                    $entries[ldap_get_dn($ds, $entry)] = $this->cleanupAttributes(
                        ldap_get_attributes($ds, $entry),
                        $fields
                    );
                }
            }
        } while ((! $serverSorting || $limit === 0 || $limit !== count($entries))
            && ($entry = ldap_next_entry($ds, $entry))
        );

        if (! $serverSorting) {
            if ($query->hasOrder()) {
                uasort($entries, array($query, 'compare'));
            }

            if ($limit && $count > $limit) {
                $entries = array_splice($entries, $query->hasOffset() ? $query->getOffset() : 0, $limit);
            }
        }

        ldap_free_result($results);
        return $entries;
    }

    /**
     * Run the given LDAP query and return the resulting entries
     *
     * This utilizes paged search requests as defined in RFC 2696.
     *
     * @param LdapQuery $query The query to fetch results with
     * @param ?array $fields Request these attributes instead of the ones registered in the given query
     * @param int $pageSize The maximum page size, defaults to self::PAGE_SIZE
     *
     * @return array
     *
     * @throws LdapException In case an error occured while fetching the results
     */
    protected function runPagedQuery(LdapQuery $query, ?array $fields = null, $pageSize = null)
    {
        if ($pageSize === null) {
            $pageSize = static::PAGE_SIZE;
        }

        $limit = $query->getLimit();
        $offset = $query->hasOffset() ? $query->getOffset() : 0;

        if ($fields === null) {
            $fields = $query->getColumns();
        }

        $ds = $this->getConnection();

        $serverSorting = false;//$this->getCapabilities()->hasOid(LdapCapabilities::LDAP_SERVER_SORT_OID);
        if (! $serverSorting && $query->hasOrder() && ! empty($fields)) {
            foreach ($query->getOrder() as $rule) {
                if (! in_array($rule[0], $fields, true)) {
                    $fields[] = $rule[0];
                }
            }
        }

        $unfoldAttribute = $query->getUnfoldAttribute();
        if ($unfoldAttribute) {
            foreach ($query->getFilter()->listFilteredColumns() as $filterColumn) {
                $fieldKey = array_search($filterColumn, $fields, true);
                if ($fieldKey === false || is_string($fieldKey)) {
                    $fields[] = $filterColumn;
                }
            }
        }

        $controls = [];
        $legacyControlHandling = version_compare(PHP_VERSION, '7.3.0') < 0;
        if ($serverSorting && $query->hasOrder()) {
            $control = [
                'oid'   => LDAP_CONTROL_SORTREQUEST,
                'value' => $this->encodeSortRules($query->getOrder())
            ];
            if ($legacyControlHandling) {
                ldap_set_option($ds, LDAP_OPT_SERVER_CONTROLS, [$control]);
            } else {
                $controls[LDAP_CONTROL_SORTREQUEST] = $control;
            }
        }

        $count = 0;
        $cookie = '';
        $entries = array();
        do {
            if ($legacyControlHandling) {
                // Do not request the pagination control as a critical extension, as we want the
                // server to return results even if the paged search request cannot be satisfied
                ldap_control_paged_result($ds, $pageSize, false, $cookie);
            } else {
                $controls[LDAP_CONTROL_PAGEDRESULTS] = [
                    'oid'        => LDAP_CONTROL_PAGEDRESULTS,
                    'iscritical' => false, // See above
                    'value'      => [
                        'size'   => $pageSize,
                        'cookie' => $cookie
                    ]
                ];
            }

            $results = $this->ldapSearch(
                $query,
                array_values($fields),
                0,
                ($serverSorting || ! $query->hasOrder()) && $limit ? $offset + $limit : 0,
                0,
                LDAP_DEREF_NEVER,
                empty($controls) ? null : $controls
            );
            if ($results === false) {
                if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) {
                    break;
                }

                throw new LdapException(
                    'LDAP query "%s" (base %s) failed. Error: %s',
                    (string) $query,
                    $query->getBase() ?: $this->getDn(),
                    ldap_error($ds)
                );
            } elseif (ldap_count_entries($ds, $results) === 0) {
                if (in_array(
                    ldap_errno($ds),
                    array(static::LDAP_SIZELIMIT_EXCEEDED, static::LDAP_ADMINLIMIT_EXCEEDED),
                    true
                )) {
                    Logger::warning(
                        'Unable to request more than %u results. Does the server allow paged search requests? (%s)',
                        $count,
                        ldap_error($ds)
                    );
                }

                break;
            }

            $entry = ldap_first_entry($ds, $results);
            if ($entry === false) {
                return [];
            }

            do {
                if ($unfoldAttribute) {
                    $rows = $this->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields, $unfoldAttribute);
                    if (is_array($rows)) {
                        // TODO: Register the DN the same way as a section name in the ArrayDatasource!
                        foreach ($rows as $row) {
                            if ($query->getFilter()->matches($row)) {
                                $count += 1;
                                if (! $serverSorting || $offset === 0 || $offset < $count) {
                                    $entries[] = $row;
                                }

                                if ($serverSorting && $limit > 0 && $limit === count($entries)) {
                                    break;
                                }
                            }
                        }
                    } else {
                        $count += 1;
                        if (! $serverSorting || $offset === 0 || $offset < $count) {
                            $entries[ldap_get_dn($ds, $entry)] = $rows;
                        }
                    }
                } else {
                    $count += 1;
                    if (! $serverSorting || $offset === 0 || $offset < $count) {
                        $entries[ldap_get_dn($ds, $entry)] = $this->cleanupAttributes(
                            ldap_get_attributes($ds, $entry),
                            $fields
                        );
                    }
                }
            } while ((! $serverSorting || $limit === 0 || $limit !== count($entries))
                && ($entry = ldap_next_entry($ds, $entry))
            );

            if ($legacyControlHandling) {
                if (false === @ldap_control_paged_result_response($ds, $results, $cookie)) {
                    // If the page size is greater than or equal to the sizeLimit value, the server should ignore the
                    // control as the request can be satisfied in a single page: https://www.ietf.org/rfc/rfc2696.txt
                    // This applies no matter whether paged search requests are permitted or not. You're done once you
                    // got everything you were out for.
                    if ($serverSorting && count($entries) !== $limit) {
                        // The server does not support pagination, but still returned a response by ignoring the
                        // pagedResultsControl. We output a warning to indicate that the pagination control was ignored.
                        Logger::warning(
                            'Unable to request paged LDAP results. Does the server allow paged search requests?'
                        );
                    }
                }
            } else {
                ldap_parse_result($ds, $results, $errno, $dn, $errmsg, $refs, $controlsReturned);
                if (isset($controlsReturned[LDAP_CONTROL_PAGEDRESULTS])) {
                    $cookie = $controlsReturned[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie'];
                }
            }

            ldap_free_result($results);
        } while ($cookie && (! $serverSorting || $limit === 0 || count($entries) < $limit));

        if ($legacyControlHandling && $cookie) {
            // A sequence of paged search requests is abandoned by the client sending a search request containing a
            // pagedResultsControl with the size set to zero (0) and the cookie set to the last cookie returned by
            // the server: https://www.ietf.org/rfc/rfc2696.txt
            ldap_control_paged_result($ds, 0, false, $cookie);
            // Returns no entries, due to the page size
            ldap_search($ds, $query->getBase() ?: $this->getDn(), (string) $query);
        }

        if (! $serverSorting) {
            if ($query->hasOrder()) {
                uasort($entries, array($query, 'compare'));
            }

            if ($limit && $count > $limit) {
                $entries = array_splice($entries, $query->hasOffset() ? $query->getOffset() : 0, $limit);
            }
        }

        return $entries;
    }

    /**
     * Clean up the given attributes and return them as simple object
     *
     * Applies column aliases, aggregates/unfolds multi-value attributes
     * as array and sets null for each missing attribute.
     *
     * @param   array   $attributes
     * @param   array   $requestedFields
     * @param   string  $unfoldAttribute
     *
     * @return  object|array    An array in case the object has been unfolded
     */
    public function cleanupAttributes($attributes, array $requestedFields, $unfoldAttribute = null)
    {
        // In case the result contains attributes with a differing case than the requested fields, it is
        // necessary to create another array to map attributes case insensitively to their requested counterparts.
        // This does also apply the virtual alias handling. (Since an LDAP server does not handle such)
        $loweredFieldMap = array();
        foreach ($requestedFields as $alias => $name) {
            $loweredName = strtolower($name);
            if (isset($loweredFieldMap[$loweredName])) {
                if (! is_array($loweredFieldMap[$loweredName])) {
                    $loweredFieldMap[$loweredName] = array($loweredFieldMap[$loweredName]);
                }

                $loweredFieldMap[$loweredName][] = is_string($alias) ? $alias : $name;
            } else {
                $loweredFieldMap[$loweredName] = is_string($alias) ? $alias : $name;
            }
        }

        $cleanedAttributes = array();
        for ($i = 0; $i < $attributes['count']; $i++) {
            $attribute_name = $attributes[$i];
            if ($attributes[$attribute_name]['count'] === 1) {
                $attribute_value = $attributes[$attribute_name][0];
            } else {
                $attribute_value = array();
                for ($j = 0; $j < $attributes[$attribute_name]['count']; $j++) {
                    $attribute_value[] = $attributes[$attribute_name][$j];
                }
            }

            $requestedAttributeName = isset($loweredFieldMap[strtolower($attribute_name)])
                ? $loweredFieldMap[strtolower($attribute_name)]
                : $attribute_name;
            if (is_array($requestedAttributeName)) {
                foreach ($requestedAttributeName as $requestedName) {
                    $cleanedAttributes[$requestedName] = $attribute_value;
                }
            } else {
                $cleanedAttributes[$requestedAttributeName] = $attribute_value;
            }
        }

        // The result may not contain all requested fields, so populate the cleaned
        // result with the missing fields and their value being set to null
        foreach ($requestedFields as $alias => $name) {
            if (! is_string($alias)) {
                $alias = $name;
            }

            if (! array_key_exists($alias, $cleanedAttributes)) {
                $cleanedAttributes[$alias] = null;
                Logger::debug('LDAP query result does not provide the requested field "%s"', $name);
            }
        }

        if ($unfoldAttribute !== null
            && isset($cleanedAttributes[$unfoldAttribute])
            && is_array($cleanedAttributes[$unfoldAttribute])
        ) {
            $siblings = array();
            foreach ($loweredFieldMap as $loweredName => $requestedNames) {
                if (is_array($requestedNames) && in_array($unfoldAttribute, $requestedNames, true)) {
                    $siblings = array_diff($requestedNames, array($unfoldAttribute));
                    break;
                }
            }

            $values = $cleanedAttributes[$unfoldAttribute];
            unset($cleanedAttributes[$unfoldAttribute]);
            $baseRow = (object) $cleanedAttributes;
            $rows = array();
            foreach ($values as $value) {
                $row = clone $baseRow;
                $row->{$unfoldAttribute} = $value;
                foreach ($siblings as $sibling) {
                    $row->{$sibling} = $value;
                }

                $rows[] = $row;
            }

            return $rows;
        }

        return (object) $cleanedAttributes;
    }

    /**
     * Encode the given array of sort rules as ASN.1 octet stream according to RFC 2891
     *
     * @param   array   $sortRules
     *
     * @return  string  Binary representation of the octet stream
     */
    protected function encodeSortRules(array $sortRules)
    {
        $sequenceOf = '';

        foreach ($sortRules as $rule) {
            if ($rule[1] === Sortable::SORT_DESC) {
                $reversed = '8101ff';
            } else {
                $reversed = '';
            }

            $attributeType = unpack('H*', $rule[0]);
            $attributeType = $attributeType[1];
            $attributeOctets = strlen($attributeType) / 2;
            if ($attributeOctets >= 127) {
                // Use the indefinite form of the length octets (the long form would be another option)
                $attributeType = '0440' . $attributeType . '0000';
            } else {
                $attributeType = '04' . str_pad(dechex($attributeOctets), 2, '0', STR_PAD_LEFT) . $attributeType;
            }

            $sequence = $attributeType . $reversed;
            $sequenceOctects = strlen($sequence) / 2;
            if ($sequenceOctects >= 127) {
                $sequence = '3040' . $sequence . '0000';
            } else {
                $sequence = '30' . str_pad(dechex($sequenceOctects), 2, '0', STR_PAD_LEFT) . $sequence;
            }

            $sequenceOf .= $sequence;
        }

        $sequenceOfOctets = strlen($sequenceOf) / 2;
        if ($sequenceOfOctets >= 127) {
            $sequenceOf = '3040' . $sequenceOf . '0000';
        } else {
            $sequenceOf = '30' . str_pad(dechex($sequenceOfOctets), 2, '0', STR_PAD_LEFT) . $sequenceOf;
        }

        return hex2bin($sequenceOf);
    }

    /**
     * Prepare and establish a connection with the LDAP server
     *
     * @param ?Inspection $info Optional inspection to fill with diagnostic info
     *
     * @throws LdapException In case the connection is not possible
     */
    protected function prepareNewConnection(?Inspection $info = null)
    {
        if (! isset($info)) {
            $info = new Inspection('');
        }

        $hostname = $this->normalizeHostname($this->hostname);

        $ds = ldap_connect($hostname);
        if ($ds === false) {
            throw new LdapException('Failed to connect to LDAP');
        }

        // Set a proper timeout for each connection
        ldap_set_option($ds, LDAP_OPT_NETWORK_TIMEOUT, $this->timeout);

        // Usage of ldap_rename, setting LDAP_OPT_REFERRALS to 0 or using STARTTLS requires LDAPv3.
        // If this does not work we're probably not in a PHP 5.3+ environment as it is VERY
        // unlikely that the server complains about it by itself prior to a bind request
        ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, 3);

        // Not setting this results in "Operations error" on AD when using the whole domain as search base
        ldap_set_option($ds, LDAP_OPT_REFERRALS, 0);

        if ($this->encryption === static::LDAPS) {
            $info->write('Connect using LDAPS');
        } elseif ($this->encryption === static::STARTTLS) {
            $this->encrypted = true;
            $info->write('Connect using STARTTLS');
            if (! ldap_start_tls($ds)) {
                throw new LdapException('LDAP STARTTLS failed: %s', ldap_error($ds));
            }
        } elseif ($this->encryption !== static::LDAPS) {
            $this->encrypted = false;
            $info->write('Connect without encryption');
        }

        return $ds;
    }

    /**
     * Perform a LDAP search and return the result or false on error
     *
     * @param LdapQuery $query
     * @param ?array $attributes An array of the required attributes
     * @param int $attrsonly Should be set to 1 if only attribute types are wanted
     * @param int $sizelimit Enables you to limit the count of entries fetched
     * @param int $timelimit Sets the number of seconds how long is spend on the search
     * @param int $deref
     * @param array $controls LDAP Controls to send with the request (Only supported with PHP v7.3+)
     *
     * @throws LogicException If the LDAP query search scope is unsupported
     */
    public function ldapSearch(
        LdapQuery $query,
        ?array $attributes = null,
        $attrsonly = 0,
        $sizelimit = 0,
        $timelimit = 0,
        $deref = LDAP_DEREF_NEVER,
        $controls = null
    ) {
        $queryString = (string) $query;
        $baseDn = $query->getBase() ?: $this->getDn();
        $scope = $query->getScope();

        if (Logger::getInstance()->getLevel() === Logger::DEBUG) {
            // We're checking the level by ourselves to avoid rendering the ldapsearch commandline for nothing
            $starttlsParam = $this->encryption === static::STARTTLS ? ' -ZZ' : '';

            $bindParams = '';
            if ($this->bound) {
                $bindParams = ' -D "' . $this->bindDn . '"' . ($this->bindPw ? ' -W' : '');
            }

            if ($deref === LDAP_DEREF_NEVER) {
                $derefName = 'never';
            } elseif ($deref === LDAP_DEREF_ALWAYS) {
                $derefName = 'always';
            } elseif ($deref === LDAP_DEREF_SEARCHING) {
                $derefName = 'search';
            } else { // $deref === LDAP_DEREF_FINDING
                $derefName = 'find';
            }

            Logger::debug("Issuing LDAP search. Use '%s' to reproduce.", sprintf(
                'ldapsearch -P 3%s -H "%s"%s -b "%s" -s "%s" -z %u -l %u -a "%s"%s%s%s',
                $starttlsParam,
                $this->normalizeHostname($this->hostname),
                $bindParams,
                $baseDn,
                $scope,
                $sizelimit,
                $timelimit,
                $derefName,
                $attrsonly ? ' -A' : '',
                $queryString ? ' "' . $queryString . '"' : '',
                $attributes ? ' "' . join('" "', $attributes) . '"' : ''
            ));
        }

        switch ($scope) {
            case LdapQuery::SCOPE_SUB:
                $function = 'ldap_search';
                break;
            case LdapQuery::SCOPE_ONE:
                $function = 'ldap_list';
                break;
            case LdapQuery::SCOPE_BASE:
                $function = 'ldap_read';
                break;
            default:
                throw new LogicException('LDAP scope %s not supported by ldapSearch', $scope);
        }

        // Explicit calls with and without controls,
        // because the parameter is only supported since PHP 7.3.
        // Since it is a public method,
        // providing controls will naturally fail if the parameter is not supported by PHP.
        if ($controls !== null) {
            return @$function(
                $this->getConnection(),
                $baseDn,
                $queryString,
                $attributes,
                $attrsonly,
                $sizelimit,
                $timelimit,
                $deref,
                $controls
            );
        } else {
            return @$function(
                $this->getConnection(),
                $baseDn,
                $queryString,
                $attributes,
                $attrsonly,
                $sizelimit,
                $timelimit,
                $deref
            );
        }
    }

    /**
     * Create an LDAP entry
     *
     * @param   string  $dn             The distinguished name to use
     * @param   array   $attributes     The entry's attributes
     *
     * @return  bool                    Whether the operation was successful
     */
    public function addEntry($dn, array $attributes)
    {
        return ldap_add($this->getConnection(), $dn, $attributes);
    }

    /**
     * Modify an LDAP entry
     *
     * @param   string  $dn             The distinguished name to use
     * @param   array   $attributes     The attributes to update the entry with
     *
     * @return  bool                    Whether the operation was successful
     */
    public function modifyEntry($dn, array $attributes)
    {
        return ldap_modify($this->getConnection(), $dn, $attributes);
    }

    /**
     * Change the distinguished name of an LDAP entry
     *
     * @param   string  $dn             The entry's current distinguished name
     * @param   string  $newRdn         The new relative distinguished name
     * @param   string  $newParentDn    The new parent or superior entry's distinguished name
     *
     * @return  resource                The resulting search result identifier
     *
     * @throws  LdapException           In case an error occured
     */
    public function moveEntry($dn, $newRdn, $newParentDn)
    {
        $ds = $this->getConnection();
        $result = ldap_rename($ds, $dn, $newRdn, $newParentDn, false);
        if ($result === false) {
            throw new LdapException('Could not move entry "%s" to "%s": %s', $dn, $newRdn, ldap_error($ds));
        }

        return $result;
    }

    /**
     * Return the LDAP specific configuration directory with the given relative path being appended
     *
     * @param   string  $sub
     *
     * @return  string
     */
    protected function getConfigDir($sub = null)
    {
        $dir = Config::$configDir . '/ldap';
        if ($sub !== null) {
            $dir .= '/' . $sub;
        }

        return $dir;
    }

    /**
     * Render and return a valid LDAP filter representation of the given filter
     *
     * @param   Filter  $filter
     * @param   int     $level
     *
     * @return  string
     */
    public function renderFilter(Filter $filter, $level = 0)
    {
        if ($filter->isExpression()) {
            /** @var $filter FilterExpression */
            return $this->renderFilterExpression($filter);
        }

        /** @var $filter FilterChain */
        $parts = array();
        foreach ($filter->filters() as $filterPart) {
            $part = $this->renderFilter($filterPart, $level + 1);
            if ($part) {
                $parts[] = $part;
            }
        }

        if (empty($parts)) {
            return '';
        }

        $format = '%1$s(%2$s)';
        if (count($parts) === 1 && ! $filter instanceof FilterNot) {
            $format = '%2$s';
        }
        if ($level === 0) {
            $format = '(' . $format . ')';
        }

        return sprintf($format, $filter->getOperatorSymbol(), implode(')(', $parts));
    }

    /**
     * Render and return a valid LDAP filter expression of the given filter
     *
     * @param   FilterExpression    $filter
     *
     * @return  string
     */
    protected function renderFilterExpression(FilterExpression $filter)
    {
        $column = $filter->getColumn();
        $sign = $filter->getSign();
        $expression = $filter->getExpression();
        $format = '%1$s%2$s%3$s';

        if ($expression === null || $expression === true) {
            $expression = '*';
        } elseif (is_array($expression)) {
            $seqFormat = '|(%s)';
            if ($sign === '!=') {
                $seqFormat = '!(' . $seqFormat . ')';
                $sign = '=';
            }

            $seqParts = array();
            foreach ($expression as $expressionValue) {
                $seqParts[] = sprintf(
                    $format,
                    LdapUtils::quoteForSearch($column),
                    $sign,
                    LdapUtils::quoteForSearch($expressionValue, true)
                );
            }

            return sprintf($seqFormat, implode(')(', $seqParts));
        }

        if ($sign === '!=') {
            $format = '!(%1$s=%3$s)';
        }

        return sprintf(
            $format,
            LdapUtils::quoteForSearch($column),
            $sign,
            LdapUtils::quoteForSearch($expression, true)
        );
    }

    /**
     * Inspect if this LDAP Connection is working as expected
     *
     * Check if connection, bind and encryption is working as expected and get additional
     * information about the used
     *
     * @return  Inspection  Inspection result
     */
    public function inspect()
    {
        $insp = new Inspection('Ldap Connection');

        // Try to connect to the server with the given connection parameters
        try {
            $ds = $this->prepareNewConnection($insp);
        } catch (Exception $e) {
            if ($this->encryption === 'starttls') {
                // The Exception does not return any proper error messages in case of certificate errors. Connecting
                // by STARTTLS will usually fail at this point when the certificate is unknown,
                // so at least try to give some hints.
                $insp->write('NOTE: There might be an issue with the chosen encryption. Ensure that the LDAP-Server ' .
                    'supports STARTTLS and that the LDAP-Client is configured to accept its certificate.');
            }
            return $insp->error($e->getMessage());
        }

        // Try a bind-command with the given user credentials, this must not fail
        $success = @ldap_bind($ds, $this->bindDn, $this->bindPw);
        $msg = sprintf(
            'LDAP bind (%s / %s) to %s',
            $this->bindDn,
            '***' /* $this->bindPw */,
            $this->normalizeHostname($this->hostname)
        );
        if (! $success) {
            // ldap_error does not return any proper error messages in case of certificate errors. Connecting
            // by LDAPS will usually fail at this point when the certificate is unknown, so at least try to give
            // some hints.
            if ($this->encryption === 'ldaps') {
                $insp->write('NOTE: There might be an issue with the chosen encryption. Ensure that the LDAP-Server ' .
                    ' supports LDAPS and that the LDAP-Client is configured to accept its certificate.');
            }
            return $insp->error(sprintf('%s failed: %s', $msg, ldap_error($ds)));
        }
        $insp->write(sprintf($msg . ' successful'));

        // Try to execute a schema discovery this may fail if schema discovery is not supported
        try {
            $cap = LdapCapabilities::discoverCapabilities($this);
            $discovery = new Inspection('Discovery Results');
            $vendor = $cap->getVendor();
            if (isset($vendor)) {
                $discovery->write($vendor);
            }
            $version = $cap->getVersion();
            if (isset($version)) {
                $discovery->write($version);
            }
            $discovery->write('Supports STARTTLS: ' . ($cap->hasStartTls() ? 'True' : 'False'));
            $discovery->write('Default naming context: ' . $cap->getDefaultNamingContext());
            $insp->write($discovery);
        } catch (Exception $e) {
            $insp->write('Schema discovery not possible: ' . $e->getMessage());
        }
        return $insp;
    }

    /**
     * Normalize all given hostnames to a valid LDAP URL
     *
     * @param string $hostname
     *
     * @return string
     */
    protected function normalizeHostname($hostname)
    {
        $scheme = $this->encryption === static::LDAPS ? 'ldaps://' : 'ldap://';
        $normalizeHostname = function ($hostname) use ($scheme) {
            if (strpos($hostname, $scheme) === false) {
                $hostname = $scheme . $hostname;
            }

            if (! preg_match('/:\d+$/', $hostname)) {
                $hostname .= ':' . $this->port;
            }

            return $hostname;
        };

        $ldapUrls = explode(' ', $hostname);
        if (count($ldapUrls) > 1) {
            foreach ($ldapUrls as & $uri) {
                $uri = $normalizeHostname($uri);
            }

            $hostname = implode(' ', $ldapUrls);
        } else {
            $hostname = $normalizeHostname($hostname);
        }

        return $hostname;
    }
}
