<?php
/**
 * Defines main REST API class
 *
 * @link https://personalizewp.com/
 * @since 3.2.0
 *
 * @package PersonalizeWP
 * @subpackage PersonalizeWP/includes
 */

namespace PersonalizeWP;

use PersonalizeWP\Traits\SingletonTrait;
use PersonalizeWP\REST\Blocks_Controller;
use PersonalizeWP\REST\Visitor_Controller;
use PersonalizeWP\REST\Activity_Controller;
use PersonalizeWP\Admin\REST\Settings_Controller;
use PersonalizeWP\Admin\REST\Forms_Controller;
use PersonalizeWP\Admin\REST\Products_Controller;
use PersonalizeWP\Admin\REST\Subdivisions_Controller;


defined( 'ABSPATH' ) || exit; // Exit if accessed directly.

/**
 * API class
 * Initialises all individual controllers for specific REST endpoints.
 * Adds security elements to all endpoints and provides utility validation functions.
 * Functionality previously part of PublicFacing and Pro API classes.
 */
class API {

	use SingletonTrait;

	/**
	 * Holds Instance of plugin object
	 *
	 * @var PersonalizeWP
	 */
	private $plugin;

	/**
	 * The namespace for the API endpoints, without versioning.
	 *
	 * @since 2.6.0
	 *
	 * @var string
	 */
	protected $namespace = 'personalizewp/';

	/**
	 * Option used for Auth Secret key
	 *
	 * @since 3.1.0
	 *
	 * @var string
	 */
	protected $auth_secret = '_personalizewp_auth_secret_key';

	/**
	 * Option used to store all Auth tokens, URL:Token pairs
	 *
	 * @since 3.1.0
	 *
	 * @var string
	 */
	protected $auth_tokens = '_personalizewp_auth_tokens';

	/**
	 * Initialize the class and set its properties.
	 */
	private function __construct() {
		$this->plugin = \personalizewp();
	}

	/**
	 * Execute WordPress hooks
	 */
	public function setup() {
		add_action( 'rest_api_init', [ $this, 'register_public_endpoints' ] );
		add_action( 'rest_api_init', [ $this, 'register_admin_routes' ] );
		add_action( 'wp_loaded', [ $this, 'populate_default_auth_token' ] );

		// Send CORS server headers and authenticate API tokens, hooked in early before processing request.
		add_filter( 'rest_pre_dispatch', array( $this, 'send_cors_headers_validate_auth_tokens' ), 10, 3 );
		add_filter( 'rest_allowed_cors_headers', array( $this, 'allowed_cors_headers' ) );
		add_filter( 'rest_exposed_cors_headers', array( $this, 'exposed_cors_headers' ) );
	}

	/**
	 * Sends Cross Origin Resource headers, and validate any custom set API auth tokens.
	 *
	 * For security reasons, browsers restrict cross-origin HTTP requests initiated from scripts.
	 * This overrides that by providing access should the request be for PersonalizeWP.
	 *
	 * These checks prevent access to the API from non-allowed origins. By default, the WordPress
	 * REST API allows access from any origin.
	 *
	 * @access public
	 *
	 * @since 3.1.0 Introduced.
	 *
	 * @uses get_http_origin()
	 *
	 * @param mixed            $result  Response to replace the requested version with. Can be anything
	 *                                  a normal endpoint can return, or null to not hijack the request.
	 * @param \WP_REST_Server  $server  Server instance.
	 * @param \WP_REST_Request $request Request used to generate the response.
	 *
	 * @return mixed
	 */
	public function send_cors_headers_validate_auth_tokens( $result, $server, $request ) {

		// Check if it's a restricted endpoint of ours.
		if ( ! $this->is_restricted_endpoint( $request ) ) {
			// If not defer back.
			return $result;
		}

		$origin = get_http_origin();

		// Requests from file:// and data: URLs send "Origin: null".
		if ( 'null' !== $origin ) {
			$origin = $this->sanitize_origin_url( $origin );
		}

		// Send an allow origin header if origin is allowed.
		// Note: Gets in there before WP sends the same header.
		if ( $this->is_allowed_origin( $origin ) ) {

			// Restrict to same Origin only.
			$server->send_header( 'Access-Control-Allow-Origin', $origin );

			// Authenticate API request, with token against origin.
			if ( $this->validate_auth_token( $this->get_auth_token( $request ), $origin ) ) {
				// Allow endpoint request, just return.
				return $result;
			}

			// Deny the request.
			$error = new \WP_Error(
				'personalizewp_auth_error_invalid_token',
				__( 'Invalid API token.', 'personalizewp' ),
				[
					'status' => 401,
				]
			);
			return $error;
		}

		// Deny the request, restrict to home URL only.
		$server->send_header( 'Access-Control-Allow-Origin', home_url() );

		$error = new \WP_Error(
			'rest_no_matching_origin',
			__( 'A valid origin was not provided.', 'personalizewp' ),
			[
				'status' => 401,
			]
		);
		return $error;
	}

