<?php

namespace LdapRecord\Testing;

use Closure;
use Exception;
use LdapRecord\DetailedError;
use LdapRecord\DetectsErrors;
use LdapRecord\HandlesConnection;
use LdapRecord\LdapInterface;
use LdapRecord\Support\Arr;
use PHPUnit\Framework\Assert as PHPUnit;
use PHPUnit\Framework\Constraint\Constraint;

class LdapFake implements LdapInterface
{
    use HandlesConnection, DetectsErrors;

    /**
     * The expectations of the LDAP fake.
     *
     * @var array
     */
    protected $expectations = [];

    /**
     * The default fake error number.
     *
     * @var int
     */
    protected $errNo = 1;

    /**
     * The default fake last error string.
     *
     * @var string
     */
    protected $lastError = '';

    /**
     * The default fake diagnostic message string.
     *
     * @var string
     */
    protected $diagnosticMessage = '';

    /**
     * Create a new expected operation.
     *
     * @param string $method
     *
     * @return LdapExpectation
     */
    public static function operation($method)
    {
        return new LdapExpectation($method);
    }

    /**
     * Set the user that will pass binding.
     *
     * @param string $dn
     *
     * @return $this
     */
    public function shouldAuthenticateWith($dn)
    {
        return $this->expect(
            static::operation('bind')->with($dn, PHPUnit::anything())->andReturn(true)
        );
    }

    /**
     * Add an LDAP method expectation.
     *
     * @param LdapExpectation|array $expectations
     *
     * @return $this
     */
    public function expect($expectations = [])
    {
        $expectations = Arr::wrap($expectations);

        foreach ($expectations as $key => $expectation) {
            if (! is_int($key)) {
                $operation = static::operation($key);

                $expectation instanceof Closure
                    ? $expectation($operation)
                    : $operation->andReturn($expectation);

                $expectation = $operation;
            }

            if (! $expectation instanceof LdapExpectation) {
                $expectation = static::operation($expectation);
            }

            $this->expectations[$expectation->getMethod()][] = $expectation;
        }

        return $this;
    }

    /**
     * Determine if the method has any expectations.
     *
     * @param string $method
     *
     * @return bool
     */
    public function hasExpectations($method)
    {
        return count($this->getExpectations($method)) > 0;
    }

    /**
     * Get expectations by method.
     *
     * @param string $method
     *
     * @return LdapExpectation[]|mixed
     */
    public function getExpectations($method)
    {
        return $this->expectations[$method] ?? [];
    }

    /**
     * Remove an expectation by method and key.
     *
     * @param string $method
     * @param int    $key
     *
     * @return void
     */
    public function removeExpectation($method, $key)
    {
        unset($this->expectations[$method][$key]);
    }

    /**
     * Set the error number of a failed bind attempt.
     *
     * @param int $number
     *
     * @return $this
     */
    public function shouldReturnErrorNumber($number = 1)
    {
        $this->errNo = $number;

        return $this;
    }

    /**
     * Set the last error of a failed bind attempt.
     *
     * @param string $message
     *
     * @return $this
     */
    public function shouldReturnError($message = '')
    {
        $this->lastError = $message;

        return $this;
    }

    /**
     * Set the diagnostic message of a failed bind attempt.
     *
     * @param string $message
     *
     * @return $this
     */
    public function shouldReturnDiagnosticMessage($message = '')
    {
        $this->diagnosticMessage = $message;

        return $this;
    }

    /**
     * Return a fake error number.
     *
     * @return int
     */
    public function errNo()
    {
        return $this->errNo;
    }

    /**
     * Return a fake error.
     *
     * @return string
     */
    public function getLastError()
    {
        return $this->lastError;
    }

    /**
     * @inheritdoc
     */
    public function getDiagnosticMessage()
    {
        return $this->diagnosticMessage;
    }

    /**
     * Return a fake detailed error.
     *
     * @return DetailedError
     */
    public function getDetailedError()
    {
        return new DetailedError(
            $this->errNo(),
            $this->getLastError(),
            $this->getDiagnosticMessage()
        );
    }

    /**
     * @inheritdoc
     */
    public function getEntries($searchResults)
    {
        return $searchResults;
    }

    /**
     * @inheritdoc
     */
    public function isUsingSSL()
    {
        return $this->hasExpectations('isUsingSSL')
            ? $this->resolveExpectation('isUsingSSL')
            : $this->useSSL;
    }

    /**
     * @inheritdoc
     */
    public function isUsingTLS()
    {
        return $this->hasExpectations('isUsingTLS')
            ? $this->resolveExpectation('isUsingTLS')
            : $this->useTLS;
    }

    /**
     * @inheritdoc
     */
    public function isBound()
    {
        return $this->hasExpectations('isBound')
            ? $this->resolveExpectation('isBound')
            : $this->bound;
    }

    /**
     * @inheritdoc
     */
    public function setOption($option, $value)
    {
        return $this->hasExpectations('setOption')
            ? $this->resolveExpectation('setOption', func_get_args())
            : true;
    }

    /**
     * @inheritdoc
     */
    public function getOption($option, &$value = null)
    {
        return $this->resolveExpectation('getOption', func_get_args());
    }

    /**
     * @inheritdoc
     */
    public function startTLS()
    {
        return $this->resolveExpectation('startTLS', func_get_args());
    }

    /**
     * @inheritdoc
     */
    public function connect($hosts = [], $port = 389)
    {
        $this->bound = false;

        $this->host = $this->makeConnectionUris($hosts, $port);

        return $this->connection = $this->hasExpectations('connect')
            ? $this->resolveExpectation('connect', func_get_args())
            : true;
    }

