<?php

declare(strict_types=1);

namespace ShieldX\Security;

defined('ABSPATH') || exit;

use ShieldX\Core\SchedulerBridge;

final class ReputationFeed
{
    public const HOOK = 'shieldx_reputation_feed_refresh';

    private const MAX_RESPONSE_BYTES = 524288;
    private const REFRESH_DELAY_SECONDS = 60;
    private const FEED_DNS_CACHE_SECONDS = 60;

    /** @var array<int,string> */
    private static $feedListKeys = [
        'ips',
        'ip_blocklist',
        'ip_blocklist_cidrs',
        'hostnames',
        'hosts',
        'hostname_blocklist',
        'user_agents',
        'user_agent_blocklist',
        'allowlist',
        'allowed',
    ];

    /** @var array<int,string> */
    private static $feedMetadataKeys = [
        'version',
        'updated_at',
        'generated_at',
        'source',
        'description',
    ];

    public static function scheduleRefresh(): bool
    {
        if (!SchedulerBridge::active()) {
            self::clearScheduled();
            return false;
        }

        if (!function_exists('wp_schedule_single_event')) {
            return false;
        }

        if (function_exists('wp_next_scheduled') && (int) wp_next_scheduled(self::HOOK) > 0) {
            return true;
        }

        return (bool) wp_schedule_single_event(time() + self::REFRESH_DELAY_SECONDS, self::HOOK);
    }

    public static function clearScheduled(): void
    {
        if (function_exists('wp_clear_scheduled_hook')) {
            wp_clear_scheduled_hook(self::HOOK);
        }
    }

    public static function runScheduled(): void
    {
        $wafSettings = WafSettings::fromWordPress();
        $current = $wafSettings->all();
        $url = (string) ($current['reputation_feed_url'] ?? '');
        if ($url === '') {
            $current['reputation_last_attempt'] = time();
            $current['reputation_last_status'] = 'skipped';
            $current['reputation_last_error'] = '';
            $wafSettings->save((array) ($current['disabled_rules'] ?? []), (array) ($current['plugin_whitelist'] ?? []), $current);

            return;
        }

        $refresh = self::refresh($url);
        $next = $current;
        $next['reputation_last_attempt'] = time();
        if (!empty($refresh['ok'])) {
            $feedSettings = isset($refresh['settings']) && is_array($refresh['settings']) ? $refresh['settings'] : [];
            $next['reputation_ip_blocklist'] = (array) ($feedSettings['reputation_ip_blocklist'] ?? []);
            $next['reputation_hostname_blocklist'] = (array) ($feedSettings['reputation_hostname_blocklist'] ?? []);
            $next['reputation_user_agent_blocklist'] = (array) ($feedSettings['reputation_user_agent_blocklist'] ?? []);
            $next['reputation_last_refresh'] = (int) ($feedSettings['reputation_last_refresh'] ?? time());
            $next['reputation_last_status'] = 'completed';
            $next['reputation_last_error'] = '';
        } else {
            $next['reputation_last_status'] = 'failed';
            $next['reputation_last_error'] = (string) ($refresh['message'] ?? __('Reputation feed refresh failed.', 'xerotact-shield-x-security'));
        }

        $wafSettings->save((array) ($current['disabled_rules'] ?? []), (array) ($current['plugin_whitelist'] ?? []), $next);

    }

