<?php
/**
 * WPMR Pro Operations Trait
 *
 * Handles SaaS control plane communication and local execution of file operations.
 *
 * @package WPMR
 * @since 3.9.0
 */

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

trait WPMR_API_Operations {

	/**
	 * Public key used for RSA signature verification.
	 *
	 * Stored as a base64-encoded DER payload (no PEM header/footer); converted into PEM
	 * format at runtime in `validate_saas_response()`.
	 *
	 * @var string
	 */
	private $saas_public_key = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4a5vWufgOxDbdeWt9fWRt0L89z4P6kjnL9arr8L/+79zRHIxX8Rfi8nQLy5z9UwsEhGNaqSArR+geApQOdbNmF2bCwOppcBeXFlzRcICadXC04yposn6nkBNUjDQO0qRqNtgF2cM9uzYeL2W7i2N0vy4nPNJo1geny5NVai7GcqAZSh5aAaJLqoQCQ74gZkDDsSVPUkqRwV4V+hj94e3YSTmXrCpmvfDmESfuNvrwFPh/y+zJOIEuM4QWyDcUUElpHrHTE3McB3Qc8gcXsy54wRMrXXhNMoQWufRacidX4fHNBliKu4+r+JB++jYJAWipiCmBoWqZJAtSjWFXOCiAQIDAQAB';

	/**
	 * Build a consistent SaaS state payload.
	 *
	 * Used by `saas_request()` to attach site/plugin context to outbound requests.
	 *
	 * Better name: `build_saas_state_payload()`.
	 *
	 * @param array $extra Additional context to merge into the payload.
	 * @return array State payload ready for encoding.
	 */
	function get_api_state( $extra = array() ) {
		$upload_dir = wp_upload_dir();
		$wp_paths   = array(
			'ABSPATH'         => ABSPATH,
			'WP_CONTENT_DIR'  => WP_CONTENT_DIR,
			'WP_PLUGIN_DIR'   => WP_PLUGIN_DIR,
			'WPMU_PLUGIN_DIR' => WPMU_PLUGIN_DIR,
			'WP_LANG_DIR'     => defined( 'WP_LANG_DIR' ) ? WP_LANG_DIR : '',
			'WP_TEMP_DIR'     => defined( 'WP_TEMP_DIR' ) ? WP_TEMP_DIR : '',
			'UPLOADS'         => defined( 'UPLOADS' ) ? UPLOADS : '',
			'get_theme_root'  => get_theme_root(),
			'theme_root'      => $this->get_theme_root_paths(),
			'wp_upload_dir'   => isset( $upload_dir['basedir'] ) ? $upload_dir['basedir'] : '',
			'is_subdirectory' => $this->is_subdirectory_install(),
		);

		$compatibility = $this->plugin_data;
		if ( empty( $compatibility ) || ! is_array( $compatibility ) ) {
			$compatibility = (array) $compatibility;
		}

		$state = array(
			'user'           => $this->get_setting( 'user' ),
			'compatibility'  => $compatibility,
			'lic'            => $this->get_setting( 'license_key' ),
			'license_status' => get_transient( 'WPMR_license_status' ),
			'timestamp'      => time(),
			'site_url'       => site_url(),
			'home_url'       => home_url(),
			'wp_paths'       => $wp_paths,
		);

		// TODO: Consider caching the computed state per request if repeated calls become expensive.
		return array_merge( $state, (array) $extra );
	}

