<?php
/**
 * The Kronolith_Driver_Resource class implements the Kronolith_Driver API for
 * storing resource calendars in a SQL backend.
 *
 * Copyright 1999-2017 Horde LLC (http://www.horde.org/)
 *
 * See the enclosed file COPYING for license information (GPL). If you
 * did not receive this file, see http://www.horde.org/licenses/gpl.
 *
 * @author  Luc Saillard <luc.saillard@fr.alcove.com>
 * @author  Chuck Hagenbuch <chuck@horde.org>
 * @author  Jan Schneider <jan@horde.org>
 * @author  Michael J Rubinsky <mrubinsk@horde.org>
 * @package Kronolith
 */
class Kronolith_Driver_Resource_Sql extends Kronolith_Driver
{
    /**
     * The main event storage driver.
     *
     * @var Kronolith_Driver
     */
    protected $_driver;

    /**
     * Column information as Horde_Db_Adapter_Base_Column objects.
     *
     * @var array
     */
    protected $_columns = array();

    /**
     * The class name of the event object to instantiate.
     *
     * @var string
     */
    protected $_eventClass = 'Kronolith_Event_Resource_Sql';

    /**
     * Attempts to open a connection to the SQL server.
     *
     * @throws Kronolith_Exception
     */
    public function initialize()
    {
        if (empty($this->_params['db'])) {
            throw new InvalidArgumentException('Missing required Horde_Db_Adapter instance');
        }
        try {
            $this->_db = $this->_params['db'];
        } catch (Horde_Exception $e) {
            throw new Kronolith_Exception($e);
        }

        $this->_params = array_merge(array(
            'table' => 'kronolith_resources'
        ), $this->_params);

        $this->_driver = Kronolith::getDriver();
        $this->_columns = $this->_db->columns($this->_params['table']);
    }

    /**
     * Selects a calendar as the currently opened calendar.
     *
     * @param string $calendar  A calendar identifier.
     */
    public function open($calendar)
    {
        $this->calendar = $calendar;
        $this->_driver->open($calendar);
    }

    /**
     * Lists all events in the time range, optionally restricting results to
     * only events with alarms.
     *
     * @param Horde_Date $startDate  The start of range date.
     * @param Horde_Date $endDate    The end of date range.
     * @param array $options         Additional options:
     *   - show_recurrence: (boolean) Return every instance of a recurring
     *                       event?
     *                      DEFAULT: false (Only return recurring events once
     *                      inside $startDate - $endDate range)
     *   - has_alarm:       (boolean) Only return events with alarms.
     *                      DEFAULT: false (Return all events)
     *   - json:            (boolean) Store the results of the event's toJson()
     *                      method?
     *                      DEFAULT: false
     *   - cover_dates:     (boolean) Add the events to all days that they
     *                      cover?
     *                      DEFAULT: true
     *   - hide_exceptions: (boolean) Hide events that represent exceptions to
     *                      a recurring event.
     *                      DEFAULT: false (Do not hide exception events)
     *   - fetch_tags:      (boolean) Fetch tags for all events.
     *                      DEFAULT: false (Do not fetch event tags)
     *
     * @throws Kronolith_Exception
     */
    public function listEvents(Horde_Date $startDate = null,
                               Horde_Date $endDate = null,
                               array $options = array())
    {
        $json = !empty($options['json']);
        $options['json'] = false;
        $events = $this->_driver->listEvents($startDate, $endDate, $options);
        $results = array();

        foreach ($events as $period_key => $period) {
            foreach ($period as $event_id => $event) {
                $resource_event = $this->_buildResourceEvent($event);
                $results[$period_key][$event_id] = $json
                    ? $resource_event->toJson()
                    : $resource_event;
            }
        }

        return $results;
    }

    protected function _buildResourceEvent($driver_event)
    {
        $resource_event = new $this->_eventClass($this);
        $resource_event->fromDriver($driver_event->toProperties(true));
        $resource_event->calendar = $this->calendar;

        return $resource_event;
    }

    /**
     * Get an event or events with the given UID value.
     *
     * @param string $uid       The UID to match
     * @param array $calendars  A restricted array of calendar ids to search
     * @param boolean $getAll   Return all matching events?
     *
     * @return Kronolith_Event
     * @throws Kronolith_Exception
     * @throws Horde_Exception_NotFound
     */
    public function getByUID($uid, $calendars = null, $getAll = false)
    {
       $event = new $this->_eventClass($this);
       $driver_event = $this->_driver->getByUID($uid, $calendars, $getAll);
       $event->fromDriver($driver_event->toProperties(true));
       $event->calendar = $this->calendar;
       return $event;
    }

