<?php

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Api;

use Automattic\WooCommerce\Api\ApiException;
use Automattic\WooCommerce\Vendor\GraphQL\GraphQL;
use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\DocumentNode;
use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FieldNode;
use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\InlineFragmentNode;
use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\OperationDefinitionNode;
use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\SelectionSetNode;
use Automattic\WooCommerce\Vendor\GraphQL\Type\Schema;
use Automattic\WooCommerce\Vendor\GraphQL\Error\DebugFlag;
use Automattic\WooCommerce\Vendor\GraphQL\Validator\DocumentValidator;
use Automattic\WooCommerce\Vendor\GraphQL\Validator\Rules\DisableIntrospection;
use Automattic\WooCommerce\Vendor\GraphQL\Validator\Rules\QueryComplexity;
use Automattic\WooCommerce\Vendor\GraphQL\Validator\Rules\QueryDepth;

/**
 * Handles incoming GraphQL requests over the WooCommerce REST API.
 */
abstract class GraphQLController {
	/**
	 * Maximum nesting depth allowed in a GraphQL query.
	 *
	 * Queries exceeding this depth are rejected during validation, before any
	 * resolver runs. See {@see self::get_max_query_depth()} for the accessor.
	 */
	private const MAX_QUERY_DEPTH = 15;

	/**
	 * Maximum computed complexity score allowed for a GraphQL query.
	 *
	 * Complexity is the sum of per-field scores; connection fields multiply
	 * their child score by the requested page size. Queries exceeding this
	 * score are rejected during validation. See {@see self::get_max_query_complexity()}.
	 */
	private const MAX_QUERY_COMPLEXITY = 1000;

	/**
	 * Cached GraphQL schema instance.
	 *
	 * @var ?Schema
	 */
	private ?Schema $schema = null;

	/**
	 * Query cache / APQ resolver.
	 *
	 * @var QueryCache
	 */
	private QueryCache $query_cache;

	/**
	 * DI: injected by WooCommerce container.
	 *
	 * @internal
	 * @param QueryCache $query_cache The query cache instance.
	 */
	final public function init( QueryCache $query_cache ): void {
		$this->query_cache = $query_cache;
	}

	/**
	 * The maximum nesting depth allowed in a GraphQL query.
	 *
	 * Exposed as a method so the limit can become configurable — e.g. via a
	 * filter or store option — without requiring call-site changes.
	 */
	public static function get_max_query_depth(): int {
		return self::MAX_QUERY_DEPTH;
	}

	/**
	 * The maximum computed complexity score allowed for a GraphQL query.
	 *
	 * Exposed as a method so the limit can become configurable — e.g. via a
	 * filter or store option — without requiring call-site changes.
	 */
	public static function get_max_query_complexity(): int {
		return self::MAX_QUERY_COMPLEXITY;
	}

	/**
	 * Register the GraphQL REST route.
	 */
	public function register(): void {
		$methods = Main::filter_methods_against_settings( array( 'GET', 'POST' ) );
		if ( empty( $methods ) ) {
			return;
		}

		register_rest_route(
			'wc',
			'/graphql',
			array(
				'methods'             => $methods,
				'callback'            => array( $this, 'handle_request' ),
				// Auth is handled per-query/mutation.
				'permission_callback' => '__return_true',
			)
		);
	}

	/**
	 * Handle an incoming GraphQL request.
	 *
	 * @param \WP_REST_Request $request The REST request.
	 */
	public function handle_request( \WP_REST_Request $request ): \WP_REST_Response {
		try {
			return $this->process_request( $request );
		} catch ( \Throwable $e ) {
			$output = array(
				'errors' => array(
					$this->format_exception( $e, $request ),
				),
			);

			$status = $this->get_error_status( $output['errors'] );
			return new \WP_REST_Response( $output, $status );
		}
	}

