<?php
/**
 * EGroupware Timesheet: REST API
 *
 * @link https://www.egroupware.org
 * @package timesheet
 * @author Ralf Becker <rb@egroupware.org>
 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
 */

namespace EGroupware\Timesheet;

use EGroupware\Api;

/**
 * REST API for Timesheet
 */
class ApiHandler extends Api\CalDAV\Handler
{
	/**
	 * @var \timesheet_bo
	 */
	protected \timesheet_bo $bo;

	/**
	 * Extension to append to url/path
	 *
	 * @var string
	 */
	static $path_extension = '';

	/**
	 * Constructor
	 *
	 * @param string $app 'calendar', 'addressbook' or 'infolog'
	 * @param Api\CalDAV $caldav calling class
	 */
	function __construct($app, Api\CalDAV $caldav)
	{
		parent::__construct('timesheet', $caldav);
		self::$path_extension = '';

		$this->bo = new \timesheet_bo();
	}

	/**
	 * Options for json_encode of responses
	 */
	const JSON_RESPONSE_OPTIONS = JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR;

	/**
	 * Handle propfind in the timesheet folder / get request on the collection itself
	 *
	 * @param string $path
	 * @param array &$options
	 * @param array &$files
	 * @param int $user account_id
	 * @param string $id =''
	 * @return mixed boolean true on success, false on failure or string with http status (eg. '404 Not Found')
	 */
	function propfind($path,&$options,&$files,$user,$id='')
	{
		$filter = [
			'ts_owner' => $user ?: null,
		];

		// process REPORT filters or multiget href's
		$nresults = null;
		if (($id || $options['root']['name'] != 'propfind') && !$this->_report_filters($options,$filter,$id, $nresults))
		{
			return false;
		}
		if ($id) $path = dirname($path).'/';	// carddav_name gets added anyway in the callback

		if ($this->debug) error_log(__METHOD__."($path,".array2string($options).",,$user,$id) filter=".array2string($filter));

		// rfc 6578 sync-collection report: filter for sync-token is already set in _report_filters
		if ($options['root']['name'] === 'sync-collection')
		{
			// callback to query sync-token, after propfind_callbacks / iterator is run and
			// stored max. modification-time in $this->sync_collection_token
			$files['sync-token'] = array($this, 'get_sync_collection_token');
			$files['sync-token-params'] = array($path, $user);

			$this->sync_collection_token = $this->more_results = null;

			$filter['order'] = 'ts_modified ASC';	// return oldest modifications first
			$filter['sync-collection'] = true;
		}

		if (isset($nresults) && $options['root']['name'] === 'sync-collection')
		{
			$files['files'] = $this->propfind_generator($path, $filter, $files['files'], (int)$nresults);
		}
		else
		{
			// return iterator, calling ourselves to return result in chunks
			$files['files'] = $this->propfind_generator($path,$filter, $files['files']);
		}
		return true;
	}

	/**
	 * Query ctag for infolog
	 *
	 * @return string
	 */
	public function getctag($path,$user)
	{
		return $this->bo->getctag($user);
	}

	/**
	 * Chunk-size for DB queries of profind_generator
	 */
	const CHUNK_SIZE = 500;

