<?php

declare(strict_types=1);

namespace SiteFort\Modules\Cloud;

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

use SiteFort\Modules\Cloud\Service\CloudSettings;

/**
 * HTTP Client for AWS Lambda API.
 */
class SecureApi {

    private const CONSOLE_API_URL = 'https://console.securewp.net/api/v1';
    
    private const CLOUD_API_URL = 'https://intel.securewp.net/v1';

    private const REQUEST_TIMEOUT_SECONDS = 15;
    public const SCANNER_WORKER_TIMEOUT_SECONDS = 10;
    public const SCANNER_WORKER_MAX_ATTEMPTS = 1;

    /**
     * Activation/connect POST paths that support backend idempotency caching.
     */
    private const IDEMPOTENT_POST_PATH_SUFFIXES = [
        '/api/v1/services/connect/verify-otp',
        '/api/v1/services/connect/private/verify-otp',
        '/api/v1/services/connect/retry',
        '/api/v1/services/connect/activate-free',
        '/api/v1/services/connect/verify-license',
        '/api/v1/services/connect/private/verify-license',
        '/api/v1/services/connect/console/exchange',
        '/api/v1/services/connect/private/console/exchange',
    ];

    /**
     * Prefix for route-scoped non-blocking API backoff transients.
     */
    private const BACKOFF_TRANSIENT_PREFIX = 'sitefort_api_backoff_';
    
    private $license_key;
    private $site_token;
    private $site_secret;
    private $correlation_id;

    /**
     * Explicit Cloud endpoint routing map.
     * Keys are caller-facing endpoints, values are Cloud API paths.
     */
    private const CLOUD_ROUTE_MAP = [
        'services/intelligence/scan/preflight'     => '/scan/preflight',
        'services/intelligence/check-hash'         => '/scan/check-hash',
        'services/intelligence/get-secure-link'    => '/scan/get-secure-link',
        'services/intelligence/reputation'         => '/scan/reputation',
        'services/intelligence/vulnerabilities'    => '/vulnerabilities/',
        'services/intelligence/suspicious-usernames' => '/scan/suspicious-usernames',
        'services/intelligence/dictionary'         => '/scan/dictionary',
        'services/intelligence/db-patterns'        => '/scan/db-patterns',
        'repair/file'                              => '/repair/file',
        'services/intelligence/threat-blocklist'   => '/threats/blocklist',
        'services/ip-detect/init'                  => '/ip-detect/init',
    ];

    /**
     * Explicit Console endpoint routing map.
     * Keys are caller-facing endpoints, values are Console API paths.
     */
    private const CONSOLE_ROUTE_MAP = [
        'services/connect/request-otp'      => '/services/connect/request-otp',
        'services/connect/request-license-otp' => '/services/connect/request-license-otp',
        'services/connect/private/request-otp' => '/services/connect/private/request-otp',
        'services/connect/private/request-license-otp' => '/services/connect/private/request-license-otp',
        'services/connect/private/verify-otp' => '/services/connect/private/verify-otp',
        'services/connect/private/verify-license' => '/services/connect/private/verify-license',
        'services/connect/verify-otp'       => '/services/connect/verify-otp',
        'services/connect/console/init'     => '/services/connect/console/init',
        'services/connect/private/console/init' => '/services/connect/private/console/init',
        'services/connect/console/exchange' => '/services/connect/console/exchange',
        'services/connect/private/console/exchange' => '/services/connect/private/console/exchange',
        'services/connect/console/exchange/preview' => '/services/connect/console/exchange/preview',
        'services/connect/retry'            => '/services/connect/retry',
        'services/connect/activate-free'    => '/services/connect/activate-free',
        'services/connect/verify-license'   => '/services/connect/verify-license',
        'services/connect/deactivate'       => '/services/connect/deactivate',
        'services/connect/telemetry'        => '/services/connect/telemetry',
        'services/license/check'            => '/services/license/check',
        // Key backup (escrow)
        'services/connect/key-backup'             => '/services/connect/key-backup',
        'services/connect/key-backup/request-otp' => '/services/connect/key-backup/request-otp',
        'services/connect/key-backup/retrieve'    => '/services/connect/key-backup/retrieve',
        'services/connect/migration/resolve'      => '/services/connect/migration/resolve',
        'services/connect/migration-reissue'      => '/services/connect/migration-reissue',
        // C2 push-based endpoints
        'services/state/push'               => '/services/state/push',
        'services/commands/ack'             => '/services/commands/ack',
        'services/integration-secrets/envelope' => '/services/integration-secrets/envelope',
    ];