    /**
     * @throws Kronolith_Exception
     * @throws Horde_Exception_NotFound
     */
    public function getEvent($eventId = null)
    {
        if (!strlen($eventId)) {
            $event = new $this->_eventClass($this);
            $event->calendar = $this->calendar;

            return $event;
        }

        $driver_event = $this->_driver->getEvent($eventId);
        $event = $this->_buildResourceEvent($driver_event);

        return $event;
    }

    /**
     * Saves an event in the backend.
     *
     * If it is a new event, it is added, otherwise the event is updated.
     *
     * @param Kronolith_Event $event  The event to save.
     *
     * @return string  The event id.
     * @throws Horde_Mime_Exception
     * @throws Kronolith_Exception
     */
    public function saveEvent(Kronolith_Event $event)
    {
        return $this->_driver->saveEvent($event);
    }

    /**
     * Delete an event.
     *
     * Since this is the Kronolith_Resource's version of the event, if we
     * delete it, we must also make sure to remove it from the event that
     * it is attached to. Not sure if there is a better way to do this...
     *
     * @param string|Kronolith_Event_Resource_Sql $eventId  The ID of the event
     *                                                      to delete.
     * @param boolean $silent  Don't send notifications, used when deleting
     *                         events in bulk from maintenance tasks.
     * @param boolean $keep_bound  If true, does not remove the resource from
     *                             the bound event. @since 4.2.2
     *
     * @throws Kronolith_Exception
     * @throws Horde_Exception_NotFound
     */
    public function deleteEvent($eventId, $silent = false, $keep_bound = false)
    {
        if ($eventId instanceof Kronolith_Event_Resource_Sql) {
            $delete_event = $eventId;
            $eventId = $delete_event->id;
        } else {
            $delete_event = $this->getEvent($eventId);
        }

        if ($keep_bound) {
            return;
        }

        $uid = $delete_event->uid;
        $events = $this->_driver->getByUID($uid, null, true);
        foreach ($events as $e) {
            $resources = $e->getResources();
            if (count($resources)) {
                $r = $this->getResource($this->getResourceIdByCalendar($delete_event->calendar));
                $e->removeResource($r);
                $e->save();
            }
        }
        $this->_driver->open($this->calendar);
        $this->_driver->deleteEvent($delete_event, $silent);
    }

    /**
     * Save or update a Kronolith_Resource
     *
     * @param Kronolith_Resource_Base $resource
     *
     * @return Kronolith_Resource object
     * @throws Kronolith_Exception, Horde_Exception_PermissionDenied
     */
    public function save(Kronolith_Resource_Base $resource)
    {
        if (!$GLOBALS['registry']->isAdmin() &&
            !$GLOBALS['injector']->getInstance('Horde_Core_Perms')->hasAppPermission('resource_management')) {
            throw new Horde_Exception_PermissionDenied();
        }
        if ($resource->getId()) {
            $query = 'UPDATE ' . $this->_params['table'] . ' SET resource_name = ?, '
                . 'resource_calendar = ? , resource_description = ?, '
                . 'resource_response_type = ?, resource_type = ?, '
                . 'resource_members = ?, resource_email = ? WHERE resource_id = ?';

            $values = array($this->convertToDriver($resource->get('name')),
                            $resource->get('calendar'),
                            $this->convertToDriver($resource->get('description')),
                            $resource->get('response_type'),
                            $resource->get('type'),
                            serialize($resource->get('members')),
                            $resource->get('email'),
                            $resource->getId());

            try {
                $this->_db->update($query, $values);
            } catch (Horde_Db_Exception $e) {
                throw new Kronolith_Exception($e);
            }
        } else {
            $query = 'INSERT INTO ' . $this->_params['table']
                . ' (resource_name, resource_calendar, '
                .  'resource_description, resource_response_type, '
                . ' resource_type, resource_members, resource_email)'
                . ' VALUES (?, ?, ?, ?, ?, ?, ?)';
            $values = array($this->convertToDriver($resource->get('name')),
                            $resource->get('calendar'),
                            $this->convertToDriver($resource->get('description')),
                            $resource->get('response_type'),
                            $resource->get('type'),
                            serialize($resource->get('members')),
                            $resource->get('email'));
            try {
                $id = $this->_db->insert($query, $values);
            } catch (Horde_Db_Exception $e) {
                throw new Kronolith_Exception($e);
            }
            $resource->setId($id);
        }

        return $resource;
    }

