<?php

/**
 * This file is part of the FreeDSx LDAP package.
 *
 * (c) Chad Sikorra <Chad.Sikorra@gmail.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace FreeDSx\Ldap;

use FreeDSx\Ldap\Control\Control;
use FreeDSx\Ldap\Control\ControlBag;
use FreeDSx\Ldap\Control\Sorting\SortingControl;
use FreeDSx\Ldap\Control\Sorting\SortKey;
use FreeDSx\Ldap\Entry\Dn;
use FreeDSx\Ldap\Entry\Entries;
use FreeDSx\Ldap\Entry\Entry;
use FreeDSx\Ldap\Entry\Rdn;
use FreeDSx\Ldap\Exception\OperationException;
use FreeDSx\Ldap\Operation\Request\ExtendedRequest;
use FreeDSx\Ldap\Operation\Request\RequestInterface;
use FreeDSx\Ldap\Operation\Request\SearchRequest;
use FreeDSx\Ldap\Operation\ResultCode;
use FreeDSx\Ldap\Protocol\ClientProtocolHandler;
use FreeDSx\Ldap\Protocol\LdapMessageResponse;
use FreeDSx\Ldap\Search\DirSync;
use FreeDSx\Ldap\Search\Filter\FilterInterface;
use FreeDSx\Ldap\Search\Paging;
use FreeDSx\Ldap\Search\RangeRetrieval;
use FreeDSx\Ldap\Search\Vlv;
use FreeDSx\Sasl\Exception\SaslException;

/**
 * The LDAP client.
 *
 * @author Chad Sikorra <Chad.Sikorra@gmail.com>
 */
class LdapClient
{
    public const REFERRAL_IGNORE = 'ignore';

    public const REFERRAL_FOLLOW = 'follow';

    public const REFERRAL_THROW = 'throw';

    /**
     * @var array
     */
    protected $options = [
        'version' => 3,
        'servers' => [],
        'port' => 389,
        'transport' => 'tcp',
        'base_dn' => null,
        'page_size' => 1000,
        'use_ssl' => false,
        'ssl_validate_cert' => true,
        'ssl_allow_self_signed' => null,
        'ssl_ca_cert' => null,
        'ssl_peer_name' => null,
        'timeout_connect' => 3,
        'timeout_read' => 10,
        'referral' => 'throw',
        'referral_chaser' => null,
        'referral_limit' => 10,
    ];

    /**
     * @var ClientProtocolHandler|null
     */
    protected $handler;

    /**
     * @param array $options
     */
    public function __construct(array $options = [])
    {
        $this->options = array_merge($this->options, $options);
    }

    /**
     * A Simple Bind to LDAP with a username and password.
     *
     * @param string $username
     * @param string $password
     * @return LdapMessageResponse
     * @throws Exception\BindException
     */
    public function bind(string $username, string $password): LdapMessageResponse
    {
        return $this->sendAndReceive(Operations::bind($username, $password)->setVersion($this->options['version']));
    }

    /**
     * A SASL Bind to LDAP with SASL options and an optional specific mechanism type.
     *
     * @param array $options The SASL options (ie. ['username' => '...', 'password' => '...'])
     * @param string $mechanism A specific mechanism to use. If none is supplied, one will be selected.
     * @return LdapMessageResponse
     * @throws Exception\BindException
     * @throws OperationException
     * @throws SaslException
     */
    public function bindSasl(array $options = [], string $mechanism = ''): LdapMessageResponse
    {
        return $this->sendAndReceive(Operations::bindSasl($options, $mechanism)->setVersion($this->options['version']));
    }

    /**
     * Check whether an entry matches a certain attribute and value.
     *
     * @param string|Dn $dn
     * @param string $attributeName
     * @param string $value
     * @param Control ...$controls
     * @return bool
     * @throws OperationException
     */
    public function compare($dn, string $attributeName, string $value, Control ...$controls): bool
    {
        /** @var \FreeDSx\Ldap\Operation\Response\CompareResponse $response */
        $response = $this->sendAndReceive(Operations::compare($dn, $attributeName, $value), ...$controls)->getResponse();

        return $response->getResultCode() === ResultCode::COMPARE_TRUE;
    }