    public function __construct( string $license_key, string $site_token = '', string $site_secret = '' ) {
        $this->license_key = $license_key;
        $this->site_token = $site_token;
        $this->site_secret = $site_secret;
        $this->correlation_id = $this->generate_correlation_id();
    }
    
    private function generate_correlation_id(): string {
        return sprintf(
            '%s-%s-%s',
            substr( md5( home_url() ), 0, 8 ),
            time(),
            wp_generate_password( 8, false )
        );
    }

    /**
     * Allowed base domain suffixes for the Cloud API override constant.
     * Any override must start with https:// and end with one of these.
     */
    private const CLOUD_API_ALLOWED_SUFFIXES = [
        '.securewp.net',
        '.intel.securewp.net',
    ];

    /**
     * Allowed base domain suffixes for the Console API override constant.
     */
    private const CONSOLE_API_ALLOWED_SUFFIXES = [
        '.securewp.net',
        '.console.securewp.net',
    ];

    /**
     * Return the override constant value only if it is safe (HTTPS, allowed domain).
     * Falls back to the hardcoded constant when the override is absent or invalid.
     *
     * @param string $constant_name PHP constant name (e.g. 'SITEFORT_CLOUD_API_URL').
     * @param string $fallback      Hardcoded default URL.
     * @param string[] $allowed_suffixes Domain suffix allowlist.
     * @return string Validated base URL.
     */
    private static function validated_base_url( string $constant_name, string $fallback, array $allowed_suffixes ): string {
        if ( ! defined( $constant_name ) ) {
            return $fallback;
        }

        $value = (string) constant( $constant_name );

        // Must use HTTPS.
        if ( strpos( $value, 'https://' ) !== 0 ) {
            sitefort_log( "[SiteFort] {$constant_name} override rejected: must use HTTPS. Using default." );
            return $fallback;
        }

        // Must end with an allowed domain suffix.
        $host = (string) wp_parse_url( $value, PHP_URL_HOST );
        foreach ( $allowed_suffixes as $suffix ) {
            if ( str_ends_with( $host, $suffix ) || $host === ltrim( $suffix, '.' ) ) {
                return rtrim( $value, '/' );
            }
        }

        sitefort_log( "[SiteFort] {$constant_name} override rejected: host '{$host}' not in allowlist. Using default." );
        return $fallback;
    }

    /**
     * Resolve endpoint to full URL based on path.
     * Uses explicit routing maps to avoid implicit endpoint guessing.
     */
    private function resolve_endpoint( string $endpoint ) {
        $endpoint = ltrim( $endpoint, '/' );

        if ( strpos( $endpoint, 'jobs/' ) === 0 ) {
            // Validate the job sub-path: only alphanumeric, hyphens, underscores, and forward slashes.
            $job_sub = substr( $endpoint, strlen( 'jobs/' ) );
            if ( $job_sub === '' || ! preg_match( '/^[a-zA-Z0-9\-_\/]+$/', $job_sub ) ) {
                return new \WP_Error( 'invalid_endpoint', 'Invalid jobs endpoint path.' );
            }
            $base_cloud = self::validated_base_url( 'SITEFORT_CLOUD_API_URL', self::CLOUD_API_URL, self::CLOUD_API_ALLOWED_SUFFIXES );
            return $base_cloud . '/scan/' . $endpoint;
        }

        if ( array_key_exists( $endpoint, self::CLOUD_ROUTE_MAP ) ) {
            $base_cloud = self::validated_base_url( 'SITEFORT_CLOUD_API_URL', self::CLOUD_API_URL, self::CLOUD_API_ALLOWED_SUFFIXES );
            return $base_cloud . self::CLOUD_ROUTE_MAP[ $endpoint ];
        }

        if ( array_key_exists( $endpoint, self::CONSOLE_ROUTE_MAP ) ) {
            $base_console = self::validated_base_url( 'SITEFORT_CONSOLE_API_URL', self::CONSOLE_API_URL, self::CONSOLE_API_ALLOWED_SUFFIXES );
            return $base_console . self::CONSOLE_ROUTE_MAP[ $endpoint ];
        }

        return new \WP_Error( 'unknown_endpoint', 'Unknown API endpoint: ' . $endpoint );
    }

