<?php

class ITSEC_Lib_REST {
	const LINK_REL = 'https://s.api.ithemes.com/l/ithemes-security/';
	const DATE_FORMAT = 'Y-m-d\TH:i:sP';

	private const P_24_HOURS = '24-hours';
	private const P_WEEK = 'week';
	private const P_30_DAYS = '30-days';

	/**
	 * Get the URI for an Solid Security link relation.
	 *
	 * @param string $relation
	 *
	 * @return string
	 */
	public static function get_link_relation( $relation ) {
		return self::LINK_REL . $relation;
	}

	/**
	 * Converts an error to a response object.
	 *
	 * This iterates over all error codes and messages to change it into a flat
	 * array. This enables simpler client behaviour, as it is represented as a
	 * list in JSON rather than an object/map.
	 *
	 * {@see WP_REST_Server::error_to_response()}
	 *
	 * @param WP_Error $error WP_Error instance.
	 *
	 * @return WP_REST_Response List of associative arrays with code and message keys.
	 */
	public static function error_to_response( WP_Error $error ) {
		if ( function_exists( 'rest_convert_error_to_response' ) ) {
			return rest_convert_error_to_response( $error );
		}

		$status = array_reduce(
			$error->get_all_error_data(),
			function ( $status, $error_data ) {
				return is_array( $error_data ) && isset( $error_data['status'] ) ? $error_data['status'] : $status;
			},
			500
		);

		$errors = array();

		foreach ( (array) $error->errors as $code => $messages ) {
			$all_data  = $error->get_all_error_data( $code );
			$last_data = array_pop( $all_data );

			foreach ( (array) $messages as $message ) {
				$formatted = array(
					'code'    => $code,
					'message' => $message,
					'data'    => $last_data,
				);

				if ( $all_data ) {
					$formatted['additional_data'] = $all_data;
				}

				$errors[] = $formatted;
			}
		}

		$data = $errors[0];
		if ( count( $errors ) > 1 ) {
			// Remove the primary error.
			array_shift( $errors );
			$data['additional_errors'] = $errors;
		}

		return new WP_REST_Response( $data, $status );
	}

	/**
	 * Get the status code to send from the list of statuses.
	 *
	 * @param int ...$statuses
	 *
	 * @return int
	 */
	public static function get_status( ...$statuses ) {
		if ( ! $statuses ) {
			return 200;
		}

		$prev = $statuses[0];

		foreach ( $statuses as $status ) {
			if ( $prev !== $status ) {
				return WP_Http::MULTI_STATUS;
			}

			$prev = $status;
		}

		return $prev;
	}

	/**
	 * Gets a Bearer token from the Authorization header.
	 *
	 * @param string $header
	 *
	 * @return string
	 */
	public static function get_token_from_auth_header( $header ) {
		$prefix = 'Bearer ';

		if ( 0 !== strpos( $header, $prefix ) ) {
			return '';
		}

		return trim( substr( $header, strlen( $prefix ) ) );
	}

	/**
	 * Gets the authorization status code to use.
	 *
	 * @param WP_User|null $user
	 *
	 * @return int
	 */
	public static function auth_code_required( $user = null ) {
		if ( func_num_args() === 0 ) {
			return rest_authorization_required_code();
		}

		return $user instanceof WP_User && $user->exists() ? 403 : 401;
	}

	/**
	 * Validates an IP address.
	 *
	 * @param string          $ip      The IP address.
	 * @param WP_REST_Request $request The request object.
	 * @param string          $param   The parameter name.
	 *
	 * @return true|WP_Error
	 */
	public static function validate_ip( $ip, $request, $param ) {
		if ( ! is_string( $ip ) || ! ITSEC_Lib_IP_Tools::ip_wild_to_ip_cidr( $ip ) ) {
			/* translators: %s: Parameter name. */
			return new WP_Error( 'rest_invalid_param', sprintf( __( '%s is not a valid IP address.', 'it-l10n-ithemes-security-pro' ), $param ) );
		}

		return true;
	}

	/**
	 * Sanitizes an IP address.
	 *
	 * @param string $ip The IP address.
	 *
	 * @return string|false
	 */
	public static function sanitize_ip( $ip ) {
		return ITSEC_Lib_IP_Tools::ip_wild_to_ip_cidr( $ip );
	}