	/**
	 * Request and validate a file action from the SaaS control plane.
	 *
	 * Call sites: used by AJAX handlers (e.g. whitelist/repair/delete) to fetch an
	 * action envelope that includes `action_id` and a `payload` describing whether
	 * the requested action is available and, for repairs, the remote `src` URL.
	 *
	 * The request is rejected if the response signature is not verified.
	 *
	 * Better name: `request_saas_file_action()`.
	 *
	 * @param string $action_type Action type (e.g. `saas_repair_file`, `saas_delete_file`, `saas_whitelist_file`).
	 * @param string $file        Absolute file path.
	 * @return array|WP_Error SaaS response envelope (typically containing `action_id` and `payload`) or WP_Error.
	 */
	function request_saas_action( $action_type, $file ) {
		// Collect file context
		if ( ! $this->is_valid_file( $file ) ) {
			return new WP_Error( 'invalid_file', 'Invalid file provided.' );
		}

		$file_sha256 = file_exists( $file ) ? hash_file( 'sha256', $file ) : '';
		$file_type   = $this->determine_file_type( $file );

		if ( 'saas_repair_file' === $action_type && 'unknown' === $file_type ) {
			return new WP_Error( 'unsupported_repair_target', 'Repair is only available for WordPress core, plugin, or theme files.' );
		}
		$file_repo = $this->build_file_repository_context( $file, $file_type );

		$file_payload = array(
			'path'   => $file,
			'sha256' => $file_sha256,
			'kind'   => $file_type,
		);

		if ( ! empty( $file_repo ) ) {
			$file_payload['repo'] = $file_repo;
		}

		$response = $this->saas_request(
			$action_type,
			array(
				'method'      => 'POST',
				'state_extra' => array( 'file' => $file_payload ),
				'timeout'     => 30,
			)
		);

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

		// Check if signature was verified during transport
		if ( empty( $response['signature_verified'] ) ) {
			return new WP_Error( 'saas_signature_failed', 'Response signature verification failed.' );
		}

		$data = isset( $response['response'] ) ? $response['response'] : null;
		if ( empty( $data ) ) {
			return new WP_Error( 'saas_invalid_response', 'Invalid response from Malcure service.' );
		}

		if ( ! isset( $data['success'] ) || ! $data['success'] ) {
			$error_message = '';
			if ( isset( $data['data']['message'] ) ) {
				$error_message = $data['data']['message'];
			} elseif ( isset( $response['payload']['message'] ) ) {
				$error_message = $response['payload']['message'];
			}

			if ( empty( $error_message ) ) {
				$error_message = 'Malcure service could not process this request.';
			}

			$error_data = isset( $response['payload'] ) ? $response['payload'] : array();
			return new WP_Error( 'wpmr_saas_action_failed', $error_message, $error_data );
		}

		if ( ! isset( $response['payload'] ) ) {
			return new WP_Error( 'wpmr_saas_action_failed', 'Invalid response payload.' );
		}

		// Return the full envelope so downstream consumers have access to action_id and structure
		if ( isset( $data['data'] ) ) {
			return $data['data'];
		}

		return $response['payload'];
	}

	/**
	 * Sanitize a SaaS-provided message for safe display in the WP admin.
	 *
	 * Call sites: used before echoing `payload['message']` returned from
	 * `request_saas_action()`.
	 *
	 * Better name: `sanitize_saas_message_html()`.
	 *
	 * @param string $message Message that may contain limited HTML (e.g., links).
	 * @return string Sanitized HTML string.
	 */
	function sanitize_saas_reason_html( $message ) {
		$allowed = array(
			'a'      => array(
				'href'   => array(),
				'target' => array(),
				'rel'    => array(),
			),
			'strong' => array(),
			'em'     => array(),
		);

		return wp_kses( (string) $message, $allowed );
	}

	/**
	 * Validate a signed SaaS response using RSA signature verification.
	 *
	 * Used internally by `saas_request()` to validate the nested signed payload.
	 * Expects a payload containing `signature`, `action_id`, and `api_version`.
	 *
	 * Better name: `verify_saas_signed_payload()`.
	 *
	 * @param array $response Signed response payload from SaaS.
	 * @return array{valid:bool,error:string} Validation result.
	 */
	function validate_saas_response( $response ) {
		// Check required fields
		if ( empty( $response['signature'] ) || empty( $response['action_id'] ) ) {
			return array(
				'valid' => false,
				'error' => 'Invalid response structure from Malcure service.',
			);
		}

		// Check API version compatibility
		if ( empty( $response['api_version'] ) ) {
			return array(
				'valid' => false,
				'error' => 'Missing API version in response.',
			);
		}

		if ( ! function_exists( 'openssl_verify' ) || ! extension_loaded( 'openssl' ) ) {
			return array(
				'valid' => false,
				'error' => 'OpenSSL extension is required to verify responses from the Malcure service. Please enable it on your server.',
			);
		}

		// Extract signature
		$signature = $response['signature'];
		unset( $response['signature'] );

		// Create canonical JSON string
		$json = json_encode( $response, JSON_UNESCAPED_SLASHES );

		// Verify signature
		$public_key = "-----BEGIN PUBLIC KEY-----\n" .
						chunk_split( $this->saas_public_key, 64, "\n" ) .
						'-----END PUBLIC KEY-----';

		$key = openssl_pkey_get_public( $public_key );
		if ( ! $key ) {
			return array(
				'valid' => false,
				'error' => 'Failed to load public key for signature verification.',
			);
		}

		$result = openssl_verify( $json, base64_decode( $signature ), $key, OPENSSL_ALGO_SHA256 );
		if ( is_resource( $key ) ) {
			openssl_free_key( $key );
		}

		if ( $result !== 1 ) {
			return array(
				'valid' => false,
				'error' => 'Signature verification failed. Response may have been tampered with.',
			);
		}

		// Check TTL
		if ( ! empty( $response['ttl_seconds'] ) ) {
			// TTL validation can be added here if timestamp is included in response
		}

		return array(
			'valid' => true,
			'error' => '',
		);
	}