    public function get( string $endpoint, array $params = [], ?int $timeout_seconds = null, ?int $max_attempts = null ) {
        $resolved = $this->resolve_endpoint( $endpoint );
        if ( is_wp_error( $resolved ) ) {
            return $resolved;
        }
        $resolved = $this->with_console_site_url( $resolved );
        $url = add_query_arg( $params, $resolved );
        
        $args = [
            'timeout'   => $this->normalize_timeout_seconds( $timeout_seconds ),
            'sslverify' => true,
        ];

        return $this->request_with_retry( 'GET', $url, $args, '', $max_attempts );
    }

    public function post( string $endpoint, array $body = [], ?int $timeout_seconds = null, ?int $max_attempts = null ) {
        $url = $this->resolve_endpoint( $endpoint );
        if ( is_wp_error( $url ) ) {
            return $url;
        }
        $url = $this->with_console_site_url( $url );
        $flags = 0;
        if ( defined( 'JSON_INVALID_UTF8_SUBSTITUTE' ) ) {
            $flags |= JSON_INVALID_UTF8_SUBSTITUTE;
        }
        $json_body = json_encode( $body, $flags );
        
        if ( $json_body === false ) {
            return new \WP_Error( 
                'json_encode_error', 
                'Failed to encode request body: ' . json_last_error_msg() 
            );
        }

        $args = [
            'body'      => $json_body,
            'timeout'   => $this->normalize_timeout_seconds( $timeout_seconds ),
            'sslverify' => true,
        ];

        return $this->request_with_retry( 'POST', $url, $args, $json_body, $max_attempts );
    }

    private function normalize_timeout_seconds( ?int $timeout_seconds ): int {
        if ( null === $timeout_seconds || $timeout_seconds <= 0 ) {
            return self::REQUEST_TIMEOUT_SECONDS;
        }

        return max( 1, min( self::REQUEST_TIMEOUT_SECONDS, $timeout_seconds ) );
    }

    private function normalize_max_attempts( ?int $max_attempts, int $default_max_attempts ): int {
        if ( null === $max_attempts || $max_attempts <= 0 ) {
            return max( 1, $default_max_attempts );
        }

        return max( 1, min( 3, $max_attempts ) );
    }

    /**
     * Lightweight POST for background workers (Action Scheduler).
     *
     * Uses a short timeout (5 s) and a single retry.  Designed to run
     * inside an Action Scheduler async action so it never blocks a
     * user-facing request.  Returns true on 2xx, false on failure.
     *
     * @param string $endpoint API endpoint.
     * @param array  $body     Request body.
     * @return bool Whether the request succeeded (2xx).
     */
    public function post_background( string $endpoint, array $body = [] ): bool {
        $url = $this->resolve_endpoint( $endpoint );
        if ( is_wp_error( $url ) ) {
            return false;
        }
        $url = $this->with_console_site_url( $url );

        $backoff_key = $this->get_backoff_transient_key( 'POST', $url );
        $backoff_until = (int) get_transient( $backoff_key );
        if ( $backoff_until > 0 && time() < $backoff_until ) {
            return false;
        }

        $flags = 0;
        if ( defined( 'JSON_INVALID_UTF8_SUBSTITUTE' ) ) {
            $flags |= JSON_INVALID_UTF8_SUBSTITUTE;
        }
        $json_body = json_encode( $body, $flags );
        if ( $json_body === false ) {
            return false;
        }

        $max_attempts = 2; // 1 try + 1 retry.
        for ( $attempt = 1; $attempt <= $max_attempts; $attempt++ ) {
            $headers  = $this->get_signed_headers( 'POST', $url, $json_body );
            $response = wp_remote_post( $url, [
                'body'      => $json_body,
                'headers'   => $headers,
                'timeout'   => 5,
                'sslverify' => true,
            ] );

            if ( ! is_wp_error( $response ) ) {
                $code = wp_remote_retrieve_response_code( $response );
                if ( $code >= 200 && $code < 300 ) {
                    return true;
                }
                $this->capture_migration_pending_response( $response );
                if ( $code < 500 ) {
                    return false; // Client error - no point retrying.
                }
            }

            if ( $attempt < $max_attempts ) {
                // Non-blocking backoff: store expiry in a transient instead of sleeping.
                set_transient( $backoff_key, time() + 1, 1 );
            }
        }

        return false;
    }

