<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
 * `WP_REST_Controller` is basically a wrapper for `register_rest_route()`
 * `WPCOM_REST_API_V2_Field_Controller` is a mostly-analogous wrapper for `register_rest_field()`
 *
 * @todo - nicer API for array values?
 *
 * @package automattic/jetpack
 */

/**
 * Abstract WPCOM_REST_API_V2_Field_Controller class extended for different fields needed in the Jetpack plugin.
 */
abstract class WPCOM_REST_API_V2_Field_Controller {
	/**
	 * The REST Object Type(s) to which the field should be added.
	 *
	 * @var string|string[]
	 */
	protected $object_type;

	/**
	 * The name of the REST API field to add.
	 *
	 * @var string
	 */
	protected $field_name;

	/**
	 * Constructor
	 */
	public function __construct() {
		if ( ! $this->object_type ) {
			_doing_it_wrong(
				'WPCOM_REST_API_V2_Field_Controller::$object_type',
				sprintf(
					/* translators: %s: object_type */
					esc_html__( "Property '%s' must be overridden.", 'jetpack' ),
					'object_type'
				),
				'jetpack-6.8'
			);
			return;
		}

		if ( ! $this->field_name ) {
			_doing_it_wrong(
				'WPCOM_REST_API_V2_Field_Controller::$field_name',
				sprintf(
					/* translators: %s: field_name */
					esc_html__( "Property '%s' must be overridden.", 'jetpack' ),
					'field_name'
				),
				'jetpack-6.8'
			);
			return;
		}

		add_action( 'rest_api_init', array( $this, 'register_fields' ) );

		// do this again later to collect any CPTs that get registered later.
		add_action( 'restapi_theme_init', array( $this, 'register_fields' ), 20 );
	}

	/**
	 * Registers the field with the appropriate schema and callbacks.
	 */
	public function register_fields() {
		foreach ( (array) $this->object_type as $object_type ) {
			if ( $this->is_registered( $object_type ) ) {
				continue;
			}
			register_rest_field(
				$object_type,
				$this->field_name,
				array(
					'get_callback'    => array( $this, 'get_for_response' ),
					'update_callback' => array( $this, 'update_from_request' ),
					'schema'          => $this->get_schema(),
				)
			);
		}
	}

	/**
	 * Checks if the field is already registered for the object_type
	 *
	 * @param string $object_type The name of the object type.
	 * @return boolean Whether the field has been registered for the type.
	 */
	public function is_registered( $object_type ) {
		global $wp_rest_additional_fields;

		return ! empty( $wp_rest_additional_fields[ $object_type ][ $this->field_name ] );
	}

	/**
	 * Ensures the response matches the schema and request context.
	 *
	 * @param mixed           $value   Value passed in request.
	 * @param WP_REST_Request $request WP API request.
	 *
	 * @return mixed
	 */
	private function prepare_for_response( $value, $request ) {
		$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
		$schema  = $this->get_schema();

		$is_valid = rest_validate_value_from_schema( $value, $schema, $this->field_name );
		if ( is_wp_error( $is_valid ) ) {
			return $is_valid;
		}

		return $this->filter_response_by_context( $value, $schema, $context );
	}

	/**
	 * Returns the schema's default value
	 *
	 * If there is no default, returns the type's falsey value.
	 *
	 * @param array $schema Schema to validate against.
	 *
	 * @return mixed
	 */
	final public function get_default_value( $schema ) {
		if ( isset( $schema['default'] ) ) {
			return $schema['default'];
		}

		// If you have something more complicated, use $schema['default'].
		switch ( $schema['type'] ?? 'null' ) {
			case 'string':
				return '';
			case 'integer':
			case 'number':
				return 0;
			case 'object':
				return (object) array();
			case 'array':
				return array();
			case 'boolean':
				return false;
			case 'null':
			default:
				return null;
		}
	}