    /**
     * Create a new entry.
     *
     * @param Entry $entry
     * @param Control ...$controls
     * @return LdapMessageResponse
     * @throws OperationException
     */
    public function create(Entry $entry, Control ...$controls): LdapMessageResponse
    {
        $response = $this->sendAndReceive(Operations::add($entry), ...$controls);
        $entry->changes()->reset();

        return $response;
    }

    /**
     * Read an entry.
     *
     * @param string $entry
     * @param string[] $attributes
     * @param Control ...$controls
     * @return Entry|null
     * @throws OperationException
     */
    public function read(string $entry = '', $attributes = [], Control ...$controls): ?Entry
    {
        try {
            return $this->readOrFail($entry, $attributes, ...$controls);
        } catch (Exception\OperationException $e) {
            if ($e->getCode() === ResultCode::NO_SUCH_OBJECT) {
                return null;
            }
            throw $e;
        }
    }

    /**
     * Read an entry from LDAP. If the entry is not found an OperationException is thrown.
     *
     * @param string $entry
     * @param string[] $attributes
     * @param Control ...$controls
     * @return Entry
     * @throws OperationException
     */
    public function readOrFail(string $entry = '', $attributes = [], Control ...$controls): Entry
    {
        $entryObj = $this->search(Operations::read($entry, ...$attributes), ...$controls)->first();
        if ($entryObj === null) {
            throw new OperationException(sprintf(
                'The entry "%s" was not found.',
                $entry
            ), ResultCode::NO_SUCH_OBJECT);
        }

        return $entryObj;
    }

    /**
     * Delete an entry.
     *
     * @param string $entry
     * @param Control ...$controls
     * @return LdapMessageResponse
     * @throws OperationException
     */
    public function delete(string $entry, Control ...$controls): LdapMessageResponse
    {
        return $this->sendAndReceive(Operations::delete($entry), ...$controls);
    }

    /**
     * Update an existing entry.
     *
     * @param Entry $entry
     * @param Control ...$controls
     * @return LdapMessageResponse
     * @throws OperationException
     */
    public function update(Entry $entry, Control ...$controls): LdapMessageResponse
    {
        $response = $this->sendAndReceive(Operations::modify($entry->getDn(), ...$entry->changes()), ...$controls);
        $entry->changes()->reset();

        return $response;
    }

    /**
     * Move an entry to a new location.
     *
     * @param string|Entry $dn
     * @param string|Entry $newParentDn
     * @return LdapMessageResponse
     * @throws OperationException
     */
    public function move($dn, $newParentDn): LdapMessageResponse
    {
        return $this->sendAndReceive(Operations::move($dn, $newParentDn));
    }

    /**
     * Rename an entry (changing the RDN).
     *
     * @param string|Entry $dn
     * @param string|Rdn $newRdn
     * @param bool $deleteOldRdn
     * @return LdapMessageResponse
     * @throws OperationException
     */
    public function rename($dn, $newRdn, bool $deleteOldRdn = true): LdapMessageResponse
    {
        return $this->sendAndReceive(Operations::rename($dn, $newRdn, $deleteOldRdn));
    }

    /**
     * Send a search response and return the entries.
     *
     * @param SearchRequest $request
     * @param Control ...$controls
     * @return Entries
     * @throws OperationException
     */
    public function search(SearchRequest $request, Control ...$controls): Entries
    {
        /** @var \FreeDSx\Ldap\Operation\Response\SearchResponse $response */
        $response = $this->sendAndReceive($request, ...$controls)->getResponse();

        return $response->getEntries();
    }

    /**
     * A helper for performing a paging based search.
     *
     * @param SearchRequest $search
     * @param null|int $size
     * @return Paging
     */
    public function paging(SearchRequest $search, ?int $size = null): Paging
    {
        return new Paging($this, $search, $size ?? $this->options['page_size']);
    }

    /**
     * A helper for performing a VLV (Virtual List View) based search.
     *
     * @param SearchRequest $search
     * @param SortingControl|string|SortKey $sort
     * @param int $afterCount
     * @return Vlv
     */
    public function vlv(SearchRequest $search, $sort, int $afterCount): Vlv
    {
        return new Vlv($this, $search, $sort, $afterCount);
    }

    /**
     * A helper for performing a DirSync search operation against AD.
     *
     * @param string|null $rootNc
     * @param FilterInterface|null $filter
     * @param mixed ...$attributes
     * @return DirSync
     */
    public function dirSync(?string $rootNc = null, FilterInterface $filter = null, ...$attributes): DirSync
    {
        return new DirSync($this, $rootNc, $filter, ...$attributes);
    }

