<?php declare(strict_types = 1);
/**
 * HTTP API request collector.
 *
 * @package query-monitor
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * @extends QM_DataCollector<QM_Data_HTTP>
 */
class QM_Collector_HTTP extends QM_DataCollector {

	/**
	 * @var string
	 */
	public $id = 'http';

	/**
	 * @var mixed|null
	 */
	private $info = null;

	/**
	 * @var array<string, array<string, mixed>>
	 * @phpstan-var array<string, array{
	 *   url: string,
	 *   start: float,
	 *   args: array<string, mixed>,
	 *   filtered_trace: list<array<string, mixed>>,
	 *   component: QM_Component,
	 * }>
	 */
	private $http_requests = array();

	/**
	 * @var array<string, array<string, mixed>>
	 * @phpstan-var array<string, array{
	 *   end: float,
	 *   args: array<string, mixed>,
	 *   response: mixed[]|WP_Error,
	 *   info: array<string, mixed>|null,
	 *   intercepted: bool,
	 * }>
	 */
	private $http_responses = array();

	public function get_storage(): QM_Data {
		return new QM_Data_HTTP();
	}

	/**
	 * @return void
	 */
	public function set_up() {

		parent::set_up();

		add_filter( 'http_request_args', array( $this, 'filter_http_request_args' ), 9999, 2 );
		add_filter( 'pre_http_request', array( $this, 'filter_pre_http_request' ), 9999, 3 );
		add_action( 'http_api_debug', array( $this, 'action_http_api_debug' ), 9999, 5 );

		add_action( 'requests-curl.after_request', array( $this, 'action_curl_after_request' ), 9999, 2 );
		add_action( 'requests-fsockopen.after_request', array( $this, 'action_fsockopen_after_request' ), 9999, 2 );

	}

	/**
	 * @return void
	 */
	public function tear_down() {
		remove_filter( 'http_request_args', array( $this, 'filter_http_request_args' ), 9999 );
		remove_filter( 'pre_http_request', array( $this, 'filter_pre_http_request' ), 9999 );
		remove_action( 'http_api_debug', array( $this, 'action_http_api_debug' ), 9999 );

		remove_action( 'requests-curl.after_request', array( $this, 'action_curl_after_request' ), 9999 );
		remove_action( 'requests-fsockopen.after_request', array( $this, 'action_fsockopen_after_request' ), 9999 );

		parent::tear_down();
	}

	/**
	 * @return array<int, string>
	 */
	public function get_concerned_actions() {
		$actions = array(
			'http_api_curl',
			'requests-multiple.request.complete',
			'requests-request.progress',
			'requests-transport.internal.parse_error',
			'requests-transport.internal.parse_response',
		);
		$transports = array(
			'requests',
			'curl',
			'fsockopen',
		);

		foreach ( $transports as $transport ) {
			$actions[] = "requests-{$transport}.after_headers";
			$actions[] = "requests-{$transport}.after_multi_exec";
			$actions[] = "requests-{$transport}.after_request";
			$actions[] = "requests-{$transport}.after_send";
			$actions[] = "requests-{$transport}.before_multi_add";
			$actions[] = "requests-{$transport}.before_multi_exec";
			$actions[] = "requests-{$transport}.before_parse";
			$actions[] = "requests-{$transport}.before_redirect";
			$actions[] = "requests-{$transport}.before_redirect_check";
			$actions[] = "requests-{$transport}.before_request";
			$actions[] = "requests-{$transport}.before_send";
			$actions[] = "requests-{$transport}.remote_host_path";
			$actions[] = "requests-{$transport}.remote_socket";
		}

		return $actions;
	}

	/**
	 * @return array<int, string>
	 */
	public function get_concerned_filters() {
		return array(
			'block_local_requests',
			'http_allowed_safe_ports',
			'http_headers_useragent',
			'http_request_args',
			'http_request_host_is_external',
			'http_request_redirection_count',
			'http_request_reject_unsafe_urls',
			'http_request_timeout',
			'http_request_version',
			'http_response',
			'https_local_ssl_verify',
			'https_ssl_verify',
			'pre_http_request',
			'use_curl_transport',
			'use_streams_transport',
		);
	}

