<?php
declare(strict_types=1);

namespace Celena\Module\Webhooks;

use Celena\Core\Database\Connection;
use Celena\Core\Plugin\Hook;
use Celena\Core\Queue\QueueManager;

/**
 * Подписывается на все Hook::action и отправляет POST-запросы на URL, привязанные к событиям.
 * На production рекомендуется отправлять через очередь (job SendWebhookJob), чтобы не блокировать запрос.
 */
final class WebhookDispatcher
{
    public function __construct(
        private readonly Connection $conn,
        private readonly QueueManager $queue,
    ) {
    }

    /** Отправить webhook немедленно (синхронно). Используется из job'а. */
    public static function send(string $url, string $secret, array $payload): int
    {
        // Защита от SSRF: только http(s) и публичные IP.
        if (!self::isUrlAllowed($url)) {
            return 0;
        }
        $body = json_encode($payload, JSON_UNESCAPED_UNICODE);
        $signature = $secret !== '' ? hash_hmac('sha256', (string) $body, $secret) : '';
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $body,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'User-Agent: Celena-CMS-Webhook',
                'X-Celena-Signature: ' . $signature,
                'X-Celena-Event: ' . (string) ($payload['_event'] ?? ''),
            ],
            CURLOPT_TIMEOUT => 15,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
            CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
        ]);
        curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);
        return $status;
    }

    /**
     * Разрешён ли URL для исходящего webhook'а.
     * Why: SSRF — без проверки админ (или, при ошибке доступа, любой пользователь)
     *      может направить запрос на 127.0.0.1, 169.254.169.254 (метаданные облака),
     *      внутренний Redis/etcd, file:// и т.п.
     */
    public static function isUrlAllowed(string $url): bool
    {
        $parts = parse_url($url);
        if ($parts === false || empty($parts['scheme']) || empty($parts['host'])) {
            return false;
        }
        $scheme = strtolower((string) $parts['scheme']);
        if (!in_array($scheme, ['http', 'https'], true)) {
            return false;
        }
        $host = (string) $parts['host'];
        // IDN/normalize и резолв
        $ips = [];
        if (filter_var($host, FILTER_VALIDATE_IP)) {
            $ips = [$host];
        } else {
            $records = @dns_get_record($host, DNS_A | DNS_AAAA) ?: [];
            foreach ($records as $r) {
                if (!empty($r['ip']))   $ips[] = (string) $r['ip'];
                if (!empty($r['ipv6'])) $ips[] = (string) $r['ipv6'];
            }
            if ($ips === []) {
                // последний шанс — gethostbyname (только IPv4); если не разрешился — отказываем
                $r = gethostbyname($host);
                if ($r !== $host) $ips[] = $r;
            }
        }
        if ($ips === []) {
            return false;
        }
        foreach ($ips as $ip) {
            if (!self::isPublicIp($ip)) {
                return false;
            }
        }
        return true;
    }

    private static function isPublicIp(string $ip): bool
    {
        // FILTER_FLAG_NO_PRIV_RANGE отсекает 10/8, 172.16/12, 192.168/16, fc00::/7;
        // FILTER_FLAG_NO_RES_RANGE — 0.0.0.0/8, 127/8, 169.254/16, 224/4, ::1, и т.д.
        return (bool) filter_var(
            $ip,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
        );
    }

    /** Зарегистрировать подписки на все хуки из БД — вызывается из bootstrap. */
    public function registerHooks(): void
    {
        $webhooks = $this->conn->builder('celena_webhooks')
            ->where('is_active', '=', true)
            ->get();
        if ($webhooks === []) {
            return;
        }
        $byEvent = [];
        foreach ($webhooks as $w) {
            $byEvent[(string) $w['event']][] = $w;
        }
        foreach ($byEvent as $event => $hooks) {
            Hook::on($event, function (...$args) use ($event, $hooks) {
                foreach ($hooks as $h) {
                    $this->queue->dispatch(
                        SendWebhookJob::class,
                        [
                            'url' => (string) $h['url'],
                            'secret' => (string) ($h['secret'] ?? ''),
                            'payload' => [
                                '_event' => $event,
                                '_sent_at' => date('c'),
                                'args' => $args,
                            ],
                            'webhook_id' => (int) $h['id'],
                        ],
                    );
                }
            });
        }
    }
}
