<?php namespace Clockwork\Request;

use Clockwork\Helpers\Serializer;

// Data structure representing a single application request
class Request
{
	// Unique request ID
	public $id;

	// Metadata version
	public $version = 1;

	// Request type (request, command, queue-job or test)
	public $type = 'request';

	// Request time
	public $time;

	// Request method
	public $method;

	// Request URL
	public $url;

	// Request URI
	public $uri;

	// Request headers
	public $headers = [];

	// Textual representation of the executed controller
	public $controller;

	// Request GET data
	public $getData = [];

	// Request POST data
	public $postData = [];

	// Request body data
	public $requestData = [];

	// Session data array
	public $sessionData = [];

	// Authenticated user
	public $authenticatedUser;

	// Request cookies
	public $cookies = [];

	// Response time
	public $responseTime;

	// Response processing time
	public $responseDuration;

	// Response status code
	public $responseStatus;

	// Peak memory usage in bytes
	public $memoryUsage;

	// Executed middleware
	public $middleware = [];

	// Database queries
	public $databaseQueries = [];

	// Database queries count
	public $databaseQueriesCount;

	// Database slow queries count
	public $databaseSlowQueries;

	// Database query counts of a particular type (selects, inserts, updates, deletes, others)
	public $databaseSelects;
	public $databaseInserts;
	public $databaseUpdates;
	public $databaseDeletes;
	public $databaseOthers;

	// Cache queries
	public $cacheQueries = [];

	// Cache query counts of a particular type (reads, hits, writes, deletes)
	public $cacheReads;
	public $cacheHits;
	public $cacheWrites;
	public $cacheDeletes;

	// Cache queries execution time
	public $cacheTime;

	// Model actions
	public $modelsActions = [];

	// Model action counts by model
	public $modelsRetrieved = [];
	public $modelsCreated = [];
	public $modelsUpdated = [];
	public $modelsDeleted = [];

	// Redis commands
	public $redisCommands = [];

	// Dispatched queue jobs
	public $queueJobs = [];

	// Timeline events
	public $timelineData = [];

	// Log messages
	public $log = [];

	// Fired events
	public $events = [];

	// Application routes
	public $routes = [];

	// Sent notifications
	public $notifications = [];

	// Sent emails (legacy property replaced by notifications)
	public $emailsData = [];

	// Rendered views
	public $viewsData = [];

	// Custom user data
	public $userData = [];

	// Subrequests
	public $subrequests = [];

	// Xebug profiler data
	public $xdebug = [];

	// Command name
	public $commandName;

	// Command arguments passed in
	public $commandArguments = [];

	// Command arguments defaults
	public $commandArgumentsDefaults = [];

	// Command options passed in
	public $commandOptions = [];

	// Command options defaults
	public $commandOptionsDefaults = [];

	// Command exit code
	public $commandExitCode;

	// Command output
	public $commandOutput;

	// Queue job name
	public $jobName;

	// Queue job description
	public $jobDescription;

	// Queue job status
	public $jobStatus;

	// Queue job payload
	public $jobPayload = [];

	// Queue job queue name
	public $jobQueue;

	// Queue job connection name
	public $jobConnection;

	// Queue job additional options
	public $jobOptions = [];

	// Test name
	public $testName;

	// Test status
	public $testStatus;

	// Test status message (eg. in case of failure)
	public $testStatusMessage;

	// Ran test asserts
	public $testAsserts = [];

	// Client-side performance metrics in the form of [ metric => value ]
	public $clientMetrics = [];

	// Web vitals in the form of [ vital => value ]
	public $webVitals = [];

	// Parent request
	public $parent;

	// Token to update this request data
	public $updateToken;

	// Log instance for the current request
	protected $currentLog;

	// Timeline instance for the current request
	protected $currentTimeline;

	// Array of property values to override collected values from data sources
	protected $overrides = [];

	// Create a new request, if optional data array argument is provided, it will be used to populate the request object,
	// otherwise an empty request with current time, autogenerated ID and update token will be created
	public function __construct(array $data = [])
	{
		$this->id = isset($data['id']) ? $data['id'] : $this->generateRequestId();
		$this->time = microtime(true);
		$this->updateToken = isset($data['updateToken']) ? $data['updateToken'] : $this->generateUpdateToken();

		foreach ($data as $key => $val) $this->$key = $val;

		$this->currentLog = new Log($this->log);
		$this->currentTimeline = new Timeline\Timeline($this->timelineData);
	}

	// Compute the sum of durations of all database queries
	public function getDatabaseDuration()
	{
		return array_reduce($this->databaseQueries, function ($total, $query) {
			return isset($query['duration']) ? $total + $query['duration'] : $total;
		}, 0);
	}