	/**
	 * Checks if the incoming request is a PersonalizeWP endpoint or not and if the endpoint should be restricted by CORS.
	 *
	 * @since 3.1.0
	 *
	 * @param \WP_REST_Request $request The request object.
	 *
	 * @return bool
	 */
	protected function is_restricted_endpoint( $request ): bool {
		$route = $request->get_route();

		if (
			// Check that we're broadly getting one of our requests, covering various endpoints.
			str_starts_with( $route, '/' . untrailingslashit( $this->namespace ) )
		) {

			// These are restricted to logged in users, and are only used within the admin.
			$non_restricted_endpoints = apply_filters(
				'personalizewp_non_cors_restricted_endpoints',
				array(
					"/{$this->namespace}v1/settings",
					"/{$this->namespace}v1/forms-search",
					"/{$this->namespace}v1/products-search",
					"/{$this->namespace}v1/subdivisions",
				)
			);

			// Checking specifically if the PWP based incoming endpoint is restricted.
			foreach ( $non_restricted_endpoints as $non_restricted_endpoint ) {
				if ( str_starts_with( $route, $non_restricted_endpoint ) ) {
					// Immediately stop as doesn't need to be restricted.
					return false;
				}
			}
			// Since our endpoint, and *not* on the "allow unrestricted" list, mark as restricted.
			return true;
		}

		// Not our endpoint so don't restrict.
		return false;
	}

	/**
	 * Validates Request origin to known list of allowed origins
	 *
	 * Allowed origins can be changed using the `personalizewp_allowed_http_origins` filter if
	 * access needs to be granted to other domains.
	 *
	 * @since 3.1.0
	 *
	 * @param string $origin Origin of request
	 *
	 * @return bool
	 */
	protected function is_allowed_origin( $origin ): bool {

		$home_url        = set_url_scheme( $this->sanitize_origin_url( home_url() ), 'https' ); // Always assume https
		$allowed_origins = [ $home_url ]; // Ensures ssl is there, in case the default is non-ssl.

		// Automatically filter the origins to add any URLs from auth tokens UI.
		$url_tokens = $this->get_all_api_auth_tokens();
		if ( ! empty( $url_tokens ) ) {
			$allowed_origins = array_merge( array_keys( $url_tokens ) );
			$allowed_origins = array_filter( array_unique( $allowed_origins ) );
		}

		/**
		 * Filter the origin URLs allowed for PersonalizeWP requests.
		 *
		 * @since 3.1.0
		 *
		 * @param string[] $allowed_origins {
		 *     Array of default allowed HTTP origins.
		 *
		 *     @type string $0 Non-secure URL for home origin.
		 *     @type string $1 Secure URL for home origin.
		 * }
		 */
		$allowed_origins = apply_filters( 'personalizewp_allowed_http_origins', $allowed_origins );

		return in_array( $origin, $allowed_origins, true );
	}

	/**
	 * Gets the auth token from the request header.
	 *
	 * @param \WP_REST_Request $request The REST request instance.
	 *
	 * @since 3.1.0
	 *
	 * @return string
	 */
	protected function get_auth_token( \WP_REST_Request $request ) {
		return sanitize_text_field( wp_unslash( $request->get_header( 'X-PWP-Auth-Token' ) ?? '' ) );
	}

	/**
	 * Add allowed CORS headers for PWP.
	 *
	 * @access public
	 *
	 * @since 3.1.0 Introduced.
	 *
	 * @param array $allowed_headers Allowed headers.
	 *
	 * @return array
	 */
	public function allowed_cors_headers( $allowed_headers ) {
		$allowed_headers[] = 'Cache-Control';
		$allowed_headers[] = 'X-Requested-With';
		$allowed_headers[] = 'X-PWP-Auth-Token';

		return array_unique( $allowed_headers );
	}

