<?php

namespace WPSN\Core\Http;

use WP_Error;

defined('ABSPATH') || exit;

/**
 * Best-effort hardened HTTP client wrapper around WP HTTP API.
 *
 * Goals:
 * - Provide consistent defaults (UA/timeout/redirections).
 * - Add circuit breaker to avoid repeated 503 loops.
 * - Add small retry with jitter for transient network errors.
 * - Respect Retry-After by opening the circuit (fail fast; do not sleep for long).
 */
final class HttpClient
{
    /**
     * @param array<string,mixed> $args
     * @param array<string,mixed> $policy
     * @return array|WP_Error
     */
    public static function get(string $url, array $args = [], array $policy = [])
    {
        $args['method'] = 'GET';
        return self::request($url, $args, $policy);
    }

    /**
     * @param array<string,mixed> $args
     * @param array<string,mixed> $policy
     * @return array|WP_Error
     */
    public static function post(string $url, array $args = [], array $policy = [])
    {
        $args['method'] = 'POST';
        return self::request($url, $args, $policy);
    }

    /**
     * @param array<string,mixed> $args
     * @param array<string,mixed> $policy
     * @return array|WP_Error
     */
    public static function request(string $url, array $args, array $policy = [])
    {
        $url = (string) $url;
        if ($url === '') {
            return new WP_Error('wpsn_http_empty_url', 'Empty URL');
        }

        $policy = self::normalizePolicy($url, $args, $policy);

        // Circuit breaker (disabled for loopback by default).
        $cbKey = (string) ($policy['circuit_key'] ?? '');
        $cb = null;
        if (!empty($policy['use_circuit']) && $cbKey !== '') {
            $cb = new CircuitBreaker($cbKey, (int) $policy['circuit_fail_threshold'], (int) $policy['circuit_open_seconds']);
            $st = $cb->status();
            if (!empty($st['open'])) {
                return new WP_Error('wpsn_http_circuit_open', 'Service temporarily unavailable (circuit open).', [
                    'retry_after' => (int) ($st['retry_after'] ?? 0),
                    'circuit_key' => $cbKey,
                ]);
            }
        }

        $args = self::mergeDefaults($args, $policy);

        $maxRetries = (int) ($policy['retries'] ?? 0);
        $maxRetries = max(0, min(5, $maxRetries));

        $attempt = 0;
        $last = null;

        while (true) {
            $attempt++;

            $resp = wp_remote_request($url, $args);
            $last = $resp;

            // Success path.
            if (!is_wp_error($resp)) {
                $status = (int) wp_remote_retrieve_response_code($resp);
                if ($status >= 200 && $status < 300) {
                    if ($cb) {
                        $cb->reportSuccess();
                    }
                    return $resp;
                }

                $retryAfter = self::extractRetryAfter($resp);

                // Fail-fast: respect Retry-After by opening the circuit (do not sleep long).
                if ($cb && $retryAfter > 2) {
                    $cb->reportFailure($status, 'http_' . (string) $status, $retryAfter);
                } elseif ($cb) {
                    $cb->reportFailure($status, 'http_' . (string) $status, 0);
                }

                if ($attempt <= ($maxRetries + 1) && self::isRetryableStatus($status, $policy)) {
                    // If Retry-After is large, do not retry in this request.
                    if ($retryAfter > 2) {
                        return $resp;
                    }

                    if ($attempt <= $maxRetries + 1) {
                        $delay = self::computeDelaySeconds($attempt - 1, $policy);
                        if ($retryAfter > 0) {
                            $delay = max($delay, min(2.0, (float) $retryAfter));
                        }
                        if ($delay > 0) {
                            usleep((int) round($delay * 1000000));
                        }
                        if ($attempt <= $maxRetries + 1) {
                            continue;
                        }
                    }
                }

                return $resp;
            }

            // WP_Error path.
            $code = (string) $resp->get_error_code();
            if ($cb) {
                $cb->reportFailure(0, $code, 0);
            }

            if ($attempt <= ($maxRetries + 1) && self::isRetryableWpError($resp, $policy)) {
                $delay = self::computeDelaySeconds($attempt - 1, $policy);
                if ($delay > 0) {
                    usleep((int) round($delay * 1000000));
                }
                if ($attempt <= $maxRetries + 1) {
                    continue;
                }
            }

            return $resp;
        }
    }