	// Compute response duration in milliseconds
	public function getResponseDuration()
	{
		return ($this->responseTime - $this->time) * 1000;
	}

	// Get all request data as an array
	public function toArray()
	{
		return [
			'id'                       => $this->id,
			'version'                  => $this->version,
			'type'                     => $this->type,
			'time'                     => $this->time,
			'method'                   => $this->method,
			'url'                      => $this->url,
			'uri'                      => $this->uri,
			'headers'                  => $this->headers,
			'controller'               => $this->controller,
			'getData'                  => $this->getData,
			'postData'                 => $this->postData,
			'requestData'              => $this->requestData,
			'sessionData'              => $this->sessionData,
			'authenticatedUser'        => $this->authenticatedUser,
			'cookies'                  => $this->cookies,
			'responseTime'             => $this->responseTime,
			'responseStatus'           => $this->responseStatus,
			'responseDuration'         => $this->responseDuration ?: $this->getResponseDuration(),
			'memoryUsage'              => $this->memoryUsage,
			'middleware'               => $this->middleware,
			'databaseQueries'          => $this->databaseQueries,
			'databaseQueriesCount'     => $this->databaseQueriesCount,
			'databaseSlowQueries'      => $this->databaseSlowQueries,
			'databaseSelects'          => $this->databaseSelects,
			'databaseInserts'          => $this->databaseInserts,
			'databaseUpdates'          => $this->databaseUpdates,
			'databaseDeletes'          => $this->databaseDeletes,
			'databaseOthers'           => $this->databaseOthers,
			'databaseDuration'         => $this->getDatabaseDuration(),
			'cacheQueries'             => $this->cacheQueries,
			'cacheReads'               => $this->cacheReads,
			'cacheHits'                => $this->cacheHits,
			'cacheWrites'              => $this->cacheWrites,
			'cacheDeletes'             => $this->cacheDeletes,
			'cacheTime'                => $this->cacheTime,
			'modelsActions'            => $this->modelsActions,
			'modelsRetrieved'          => $this->modelsRetrieved,
			'modelsCreated'            => $this->modelsCreated,
			'modelsUpdated'            => $this->modelsUpdated,
			'modelsDeleted'            => $this->modelsDeleted,
			'redisCommands'            => $this->redisCommands,
			'queueJobs'                => $this->queueJobs,
			'timelineData'             => $this->timeline()->toArray(),
			'log'                      => $this->log()->toArray(),
			'events'                   => $this->events,
			'routes'                   => $this->routes,
			'notifications'            => $this->notifications,
			'emailsData'               => $this->emailsData,
			'viewsData'                => $this->viewsData,
			'userData'                 => array_map(function ($data) {
				return $data instanceof UserData ? $data->toArray() : $data;
			}, $this->userData),
			'subrequests'              => $this->subrequests,
			'xdebug'                   => $this->xdebug,
			'commandName'              => $this->commandName,
			'commandArguments'         => $this->commandArguments,
			'commandArgumentsDefaults' => $this->commandArgumentsDefaults,
			'commandOptions'           => $this->commandOptions,
			'commandOptionsDefaults'   => $this->commandOptionsDefaults,
			'commandExitCode'          => $this->commandExitCode,
			'commandOutput'            => $this->commandOutput,
			'jobName'                  => $this->jobName,
			'jobDescription'           => $this->jobDescription,
			'jobStatus'                => $this->jobStatus,
			'jobPayload'               => $this->jobPayload,
			'jobQueue'                 => $this->jobQueue,
			'jobConnection'            => $this->jobConnection,
			'jobOptions'               => $this->jobOptions,
			'testName'                 => $this->testName,
			'testStatus'               => $this->testStatus,
			'testStatusMessage'        => $this->testStatusMessage,
			'testAsserts'              => $this->testAsserts,
			'clientMetrics'            => $this->clientMetrics,
			'webVitals'                => $this->webVitals,
			'parent'                   => $this->parent,
			'updateToken'              => $this->updateToken
		];
	}

	// Get all request data as a JSON string
	public function toJson()
	{
		return json_encode($this->toArray(), \JSON_PARTIAL_OUTPUT_ON_ERROR);
	}

	// Return request data except specified keys as an array
	public function except($keys)
	{
		return array_filter($this->toArray(), function ($value, $key) use ($keys) {
			return ! in_array($key, $keys);
		}, ARRAY_FILTER_USE_BOTH);
	}

	// Return only request data with specified keys as an array
	public function only($keys)
	{
		return array_filter($this->toArray(), function ($value, $key) use ($keys) {
			return in_array($key, $keys);
		}, ARRAY_FILTER_USE_BOTH);
	}

