<?php
/**
 * Nag driver classes for the Kolab IMAP server.
 *
 * Copyright 2004-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  Gunnar Wrobel <wrobel@pardus.de>
 * @author  Thomas Jarosch <thomas.jarosch@intra2net.com>
 * @author  Stuart Binge <omicron@mighty.co.za>
 * @package Nag
 */
class Nag_Driver_Kolab extends Nag_Driver
{
    /**
     * The Kolab_Storage backend.
     *
     * @var Horde_Kolab_Storage
     */
    protected $_kolab;

    /**
     * The current tasklist.
     *
     * @var Horde_Kolab_Storage_Data
     */
    protected $_data;

    /**
     * Constructs a new Kolab storage object.
     *
     * @param string $tasklist  The tasklist to load.
     * @param array $params     A hash containing connection parameters.
     */
    public function __construct($tasklist, $params = array())
    {
        $this->_tasklist = $tasklist;
        $this->_kolab = $params['kolab'];
    }

    /**
     * Return the Kolab data handler for the current tasklist.
     *
     * @return Horde_Kolab_Storage_Data The data handler.
     */
    protected function _getData()
    {
        if (empty($this->_tasklist)) {
            throw new Nag_Exception(
                'The tasklist has been left undefined but is required!'
            );
        }
        if ($this->_data === null) {
            $this->_data = $this->_getDataForTasklist($this->_tasklist);
        }
        return $this->_data;
    }

    /**
     * Return the Kolab data handler for the specified tasklist.
     *
     * @param string $tasklist The tasklist name.
     *
     * @return Horde_Kolab_Storage_Date The data handler.
     */
    protected function _getDataForTasklist($tasklist)
    {
        return $this->_kolab->getData(
            $GLOBALS['nag_shares']->getShare($tasklist)->get('folder'),
            'task'
        );
    }

    /**
     * Retrieves one task from the backend.
     *
     * @param string $taskId  The id of the task to retrieve.
     *
     * @return Nag_Task  A Nag_Task object.
     * @throws Horde_Exception_NotFound
     * @throws Nag_Exception
     */
    public function get($taskId)
    {
        $uid = Horde_Url::uriB64Decode($taskId);
        if (!$this->_getData()->objectIdExists($uid)) {
            throw new Horde_Exception_NotFound();
        }
        $task = $this->_getData()->getObject($uid)->getData();
        $nag_task = $this->_buildTask($task);
        $nag_task['tasklist_id'] = $this->_tasklist;
        return new Nag_Task($this, $nag_task);
    }

    /**
     * Detect if a string is already Horde_Url::uriB64Encode()'ed or not.
     *
     * @param string $s  The string to test.
     *
     * @return  boolean  True if $s was base64 encoded.
     * @todo  This will be removed in Horde 6.
     */
    public function _isBase64EncodedUid($s)
    {
         // Redo stuff possibly done by Horde_Url::uriB64Encode()
        $data = str_replace(array('-', '_'), array('+', '/'), $s);
        $mod4 = strlen($data) % 4;
        if ($mod4) {
            $data .= substr('====', $mod4);
        }
        if (!preg_match('/^[a-zA-Z0-9\/\r\n+]*={0,2}$/', $data)) {
            return false;
        }

         // Decode the string in strict mode and check the results
        $decoded = base64_decode($data, true);
        if ($decoded === false) {
            return false;
        }

        // Check if the decoded string resembles a uid hash...
        if (!preg_match('/^[a-zA-Z0-9-]+$/', $decoded)) {
            return false;
        }

         // Encode the string again for a final sanity check.
        if (base64_encode($decoded) != $data) {
            return false;
        }

        return true;
    }