    /**
     * Reliable POST for control-plane events that must converge quickly.
     *
     * Unlike post_background(), this path does not honor the shared
     * non-blocking backoff transient because terminal lifecycle state must not
     * be skipped behind unrelated telemetry/cloud retry windows.
     *
     * Uses the normal request timeout and retries server/network failures once.
     *
     * @param string $endpoint API endpoint.
     * @param array  $body     Request body.
     * @return bool Whether the request succeeded (2xx).
     */
    public function post_control_plane( string $endpoint, array $body = [] ): bool {
        $url = $this->resolve_endpoint( $endpoint );
        if ( is_wp_error( $url ) ) {
            return false;
        }
        $url = $this->with_console_site_url( $url );

        $flags = 0;
        if ( defined( 'JSON_INVALID_UTF8_SUBSTITUTE' ) ) {
            $flags |= JSON_INVALID_UTF8_SUBSTITUTE;
        }
        $json_body = json_encode( $body, $flags );
        if ( $json_body === false ) {
            return false;
        }

        $max_attempts = 2;
        for ( $attempt = 1; $attempt <= $max_attempts; $attempt++ ) {
            $headers  = $this->get_signed_headers( 'POST', $url, $json_body );
            $response = wp_remote_post( $url, [
                'body'      => $json_body,
                'headers'   => $headers,
                'timeout'   => self::REQUEST_TIMEOUT_SECONDS,
                'sslverify' => true,
            ] );

            if ( ! is_wp_error( $response ) ) {
                $code = wp_remote_retrieve_response_code( $response );
                if ( $code >= 200 && $code < 300 ) {
                    return true;
                }
                $this->capture_migration_pending_response( $response );
                if ( $code < 500 ) {
                    return false;
                }
            }
        }

        return false;
    }

    private function request_with_retry( string $method, string $url, array $args, string $body, ?int $max_attempts_override = null ) {
        $backoff_key = $this->get_backoff_transient_key( $method, $url );
        $backoff_until = (int) get_transient( $backoff_key );
        if ( $backoff_until > 0 && time() < $backoff_until ) {
            return new \WP_Error(
                'rate_limited',
                'API request temporarily deferred. Try again in ' . ( $backoff_until - time() ) . ' seconds.',
                [ 'retry_after' => $backoff_until - time() ]
            );
        }

        $attempts = 0;
        // Default retries stay GET-only; scanner workers pass an explicit attempt cap.
        $max_attempts = $this->normalize_max_attempts( $max_attempts_override, $method === 'GET' ? 3 : 1 );

        do {
            $attempts++;
            // Re-sign every attempt to prevent replay detection on retries.
            $args['headers'] = $this->get_signed_headers( $method, $url, $body );
            try {
                if ( $method === 'GET' ) {
                    $response = wp_remote_get( $url, $args );
                } else {
                    $response = wp_remote_post( $url, $args );
                }

                if ( ! is_wp_error( $response ) ) {
                    $code = wp_remote_retrieve_response_code( $response );
                    if ( $code < 500 ) {
                        return $this->process_response( $response );
                    }
                }
            } catch ( \Throwable $e ) {
                $response = new \WP_Error( 'http_exception', $e->getMessage() );
            }
            
            if ( $attempts < $max_attempts ) {
                $backoff_seconds = (int) pow( 2, $attempts - 1 );
                set_transient( $backoff_key, time() + $backoff_seconds, $backoff_seconds );
            }

        } while ( $attempts < $max_attempts );

        // Slow subsequent calls after repeated failure.
        $retry_after = 4;
        set_transient( $backoff_key, time() + $retry_after, $retry_after );

        $processed = $this->process_response( $response );
        if ( is_wp_error( $processed ) ) {
            return $this->add_error_data(
                $processed,
                [
                    'retry_after' => $retry_after,
                ]
            );
        }

        return $processed;
    }