	/**
	 * Process the GraphQL request. Extracted so that handle_request() can
	 * wrap everything in a single try/catch that respects debug mode.
	 *
	 * @param \WP_REST_Request $request The REST request.
	 */
	private function process_request( \WP_REST_Request $request ): \WP_REST_Response {
		// 2. Parse request. GET query-string `variables` and `extensions`
		// arrive as JSON strings; decode_json_param() unifies them with the
		// already-decoded-array path from POST bodies and rejects malformed
		// or non-object payloads up front so they surface as HTTP 400
		// INVALID_ARGUMENT instead of as confusing resolver errors (null
		// decode) or HTTP 500 TypeErrors (scalar decode).
		$query          = $request->get_param( 'query' );
		$operation_name = $request->get_param( 'operationName' );
		$variables      = $this->decode_json_param( $request->get_param( 'variables' ), 'variables' );
		$extensions     = $this->decode_json_param( $request->get_param( 'extensions' ), 'extensions' );

		// 3. Resolve query (cache lookup / APQ / parse).
		$source = $this->query_cache->resolve( $query, $extensions );
		if ( is_array( $source ) ) {
			return new \WP_REST_Response( $source, $this->get_resolve_error_status( $source ) );
		}

		// 4. Reject mutations over GET (GraphQL over HTTP spec).
		if ( 'GET' === $request->get_method() && $this->document_has_mutation( $source, $operation_name ) ) {
			return new \WP_REST_Response(
				array(
					'errors' => array(
						array(
							'message'    => 'Mutations are not allowed over GET requests. Use POST instead.',
							'extensions' => array( 'code' => 'METHOD_NOT_ALLOWED' ),
						),
					),
				),
				405
			);
		}

		// 5. Load schema.
		$schema = $this->get_schema();

		// 6. Build validation rules.
		// A single QueryComplexity instance is kept so its computed score can
		// be surfaced in the debug extensions after execution.
		$complexity_rule    = new QueryComplexity( self::get_max_query_complexity() );
		$validation_rules   = array_values( DocumentValidator::allRules() );
		$validation_rules[] = new QueryDepth( self::get_max_query_depth() );
		$validation_rules[] = $complexity_rule;
		if ( ! $this->is_introspection_allowed( $request ) ) {
			$validation_rules[] = new DisableIntrospection( DisableIntrospection::ENABLED );
		}

		// 7. Execute.
		$result = GraphQL::executeQuery(
			schema: $schema,
			source: $source,
			variableValues: $variables,
			operationName: $operation_name,
			validationRules: $validation_rules,
		);

		// Install an error formatter that guarantees every error carries an
		// `extensions.code`. Our resolvers route everything through
		// Utils::execute_command / Utils::authorize_command, which already
		// translate domain exceptions (ApiException, InvalidArgumentException,
		// generic Throwable) into coded GraphQL errors at the throw site.
		// What reaches us uncoded here is webonyx-native validation and
		// execution output, so we infer from webonyx's ClientAware signal:
		// client-safe errors become BAD_USER_INPUT (400), the rest become
		// INTERNAL_ERROR (500).
		//
		// In debug mode the same formatter also walks the previous-exception
		// chain so wrapped errors (e.g. a \ValueError caught by a resolver and
		// re-thrown as INTERNAL_ERROR) stay visible to the developer instead
		// of being masked behind the generic "Internal server error" message.
		$debug_mode = $this->is_debug_mode( $request );
		$result->setErrorFormatter(
			function ( \Throwable $error ) use ( $debug_mode ): array {
				$formatted = \Automattic\WooCommerce\Vendor\GraphQL\Error\FormattedError::createFromException( $error );

				if ( ! isset( $formatted['extensions']['code'] ) ) {
					$client_safe                     = $error instanceof \Automattic\WooCommerce\Vendor\GraphQL\Error\ClientAware && $error->isClientSafe();
					$formatted['extensions']['code'] = $client_safe ? 'BAD_USER_INPUT' : 'INTERNAL_ERROR';
				}

				// SerializationError (thrown during schema-type coercion, e.g. when
				// a resolver returns an Int that doesn't fit 32 bits) extends
				// \Exception rather than webonyx's ClientAware Error, so it lands
				// in the INTERNAL_ERROR bucket above. Its message is actually
				// client-actionable ("value out of range — send smaller inputs"),
				// so promote it to BAD_USER_INPUT when it shows up anywhere in
				// the previous-exception chain.
				if ( 'BAD_USER_INPUT' !== ( $formatted['extensions']['code'] ?? null ) ) {
					$cursor = $error;
					while ( $cursor instanceof \Throwable ) {
						if ( $cursor instanceof \Automattic\WooCommerce\Vendor\GraphQL\Error\SerializationError ) {
							$formatted['extensions']['code'] = 'BAD_USER_INPUT';
							break;
						}
						$cursor = $cursor->getPrevious();
					}
				}

				if ( $debug_mode ) {
					$chain = $this->extract_previous_chain( $error );
					if ( ! empty( $chain ) ) {
						$formatted['extensions']['previous'] = $chain;
					}
				}

				return $formatted;
			}
		);

		$debug_flags = $this->get_debug_flags( $request );
		$output      = $result->toArray( $debug_flags );

		// 8. Debug-mode metrics: expose the computed complexity and depth so
		// clients tuning queries can see what the server scored the request at.
		if ( $this->is_debug_mode( $request ) ) {
			if ( ! isset( $output['extensions'] ) ) {
				$output['extensions'] = array();
			}
			if ( ! isset( $output['extensions']['debug'] ) ) {
				$output['extensions']['debug'] = array();
			}
			$output['extensions']['debug']['complexity'] = $complexity_rule->getQueryComplexity();
			$output['extensions']['debug']['depth']      = $this->compute_query_depth( $source, $operation_name );
		}

		// 9. Determine HTTP status code. GraphQL emits `data: { field: null }`
		// for nullable root fields even when the resolver errored, so gating
		// the status override on `data` being absent would leave nearly every
		// error response on HTTP 200. Always derive the status from the
		// errors array when one is present — clients that need "200 with
		// partial data" semantics can still read the `errors` array.
		$status = isset( $output['errors'] ) ? $this->get_error_status( $output['errors'] ) : 200;

		return new \WP_REST_Response( $output, $status );
	}

