<?php

declare(strict_types=1);

namespace ShieldX\Security;

defined('ABSPATH') || exit;

/**
 * Resolves the visitor IP without trusting spoofable proxy headers by default.
 */
final class ClientIpResolver
{
    private const HEADER_CF_CONNECTING_IP = 'cf_connecting_ip';
    private const HEADER_X_REAL_IP = 'x_real_ip';
    private const HEADER_X_FORWARDED_FOR = 'x_forwarded_for';

    /** @var array<string,string> */
    private const HEADER_SERVER_KEYS = [
        self::HEADER_CF_CONNECTING_IP => 'HTTP_CF_CONNECTING_IP',
        self::HEADER_X_REAL_IP => 'HTTP_X_REAL_IP',
        self::HEADER_X_FORWARDED_FOR => 'HTTP_X_FORWARDED_FOR',
    ];

    /**
     * @param array<string,mixed> $server
     */
    public static function fromServer(array $server, ?WafSettings $settings = null): string
    {
        $remote = self::remoteAddr($server);

        if (!$settings || $remote === '' || !self::isTrustedProxy($server, $settings)) {
            return $remote !== '' ? $remote : 'unknown';
        }

        $header = $settings->trustedProxyHeader();
        if ($header === '' || !isset(self::HEADER_SERVER_KEYS[$header])) {
            return $remote;
        }

        $headerValue = (string) ($server[self::HEADER_SERVER_KEYS[$header]] ?? '');
        $candidate = $header === self::HEADER_X_FORWARDED_FOR
            ? self::clientIpFromForwardedFor($headerValue, $settings)
            : self::firstValidIp($headerValue);

        return $candidate !== '' ? $candidate : $remote;
    }

    /**
     * @param array<string,mixed> $server
     */
    public static function remoteAddr(array $server): string
    {
        $remote = trim((string) ($server['REMOTE_ADDR'] ?? ''));

        return filter_var($remote, FILTER_VALIDATE_IP) ? $remote : '';
    }

