<?php
/*
 * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
 * SPDX-License-Identifier: Apache-2.0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * See the LICENSE file in the project root for the full license text.
 */

declare(strict_types=1);

namespace App\Http;

/**
 * Trusted-proxy helper for `X-Forwarded-For` and `X-Forwarded-Proto`
 * (R01-N05 / R01-N07).
 *
 * Operators populate `TRUSTED_PROXIES` in `.env` with a comma-separated list
 * of CIDRs (e.g. `10.0.0.0/8, 192.168.0.0/16, 2001:db8::/32`). Bare IPs
 * without `/n` are treated as `/32` (IPv4) or `/128` (IPv6). With the env
 * var blank — the default — the helper trusts no proxy and returns
 * `REMOTE_ADDR` verbatim.
 *
 * Two consumers:
 *
 *   - `clientIp(array $server)` walks rightmost-to-leftmost through
 *     `X-Forwarded-For` (with `REMOTE_ADDR` appended logically), returning
 *     the first hop that is not a trusted proxy. This is the IP that goes
 *     into the audit log and the local-admin throttle bucket (R01-N07).
 *
 *   - `isHttps(array $server)` recognises a request as HTTPS when either
 *     the server-side scheme is already TLS (`HTTPS=on`, port 443, etc.)
 *     OR `X-Forwarded-Proto: https` arrived from a trusted proxy
 *     (R01-N05).
 *
 * Untrusted forwarded headers are ignored; an attacker can't lie their way
 * into a different audit IP or HTTPS posture by hand-crafting request
 * headers, because their transport-layer `REMOTE_ADDR` will not match a
 * configured CIDR.
 */
final class TrustedProxies
{
    /** @var array<int, array{0:string,1:int}> list of [networkBin, prefixBits] */
    private array $cidrs;

    /**
     * @param string[] $cidrs CIDR strings such as "10.0.0.0/8" or "127.0.0.1".
     *                        Invalid entries are silently skipped — operators
     *                        get the secure default (no trust) on a typo.
     */
    public function __construct(array $cidrs)
    {
        $parsed = [];
        foreach ($cidrs as $raw) {
            $entry = self::parseCidr((string) $raw);
            if ($entry !== null) {
                $parsed[] = $entry;
            }
        }
        $this->cidrs = $parsed;
    }

    /** Build from `TRUSTED_PROXIES`. Empty / unset → trusts nothing. */
    public static function fromEnv(): self
    {
        $raw = getenv('TRUSTED_PROXIES');
        if (!is_string($raw) || trim($raw) === '') {
            return new self([]);
        }
        $parts = array_filter(
            array_map('trim', explode(',', $raw)),
            static fn(string $p): bool => $p !== '',
        );
        return new self(array_values($parts));
    }