	// Return log instance for the current request
	public function log()
	{
		return $this->currentLog;
	}

	// Return timeline instance for the current request
	public function timeline()
	{
		return $this->currentTimeline;
	}

	// Add a new overridden property
	public function override($property, $value)
	{
		$this->overrides[$property] = $value;
		return $this;
	}

	// Get or set all overrides at once
	public function overrides($overrides = null)
	{
		if (! $overrides) return $this->overrides;

		$this->overrides = $overrides;
		return $this;
	}

	// Add database query, takes query, bindings, duration (in ms) and additional data - connection (connection name),
	// time (when was the query executed), file (caller file name), line (caller line number), trace (serialized trace),
	// model (associated ORM model)
	public function addDatabaseQuery($query, $bindings = [], $duration = null, $data = [])
	{
		$this->databaseQueries[] = [
			'query'      => $query,
			'bindings'   => (new Serializer)->normalize($bindings),
			'duration'   => $duration,
			'connection' => isset($data['connection']) ? $data['connection'] : null,
			'time'       => isset($data['time']) ? $data['time'] : microtime(true) - ($duration ?: 0) / 1000,
			'file'       => isset($data['file']) ? $data['file'] : null,
			'line'       => isset($data['line']) ? $data['line'] : null,
			'trace'      => isset($data['trace']) ? $data['trace'] : null,
			'model'      => isset($data['model']) ? $data['model'] : null,
			'tags'       => array_merge(
				isset($data['tags']) ? $data['tags'] : [], isset($data['slow']) ? [ 'slow' ] : []
			)
		];
	}

	// Add model action, takes model, action and additional data - key, attributes, changes, time (when was the action
	// executed), query, duration (in ms), connection (connection name), trace (serialized trace), file (caller file
	// name), line (caller line number), tags
	public function addModelAction($model, $action, $data = [])
	{
		$this->modelActions[] = [
			'model'      => $model,
			'key'        => isset($data['key']) ? $data['key'] : null,
			'action'     => $action,
			'attributes' => isset($data['attributes']) ? $data['attributes'] : [],
			'changes'    => isset($data['changes']) ? $data['changes'] : [],
			'duration'   => $duration = isset($data['duration']) ? $data['duration'] : null,
			'time'       => isset($data['time']) ? $data['time'] : microtime(true) - ($duration ?: 0) / 1000,
			'query'      => isset($data['query']) ? $data['query'] : null,
			'connection' => isset($data['connection']) ? $data['connection'] : null,
			'trace'      => isset($data['trace']) ? $data['trace'] : null,
			'file'       => isset($data['file']) ? $data['file'] : null,
			'line'       => isset($data['line']) ? $data['line'] : null,
			'tags'       => isset($data['tags']) ? $data['tags'] : []
		];
	}

	// Add cache query, takes type, key, value, duration (in ms) and additional data - connection (connection name),
	// time (when was the query executed), file (caller file name), line (caller line number), trace (serialized trace),
	// expiration
	public function addCacheQuery($type, $key, $value = null, $duration = null, $data = [])
	{
		$this->cacheQueries[] = [
			'type'       => $type,
			'key'        => $key,
			'value'      => (new Serializer)->normalize($value),
			'duration'   => $duration,
			'connection' => isset($data['connection']) ? $data['connection'] : null,
			'time'       => isset($data['time']) ? $data['time'] : microtime(true) - ($duration ?: 0) / 1000,
			'file'       => isset($data['file']) ? $data['file'] : null,
			'line'       => isset($data['line']) ? $data['line'] : null,
			'trace'      => isset($data['trace']) ? $data['trace'] : null,
			'expiration' => isset($data['expiration']) ? $data['expiration'] : null
		];
	}

	// Add event, takes event name, data, time and additional data - listeners, duration (in ms), file (caller file
	// name), line (caller line number), trace (serialized trace)
	public function addEvent($event, $eventData = null, $time = null, $data = [])
	{
		$this->events[] = [
			'event'     => $event,
			'data'      => (new Serializer)->normalize($eventData),
			'duration'  => $duration = isset($data['duration']) ? $data['duration'] : null,
			'time'      => $time ? $time : microtime(true) - ($duration ?: 0) / 1000,
			'listeners' => isset($data['listeners']) ? $data['listeners'] : null,
			'file'      => isset($data['file']) ? $data['file'] : null,
			'line'      => isset($data['line']) ? $data['line'] : null,
			'trace'     => isset($data['trace']) ? $data['trace'] : null
		];
	}