	/**
	 * Retrieves an array of endpoint arguments from the item schema for the controller.
	 *
	 * @param array  $schema The JSON schema to use.
	 * @param string $method Optional. HTTP method of the request. The arguments for `CREATABLE` requests are
	 *                       checked for required values and may fall-back to a given default, this is not done
	 *                       on `EDITABLE` requests. Default WP_REST_Server::CREATABLE.
	 *
	 * @return array Endpoint arguments.
	 */
	public static function get_endpoint_args_for_schema( $schema, $method = WP_REST_Server::CREATABLE ) {
		$schema_properties       = ! empty( $schema['properties'] ) ? $schema['properties'] : array();
		$endpoint_args           = array();
		$valid_schema_properties = array(
			'title',
			'description',
			'type',
			'format',
			'enum',
			'items',
			'properties',
			'additionalProperties',
			'minimum',
			'maximum',
			'exclusiveMinimum',
			'exclusiveMaximum',
			'minLength',
			'maxLength',
			'pattern',
			'minItems',
			'maxItems',
			'uniqueItems',
		);

		foreach ( $schema_properties as $field_id => $params ) {

			// Arguments specified as `readonly` are not allowed to be set.
			if ( ! empty( $params['readonly'] ) ) {
				continue;
			}

			$endpoint_args[ $field_id ] = [
				'validate_callback' => 'rest_validate_request_arg',
				'sanitize_callback' => 'rest_sanitize_request_arg',
			];

			if ( WP_REST_Server::CREATABLE === $method && isset( $params['default'] ) ) {
				$endpoint_args[ $field_id ]['default'] = $params['default'];
			}

			if ( WP_REST_Server::CREATABLE === $method && ! empty( $params['required'] ) ) {
				$endpoint_args[ $field_id ]['required'] = $params['required'];
			}

			foreach ( $valid_schema_properties as $schema_prop ) {
				if ( isset( $params[ $schema_prop ] ) ) {
					$endpoint_args[ $field_id ][ $schema_prop ] = $params[ $schema_prop ];
				}
			}

			// Merge in any options provided by the schema property.
			if ( isset( $params['arg_options'] ) ) {

				// Only use required / default from arg_options on CREATABLE endpoints.
				if ( WP_REST_Server::CREATABLE !== $method ) {
					$params['arg_options'] = array_diff_key(
						$params['arg_options'],
						array(
							'required' => '',
							'default'  => '',
						)
					);
				}

				$endpoint_args[ $field_id ] = array_merge( $endpoint_args[ $field_id ], $params['arg_options'] );
			}
		}

		return $endpoint_args;
	}

	/**
	 * Retrieves an array of endpoint arguments from the item schema for the controller.
	 *
	 * @param array  $schema The JSON schema to use.
	 * @param string $method Optional. HTTP method of the request. The arguments for `CREATABLE` requests are
	 *                       checked for required values and may fall-back to a given default, this is not done
	 *                       on `EDITABLE` requests. Default WP_REST_Server::CREATABLE.
	 *
	 * @return array The sanitized schema.
	 */
	public static function sanitize_schema_for_output( $schema, $method = WP_REST_Server::CREATABLE ) {
		$args = static::get_endpoint_args_for_schema( $schema, $method );

		foreach ( $args as $arg => $arg_schema ) {
			unset( $arg_schema['validate_callback'], $arg_schema['sanitize_callback'] );

			$schema['properties'][ $arg ] = $arg_schema;
		}

		return $schema;
	}

	/**
	 * Gets the HTTP method used with the REST API.
	 *
	 * @return string
	 */
	public static function get_http_method() {
		if ( isset( $_GET['_method'] ) ) {
			return strtoupper( $_GET['_method'] );
		}

		if ( isset( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ) ) {
			return strtoupper( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] );
		}