	/**
	 * Build and cache the GraphQL schema.
	 */
	private function get_schema(): Schema {
		if ( null === $this->schema ) {
			$this->schema = $this->build_schema();
		}
		return $this->schema;
	}

	/**
	 * Construct the GraphQL schema.
	 *
	 * Implemented by the autogenerated subclass emitted by ApiBuilder
	 * (both for WooCommerce core and for sibling plugins that reuse this
	 * infrastructure) so the base class stays agnostic to any specific
	 * autogenerated namespace.
	 */
	abstract protected function build_schema(): Schema;

	/**
	 * Decode an optional JSON-object param (`variables` / `extensions`) into an array.
	 *
	 * WP_REST_Request delivers POST-body params as already-decoded arrays,
	 * but GET query-string equivalents arrive as raw JSON strings. This
	 * helper unifies the two and rejects malformed JSON or non-object
	 * payloads with an InvalidArgumentException — which handle_request()
	 * surfaces as HTTP 400 INVALID_ARGUMENT, rather than letting a null
	 * decode slip through as "no variables" or a scalar decode trigger a
	 * downstream TypeError / HTTP 500.
	 *
	 * @param mixed  $value The param value from WP_REST_Request::get_param().
	 * @param string $name  The param name, used in error messages.
	 * @return array The decoded object, or an empty array when the param is omitted / empty / JSON null.
	 * @throws \InvalidArgumentException When the payload is not a JSON object or not valid JSON.
	 */
	private function decode_json_param( $value, string $name ): array {
		if ( null === $value ) {
			return array();
		}
		if ( is_array( $value ) ) {
			return $value;
		}
		// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON.
		if ( ! is_string( $value ) ) {
			throw new \InvalidArgumentException(
				sprintf( 'Argument `%s` must be a JSON object or omitted.', $name )
			);
		}
		if ( '' === $value ) {
			return array();
		}
		$decoded = json_decode( $value, true );
		if ( JSON_ERROR_NONE !== json_last_error() ) {
			throw new \InvalidArgumentException(
				sprintf( 'Argument `%s` is not valid JSON: %s', $name, json_last_error_msg() )
			);
		}
		if ( null === $decoded ) {
			// Literal "null" JSON payload — treat as omitted.
			return array();
		}
		if ( ! is_array( $decoded ) ) {
			throw new \InvalidArgumentException(
				sprintf( 'Argument `%s` must be a JSON object (got %s).', $name, gettype( $decoded ) )
			);
		}
		return $decoded;
		// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
	}

	/**
	 * Determine debug flags based on WP_DEBUG, user role, and query string.
	 *
	 * @param \WP_REST_Request $request The REST request.
	 */
	private function get_debug_flags( \WP_REST_Request $request ): int {
		if ( ! $this->is_debug_mode( $request ) ) {
			return DebugFlag::NONE;
		}
		return DebugFlag::INCLUDE_DEBUG_MESSAGE | DebugFlag::INCLUDE_TRACE;
	}

	/**
	 * Check whether GraphQL introspection is allowed for this request.
	 *
	 * Introspection is permitted if either condition holds:
	 * - The request is in debug mode ({@see self::is_debug_mode()}).
	 * - The caller has the `manage_woocommerce` capability.
	 *
	 * Gating on capability rather than mere authentication keeps the full
	 * schema (including admin-only mutations) hidden from low-privilege
	 * roles such as `customer`, which every storefront account is assigned
	 * at checkout — while still allowing admin tooling (e.g. GraphiQL-like
	 * explorers) to query it.
	 *
	 * @param \WP_REST_Request $request The REST request.
	 */
	private function is_introspection_allowed( \WP_REST_Request $request ): bool {
		return $this->is_debug_mode( $request ) || current_user_can( 'manage_woocommerce' );
	}

