<?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   Lock
 */

/**
 * Lock storage in a MongoDB database.
 *
 * @author    Michael Slusarz <slusarz@horde.org>
 * @category  Horde
 * @copyright 2013-2016 Horde LLC
 * @license   http://www.horde.org/licenses/lgpl21 LGPL 2.1
 * @package   Lock
 */
class Horde_Lock_Mongo extends Horde_Lock
{
    /* Field names. */
    const EXPIRY_TS = 'expiry_ts';
    const LID = 'lid';
    const ORIGIN_TS = 'origin_ts';
    const OWNER = 'owner';
    const PRINCIPAL = 'principal';
    const SCOPE = 'scope';
    const TYPE = 'type';
    const UPDATE_TS = 'update_ts';

    /**
     * The MongoDB Collection object for the cache data.
     *
     * @var MongoCollection
     */
    protected $_db;

    /**
     * Ugly hack: lock driver written with assumption that it returns data
     * as defined by SQL columns. So need to do mapping in this driver.
     *
     * @var array
     */
    protected $_map = array(
        self::EXPIRY_TS => 'lock_expiry_timestamp',
        self::LID => 'lock_id',
        self::ORIGIN_TS => 'lock_origin_timestamp',
        self::OWNER => 'lock_owner',
        self::PRINCIPAL => 'lock_principal',
        self::SCOPE => 'lock_scope',
        self::TYPE => 'lock_type',
        self::UPDATE_TS => 'lock_update_timestamp'
    );

    /**
     * Constructor.
     *
     * @param array $params  Parameters:
     * <pre>
     *   - collection: (string) The collection name.
     *   - mongo_db: [REQUIRED] (Horde_Mongo_Client) A MongoDB client object.
     * </pre>
     */
    public function __construct(array $params = array())
    {
        if (!isset($params['mongo_db'])) {
            throw new InvalidArgumentException('Missing mongo_db parameter.');
        }

        parent::__construct(array_merge(array(
            'collection' => 'horde_locks'
        ), $params));

        $this->_db = $this->_params['mongo_db']
            ->selectCollection(null, $this->_params['collection']);
    }

    /**
     * Destructor.
     */
    public function __destruct()
    {
        if (!rand(0, 999)) {
            $this->gc();
        }
    }

    /**
     */
    public function getLockInfo($lockid)
    {
        $query = array(
            self::LID => $lockid,
            '$or' => array(
                array(self::EXPIRY_TS => array('$gte' => time()),),
                array(self::EXPIRY_TS => Horde_Lock::PERMANENT)
            )
        );

        try {
            return $this->_mapFields($this->_db->findOne($query));
        } catch (MongoException $e) {
            throw new Horde_Lock_Exception($e);
        }
    }

    /**
     */
    public function getLocks($scope = null, $principal = null, $type = null)
    {
        $query = array(
            '$or' => array(
                array(self::EXPIRY_TS => array('$gte' => time()),),
                array(self::EXPIRY_TS => Horde_Lock::PERMANENT)
            )
        );

        // Check to see if we need to filter the results
        if (!empty($principal)) {
            $query[self::PRINCIPAL] = $principal;
        }
        if (!empty($scope)) {
            $query[self::SCOPE] = $scope;
        }
        if (!empty($type)) {
            $query[self::TYPE] = $type;
        }

        try {
            $result = $this->_db->find($query);
        } catch (MongoException $e) {
            throw new Horde_Lock_Exception($e);
        }

        $locks = array();
        foreach ($result as $val) {
            $locks[$val[self::LID]] = $this->_mapFields($val);
        }

        return $locks;
    }

    /**
     */
    public function resetLock($lockid, $lifetime)
    {
        if (!$this->getLockInfo($lockid)) {
            return false;
        }

        $now = time();
        $expiration = ($lifetime == Horde_Lock::PERMANENT)
            ? Horde_Lock::PERMANENT
            : ($now + $lifetime);

        try {
            $this->_db->update(
                array(
                    self::EXPIRY_TS => array('$ne' => Horde_Lock::PERMANENT),
                    self::LID => $lockid
                ),
                array(
                    '$set' => array(
                        self::EXPIRY_TS => $expiration,
                        self::UPDATE_TS => $now
                    )
                )
            );
        } catch (MongoException $e) {
            throw new Horde_Lock_Exception($e);
        }

        return true;
    }

    /**
     */
    public function setLock($requestor, $scope, $principal,
                            $lifetime = 1, $type = Horde_Lock::TYPE_SHARED)
    {
        $oldlocks = $this->getLocks(
            $scope,
            $principal,
            ($type == Horde_Lock::TYPE_SHARED) ? Horde_Lock::TYPE_EXCLUSIVE : null
        );

        if (count($oldlocks) != 0) {
            // A lock exists.  Deny the new request.
            if ($this->_logger) {
                $this->_logger->log(
                    sprintf(
                        'Lock requested for %s denied due to existing lock.',
                        $principal
                    ),
                    'NOTICE'
                );
            }
            return false;
        }

        $lockid = strval(new Horde_Support_Uuid());

        $now = time();
        $expiration = ($lifetime == Horde_Lock::PERMANENT)
            ? Horde_Lock::PERMANENT
            : ($now + $lifetime);

        try {
            $doc = array(
                self::EXPIRY_TS => $expiration,
                self::LID => $lockid,
                self::ORIGIN_TS => $now,
                self::OWNER => $requestor,
                self::PRINCIPAL => $principal,
                self::SCOPE => $scope,
                self::TYPE => $type,
                self::UPDATE_TS => $now
            );
            $this->_db->insert($doc);
        } catch (MongoException $e) {
            throw new Horde_Lock_Exception($e);
        }

        if ($this->_logger) {
            $this->_logger->log(
                sprintf(
                    'Lock %s set successfully by %s in scope %s on "%s"',
                    $lockid, $requestor, $scope, $principal
                ),
                'DEBUG'
            );
        }

        return $lockid;
    }

    /**
     */
    public function clearLock($lockid)
    {
        if (empty($lockid)) {
            throw new Horde_Lock_Exception('Must supply a valid lock ID.');
        }

        try {
            /* Since we're trying to clear the lock we don't care whether it is
             * still valid or not. Unconditionally remove it. */
            $this->_db->remove(array(self::LID => $lockid));
        } catch (MongoException $e) {
            throw new Horde_Lock_Exception($e);
        }

        if ($this->_logger) {
            $this->_logger->log(
                sprintf('Lock %s cleared successfully.', $lockid), 'DEBUG'
            );
        }

        return true;
    }

    /**
     * Do garbage collection needed for the driver.
     */
    public function gc()
    {
        try {
            $result = $this->_db->remove(array(
                self::EXPIRY_TS => array(
                    '$lt' => time(),
                    '$ne' => Horde_Lock::PERMANENT
                )
            ));

            if ($this->_logger) {
                $this->_logger->log(
                    sprintf(
                        'Lock garbage collection cleared %d locks.',
                        $result['n']
                    ),
                    'DEBUG'
                );
            }
        } catch (MongoException $e) {}
    }

    /**
     * Map return to SQL fields.
     *
     * @return array
     */
    protected function _mapFields($res)
    {
        $out = array();

        if ($res) {
            foreach ($res as $key => $val) {
                if (isset($this->_map[$key])) {
                    $out[$this->_map[$key]] = $val;
                }
            }
        }

        return $out;
    }

}
