<?php
/**
 * Result set of an LDAP search
 *
 * Copyright 2009 Jan Wagner, Benedikt Hallinger
 * Copyright 2010-2017 Horde LLC (http://www.horde.org/)
 *
 * @category  Horde
 * @package   Ldap
 * @author    Tarjej Huse <tarjei@bergfald.no>
 * @author    Benedikt Hallinger <beni@php.net>
 * @author    Jan Schneider <jan@horde.org>
 * @license   http://www.gnu.org/licenses/lgpl-3.0.html LGPL-3.0
 */
class Horde_Ldap_Search implements Iterator
{
    /**
     * Search result identifier.
     *
     * @var resource
     */
    protected $_search;

    /**
     * LDAP resource link.
     *
     * @var resource
     */
    protected $_link;

    /**
     * Horde_Ldap object.
     *
     * A reference of the Horde_Ldap object for passing to Horde_Ldap_Entry.
     *
     * @var Horde_Ldap
     */
    protected $_ldap;

    /**
     * Result entry identifier.
     *
     * @var resource
     */
    protected $_entry;

    /**
     * The errorcode from the search.
     *
     * Some errorcodes might be of interest that should not be considered
     * errors, for example:
     * - 4: LDAP_SIZELIMIT_EXCEEDED - indicates a huge search. Incomplete
     *      results are returned. If you just want to check if there is
     *      anything returned by the search at all, this could be catched.
     * - 32: no such object - search here returns a count of 0.
     *
     * @var integer
     */
    protected $_errorCode = 0;

    /**
     * Cache for all entries already fetched from iterator interface.
     *
     * @var array
     */
    protected $_iteratorCache = array();

    /**
     * Attributes we searched for.
     *
     * This variable gets set from the constructor and can be retrieved through
     * {@link searchedAttributes()}.
     *
     * @var array
     */
    protected $_searchedAttrs = array();

    /**
     * Cache variable for storing entries fetched internally.
     *
     * This currently is only used by {@link pop_entry()}.
     *
     * @var array
     */
    protected $_entry_cache = false;

    /**
     * Constructor.
     *
     * @param resource            $search     Search result identifier.
     * @param Horde_Ldap|resource $ldap       Horde_Ldap object or a LDAP link
     *                                        resource
     * @param array               $attributes The searched attribute names,
     *                                        see {@link $_searchedAttrs}.
     */
    public function __construct($search, $ldap, $attributes = array())
    {
        $this->setSearch($search);

        if ($ldap instanceof Horde_Ldap) {
            $this->_ldap = $ldap;
            $this->setLink($this->_ldap->getLink());
        } else {
            $this->setLink($ldap);
        }

        $this->_errorCode = @ldap_errno($this->_link);

        if (is_array($attributes) && !empty($attributes)) {
            $this->_searchedAttrs = $attributes;
        }
    }

    /**
     * Destructor.
     */
    public function __destruct()
    {
        @ldap_free_result($this->_search);
    }

    /**
     * Returns all entries from the search result.
     *
     * @return array  All entries.
     * @throws Horde_Ldap_Exception
     */
    public function entries()
    {
        $entries = array();
        while ($entry = $this->shiftEntry()) {
            $entries[] = $entry;
        }
        return $entries;
    }

    /**
     * Get the next entry from the search result.
     *
     * This will return a valid Horde_Ldap_Entry object or false, so you can
     * use this method to easily iterate over the entries inside a while loop.
     *
     * @return Horde_Ldap_Entry|false  Reference to Horde_Ldap_Entry object or
     *                                 false if no more entries exist.
     * @throws Horde_Ldap_Exception
     */
    public function shiftEntry()
    {
        if (is_null($this->_entry)) {
            if (!$this->_entry = @ldap_first_entry($this->_link, $this->_search)) {
                return false;
            }
            $entry = Horde_Ldap_Entry::createConnected($this->_ldap, $this->_entry);
        } else {
            if (!$this->_entry = @ldap_next_entry($this->_link, $this->_entry)) {
                return false;
            }
            $entry = Horde_Ldap_Entry::createConnected($this->_ldap, $this->_entry);
        }

        return $entry;
    }

    /**
     * Retrieve the next entry in the search result, but starting from last
     * entry.
     *
     * This is the opposite to {@link shiftEntry()} and is also very useful to
     * be used inside a while loop.
     *
     * @return Horde_Ldap_Entry|false
     * @throws Horde_Ldap_Exception
     */
    public function popEntry()
    {
        if (false === $this->_entry_cache) {
            // Fetch entries into cache if not done so far.
            $this->_entry_cache = $this->entries();
        }

        return count($this->_entry_cache) ? array_pop($this->_entry_cache) : false;
    }