	/**
	 * @return array<int, string>
	 */
	public function get_concerned_constants() {
		return array(
			'WP_PROXY_HOST',
			'WP_PROXY_PORT',
			'WP_PROXY_USERNAME',
			'WP_PROXY_PASSWORD',
			'WP_PROXY_BYPASS_HOSTS',
			'WP_HTTP_BLOCK_EXTERNAL',
			'WP_ACCESSIBLE_HOSTS',
		);
	}

	/**
	 * Filter the arguments used in an HTTP request.
	 *
	 * Used to log the request, and to add the logging key to the arguments array.
	 *
	 * @param  array<string, mixed> $args HTTP request arguments.
	 * @param  string               $url  The request URL.
	 * @return array<string, mixed> HTTP request arguments.
	 */
	public function filter_http_request_args( array $args, $url ) {
		$trace = new QM_Backtrace( array(
			'ignore_hook' => array(
				current_filter() => true,
			),
			'ignore_class' => array(
				'WP_Http' => true,
			),
			'ignore_func' => array(
				'wp_safe_remote_request' => true,
				'wp_safe_remote_get' => true,
				'wp_safe_remote_post' => true,
				'wp_safe_remote_head' => true,
				'wp_remote_request' => true,
				'wp_remote_get' => true,
				'wp_remote_post' => true,
				'wp_remote_head' => true,
				'wp_remote_fopen' => true,
				'download_url' => true,
				'vip_safe_wp_remote_get' => true,
				'vip_safe_wp_remote_request' => true,
				'wpcom_vip_file_get_contents' => true,
			),
		) );

		if ( isset( $args['_qm_key'], $this->http_requests[ $args['_qm_key'] ] ) ) {
			// Something has triggered another HTTP request from within the `pre_http_request` filter
			// (eg. WordPress Beta Tester and FAIR do this). This allows for one level of nested queries.
			$args['_qm_original_key'] = $args['_qm_key'];
			$start = $this->http_requests[ $args['_qm_key'] ]['start'];
		} else {
			$start = microtime( true );
		}

		$key = microtime( true ) . $url;
		$this->http_requests[ $key ] = array(
			'url' => $url,
			'args' => $args,
			'start' => $start,
			'filtered_trace' => $trace->get_filtered_trace(),
			'component' => $trace->get_component(),
		);
		$args['_qm_key'] = $key;
		return $args;
	}

	/**
	 * Log the HTTP request's response if it's being short-circuited by another plugin.
	 *
	 * `$response` should be one of boolean false, an array, or a `WP_Error`, but be aware that plugins
	 * which short-circuit the request using this filter may (incorrectly) return data of another type.
	 *
	 * @param false|mixed[]|WP_Error $response The preemptive HTTP response. Default false.
	 * @param array<string, mixed>   $args     HTTP request arguments.
	 * @param string                 $url      The request URL.
	 * @return false|mixed[]|WP_Error The preemptive HTTP response.
	 */
	public function filter_pre_http_request( $response, array $args, $url ) {

		// All is well:
		if ( false === $response ) {
			return $response;
		}

		// Something's filtering the response, so we'll log it
		$this->log_http_response( $response, $args, $url, true );

		return $response;
	}

	/**
	 * Debugging action for the HTTP API.
	 *
	 * @param mixed                $response A parameter which varies depending on $action.
	 * @param string               $action   The debug action. Currently one of 'response' or 'transports_list'.
	 * @param string               $class    The HTTP transport class name.
	 * @param array<string, mixed> $args     HTTP request arguments.
	 * @param string               $url      The request URL.
	 * @return void
	 */
	public function action_http_api_debug( $response, $action, $class, $args, $url ) {
		if ( $action === 'response' ) {
			$this->log_http_response( $response, $args, $url );
		}
	}

	/**
	 * @param mixed $headers
	 * @param mixed[] $info
	 * @return void
	 */
	public function action_curl_after_request( $headers, ?array $info = null ) {
		$this->info = $info;
	}

	/**
	 * @param mixed $headers
	 * @param mixed[] $info
	 * @return void
	 */
	public function action_fsockopen_after_request( $headers, ?array $info = null ) {
		$this->info = $info;
	}