    /**
     * Send a request operation to LDAP. This may return null if the request expects no response.
     *
     * @param RequestInterface $request
     * @param Control ...$controls
     * @return LdapMessageResponse|null
     * @throws Exception\BindException
     * @throws Exception\ConnectionException
     * @throws OperationException
     */
    public function send(RequestInterface $request, Control ...$controls): ?LdapMessageResponse
    {
        return $this->handler()->send($request, ...$controls);
    }

    /**
     * Send a request to LDAP that expects a response. If none is received an OperationException is thrown.
     *
     * @param RequestInterface $request
     * @param Control ...$controls
     * @return LdapMessageResponse
     * @throws Exception\BindException
     * @throws Exception\ConnectionException
     * @throws OperationException
     */
    public function sendAndReceive(RequestInterface $request, Control ...$controls): LdapMessageResponse
    {
        $response = $this->send($request, ...$controls);
        if ($response === null) {
            throw new OperationException('Expected an LDAP message response, but none was received.');
        }

        return $response;
    }

    /**
     * Issue a startTLS to encrypt the LDAP connection.
     *
     * @return $this
     * @throws Exception\ConnectionException
     * @throws OperationException
     */
    public function startTls(): self
    {
        $this->send(Operations::extended(ExtendedRequest::OID_START_TLS));

        return $this;
    }

    /**
     * Unbind and close the LDAP TCP connection.
     *
     * @return $this
     * @throws Exception\ConnectionException
     * @throws OperationException
     */
    public function unbind(): self
    {
        $this->send(Operations::unbind());

        return $this;
    }

    /**
     * Perform a whoami request and get the returned value.
     *
     * @return string
     * @throws OperationException
     */
    public function whoami(): ?string
    {
        /** @var \FreeDSx\Ldap\Operation\Response\ExtendedResponse $response */
        $response = $this->sendAndReceive(Operations::whoami())->getResponse();

        return $response->getValue();
    }

    /**
     * Get a helper class for handling ranged attributes.
     *
     * @return RangeRetrieval
     */
    public function range(): RangeRetrieval
    {
        return new RangeRetrieval($this);
    }

    /**
     * Access to add/set/remove/reset the controls to be used for each request. If you want request specific controls in
     * addition to these, then pass them as a parameter to the send() method.
     *
     * @return ControlBag
     */
    public function controls(): ControlBag
    {
        return $this->handler()->controls();
    }

    /**
     * Get the options currently set.
     *
     * @return array
     */
    public function getOptions(): array
    {
        return $this->options;
    }

    /**
     * Merge a set of options. Depending on what you are changing, you many want to set the $forceDisconnect param to
     * true, which forces the client to disconnect. After which you would have to manually bind again.
     *
     * @param array $options The set of options to merge in.
     * @param bool $forceDisconnect Whether the client should disconnect; forcing a manual re-connect / bind. This is
     *                              false by default.
     * @return $this
     */
    public function setOptions(
        array $options,
        bool $forceDisconnect = false
    ): self {
        $this->options = array_merge(
            $this->options,
            $options
        );
        if ($forceDisconnect) {
            $this->unbindIfConnected();
        }

        return $this;
    }

    /**
     * @param ClientProtocolHandler|null $handler
     * @return $this
     */
    public function setProtocolHandler(ClientProtocolHandler $handler = null): self
    {
        $this->handler = $handler;

        return $this;
    }

    /**
     * A simple check to determine if this client has an established connection to a server.
     *
     * @return bool
     */
    public function isConnected(): bool
    {
        return ($this->handler !== null && $this->handler->isConnected());
    }

    /**
     * Try to clean-up if needed.
     *
     * @throws Exception\ConnectionException
     * @throws OperationException
     */
    public function __destruct()
    {
        $this->unbindIfConnected();
    }

    protected function handler(): ClientProtocolHandler
    {
        if ($this->handler === null) {
            $this->handler = new Protocol\ClientProtocolHandler($this->options);
        }

        return $this->handler;
    }

    /**
     * @throws Exception\ConnectionException
     * @throws OperationException
     */
    private function unbindIfConnected(): void
    {
        if ($this->handler !== null && $this->handler->isConnected()) {
            $this->unbind();
        }
    }
}
