<?php
/**
 * Created by PhpStorm.
 * User: crose
 * Date: 3/14/17
 * Time: 11:32 PM
 */

namespace IMATHUZH\Qfq\Core\Helper;


/**
 * Class Ldap
 * @package qfq
 */
class Ldap {

    /**
     *
     */
    public function __construct() {
        // This handler is necessary to catch 'ldap_bind()' errors.
        set_error_handler("\\IMATHUZH\\Qfq\\Core\\Exception\\ErrorHandler::exception_error_handler");

    }

    /**
     * @param $ldapServer
     *
     * @return resource
     * @throws \UserFormException
     */
    private function ldapConnect($ldapServer) {

        $ds = ldap_connect($ldapServer);  // must be a valid LDAP server!
        if (!$ds) {
            throw new \UserFormException(
                json_encode([ERROR_MESSAGE_TO_USER => 'Unable to connect to LDAP server', ERROR_MESSAGE_TO_DEVELOPER => "Unable to connect to LDAP server: $ldapServer"]),
                ERROR_LDAP_CONNECT);
        }

        // http://php.net/manual/en/function.ldap-set-option.php >> This function is only available when using OpenLDAP 2.x.x OR Netscape Directory SDK x.x.
        // Do not check for success.
        ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, 3);

        return $ds;
    }

    /**
     * @param              $ds
     * @param array $config
     * @param array $attr
     *
     * @return resource
     */
    private function ldapSearch($ds, array $config, array $attr) {
        // 'Size Limit errors' are reported, even if it is not a real problem.
        // Fake all errors at the moment.
        // TODO: just drop the 'Size Limit errors' and report all others
        set_error_handler(function () { /* ignore errors */
        });

        $sr = ldap_search($ds, $config[FE_LDAP_BASE_DN], $config[FE_LDAP_SEARCH], $attr, 0, $config[FE_TYPEAHEAD_LIMIT] + 1, $config[FE_LDAP_TIME_LIMIT]);
        restore_error_handler();

        return $sr;
    }

    /**
     * Very specific function. Not generic. Rearranges ldapSearch and might break the original logic.
     *
     * Explode $ldapValue by ' '. If two entries found, do the special PermutSearch, else take the search at it is.
     *
     * PermutSearch: Explode ldapSearch by '(...)'. Assume that all of them 'or' combined.
     * Calculate all tuple permutations of the search token. From every permutation, take the reverse order.
     * Fill the tuple with the two entries. 'and' combine the tuple. 'or' combine all the 'and' tuples.
     *
     * (|(a=?)(b=?)(c=?)), ?=X|Y:  (| (&(a=X)(b=Y)) (&(a=Y)(b=X)) (&(a=X)(c=Y)) (&(a=Y)(c=X)) (&(b=X)(c=Y))
     * (&(=Y)(c=X)) )
     *
     * @param string $ldapSearch
     * @param string $searchValue
     *
     * @return string
     */
    private function explodePermutSearch($ldapSearch, $searchValue) {

        if ($ldapSearch == '' || $searchValue == '') {
            return '';
        }
        $tokenArr = explode(' ', trim($searchValue));
        if (count($tokenArr) != 2) {
            // If there is only one token (or more than two, which can't be handled now): replace and return.
            return str_replace(TYPEAHEAD_PLACEHOLDER, $searchValue, $ldapSearch);
        }

        // Fill array with all search token
        preg_match_all('/\(\w*=.*?\)/', $ldapSearch, $searchArr);

        // Prefill value '1' in $searchArrA and value '2' in $searchArrB
        $searchArrA = OnArray::arrayValueReplace($searchArr[0], TYPEAHEAD_PLACEHOLDER, $tokenArr[0]);
        $searchArrB = OnArray::arrayValueReplace($searchArr[0], TYPEAHEAD_PLACEHOLDER, $tokenArr[1]);

        $permut = array();
        $max = count($searchArrA);
        for ($ii = 0; $ii < $max; $ii++) {
            for ($jj = $ii + 1; $jj < $max; $jj++) {
                $permut[] = '(&' . $searchArrA[$ii] . $searchArrB[$jj] . ')';
                $permut[] = '(&' . $searchArrA[$jj] . $searchArrB[$ii] . ')';
            }
        }

        $searchString = '(|' . implode('', $permut) . ')';

        return $searchString;
    }

    /**
     * Explode $ldapValue by ' '. If more than one entry is found, append the search, replaced by word 1, word 2, ...
     *
     * (|(a=*?*)(b=*?*)(c=*?*)), ?=X Y Z:    (& (|(a=*X*)(b=*X*)(c=*X*))  (|(a=*Y*)(b=*Y*)(c=*Y*))
     * (|(a=*Z*)(b=*Z*)(c=*Z*)) )
     *
     * @param string $ldapSearch
     * @param string $searchValue
     *
     * @return string
     */
    private function explodeSearchPerToken($ldapSearch, $searchValue) {

        $searchValue = trim($searchValue);
        if ($ldapSearch == '' || $searchValue == '') {
            return '';
        }

        $tokenArr = OnArray::removeEmptyElementsFromArray(explode(' ', $searchValue));
        if (count($tokenArr) == 1) {
            // If there is only one token : replace and return.
            return str_replace(TYPEAHEAD_PLACEHOLDER, $searchValue, $ldapSearch);
        }

        $searchString = '';
        foreach ($tokenArr as $word) {
            $searchString .= str_replace(TYPEAHEAD_PLACEHOLDER, $word, $ldapSearch);
        }

        return '(&' . $searchString . ')';

    }

    /**
     * @param array $config
     * @param string $searchValue
     * @param string $mode
     *
     * @return array
     * @throws \UserFormException
     */
    private function prepareConfig(array $config, $searchValue, $mode) {

        $config[FE_LDAP_ATTRIBUTES] = Support::setIfNotSet($config, FE_LDAP_ATTRIBUTES, '');
        $config[FE_LDAP_TIME_LIMIT] = Support::setIfNotSet($config, FE_LDAP_TIME_LIMIT, DEFAULT_LDAP_TIME_LIMIT);

        $config[FE_TYPEAHEAD_LIMIT] = ($mode == MODE_LDAP_MULTI) ? $config[FE_TYPEAHEAD_LIMIT] : 1;

        if ($mode == MODE_LDAP_PREFETCH) {
            $config[FE_LDAP_SEARCH] = str_replace(TYPEAHEAD_PLACEHOLDER, $searchValue, $config[FE_LDAP_SEARCH]);
        }

        if ($mode == MODE_LDAP_MULTI) {

            if (isset($config[F_TYPEAHEAD_LDAP_SEARCH_PER_TOKEN])) {
                $config[FE_LDAP_SEARCH] = $this->explodeSearchPerToken($config[FE_LDAP_SEARCH], $searchValue);
            } else {
                $config[FE_LDAP_SEARCH] = str_replace(TYPEAHEAD_PLACEHOLDER, $searchValue, $config[FE_LDAP_SEARCH]);
            }

            $config[FE_TYPEAHEAD_LDAP_ID_PRINTF] = Support::setIfNotSet($config, FE_TYPEAHEAD_LDAP_ID_PRINTF, '');
            $config[FE_TYPEAHEAD_LDAP_VALUE_PRINTF] = Support::setIfNotSet($config, FE_TYPEAHEAD_LDAP_VALUE_PRINTF, '');

            if ($config[FE_TYPEAHEAD_LDAP_ID_PRINTF] == '') {
                $config[FE_TYPEAHEAD_LDAP_ID_PRINTF] = $config[FE_TYPEAHEAD_LDAP_VALUE_PRINTF];
            }

            if ($config[FE_TYPEAHEAD_LDAP_VALUE_PRINTF] == '') {
                $config[FE_TYPEAHEAD_LDAP_VALUE_PRINTF] = $config[FE_TYPEAHEAD_LDAP_ID_PRINTF];
            }

            if ($config[FE_TYPEAHEAD_LDAP_ID_PRINTF] == '') {
                throw new \UserFormException("Missing parameter '" . FE_TYPEAHEAD_LDAP_ID_PRINTF . "' and/or '" . FE_TYPEAHEAD_LDAP_VALUE_PRINTF);
            }
        }

        return $config;
    }

    /**
     *
     * @param array $config [FE_LDAP_SERVER , FE_LDAP_BASE_DN, FE_LDAP_SEARCH, FE_TYPEAHEAD_LIMIT,
     *                            FE_TYPEAHEAD_LDAP_KEY_PRINTF, FE_TYPEAHEAD_LDAP_VALUE_PRINTF]
     * @param string $searchValue value to search via $config[FE_LDAP_SEARCH]
     * @param string $mode MODE_LDAP_SINGLE | MODE_LDAP_MULTI | MODE_LDAP_PREFETCH
     *
     * @return array Array: [ [ 'key' => '...', 'value' => '...' ], ]
     * @throws \CodeException
     * @throws \UserFormException
     */
    public function process(array $config, $searchValue, $mode = MODE_LDAP_MULTI) {
        $arr = array();

        // For TypeAhead, use an optional given F_TYPEAHEAD_LDAP_SEARCH
        switch ($mode) {
            case MODE_LDAP_PREFETCH:
                if (!isset($config[F_TYPEAHEAD_LDAP_SEARCH_PREFETCH]) || $config[F_TYPEAHEAD_LDAP_SEARCH_PREFETCH] == '') {
                    throw new \UserFormException("Missing definition for `" . F_TYPEAHEAD_LDAP_SEARCH_PREFETCH . "`", ERROR_MISSING_TYPE_AHEAD_LDAP_SEARCH_PREFETCH);
                }
                $config[F_LDAP_SEARCH] = $config[F_TYPEAHEAD_LDAP_SEARCH_PREFETCH];
                break;

            case MODE_LDAP_MULTI:
                if (!isset($config[F_TYPEAHEAD_LDAP_SEARCH]) || $config[F_TYPEAHEAD_LDAP_SEARCH] == '') {
                    throw new \UserFormException("Missing definition for `" . F_TYPEAHEAD_LDAP_SEARCH . "`", ERROR_MISSING_TYPE_AHEAD_LDAP_SEARCH);
                }
                $config[F_LDAP_SEARCH] = $config[F_TYPEAHEAD_LDAP_SEARCH];
                break;

            case MODE_LDAP_SINGLE:
                if (!isset($config[F_LDAP_SEARCH]) || $config[F_LDAP_SEARCH] == '') {
                    throw new \UserFormException("Missing definition for `" . F_LDAP_SEARCH . "`", ERROR_MISSING_TYPE_AHEAD_LDAP_SEARCH);
                }
                break;

            default:
                throw new \UserFormException("Unknown mode: " . $mode, ERROR_UNKNOWN_MODE);
        }

        $searchValue = Support::ldap_escape($searchValue, null, LDAP_ESCAPE_FILTER);
        $config = $this->prepareConfig($config, $searchValue, $mode);

        $ds = $this->ldapConnect($config[FE_LDAP_SERVER]);  // must be a valid LDAP server!

        if (isset($config[SYSTEM_LDAP_1_RDN]) && isset($config[SYSTEM_LDAP_1_PASSWORD])) {
            if (false === ldap_bind($ds, $config[SYSTEM_LDAP_1_RDN], $config[SYSTEM_LDAP_1_PASSWORD])) {
                throw new \UserFormException("LDAP: Error trying to bind: " . ldap_error($ds), ERROR_LDAP_BIND);
            }
        }

        $keyArr = $this->preparePrintf($config, FE_TYPEAHEAD_LDAP_ID_PRINTF, $keyFormat);
        $valueArr = $this->preparePrintf($config, FE_TYPEAHEAD_LDAP_VALUE_PRINTF, $valueFormat);
        $specificArr = OnArray::arrayValueToLower(OnArray::trimArray(explode(',', $config[FE_LDAP_ATTRIBUTES])));

        // merge, trim, toLower, unique, values
        $attr = array_values(
            array_unique(
                OnArray::removeEmptyElementsFromArray(
                    array_merge($keyArr, $valueArr, $specificArr))));

        $sr = $this->ldapSearch($ds, $config, $attr);
        if ($sr !== false) {
            $info = ldap_get_entries($ds, $sr);

            switch ($mode) {
                case MODE_LDAP_MULTI:
                case MODE_LDAP_PREFETCH:
                    // Iterate over all Elements, per element collect all needed attributes
                    for ($i = 0; $i < $info["count"]; $i++) {

                        // HTML Entities will be escaped on Client side.
                        $key = $this->printfResult($keyFormat, $keyArr, $info[$i], false);
                        $value = $this->printfResult($valueFormat, $valueArr, $info[$i], false);

                        if ($key == '' || $value == '') {
                            continue; // if $key or $value is empty: skip
                        }

                        $arr[] = [API_TYPEAHEAD_KEY => $key, API_TYPEAHEAD_VALUE => $value];
                    }
                    break;
                case MODE_LDAP_SINGLE:
                    // Collect all attributes
                    foreach ($attr as $key) {
                        $value = isset($info[0][$key][0]) ? $info[0][$key][0] : '';
                        $arr[$key] = $value;
                    }
                    break;
                default:
                    throw new \UserFormException("Unknown mode: " . $mode, ERROR_UNKNOWN_MODE);

            }

            ldap_close($ds);
        }

        return $arr;
    }

    /**
     * Very specific function to prepare the later 'printfResult()'.
     *
     * @param array $config Check existence and take element $key
     * @param string $key FE_TYPEAHEAD_LDAP_KEY_PRINTF, FE_TYPEAHEAD_LDAP_VALUE_PRINTF
     * @param string $fmtFirst Returns the first part of $fmtComplete - the printf format string without any args.
     *
     * @return array            Array with all requested keynames from $fmtComplete
     * @throws \CodeException
     * @throws \UserFormException
     */
    private function preparePrintf(array $config, $key, &$fmtFirst) {

        $fmtFirst = '';

        if (!isset($config[$key])) {
            return array();
        }

        $fmtComplete = $config[$key];
        // Typical $fmtComplete: "'%s / %s / %s', cn, mail. telephonenumber"
        $arr = KeyValueStringParser::explodeWrapped(',', $fmtComplete);

        if (count($arr) < 2) {
            throw new \UserFormException("Expect a sprintf compatible format string with a least one argument. Got: '" . $fmtComplete . "'", ERROR_MISSING_PRINTF_ARGUMENTS);
        }

        // unquote and return the part printf-'formatString'
        $fmtFirst = trim($arr[0], SINGLE_TICK . DOUBLE_TICK);

        array_shift($arr); // remove first entry:

        // toLower & trim are mandatory here: access to LDAP entries are comming soon.
        return OnArray::arrayValueToLower(OnArray::trimArray($arr));
    }

    /**
     * Plays sprintf with supplied arguments. Collect the values of the arguments in the array
     *  $keyArr to pass them via 'call_user_func_array' to sprintf.
     *
     * @param $format
     * @param array $keyArr
     * @param $infoElement
     *
     * @param bool $doHtmlEntity
     * @return string       output of sprintf
     */
    private function printfResult($format, array $keyArr, $infoElement, $doHtmlEntity = true) {

        $args = array($format);

        foreach ($keyArr as $key) {
            $val = '';

            if (isset($infoElement[$key][0])) {
                $val = $infoElement[$key][0];
                if ($doHtmlEntity === true) {
                    $val = htmlentities($val);
                }
            }
            $args[] = $val;
        }

        return call_user_func_array('sprintf', $args);
    }
}