	/**
	 * Generator for propfind with ability to skip reporting not found ids
	 *
	 * @param string $path
	 * @param array& $filter
	 * @param array $extra extra resources like the collection itself
	 * @param int|null $nresults option limit of number of results to report
	 * @param boolean $report_not_found_multiget_ids=true
	 * @return Generator<array with values for keys path and props>
	 */
	function propfind_generator($path, array &$filter, array $extra=[], $nresults=null, $report_not_found_multiget_ids=true)
	{
		//error_log(__METHOD__."('$path', ".array2string($filter).", ".array2string($start).", $report_not_found_multiget_ids)");
		$starttime = microtime(true);

		// yield extra resources like the root itself
		$yielded = 0;
		foreach($extra as $resource)
		{
			if (++$yielded && isset($nresults) && $yielded > $nresults)
			{
				$this->sync_collection_token = Api\DateTime::user2server($resource['modified'], 'ts')-1;
				$this->more_results = true;
				return;
			}
			yield $resource;
		}

		if (!empty($filter['order']))
		{
			$order = $filter['order'];
			unset($filter['order']);
		}
		else
		{
			$order = 'egw_timesheet.ts_id';
		}
		// detect sync-collection report
		$sync_collection_report = $filter['sync-collection'];
		unset($filter['sync-collection']);

		// stop output buffering switched on to log the response, if we should return more than 200 entries
		if (!empty($this->requested_multiget_ids) && ob_get_level() && count($this->requested_multiget_ids) > 200)
		{
			$this->caldav->log("### ".count($this->requested_multiget_ids)." resources requested in multiget REPORT --> turning logging off to allow streaming of the response");
			ob_end_flush();
		}

		$search = $filter['search'] ?? [];
		unset($filter['search']);
		[$sync_token, $sync_token_offset] = $filter['sync_token_offset'] ?? [0, 0];
		unset($filter['sync_token_offset']);
		$inital_sync_token_offset = $sync_token_offset;
		for($chunk=0; ($timesheets =& $this->bo->search($search, '*', $order, '', '', False, 'AND',
			[$inital_sync_token_offset+$chunk*self::CHUNK_SIZE, $nresults ?: self::CHUNK_SIZE], $filter)); ++$chunk)
		{
			// read custom-fields
			if ($this->bo->customfields)
			{
				$id2keys = array();
				foreach($timesheets as $key => &$timesheet)
				{
					$id2keys[$timesheet['ts_id']] = $key;
				}
				if (($cfs = $this->bo->read_customfields(array_keys($id2keys))))
				{
					foreach($cfs as $id => $data)
					{
						$timesheets[$id2keys[$id]] += $data;
					}
				}
			}
			foreach($timesheets as &$timesheet)
			{
				$content = JsTimesheet::JsTimesheet($timesheet, false);
				$timesheet = Api\Db::strip_array_keys($timesheet, 'ts_');

				if ($sync_token != ($modified=Api\DateTime::user2server($timesheet['modified'], 'ts')))
				{
					$sync_token = $modified;
					$sync_token_offset = 0;
				}
				$sync_token_offset++;

				// remove timesheet from requested multiget ids, to be able to report not found urls
				if (!empty($this->requested_multiget_ids) && ($k = array_search($timesheet[self::$path_attr], $this->requested_multiget_ids)) !== false)
				{
					unset($this->requested_multiget_ids[$k]);
				}
				// sync-collection report: deleted entry need to be reported without properties
				if ($timesheet['status'] == \timesheet_bo::DELETED_STATUS)
				{
					yield ['path' => $path.urldecode($this->get_path($timesheet))];
					continue;
				}
				$props = [
					'getcontenttype' => Api\CalDAV::mkprop('getcontenttype', 'application/json'),
					'getlastmodified' => Api\DateTime::user2server($timesheet['modified'], 'utc'),
					'displayname' => $timesheet['title'],
					'getcontentlength' => bytes(is_array($content) ? Api\CalDAV::json_encode(json_encode($content)) : $content),
					'data' => Api\CalDAV::mkprop('data', Api\CalDAV::isJSON() || !is_array($content) ? $content : Api\CalDAV::json_encode($content)),
				];
				yield $this->add_resource($path, $timesheet, $props);

				if (++$yielded && isset($nresults) && $yielded >= $nresults)
				{
					break 2;
				}
			}
			if ($this->bo->total <= $yielded+$inital_sync_token_offset)
			{
				break;
			}
		}
		// sync-collection report --> return modified of last timesheet as sync-token
		if ($sync_collection_report)
		{
			$this->sync_collection_token = $sync_token.'_'.$sync_token_offset;
			if ($this->bo->total > $yielded+$inital_sync_token_offset)
			{
				$this->more_results = true;
			}
		}

		// report not found multiget urls
		if ($report_not_found_multiget_ids && !empty($this->requested_multiget_ids))
		{
			foreach($this->requested_multiget_ids as $id)
			{
				if (++$yielded && isset($nresults) && $yielded > $nresults)
				{
					--$this->sync_collection_token;
					$this->more_results = true;
					return;
				}
				yield ['path' => $path.$id.self::$path_extension];
			}
		}

		if ($this->debug)
		{
			error_log(__METHOD__."($path, filter=".json_encode($filter).', extra='.json_encode($extra).
				", nresults=$nresults, report_not_found=$report_not_found_multiget_ids) took ".
				(microtime(true) - $starttime)." to return $yielded resources");
		}
	}