    /**
     * Build a task based a data array
     *
     * @param array  $task     The data for the task
     *
     * @return array  The converted data array representing the task
     */
    protected function _buildTask($task)
    {
         // This decoding of parent ID hashes is required because of a
         // previous bug in Nag_Driver_Kolab.
         // See Bug: 14197
        $parent_uid = $task['parent'];
        if (!empty($parent_uid) && $this->_isBase64EncodedUid($parent_uid)) {
            $parent_uid = Horde_Url::uriB64Decode($parent_uid);
        }

        $result = array(
            'task_id' => Horde_Url::uriB64Encode($task['uid']),
            'uid' => $task['uid'],
            'name' => $task['summary'],
            'desc' => $task['body'],
            'priority' => $task['priority'],
            // parent is keyed to internal id, not UID.
            'parent' => Horde_Url::uriB64Encode($parent_uid),
            'alarm' => $task['alarm'],
            'completed' => !empty($task['completed']),
            'completed_date' => $task['completed_date'],
            'private' => $task['sensitivity'] != 'public',
            'owner' => $GLOBALS['nag_shares']->getShare($this->_tasklist)->get('owner'),
        );

        if (isset($task['categories'])) {
            $result['internaltags'] = $task['categories'];
        }

        if (!empty($task['start-date'])) {
            $result['start'] = $task['start-date']->format('U');
        }
        if (!empty($task['due-date'])) {
            $result['due'] = $task['due-date']->format('U');
        }
        if (!empty($task['creation-date'])) {
            $result['created'] = new Horde_Date($task['creation-date']);
        }
        if (!empty($task['last-modification-date'])) {
            $result['modified'] = new Horde_Date($task['last-modification-date']);
        }

        if (isset($task['recurrence']) && isset($task['due-date'])) {
            $recurrence = new Horde_Date_Recurrence($task['due-date']);
            $recurrence->fromKolab($task['recurrence']);
            $result['recurrence'] = $recurrence;
        }

        if (isset($task['organizer'])) {
            $result['assignee'] = $task['organizer']['smtp-address'];
        }

        if (isset($task['horde-estimate'])) {
            $result['estimate'] = $task['horde-estimate'];
        }
        if (isset($task['horde-alarm-methods'])) {
            $result['methods'] = @unserialize($task['horde-alarm-methods']);
        }

        return $result;
    }


    /**
     * Retrieves one task from the database by UID.
     *
     * @param string $uid  The UID of the task to retrieve.
     *
     * @return array  The array of task attributes.
     */
    public function getByUID($uid)
    {
        foreach (array_keys(Nag::listTasklists(false, Horde_Perms::READ, false)) as $tasklist) {
            $this->_tasklist = $tasklist;
            try {
                return $this->get(Horde_Url::uriB64Encode($uid));
            } catch (Horde_Exception_NotFound $e) {
            }
        }
        throw new Horde_Exception_NotFound();
    }

    /**
     * Adds a task to the backend storage.
     *
     * @param array $task  A hash with the following possible properties:
     *     - name: (string) The name (short) of the task.
     *     - desc: (string) The description (long) of the task.
     *     - start: (OPTIONAL, integer) The start date of the task.
     *     - due: (OPTIONAL, integer) The due date of the task.
     *     - priority: (OPTIONAL, integer) The priority of the task.
     *     - estimate: (OPTIONAL, float) The estimated time to complete the
     *                 task.
     *     - completed: (OPTIONAL, integer) The completion state of the task.
     *     - tags: (OPTIONAL, string) The tags of the task.
     *     - alarm: (OPTIONAL, integer) The alarm associated with the task.
     *     - methods: (OPTIONAL, array) The overridden alarm notification
     *                methods.
     *     - uid: (OPTIONAL, string) A Unique Identifier for the task.
     *     - parent: (OPTIONAL, string) The parent task.
     *     - private: (OPTIONAL, boolean) Whether the task is private.
     *     - owner: (OPTIONAL, string) The owner of the event.
     *     - assignee: (OPTIONAL, string) The assignee of the event.
     *     - recurrence: (OPTIONAL, Horde_Date_Recurrence|array) Recurrence
     *                   information.
     *
     * @return string  The Nag ID of the new task.
     * @throws Nag_Exception
     */
    protected function _add(array $task)
    {
        $object = $this->_getObject($task);
        $object['uid'] = $this->_getData()->generateUid();
        try {
            $this->_getData()->create($object);
            $this->_addTags($task);
        } catch (Horde_Kolab_Storage_Exception $e) {
            throw new Nag_Exception($e);
        }
        return Horde_Url::uriB64Encode($object['uid']);
    }

    /**
     * Modifies an existing task.
     *
     * @param string $taskId  The task to modify.
     * @param array $properties  A hash with the following possible properties:
     *     - id: (string) The task to modify.
     *     - name: (string) The name (short) of the task.
     *     - desc: (string) The description (long) of the task.
     *     - start: (OPTIONAL, integer) The start date of the task.
     *     - due: (OPTIONAL, integer) The due date of the task.
     *     - priority: (OPTIONAL, integer) The priority of the task.
     *     - estimate: (OPTIONAL, float) The estimated time to complete the
     *                 task.
     *     - completed: (OPTIONAL, integer) The completion state of the task.
     *     - tags: (OPTIONAL, string) The tags of the task.
     *     - alarm: (OPTIONAL, integer) The alarm associated with the task.
     *     - methods: (OPTIONAL, array) The overridden alarm notification
     *                methods.
     *     - uid: (OPTIONAL, string) A Unique Identifier for the task.
     *     - parent: (OPTIONAL, string) The parent task.
     *     - private: (OPTIONAL, boolean) Whether the task is private.
     *     - owner: (OPTIONAL, string) The owner of the event.
     *     - assignee: (OPTIONAL, string) The assignee of the event.
     *     - completed_date: (OPTIONAL, integer) The task's completion date.
     *     - recurrence: (OPTIONAL, Horde_Date_Recurrence|array) Recurrence
     *                   information.
     */
    protected function _modify($taskId, array $task)
    {
        $object = $this->_getObject($task);
        $object['uid'] = Horde_Url::uriB64Decode($taskId);
        try {
            $this->_getData()->modify($object);
            $this->_updateTags($task);
        } catch (Horde_Kolab_Storage_Exception $e) {
            throw new Nag_Exception($e);
        }
    }