    private function get_backoff_transient_key( string $method, string $url ): string {
        $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
        $path = (string) wp_parse_url( $url, PHP_URL_PATH );
        $fingerprint = strtoupper( $method ) . '|' . $host . '|' . $path;

        return self::BACKOFF_TRANSIENT_PREFIX . substr( hash( 'sha256', $fingerprint ), 0, 16 );
    }

    /**
     * Merge additional machine-readable metadata onto a WP_Error.
     *
     * @param \WP_Error           $error Error instance.
     * @param array<string,mixed> $extra Additional data.
     * @return \WP_Error
     */
    private function add_error_data( \WP_Error $error, array $extra ): \WP_Error {
        $code = $error->get_error_code();
        $data = $error->get_error_data( $code );
        if ( ! is_array( $data ) ) {
            $data = [];
        }

        $error->add_data( array_merge( $data, $extra ), $code );
        return $error;
    }

    private function with_console_site_url( string $url ): string {
        if ( $this->site_token === '' || $this->site_secret === '' ) {
            return $url;
        }

        $console_base = self::validated_base_url( 'SITEFORT_CONSOLE_API_URL', self::CONSOLE_API_URL, self::CONSOLE_API_ALLOWED_SUFFIXES );
        if ( strpos( $url, $console_base ) !== 0 ) {
            return $url;
        }

        return add_query_arg( [ 'site_url' => (string) home_url() ], $url );
    }

    /**
     * Persist migration status from Console client errors so background workers
     * stop retrying once the migrated copy is paused.
     */
    private function capture_migration_pending_response( $response ): void {
        if ( is_wp_error( $response ) ) {
            return;
        }

        $code = wp_remote_retrieve_response_code( $response );
        if ( $code < 400 || $code >= 500 ) {
            return;
        }

        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( ! is_array( $data ) ) {
            return;
        }

        $detail = $data['detail'] ?? null;
        if ( ! is_array( $detail ) ) {
            return;
        }

        $error_code = strtolower( trim( (string) ( $detail['error_code'] ?? '' ) ) );
        if ( $error_code !== 'site_migration_pending' ) {
            return;
        }

        $migration_status = sanitize_text_field( (string) ( $detail['migration_status'] ?? 'pending' ) );
        if ( $migration_status === '' ) {
            $migration_status = 'pending';
        }

        $pending_url = esc_url_raw( (string) ( $detail['migration_pending_url'] ?? '' ) );
        if ( $pending_url === '' ) {
            $pending_url = esc_url_raw( (string) home_url() );
        }
        $linked_url = esc_url_raw( (string) ( $detail['linked_site_url'] ?? '' ) );
        $replace_allowed = ! array_key_exists( 'migration_replace_allowed', $detail ) || (bool) $detail['migration_replace_allowed'];
        $replace_message = sanitize_text_field( (string) ( $detail['migration_replace_message'] ?? '' ) );

        CloudSettings::update_stored_fields( [
            'linked_record_url'         => $linked_url,
            'migration_status'          => $migration_status,
            'migration_pending_url'     => $pending_url,
            'migration_replace_allowed' => $replace_allowed,
            'migration_replace_message' => $replace_message,
        ] );
    }