	/**
	 * Process filter GET parameter:
	 * - filter[<json-attribute-name>]=<value>
	 * - filter[%23<custom-field-name]=<value>
	 * - filter[search]=<pattern> with string pattern like for search in the UI
	 * - filter[search][%23<custom-field-name]=<value>
	 * - filter[search][<db-column>]=<value>
	 *
	 * @param array $filter
	 * @return array
	 */
	protected function filter2col_filter(array $filter)
	{
		$cols = [];
		foreach($filter as $name => $value)
		{
			switch($name)
			{
				case 'search':
					$cols = array_merge($cols, $this->bo->search2criteria($value));
					break;
				case 'category':
				case 'pricelist':
					$cols[$name === 'pricelist' ? 'pl_id' : 'cat_id'] = $value;
					break;
				case 'status':
					$value = array_map(function ($val) use ($value)
					{
						if (!is_numeric($val) || (string)(int)$val !== $val)
						{
							$val = array_search($val, $this->bo->status_labels, true);
						}
						elseif (isset($this->status_labels[$val]))
						{
							$val = (int)$val;
						}
						else
						{
							$val = false;
						}
						if ($val === false)
						{
							throw new Api\CalDAV\JsParseException("Invalid status filter value ".json_encode($value));
						}
						return (int)$val;
					}, (array)$value);
					$cols['ts_status'] = count($value) <= 1 ? array_pop($value) : $value;
					break;
				case 'linked':
					if (!preg_match('/^([a-z_]+):(\d+)$/i', $filter['linked'], $matches) ||
						!isset($GLOBALS['egw_info']['user']['apps'][$matches[1]]) ||
						(int)$matches[2] <= 0)
					{
						throw new Api\Exception("Invalid linked-filter '$value', should be '<app-name>:<nummeric-ID>'!", 400);
					}
					$cols['ts_id'] = Api\Link::get_links($matches[1], $matches[2], 'timesheet');
					if (!$cols['ts_id']) $cols['ts_id'] = [0];  // to return nothing and not all timesheets
					break;
				default:
					if ($name[0] === '#')
					{
						$cols[$name] = $value;
					}
					else
					{
						$cols['ts_'.$name] = $value;
					}
					break;
			}
		}
		return $cols;
	}