    /** True iff `$ip` falls inside any configured CIDR. */
    public function isTrusted(string $ip): bool
    {
        $bin = @inet_pton($ip);
        if ($bin === false) {
            return false;
        }
        foreach ($this->cidrs as [$rangeBin, $bits]) {
            if (strlen($bin) !== strlen($rangeBin)) {
                continue; // family mismatch (v4 vs v6)
            }
            if (self::matchPrefix($bin, $rangeBin, $bits)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Resolve the originating client IP for a request.
     *
     * Algorithm (RFC 7239 / X-Forwarded-For convention):
     *   1. If `REMOTE_ADDR` itself is not in a trusted CIDR → return it as-is.
     *      The forwarded header cannot be trusted from an arbitrary client.
     *   2. Otherwise build the chain `[XFF entries…, REMOTE_ADDR]` and walk
     *      from right (closest to us) to left (closest to the client), skipping
     *      hops that are still trusted. The first non-trusted hop is the
     *      client.
     *   3. If every hop is trusted (e.g. an internal-only deployment) we fall
     *      back to the leftmost XFF entry; that's the most informative value
     *      we have.
     *
     * @param array<string,mixed> $server $_SERVER-shaped map.
     */
    public function clientIp(array $server): string
    {
        $remote = self::scalar($server, 'REMOTE_ADDR');
        if ($remote === '') {
            return '';
        }
        if (!$this->isTrusted($remote)) {
            return $remote;
        }

        $xffRaw = self::scalar($server, 'HTTP_X_FORWARDED_FOR');
        if ($xffRaw === '') {
            return $remote;
        }

        $hops = array_values(array_filter(
            array_map(
                static fn(string $hop): string => self::stripPort(trim($hop)),
                explode(',', $xffRaw),
            ),
            static fn(string $hop): bool => $hop !== '',
        ));
        if ($hops === []) {
            return $remote;
        }

        // Walk rightmost-to-leftmost; skip trusted hops, return first
        // untrusted one.
        for ($i = count($hops) - 1; $i >= 0; $i--) {
            $hop = $hops[$i];
            if (!$this->isTrusted($hop)) {
                return $hop;
            }
        }

        // Every hop is trusted — return leftmost as the "deepest" guess.
        return $hops[0];
    }

    /**
     * True iff the request is effectively over HTTPS.
     *
     * Server-side TLS (`HTTPS=on`, port 443, `REQUEST_SCHEME=https`) is
     * always trusted. `X-Forwarded-Proto: https` is only honoured when the
     * immediate `REMOTE_ADDR` is in a trusted CIDR — otherwise an off-net
     * attacker could lie their way into Secure-cookie + HSTS behaviour
     * during a downgrade attack.
     *
     * @param array<string,mixed> $server $_SERVER-shaped map.
     */
    public function isHttps(array $server): bool
    {
        $https = strtolower(self::scalar($server, 'HTTPS'));
        if ($https !== '' && $https !== 'off') {
            return true;
        }
        $scheme = strtolower(self::scalar($server, 'REQUEST_SCHEME'));
        if ($scheme === 'https') {
            return true;
        }
        $port = self::scalar($server, 'SERVER_PORT');
        if ($port === '443') {
            return true;
        }

        $remote = self::scalar($server, 'REMOTE_ADDR');
        if ($remote === '' || !$this->isTrusted($remote)) {
            return false;
        }
        $proto = strtolower(trim(self::scalar($server, 'HTTP_X_FORWARDED_PROTO')));
        if ($proto === '') {
            return false;
        }
        // Some proxies (HAProxy, AWS ALB) emit a comma-separated list when
        // there is more than one hop. Take the leftmost entry — the one set
        // by the outermost proxy that actually saw the client.
        $first = strtolower(trim(strtok($proto, ',') ?: ''));
        return $first === 'https';
    }

    /**
     * @return array{0:string,1:int}|null parsed CIDR or null when invalid.
     */
    private static function parseCidr(string $raw): ?array
    {
        if ($raw === '') {
            return null;
        }
        $slash = strpos($raw, '/');
        $addr  = $slash === false ? $raw : substr($raw, 0, $slash);
        $bin   = @inet_pton($addr);
        if ($bin === false) {
            return null;
        }
        $maxBits = strlen($bin) * 8; // 32 for v4, 128 for v6
        if ($slash === false) {
            return [$bin, $maxBits];
        }
        $bitsRaw = substr($raw, $slash + 1);
        if ($bitsRaw === '' || !ctype_digit($bitsRaw)) {
            return null;
        }
        $bits = (int) $bitsRaw;
        if ($bits < 0 || $bits > $maxBits) {
            return null;
        }
        return [$bin, $bits];
    }

    /** Compare the leftmost `$bits` bits of two `inet_pton` byte strings. */
    private static function matchPrefix(string $a, string $b, int $bits): bool
    {
        $fullBytes = intdiv($bits, 8);
        if ($fullBytes > 0 && substr($a, 0, $fullBytes) !== substr($b, 0, $fullBytes)) {
            return false;
        }
        $remaining = $bits % 8;
        if ($remaining === 0) {
            return true;
        }
        $mask = (~((1 << (8 - $remaining)) - 1)) & 0xFF;
        return (ord($a[$fullBytes]) & $mask) === (ord($b[$fullBytes]) & $mask);
    }

    private static function stripPort(string $hop): string
    {
        if ($hop === '' || $hop[0] === '[') {
            // bracketed IPv6: "[::1]:443" → "::1"
            $end = strpos($hop, ']');
            return $end === false ? $hop : substr($hop, 1, $end - 1);
        }
        // IPv4 with port: "1.2.3.4:5678" → "1.2.3.4". Bare IPv6 has multiple
        // colons and no brackets; leave it alone.
        if (substr_count($hop, ':') === 1) {
            return substr($hop, 0, (int) strpos($hop, ':'));
        }
        return $hop;
    }

    /** @param array<string,mixed> $server */
    private static function scalar(array $server, string $key): string
    {
        $v = $server[$key] ?? '';
        return is_scalar($v) ? (string) $v : '';
    }
}