	// Add route, takes method, uri, action and additional data - name, middleware, before (before filters), after
	// (after filters)
	public function addRoute($method, $uri, $action, $data = [])
	{
		$this->routes[] = [
			'method'     => $method,
			'uri'        => $uri,
			'action'     => $action,
			'name'       => isset($data['name']) ? $data['name'] : null,
			'middleware' => isset($data['middleware']) ? $data['middleware'] : null,
			'before'     => isset($data['before']) ? $data['before'] : null,
			'after'      => isset($data['after']) ? $data['after'] : null
		];
	}

	// Add sent notifucation, takes subject, recipient, sender, and additional data - time, duration, type, content, data
	public function addNotification($subject, $to, $from = null, $data = [])
	{
		$this->notifications[] = [
			'subject'  => $subject,
			'from'     => $from,
			'to'       => $to,
			'content'  => isset($data['content']) ? $data['content'] : null,
			'type'     => isset($data['type']) ? $data['type'] : null,
			'data'     => isset($data['data']) ? $data['data'] : [],
			'duration' => $duration = isset($data['duration']) ? $data['duration'] : null,
			'time'     => isset($data['time']) ? $data['time'] : microtime(true) - ($duration ?: 0) / 1000,
			'trace'    => isset($data['trace']) ? $data['trace'] : null,
			'file'     => isset($data['file']) ? $data['file'] : null,
			'line'     => isset($data['line']) ? $data['line'] : null
		];
	}

	// Add sent email, takes subject, recipient address, sender address, array of headers, and additional data - time
	// (when was the email sent), duration (sending time in ms)
	public function addEmail($subject, $to, $from = null, $headers = [], $data = [])
	{
		$this->emailsData[] = [
			'start'       => isset($data['time']) ? $data['time'] : null,
			'end'         => isset($data['time'], $data['duration']) ? $data['time'] + $data['duration'] / 1000 : null,
			'duration'    => isset($data['duration']) ? $data['duration'] : null,
			'description' => 'Sending an email message',
			'data'        => [
				'subject' => $subject,
				'to'      => $to,
				'from'    => $from,
				'headers' => (new Serializer)->normalize($headers)
			]
		];
	}

	// Add view, takes view name, view data and additional data - time (when was the view rendered), duration (sending
	// time in ms)
	public function addView($name, $viewData = [], $data = [])
	{
		$this->viewsData[] = [
			'start'       => isset($data['time']) ? $data['time'] : null,
			'end'         => isset($data['time'], $data['duration']) ? $data['time'] + $data['duration'] / 1000 : null,
			'duration'    => isset($data['duration']) ? $data['duration'] : null,
			'description' => 'Rendering a view',
			'data'        => [
				'name' => $name,
				'data' => (new Serializer)->normalize($viewData)
			]
		];
	}

	// Add executed subrequest, takes the requested url, subrequest Clockwork ID and additional data - path if non-default
	public function addSubrequest($url, $id, $data = [])
	{
		$this->subrequests[] = [
			'url'  => $url,
			'id'   => $id,
			'path' => isset($data['path']) ? $data['path'] : null
		];
	}

	// Set the authenticated user, takes a username, an id and additional data - email and name
	public function setAuthenticatedUser($username, $id = null, $data = [])
	{
		$this->authenticatedUser = [
			'id'       => $id,
			'username' => $username,
			'email'    => isset($data['email']) ? $data['email'] : null,
			'name'     => isset($data['name']) ? $data['name'] : null
		];
	}

	// Set parent request, takes the request id and additional options - url and path if non-default
	public function setParent($id, $data = [])
	{
		$this->parent = [
			'id'   => $id,
			'url'  => isset($data['url']) ? $data['url'] : null,
			'path' => isset($data['path']) ? $data['path'] : null
		];
	}

	// Add custom user data
	public function userData($key = null)
	{
		if ($key && isset($this->userData[$key])) {
			return $this->userData[$key];
		}

		$userData = (new UserData)->title($key);

		return $key ? $this->userData[$key] = $userData : $this->userData[] = $userData;
	}

	// Add a ran test assert, takes the assert name, arguments, whether it passed and trace as arguments
	public function addTestAssert($name, $arguments = null, $passed = true, $trace = null)
	{
		$this->testAsserts[] = [
			'name'      => $name,
			'arguments' => (new Serializer)->normalize($arguments),
			'trace'     => $trace,
			'passed'    => $passed
		];
	}

	// Generate unique request ID in the form of <current time>-<random number>
	protected function generateRequestId()
	{
		return str_replace('.', '-', sprintf('%.4F', microtime(true))) . '-' . mt_rand();
	}

	// Generate a random update token
	protected function generateUpdateToken()
	{
		$length = 8;
		$bytes = function_exists('random_bytes') ? random_bytes($length) : openssl_random_pseudo_bytes($length);

		return substr(bin2hex($bytes), 0, $length);
	}
}