    /**
     * @return array{ok:bool,settings:array<string,mixed>,message:string}
     */
    public static function refresh(string $url): array
    {
        $url = self::sanitizeUrl($url);
        if ($url === '') {
            return self::result(false, [], __('Enter a valid public HTTPS JSON feed URL.', 'xerotact-shield-x-security'));
        }

        if (!function_exists('wp_remote_get') || !function_exists('wp_remote_retrieve_response_code') || !function_exists('wp_remote_retrieve_body')) {
            return self::result(false, [], __('WordPress HTTP API is unavailable on this host.', 'xerotact-shield-x-security'));
        }

        $pinnedIp = self::resolvePublicFeedIp($url);
        if ($pinnedIp === '') {
            return self::result(false, [], __('Feed host could not be resolved to a public IP address.', 'xerotact-shield-x-security'));
        }

        if (!function_exists('add_action') || !function_exists('remove_action') || !function_exists('curl_setopt') || !defined('CURLOPT_RESOLVE')) {
            return self::result(false, [], __('Feed fetch requires the WordPress cURL transport so the validated public IP can be pinned.', 'xerotact-shield-x-security'));
        }

        $curlPin = null;
        $pinApplied = false;
        $curlPin = static function ($handle, $requestArgs, $requestUrl) use ($url, $pinnedIp, &$pinApplied): void {
            unset($requestArgs);

            if ((string) $requestUrl !== $url) {
                return;
            }

            $parts = self::urlParts($url);
            if (!is_array($parts)) {
                return;
            }

            $host = (string) ($parts['host'] ?? '');
            $port = (int) ($parts['port'] ?? 443);
            $pinAddress = strpos($pinnedIp, ':') !== false ? '[' . $pinnedIp . ']' : $pinnedIp;
            // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- The request still uses wp_remote_get(); this hook only pins the previously validated public IP because WP HTTP has no CURLOPT_RESOLVE abstraction.
            $pinApplied = (bool) curl_setopt($handle, CURLOPT_RESOLVE, [$host . ':' . (string) $port . ':' . $pinAddress]);
        };
        try {
            add_action('http_api_curl', $curlPin, 10, 3);
        } catch (\Throwable $error) {
            \ShieldX\Support\ErrorLog::write('SHIELD-X reputation feed pin hook registration failed: ' . $error->getMessage());

            return self::result(false, [], __('Feed fetch could not pin the validated public IP address.', 'xerotact-shield-x-security'));
        }

        try {
            $response = wp_remote_get($url, [
                'timeout' => 8,
                'redirection' => 0,
                'reject_unsafe_urls' => true,
                'limit_response_size' => self::MAX_RESPONSE_BYTES,
                'headers' => [
                    'Accept' => 'application/json',
                    'User-Agent' => 'SHIELD-X/' . (defined('SHIELDX_VERSION') ? SHIELDX_VERSION : 'dev'),
                ],
            ]);
        } catch (\Throwable $error) {
            return self::result(false, [], sprintf(
                /* translators: %s is an HTTP transport error message. */
                __('Feed request failed: %s', 'xerotact-shield-x-security'),
                $error->getMessage()
            ));
        } finally {
            try {
                remove_action('http_api_curl', $curlPin, 10);
            } catch (\Throwable $error) {
                \ShieldX\Support\ErrorLog::write('SHIELD-X reputation feed pin hook removal failed: ' . $error->getMessage());
            }
        }

        if (!$pinApplied) {
            return self::result(false, [], __('Feed fetch was refused because the validated public IP was not pinned by the HTTP transport.', 'xerotact-shield-x-security'));
        }

        if (function_exists('is_wp_error') && is_wp_error($response)) {
            return self::result(false, [], $response->get_error_message());
        }

        $code = (int) wp_remote_retrieve_response_code($response);
        if ($code < 200 || $code >= 300) {
            return self::result(false, [], sprintf(
                /* translators: %d is an HTTP response code. */
                __('Feed returned HTTP %d.', 'xerotact-shield-x-security'),
                $code
            ));
        }

        $body = (string) wp_remote_retrieve_body($response);
        if (strlen($body) > self::MAX_RESPONSE_BYTES) {
            return self::result(false, [], __('Feed response exceeded the size limit.', 'xerotact-shield-x-security'));
        }

        $decoded = \ShieldX\Support\Json::decodeArray($body);
        if (!is_array($decoded)) {
            return self::result(false, [], __('Feed response was not valid JSON.', 'xerotact-shield-x-security'));
        }

        $schemaError = self::schemaError($decoded);
        if ($schemaError !== '') {
            return self::result(false, [], $schemaError);
        }

        $settings = self::settingsFromPayload($decoded);
        $settings['reputation_last_refresh'] = time();
        $settings['reputation_last_error'] = '';

        return self::result(true, $settings, __('Reputation feed refreshed.', 'xerotact-shield-x-security'));
    }

    /**
     * @param array<string,mixed> $payload
     * @return array<string,mixed>
     */
    public static function settingsFromPayload(array $payload): array
    {
        return [
            'reputation_ip_blocklist' => self::listFromPayload($payload, ['ips', 'ip_blocklist', 'ip_blocklist_cidrs'], 'ip'),
            'reputation_hostname_blocklist' => self::listFromPayload($payload, ['hostnames', 'hosts', 'hostname_blocklist'], 'hostname'),
            'reputation_user_agent_blocklist' => self::listFromPayload($payload, ['user_agents', 'user_agent_blocklist']),
            'reputation_allowlist' => self::listFromPayload($payload, ['allowlist', 'allowed']),
        ];
    }