    /**
     * Retrieve the Kolab object representations for the task.
     *
     * @param array $task  A hash with the following possible properties:
     *     - name: (string) The name (short) of the task.
     *     - desc: (string) The description (long) of the task.
     *     - start: (OPTIONAL, integer) The start date of the task.
     *     - due: (OPTIONAL, integer) The due date of the task.
     *     - priority: (OPTIONAL, integer) The priority of the task.
     *     - estimate: (OPTIONAL, float) The estimated time to complete the
     *                 task.
     *     - completed: (OPTIONAL, integer) The completion state of the task.
     *     - tags: (OPTIONAL, string) The tags of the task.
     *     - alarm: (OPTIONAL, integer) The alarm associated with the task.
     *     - methods: (OPTIONAL, array) The overridden alarm notification
     *                methods.
     *     - uid: (OPTIONAL, string) A Unique Identifier for the task.
     *     - parent: (OPTIONAL, string) The parent task.
     *     - private: (OPTIONAL, boolean) Whether the task is private.
     *     - owner: (OPTIONAL, string) The owner of the event.
     *     - assignee: (OPTIONAL, string) The assignee of the event.
     *     - completed_date: (OPTIONAL, integer) The task's completion date.
     *     - recurrence: (OPTIONAL, Horde_Date_Recurrence|array) Recurrence
     *                   information.
     *
     * @return array The Kolab object.
     */
    protected function _getObject($task)
    {
        $object = array(
            'summary' => $task['name'],
            'body' => $task['desc'],
            //@todo: Match Horde/Kolab priority values
            'priority' => $task['priority'],
            // Kolab's parent field is UID, Nag's parent is id.
            'parent' => Horde_Url::uriB64Decode($task['parent']),
        );
        if (!empty($task['start'])) {
            $object['start-date'] = new DateTime('@' . $task['start']);
        }
        if (!empty($task['due'])) {
            $object['due-date'] = new DateTime('@' . $task['due']);
        }
        if ($task['recurrence']) {
            $object['recurrence'] = $task['recurrence']->toKolab();
        }
        if ($task['completed']) {
            $object['completed'] = 100;
            $object['status'] = 'completed';
        } else {
            $object['completed'] = 0;
            $object['status'] = 'not-started';
        }
        if ($task['alarm'] !== 0) {
            $object['alarm'] = (int)$task['alarm'];
        }
        if ($task['private']) {
            $object['sensitivity'] = 'private';
        } else {
            $object['sensitivity'] = 'public';
        }
        if (isset($task['completed_date'])) {
            $object['completed_date'] = $task['completed_date'];
        }
        if ($task['estimate'] !== 0.0) {
            $object['horde-estimate'] = number_format((float)$task['estimate'], 2);
        }
        if ($task['methods'] !== null) {
            $object['horde-alarm-methods'] = serialize($task['methods']);
        }
        if ($task['owner'] !== null) {
            //@todo: Display name
            $object['creator'] = array(
                'smtp-address' => $task['owner'],
            );
        }
        if ($task['assignee'] !== null) {
            //@todo: Display name
            $object['organizer'] = array(
                'smtp-address' => $task['assignee'],
            );
        }
        if ($task['tags'] && !is_array($task['tags'])) {
            $object['categories'] = $GLOBALS['injector']->getInstance('Nag_Tagger')->split($task['tags']);
            usort($object['categories'], 'strcoll');
        }
        return $object;
    }

    /**
     * Moves a task to a different tasklist.
     *
     * @param string $taskId       The task to move.
     * @param string $newTasklist  The new tasklist.
     */
    protected function _move($taskId, $newTasklist)
    {
        $this->_getData()->move(
            Horde_Url::uriB64Decode($taskId),
            $GLOBALS['nag_shares']->getShare($newTasklist)->get('folder')
        );
        $this->_getDataForTasklist($newTasklist)->synchronize();
    }

    /**
     * Deletes a task from the backend.
     *
     * @param string $taskId  The task to delete.
     */
    protected function _delete($taskId)
    {
        $this->_getData()->delete(Horde_Url::uriB64Decode($taskId));
    }

    /**
     * Deletes all tasks from the backend.
     *
     * @return array  An array of ids that have been deleted.
     */
    protected function _deleteAll()
    {
        $this->retrieve();
        $this->tasks->reset();
        $ids = array();
        while ($task = $this->tasks->each()) {
            $ids[] = $task->id;
        }
        $this->_getData()->deleteAll();

        return $ids;
    }