	/**
	 * Execute a repair action locally by fetching and rewriting the target file.
	 *
	 * Call sites: invoked after a successful `request_saas_action( 'saas_repair_file', ... )`
	 * response indicates the action is available.
	 *
	 * Uses WP_Filesystem and records an event log entry with before/after SHA256.
	 *
	 * Better name: `repair_file_from_saas_source()`.
	 *
	 * @param array  $response SaaS action envelope (expects `payload['src']` and `action_id`).
	 * @param string $file     Absolute file path to repair.
	 * @return bool|WP_Error True on success, WP_Error on failure.
	 */
	function perform_repair_action( $response, $file ) {
		$src = isset( $response['payload']['src'] ) ? $response['payload']['src'] : '';
		if ( empty( $src ) ) {
			return new WP_Error( 'no_source', 'No source URL provided for repair.' );
		}

		$perform_action = apply_filters( 'wpmr_perform_edit_action', true, $response, $file, 'repair' );
		if ( ! $perform_action ) {
			return new WP_Error( 'repair_aborted', 'Repair action aborted by filter.' );
		}

		// Fetch file from WordPress.org
		$fetch_response = wp_safe_remote_get(
			$src,
			array(
				'timeout'   => 30,
				'sslverify' => true,
			)
		);

		if ( is_wp_error( $fetch_response ) ) {
			return new WP_Error( 'fetch_failed', 'Failed to fetch file: ' . $fetch_response->get_error_message() );
		}

		$fetch_code = wp_remote_retrieve_response_code( $fetch_response );
		if ( $fetch_code !== 200 ) {
			return new WP_Error( 'fetch_failed', 'Failed to fetch file. HTTP code: ' . $fetch_code );
		}

		$content = wp_remote_retrieve_body( $fetch_response );
		if ( empty( $content ) ) {
			return new WP_Error( 'empty_content', 'Fetched file is empty.' );
		}

		// Calculate SHA256 before
		$sha256_before = file_exists( $file ) ? hash_file( 'sha256', $file ) : '';

		// Write file using WP_Filesystem
		global $wp_filesystem;
		if ( ! function_exists( 'WP_Filesystem' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}
		WP_Filesystem();

		if ( ! $wp_filesystem->put_contents( $file, $content, FS_CHMOD_FILE ) ) {
			return new WP_Error( 'write_failed', 'Failed to write file.' );
		}

		// Calculate SHA256 after
		$sha256_after = hash_file( 'sha256', $file );

		// Log operation
		$this->log_event(
			'file_repaired',
			array(
				'file'          => $file,
				'action_id'     => $response['action_id'],
				'sha256_before' => $sha256_before,
				'sha256_after'  => $sha256_after,
			)
		);

		return true;
	}

	/**
	 * Execute a delete action locally via WP_Filesystem.
	 *
	 * Call sites: invoked after `request_saas_action( 'saas_delete_file', ... )`.
	 * A separate local safety check (`is_deletable()`) is performed before reaching this method.
	 *
	 * Better name: `delete_file_with_audit_log()`.
	 *
	 * @param array  $response SaaS action envelope (expects `action_id`).
	 * @param string $file     Absolute file path to delete.
	 * @return bool|WP_Error True on success, WP_Error on failure.
	 */
	function perform_delete_action( $response, $file ) {
		// Double-check file is deletable
		if ( ! $this->is_deletable( $file ) ) {
			return new WP_Error( 'not_deletable', 'File cannot be deleted (critical system file).' );
		}

		if ( ! file_exists( $file ) ) {
			return new WP_Error( 'file_not_found', 'File does not exist.' );
		}

		$perform_action = apply_filters( 'wpmr_perform_edit_action', true, $response, $file, 'delete' );
		if ( ! $perform_action ) {
			return new WP_Error( 'delete_aborted', 'Delete action aborted by filter.' );
		}

		// Calculate SHA256 before deletion
		$sha256_before = hash_file( 'sha256', $file );

		// Delete file using WP_Filesystem
		global $wp_filesystem;
		if ( ! function_exists( 'WP_Filesystem' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}
		WP_Filesystem();

		if ( ! $wp_filesystem->delete( $file, false, 'f' ) ) {
			return new WP_Error( 'delete_failed', 'Failed to delete file.' );
		}

		// Log operation
		$this->log_event(
			'file_deleted',
			array(
				'file'      => $file,
				'action_id' => $response['action_id'],
				'sha256'    => $sha256_before,
			)
		);

		return true;
	}

	/**
	 * Execute a whitelist action locally by recording the file hash in settings.
	 *
	 * Call sites: invoked after `request_saas_action( 'saas_whitelist_file', ... )`.
	 * Stores `sha256(file)` into the `whitelist` settings array under the absolute path.
	 *
	 * Better name: `whitelist_file_by_hash()`.
	 *
	 * @param array  $response SaaS action envelope (expects `action_id`).
	 * @param string $file     Absolute file path to whitelist.
	 * @return bool|WP_Error True on success, WP_Error on failure.
	 */
	function perform_whitelist_action( $response, $file ) {
		if ( ! file_exists( $file ) ) {
			return new WP_Error( 'file_not_found', 'File does not exist.' );
		}

		$perform_action = apply_filters( 'wpmr_perform_edit_action', true, $response, $file, 'whitelist' );
		if ( ! $perform_action ) {
			return new WP_Error( 'whitelist_aborted', 'Whitelist action aborted by filter.' );
		}

		$whitelist = $this->get_setting( 'whitelist' );
		if ( ! is_array( $whitelist ) ) {
			$whitelist = array();
		}

		$file_sha256 = @hash_file( 'sha256', $file );
		if ( ! $file_sha256 ) {
			return new WP_Error( 'whitelist_failed', 'Can\'t whitelist. File: ' . $file );
		}

		$whitelist[ $file ] = $file_sha256;
		$this->update_setting( 'whitelist', $whitelist );

		// Log operation
		$this->log_event(
			'file_whitelisted',
			array(
				'file'      => $file,
				'action_id' => $response['action_id'],
				'sha256'    => $file_sha256,
			)
		);

		return true;
	}

	/**
	 * Gather theme root directories including registered directories.
	 *
	 * Used by `get_api_state()` to send a richer set of theme roots in environments
	 * where themes are stored outside the default theme root.
	 *
	 * Better name: `get_all_theme_root_paths()`.
	 *
	 * @return string[] Normalized absolute theme root directories.
	 */
	private function get_theme_root_paths() {
		$roots        = array();
		$default_root = get_theme_root();
		if ( $default_root ) {
			$roots[] = wp_normalize_path( $default_root );
		}

		global $_wp_theme_directories;
		if ( ! empty( $_wp_theme_directories ) && is_array( $_wp_theme_directories ) ) {
			foreach ( $_wp_theme_directories as $dir ) {
				$roots[] = wp_normalize_path( $dir );
			}
		}

		return array_values( array_unique( $roots ) );
	}

	/**
	 * Determine file type (core|plugin|theme|unknown) using legacy heuristics.
	 *
	 * This implementation walks installed plugins/themes and does a path-based match,
	 * and falls back to core checksum presence.
	 *
	 * Better name: `determine_file_type_by_path_heuristics()`.
	 *
	 * @deprecated Use `determine_file_type()` which relies on the checksums origin table.
	 *
	 * @param string $file Absolute file path.
	 * @return string File type.
	 */
	function determine_file_type_legacy( $file ) {

		if ( ! $this->is_repairable( $file ) ) {
			return 'unknown';
		}

		if ( strpos( $file, WP_PLUGIN_DIR ) !== false ) { // file is inside plugins directory
			if ( ! function_exists( 'get_plugins' ) ) {
				require_once ABSPATH . 'wp-admin/includes/plugin.php';
			}

			$plugins = get_plugins();
			foreach ( $plugins as $plugin_file => $plugin_data ) {
				$plugin_dir = dirname( $plugin_file );
				if ( '' === $plugin_dir || '.' === $plugin_dir ) {
					continue; // skip single-file plugins without directory for now
				}

				$plugin_path = wp_normalize_path( trailingslashit( WP_PLUGIN_DIR ) . trailingslashit( $plugin_dir ) );
				if ( strpos( wp_normalize_path( $file ), $plugin_path ) === 0 ) {
					return 'plugin';
				}
			}
		}

		$themes = wp_get_themes();
		foreach ( $themes as $tk => $tv ) {
			if ( strpos( wp_normalize_path( $file ), wp_normalize_path( get_theme_root( $tk ) . DIRECTORY_SEPARATOR . $tk ) ) !== false ) {
				return 'theme';
			}
		}

		remove_filter( 'serve_checksums', array( $this, 'get_cached_checksums' ), 11 );

		$checksums = $this->get_all_checksums();
		if ( array_key_exists( $this->normalise_path( $file ), $checksums ) ) {
			return 'core';
		}

		return 'unknown';
	}

	/**
	 * Determine file type (core, plugin, theme, unknown).
	 *
	 * Uses the origin checksums database table to classify the file via its `type` column.
	 *
	 * Call sites: used by `request_saas_action()` (and exposed via a WP-CLI diagnostic command)
	 * to decide whether a repair action is supported.
	 *
	 * Better name: `classify_file_type_from_checksums()`.
	 *
	 * @param string $file Absolute file path
	 * @return string File type
	 */
	function determine_file_type( $file ) {

		$default_type = 'unknown';

		if ( ! $this->is_safe_file_path( $file ) ) {
			return $default_type;
		}

		// Ensure checksums are present (table-backed). This method intentionally relies on the
		// DB-populated origin checksums source rather than absolute-path matching.
		$this->get_core_checksums();

		$normalized = wp_normalize_path( (string) $file );
		$relative   = $this->normalise_component_path( $normalized );
		if ( '' === $relative ) {
			return $default_type;
		}

		global $wpdb;
		$table = $wpdb->prefix . 'wpmr_checksums';
		$type  = $wpdb->get_var( $wpdb->prepare( "SELECT `type` FROM $table WHERE `path` = %s LIMIT 1", $relative ) );
		if ( null === $type ) {
			return $default_type;
		}
		$type = strtolower( (string) $type );
		if ( 'core' === $type ) {
			return 'core';
		}
		if ( 0 === strpos( $type, 'plugin' ) ) {
			return 'plugin';
		}
		if ( 0 === strpos( $type, 'theme' ) ) {
			return 'theme';
		}

		// Unknown type values should not be treated as repairable targets.
		return $default_type;
	}

	/**
	 * Build repository context for the requested file.
	 *
	 * Used by `request_saas_action()` to provide the SaaS service with enough metadata
	 * (component type, slug, version, relative_path) to locate a canonical copy.
	 *
	 * Better name: `build_component_repository_context()`.
	 *
	 * @param string $file      Absolute file path.
	 * @param string $file_type Classified file type.
	 * @return array Repository context metadata.
	 */
	private function build_file_repository_context( $file, $file_type ) {
		$file_type = strtolower( $file_type );
		$file      = wp_normalize_path( $file );

		switch ( $file_type ) {
			case 'core':
				return $this->get_core_repository_context( $file );
			case 'plugin':
				return $this->get_plugin_repository_context( $file );
			case 'theme':
				return $this->get_theme_repository_context( $file );
		}

		return array();
	}

	/**
	 * Build repository context for WordPress core files.
	 *
	 * Better name: `build_core_repository_context()`.
	 *
	 * @param string $file Normalized absolute file path.
	 * @return array{type:string,slug:string,version:string,relative_path:string}|array{}
	 */
	private function get_core_repository_context( $file ) {
		$abspath = wp_normalize_path( trailingslashit( ABSPATH ) );
		if ( strpos( $file, $abspath ) !== 0 ) {
			return array();
		}

		$relative = $this->sanitize_relative_path( substr( $file, strlen( $abspath ) ) );
		if ( '' === $relative ) {
			return array();
		}

		$version = get_bloginfo( 'version' );
		if ( empty( $version ) && isset( $GLOBALS['wp_version'] ) ) {
			$version = $GLOBALS['wp_version'];
		}

		return array(
			'type'          => 'core',
			'slug'          => 'wordpress',
			'version'       => $version,
			'relative_path' => $relative,
		);
	}

	/**
	 * Build repository context for plugin files.
	 *
	 * Supports standard plugins and MU plugins; in the MU case version is not available.
	 *
	 * Better name: `build_plugin_repository_context()`.
	 *
	 * @param string $file Normalized absolute file path.
	 * @return array{type:string,slug:string,version:string,relative_path:string}|array{}
	 */
	private function get_plugin_repository_context( $file ) {
		if ( ! function_exists( 'get_plugins' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		$plugins          = get_plugins();
		$plugins_base_dir = wp_normalize_path( trailingslashit( WP_PLUGIN_DIR ) );

		foreach ( $plugins as $plugin_file => $plugin_data ) {
			$plugin_abs_path = wp_normalize_path( $plugins_base_dir . $plugin_file );
			$plugin_dir_path = wp_normalize_path( trailingslashit( dirname( $plugin_abs_path ) ) );

			if ( strpos( $file, $plugin_dir_path ) !== 0 ) {
				continue;
			}

			$relative_path = $this->sanitize_relative_path( substr( $file, strlen( $plugin_dir_path ) ) );
			$parts         = explode( '/', $plugin_file );
			$plugin_slug   = ( count( $parts ) > 1 ) ? $parts[0] : basename( $plugin_file, '.php' );
			$version       = isset( $plugin_data['Version'] ) ? $plugin_data['Version'] : '';

			return array(
				'type'          => 'plugin',
				'slug'          => sanitize_key( $plugin_slug ),
				'version'       => $version,
				'relative_path' => $relative_path,
			);
		}

		$mu_base_dir = wp_normalize_path( trailingslashit( WPMU_PLUGIN_DIR ) );
		if ( ! empty( $mu_base_dir ) && strpos( $file, $mu_base_dir ) === 0 ) {
			$relative_path = $this->sanitize_relative_path( substr( $file, strlen( $mu_base_dir ) ) );
			$slug          = $relative_path;
			$slash_pos     = strpos( $relative_path, '/' );
			if ( false !== $slash_pos ) {
				$slug = substr( $relative_path, 0, $slash_pos );
			}

			return array(
				'type'          => 'plugin',
				'slug'          => sanitize_key( $slug ),
				'version'       => '',
				'relative_path' => $relative_path,
			);
		}

		return array();
	}

	/**
	 * Build repository context for theme files.
	 *
	 * Better name: `build_theme_repository_context()`.
	 *
	 * @param string $file Normalized absolute file path.
	 * @return array{type:string,slug:string,version:string,relative_path:string}|array{}
	 */
	private function get_theme_repository_context( $file ) {
		if ( ! function_exists( 'wp_get_themes' ) ) {
			require_once ABSPATH . 'wp-includes/theme.php';
		}

		$themes = wp_get_themes();

		foreach ( $themes as $stylesheet => $theme ) {
			$theme_dir = wp_normalize_path( trailingslashit( $theme->get_stylesheet_directory() ) );
			if ( strpos( $file, $theme_dir ) !== 0 ) {
				continue;
			}

			$relative_path = $this->sanitize_relative_path( substr( $file, strlen( $theme_dir ) ) );
			$version       = $theme->get( 'Version' );

			return array(
				'type'          => 'theme',
				'slug'          => sanitize_key( $stylesheet ),
				'version'       => $version,
				'relative_path' => $relative_path,
			);
		}

		return array();
	}

	/**
	 * Normalize and sanitize a relative path segment for transport.
	 *
	 * Removes empty/dot segments and normalizes separators to `/`.
	 *
	 * Better name: `sanitize_relative_path_segment()`.
	 *
	 * @param string $path Path fragment.
	 * @return string Sanitized relative path.
	 */
	private function sanitize_relative_path( $path ) {
		$path     = wp_normalize_path( $path );
		$segments = explode( '/', $path );
		$clean    = array();

		foreach ( $segments as $segment ) {
			if ( '' === $segment || '.' === $segment || '..' === $segment ) {
				continue;
			}
			$clean[] = $segment;
		}

		return implode( '/', $clean );
	}

	/**
	 * Detect whether WordPress is installed in a subdirectory.
	 *
	 * Used by `get_api_state()` to flag installs where `site_url()` differs from `home_url()`.
	 *
	 * Better name: `is_wp_subdirectory_install()`.
	 *
	 * @return bool True if subdirectory install.
	 */
	function is_subdirectory_install() {
		if ( site_url() !== home_url() ) {
			return true;
		}

		if ( ! empty( $_SERVER['DOCUMENT_ROOT'] ) ) {
			$doc_root = trailingslashit( $_SERVER['DOCUMENT_ROOT'] );
			$abspath  = trailingslashit( ABSPATH );
			if ( $abspath !== $doc_root ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Perform a SaaS request with consistent state handling, logging, and signature verification.
	 *
	 * Call sites: used across multiple traits (scanner, checksums, account, etc.) to
	 * reach the Malcure control plane.
	 *
	 * Filters:
	 * - `saas_msg_payload`  Allows mutation of the outbound transport snapshot.
	 * - `saas_msg_response` Allows mutation of the normalized response envelope.
	 *
	 * Better name: `send_saas_request()`.
	 *
	 * @param string $wpmr_action Target action handled by the control plane.
	 * @param array  $options     Request overrides (method, query, body, headers, send_state, state_extra, timeout, sslverify, blocking, log).
	 * @return array|WP_Error Normalized response envelope (includes `payload`, `response`, `signature_verified`) or WP_Error.
	 */
	function saas_request( $wpmr_action, $options = array() ) {
		$defaults = array(
			'method'      => 'GET',
			'query'       => array(),
			'body'        => array(),
			'headers'     => array(),
			'send_state'  => 'body',
			'state_extra' => array(),
			'timeout'     => 15,
			'sslverify'   => true,
			'blocking'    => true,
			'log'         => true,
		);

		$options = wp_parse_args( $options, $defaults );

		// $options = apply_filters( 'wpmr_saas_request_options', $options, $wpmr_action );

		if ( $options['log'] ) {
			$this->flog( '' );
			$this->flog( '' );
			$this->flog( '================================' );
			$this->flog( '================================' );
			$this->flog( '' );
			$this->flog( '' );
		}

		if ( empty( $wpmr_action ) ) {
			return new WP_Error( 'wpmr_missing_action', 'Missing SaaS action parameter.' );
		}
		$method  = strtoupper( $options['method'] );
		$query   = is_array( $options['query'] ) ? $options['query'] : array();
		$body    = is_array( $options['body'] ) ? $options['body'] : array();
		$headers = is_array( $options['headers'] ) ? $options['headers'] : array();

		$query = array_merge(
			array(
				'wpmr_action' => $wpmr_action,
				'cachebust'   => microtime( true ),
			),
			$query
		);

		$state          = array();
		$state_location = in_array( $options['send_state'], array( 'query', 'body', 'none' ), true ) ? $options['send_state'] : 'body';
		$encoded_state  = '';
		if ( 'none' !== $state_location ) {
			$state = $this->get_api_state( $options['state_extra'] );

			if ( $options['log'] ) {
				$this->flog( __FUNCTION__ . ' State for ' . $wpmr_action . ': ' );
				$this->flog( $state );
			}

			$encoded_state = $this->encode( $state );
			if ( 'query' === $state_location ) {
				$query['state'] = $encoded_state;
			} else {
				$body['state'] = $encoded_state;
			}
		}

		$request_id = uniqid( 'wpmr_saas_', true );
		$url        = add_query_arg( $query, WPMR_SERVER );
		$start_time = microtime( true );

		if ( 'GET' === $method && ! empty( $body ) ) {
			$url  = add_query_arg( $body, $url );
			$body = array();
		}

		$args = array(
			'headers'   => $headers,
			'cookies'   => array(),
			'compress'  => false,
			'sslverify' => (bool) $options['sslverify'],
			'timeout'   => (float) $options['timeout'],
			'blocking'  => (bool) $options['blocking'],
		);

		if ( ! empty( $body ) ) {
			$args['body'] = $body;
		}

		$args['method'] = $method;

		$transport_snapshot = array(
			'url'            => $url,
			'method'         => $method,
			'args'           => $args,
			'query'          => $query,
			'state'          => $state,
			'state_location' => $state_location,
		);

		$transport_snapshot = apply_filters( 'saas_msg_payload', $transport_snapshot, $wpmr_action, $options );
		if ( is_array( $transport_snapshot ) ) {
			if ( isset( $transport_snapshot['url'] ) ) {
				$url = $transport_snapshot['url'];
			}
			if ( isset( $transport_snapshot['method'] ) ) {
				$method = strtoupper( $transport_snapshot['method'] );
			}
			if ( isset( $transport_snapshot['args'] ) && is_array( $transport_snapshot['args'] ) ) {
				$args = $transport_snapshot['args'];
			}
			if ( isset( $transport_snapshot['query'] ) && is_array( $transport_snapshot['query'] ) ) {
				$query = $transport_snapshot['query'];
			}
		}

		$args['method'] = $method;

		if ( $options['log'] ) {
			$this->flog( __FUNCTION__ . ' $url ' . $url . "\n" . '$args' );
			$this->flog( json_encode( $args ) );
		}
		$response = wp_remote_request( $url, $args );
		$duration = round( ( microtime( true ) - $start_time ) * 1000, 2 );

		if ( is_wp_error( $response ) ) {
			if ( $options['log'] ) {
				$this->flog( __FUNCTION__ . ' wp_remote_request error: ' . $response->get_error_message() );
			}
			return new WP_Error( 'wpmr_saas_transport', 'Could not reach the Malcure service: ' . $response->get_error_message() );
		}

		if ( false === $options['blocking'] ) {
			$async_result = array(
				'request_id' => $request_id,
				'url'        => $url,
				'method'     => $method,
				'code'       => null,
				'body'       => null,
				'headers'    => array(),
				'response'   => null,
				'duration'   => $duration,
			);

			$async_result = apply_filters( 'saas_msg_response', $async_result, $wpmr_action, $options );

			return $async_result;
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		$body = wp_remote_retrieve_body( $response );

		if ( 200 !== $code ) {

			return new WP_Error( 'wpmr_saas_http', 'Malcure service returned HTTP ' . $code . '.', array( 'body' => $body ) );
		}

		$data                 = null;
		$signature_verified   = false;
		$signed_payload_error = null;

		$data = json_decode( $body, true );
		if ( $options['log'] ) {
			$this->flog( '' );
			$this->flog( '================================' );
			$this->flog( '' );
			$this->flog( __FUNCTION__ . ' decoded json server response to: ' );
			$this->flog( $data );
		}
		if ( null === $data && JSON_ERROR_NONE !== json_last_error() ) {
			return new WP_Error( 'wpmr_saas_json', 'Unable to decode service response.' );
		}

		if ( is_array( $data ) ) {
			$signed_payload = null;
			if ( isset( $data['data'] ) && is_array( $data['data'] ) && isset( $data['data']['signature'], $data['data']['action_id'] ) ) {
				$signed_payload = $data['data'];
			}

			if ( $signed_payload ) {
				$validation = $this->validate_saas_response( $signed_payload );
				if ( ! $validation['valid'] ) {
					$signed_payload_error = $validation['error'];
				}
			}
		}

		if ( $signed_payload_error ) {
			if ( $options['log'] ) {
				$this->flog( __FUNCTION__ . ' signature verification error: ' . $signed_payload_error );
			}
			return new WP_Error( 'wpmr_saas_signature', $signed_payload_error );
		}

		if ( isset( $signed_payload ) && empty( $signed_payload_error ) && ! empty( $signed_payload ) ) {
			$signature_verified = true;
		}

		$payload = null;
		if ( isset( $data['data'] ) && is_array( $data['data'] ) && array_key_exists( 'payload', $data['data'] ) ) {
			$payload = $data['data']['payload'];
		}

		$result = array(
			'request_id'         => $request_id,
			'url'                => $url,
			'method'             => $method,
			'code'               => $code,
			'body'               => $body,
			'headers'            => wp_remote_retrieve_headers( $response ),
			'response'           => $data,
			'payload'            => $payload,
			'duration'           => $duration,
			'signature_verified' => $signature_verified,
		);

		$result = apply_filters( 'saas_msg_response', $result, $wpmr_action, $options );

		return $result;
	}


	/**
	 * Retrieve SaaS compatibility metadata.
	 *
	 * Call sites: used in the admin UI to gate features and show compatibility notices.
	 * Results are cached in a transient.
	 *
	 * Better name: `get_cached_saas_compatibility_status()`.
	 *
	 * @param bool $force_refresh Whether to bypass the cached result.
	 * @return array Compatibility payload.
	 */
	protected function get_saas_compatibility_status( $force_refresh = false ) {
		$cache_key = 'WPMR_compatibility';
		if ( ! $force_refresh ) {
			$cached = get_transient( $cache_key );
			if ( false !== $cached ) {

				return $cached;
			}
		}

		$response = $this->saas_request(
			'saas_test_compatibility',
			array(
				'method'     => 'POST',
				'send_state' => 'body',
				'timeout'    => 15,
				'log'        => false,
			)
		);

		if ( is_wp_error( $response ) ) {
			$expected = array(
				'supported'   => false,
				'error'       => $response->get_error_message(),
				'checked_at'  => time(),
				'api_version' => '',
				'message'     => '',
			);

			set_transient( $cache_key, $expected, HOUR_IN_SECONDS * 3 );
			return $expected;
		}

		$body = isset( $response['payload'] ) ? $response['payload'] : null;

		// Fallback for compatibility check which returns data directly in data object
		if ( empty( $body ) && isset( $response['response']['data'] ) && is_array( $response['response']['data'] ) ) {
			$body = $response['response']['data'];
		}

		if ( empty( $body ) || ! is_array( $body ) ) {
			$expected = array(
				'supported'   => false,
				'error'       => 'Malcure API returned an empty response.',
				'checked_at'  => time(),
				'api_version' => '',
				'message'     => '',
			);

			set_transient( $cache_key, $expected, HOUR_IN_SECONDS * 3 );
			return $expected;
		}

		$expected = array(
			'supported'   => isset( $body['supported'] ) ? (bool) $body['supported'] : false,
			'api_version' => isset( $body['api_version'] ) ? $body['api_version'] : '',
			'message'     => isset( $body['message'] ) ? $body['message'] : '',
			'checked_at'  => time(),
			'error'       => '',
		);

		if ( ! array_key_exists( 'supported', $body ) ) {
			$expected['supported'] = false;
			$expected['error']     = 'Malcure API returned an unexpected payload.';
		}

		set_transient( $cache_key, $expected, HOUR_IN_SECONDS * 12 );

		return $expected;
	}
}
