<?php

/*
 * This file is part of the AuthStack project.
 *
 * (c) Buckhill Ltd <development@buckhill.co.uk>
 *
 * This source file is subject to the Buckhill license that is bundled
 * with this source code in the file LICENSE.
 */

namespace Buckhill\LDAP;

use Buckhill\LDAP\Errors\BadOptionFormatError;
use Buckhill\LDAP\Errors\ConnectionError;
use Buckhill\LDAP\Errors\NoConnectionError;
use Buckhill\LDAP\Errors\SearchError;

class Client implements ClientInterface
{
    protected $host;
    protected $port;
    protected $options;
    protected $tls;
    protected $connection;
    protected $connected = false;
    protected $escape = [',', '\\', '#', '+', '<', '>', ';', '"', '='];
    protected $replace = ['\,', '\\\\', '\#', '\+', '\<', '\>', '\;', '\"', '\='];
    protected $timeout = 5;

    public function __construct($host, $port, array $options = null, $tls = false, $timeout = 5)
    {
        $this->host = $host;
        $this->port = $port;
        $this->options = $options;
        $this->tls = $tls;
        $this->timeout = $timeout;
    }


    /**
     * @return bool
     * @throws BadOptionFormatError
     * @throws ConnectionError
     */
    public function connect()
    {
        // Create the LDAP connection
        $this->connection = @ldap_connect($this->host, $this->port);

        if(false === $this->connection)
        {
            throw new ConnectionError(sprintf("Could not connect to %s:%s", $this->host, $this->port));
        }

        // Initiate TLS if required
        if($this->tls)
        {
            if(false === @ldap_start_tls($this->connection))
            {
                throw new ConnectionError(sprintf("Could not start TLS for %s:%s", $this->host, $this->port));
            }
        }

        // Set connection options, such as protocol version etc
        // Check http://php.net/ldap_set_option for the list of options
        if(is_array($this->options) && sizeof($this->options))
        {
            foreach($this->options as $option)
            {
                if(isset($option['name']) && isset($option['value']))
                {
                    if(false === @ldap_set_option($this->connection, $option['name'], $option['value']))
                    {
                        throw new ConnectionError(sprintf("Unable to set option %s to value %s for %s:%s",
                            $option['name'], $option['value'], $this->host, $this->port
                        ));
                    }
                }
                else
                {
                    throw new BadOptionFormatError("Bad options format. Every option must contain 'name' and 'value' pairs.");
                }
            }
        }

        return $this->connected = true;
    }

    /**
     * @param $option
     * @param $value
     * @throws ConnectionError
     */
    public function setOption($option, $value)
    {
        if(false === @ldap_set_option($this->connection, $option, $value))
        {
            throw new ConnectionError(sprintf("Unable to set option %s to value %s for %s:%s", $option, $value, $this->host, $this->port));
        }
    }


    /**
     * @return bool
     */
    public function isConnected()
    {
        return $this->connected;
    }

    /**
     * @return resource
     */
    public function getConnection()
    {
        return $this->connection;
    }

    /**
     * @param $username
     * @param $password
     *
     * @return bool
     */
    public function authenticate($username, $password)
    {
        return $this->bind($username, $password);
    }


    /**
     * @return bool
     */
    public function anonymous()
    {
        return $this->bind(null, null, true);
    }


    /**
     * @param null       $username
     * @param null       $password
     * @param bool|false $anonymous
     *
     * @return bool
     * @throws NoConnectionError
     */
    public function bind($username = null, $password = null, $anonymous = false)
    {
        $this->checkIfConnected();

        $result = $anonymous ?
            @ldap_bind($this->connection)
            :
            @ldap_bind($this->connection, $username, $password);

        return $result;
    }


    /**
     * Bear in mind that before modifying the directory, you need to authenticate (bind) as administrator first
     *
     * @param       $dn
     * @param array $entry
     *
     * @return bool
     */
    public function modify($dn, array $entry)
    {
        $result = @ldap_modify($this->connection, $dn, $entry);

        return $result;
    }


    /**
     * @param       $base_dn
     * @param       $filter
     * @param array $attributes
     * @param int   $attrsonly
     * @param int   $sizelimit
     * @param int   $timelimit
     * @param int   $deref
     *
     * @return array|EntryCollection
     * @throws SearchError
     */
    public function search($base_dn, $filter, array $attributes = null, $attrsonly = 0, $sizelimit = 0, $timelimit = 0, $deref = LDAP_DEREF_NEVER)
    {
        if(empty($attributes))
        {
            $result = @ldap_search($this->connection, $base_dn, $filter);
        }
        else
        {
            $result = @ldap_search($this->connection, $base_dn, $filter, $attributes, $attrsonly, $sizelimit, $timelimit, $deref);
        }

        if(false === $result)
        {
            throw new SearchError($this->errorString());
        }

        $entries = ldap_get_entries($this->connection, $result);


        $result = [];

        if(isset($entries['count']))
        {
            $cnt = (int)$entries['count'];

            if($cnt > 0)
            {
                for($i = 0; $i < $cnt; $i++)
                {
                    $result[] = EntryCollection::fromArray($entries[$i]);
                }
            }
        }

        if(sizeof($result) === 1)
        {
            return $result[0];
        }

        return $result;
    }

    /**
     * @param $dn
     * @param $uid
     * @param array $entry
     * @param $password
     * @param null $group
     * @return bool
     */
    public function addUser($dn, $uid, array $entry, $password, $group = null)
    {
        $entry = array_merge($entry, [
            'uid'           => $uid,
            'userPassword'  => $this->toPassword($password)
        ]);

        $result = @ldap_add($this->connection, sprintf("uid=%s,%s", $uid, $dn), $entry);

        if(!$result)
        {
            return false;
        }

        if(!is_null($group))
        {
            $result = @ldap_mod_add($this->connection, $group, ['uid' => $entry['username']]);
        }

        return $result;
    }

    /**
     * @param $dn
     * @return bool
     */
    public function deleteUser($dn)
    {
        $result = @ldap_delete($this->connection, $dn);

        return $result;
    }


    /**
     * Utility method that returns a valid string when changing passwords
     *
     * @param $password
     *
     * @return string
     */
    public function toPassword($password)
    {
        return sprintf("{SHA}%s", base64_encode(pack('H*', sha1($password))));
    }


    /**
     * @throws NoConnectionError
     */
    public function checkIfConnected()
    {
        if(!$this->isConnected())
        {
            throw new NoConnectionError(
                sprintf("There's no active connection for %s:%s. Did you invoke connect() method?",
                    $this->host, $this->port
                ));
        }
    }


    /**
     * This method should be used for escaping "username" while performing authentication
     *
     * @param $string
     *
     * @return mixed|string
     */
    public function escape($string)
    {
        // Leading and trailing spaces should be escaped
        $string = trim($string);

        // Special chars should be escaped with a backslash character \
        $string = str_replace($this->escape, $this->replace, $string);

        return $string;
    }


    /**
     * @return int
     */
    public function errorNumber()
    {
        return ldap_errno($this->connection);
    }


    /**
     * @return string
     */
    public function errorString()
    {
        return ldap_error($this->connection);
    }
}