<?php

namespace WPGraphQL\Utils;

use GraphQL\Type\Definition\ResolveInfo;
use WPGraphQL\AppContext;

/**
 * Class Tracing
 *
 * Sets up trace data to track how long individual fields take to resolve in WPGraphQL
 *
 * @package WPGraphQL\Utils
 *
 * phpcs:disable -- PHPStan annotation.
 * @phpstan-type FieldTrace array{
 *  path?: array<int,int|string>,
 *  parentType?: string,
 *  fieldName?: string,
 *  returnType?: string,
 *  startOffset?: float|int,
 *  startMicrotime?: float,
 *  duration?: float|int,
 * }
 *
 * @phpstan-type SanitizedFieldTrace array{
 *  path: array<int,int|string>,
 *  parentType: string,
 *  fieldName: string,
 *  returnType: string,
 *  startOffset: int|'',
 *  duration: int|'',
 * }
 *
 * @phpstan-type Trace array{
 *  version: int,
 *  startTime: float,
 *  endTime: float,
 *  duration: int,
 *  execution: array{
 *   resolvers: SanitizedFieldTrace[]
 *  }
 * }
 * phpcs:enable
 */
class Tracing {

	/**
	 * Whether Tracing is enabled
	 *
	 * @var bool
	 */
	public $tracing_enabled;

	/**
	 * Stores the logs for the trace
	 *
	 * @var SanitizedFieldTrace[]
	 */
	public $trace_logs = [];

	/**
	 * The start microtime
	 *
	 * @var float
	 */
	public $request_start_microtime;

	/**
	 * The start timestamp
	 *
	 * @var float
	 */
	public $request_start_timestamp;

	/**
	 * The end microtime
	 *
	 * @var float
	 */
	public $request_end_microtime;

	/**
	 * The end timestamp
	 *
	 * @var float
	 */
	public $request_end_timestamp;

	/**
	 * The trace for the current field being resolved
	 *
	 * @var FieldTrace
	 */
	public $field_trace = [];

	/**
	 * The version of the Apollo Tracing Spec
	 *
	 * @var int
	 */
	public $trace_spec_version = 1;

	/**
	 * The user role tracing is limited to
	 *
	 * @var string
	 */
	public $tracing_user_role;

	/**
	 * Initialize tracing
	 *
	 * @return void
	 */
	public function init() {

		// Check whether Query Logs have been enabled from the settings page
		$enabled               = get_graphql_setting( 'tracing_enabled', 'off' );
		$this->tracing_enabled = 'on' === $enabled;

		$this->tracing_user_role = get_graphql_setting( 'tracing_user_role', 'manage_options' );

		if ( ! $this->tracing_enabled ) {
			return;
		}

		add_action( 'do_graphql_request', [ $this, 'init_trace' ] );
		add_action( 'graphql_execute', [ $this, 'end_trace' ], 99, 0 );
		add_filter( 'graphql_access_control_allow_headers', [ $this, 'return_tracing_headers' ] );
		add_filter(
			'graphql_request_results',
			[
				$this,
				'add_tracing_to_response_extensions',
			],
			10,
			1
		);
		add_action( 'graphql_before_resolve_field', [ $this, 'init_field_resolver_trace' ], 10, 4 );
		add_action( 'graphql_after_resolve_field', [ $this, 'end_field_resolver_trace' ], 10 );
	}

	/**
	 * Sets the timestamp and microtime for the start of the request
	 */
	public function init_trace(): void {
		$this->request_start_microtime = microtime( true );
		$this->request_start_timestamp = $this->format_timestamp( $this->request_start_microtime );
	}

	/**
	 * Sets the timestamp and microtime for the end of the request
	 *
	 * @return void
	 */
	public function end_trace() {
		$this->request_end_microtime = microtime( true );
		$this->request_end_timestamp = $this->format_timestamp( $this->request_end_microtime );
	}

	/**
	 * Initialize tracing for an individual field
	 *
	 * @param mixed                                $source         The source passed down the Resolve Tree
	 * @param array<string,mixed>                  $args           The args for the field
	 * @param \WPGraphQL\AppContext                $context The AppContext passed down the ResolveTree
	 * @param \GraphQL\Type\Definition\ResolveInfo $info The ResolveInfo passed down the ResolveTree
	 *
	 * @return void
	 */
	public function init_field_resolver_trace( $source, array $args, AppContext $context, ResolveInfo $info ) {
		$this->field_trace = [
			'path'           => $info->path,
			'parentType'     => $info->parentType->name,
			'fieldName'      => $info->fieldName,
			'returnType'     => $info->returnType->name ?? $info->returnType,
			'startOffset'    => $this->get_start_offset(),
			'startMicrotime' => microtime( true ),
		];
	}

	/**
	 * End the tracing for a resolver
	 *
	 * @return void
	 */
	public function end_field_resolver_trace() {
		if ( ! empty( $this->field_trace ) ) {
			$this->field_trace['duration'] = $this->get_field_resolver_duration();
			$sanitized_trace               = $this->sanitize_resolver_trace( $this->field_trace );
			$this->trace_logs[]            = $sanitized_trace;
		}

		// reset the field trace
		$this->field_trace = [];
	}

	/**
	 * Given a resolver start time, returns the duration of a resolver
	 *
	 * @return float
	 */
	public function get_field_resolver_duration() {
		$start_microtime = $this->field_trace['startMicrotime'] ?? 0;

		return ( microtime( true ) - $start_microtime ) * 1000000;
	}

	/**
	 * Get the offset between the start of the request and now
	 *
	 * @return float
	 */
	public function get_start_offset() {
		return ( microtime( true ) - $this->request_start_microtime ) * 1000000;
	}