	/**
	 * Process the filters from the CalDAV REPORT request
	 *
	 * @param array $options
	 * @param array &$filters
	 * @param string $id
	 * @param int &$nresult on return limit for number or results or unchanged/null
	 * @return boolean true if filter could be processed
	 */
	function _report_filters($options, &$filters, $id, &$nresults)
	{
		// in case of JSON/REST API pass filters to report
		if (Api\CalDAV::isJSON() && !empty($options['filters']) && is_array($options['filters']))
		{
			$filters = $this->filter2col_filter($options['filters']) + $filters;    // + to allow overwriting default owner filter (BO ensures ACL!)
		}
		elseif (!empty($options['filters']))
		{
			/* Example of a complex filter used by Mac Addressbook
			  <B:filter test="anyof">
			    <B:prop-filter name="FN" test="allof">
			      <B:text-match collation="i;unicode-casemap" match-type="contains">becker</B:text-match>
			      <B:text-match collation="i;unicode-casemap" match-type="contains">ralf</B:text-match>
			    </B:prop-filter>
			    <B:prop-filter name="EMAIL" test="allof">
			      <B:text-match collation="i;unicode-casemap" match-type="contains">becker</B:text-match>
			      <B:text-match collation="i;unicode-casemap" match-type="contains">ralf</B:text-match>
			    </B:prop-filter>
			    <B:prop-filter name="NICKNAME" test="allof">
			      <B:text-match collation="i;unicode-casemap" match-type="contains">becker</B:text-match>
			      <B:text-match collation="i;unicode-casemap" match-type="contains">ralf</B:text-match>
			    </B:prop-filter>
			  </B:filter>
			*/
			$filter_test = isset($options['filters']['attrs']) && isset($options['filters']['attrs']['test']) ?
				$options['filters']['attrs']['test'] : 'anyof';
			$prop_filters = array();

			$matches = $prop_test = $column = null;
			foreach($options['filters'] as $n => $filter)
			{
				if (!is_int($n)) continue;	// eg. attributes of filter xml element

				switch((string)$filter['name'])
				{
					case 'param-filter':
						$this->caldav->log(__METHOD__."(...) param-filter='{$filter['attrs']['name']}' not (yet) implemented!");
						break;
					case 'prop-filter':	// can be multiple prop-filter, see example
						if ($matches) $prop_filters[] = implode($prop_test=='allof'?' AND ':' OR ',$matches);
						$matches = array();
						$prop_filter = strtoupper($filter['attrs']['name']);
						$prop_test = isset($filter['attrs']['test']) ? $filter['attrs']['test'] : 'anyof';
						if ($this->debug > 1) error_log(__METHOD__."(...) prop-filter='$prop_filter', test='$prop_test'");
						break;
					case 'is-not-defined':
						$matches[] = '('.$column."='' OR ".$column.' IS NULL)';
						break;
					case 'text-match':	// prop-filter can have multiple text-match, see example
						if (!isset($this->filter_prop2cal[$prop_filter]))	// eg. not existing NICKNAME in EGroupware
						{
							if ($this->debug || $prop_filter != 'NICKNAME') error_log(__METHOD__."(...) text-match: $prop_filter {$filter['attrs']['match-type']} '{$filter['data']}' unknown property '$prop_filter' --> ignored");
							$column = false;	// to ignore following data too
						}
						else
						{
							switch($filter['attrs']['collation'])	// todo: which other collations allowed, we are always unicode
							{
								case 'i;unicode-casemap':
								default:
									$comp = ' '.$GLOBALS['egw']->db->capabilities[Api\Db::CAPABILITY_CASE_INSENSITIV_LIKE].' ';
									break;
							}
							$column = $this->filter_prop2cal[strtoupper($prop_filter)];
							if (strpos($column, '_') === false) $column = 'ts_'.$column;
							if (!isset($filters['order'])) $filters['order'] = $column;
							$match_type = $filter['attrs']['match-type'];
							$negate_condition = isset($filter['attrs']['negate-condition']) && $filter['attrs']['negate-condition'] == 'yes';
						}
						break;
					case '':	// data of text-match element
						if (isset($filter['data']) && isset($column))
						{
							if ($column)	// false for properties not known to EGroupware
							{
								$value = str_replace(array('%', '_'), array('\\%', '\\_'), $filter['data']);
								switch($match_type)
								{
									case 'equals':
										$sql_filter = $column . $comp . $GLOBALS['egw']->db->quote($value);
										break;
									default:
									case 'contains':
										$sql_filter = $column . $comp . $GLOBALS['egw']->db->quote('%'.$value.'%');
										break;
									case 'starts-with':
										$sql_filter = $column . $comp . $GLOBALS['egw']->db->quote($value.'%');
										break;
									case 'ends-with':
										$sql_filter = $column . $comp . $GLOBALS['egw']->db->quote('%'.$value);
										break;
								}
								$matches[] = ($negate_condition ? 'NOT ' : '').$sql_filter;

								if ($this->debug > 1) error_log(__METHOD__."(...) text-match: $prop_filter $match_type' '{$filter['data']}'");
							}
							unset($column);
							break;
						}
					// fall through
					default:
						$this->caldav->log(__METHOD__."(".array2string($options).",,$id) unknown filter=".array2string($filter).' --> ignored');
						break;
				}
			}
			if ($matches) $prop_filters[] = implode($prop_test=='allof'?' AND ':' OR ',$matches);
			if ($prop_filters)
			{
				$filters[] = $filter = '(('.implode($filter_test=='allof'?') AND (':') OR (', $prop_filters).'))';
				if ($this->debug) error_log(__METHOD__."(path=$options[path], ...) sql-filter: $filter");
			}
		}
		// parse limit from $options['other']
		/* Example limit
		  <B:limit>
		    <B:nresults>10</B:nresults>
		  </B:limit>
		*/
		foreach((array)$options['other'] as $option)
		{
			switch($option['name'])
			{
				case 'nresults':
					$nresults = (int)$option['data'];
					//error_log(__METHOD__."(...) options[other]=".array2string($options['other'])." --> nresults=$nresults");
					break;
				case 'limit':
					break;
				case 'href':
					break;	// from addressbook-multiget, handled below
				// rfc 6578 sync-report
				case 'sync-token':
					if (!empty($option['data']))
					{
						$parts = explode('/', $option['data']);
						$parts = explode('/', $option['data']);
						$filters['sync_token_offset'] = explode(self::SYNC_TOKEN_OFFSET_DELIMITER, array_pop($parts))+[null, 0];
						$filters[] = 'ts_modified>='.(int)$filters['sync_token_offset'][0];
						$filters['ts_status'] = 'all';	// to return deleted entries too
					}
					break;
				case 'sync-level':
					if ($option['data'] != '1')
					{
						$this->caldav->log(__METHOD__."(...) only sync-level {$option['data']} requested, but only 1 supported! options[other]=".array2string($options['other']));
					}
					break;
				default:
					$this->caldav->log(__METHOD__."(...) unknown xml tag '{$option['name']}': options[other]=".array2string($options['other']));
					break;
			}
		}
		// multiget --> fetch the url's
		$this->requested_multiget_ids = null;
		if ($options['root']['name'] == 'addressbook-multiget')
		{
			$this->requested_multiget_ids = [];
			foreach($options['other'] as $option)
			{
				if ($option['name'] == 'href')
				{
					$parts = explode('/',$option['data']);
					if (($id = urldecode(array_pop($parts))))
					{
						$this->requested_multiget_ids[] = self::$path_extension ? basename($id,self::$path_extension) : $id;
					}
				}
			}
			if ($this->requested_multiget_ids) $filters[self::$path_attr] = $this->requested_multiget_ids;
			if ($this->debug) error_log(__METHOD__."(...) addressbook-multiget: ids=".implode(',', $this->requested_multiget_ids));
		}
		elseif ($id)
		{
			$filters[self::$path_attr] = self::$path_extension ? basename($id,self::$path_extension) : $id;
		}
		//error_log(__METHOD__."() options[other]=".array2string($options['other'])." --> filters=".array2string($filters));
		return true;
	}