    /**
     * @param array<string,mixed> $payload
     * @param array<int,string>   $keys
     * @return array<int,string>
     */
    private static function listFromPayload(array $payload, array $keys, string $type = 'text'): array
    {
        $items = [];
        foreach ($keys as $key) {
            if (!isset($payload[$key])) {
                continue;
            }

            $value = $payload[$key];
            if (is_string($value)) {
                $value = preg_split('~[\r\n,]+~', $value) ?: [];
            }

            if (!is_array($value)) {
                continue;
            }

            foreach ($value as $entry) {
                if (is_scalar($entry)) {
                    $entry = trim((string) $entry);
                    if ($entry !== '' && self::isSafeFeedListValue($entry, $type)) {
                        $items[$entry] = $entry;
                    }
                }
            }
        }

        return array_values($items);
    }

    private static function isSafeFeedListValue(string $value, string $type): bool
    {
        if ($type === 'ip') {
            return self::isPublicIpOrNarrowCidr($value);
        }

        if ($type === 'hostname') {
            return self::isSafeFeedHostname($value);
        }

        return true;
    }

    private static function isPublicIpOrNarrowCidr(string $value): bool
    {
        if (strpos($value, '/') === false) {
            return self::isPublicIp($value);
        }

        [$ip, $prefix] = array_pad(explode('/', $value, 2), 2, '');
        if (!ctype_digit($prefix) || !filter_var($ip, FILTER_VALIDATE_IP) || !self::isPublicIp($ip)) {
            return false;
        }

        $isIpv6 = strpos($ip, ':') !== false;
        $prefixLength = (int) $prefix;
        // Feed CIDRs are deliberately conservative: broad ranges can include
        // private or reserved addresses even when their base address is public.
        return $isIpv6
            ? $prefixLength >= 64 && $prefixLength <= 128
            : $prefixLength >= 24 && $prefixLength <= 32;
    }