	/**
	 * Expose headers in CORS responses.
	 *
	 * @access public
	 *
	 * @since 3.1.0 Introduced.
	 *
	 * @param array $exposed_headers Exposed headers.
	 *
	 * @return array
	 */
	public function exposed_cors_headers( $exposed_headers ) {
		$exposed_headers[] = 'Access-Control-Max-Age';

		return array_unique( $exposed_headers );
	}

	/**
	 * Checks and populates an automatic API authentication token for the current WP home url.
	 *
	 * @since 3.1.0
	 *
	 * @return void
	 */
	public function populate_default_auth_token(): void {
		$all_tokens = $this->get_all_api_auth_tokens();

		$force_gen = false;
		$home_url  = untrailingslashit( home_url() );
		$root_url  = $this->sanitize_origin_url( $home_url );

		// Check for sub-directory created URL token that won't match an origin.
		if ( $home_url !== $root_url && isset( $all_tokens[ $home_url ] ) ) {
			// Migrate default root URL by clearing the existing token.
			$this->delete_api_auth_token( $home_url );
			// Force a regen of the home url.
			$force_gen = true;
		}

		if ( empty( $all_tokens ) || $force_gen ) {
			// Use home URL, but upgrade to https to ensure single entry to check
			$root_url = set_url_scheme( home_url(), 'https' );
			$this->generate_api_auth_token( $root_url, true );
		}
	}

	/**
	 * Return the auth secret for generating API auth tokens. Generate if it doesn't exist.
	 *
	 * @since 3.1.0
	 *
	 * @return string
	 */
	private function get_auth_secret(): string {

		if ( defined( 'PWP_AUTH_SECRET_KEY' ) && ! empty( PWP_AUTH_SECRET_KEY ) ) {
			return PWP_AUTH_SECRET_KEY;
		}

		$secret = get_option( $this->auth_secret, '' );
		if ( empty( $secret ) ) {
			// Generate a new secret key and store for future calls
			$secret = wp_generate_password( 64, true, true );
			update_option( $this->auth_secret, $secret, true );
		}

		return $secret;
	}