    /**
     * Removes a resource from storage, along with any events in the resource's
     * calendar.
     *
     * @param Kronolith_Resource_Base $resource  The kronolith resource to remove
     *
     * @throws Kronolith_Exception, Horde_Exception_PermissionDenied
     */
    public function delete($resource)
    {
        if (!$GLOBALS['registry']->isAdmin() &&
            !$GLOBALS['injector']->getInstance('Horde_Core_Perms')->hasAppPermission('resource_management')) {
            throw new Horde_Exception_PermissionDenied();
        }

        if (!$resource->getId()) {
            throw new Kronolith_Exception(_("Resource not valid."));
        }

        // Get group memberships and remove from group.
        $groups = $this->getGroupMemberships($resource->getId());
        foreach ($groups as $id) {
            $rg = $this->getResource($id);
            $members = $rg->get('members');
            unset($members[array_search($resource->getId(), $members)]);
            $rg->set('members', $members);
            $rg->save();
        }

        $this->_deleteResourceCalendar($resource->get('calendar'));
        try {
            $query = 'DELETE FROM ' . $this->_params['table'] . ' WHERE resource_id = ?';
            $this->_db->delete($query, array($resource->getId()));
        } catch (Horde_Db_Exception $e) {
            throw new Kronolith_Exception($e);
        }
    }

    /**
     * Obtain a Kronolith_Resource by the resource's id
     *
     * @param integer $id  The key for the Kronolith_Resource
     *
     * @return Kronolith_Resource_Base
     * @throws Kronolith_Exception
     */
    public function getResource($id)
    {
        $query = 'SELECT resource_id, resource_name, resource_calendar, '
            . 'resource_description, resource_response_type, resource_type, '
            . 'resource_members, resource_email FROM ' . $this->_params['table']
            . ' WHERE resource_id = ?';

        try {
            $results = $this->_db->selectOne($query, array($id));
        } catch (Horde_Db_Exception $e) {
            throw new Kronolith_Exception($e);
        }
        if (!count($results)) {
            throw new Horde_Exception_NotFound('Resource not found');
        }

        $class = 'Kronolith_Resource_' . $results['resource_type'];
        if (!class_exists($class)) {
            throw new Kronolith_Exception('Could not load the class definition for ' . $class);
        }

        return new $class($this->_fromDriver($results));
    }

    /**
     * Obtain the resource id associated with the given calendar uid.
     *
     * @param string $calendar  The calendar's uid.
     *
     * @return integer  The Kronolith_Resource id.
     * @throws Kronolith_Exception
     */
    public function getResourceIdByCalendar($calendar)
    {
        $query = 'SELECT resource_id FROM ' . $this->_params['table']
            . ' WHERE resource_calendar = ?';
        try {
            $result = $this->_db->selectValue($query, array($calendar));
        } catch (Horde_Db_Exception $e) {
            throw new Kronolith_Exception($e);
        }
        if (empty($result)) {
            throw new Horde_Exception_NotFound('Resource not found');
        }

        return $result;
    }

    /**
     * Determine if the provided calendar id represents a resource's calendar.
     *
     * @param string $calendar  The calendar identifier to check.
     *
     * @return boolean
     */
    public function isResourceCalendar($calendar)
    {
        $query = 'SELECT count(*) FROM ' . $this->_params['table']
            . ' WHERE resource_calendar = ?';
        try {
            return $this->_db->selectValue($query, array($calendar)) > 0;
        } catch (Horde_Db_Exception $e) {
            throw new Kronolith_Exception($e);
        }
    }