    /**
     * @param array<string,mixed> $policy
     */
    private static function computeDelaySeconds(int $retryIndex, array $policy): float
    {
        // Allow disabling sleep entirely (useful for certain admin flows and unit tests).
        if (!empty($policy['retry_disable_sleep'])) {
            return 0.0;
        }

        $base = isset($policy['retry_base_delay']) ? (float) $policy['retry_base_delay'] : 0.4;
        if ($base <= 0) {
            return 0.0;
        }
        $base = max(0.05, min(2.0, $base));

        $max = isset($policy['retry_max_delay']) ? (float) $policy['retry_max_delay'] : 1.5;
        $max = max(0.2, min(5.0, $max));

        // Exponential-ish: base * 2^(retryIndex-1)
        $pow = max(0, $retryIndex - 1);
        $delay = $base * (2 ** $pow);

        // Jitter 0.8x..1.2x
        $j = (mt_rand(80, 120) / 100);
        $delay *= $j;

        return min($max, $delay);
    }

    /**
     * @param array<string,mixed> $policy
     */
    private static function isRetryableStatus(int $status, array $policy): bool
    {
        $retry = $policy['retry_statuses'] ?? [429, 500, 502, 503, 504];
        if (!is_array($retry)) {
            $retry = [429, 500, 502, 503, 504];
        }
        return in_array($status, array_map('intval', $retry), true);
    }

    /**
     * @param array<string,mixed> $policy
     */
    private static function isRetryableWpError(WP_Error $err, array $policy): bool
    {
        $code = (string) $err->get_error_code();

        // Allow override.
        $retryCodes = $policy['retry_error_codes'] ?? [];
        if (is_array($retryCodes)) {
            foreach ($retryCodes as $rc) {
                if ((string) $rc === $code) {
                    return true;
                }
            }
        }

        // Default network-ish errors.
        $defaults = [
            'http_request_failed',
            'connect',
            'resolve',
            'timeout',
            'operation_timedout',
            'couldnt_connect',
            'couldnt_resolve_host',
            'couldnt_resolve_proxy',
            'ssl_connect_error',
        ];

        if (in_array($code, $defaults, true)) {
            return true;
        }

        $msg = strtolower((string) $err->get_error_message());
        if ($msg !== '' && (strpos($msg, 'timed out') !== false || strpos($msg, 'timeout') !== false)) {
            return true;
        }

        return false;
    }

    /**
     * @param array|WP_Error $resp
     */
    private static function extractRetryAfter($resp): int
    {
        if (is_wp_error($resp)) {
            return 0;
        }
        $ra = wp_remote_retrieve_header($resp, 'retry-after');
        if (is_array($ra)) {
            $ra = reset($ra);
        }
        $ra = (string) $ra;
        if ($ra === '') {
            return 0;
        }
        // retry-after can be seconds or a date. Handle seconds.
        if (ctype_digit($ra)) {
            return (int) $ra;
        }
        $ts = strtotime($ra);
        if ($ts) {
            $delta = $ts - time();
            return $delta > 0 ? (int) $delta : 0;
        }
        return 0;
    }