    /**
     * Get headers with canonical HMAC signature.
     * 
     * @param string $method HTTP method
     * @param string $url Full URL
     * @param string $body JSON body to sign
     * @return array Headers with signature
     */
    private function get_plugin_version(): string {
        $version = defined( 'SITEFORT_VERSION' ) ? (string) SITEFORT_VERSION : 'dev';
        $version = preg_replace( '/[^A-Za-z0-9._+-]/', '', $version );
        return is_string( $version ) && $version !== '' ? $version : 'dev';
    }

    private function get_signed_headers( string $method, string $url, string $body ): array {
        $timestamp = (string) time();
        $nonce = $this->generate_nonce();
        $request_id = $this->generate_request_id();
        $body_hash = hash( 'sha256', $body );
        $signature = $this->sign_request( $method, $url, $timestamp, $nonce, $request_id, $body_hash );
        $plugin_version = $this->get_plugin_version();
        $home_url = str_replace( [ "\r", "\n" ], '', (string) home_url() );

        $headers = [
            'X-Site-Token'        => $this->site_token,
            'X-Correlation-ID'    => $this->correlation_id,
            'X-Request-Timestamp' => $timestamp,
            'X-Request-Nonce'     => $nonce,
            'X-Request-Id'        => $request_id,
            'X-Request-Body-Hash' => $body_hash,
            'X-Request-Signature-Version' => '3',
            'X-Request-Signature' => $signature,
            'Content-Type'        => 'application/json',
            'Accept'              => 'application/json',
            'User-Agent'          => 'SiteFort/' . $plugin_version . '; ' . $home_url,
        ];

        if ( $this->should_send_idempotency_header( $method, $url ) ) {
            $headers['X-Idempotency-Key'] = $this->build_idempotency_key( $method, $url, $body );
        }

        return $headers;
    }
    
    /**
     * Create canonical HMAC-SHA256 signature for request integrity.
     * 
     * @param string $method HTTP method
     * @param string $url Full URL
     * @param string $timestamp Unix timestamp
     * @param string $nonce Request nonce
     * @param string $request_id Request id
     * @param string $body_hash SHA-256 hex of body
     * @return string Hex-encoded signature
     */
    private function sign_request( string $method, string $url, string $timestamp, string $nonce, string $request_id, string $body_hash ): string {
        $payload = $this->build_canonical_payload( $method, $url, $timestamp, $nonce, $request_id, $body_hash );
        
        return hash_hmac( 'sha256', $payload, $this->site_secret );
    }

    private function generate_nonce(): string {
        try {
            return bin2hex( random_bytes( 16 ) );
        } catch ( \Throwable $e ) {
            return wp_generate_password( 32, false );
        }
    }

    private function generate_request_id(): string {
        return wp_generate_uuid4();
    }

    private function build_canonical_payload( string $method, string $url, string $timestamp, string $nonce, string $request_id, string $body_hash ): string {
        $parts = wp_parse_url( $url );
        $path = $parts['path'] ?? '/';
        $query = $parts['query'] ?? '';
        $canonical_query = $this->canonicalize_query( $query );
        $method = strtoupper( $method );

        return implode( "\n", [
            $method,
            $path,
            $canonical_query,
            $timestamp,
            $nonce,
            $request_id,
            $body_hash,
        ] );
    }

    private function canonicalize_query( string $query ): string {
        if ( ! class_exists( '\SiteFort\Includes\RequestVerifier' ) ) {
            require_once SITEFORT_PLUGIN_DIR . 'includes/RequestVerifier.php';
        }
        return \SiteFort\Includes\RequestVerifier::canonicalize_query( $query );
    }