    /**
     * Return entries sorted as array.
     *
     * This returns a array with sorted entries and the values. Sorting is done
     * with PHPs {@link array_multisort()}.
     *
     * This method relies on {@link asArray()} to fetch the raw data of the
     * entries.
     *
     * Please note that attribute names are case sensitive!
     *
     * Usage example:
     * <code>
     *   // To sort entries first by location, then by surname, but descending:
     *   $entries = $search->sortedAsArray(array('locality', 'sn'), SORT_DESC);
     * </code>
     *
     * @todo what about server side sorting as specified in
     *       http://www.ietf.org/rfc/rfc2891.txt?
     * @todo Nuke evil eval().
     *
     * @param array   $attrs Attribute names as sort criteria.
     * @param integer $order Ordering direction, either constant SORT_ASC or
     *                       SORT_DESC
     *
     * @return array Sorted entries.
     * @throws Horde_Ldap_Exception
     */
    public function sortedAsArray(array $attrs = array('cn'), $order = SORT_ASC)
    {
        /* New code: complete "client side" sorting */
        // First some parameterchecks.
        if ($order != SORT_ASC && $order != SORT_DESC) {
            throw new Horde_Ldap_Exception('Sorting failed: sorting direction not understood! (neither constant SORT_ASC nor SORT_DESC)');
        }

        // Fetch the entries data.
        $entries = $this->asArray();

        // Now sort each entries attribute values.
        // This is neccessary because later we can only sort by one value, so
        // we need the highest or lowest attribute now, depending on the
        // selected ordering for that specific attribute.
        foreach ($entries as $dn => $entry) {
            foreach ($entry as $attr_name => $attr_values) {
                sort($entries[$dn][$attr_name]);
                if ($order == SORT_DESC) {
                    array_reverse($entries[$dn][$attr_name]);
                }
            }
        }

        // Reformat entries array for later use with
        // array_multisort(). $to_sort will be a numeric array similar to
        // ldap_get_entries().
        $to_sort = array();
        foreach ($entries as $dn => $entry_attr) {
            $row = array('dn' => $dn);
            foreach ($entry_attr as $attr_name => $attr_values) {
                $row[$attr_name] = $attr_values;
            }
            $to_sort[] = $row;
        }

        // Build columns for array_multisort(). Each requested attribute is one
        // row.
        $columns = array();
        foreach ($attrs as $attr_name) {
            foreach ($to_sort as $key => $row) {
                $columns[$attr_name][$key] =& $to_sort[$key][$attr_name][0];
            }
        }

        // Sort the colums with array_multisort() if there is something to sort
        // and if we have requested sort columns.
        if (!empty($to_sort) && !empty($columns)) {
            $sort_params = '';
            foreach ($attrs as $attr_name) {
                $sort_params .= '$columns[\'' . $attr_name . '\'], ' . $order . ', ';
            }
            eval("array_multisort($sort_params \$to_sort);");
        }

        return $to_sort;
    }

    /**
     * Returns entries sorted as objects.
     *
     * This returns a array with sorted Horde_Ldap_Entry objects. The sorting
     * is actually done with {@link sortedAsArray()}.
     *
     * Please note that attribute names are case sensitive!
     *
     * Also note that it is (depending on server capabilities) possible to let
     * the server sort your results. This happens through search controls and
     * is described in detail at {@link http://www.ietf.org/rfc/rfc2891.txt}
     *
     * Usage example:
     * <code>
     *   // To sort entries first by location, then by surname, but descending:
     *   $entries = $search->sorted(array('locality', 'sn'), SORT_DESC);
     * </code>
     *
     * @todo Entry object construction could be faster. Maybe we could use one
     *       of the factories instead of fetching the entry again.
     *
     * @param array   $attrs Attribute names as sort criteria.
     * @param integer $order Ordering direction, either constant SORT_ASC or
     *                       SORT_DESC
     *
     * @return array Sorted entries.
     * @throws Horde_Ldap_Exception
     */
    public function sorted($attrs = array('cn'), $order = SORT_ASC)
    {
        $return = array();
        $sorted = $this->sortedAsArray($attrs, $order);
        foreach ($sorted as $row) {
            $entry = $this->_ldap->getEntry($row['dn'], $this->searchedAttributes());
            $return[] = $entry;
        }
        return $return;
    }