    /**
     * @param array<string,mixed> $args
     * @param array<string,mixed> $policy
     * @return array<string,mixed>
     */
    private static function mergeDefaults(array $args, array $policy): array
    {
        if (!isset($args['timeout'])) {
            $args['timeout'] = isset($policy['timeout']) ? (int) $policy['timeout'] : 20;
        }
        $args['timeout'] = max(1, min(120, (int) $args['timeout']));

        if (!isset($args['redirection'])) {
            $args['redirection'] = 3;
        }

        if (!isset($args['user-agent']) || (string) $args['user-agent'] === '') {
            $args['user-agent'] = 'WPSN/' . (defined('WPSN_VERSION') ? WPSN_VERSION : 'dev') . '; ' . home_url('/');
        }

        // Ensure headers is array.
        if (!isset($args['headers']) || !is_array($args['headers'])) {
            $args['headers'] = [];
        }

        // Surface service/context for privacy logging if provided in policy.
        if (!empty($policy['service']) && empty($args['headers']['X-WPSN-Service'])) {
            $args['headers']['X-WPSN-Service'] = sanitize_key((string) $policy['service']);
        }
        if (!empty($policy['context']) && empty($args['headers']['X-WPSN-Context'])) {
            $args['headers']['X-WPSN-Context'] = sanitize_key((string) $policy['context']);
        }

        return $args;
    }

    /**
     * @param array<string,mixed> $args
     * @param array<string,mixed> $policy
     * @return array<string,mixed>
     */
    private static function normalizePolicy(string $url, array $args, array $policy): array
    {
        // Derive service/context from headers if not provided.
        $headers = isset($args['headers']) && is_array($args['headers']) ? $args['headers'] : [];

        if (empty($policy['service'])) {
            foreach ($headers as $k => $v) {
                if (strtolower((string) $k) === 'x-wpsn-service') {
                    $policy['service'] = sanitize_key((string) $v);
                    break;
                }
            }
        }
        if (empty($policy['context'])) {
            foreach ($headers as $k => $v) {
                if (strtolower((string) $k) === 'x-wpsn-context') {
                    $policy['context'] = sanitize_key((string) $v);
                    break;
                }
            }
        }

        // Defaults.
        if (!isset($policy['retries'])) {
            $policy['retries'] = 1; // small retry by default
        }
        if (!isset($policy['retry_base_delay'])) {
            $policy['retry_base_delay'] = 0.4;
        }
        if (!isset($policy['retry_max_delay'])) {
            $policy['retry_max_delay'] = 1.5;
        }

        if (!isset($policy['retry_statuses'])) {
            $policy['retry_statuses'] = [429, 500, 502, 503, 504];
        }

        if (!isset($policy['circuit_fail_threshold'])) {
            $policy['circuit_fail_threshold'] = 3;
        }
        if (!isset($policy['circuit_open_seconds'])) {
            $policy['circuit_open_seconds'] = 300;
        }

        // Circuit breaker default: ON for external hosts, OFF for loopback.
        $host = (string) (wp_parse_url($url, PHP_URL_HOST) ?: '');
        $siteHost = (string) (wp_parse_url(home_url('/'), PHP_URL_HOST) ?: '');
        $isLoopback = ($host !== '' && $siteHost !== '' && strtolower($host) === strtolower($siteHost));

        if (!isset($policy['use_circuit'])) {
            $policy['use_circuit'] = $isLoopback ? false : true;
        }

        if (empty($policy['circuit_key'])) {
            $policy['circuit_key'] = self::deriveCircuitKey($url, $policy);
        }

        // Allow last-minute policy overrides.
        /**
         * Filter request policy.
         *
         * @param array<string,mixed> $policy
         * @param string $url
         * @param array<string,mixed> $args
         */
        $policy = (array) apply_filters('wpsn/http/policy', $policy, $url, $args);

        return $policy;
    }

    /**
     * @param array<string,mixed> $policy
     */
    private static function deriveCircuitKey(string $url, array $policy): string
    {
        if (!empty($policy['service'])) {
            return 'svc_' . sanitize_key((string) $policy['service']);
        }

        $host = (string) (wp_parse_url($url, PHP_URL_HOST) ?: '');
        $host = sanitize_key($host);
        if ($host !== '') {
            return 'host_' . $host;
        }

        return 'default';
    }
}