	/**
	 * Log an HTTP response.
	 *
	 * @param mixed[]|WP_Error     $response    The HTTP response.
	 * @param array<string, mixed> $args        HTTP request arguments.
	 * @param string               $url         The request URL.
	 * @param bool                 $intercepted Whether the request was intercepted and short-circuited by a filter.
	 * @return void
	 */
	public function log_http_response( $response, array $args, $url, bool $intercepted = false ) {
		/** @var string */
		$key = $args['_qm_key'];

		$http_response = array(
			'end' => microtime( true ),
			'response' => $response,
			'args' => $args,
			'info' => $this->info,
			'intercepted' => $intercepted,
		);

		if ( isset( $args['_qm_original_key'] ) ) {
			/** @var string */
			$original_key = $args['_qm_original_key'];
			$this->http_responses[ $original_key ]['end'] = $this->http_requests[ $original_key ]['start'];
			$this->http_responses[ $original_key ]['response'] = new WP_Error( 'http_request_not_executed' );
		}

		$this->http_responses[ $key ] = $http_response;

		$this->info = null;
	}

	/**
	 * @return void
	 */
	public function process() {
		$this->data->ltime = 0;

		if ( empty( $this->http_requests ) ) {
			return;
		}

		/**
		 * List of HTTP API error codes to ignore.
		 *
		 * @since 2.7.0
		 *
		 * @param array $http_errors Array of HTTP errors.
		 */
		$silent = apply_filters( 'qm/collect/silent_http_errors', array(
			'http_request_not_executed',
			'airplane_mode_enabled',
		) );

		$home_host = (string) parse_url( home_url(), PHP_URL_HOST );

		foreach ( $this->http_requests as $key => $request ) {
			$response = $this->http_responses[ $key ];

			if ( empty( $response['response'] ) ) {
				// Timed out
				$response['response'] = new WP_Error( 'http_request_timed_out' );
				$response['end'] = floatval( $request['start'] + $response['args']['timeout'] );
			}

			if ( $response['response'] instanceof WP_Error ) {
				if ( ! in_array( $response['response']->get_error_code(), $silent, true ) ) {
					$this->data->errors['alert'][] = $key;
				}
				$type = 'error';
			} elseif ( ! $response['args']['blocking'] ) {
				$type = 'non-blocking';
			} else {
				$code = intval( wp_remote_retrieve_response_code( $response['response'] ) );
				$type = "HTTP {$code}";
				if ( ( $code >= 400 ) && ( 'HEAD' !== $request['args']['method'] ) ) {
					$this->data->errors['warning'][] = $key;
				}
			}

			$ltime = ( $response['end'] - $request['start'] );
			$redirected_to = null;

			if ( isset( $response['info'] ) && ! empty( $response['info']['url'] ) && is_string( $response['info']['url'] ) ) {
				// Ignore query variables when detecting a redirect.
				$from = untrailingslashit( preg_replace( '#\?[^$]*$#', '', $request['url'] ) );
				$to = untrailingslashit( preg_replace( '#\?[^$]*$#', '', $response['info']['url'] ) );

				if ( $from !== $to ) {
					$redirected_to = $response['info']['url'];
				}
			}

			if ( ! $response['intercepted'] ) {
				$this->data->ltime += $ltime;
			}

			$host = (string) parse_url( $request['url'], PHP_URL_HOST );
			$local = ( $host === $home_host );

			$this->log_type( $type );
			$this->log_component( $request['component'], $ltime, $type );
			$this->data->http[ $key ] = array(
				'args' => $response['args'],
				'component' => $request['component'],
				'filtered_trace' => $request['filtered_trace'],
				'host' => $host,
				'info' => $response['info'],
				'local' => $local,
				'ltime' => $ltime,
				'redirected_to' => $redirected_to,
				'response' => $response['response'],
				'type' => $type,
				'url' => $request['url'],
				'intercepted' => $response['intercepted'],
			);
		}

	}