	/**
	 * Handle get request for an applications entry
	 *
	 * @param array &$options
	 * @param int $id
	 * @param int $user =null account_id
	 * @return mixed boolean true on success, false on failure or string with http status (eg. '404 Not Found')
	 */
	function get(&$options,$id,$user=null)
	{
		header('Content-Type: application/json');

		if (!is_array($timesheet = $this->_common_get_put_delete('GET',$options,$id)))
		{
			return $timesheet;
		}

		try
		{
			// only JsTimesheet, no *DAV
			if (($type=Api\CalDAV::isJSON()))
			{
				$options['data'] = JsTimesheet::JsTimesheet($timesheet, $type);
				$options['mimetype'] = 'application/json';

				header('Content-Encoding: identity');
				header('ETag: "'.$this->get_etag($timesheet).'"');
				return true;
			}
		}
		catch (\Throwable $e) {
			return self::handleException($e);
		}
		return '501 Not Implemented';
	}

	/**
	 * Handle exception by returning an appropriate HTTP status and JSON content with an error message
	 *
	 * @param \Throwable $e
	 * @return string
	 */
	protected function handleException(\Throwable $e) : string
	{
		_egw_log_exception($e);
		header('Content-Type: application/json');
		echo json_encode([
				'error'   => $code = $e->getCode() ?: 500,
				'message' => $e->getMessage(),
				'details' => $e->details ?? null,
				'script'  => $e->script ?? null,
			]+(empty($GLOBALS['egw_info']['server']['exception_show_trace']) ? [] : [
				'trace' => array_map(static function($trace)
				{
					$trace['file'] = str_replace(EGW_SERVER_ROOT.'/', '', $trace['file']);
					return $trace;
				}, $e->getTrace())
			]), self::JSON_RESPONSE_OPTIONS);
		return (400 <= $code && $code < 600 ? $code : 500).' '.$e->getMessage();
	}