    /**
     * @inheritdoc
     */
    public function close()
    {
        $this->connection = null;
        $this->bound = false;
        $this->host = null;

        return $this->hasExpectations('close')
            ? $this->resolveExpectation('close')
            : true;
    }

    /**
     * @inheritdoc
     */
    public function bind($username, $password)
    {
        return $this->bound = $this->resolveExpectation('bind', func_get_args());
    }

    /**
     * @inheritdoc
     */
    public function search($dn, $filter, array $fields, $onlyAttributes = false, $size = 0, $time = 0, $deref = LDAP_DEREF_NEVER, $serverControls = [])
    {
        return $this->resolveExpectation('search', func_get_args());
    }

    /**
     * @inheritdoc
     */
    public function listing($dn, $filter, array $fields, $onlyAttributes = false, $size = 0, $time = 0, $deref = LDAP_DEREF_NEVER, $serverControls = [])
    {
        return $this->resolveExpectation('listing', func_get_args());
    }

    /**
     * @inheritdoc
     */
    public function read($dn, $filter, array $fields, $onlyAttributes = false, $size = 0, $time = 0, $deref = LDAP_DEREF_NEVER, $serverControls = [])
    {
        return $this->resolveExpectation('read', func_get_args());
    }

    /**
     * @inheritdoc
     */
    public function parseResult($result, &$errorCode, &$dn, &$errorMessage, &$referrals, &$serverControls = [])
    {
        return $this->resolveExpectation('parseResult', func_get_args());
    }

    /**
     * @inheritdoc
     */
    public function add($dn, array $entry)
    {
        return $this->resolveExpectation('add', func_get_args());
    }

    /**
     * @inheritdoc
     */
    public function delete($dn)
    {
        return $this->resolveExpectation('delete', func_get_args());
    }

    /**
     * @inheritdoc
     */
    public function rename($dn, $newRdn, $newParent, $deleteOldRdn = false)
    {
        return $this->resolveExpectation('rename', func_get_args());
    }

    /**
     * @inheritdoc
     */
    public function modify($dn, array $entry)
    {
        return $this->resolveExpectation('modify', func_get_args());
    }

    /**
     * @inheritdoc
     */
    public function modifyBatch($dn, array $values)
    {
        return $this->resolveExpectation('modifyBatch', func_get_args());
    }

    /**
     * @inheritdoc
     */
    public function modAdd($dn, array $entry)
    {
        return $this->resolveExpectation('modAdd', func_get_args());
    }

    /**
     * @inheritdoc
     */
    public function modReplace($dn, array $entry)
    {
        return $this->resolveExpectation('modReplace', func_get_args());
    }

    /**
     * @inheritdoc
     */
    public function modDelete($dn, array $entry)
    {
        return $this->resolveExpectation('modDelete', func_get_args());
    }

    /**
     * @inheritdoc
     */
    public function controlPagedResult($pageSize = 1000, $isCritical = false, $cookie = '')
    {
        return $this->resolveExpectation('controlPagedResult', func_get_args());
    }

    /**
     * @inheritdoc
     */
    public function controlPagedResultResponse($result, &$cookie)
    {
        return $this->resolveExpectation('controlPagedResultResponse', func_get_args());
    }

    /**
     * @inheritdoc
     */
    public function freeResult($result)
    {
        return $this->resolveExpectation('freeResult', func_get_args());
    }

    /**
     * @inheritdoc
     */
    public function err2Str($number)
    {
        return $this->resolveExpectation('err2Str', func_get_args());
    }

    /**
     * Resolve the methods expectations.
     *
     * @param string $method
     * @param array  $args
     *
     * @return mixed
     *
     * @throws Exception
     */
    protected function resolveExpectation($method, array $args = [])
    {
        foreach ($this->getExpectations($method) as $key => $expectation) {
            $this->assertMethodArgumentsMatch($method, $expectation->getExpectedArgs(), $args);

            $expectation->decrementCallCount();

            if ($expectation->getExpectedCount() === 0) {
                $this->removeExpectation($method, $key);
            }

            if (! is_null($exception = $expectation->getExpectedException())) {
                throw $exception;
            }

            if ($expectation->isReturningError()) {
                $this->applyExpectationError($expectation);
            }

            return $expectation->getExpectedValue();
        }

        throw new Exception("LDAP method [$method] was unexpected.");
    }

    /**
     * Apply the expectation error to the fake.
     *
     * @param LdapExpectation $expectation
     *
     * @return void
     */
    protected function applyExpectationError(LdapExpectation $expectation)
    {
        $this->shouldReturnError($expectation->getExpectedErrorMessage());
        $this->shouldReturnErrorNumber($expectation->getExpectedErrorCode());
        $this->shouldReturnDiagnosticMessage($expectation->getExpectedErrorDiagnosticMessage());
    }

    /**
     * Assert that the expected arguments match the operations arguments.
     *
     * @param string       $method
     * @param Constraint[] $expectedArgs
     * @param array        $methodArgs
     *
     * @return void
     */
    protected function assertMethodArgumentsMatch($method, array $expectedArgs = [], array $methodArgs = [])
    {
        foreach ($expectedArgs as $key => $constraint) {
            $argNumber = $key + 1;

            PHPUnit::assertArrayHasKey(
                $key,
                $methodArgs,
                "LDAP method [$method] argument #{$argNumber} does not exist."
            );

            $constraint->evaluate(
                $methodArgs[$key],
                "LDAP method [$method] expectation failed."
            );
        }
    }
}