	/**
	 * Log a Guzzle HTTP request.
	 *
	 * @since 3.19.0
	 *
	 * @param \Psr\Http\Message\RequestInterface $request    The Guzzle request object.
	 * @param \Psr\Http\Message\ResponseInterface|null $response The Guzzle response object, or null if an exception occurred.
	 * @param \Exception|null $exception The exception thrown, or null if the request was successful.
	 * @param string $url        The request URL.
	 * @param float $start_time  The request start time.
	 * @param QM_Backtrace $trace The backtrace object.
	 * @param array<string, mixed> $options Guzzle request options.
	 */
	public function log_guzzle_request( $request, $response, $exception, string $url, float $start_time, QM_Backtrace $trace, array $options ) : void {
		$end_time = microtime( true );
		$ltime = $end_time - $start_time;
		$key = $start_time . $url;
		$args = array(
			'method' => $request->getMethod(),
			'timeout' => $options['timeout'] ?? 30,
			'blocking' => true,
			'sslverify' => $options['verify'] ?? true,
			'_qm_guzzle' => true,
		);

		if ( $exception ) {
			$wp_error = new WP_Error( 'guzzle_request_failed', $exception->getMessage() );
			$type = 'error';
			$response_data = $wp_error;
		} else {
			$response_data = array(
				'headers' => array(),
				'body' => (string) $response->getBody(),
				'response' => array(
					'code' => $response->getStatusCode(),
					'message' => $response->getReasonPhrase(),
				),
				'cookies' => array(),
				'filename' => null,
			);

			foreach ( $response->getHeaders() as $name => $values ) {
				$response_data['headers'][ $name ] = implode( ', ', $values );
			}

			$code = $response->getStatusCode();
			$type = "HTTP {$code}";
		}

		$home_host = (string) parse_url( home_url(), PHP_URL_HOST );
		$host = (string) parse_url( $url, PHP_URL_HOST );
		$local = ( $host === $home_host );

		$this->log_type( $type );
		$this->log_component( $trace->get_component(), $ltime, $type );

		$this->data->http[ $key ] = array(
			'args' => $args,
			'component' => $trace->get_component(),
			'filtered_trace' => $trace->get_filtered_trace(),
			'host' => $host,
			'info' => null,
			'local' => $local,
			'ltime' => $ltime,
			'redirected_to' => null,
			'response' => $response_data,
			'type' => $type,
			'url' => $url,
			'intercepted' => false,
		);

		$this->data->ltime += $ltime;

		if ( $exception || ( $response && $response->getStatusCode() >= 400 ) ) {
			$this->data->errors['warning'][] = $key;
		}
	}

	/**
	 * Creates a Guzzle middleware for logging HTTP requests to Query Monitor.
	 *
	 * Usage:
	 *
	 *   $stack = HandlerStack::create();
	 *   $stack->push( QM_Collector_HTTP::guzzle_middleware() );
	 *   $client = new Client( [ 'handler' => $stack ] );
	 *
	 * @since 3.19.0
	 *
	 * @return callable Guzzle middleware callable.
	 */
	public static function guzzle_middleware(): callable {
		return function ( callable $handler ) {
			return function ( \Psr\Http\Message\RequestInterface $request, array $options ) use ( $handler ) {
				$collector = QM_Collectors::get( 'http' );

				if ( ! ( $collector instanceof QM_Collector_HTTP ) ) {
					return $handler( $request, $options );
				}

				$url = (string) $request->getUri();
				$start_time = microtime( true );

				$trace = new QM_Backtrace( array(
					'ignore_namespace' => array(
						'GuzzleHttp' => true,
					),
				) );

				$promise = $handler( $request, $options );

				return $promise->then(
					function ( \Psr\Http\Message\ResponseInterface $response ) use ( $collector, $request, $options, $url, $start_time, $trace ) {
						$collector->log_guzzle_request( $request, $response, null, $url, $start_time, $trace, $options );
						return $response;
					},
					function ( \Exception $exception ) use ( $collector, $request, $options, $url, $start_time, $trace ) {
						$collector->log_guzzle_request( $request, null, $exception, $url, $start_time, $trace, $options );
						throw $exception;
					}
				);
			};
		};
	}

}

# Load early in case a plugin is doing an HTTP request when it initialises instead of after the `plugins_loaded` hook
QM_Collectors::add( new QM_Collector_HTTP() );