	/**
	 * Generate, store and return an API auth token for a specific URL. Store is optional.
	 *
	 * @since 3.1.0
	 *
	 * @param string $url   URL to generate token against.
	 * @param bool   $store Flag to indicate storing the key for future lookups.
	 *
	 * @return string
	 */
	public function generate_api_auth_token( $url, $store = false ): string {

		$url   = $this->sanitize_origin_url( $url );
		$token = base64_encode( hash_hmac( 'sha384', $url, $this->get_auth_secret(), true ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Token creation

		if ( $store ) {
			$all_tokens = $this->get_all_api_auth_tokens();
			// Only set if it doesn't already exist.
			if ( ! isset( $all_tokens[ $url ] ) ) {
				$all_tokens = array_merge(
					$all_tokens,
					[
						"{$url}" => $token,
					]
				);
				update_option( $this->auth_tokens, $all_tokens, true );
			}
		}

		return $token;
	}

	/**
	 * Return all API auth tokens.
	 *
	 * @since 3.1.0
	 *
	 * @return array URL:token pairs, empty if none.
	 */
	public function get_all_api_auth_tokens(): array {

		return get_option( $this->auth_tokens, [] );
	}

	/**
	 * Return an API auth token for a specific URL.
	 * Used by PageSettings in admin.
	 *
	 * @since 3.1.0
	 *
	 * @param string $url URL to return token against.
	 *
	 * @return string Return token, or empty if URL doesn't exist.
	 */
	public function get_api_auth_token( $url ): string {

		// Strip URL back to simple URL scheme and host matching browser origin format.
		$url = $this->sanitize_origin_url( $url );

		$all_tokens = $this->get_all_api_auth_tokens();
		if ( isset( $all_tokens[ $url ] ) && ! empty( $all_tokens[ $url ] ) ) {
			return $all_tokens[ $url ];
		}

		return '';
	}

	/**
	 * Delete an existing API auth token for a specific URL.
	 * Used by PageSettings in admin.
	 *
	 * @since 3.1.0
	 *
	 * @param string $url URL to delete token for.
	 *
	 * @return bool True if deleted, false otherwise
	 */
	public function delete_api_auth_token( $url ): bool {

		// Lightly sanitise by just removing any potential trailing slash.
		// Don't fully sanitise as then would not be possible to remove incorrect URLs.
		$url = untrailingslashit( $url );

		$all_tokens = $this->get_all_api_auth_tokens();
		if ( isset( $all_tokens[ $url ] ) ) {
			unset( $all_tokens[ $url ] );
			return update_option( $this->auth_tokens, $all_tokens, true );
		}

		return false;
	}

	/**
	 * Validate an auth token against a URL origin.
	 *
	 * @since 3.1.0
	 *
	 * @param string $token Token to validate
	 * @param string $url   URL origin to validate token against
	 *
	 * @return bool True if validated, false otherwise
	 */
	public function validate_auth_token( $token, $url ): bool {
		$expected_token = $this->get_api_auth_token( $url );
		$validated      = hash_equals( $expected_token, $token );

		return $validated;
	}

	/**
	 * Registers public API endpoints.
	 *
	 * @since 3.2.0
	 *
	 * @return void
	 */
	public function register_public_endpoints() {

		$controller = new Blocks_Controller( $this->namespace );
		$controller->register_routes();

		$controller = new Visitor_Controller( $this->namespace );
		$controller->register_routes();

		$controller = new Activity_Controller( $this->namespace );
		$controller->register_routes();
	}

	/**
	 * Registers routes to support the admin.
	 *
	 * @since 3.2.0
	 *
	 * @return void
	 */
	public function register_admin_routes() {

		// Settings, for block editor.
		$controller = new Settings_Controller( $this->namespace );
		$controller->register_routes();

		// Forms.
		$controller = new Forms_Controller( $this->namespace );
		$controller->register_routes();

		// Products.
		$controller = new Products_Controller( $this->namespace );
		$controller->register_routes();

		// Subdivisions (for state/region condition).
		$controller = new Subdivisions_Controller( $this->namespace );
		$controller->register_routes();
	}

	/**
	 * Sanitize a request argument based on details registered to the route.
	 *
	 * @param  mixed            $value   Value of the argument.
	 * @param  \WP_REST_Request $request The current request object.
	 * @param  string           $param   Key of the parameter.
	 * @return \WP_Error|boolean
	 */
	public static function sanitize_arg( $value, $request, $param ) {

		if ( in_array( $param, array( 'url', 'referrer' ), true ) ) {
			return sanitize_url( $value );
		}

		if ( 'type' === $param ) {
			return sanitize_key( $value );
		}

		// It is as simple as returning the sanitized value.
		return sanitize_text_field( $value );
	}

	/**
	 * Validate the arg is a string and not empty. rest_validate_request_arg() only does the former.
	 *
	 * @param  mixed            $value   Value of the argument.
	 * @param  \WP_REST_Request $request The current request object.
	 * @param  string           $param   Key of the parameter.
	 * @return \WP_Error|boolean
	 */
	public static function validate_non_empty_string( $value, $request, $param ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed

		if ( ! is_string( $value ) || empty( $value ) ) {
			return false;
		}
		return true;
	}

	/**
	 * Validate the arg is an array and not empty.
	 *
	 * @param  mixed            $value   Value of the argument.
	 * @param  \WP_REST_Request $request The current request object.
	 * @param  string           $param   Key of the parameter.
	 * @return \WP_Error|boolean
	 */
	public static function validate_non_empty_array( $value, $request, $param ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed

		if ( ! is_array( $value ) || empty( $value ) ) {
			return false;
		}
		return true;
	}

	/**
	 * Sanitise a URL to match the format that browsers send within their `origin` header
	 *
	 * Ensures that sub-directories, trailing slashes etc are removed.
	 *
	 * @since 3.2.2
	 *
	 * @param string $url  URL to sanitise to match browser origin format
	 *
	 * @return string Sanitised URL or empty
	 */
	public function sanitize_origin_url( $url ) {

		$_parts = wp_parse_url( $url );
		if ( empty( $_parts['scheme'] ) || empty( $_parts['host'] ) ) {
			return '';
		}
		$url = untrailingslashit( sprintf( '%s://%s', $_parts['scheme'], $_parts['host'] ) );

		return $url;
	}
}
