<?php
/**
 * Copyright 2013-2016 Horde LLC (http://www.horde.org/)
 *
 * See the enclosed file COPYING for license information (LGPL). If you
 * did not receive this file, see http://www.horde.org/licenses/lgpl21.
 *
 * @category  Horde
 * @copyright 2013-2016 Horde LLC
 * @license   http://www.horde.org/licenses/lgpl21 LGPL 2.1
 * @package   HashTable
 */

/**
 * The Horde_HashTable class provides an API to interact with various hash
 * table implementations.
 *
 * @author    Michael Slusarz <slusarz@horde.org>
 * @category  Horde
 * @copyright 2013-2016 Horde LLC
 * @license   http://www.horde.org/licenses/lgpl21 LGPL 2.1
 * @package   HashTable
 *
 * @property-read boolean $locking  Does hash table provide locking? (@since
 *                                  1.1.0)
 * @property-read boolean $persistent  Does hash table provide persistent
 *                                     storage?
 * @property-write string $prefix  Set the hash key prefix.
 */
abstract class Horde_HashTable_Base implements ArrayAccess, Serializable
{
    /**
     * A list of items known not to exist.
     *
     * @var array
     */
    protected $_noexist = array();

    /**
     * Configuration parameters.
     *
     * @var array
     */
    protected $_params = array(
        'prefix' => 'hht_'
    );

    /**
     * Persistent storage provided by driver?
     *
     * @var boolean
     */
    protected $_persistent = false;

    /**
     * Constructor.
     *
     * @param array $params  Configuration parameters:
     * <pre>
     *   - logger: (Horde_Log_Logger) Logger object.
     *   - prefix: (string) Prefix to use for key storage.
     * </pre>
     *
     * @throws Horde_HashTable_Exception
     */
    public function __construct(array $params = array())
    {
        $this->_params = array_merge($this->_params, $params);
        $this->_init();
    }

    /**
     * Do initialization.
     *
     * @throws Horde_HashTable_Exception
     */
    protected function _init()
    {
    }

    /**
     */
    public function __set($name, $val)
    {
        switch ($name) {
        case 'prefix':
            $this->_params['prefix'] = $val;
            break;
        }
    }

    /**
     */
    public function __get($name)
    {
        switch ($name) {
        case 'locking':
            return ($this instanceof Horde_HashTable_Lock);

        case 'persistent':
            return $this->_persistent;
        }
    }

    /**
     * Delete a key(s).
     *
     * @param mixed $keys  The key or an array of keys to delete.
     *
     * @return boolean  True on success.
     */
    public function delete($keys)
    {
        if (!is_array($keys)) {
            $keys = array($keys);
        }

        if ($todo = array_diff($keys, array_keys($this->_noexist))) {
            $to_delete = array_fill_keys(array_map(array($this, 'hkey'), $todo), $todo);
            if (!$this->_delete(array_keys($to_delete))) {
                return false;
            }

            if (!empty($this->_params['logger'])) {
                $this->_params['logger']->debug(sprintf(
                    '%s: Deleted keys (%s)',
                    get_class($this),
                    implode(',', array_keys($to_delete))
                ));
            }

            $this->_noexist = array_merge($this->_noexist, array_fill_keys(array_values($todo), true));
        }

        return true;
    }

    /**
     * Delete keys.
     *
     * @param array $key  An array of keys to delete.
     *
     * @return boolean  True on success.
     */
    abstract protected function _delete($keys);

    /**
     * Do the keys exists?
     *
     * @param mixed $keys  The key or an array of keys.
     *
     * @return mixed  A boolean/array of booleans indicating existence (return
     *                type is the type of $keys).
     */
    public function exists($keys)
    {
        return $this->_getExists($keys, array($this, '_exists'));
    }

    /**
     * Get data associated with keys.
     *
     * @param array $keys  An array of keys.
     *
     * @return array  Existence check. Values are boolean true/false.
     */
    abstract protected function _exists($keys);

    /**
     * Get data associated with a key(s).
     *
     * @param mixed $keys  The key or an array of keys.
     *
     * @return mixed  The string/array on success (return type is the type of
     *                $keys); false value(s) on failure.
     */
    public function get($keys)
    {
        return $this->_getExists($keys, array($this, '_get'));
    }