    /**
     * @param array<string,mixed> $server
     */
    public static function isTrustedProxy(array $server, ?WafSettings $settings = null): bool
    {
        if (!$settings) {
            return false;
        }

        $remote = self::remoteAddr($server);
        if ($remote === '') {
            return false;
        }

        foreach ($settings->trustedProxyCidrs() as $cidr) {
            if ($remote === $cidr || (strpos($cidr, '/') !== false && self::ipInCidr($remote, $cidr))) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param array<string,mixed> $server
     */
    public static function trustedForwardedProto(array $server, ?WafSettings $settings = null): string
    {
        if (!self::isTrustedProxy($server, $settings)) {
            return '';
        }

        foreach (['HTTP_X_FORWARDED_PROTO', 'HTTP_X_FORWARDED_SCHEME'] as $key) {
            $value = strtolower(trim(explode(',', (string) ($server[$key] ?? ''))[0]));
            if (in_array($value, ['http', 'https'], true)) {
                return $value;
            }
        }

        if (in_array(strtolower((string) ($server['HTTP_X_FORWARDED_SSL'] ?? '')), ['on', '1'], true)) {
            return 'https';
        }

        if (in_array(strtolower((string) ($server['HTTP_FRONT_END_HTTPS'] ?? '')), ['on', '1'], true)) {
            return 'https';
        }

        return '';
    }

    public static function sanitizeProxyHeader(string $header): string
    {
        $header = strtolower(trim($header));

        return isset(self::HEADER_SERVER_KEYS[$header]) ? $header : '';
    }

    public static function sanitizeIpRange(string $value, bool $allowLoopback = false): string
    {
        $value = trim($value);

        if ($value === '') {
            return '';
        }

        $canonical = self::canonicalIp($value);
        if ($canonical !== '' && self::isAllowedTrustedProxyBase($canonical, $allowLoopback)) {
            return $canonical;
        }

        if (preg_match('~^([^/]+)/(\d{1,3})$~', $value, $matches) !== 1) {
            return '';
        }

        $ip = self::canonicalIp((string) $matches[1]);
        $prefix = (int) $matches[2];
        if ($ip === '' || !self::isAllowedTrustedProxyBase($ip, $allowLoopback)) {
            return '';
        }

        $max = strpos($ip, ':') !== false ? 128 : 32;

        return ($prefix > 0 && $prefix <= $max) ? $ip . '/' . (string) $prefix : '';
    }

    public static function ipInCidr(string $ip, string $cidr): bool
    {
        [$range, $prefix] = array_pad(explode('/', $cidr, 2), 2, '');
        $prefix = (int) $prefix;
        if (!filter_var($ip, FILTER_VALIDATE_IP) || !filter_var($range, FILTER_VALIDATE_IP)) {
            return false;
        }

        $ipBin = inet_pton($ip);
        $rangeBin = inet_pton($range);

        if (!is_string($ipBin) || !is_string($rangeBin) || strlen($ipBin) !== strlen($rangeBin)) {
            return false;
        }

        $maxPrefix = strlen($ipBin) === 16 ? 128 : 32;
        if ($prefix < 0 || $prefix > $maxPrefix) {
            return false;
        }

        $bytes = intdiv($prefix, 8);
        $bits = $prefix % 8;

        if ($bytes > 0 && substr($ipBin, 0, $bytes) !== substr($rangeBin, 0, $bytes)) {
            return false;
        }

        if ($bits === 0) {
            return true;
        }

        $mask = (0xff << (8 - $bits)) & 0xff;

        return (ord($ipBin[$bytes]) & $mask) === (ord($rangeBin[$bytes]) & $mask);
    }

    private static function firstValidIp(string $headerValue): string
    {
        foreach (explode(',', $headerValue) as $part) {
            $ip = trim($part);
            if ($ip !== '' && filter_var($ip, FILTER_VALIDATE_IP)) {
                return $ip;
            }
        }

        return '';
    }

    private static function clientIpFromForwardedFor(string $headerValue, WafSettings $settings): string
    {
        $ips = [];
        foreach (explode(',', $headerValue) as $part) {
            $ip = trim($part);
            if ($ip !== '' && filter_var($ip, FILTER_VALIDATE_IP)) {
                $ips[] = $ip;
            }
        }

        if (empty($ips)) {
            return '';
        }

        for ($index = count($ips) - 1; $index >= 0; $index--) {
            if (!self::isTrustedProxyIp($ips[$index], $settings)) {
                return $ips[$index];
            }
        }

        // An all-trusted chain has no verifiable client hop. Falling back to
        // the direct peer is safer than accepting a spoofable leftmost value.
        return '';
    }

    private static function isTrustedProxyIp(string $ip, WafSettings $settings): bool
    {
        foreach ($settings->trustedProxyCidrs() as $cidr) {
            if ($ip === $cidr || (strpos($cidr, '/') !== false && self::ipInCidr($ip, $cidr))) {
                return true;
            }
        }

        return false;
    }

    private static function canonicalIp(string $ip): string
    {
        if (!filter_var($ip, FILTER_VALIDATE_IP) || !function_exists('inet_pton') || !function_exists('inet_ntop')) {
            return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : '';
        }

        $packed = @inet_pton($ip);
        if (!is_string($packed)) {
            return '';
        }

        $canonical = @inet_ntop($packed);

        return is_string($canonical) ? $canonical : '';
    }

    private static function isAllowedTrustedProxyBase(string $ip, bool $allowLoopback = false): bool
    {
        if (!filter_var($ip, FILTER_VALIDATE_IP)) {
            return false;
        }

        if ($allowLoopback && self::isLoopbackIp($ip)) {
            return true;
        }

        return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE) !== false;
    }

    private static function isLoopbackIp(string $ip): bool
    {
        return $ip === '::1' || strpos($ip, '127.') === 0;
    }
}
