<?php
/**
 * TimeSheet - Events object
 *
 * @link http://www.egroupware.org
 * @author Ralf Becker <rb@egroupware.org>
 * @package timesheet
 * @copyright (c) 2022 by Ralf Becker <rb@egroupware.org>
 * @license https://opensource.org/licenses/gpl-license.php GPL v2+ - GNU General Public License Version 2 or higher
 */

namespace EGroupware\Timesheet;

use EGroupware\Api;

/**
 * Events object of the TimeSheet
 *
 * Uses eTemplate's Api\Storage as storage object (Table: egw_timesheet).
 */
class Events extends Api\Storage\Base
{
	const APP = 'timesheet';
	const TABLE = 'egw_timesheet_events';
	/**
	 * @var string[] name of timestamps to convert (not set automatic by setup_table/parent::__construct()!)
	 */
	public $timestamps = [
		'tse_timestamp', 'tse_time', 'tse_modified',
	];

	/**
	 * Bitfields for egw_timesheet_events.tse_type
	 */
	const OVERALL = 16;
	const START = 1;
	const STOP = 2;
	const PAUSE = 4;

	/**
	 * @var Events
	 */
	private static $instance;
	protected int $user;

	function __construct()
	{
		parent::__construct(self::APP,self::TABLE, null, '', true, 'object');

		if (!isset(self::$instance))
		{
			self::$instance = $this;
		}
		$this->user = $GLOBALS['egw_info']['user']['account_id'];
	}

	function __destruct()
	{
		if (self::$instance === $this)
		{
			self::$instance = null;
		}
	}