    /**
     * Whether a request should include X-Idempotency-Key.
     */
    private function should_send_idempotency_header( string $method, string $url ): bool {
        if ( strtoupper( $method ) !== 'POST' ) {
            return false;
        }

        $parts = wp_parse_url( $url );
        $path = (string) ( $parts['path'] ?? '' );
        if ( $path === '' ) {
            return false;
        }

        foreach ( self::IDEMPOTENT_POST_PATH_SUFFIXES as $suffix ) {
            if ( str_ends_with( $path, $suffix ) ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Build stable idempotency key from request scope + body.
     *
     * For the console exchange endpoint the key is based on the exchange code
     * rather than the full body. The proof body contains a random nonce and
     * request_id that change on every call, making the key non-stable
     * and defeating the backend's activation response cache. Keying on the
     * exchange code (which is already the backend's idempotency scope) allows
     * the backend to serve a cached response on retry after a plugin timeout,
     * even if the exchange code was already consumed on the first attempt.
     */
    private function build_idempotency_key( string $method, string $url, string $body ): string {
        $parts = wp_parse_url( $url );
        $path  = (string) ( $parts['path'] ?? '' );
        $query = (string) ( $parts['query'] ?? '' );

        if (
            str_ends_with( $path, '/services/connect/console/exchange' )
            || str_ends_with( $path, '/services/connect/private/console/exchange' )
        ) {
            $decoded  = json_decode( $body, true );
            $code     = is_array( $decoded ) ? (string) ( $decoded['code'] ?? '' ) : '';
            $site_url = is_array( $decoded ) ? (string) ( $decoded['site_url'] ?? '' ) : '';
            if ( $code !== '' ) {
                $scope = implode( '|', [ strtoupper( $method ), $path, $code, $site_url ] );
                return hash( 'sha256', $scope );
            }
        }

        $scope = implode( '|', [
            strtoupper( $method ),
            $path,
            $this->canonicalize_query( $query ),
            $body,
        ] );
        return hash( 'sha256', $scope );
    }

    /**
     * Extract a user-facing error message from API detail payloads.
     *
     * @param mixed $detail Error detail payload.
     */
    private function extract_error_message( $detail ): string {
        if ( is_string( $detail ) ) {
            $msg = trim( $detail );
            if ( $msg !== '' ) {
                return $msg;
            }
        }

        if ( ! is_array( $detail ) ) {
            return '';
        }

        if ( isset( $detail['message'] ) && is_string( $detail['message'] ) ) {
            $msg = trim( $detail['message'] );
            if ( $msg !== '' ) {
                return $msg;
            }
        }

        $messages = [];
        foreach ( $detail as $entry ) {
            if ( is_string( $entry ) ) {
                $msg = trim( $entry );
                if ( $msg !== '' ) {
                    $messages[] = $msg;
                }
                continue;
            }
            if ( is_array( $entry ) && isset( $entry['msg'] ) && is_string( $entry['msg'] ) ) {
                $msg = trim( $entry['msg'] );
                if ( $msg !== '' ) {
                    $messages[] = $msg;
                }
            }
        }

        if ( ! empty( $messages ) ) {
            return implode( '; ', $messages );
        }

        return '';
    }

    private function process_response( $response ) {
        if ( is_wp_error( $response ) ) {
            return $response;
        }

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

        // Check HTTP status before JSON decode: 5xx responses (502, 503, etc.) often return HTML
        // rather than JSON, so decoding first would produce a confusing parse error.
        if ( $code >= 500 ) {
            return new \WP_Error(
                'api_server_error',
                'Cloud service temporarily unavailable. Please try again later.',
                [ 'status' => $code ]
            );
        }

        $data = json_decode( $body, true );

        if ( json_last_error() !== JSON_ERROR_NONE ) {
            return new \WP_Error(
                'api_parse_error',
                'Unexpected response from cloud service. Please try again later.',
                [ 'status' => $code ]
            );
        }

        if ( ! is_array( $data ) ) {
            $data = [];
        }

        if ( $code >= 400 ) {
            $detail = $data['detail'] ?? null;
            $this->capture_migration_pending_response( $response );
            $msg = $data['message'] ?? '';
            if ( ! is_string( $msg ) || trim( $msg ) === '' ) {
                $msg = $this->extract_error_message( $detail );
            }
            if ( ! is_string( $msg ) || trim( $msg ) === '' ) {
                $msg = 'Unknown API Error';
            }
            if ( is_array( $msg ) ) {
                $msg = implode( '; ', array_map( 'strval', $msg ) );
            }
            return new \WP_Error(
                'api_error',
                (string) $msg,
                [
                    'status' => $code,
                    'detail' => $detail,
                    'response' => $data,
                ]
            );
        }

        return $data;
    }

}