    /**
     * Retrieves tasks from the Kolab server.
     *
     * @param integer $completed  Which tasks to retrieve (1 = all tasks,
     *                            0 = incomplete tasks, 2 = complete tasks).
     */
    public function retrieve($completed = Nag::VIEW_ALL)
    {
        $dict = array();
        $this->tasks = new Nag_Task();

        $task_list = $this->_getData()->getObjects();
        if (empty($task_list)) {
            return;
        }

        foreach ($task_list as $task) {
            $tid = Horde_Url::uriB64Encode($task['uid']);
            $nag_task = $this->_buildTask($task);
            $nag_task['tasklist_id'] = $this->_tasklist;
            $t = new Nag_Task($this, $nag_task);
            $complete = $t->completed;
            if (empty($t->start)) {
                $start = null;
            } else {
                $start = $t->start;
            }

            switch ($completed) {
            case Nag::VIEW_INCOMPLETE:
                if ($complete) {
                    continue 2;
                }
                if ($start && $t->recurs() &&
                    ($completions = $t->recurrence->getCompletions())) {
                    sort($completions);
                    list($year, $month, $mday) = sscanf(
                        end($completions),
                        '%04d%02d%02d'
                    );
                    $lastCompletion = new Horde_Date($year, $month, $mday);
                    $recurrence = clone $t->recurrence;
                    $recurrence->start = new Horde_Date($start);
                    $start = $recurrence
                        ->nextRecurrence($lastCompletion)
                        ->timestamp();
                    if ($start > $_SERVER['REQUEST_TIME']) {
                        continue 2;
                    }
                }
                break;
            case Nag::VIEW_COMPLETE:
                if (!$complete) {
                    continue 2;
                }
                break;
            case Nag::VIEW_FUTURE:
                if ($complete || $start == 0) {
                    continue 2;
                }
                if ($start && $t->recurs() &&
                    ($completions = $t->recurrence->getCompletions())) {
                    sort($completions);
                    list($year, $month, $mday) = sscanf(
                        end($completions),
                        '%04d%02d%02d'
                    );
                    $lastCompletion = new Horde_Date($year, $month, $mday);
                    $recurrence = clone $t->recurrence;
                    $recurrence->start = new Horde_Date($start);
                    $start = $recurrence
                        ->nextRecurrence($lastCompletion)
                        ->timestamp();
                    if ($start < $_SERVER['REQUEST_TIME']) {
                        continue 2;
                    }
                }
                break;
            case Nag::VIEW_FUTURE_INCOMPLETE:
                if ($complete) {
                    continue 2;
                }
                break;
            }

            if (empty($t->parent_id)) {
                $this->tasks->add($t);
            } else {
                $dict[$tid] = $t;
            }
        }

        /* Build a tree from the subtasks. */
        foreach (array_keys($dict) as $key) {
            $task = $this->tasks->get($dict[$key]->parent_id);
            if ($task) {
                $task->add($dict[$key]);
            } elseif (isset($dict[$dict[$key]->parent_id])) {
                $dict[$dict[$key]->parent_id]->add($dict[$key]);
            } else {
                $this->tasks->add($dict[$key]);
            }
        }
    }

    /**
     * Lists all alarms near $date.
     *
     * @param integer $date  The unix epoch time to check for alarms.
     *
     * @return array  An array of Nag_Task objects that have alarms that match.
     */
    public function listAlarms($date)
    {
        $task_list = $this->_getData()->getObjects();
        if (empty($task_list)) {
            return array();
        }

        $tasks = array();
        foreach ($task_list as $task) {
            $t = new Nag_Task($this, $this->_buildTask($task));
            if ($t->alarm && $t->due &&
                $t->due - $t->alarm * 60 < $date) {
                $tasks[] = $t;
            }
        }

        return $tasks;
    }

    /**
     * Retrieves sub-tasks from the database.
     *
     * @param string $parentId          The parent id for the sub-tasks to
     *                                  retrieve.
     * @param boolean $include_history  Include created/modified info? Not
     *                                  currently honored.
     *
     * @return array  List of sub-tasks.
     * @throws Nag_Exception
     */
    public function getChildren($parentId, $include_history = true)
    {
        $task_list = $this->_getData()->getObjects();
        if (empty($task_list)) {
            return array();
        }

        $tasks = array();

        foreach ($task_list as $task) {
            if (Horde_Url::uriB64Encode($task['parent']) != $parentId) {
                continue;
            }
            $t = new Nag_Task($this, $this->_buildTask($task));
            $children = $this->getChildren($t->id);
            $t->mergeChildren($children);
            $tasks[] = $t;
        }

        return $tasks;
    }
}