    /**
     * Get data associated with keys.
     *
     * @param array $keys  An array of keys.
     *
     * @return array  The retrieved keys. Non-existent keys should return
     *                false as the value.
     */
    abstract protected function _get($keys);

    /**
     * Does a get/exists action on a set of keys.
     *
     * @param mixed $keys         The key or an array of keys.
     * @param callable $callback  The internal callback action.
     *
     * @return mixed  The results.
     */
    protected function _getExists($keys, $callback)
    {
        $noexist = $out = $todo = array();

        if (!($ret_array = is_array($keys))) {
            $keys = array($keys);
        }

        foreach ($keys as $val) {
            if (isset($this->_noexist[$val])) {
                $out[$val] = false;
                $noexist[] = $val;
            } else {
                $todo[$this->hkey($val)] = $val;
            }
        }

        if (!empty($todo)) {
            foreach (call_user_func($callback, array_keys($todo)) as $key => $val) {
                if ($val === false) {
                    $this->_noexist[$todo[$key]] = true;
                    $noexist[] = $todo[$key];
                }
                $out[$todo[$key]] = $val;
            }
        }

        if (!empty($this->_params['logger'])) {
            if ($tmp = array_diff(array_keys($out), $noexist)) {
                $this->_params['logger']->debug(sprintf(
                    '%s: Retrieved keys (%s)',
                    get_class($this),
                    implode(',', $tmp)
                ));
            }
            if (!empty($noexist)) {
                $this->_params['logger']->debug(sprintf(
                    '%s: Non-existent keys (%s)',
                    get_class($this),
                    implode(',', $noexist)
                ));
            }
        }

        return $ret_array
            ? $out
            : reset($out);
    }

    /**
     * Set the value of a key.
     *
     * @param string $key  The key.
     * @param string $val  The string to store.
     * @param array $opts  Additional options:
     * <pre>
     *   - expire: (integer) Expiration time in seconds.
     *             DEFAULT: Doesn't expire.
     *   - replace: (boolean) Replace the value of key. If key doesn't exist,
     *              returns false.
     *              DEFAULT: false
     * </pre>
     *
     * @return boolean  True on success, false on error.
     */
    public function set($key, $val, array $opts = array())
    {
        if (!empty($opts['replace']) && isset($this->_noexist[$key])) {
            return false;
        }

        /* @todo BC: 'timeout' == 'expire' usage. */
        if (isset($opts['timeout']) && !isset($opts['expire'])) {
            $opts['expire'] = $opts['timeout'];
        }

        if ($this->_set($this->hkey($key), $val, $opts)) {
            unset($this->_noexist[$key]);
            $res = true;
        } else {
            $res = false;
        }

        if (!empty($this->_params['logger'])) {
            $this->_params['logger']->debug(sprintf(
                '%s: Set key %s(%s)',
                get_class($this),
                $res ? '' : 'FAILED ',
                $key
            ));
        }

        return $res;
    }

    /**
     * Set the value of a key.
     *
     * @param string $key  The key.
     * @param string $val  The string to store.
     * @param array $opts  Additional options (see set()).
     *
     * @return boolean  True on success.
     */
    abstract protected function _set($key, $val, $opts);

    /**
     * Clear all hash table entries.
     */
    abstract public function clear();

    /**
     * Add local prefix to beginning of key.
     *
     * @param string $key  Key name.
     *
     * @return string  Hash table key identifier.
     */
    public function hkey($key)
    {
        return $this->_params['prefix'] . $key;
    }

    /* ArrayAccess methods. */

    /**
     */
    public function offsetExists($offset)
    {
        return $this->exists($offset);
    }

    /**
     */
    public function offsetGet($offset)
    {
        return $this->get($offset);
    }

    /**
     */
    public function offsetSet($offset, $value)
    {
        return $this->set($offset, $value);
    }

    /**
     */
    public function offsetUnset($offset)
    {
        return $this->delete($offset);
    }

    /* Serializable methods. */

    /**
     */
    public function serialize()
    {
        return serialize($this->_params);
    }

    /**
     */
    public function unserialize($data)
    {
        $this->_params = @unserialize($data);
        $this->_init();
    }

}