    private static function isSafeFeedHostname(string $hostname): bool
    {
        $hostname = strtolower(trim($hostname, '.'));
        if ($hostname === '' || !filter_var($hostname, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) {
            return false;
        }

        if (in_array($hostname, ['localhost', 'localhost.localdomain'], true)) {
            return false;
        }

        foreach (['.local', '.localhost', '.internal', '.home.arpa'] as $suffix) {
            if (substr($hostname, -strlen($suffix)) === $suffix) {
                return false;
            }
        }

        return true;
    }

    /**
     * @param array<string,mixed> $payload
     */
    private static function schemaError(array $payload): string
    {
        $hasSupportedList = false;

        foreach ($payload as $key => $value) {
            $key = (string) $key;
            if (!in_array($key, self::$feedListKeys, true)) {
                if (!in_array($key, self::$feedMetadataKeys, true)) {
                    return __('Feed JSON contains an unsupported top-level key.', 'xerotact-shield-x-security');
                }

                continue;
            }

            $hasSupportedList = true;
            if (!is_string($value) && !is_array($value)) {
                return __('Feed list values must be strings or arrays.', 'xerotact-shield-x-security');
            }

            if (is_array($value)) {
                foreach ($value as $entry) {
                    if (!is_scalar($entry)) {
                        return __('Feed list entries must be scalar values.', 'xerotact-shield-x-security');
                    }
                }
            }
        }

        return $hasSupportedList ? '' : __('Feed JSON did not contain any supported reputation lists.', 'xerotact-shield-x-security');
    }

    public static function sanitizeUrl(string $url): string
    {
        $url = trim($url);
        if (!self::isSafeFeedUrl($url)) {
            return '';
        }

        return function_exists('esc_url_raw') ? esc_url_raw($url) : $url;
    }

    public static function isSafeFeedUrl(string $url): bool
    {
        $url = trim($url);
        if ($url === '' || strlen($url) > WafSettings::MAX_REPUTATION_FEED_URL_LENGTH || !filter_var($url, FILTER_VALIDATE_URL)) {
            return false;
        }

        $parts = self::urlParts($url);
        if (!is_array($parts)) {
            return false;
        }

        $scheme = strtolower((string) ($parts['scheme'] ?? ''));
        $host = strtolower(trim((string) ($parts['host'] ?? ''), "[] \t\n\r\0\x0B"));
        if ($scheme !== 'https' || $host === '') {
            return false;
        }

        if (in_array($host, ['localhost', 'localhost.localdomain'], true)) {
            return false;
        }

        foreach (['.local', '.localhost', '.internal', '.home.arpa'] as $privateSuffix) {
            if (substr($host, -strlen($privateSuffix)) === $privateSuffix) {
                return false;
            }
        }

        if (filter_var($host, FILTER_VALIDATE_IP)) {
            return self::isPublicIp($host);
        }

        if (strpos($host, '.') === false) {
            return false;
        }

        $addresses = self::publicFeedHostAddresses($host);
        if (is_array($addresses) && !empty($addresses)) {
            foreach ($addresses as $address) {
                if (!self::isPublicIp((string) $address)) {
                    return false;
                }
            }
        }

        return true;
    }

    private static function resolvePublicFeedIp(string $url): string
    {
        $parts = self::urlParts($url);
        if (!is_array($parts)) {
            return '';
        }

        $host = strtolower(trim((string) ($parts['host'] ?? ''), "[] \t\n\r\0\x0B"));
        if ($host === '') {
            return '';
        }

        if (filter_var($host, FILTER_VALIDATE_IP)) {
            return self::isPublicIp($host) ? $host : '';
        }

        $addresses = self::publicFeedHostAddresses($host);
        if (!is_array($addresses) || empty($addresses)) {
            return '';
        }

        foreach ($addresses as $address) {
            $address = (string) $address;
            if (!self::isPublicIp($address)) {
                return '';
            }
        }

        return (string) $addresses[0];
    }

    private static function isPublicIp(string $ip): bool
    {
        return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
    }

    /**
     * @return array<string,string>|false
     */
    private static function urlParts(string $url)
    {
        if (function_exists('wp_parse_url')) {
            $parts = wp_parse_url($url);

            return is_array($parts) ? $parts : false;
        }

        if (preg_match('~^([a-z][a-z0-9+.-]*):\/\/(?:[^@\/?#]+@)?(\[[^\]]+\]|[^:\/?#]+)~i', $url, $matches) !== 1) {
            return false;
        }

        return [
            'scheme' => strtolower((string) $matches[1]),
            'host' => trim((string) $matches[2], '[]'),
        ];
    }

    /**
     * @return array<int,string>|null
     */
    private static function publicFeedHostAddresses(string $host): ?array
    {
        if (!function_exists('gethostbynamel') && !function_exists('dns_get_record')) {
            return null;
        }

        $cacheKey = 'shieldx_reputation_feed_dns_' . hash('sha256', strtolower($host));
        if (function_exists('get_transient')) {
            try {
                $cached = get_transient($cacheKey);
                if (is_array($cached) && isset($cached['addresses']) && is_array($cached['addresses'])) {
                    return array_values(array_filter(array_map('strval', $cached['addresses'])));
                }
            } catch (\Throwable $error) {
                \ShieldX\Support\ErrorLog::write('SHIELD-X reputation feed DNS cache read failed: ' . $error->getMessage());
            }
        }

        $addresses = function_exists('gethostbynamel') ? gethostbynamel($host) : [];
        $addresses = is_array($addresses) ? array_values(array_filter(array_map('strval', $addresses))) : [];
        if (function_exists('dns_get_record')) {
            $records = dns_get_record($host, defined('DNS_AAAA') ? DNS_AAAA : 28);
            foreach (is_array($records) ? $records : [] as $record) {
                if (is_array($record) && isset($record['ipv6']) && is_string($record['ipv6'])) {
                    $addresses[] = $record['ipv6'];
                }
            }
        }
        $addresses = array_values(array_unique($addresses));

        if (function_exists('set_transient')) {
            try {
                set_transient($cacheKey, ['addresses' => $addresses], self::FEED_DNS_CACHE_SECONDS);
            } catch (\Throwable $error) {
                \ShieldX\Support\ErrorLog::write('SHIELD-X reputation feed DNS cache write failed: ' . $error->getMessage());
            }
        }

        return $addresses;
    }

    /**
     * @param array<string,mixed> $settings
     * @return array{ok:bool,settings:array<string,mixed>,message:string}
     */
    private static function result(bool $ok, array $settings, string $message): array
    {
        return [
            'ok' => $ok,
            'settings' => $settings,
            'message' => $message,
        ];
    }
}