    /**
     * Returns entries as array.
     *
     * The first array level contains all found entries where the keys are the
     * DNs of the entries. The second level arrays contian the entries
     * attributes such that the keys is the lowercased name of the attribute
     * and the values are stored in another indexed array. Note that the
     * attribute values are stored in an array even if there is no or just one
     * value.
     *
     * The array has the following structure:
     * <code>
     * array(
     *     'cn=foo,dc=example,dc=com' => array(
     *         'sn'       => array('foo'),
     *         'multival' => array('val1', 'val2', 'valN')),
     *     'cn=bar,dc=example,dc=com' => array(
     *         'sn'       => array('bar'),
     *         'multival' => array('val1', 'valN')))
     * </code>
     *
     * @return array Associative result array as described above.
     * @throws Horde_Ldap_Exception
     */
    public function asArray()
    {
        $return  = array();
        $entries = $this->entries();
        foreach ($entries as $entry) {
            $attrs            = array();
            $entry_attributes = $entry->attributes();
            foreach ($entry_attributes as $attr_name) {
                $attr_values = $entry->getValue($attr_name, 'all');
                if (!is_array($attr_values)) {
                    $attr_values = array($attr_values);
                }
                $attrs[$attr_name] = $attr_values;
            }
            $return[$entry->dn()] = $attrs;
        }
        return $return;
    }

    /**
     * Sets the search objects resource link
     *
     * @param resource $search Search result identifier.
     */
    public function setSearch($search)
    {
        $this->_search = $search;
    }

    /**
     * Sets the LDAP resource link.
     *
     * @param resource $link LDAP link identifier.
     */
    public function setLink($link)
    {
        $this->_link = $link;
    }

    /**
     * Returns the number of entries in the search result.
     *
     * @return integer Number of found entries.
     */
    public function count()
    {
        // This catches the situation where OL returned errno 32 = no such
        // object!
        if (!$this->_search) {
            return 0;
        }
        return @ldap_count_entries($this->_link, $this->_search);
    }

    /**
     * Returns the errorcode from the search.
     *
     * @return integer The LDAP error number.
     */
    public function getErrorCode()
    {
        return $this->_errorCode;
    }

    /**
     * Returns the attribute names this search selected.
     *
     * @see $_searchedAttrs
     *
     * @return array
     */
    protected function searchedAttributes()
    {
        return $this->_searchedAttrs;
    }

    /**
     * Returns wheter this search exceeded a sizelimit.
     *
     * @return boolean  True if the size limit was exceeded.
     */
    public function sizeLimitExceeded()
    {
        return $this->getErrorCode() == 4;
    }

    /* SPL Iterator interface methods. This interface allows to use
     * Horde_Ldap_Search objects directly inside a foreach loop. */

    /**
     * SPL Iterator interface: Returns the current element.
     *
     * The SPL Iterator interface allows you to fetch entries inside
     * a foreach() loop: <code>foreach ($search as $dn => $entry) { ...</code>
     *
     * Of course, you may call {@link current()}, {@link key()}, {@link next()},
     * {@link rewind()} and {@link valid()} yourself.
     *
     * If the search throwed an error, it returns false. False is also
     * returned, if the end is reached.
     *
     * In case no call to next() was made, we will issue one, thus returning
     * the first entry.
     *
     * @return Horde_Ldap_Entry|false
     * @throws Horde_Ldap_Exception
     */
    public function current()
    {
        if (count($this->_iteratorCache) == 0) {
            $this->next();
            reset($this->_iteratorCache);
        }
        $entry = current($this->_iteratorCache);
        return $entry instanceof Horde_Ldap_Entry ? $entry : false;
    }

    /**
     * SPL Iterator interface: Returns the identifying key (DN) of the current
     * entry.
     *
     * @see current()
     * @return string|false DN of the current entry; false in case no entry is
     *                      returned by current().
     */
    public function key()
    {
        $entry = $this->current();
        return $entry instanceof Horde_Ldap_Entry ? $entry->dn() :false;
    }

    /**
     * SPL Iterator interface: Moves forward to next entry.
     *
     * After a call to {@link next()}, {@link current()} will return the next
     * entry in the result set.
     *
     * @see current()
     * @throws Horde_Ldap_Exception
     */
    public function next()
    {
        // Fetch next entry. If we have no entries anymore, we add false (which
        // is returned by shiftEntry()) so current() will complain.
        if (count($this->_iteratorCache) - 1 <= $this->count()) {
            $this->_iteratorCache[] = $this->shiftEntry();
        }

        // Move array pointer to current element.  Even if we have added all
        // entries, this will ensure proper operation in case we rewind().
        next($this->_iteratorCache);
    }

    /**
     * SPL Iterator interface: Checks if there is a current element after calls
     * to {@link rewind()} or {@link next()}.
     *
     * Used to check if we've iterated to the end of the collection.
     *
     * @see current()
     * @return boolean False if there's nothing more to iterate over.
     */
    public function valid()
    {
        return $this->current() instanceof Horde_Ldap_Entry;
    }

    /**
     * SPL Iterator interface: Rewinds the Iterator to the first element.
     *
     * After rewinding, {@link current()} will return the first entry in the
     * result set.
     *
     * @see current()
     */
    public function rewind()
    {
        reset($this->_iteratorCache);
    }
}