	/**
	 * Given a trace, sanitizes the values and returns the sanitized_trace
	 *
	 * @param array<string,mixed> $trace
	 *
	 * @return SanitizedFieldTrace
	 */
	public function sanitize_resolver_trace( array $trace ) {
		$sanitized_trace                = [];
		$sanitized_trace['path']        = ! empty( $trace['path'] ) && is_array( $trace['path'] ) ? array_map(
			[
				$this,
				'sanitize_trace_resolver_path',
			],
			$trace['path']
		) : [];
		$sanitized_trace['parentType']  = ! empty( $trace['parentType'] ) ? esc_html( $trace['parentType'] ) : '';
		$sanitized_trace['fieldName']   = ! empty( $trace['fieldName'] ) ? esc_html( $trace['fieldName'] ) : '';
		$sanitized_trace['returnType']  = ! empty( $trace['returnType'] ) ? esc_html( $trace['returnType'] ) : '';
		$sanitized_trace['startOffset'] = ! empty( $trace['startOffset'] ) ? absint( $trace['startOffset'] ) : '';
		$sanitized_trace['duration']    = ! empty( $trace['duration'] ) ? absint( $trace['duration'] ) : '';

		return $sanitized_trace;
	}

	/**
	 * Given input from a Resolver Path, this sanitizes the input for output in the trace
	 *
	 * @param int|string|float|null $input The input to sanitize
	 *
	 * @return int|string
	 *
	 * @phpstan-return ( $input is int|float|numeric-string ? int : string )
	 */
	public static function sanitize_trace_resolver_path( $input ) {
		if ( is_numeric( $input ) ) {
			return absint( $input );
		}

		return esc_html( (string) $input );
	}

	/**
	 * Formats a timestamp to be RFC 3339 compliant
	 *
	 * @see https://github.com/apollographql/apollo-tracing
	 *
	 * @param string|float|int $time The timestamp to format
	 *
	 * @return float
	 */
	public function format_timestamp( $time ) {
		$time_as_float = sprintf( '%.4f', (string) $time );
		$timestamp     = \DateTime::createFromFormat( 'U.u', $time_as_float );

		return ! empty( $timestamp ) ? (float) $timestamp->format( 'Y-m-d\TH:i:s.uP' ) : (float) 0;
	}

	/**
	 * Filter the headers that WPGraphQL returns to include headers that indicate the WPGraphQL
	 * server supports Apollo Tracing and Credentials
	 *
	 * @param string[] $headers The headers to return
	 *
	 * @return string[]
	 */
	public function return_tracing_headers( array $headers ) {
		$headers[] = 'X-Insights-Include-Tracing';
		$headers[] = 'X-Apollo-Tracing';
		$headers[] = 'Credentials';

		return $headers;
	}

	/**
	 * Filter the results of the GraphQL Response to include the Query Log
	 *
	 * @param array<string,mixed>|object|mixed $response The response of the GraphQL Request
	 *
	 * @return array<string,mixed>|object|mixed
	 */
	public function add_tracing_to_response_extensions( $response ) {

		// Get the trace
		$trace = $this->get_trace();

		// If a specific capability is set for tracing and the requesting user
		// doesn't have the capability, return the unmodified response
		if ( ! $this->user_can_see_trace_data() ) {
			return $response;
		}

		if ( is_array( $response ) ) {
			$response['extensions']['tracing'] = $trace;
		} elseif ( is_object( $response ) ) {
			// @phpstan-ignore-next-line
			$response->extensions['tracing'] = $trace;
		}

		return $response;
	}

	/**
	 * Returns the request duration calculated from the start and end times
	 *
	 * @return float
	 */
	public function get_request_duration() {
		return ( $this->request_end_microtime - $this->request_start_microtime ) * 1000000;
	}

	/**
	 * Determine if the requesting user can see trace data
	 */
	public function user_can_see_trace_data(): bool {
		$can_see = false;

		// If logs are disabled, user cannot see logs
		if ( ! $this->tracing_enabled ) {
			$can_see = false;
		} elseif ( 'any' === $this->tracing_user_role ) {
			// If "any" is the selected role, anyone can see the logs
			$can_see = true;
		} else {
			// Get the current users roles
			$user = wp_get_current_user();

			// If the user doesn't have roles or the selected role isn't one the user has, the user cannot see roles.
			if ( in_array( $this->tracing_user_role, $user->roles, true ) ) {
				$can_see = true;
			}
		}

		/**
		 * Filter whether the logs can be seen in the request results or not
		 *
		 * @param bool $can_see Whether the requester can see the logs or not
		 *
		 * @hookGroup debugging
		 * @since 0.2.0
		 */
		return apply_filters( 'graphql_user_can_see_trace_data', $can_see );
	}

	/**
	 * Get the trace to add to the response
	 *
	 * @return Trace
	 */
	public function get_trace(): array {

		// Compile the trace to return with the GraphQL Response
		$trace = [
			'version'   => absint( $this->trace_spec_version ),
			'startTime' => (float) $this->request_start_microtime,
			'endTime'   => (float) $this->request_end_microtime,
			'duration'  => absint( $this->get_request_duration() ),
			'execution' => [
				'resolvers' => $this->trace_logs,
			],
		];

		/**
		 * Filter the trace
		 *
		 * @param Trace                    $trace     The trace to return
		 * @param \WPGraphQL\Utils\Tracing $instance The Tracing class instance
		 *
		 * @hookGroup debugging
		 * @since 0.2.0
		 */
		return apply_filters( 'graphql_tracing_response', $trace, $this );
	}
}