	/**
	 * Check if debug mode is active.
	 *
	 * Debug mode is active when either:
	 * - WP_DEBUG is enabled AND the current user is an administrator (or in a local environment).
	 * - The current user is an administrator (or in a local environment) AND `_debug=1` is in the query string.
	 *
	 * @param \WP_REST_Request $request The REST request.
	 */
	private function is_debug_mode( \WP_REST_Request $request ): bool {
		if ( ! $this->is_local_environment() && ! current_user_can( 'manage_options' ) ) {
			return false;
		}

		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			return true;
		}

		return '1' === $request->get_param( '_debug' );
	}

	/**
	 * Format a caught exception into a GraphQL error array.
	 *
	 * @param \Throwable       $e       The caught exception.
	 * @param \WP_REST_Request $request The REST request.
	 */
	private function format_exception( \Throwable $e, \WP_REST_Request $request ): array {
		if ( $e instanceof ApiException ) {
			// Caller-supplied extensions come first so the canonical
			// getErrorCode() can't be silently overridden by an extensions
			// entry keyed 'code'. Mirrors the same invariant enforced by
			// Utils::translate_exceptions() for the execute/authorize paths.
			$error = array(
				'message'    => $e->getMessage(),
				'extensions' => array_merge(
					$e->getExtensions(),
					array( 'code' => $e->getErrorCode() )
				),
			);
		} elseif ( $e instanceof \InvalidArgumentException ) {
			$error = array(
				'message'    => $e->getMessage(),
				'extensions' => array( 'code' => 'INVALID_ARGUMENT' ),
			);
		} else {
			$error = array(
				'message'    => 'An unexpected error occurred.',
				'extensions' => array( 'code' => 'INTERNAL_ERROR' ),
			);
		}

		if ( $this->is_debug_mode( $request ) ) {
			$error['extensions']['debug'] = array(
				'message' => $e->getMessage(),
				'file'    => $e->getFile(),
				'line'    => $e->getLine(),
				'trace'   => $e->getTraceAsString(),
			);

			$chain = $this->extract_previous_chain( $e );
			if ( ! empty( $chain ) ) {
				$error['extensions']['debug']['previous'] = $chain;
			}
		}

		return $error;
	}

	/**
	 * Walk the `getPrevious()` chain of a Throwable and return one entry per
	 * wrapped exception. Used in debug mode so that resolver-level wrappers
	 * (which bury the real cause behind a generic "INTERNAL_ERROR") still
	 * surface the underlying class/message/file/line/trace.
	 *
	 * @param \Throwable $e The outermost exception.
	 * @return array<int, array{class: string, message: string, file: string, line: int, trace: string[]}>
	 */
	private function extract_previous_chain( \Throwable $e ): array {
		$chain = array();
		for ( $prev = $e->getPrevious(); null !== $prev; $prev = $prev->getPrevious() ) {
			$chain[] = array(
				'class'   => get_class( $prev ),
				'message' => $prev->getMessage(),
				'file'    => $prev->getFile(),
				'line'    => $prev->getLine(),
				'trace'   => explode( "\n", $prev->getTraceAsString() ),
			);
		}
		return $chain;
	}

	/**
	 * Mapping from machine-readable error codes to HTTP status codes.
	 *
	 * Any code not listed here defaults to 500, so unknown/unrecognised codes
	 * from third-party resolvers stay on the safe side. The error formatter
	 * installed in process_request() guarantees every error carries a code
	 * from this table before get_error_status() inspects it.
	 */
	private const ERROR_STATUS_MAP = array(
		'UNAUTHORIZED'              => 401,
		'FORBIDDEN'                 => 403,
		'NOT_FOUND'                 => 404,
		'METHOD_NOT_ALLOWED'        => 405,
		'INVALID_ARGUMENT'          => 400,
		'BAD_USER_INPUT'            => 400,
		'GRAPHQL_PARSE_ERROR'       => 400,
		'GRAPHQL_PARSE_FAILED'      => 400,
		'GRAPHQL_VALIDATION_FAILED' => 400,
		'VALIDATION_ERROR'          => 422,
		'INTERNAL_ERROR'            => 500,
	);

	/**
	 * Determine the HTTP status code from an array of GraphQL errors.
	 *
	 * Applies the code-to-status lookup to each error and returns the worst
	 * (highest) status seen. A single genuine 5xx among mixed errors surfaces
	 * as 500, which is the more useful signal for monitoring and logs.
	 *
	 * @param array $errors The GraphQL errors array.
	 */
	private function get_error_status( array $errors ): int {
		$status = 200;
		foreach ( $errors as $error ) {
			$code   = $error['extensions']['code'] ?? null;
			$mapped = self::ERROR_STATUS_MAP[ $code ] ?? 500;
			if ( $mapped > $status ) {
				$status = $mapped;
			}
		}
		return $status;
	}

	/**
	 * Determine the HTTP status code for an error returned by QueryCache::resolve().
	 *
	 * PERSISTED_QUERY_NOT_FOUND uses 200 per the Apollo APQ convention (protocol signal, not error).
	 *
	 * @param array $response The error response array from resolve().
	 */
	private function get_resolve_error_status( array $response ): int {
		$code = $response['errors'][0]['extensions']['code'] ?? '';

		if ( 'PERSISTED_QUERY_NOT_FOUND' === $code ) {
			return 200;
		}

		return 400;
	}

	/**
	 * Compute the maximum nesting depth of the executing operation.
	 *
	 * Field selections add one level; inline fragments do not. Named-fragment
	 * spreads are not expanded here — the depth returned is therefore a lower
	 * bound when spreads are present. The webonyx QueryDepth validation rule
	 * (which does expand spreads) remains the authoritative gate; this helper
	 * only produces the metric surfaced in the debug extensions.
	 *
	 * @param DocumentNode $document       The parsed GraphQL document.
	 * @param ?string      $operation_name The requested operation name, if any.
	 */
	private function compute_query_depth( DocumentNode $document, ?string $operation_name ): int {
		$max = 0;
		foreach ( $document->definitions as $definition ) {
			if ( ! $definition instanceof OperationDefinitionNode ) {
				continue;
			}

			if ( null !== $operation_name && ( $definition->name->value ?? null ) !== $operation_name ) {
				continue;
			}

			$max = max( $max, $this->walk_depth( $definition->selectionSet, 0 ) );
		}

		return $max;
	}

	/**
	 * Recursively walk a selection set and return the maximum depth reached.
	 *
	 * @param ?SelectionSetNode $selection_set The selection set to walk, or null for a leaf.
	 * @param int               $depth         The depth of the selection set's parent.
	 */
	private function walk_depth( ?SelectionSetNode $selection_set, int $depth ): int {
		if ( null === $selection_set ) {
			return $depth;
		}

		$max = $depth;
		foreach ( $selection_set->selections as $selection ) {
			if ( $selection instanceof FieldNode ) {
				$max = max( $max, $this->walk_depth( $selection->selectionSet, $depth + 1 ) );
			} elseif ( $selection instanceof InlineFragmentNode ) {
				$max = max( $max, $this->walk_depth( $selection->selectionSet, $depth ) );
			}
		}

		return $max;
	}

	/**
	 * Check whether the parsed document contains a mutation operation.
	 *
	 * When an operation name is given, only that operation is checked;
	 * otherwise any mutation definition in the document triggers a match.
	 *
	 * @param DocumentNode $document       The parsed GraphQL document.
	 * @param ?string      $operation_name The requested operation name, if any.
	 */
	private function document_has_mutation( DocumentNode $document, ?string $operation_name ): bool {
		foreach ( $document->definitions as $definition ) {
			if ( ! $definition instanceof OperationDefinitionNode ) {
				continue;
			}

			if ( null !== $operation_name && ( $definition->name->value ?? null ) !== $operation_name ) {
				continue;
			}

			if ( 'mutation' === $definition->operation ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Check if running in a local/development environment.
	 *
	 * Prefers {@see wp_get_environment_type()} when available. Otherwise
	 * parses the site URL and performs a case-insensitive *exact* match
	 * against the hostname — not a substring check, to avoid matching
	 * impostor domains like `mylocalhost.com` or `127.0.0.1.attacker.example`.
	 */
	private function is_local_environment(): bool {
		if ( function_exists( 'wp_get_environment_type' ) && 'local' === wp_get_environment_type() ) {
			return true;
		}

		$host = wp_parse_url( get_site_url(), PHP_URL_HOST );
		if ( ! is_string( $host ) ) {
			return false;
		}

		$host = strtolower( $host );
		return 'localhost' === $host || '127.0.0.1' === $host;
	}
}