		return strtoupper( $_SERVER['REQUEST_METHOD'] );
	}

	/**
	 * Adds a status code to a WP_Error object.
	 *
	 * @param int      $status
	 * @param WP_Error $error
	 * @param bool     $overwrite
	 */
	public static function add_status_to_error( int $status, WP_Error $error, bool $overwrite = false ) {
		$data = $error->get_error_data();

		if ( ! $data ) {
			$error->add_data( [ 'status' => $status ] );
		} elseif ( ! isset( $data['status'] ) || $overwrite ) {
			$error->add_data( array_merge( (array) $data, [ 'status' => $status ] ) );
		}
	}

	/**
	 * Makes a REST API URL from a REST API root and path.
	 *
	 * @param string $root
	 * @param string $path
	 *
	 * @return string
	 */
	public static function rest_url( string $root, string $path ): string {
		if ( strpos( $root, '?' ) !== - 1 ) {
			$path = str_replace( '?', '&', $path );
		}

		$path = preg_replace( '/^\//', '', $path );

		if ( strpos( $root, '?' ) !== - 1 ) {
			$path = str_replace( '?', '&', $path );
		}

		return $root . $path;
	}

	/**
	 * Adds pagination to a REST API response.
	 *
	 * @param WP_REST_Request  $request
	 * @param WP_REST_Response $response
	 * @param int              $count
	 * @param string           $path
	 *
	 * @return void
	 */
	public static function paginate( WP_REST_Request $request, WP_REST_Response $response, int $count, string $path ) {
		$max_pages = ceil( $count / $request['per_page'] );
		$response->header( 'X-WP-Total', $count );
		$response->header( 'X-WP-TotalPages', $max_pages );

		$request_params = $request->get_query_params();
		$base           = add_query_arg(
			map_deep( $request_params, function ( $value ) {
				if ( is_bool( $value ) ) {
					$value = $value ? 'true' : 'false';
				}

				return urlencode( $value );
			} ),
			rest_url( $path )
		);

		if ( $request['page'] > 1 ) {
			$prev_page = $request['page'] - 1;

			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}

			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}

		if ( $max_pages > $request['page'] ) {
			$next_page = $request['page'] + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );

			$response->link_header( 'next', $next_link );
		}
	}

	/**
	 * Get the definition for a period collection param.
	 *
	 * @return array
	 */
	public static function get_period_arg(): array {
		return [
			'default' => self::P_30_DAYS,
			'oneOf'   => [
				[
					'type'                 => 'object',
					'additionalProperties' => false,
					'properties'           => [
						'start' => [
							'type'     => 'string',
							'format'   => 'date-time',
							'required' => true,
						],
						'end'   => [
							'type'     => 'string',
							'format'   => 'date-time',
							'required' => true,
						],
					],
				],
				[
					'type' => 'string',
					'enum' => [
						self::P_24_HOURS,
						self::P_WEEK,
						self::P_30_DAYS,
					],
				],
			],
		];
	}

	/**
	 * Get the date range for the report query.
	 *
	 * @param string|array $period
	 *
	 * @return int[]|WP_Error
	 */
	public static function parse_period_arg( $period ) {
		if ( is_array( $period ) ) {
			if ( ! isset( $period['start'], $period['end'] ) ) {
				return new WP_Error(
					'itsec.rest.invalid-period',
					__( 'Invalid Period', 'it-l10n-ithemes-security-pro' ),
					[ 'status' => WP_Http::BAD_REQUEST ]
				);
			}

			if (
				false === ( $s = strtotime( $period['start'] ) ) ||
				false === ( $e = strtotime( $period['end'] ) )
			) {
				return new WP_Error(
					'itsec.rest.invalid-period',
					__( 'Invalid Period', 'it-l10n-ithemes-security-pro' ),
					[ 'status' => WP_Http::BAD_REQUEST ]
				);
			}

			return [ $s, $e ];
		}

		$now = ITSEC_Core::get_current_time_gmt();

		switch ( $period ) {
			case self::P_24_HOURS:
				return [
					( $now - DAY_IN_SECONDS )
					-
					( ( $now - DAY_IN_SECONDS ) % HOUR_IN_SECONDS ),
					$now,
				];
			case self::P_WEEK:
				return [
					strtotime( '-1 week', $now ),
					$now,
				];
			case self::P_30_DAYS:
				return [
					strtotime( '-30 days', $now ),
					$now,
				];
		}

		return new WP_Error(
			'itsec.rest.invalid-period',
			__( 'Invalid Period', 'it-l10n-ithemes-security-pro' ),
			[ 'status' => WP_Http::BAD_REQUEST ]
		);
	}
}
