<?php

namespace Licenzo\Support;

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

/**
 * Trusted-proxy-aware client IP resolver.
 *
 * SECURITY CONTRACT:
 *   The only IP that a plugin-level process can trust unconditionally
 *   is REMOTE_ADDR (the one-hop source of the TCP connection). Every
 *   other IP-carrying header (X-Forwarded-For, Client-IP,
 *   X-Real-IP, etc.) is attacker-supplied unless the immediate
 *   REMOTE_ADDR is in a configured trusted-proxy allowlist.
 *
 *   Default posture (empty allowlist) returns REMOTE_ADDR verbatim.
 *   This is correct for single-host deployments and slightly
 *   conservative for deployments behind a proxy (rate limits become
 *   per-proxy-IP, which still caps abuse, just less granularly).
 *
 *   Opt-in to X-Forwarded-For is ONLY activated when REMOTE_ADDR is in
 *   the trusted-proxy allowlist. We then walk the forwarded chain
 *   right-to-left, skipping trusted entries, and return the first
 *   untrusted IP — the real client.
 *
 * NON-GOALS:
 *   - Does NOT fetch proxy IP ranges from any external source.
 *   - Does NOT trust HTTP_CLIENT_IP, HTTP_X_REAL_IP,
 *     HTTP_X_CLUSTER_CLIENT_IP, HTTP_FORWARDED, or HTTP_FORWARDED_FOR
 *     by default. These are cargo-culted headers, all spoofable.
 *   - Does NOT attempt to canonicalize IPv4-mapped IPv6 to IPv4.
 */
class ClientIpResolver
{
    /**
     * Sentinel returned when no valid IP can be determined.
     * Callers that rate-limit will still produce a bucket, but all
     * such requests share it — correct failure mode.
     */
    public const FALLBACK_IP = '0.0.0.0';

    /**
     * Resolve the client IP from a request environment.
     *
     * @param array                 $server          Typically $_SERVER.
     * @param array<int,string>     $trustedProxies  Allowlist of IPs / CIDRs.
     * @param string                $trustedHeader   Server key to consult when REMOTE_ADDR is trusted.
     */
    public static function resolve(array $server, array $trustedProxies = [], string $trustedHeader = 'HTTP_X_FORWARDED_FOR'): string
    {
        $remoteAddr = isset($server['REMOTE_ADDR']) ? trim((string) $server['REMOTE_ADDR']) : '';

        if (!static::isValidIp($remoteAddr)) {
            return static::FALLBACK_IP;
        }

        if (empty($trustedProxies)) {
            return $remoteAddr;
        }

        if (!static::ipMatchesAny($remoteAddr, $trustedProxies)) {
            return $remoteAddr;
        }

        $forwardedRaw = $server[$trustedHeader] ?? '';
        if (!is_string($forwardedRaw) || $forwardedRaw === '') {
            return $remoteAddr;
        }

        $chain = array_map('trim', explode(',', $forwardedRaw));

        for ($i = count($chain) - 1; $i >= 0; $i--) {
            $candidate = $chain[$i];
            if (!static::isValidIp($candidate)) {
                continue;
            }
            if (static::ipMatchesAny($candidate, $trustedProxies)) {
                continue;
            }
            return $candidate;
        }

        return $remoteAddr;
    }

    /**
     * Syntactic IP validation. Intentionally permits private / reserved
     * ranges: those flags belong to "is this a real public client",
     * not to "is this a valid IP literal". We use the CIDR check for
     * trust decisions instead.
     */
    protected static function isValidIp(string $ip): bool
    {
        if ($ip === '') {
            return false;
        }
        return filter_var($ip, FILTER_VALIDATE_IP) !== false;
    }

    /**
     * Does $ip match any allowlist entry? Entries may be exact IPs or
     * CIDR ranges (IPv4 or IPv6).
     *
     * @param array<int,string> $entries
     */
    protected static function ipMatchesAny(string $ip, array $entries): bool
    {
        foreach ($entries as $entry) {
            $entry = trim((string) $entry);
            if ($entry === '') {
                continue;
            }
            if (static::ipMatches($ip, $entry)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Exact-IP or CIDR match.
     */
    protected static function ipMatches(string $ip, string $entry): bool
    {
        if (strpos($entry, '/') === false) {
            return $ip === $entry;
        }

        [$subnet, $maskBits] = explode('/', $entry, 2);
        if (!static::isValidIp($subnet)) {
            return false;
        }
        if (!ctype_digit($maskBits)) {
            return false;
        }
        $maskBits = (int) $maskBits;

        $ipIsV6     = strpos($ip, ':') !== false;
        $subnetIsV6 = strpos($subnet, ':') !== false;
        if ($ipIsV6 !== $subnetIsV6) {
            return false;
        }

        return $ipIsV6
            ? static::ipMatchesCidrV6($ip, $subnet, $maskBits)
            : static::ipMatchesCidrV4($ip, $subnet, $maskBits);
    }

    protected static function ipMatchesCidrV4(string $ip, string $subnet, int $maskBits): bool
    {
        if ($maskBits < 0 || $maskBits > 32) {
            return false;
        }
        $ipLong     = ip2long($ip);
        $subnetLong = ip2long($subnet);
        if ($ipLong === false || $subnetLong === false) {
            return false;
        }
        if ($maskBits === 0) {
            return true;
        }
        $mask = -1 << (32 - $maskBits);
        return ($ipLong & $mask) === ($subnetLong & $mask);
    }

    protected static function ipMatchesCidrV6(string $ip, string $subnet, int $maskBits): bool
    {
        if ($maskBits < 0 || $maskBits > 128) {
            return false;
        }
        $ipBin     = inet_pton($ip);
        $subnetBin = inet_pton($subnet);
        if ($ipBin === false || $subnetBin === false) {
            return false;
        }

        $fullBytes = intdiv($maskBits, 8);
        $remainderBits = $maskBits % 8;

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

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

        $maskByte    = chr((0xFF << (8 - $remainderBits)) & 0xFF);
        $ipByte      = $ipBin[$fullBytes] ?? "\0";
        $subnetByte  = $subnetBin[$fullBytes] ?? "\0";
        return ($ipByte & $maskByte) === ($subnetByte & $maskByte);
    }
}