	/**
	 * Handle put request for a timesheet
	 *
	 * @param array &$options
	 * @param int $id
	 * @param int $user =null account_id of owner, default null
	 * @param string $prefix =null user prefix from path (eg. /ralf from /ralf/addressbook)
	 * @param string $method='PUT' also called for POST and PATCH
	 * @param ?string $content_type=null
	 * @return mixed boolean true on success, false on failure or string with http status (eg. '404 Not Found')
	 */
	function put(&$options, $id, $user=null, $prefix=null, string $method='PUT', ?string $content_type=null)
	{
		$old = $this->_common_get_put_delete($method,$options,$id);
		if (!is_null($old) && !is_array($old))
		{
			if ($this->debug) error_log(__METHOD__."(,'$id', $user, '$prefix') returning ".array2string($old));
			return $old;
		}

		$type = null;
		$timesheet = JsTimesheet::parseJsTimesheet($options['content'], $old ?: [], $content_type, $method);

		/* uncomment to return parsed data for testing
		header('Content-Type: application/json');
		echo json_encode($timesheet, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
		return "200 Ok";
		*/

		if (is_array($old))
		{
			$id = $old['id'];
			$retval = true;
		}
		else
		{
			// new entry
			$id = -1;
			$retval = '201 Created';
		}

		if (is_array($old))
		{
			$timesheet['ts_id'] = $old['id'];
			// don't allow the client to overwrite certain values
			$timesheet['ts_owner'] = $old['owner'];
			$timesheet['ts_created'] = $old['created'];
		}
		else
		{
			// only set owner, if user is explicitly specified in URL (check via prefix, NOT for /addressbook/) or sync-all-in-one!)
			if ($prefix && $user)
			{
				$timesheet['ts_owner'] = $user;
			}
			else
			{
				$timesheet['ts_owner'] = $GLOBALS['egw_info']['user']['account_id'];
			}
		}
		if ($this->http_if_match) $timesheet['etag'] = self::etag2value($this->http_if_match);

		if (($err = $this->bo->save($timesheet)))
		{
			if ($this->debug) error_log(__METHOD__."(,$id) save(".array2string($timesheet).") failed, error=$err");
			if ($err !== true)
			{
				// honor Prefer: return=representation for 412 too (no need for client to explicitly reload)
				$this->check_return_representation($options, $id, $user);
				return '412 Precondition Failed';
			}
			return '403 Forbidden';
		}
		$timesheet = Api\Db::strip_array_keys($this->bo->data, 'ts_');

		// send necessary response headers: Location, etag, ...
		$this->put_response_headers($timesheet, $options['path'], $retval);

		if ($this->debug > 1) error_log(__METHOD__."(,'$id', $user, '$prefix') returning ".array2string($retval));
		return $retval;
	}

	/**
	 * Handle delete request for an applications entry
	 *
	 * @param array &$options
	 * @param int $id
	 * @param int $user account_id of collection owner
	 * @return mixed boolean true on success, false on failure or string with http status (eg. '404 Not Found')
	 */
	function delete(&$options,$id,$user)
	{
		if (!is_array($timesheet = $this->_common_get_put_delete('DELETE',$options,$id)))
		{
			return $timesheet;
		}
		if (($ok = $this->bo->delete($timesheet['id'],self::etag2value($this->http_if_match))) === 0)
		{
			return '412 Precondition Failed';
		}
		return $ok;
	}

	/**
	 * Read an entry
	 *
	 * @param string|int $id
	 * @param string $path =null implementation can use it, used in call from _common_get_put_delete
	 * @return array|boolean array with entry, false if no read rights, null if $id does not exist
	 */
	function read($id /*,$path=null*/)
	{
		if (($ret = $this->bo->read($id)))
		{
			$ret = Api\Db::strip_array_keys($ret, 'ts_');
		}
		return $ret;
	}

	/**
	 * Check if user has the necessary rights on an entry
	 *
	 * @param int $acl Api\Acl::READ, Api\Acl::EDIT or Api\Acl::DELETE
	 * @param array|int $entry entry-array or id
	 * @return boolean null if entry does not exist, false if no access, true if access permitted
	 */
	function check_access($acl, $entry)
	{
		return $this->bo->check_acl($acl, is_array($entry) ? $entry+['ts_owner' => $entry['owner']] : $entry);
	}
}