	protected static function getInstance()
	{
		if (!isset(self::$instance))
		{
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Record/persist timer events from UI
	 *
	 * @param array $state
	 * @return int tse_id of created event
	 * @throws Api\Exception
	 * @throws Api\Exception\WrongParameter
	 */
	public function ajax_event(array $state)
	{
		list($timer, $action) = explode('-', $state['action']);
		if (!in_array($timer, ['overall', 'specific']) || !in_array($action, ['start', 'stop', 'pause']))
		{
			throw new Api\Exception\WrongParameter("Invalid action '$state[action]'!");
		}
		if (empty($state['ts']))
		{
			throw new Api\Exception\WrongParameter("Invalid timestamp ('ts') '$state[ts]'!");
		}
		$type = ($timer === 'overall' ? self::OVERALL : 0) |
			($action === 'start' ? self::START : ($action === 'stop' ? self::STOP : self::PAUSE));

		$app = $id = $ts_id = null;
		if ($timer === 'specific' && !empty($state['specific']['app_id']))
		{
			list($app, $id) = explode('::', $state['specific']['app_id'], 2);
			if ($app === self::APP)
			{
				$ts_id = $id;
			}
		}

		$this->init();
		$this->save([
			'tse_timestamp' => new Api\DateTime(),
			'tse_time' => new Api\DateTime($state['ts']),
			'account_id' => $this->user,
			'tse_modifier' => $this->user,
			'tse_type' => $type,
			'tse_app'  => $app,
			'tse_app_id' => $id,
			'ts_id'    => $ts_id,
		]);

		// create timesheet for stopped working time
		if ($timer === 'overall' && $action === 'stop')
		{
			try {
				$minutes = $this->storeWorkingTime();
				Api\Json\Response::get()->message(lang('Working time of %1 hours stored',
					sprintf('%d:%02d', intdiv($minutes, 60), $minutes % 60)), 'success');
			}
			catch(\Exception $e) {
				Api\Json\Response::get()->message(lang('Error storing working time').': '.$e->getMessage(), 'error');
			}
		}
		// return (new) tse_id
		Api\Json\Response::get()->data($this->data['tse_id']);
	}

	/**
	 * Set app::id on an already started timer
	 *
	 * @param int $tse_id id of the running timer-event
	 * @param string $app_id "app::id" string
	 * @return void
	 * @throws Api\Db\Exception\InvalidSql
	 */
	public function ajax_updateAppId(int $tse_id, string $app_id)
	{
		if ($tse_id > 0 && !empty($app_id))
		{
			list($app, $id) = explode('::', $app_id);
			$this->db->update(self::TABLE, [
				'tse_app' => $app,
				'tse_app_id' => $id,
			], [
				'tse_id' => $tse_id,
				'account_id' => $this->user,
				'ts_id IS NULL',
			], __LINE__, __FILE__, self::APP);
		}
	}

	/**
	 * Set tse_time on an already started timer
	 *
	 * @param int $tse_id id of the running timer-event
	 * @param string $time
	 * @return void
	 * @throws Api\Db\Exception\InvalidSql
	 */
	public function ajax_updateTime(int $tse_id, string $time)
	{
		if ($tse_id > 0 && !empty($time) && ($event = $this->read($tse_id)))
		{
			$time = new Api\DateTime($time);
			$this->db->update(self::TABLE, [
				'tse_time' => $time,
			], [
				'tse_id' => $tse_id,
				'account_id' => $this->user,
			], __LINE__, __FILE__, self::APP);

			// if a stop event is overwritten, we need to adjust the timesheet duration and quantity
			if (!empty($event['ts_id']) && ($diff = round(($time->getTimestamp() - $event['tse_time']->getTimestamp()) / 60)))
			{
				$bo = new \timesheet_bo();
				if ($bo->read($event['ts_id']))
				{
					$bo->data['ts_duration'] += $diff;
					$bo->data['ts_quantity'] += $diff / 60;
					// set title, in case date(s) changed
					$events = self::get(['ts_id' => $event['ts_id']]);
					$bo->data['ts_title'] = self::workingTimeTitle($events);
					$bo->save();
				}
			}
		}
	}

	/**
	 * Generate working time title
	 *
	 * @param array $events
	 * @param Api\DateTime|null &$start on return start-time
	 * @return string
	 */
	protected static function workingTimeTitle(array $events, Api\DateTime &$start=null)
	{
		$start = array_shift($events)['tse_time'];
		$timespan = Api\DateTime::to($start, true);
		$last = array_pop($events);
		if ($timespan !== ($end = Api\DateTime::to($last['tse_time'], true)))
		{
			$timespan .= ' - '.$end;
		}
		return lang('Working time from %1', $timespan);
	}

	/**
	 * Store pending overall timer events as a working time timesheet
	 *
	 * @return int minutes
	 */
	public function storeWorkingTime()
	{
		if (!($events = self::getPending(true, $time, $paused)) || !$time)
		{
			throw new Api\Exception\AssertionFailed("No pending overall events!");
		}
		$ids = array_keys($events);
		$bo = new \timesheet_bo();
		// check if we already have a timesheet for the current period
		if (($period_ts = $bo->periodeWorkingTimesheet(reset($events)['tse_time'])))
		{
			$events = array_merge(self::get(['ts_id' => $period_ts['ts_id']], $period_total, $period_paused), $events);
			$time += $period_total;
			$paused += $period_paused;
		}
		$title = self::workingTimeTitle($events, $start);
		$bo->init($period_ts);
		$bo->save([
			'ts_title' => $title,
			'cat_id' => self::workingTimeCat(),
			'ts_start' => $start,
			'start_time' => Api\DateTime::server2user($start, 'H:s'),
			'end_time' => '',
			'ts_duration' => $minutes = round($time / 60),
			'ts_quantity' => $minutes / 60.0,
			'ts_paused' => round($paused / 60),
			'ts_owner' => $this->user,
		]);
		self::addToTimesheet($bo->data['ts_id'], $ids);

		return $minutes;
	}

	/**
	 * Name of session variable to not ask again if user denied starting working time
	 */
	const DONT_ASK_AGAIN_WORKING_TIME = 'dont-ask-again-working-time';

	/**
	 * Get state of timer
	 *
	 * @return array[]|void
	 */
	public static function timerState()
	{
		try {
			$state = [
				'overall' => [
					'offset' => 0,
					'start' => null,
					'paused' => false,
					'last' => null,
					'dont_ask' => Api\Cache::getSession(__CLASS__, self::DONT_ASK_AGAIN_WORKING_TIME),
				],
				'specific' => [
					'offset' => 0,
					'start' => null,
					'paused' => false,
					'last' => null,
				],
			];
			foreach(self::getInstance()->search('', false, 'tse_id', '', '', false, 'AND', false, [
				'ts_id' => null,
				'account_id' => self::getInstance()->user,
			]) as $row)
			{
				if ($row['tse_type'] & self::OVERALL)
				{
					self::evaluate($state['overall'], $row);
				}
				else
				{
					self::evaluate($state['specific'], $row);

					// if event is associated with an app:id, set it again
					if ($row['tse_app'] && $row['tse_app_id'])
					{
						$state['specific']['app_id'] = $row['tse_app'].'::'.$row['tse_app_id'];
					}
				}
			}
			// format start-times in UTZ as JavaScript Date() understands
			foreach($state as &$timer)
			{
				foreach(['start', 'started', 'last'] as $name)
				{
					if (isset($timer[$name]))
					{
						$timer[$name] = (new Api\DateTime($timer[$name], new \DateTimeZone('UTC')))->format(Api\DateTime::ET2);
					}
				}
			}
			// send timer configuration to client-side
			$config = Api\Config::read(self::APP);
			$state['disable'] = $config['disable_timer'] ?? [];

			return $state;
		}
		catch (\Exception $e) {
			_egw_log_exception($e);
		}
	}

	/**
	 * Remember for 18h or forever to not ask again to start working time
	 *
	 * @param ?bool $never true: never ask again, set preference, otherwise remember in session for 18h
	 * @return void
	 */
	static function ajax_dontAskAgainWorkingTime(bool $never=null)
	{
		if ($never)
		{
			$prefs = new Api\Preferences($GLOBALS['egw_info']['user']['account_id']);
			$prefs->read_repository();
			$prefs->user['timesheet']['workingtime_session'] = 'no';
			$prefs->save_repository();
		}
		else
		{
			Api\Cache::setSession(__CLASS__, self::DONT_ASK_AGAIN_WORKING_TIME, true, 18*3600);
		}
	}

	/**
	 * Evaluate events
	 *
	 * Returned time and offset/duration are always rounded to full minutes, independent of their actual unit!
	 * This is done, as we only show full minutes in the UI, and we want to avoid seconds in timestamps
	 * leading to unexpected results in the minutes display.
	 *
	 * @param array& $timer array with keys 'start', 'offset' and 'paused'
	 * @param array $row
	 * @return int? time in ms for stop or pause events, null for start
	 */
	protected static function evaluate(array &$timer, array $row)
	{
		// paused timer is started or stopped
		if ($timer['paused'] && !($row['tse_type'] & self::PAUSE))
		{
			$timer['was_paused'] = 60000 * round(($row['tse_time']->getTimestamp() - $timer['pause_started']->getTimestamp())/60);
		}
		else
		{
			unset($timer['was_paused']);
		}
		if ($row['tse_type'] & self::START)
		{
			$timer['start'] = $timer['started'] = $row['tse_time'];
			$timer['started_id'] = $row['tse_id'];
			$timer['paused'] = false;
		}
		elseif ($timer['start'])
		{
			$timer['offset'] += $time = 60000 * round(($row['tse_time']->getTimestamp() - $timer['start']->getTimestamp())/60);
			$timer['start'] = null;
			$timer['paused'] = ($row['tse_type'] & self::PAUSE) === self::PAUSE;
		}
		else    // stop of paused timer
		{
			$timer['paused'] = ($row['tse_type'] & self::PAUSE) === self::PAUSE;
		}
		if ($timer['paused'])
		{
			$timer['pause_started'] = $row['tse_time'];
		}
		$timer['last'] = $row['tse_time'];
		$timer['id'] = $row['tse_id'];
		return $time ?? null;
	}

	/**
	 * Get events of given ts_id or a filter
	 *
	 * Not stopped events-sequences are NOT returned (stopped sequences end with a stop event).
	 *
	 * @param int|array $filter
	 * @param ?int &$total=null on return time in seconds
	 * @param ?int &$paused on return paused time in seconds
	 * @return array[] tse_id => array pairs plus extra key sum (time-sum in seconds)
	 */
	public static function get($filter, ?int &$total=null, ?int &$paused=null)
	{
		if (!is_array($filter))
		{
			$filter = ['ts_id' => $filter];
		}
		$timer = $init_timer = [
			'start' => null,
			'offset' => 0,
			'paused' => false,
		];
		$total = $open = $paused = 0;
		$events = [];
		foreach(self::getInstance()->search('', false, 'tse_id', '', '',
			false, 'AND', false, $filter) as $row)
		{
			$time = self::evaluate($timer, $row);
			++$open;

			if ($row['tse_type'] & self::STOP)
			{
				$row['total'] = $total += $timer['offset'] / 1000;
				$timer = $init_timer;
				$open = 0;
			}
			elseif ($row['tse_type'] & self::PAUSE)
			{
				$row['total'] = $total + $timer['offset'] / 1000;
			}
			$row['time'] = $time / 1000;
			$row['paused'] = !empty($timer['was_paused']) ? $paused += $timer['was_paused']/1000 : null;
			$events[$row['tse_id']] = $row;
		}
		// remove open / unstopped timer events
		if ($open)
		{
			$events = array_slice($events, 0, -$open, true);
		}
		return $events;
	}

	/**
	 * Get pending (overall) events of (current) user
	 *
	 * Not stopped events-sequences are NOT returned (stopped sequences end with a stop event).
	 *
	 * @param bool $overall
	 * @param ?int &$time=null on return total time in seconds
	 * @param ?int &$paused=null on return total paused time in seconds
	 * @return array[] tse_id => array pairs
	 */
	public static function getPending($overall=false, ?int &$time=null, ?int &$paused=null)
	{
		return self::get([
			'ts_id' => null,
			'account_id' => self::getInstance()->user,
			($overall ? '' : 'NOT ').'(tse_type & '.self::OVERALL.')',
		], $time, $paused);
	}

	/**
	 * Add given events to timesheet / set ts_id parameter
	 *
	 * @param int $ts_id
	 * @param int[] $events array of tse_id's
	 * @return Api\ADORecordSet|false|int
	 * @throws Api\Db\Exception\InvalidSql
	 */
	public static function addToTimesheet(int $ts_id, array $events)
	{
		return self::getInstance()->db->update(self::TABLE, ['ts_id' => $ts_id], ['tse_id' => $events], __LINE__, __FILE__, self::APP);
	}

	/**
	 * Register site config validation hooks
	 */
	public static function config_validate()
	{
		$GLOBALS['egw_info']['server']['found_validation_hook'] = [
			'final_validation' => self::class.'::final_validation',
		];
	}

	/**
	 * Final validation called after storing the config
	 *
	 * @param array $config
	 * @param Api\Config $c
	 */
	public static function final_validation($config, Api\Config $c)
	{
		// check if category for 'working time' is configured, otherwise create and store it
		if ($config['working_time_cat'] === '')
		{
			$c->config_data['working_time_cat'] = self::workingTimeCat();
		}
	}

	/**
	 * Get working time category, create it if not yet configured
	 *
	 * @return int
	 */
	public static function workingTimeCat()
	{
		$config = Api\Config::read(self::APP);
		if (empty($config['working_time_cat']) || !Api\Categories::read($config['working_time_cat']))
		{
			$cats = new Api\Categories(Api\Categories::GLOBAL_ACCOUNT, Api\Categories::GLOBAL_APPNAME);
			Api\Config::save_value('working_time_cat', $config['working_time_cat'] = $cats->add([
				'name' => lang('Working time'),
				//'data' => ['color' => '#ffb6c1'],
				'description' => lang('Created by TimeSheet configuration'),
			]), self::APP);
		}
		return $config['working_time_cat'];
	}
}