	/**
	 * The field's wrapped getter. Does permission checks and output preparation.
	 *
	 * This cannot be extended: implement `->get()` instead.
	 *
	 * @param mixed           $object_data Probably an array. Whatever the endpoint returns.
	 * @param string          $field_name  Should always match `->field_name`.
	 * @param WP_REST_Request $request     WP API request.
	 * @param string          $object_type Should always match `->object_type`.
	 *
	 * @return mixed
	 */
	final public function get_for_response( $object_data, $field_name, $request, $object_type ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		$permission_check = $this->get_permission_check( $object_data, $request );

		if ( ! $permission_check ) {
			_doing_it_wrong(
				'WPCOM_REST_API_V2_Field_Controller::get_permission_check',
				sprintf(
					/* translators: %s: get_permission_check() */
					esc_html__( "Method '%s' must return either true or WP_Error.", 'jetpack' ),
					'get_permission_check'
				),
				'jetpack-6.8'
			);
			return $this->get_default_value( $this->get_schema() );
		}

		if ( is_wp_error( $permission_check ) ) {
			return $this->get_default_value( $this->get_schema() );
		}

		$value = $this->get( $object_data, $request );

		return $this->prepare_for_response( $value, $request );
	}

	/**
	 * The field's wrapped setter. Does permission checks.
	 *
	 * This cannot be extended: implement `->update()` instead.
	 *
	 * @param mixed           $value       The new value for the field.
	 * @param mixed           $object_data Probably a WordPress object (e.g., WP_Post).
	 * @param string          $field_name  Should always match `->field_name`.
	 * @param WP_REST_Request $request     WP API request.
	 * @param string          $object_type Should always match `->object_type`.
	 * @return void|WP_Error
	 */
	final public function update_from_request( $value, $object_data, $field_name, $request, $object_type ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		$permission_check = $this->update_permission_check( $value, $object_data, $request );

		if ( ! $permission_check ) {
			_doing_it_wrong(
				'WPCOM_REST_API_V2_Field_Controller::update_permission_check',
				sprintf(
					/* translators: %s: update_permission_check() */
					esc_html__( "Method '%s' must return either true or WP_Error.", 'jetpack' ),
					'update_permission_check'
				),
				'jetpack-6.8'
			);

			return new WP_Error(
				'invalid_user_permission',
				sprintf(
					/* translators: %s: the name of an API response field */
					__( "You are not allowed to access the '%s' field.", 'jetpack' ),
					$this->field_name
				)
			);
		}

		if ( is_wp_error( $permission_check ) ) {
			return $permission_check;
		}

		$updated = $this->update( $value, $object_data, $request );

		if ( is_wp_error( $updated ) ) {
			return $updated;
		}
	}

	/**
	 * Permission Check for the field's getter. Must be implemented in the inheriting class.
	 *
	 * @param mixed           $object_data Whatever the endpoint would return for its response.
	 * @param WP_REST_Request $request     WP API request.
	 *
	 * @return true|WP_Error
	 */
	public function get_permission_check( $object_data, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		_doing_it_wrong(
			'WPCOM_REST_API_V2_Field_Controller::get_permission_check',
			sprintf(
				/* translators: %s: method name. */
				esc_html__( "Method '%s' must be overridden.", 'jetpack' ),
				__METHOD__
			),
			'jetpack-6.8'
		);
		return null;
	}

	/**
	 * The field's "raw" getter. Must be implemented in the inheriting class.
	 *
	 * @param mixed           $object_data Whatever the endpoint would return for its response.
	 * @param WP_REST_Request $request     WP API request.
	 * @return mixed
	 */
	public function get( $object_data, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		_doing_it_wrong(
			'WPCOM_REST_API_V2_Field_Controller::get',
			sprintf(
				/* translators: %s: method name. */
				esc_html__( "Method '%s' must be overridden.", 'jetpack' ),
				__METHOD__
			),
			'jetpack-6.8'
		);
	}

	/**
	 * Permission Check for the field's setter. Must be implemented in the inheriting class.
	 *
	 * @param mixed           $value The new value for the field.
	 * @param mixed           $object_data Probably a WordPress object (e.g., WP_Post).
	 * @param WP_REST_Request $request     WP API request.
	 *
	 * @return true|WP_Error
	 */
	public function update_permission_check( $value, $object_data, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		_doing_it_wrong(
			'WPCOM_REST_API_V2_Field_Controller::update_permission_check',
			sprintf(
				/* translators: %s: method name. */
				esc_html__( "Method '%s' must be overridden.", 'jetpack' ),
				__METHOD__
			),
			'jetpack-6.8'
		);
		return null;
	}

	/**
	 * The field's "raw" setter. Must be implemented in the inheriting class.
	 *
	 * @param mixed           $value The new value for the field.
	 * @param mixed           $object_data Probably a WordPress object (e.g., WP_Post).
	 * @param WP_REST_Request $request     WP API request.
	 *
	 * @return mixed
	 */
	public function update( $value, $object_data, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		_doing_it_wrong(
			'WPCOM_REST_API_V2_Field_Controller::update',
			sprintf(
				/* translators: %s: method name. */
				esc_html__( "Method '%s' must be overridden.", 'jetpack' ),
				__METHOD__
			),
			'jetpack-6.8'
		);
	}

