<?php

declare(strict_types=1);

namespace App\Support;

/**
 * SSRF-гард для исходящих webhook-URL.
 *
 * Webhook target_url задаёт авторизованный админ тенанта. Без проверки он может
 * указать внутренний адрес (`https://169.254.169.254/` cloud-metadata,
 * `https://127.0.0.1/`, `https://10.0.0.0/8`) и через кнопку «тест» получить
 * ответ внутренней службы (SSRF + info-leak). starts_with:https:// этого не ловит.
 *
 * Политика: блокируем, только если хост РАЗРЕШАЕТСЯ в приватный/зарезервированный
 * IP. Неразрешимый хост (NXDOMAIN) — не SSRF-вектор, пропускаем (реальный запрос
 * упадёт сам). Проверяются все A/AAAA-записи (защита от hostname→private).
 */
final class WebhookUrlGuard
{
    /**
     * @return string|null Причина блокировки (человекочитаемая) или null, если адрес безопасен.
     */
    public static function blockReason(string $url): ?string
    {
        $host = parse_url($url, PHP_URL_HOST);
        if (! is_string($host) || $host === '') {
            return 'Некорректный URL webhook.';
        }
        $host = trim($host, '[]'); // снять скобки IPv6-литерала

        foreach (self::resolve($host) as $ip) {
            if (! self::isPublicIp($ip)) {
                return 'URL webhook ведёт во внутреннюю/зарезервированную сеть — запрещено.';
            }
        }

        return null;
    }

    /**
     * Один резолв хоста для доставки: причина блокировки И безопасный IP для
     * пиннинга соединения. Закрывает DNS-rebind TOCTOU — блок-решение и адрес
     * подключения берутся из ОДНОГО резолва, а не из двух независимых (как было
     * бы при blockReason + повторный резолв в HTTP-клиенте).
     *
     * @return array{ip: string|null, blockReason: string|null}
     */
    public static function safeDeliveryIp(string $url): array
    {
        $host = parse_url($url, PHP_URL_HOST);
        if (! is_string($host) || $host === '') {
            return ['ip' => null, 'blockReason' => 'Некорректный URL webhook.'];
        }
        $host = trim($host, '[]');

        $ips = self::resolve($host);
        foreach ($ips as $ip) {
            if (! self::isPublicIp($ip)) {
                return ['ip' => null, 'blockReason' => 'URL webhook ведёт во внутреннюю/зарезервированную сеть — запрещено.'];
            }
        }

        // Все записи публичны (или хост не резолвится → ip=null, пиннинг не
        // применяется, запрос упадёт сам — как и в blockReason).
        return ['ip' => $ips[0] ?? null, 'blockReason' => null];
    }

    /** @return list<string> Все IP, в которые разрешается хост (пусто, если не разрешается). */
    private static function resolve(string $host): array
    {
        if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
            return [$host]; // IP-литерал — без DNS
        }

        $ips = [];
        $v4 = gethostbynamel($host);
        if (is_array($v4)) {
            $ips = array_merge($ips, $v4);
        }
        $aaaa = @dns_get_record($host, DNS_AAAA);
        if (is_array($aaaa)) {
            foreach ($aaaa as $rec) {
                if (isset($rec['ipv6']) && is_string($rec['ipv6'])) {
                    $ips[] = $rec['ipv6'];
                }
            }
        }

        return array_values(array_unique($ips));
    }

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

        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
            $lower = strtolower($ip);
            // loopback / unspecified
            if ($lower === '::1' || $lower === '::') {
                return false;
            }
            // link-local fe80::/10
            if (preg_match('/^fe[89ab]/', $lower) === 1) {
                return false;
            }
            // unique-local fc00::/7
            if ($lower[0] === 'f' && in_array($lower[1], ['c', 'd'], true)) {
                return false;
            }
            // IPv4-mapped ::ffff:a.b.c.d — проверить встроенный IPv4
            if (str_contains($lower, '::ffff:')) {
                $v4 = substr($lower, (int) strrpos($lower, ':') + 1);
                if (filter_var($v4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
                    return self::isPublicIp($v4);
                }
            }

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

        return false;
    }
}