    /**
     * Return a list of Kronolith_Resources
     *
     * Right now, all users have Horde_Perms::READ, but only system admins have
     * Horde_Perms::EDIT | Horde_Perms::DELETE
     *
     * @param integer $perms   A Horde_Perms::* constant.
     * @param array $filter    A hash of field/values to filter on.
     * @param string $orderby  Field to order results by. Null for no ordering.
     *
     * @return an array of Kronolith_Resource objects.
     * @throws Kronolith_Exception
     */
    public function listResources($perms = Horde_Perms::READ, array $filter = array(), $orderby = null)
    {
        if (($perms & (Horde_Perms::EDIT | Horde_Perms::DELETE)) &&
            !$GLOBALS['registry']->isAdmin()) {
            return array();
        }

        $query = 'SELECT resource_id, resource_name, resource_calendar, resource_description,'
            . ' resource_response_type, resource_type, resource_members, resource_email FROM '
            . $this->_params['table'];
        if (count($filter)) {
            $clause = ' WHERE ';
            $i = 0;
            $c = count($filter);
            foreach (array_keys($filter) as $field) {
                $clause .= 'resource_' . $field . ' = ?' . (($i++ < ($c - 1)) ? ' AND ' : '');
            }
            $query .= $clause;
        }

        if (!empty($orderby)) {
            $query .= ' ORDER BY resource_' . $orderby;
        }

        try {
            $results = $this->_db->selectAll($query, $filter);
        } catch (Horde_Db_Exception $e) {
            throw new Kronolith_Exception($e);
        }
        $return = array();
        foreach ($results as $row) {
            $class = 'Kronolith_Resource_' . $row['resource_type'];
            $return[$row['resource_id']] = new $class($this->_fromDriver(array_merge(array('resource_id' => $row['resource_id']), $row)));
        }

        return $return;
    }

    /**
     * Obtain the group id for each group the specified resource is a member of.
     *
     * @param integer $resource_id  The resource id to check for.
     *
     * @return array  An array of group ids.
     * @throws Kronolith_Exception
     */
    public function getGroupMemberships($resource_id)
    {
        $groups = $this->listResources(Horde_Perms::READ, array('type' => Kronolith_Resource::TYPE_GROUP));
        $in = array();
        foreach ($groups as $group) {
            $members = $group->get('members');
            if (array_search($resource_id, $members) !== false) {
                $in[] = $group->getId();
            }
        }

        return $in;
    }

    /**
     * Converts a value from the driver's charset to the default
     * charset.
     *
     * @param mixed $value  A value to convert.
     *
     * @return mixed  The converted value.
     */
    public function convertFromDriver($value)
    {
        return Horde_String::convertCharset($value, $this->_params['charset'], 'UTF-8');
    }

    /**
     * Converts a value from the default charset to the driver's
     * charset.
     *
     * @param mixed $value  A value to convert.
     *
     * @return mixed  The converted value.
     */
    public function convertToDriver($value)
    {
        return Horde_String::convertCharset($value, 'UTF-8', $this->_params['charset']);
    }

    /**
     * Delete the resource calendar
     *
     * @param string $calendar  The calendar id.
     */
    public function _deleteResourceCalendar($calendar)
    {
        $this->open($calendar);
        $events = $this->listEvents(null, null, array('cover_dates' => false));
        foreach ($events as $dayevents) {
            foreach ($dayevents as $event) {
                $this->deleteEvent($event, true);
            }
        }
    }

    /**
     * Convert from driver keys and charset to Kronolith keys and charset.
     *
     * @param array $params  The key/values to convert.
     *
     * @return array  An array of converted values.
     */
    protected function _fromDriver(array $params)
    {
        $return = array();

        foreach ($params as $field => $value) {
            switch ($field) {
            case 'resource_description':
                $value = $this->_columns['resource_description']
                    ->binaryToString($value);
                // Fall through.
            case 'resource_name':
                $value = $this->convertFromDriver($value);
                break;
            case 'resource_members':
                $value = $this->_columns['resource_members']
                    ->binaryToString($value);
                $value = @unserialize($value);
                break;
            }

            $return[str_replace('resource_', '', $field)] = $value;
        }

        return $return;
    }

    /**
     * Helper function to update an existing event's tags to tagger storage.
     *
     * @param Kronolith_Event $event  The event to update
     */
    protected function _updateTags(Kronolith_Event $event)
    {
        // noop
    }

    /**
     * Helper function to add tags from a newly creted event to the tagger.
     *
     * @param Kronolith_Event $event  The event to save tags to storage for.
     */
    protected function _addTags(Kronolith_Event $event)
    {
        // noop
    }

    protected function _handleNotifications(Kronolith_Event $event, $action)
    {
        // noop
    }
}