	/**
	 * The JSON Schema for the field
	 *
	 * @link https://json-schema.org/understanding-json-schema/
	 * As of WordPress 5.0, Core currently understands:
	 * * type
	 *   * string - not minLength, not maxLength, not pattern
	 *   * integer - minimum, maximum, exclusiveMinimum, exclusiveMaximum, not multipleOf
	 *   * number  - minimum, maximum, exclusiveMinimum, exclusiveMaximum, not multipleOf
	 *   * boolean
	 *   * null
	 *   * object - properties, additionalProperties, not propertyNames, not dependencies, not patternProperties, not required
	 *   * array: only lists, not tuples - items, not minItems, not maxItems, not uniqueItems, not contains
	 * * enum
	 * * format
	 *   * date-time
	 *   * email
	 *   * ip
	 *   * uri
	 * As of WordPress 5.0, Core does not support:
	 * * Multiple type: `type: [ 'string', 'integer' ]`
	 * * $ref, allOf, anyOf, oneOf, not, const
	 *
	 * @return array
	 */
	public function get_schema() {
		_doing_it_wrong(
			'WPCOM_REST_API_V2_Field_Controller::get_schema',
			sprintf(
				/* translators: %s: method name. */
				esc_html__( "Method '%s' must be overridden.", 'jetpack' ),
				__METHOD__
			),
			'jetpack-6.8'
		);
		return null;
	}

	/**
	 * Ensure that our request matches its expected context.
	 *
	 * @param array  $schema  Schema to validate against.
	 * @param string $context REST API Request context.
	 * @return bool
	 */
	private function is_valid_for_context( $schema, $context ) {
		return empty( $schema['context'] ) || in_array( $context, $schema['context'], true );
	}

	/**
	 * Removes properties that should not appear in the current
	 * request's context
	 *
	 * $context is a Core REST API Framework request attribute that is
	 * always one of:
	 * * view (what you see on the blog)
	 * * edit (what you see in an editor)
	 * * embed (what you see in, e.g., an oembed)
	 *
	 * Fields (and sub-fields, and sub-sub-...) can be flagged for a
	 * set of specific contexts via the field's schema.
	 *
	 * The Core API will filter out top-level fields with the wrong
	 * context, but will not recurse deeply enough into arrays/objects
	 * to remove all levels of sub-fields with the wrong context.
	 *
	 * This function handles that recursion.
	 *
	 * @param mixed  $value   Value passed to API request.
	 * @param array  $schema  Schema to validate against.
	 * @param string $context REST API Request context.
	 *
	 * @return mixed Filtered $value
	 */
	final public function filter_response_by_context( $value, $schema, $context ) {
		if ( ! $this->is_valid_for_context( $schema, $context ) ) {
			// We use this intentionally odd looking WP_Error object
			// internally only in this recursive function (see below
			// in the `object` case). It will never be output by the REST API.
			// If we return this for the top level object, Core
			// correctly remove the top level object from the response
			// for us.
			return new WP_Error( '__wrong-context__' );
		}

		switch ( $schema['type'] ) {
			case 'array':
				if ( ! isset( $schema['items'] ) ) {
					return $value;
				}

				// Shortcircuit if we know none of the items are valid for this context.
				// This would only happen in a strangely written schema.
				if ( ! $this->is_valid_for_context( $schema['items'], $context ) ) {
					return array();
				}

				// Recurse to prune sub-properties of each item.
				foreach ( $value as $key => $item ) {
					$value[ $key ] = $this->filter_response_by_context( $item, $schema['items'], $context );
				}

				return $value;
			case 'object':
				if ( ! isset( $schema['properties'] ) ) {
					return $value;
				}

				foreach ( $value as $field_name => $field_value ) {
					if ( isset( $schema['properties'][ $field_name ] ) ) {
						$field_value = $this->filter_response_by_context( $field_value, $schema['properties'][ $field_name ], $context );
						if ( is_wp_error( $field_value ) && '__wrong-context__' === $field_value->get_error_code() ) {
							unset( $value[ $field_name ] );
						} else {
							// Respect recursion that pruned sub-properties of each property.
							$value[ $field_name ] = $field_value;
						}
					}
				}

				return (object) $value;
		}

		return $value;
	}
}
