<?php
/**
 * Forms — public API.
 *
 * Static methods so other plugins can reach in without instantiating
 * the Forms bootstrap class:
 *
 *   FormsAPI::getForm($slug)
 *   FormsAPI::parseFieldDsl($text)
 *   FormsAPI::renderField($field, $value, $error)
 *   FormsAPI::validateSubmission($form, $input)
 *   FormsAPI::generateRef()
 *   FormsAPI::slugify($title, $excludeId = null)
 *   FormsAPI::dispatchWebhooks($formId, $submissionId, $payload)
 *
 * Field DSL (one field per line, pipe-separated):
 *   type|name|label|required|placeholder|options
 *
 * Examples:
 *   text|full_name|Your name|required
 *   email|email|Email|required|you@example.com
 *   tel|phone|Phone
 *   textarea|message|Tell us more|required
 *   select|reason|Reason for contact||Choose…|sales,support,other
 *   radio|priority|Priority|required||low,normal,high
 *   checkbox|consent|I agree to be contacted|required
 *   number|budget|Budget (USD)|required||1000
 *   date|when|Preferred date
 *
 * Lines starting with `#` are comments. Blank lines are ignored.
 *
 * Supported types (this first cut): text, email, tel, url, number,
 * date, textarea, select, radio, checkbox.
 * Deferred to follow-up sessions: file, signature, multi-step,
 * conditional logic, payment.
 */

class FormsAPI {

    const SUPPORTED_TYPES = [
        'text', 'email', 'tel', 'url', 'number', 'date',
        'textarea', 'select', 'radio', 'checkbox', 'file',
    ];

    /** Webhook signature: HMAC-SHA256 of the raw JSON body, keyed by per-form secret. */
    const WEBHOOK_SIGNATURE_HEADER = 'X-Slate-Signature';

    /** True once per request after we've confirmed the schema exists. */
    private static bool $schemaChecked = false;

    /**
     * Lazily create the forms_* tables if they don't already exist.
     * Defensive: if install.sql didn't run during plugin activation
     * (some hosts swallow CREATE TABLE failures), this ensures every
     * admin page and the public router can still operate.
     */
    public static function ensureSchema(): void {
        if (self::$schemaChecked) return;
        self::$schemaChecked = true;
        try {
            $pdo = Database::get();
            $pdo->exec(
                "CREATE TABLE IF NOT EXISTS `forms_definitions` (
                    `id`               INT UNSIGNED NOT NULL AUTO_INCREMENT,
                    `tenant_id`        INT UNSIGNED NOT NULL DEFAULT 1,
                    `slug`             VARCHAR(80) NOT NULL,
                    `title`            VARCHAR(200) NOT NULL,
                    `description`      TEXT NULL,
                    `fields_json`      LONGTEXT NOT NULL,
                    `submit_label`     VARCHAR(80) NOT NULL DEFAULT 'Submit',
                    `success_message`  TEXT NULL,
                    `redirect_url`     VARCHAR(500) NULL,
                    `notify_email`     VARCHAR(200) NULL,
                    `confirm_submitter` TINYINT(1) NOT NULL DEFAULT 0,
                    `confirm_subject`  VARCHAR(200) NULL,
                    `confirm_body`     TEXT NULL,
                    `status`           ENUM('draft','published','archived') NOT NULL DEFAULT 'draft',
                    `submission_limit` INT UNSIGNED NULL,
                    `created_at`       DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    `updated_at`       DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                    PRIMARY KEY (`id`),
                    UNIQUE KEY `tenant_slug` (`tenant_id`, `slug`),
                    KEY `tenant_status` (`tenant_id`, `status`)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
            );
            $pdo->exec(
                "CREATE TABLE IF NOT EXISTS `forms_submissions` (
                    `id`              INT UNSIGNED NOT NULL AUTO_INCREMENT,
                    `tenant_id`       INT UNSIGNED NOT NULL DEFAULT 1,
                    `form_id`         INT UNSIGNED NOT NULL,
                    `ref`             VARCHAR(32) NOT NULL,
                    `data_json`       LONGTEXT NOT NULL,
                    `submitter_email` VARCHAR(200) NULL,
                    `ip`              VARCHAR(60) NULL,
                    `user_agent`      VARCHAR(255) NULL,
                    `read_at`         DATETIME NULL,
                    `email_sent`      TINYINT(1) NOT NULL DEFAULT 0,
                    `email_error`     TEXT NULL,
                    `created_at`      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    PRIMARY KEY (`id`),
                    UNIQUE KEY `tenant_ref` (`tenant_id`, `ref`),
                    KEY `tenant_form` (`tenant_id`, `form_id`),
                    KEY `tenant_read` (`tenant_id`, `read_at`),
                    KEY `tenant_created` (`tenant_id`, `created_at`)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
            );
            $pdo->exec(
                "CREATE TABLE IF NOT EXISTS `forms_webhooks` (
                    `id`         INT UNSIGNED NOT NULL AUTO_INCREMENT,
                    `tenant_id`  INT UNSIGNED NOT NULL DEFAULT 1,
                    `form_id`    INT UNSIGNED NOT NULL,
                    `url`        VARCHAR(500) NOT NULL,
                    `is_active`  TINYINT(1) NOT NULL DEFAULT 1,
                    `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    PRIMARY KEY (`id`),
                    KEY `tenant_form` (`tenant_id`, `form_id`)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
            );
            $pdo->exec(
                "CREATE TABLE IF NOT EXISTS `forms_webhook_log` (
                    `id`            INT UNSIGNED NOT NULL AUTO_INCREMENT,
                    `tenant_id`     INT UNSIGNED NOT NULL DEFAULT 1,
                    `webhook_id`    INT UNSIGNED NOT NULL,
                    `submission_id` INT UNSIGNED NULL,
                    `status_code`   INT NULL,
                    `response_body` TEXT NULL,
                    `error`         TEXT NULL,
                    `created_at`    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    PRIMARY KEY (`id`),
                    KEY `tenant_webhook` (`tenant_id`, `webhook_id`),
                    KEY `tenant_created` (`tenant_id`, `created_at`)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
            );

            // CREATE TABLE IF NOT EXISTS is a no-op on a table that predates
            // a newly-added column, so reconcile columns that older installs
            // may be missing. This is what surfaced as "Undefined array key
            // 'status'" on the public router and admin list.
            self::reconcileColumns($pdo, 'forms_definitions', [
                // fields_json was missing on older installs, fataling every
                // save (edit.php) and the admin form list (submission.php).
                // Added as NULL so the ALTER succeeds on tables with rows.
                'fields_json'       => "LONGTEXT NULL",
                'description'       => "TEXT NULL",
                'submit_label'      => "VARCHAR(80) NOT NULL DEFAULT 'Submit'",
                'success_message'   => "TEXT NULL",
                'redirect_url'      => "VARCHAR(500) NULL",
                'notify_email'      => "VARCHAR(200) NULL",
                'confirm_submitter' => "TINYINT(1) NOT NULL DEFAULT 0",
                'confirm_subject'   => "VARCHAR(200) NULL",
                'confirm_body'      => "TEXT NULL",
                'status'            => "ENUM('draft','published','archived') NOT NULL DEFAULT 'draft'",
                'submission_limit'  => "INT UNSIGNED NULL",
            ]);
            // Same problem on the submissions table: tables created by an
            // older Forms version are missing data_json + the metadata
            // columns, which fatals the public submit INSERT. data_json is
            // reconciled as NULL (the CREATE defines it NOT NULL) so the
            // ALTER succeeds on tables that already hold legacy rows.
            self::reconcileColumns($pdo, 'forms_submissions', [
                'data_json'       => "LONGTEXT NULL",
                'submitter_email' => "VARCHAR(200) NULL",
                'ip'              => "VARCHAR(60) NULL",
                'user_agent'      => "VARCHAR(255) NULL",
                'read_at'         => "DATETIME NULL",
                'email_sent'      => "TINYINT(1) NOT NULL DEFAULT 0",
                'email_error'     => "TEXT NULL",
                'created_at'      => "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
            ]);

            // The earliest Forms schema stored the payload in a `data` column
            // before it was renamed to `data_json`. If that legacy column is
            // present, backfill it into `data_json` so old submissions still
            // render in the admin. Idempotent + cheap: the WHERE is a no-op
            // once every legacy row has been copied.
            $hasLegacy = $pdo->prepare(
                "SELECT COUNT(*) FROM information_schema.COLUMNS
                  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'forms_submissions'
                    AND COLUMN_NAME = 'data'"
            );
            $hasLegacy->execute();
            if ((int) $hasLegacy->fetchColumn() === 1) {
                $pdo->exec(
                    "UPDATE `forms_submissions`
                        SET `data_json` = `data`
                      WHERE `data_json` IS NULL AND `data` IS NOT NULL"
                );
            }
        } catch (\Throwable $e) {
            if (function_exists('slate_log')) {
                slate_log('FormsAPI::ensureSchema failed: ' . $e->getMessage(), 'error');
            }
        }
    }

    /**
     * Add any columns absent from an existing table. CREATE TABLE IF NOT
     * EXISTS won't alter a table that already exists, so columns introduced
     * after first install would never appear. Idempotent. Column names and
     * definitions are hardcoded literals (never user input), so the inline
     * identifiers are safe; the lookup is a prepared statement.
     */
    private static function reconcileColumns(\PDO $pdo, string $table, array $columns): void {
        $stmt = $pdo->prepare(
            "SELECT COLUMN_NAME FROM information_schema.COLUMNS
              WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?"
        );
        $stmt->execute([$table]);
        $existing = [];
        foreach ($stmt->fetchAll(\PDO::FETCH_COLUMN) as $col) {
            $existing[strtolower((string)$col)] = true;
        }
        if (!$existing) return; // table absent — the CREATE above handles it
        foreach ($columns as $name => $definition) {
            if (isset($existing[strtolower($name)])) continue;
            $pdo->exec("ALTER TABLE `{$table}` ADD COLUMN `{$name}` {$definition}");
        }
    }

    /** Load a form by slug (tenant-scoped). Decodes fields_json. */
    public static function getForm(string $slug): ?array {
        $row = Database::row(
            "SELECT * FROM forms_definitions WHERE tenant_id = ? AND slug = ?",
            [current_tenant_id(), $slug]
        );
        if (!$row) return null;
        $row['fields'] = self::decodeFields($row['fields_json'] ?? '');
        return $row;
    }

    public static function getFormById(int $id): ?array {
        $row = Database::row(
            "SELECT * FROM forms_definitions WHERE id = ? AND tenant_id = ?",
            [$id, current_tenant_id()]
        );
        if (!$row) return null;
        $row['fields'] = self::decodeFields($row['fields_json'] ?? '');
        return $row;
    }

    /**
     * Parse the field DSL into a normalised array of field defs.
     * Returns ['fields' => [...], 'errors' => [...]] so callers can
     * show line-level parse errors in the editor.
     */
    public static function parseFieldDsl(string $text): array {
        $out    = [];
        $errors = [];
        $usedNames = [];
        $lineNum = 0;
        foreach (preg_split('/\r\n|\n|\r/', $text) as $raw) {
            $lineNum++;
            $line = trim($raw);
            if ($line === '' || str_starts_with($line, '#')) continue;

            $parts = array_map('trim', explode('|', $line));
            $type        = strtolower($parts[0] ?? '');
            $name        = $parts[1] ?? '';
            $label       = $parts[2] ?? '';
            $required    = strtolower($parts[3] ?? '') === 'required';
            $placeholder = $parts[4] ?? '';
            $optionsRaw  = $parts[5] ?? '';

            if (!in_array($type, self::SUPPORTED_TYPES, true)) {
                $errors[] = "Line {$lineNum}: unsupported field type '" . $type . "'.";
                continue;
            }
            if (!preg_match('/^[a-z][a-z0-9_]{0,49}$/i', $name)) {
                $errors[] = "Line {$lineNum}: name must start with a letter and contain only letters/digits/underscores.";
                continue;
            }
            $name = strtolower($name);
            if (isset($usedNames[$name])) {
                $errors[] = "Line {$lineNum}: duplicate field name '{$name}'.";
                continue;
            }
            $usedNames[$name] = true;

            $field = [
                'type'     => $type,
                'name'     => $name,
                'label'    => $label !== '' ? $label : ucwords(str_replace('_', ' ', $name)),
                'required' => $required,
            ];
            if ($placeholder !== '') $field['placeholder'] = $placeholder;

            if (in_array($type, ['select', 'radio'], true)) {
                $opts = $optionsRaw !== ''
                      ? array_values(array_filter(array_map('trim', explode(',', $optionsRaw)), fn($s) => $s !== ''))
                      : [];
                if (empty($opts)) {
                    $errors[] = "Line {$lineNum}: {$type} field '{$name}' needs an options list (comma-separated).";
                    continue;
                }
                $field['options'] = $opts;
            }

            $out[] = $field;
        }
        return ['fields' => $out, 'errors' => $errors];
    }

    /**
     * Serialise the parsed field list back to DSL text — used by the
     * edit page to round-trip safely after a save.
     */
    public static function fieldsToDsl(array $fields): string {
        $lines = [];
        foreach ($fields as $f) {
            $parts = [
                $f['type'] ?? 'text',
                $f['name'] ?? '',
                $f['label'] ?? '',
                !empty($f['required']) ? 'required' : '',
                $f['placeholder'] ?? '',
                isset($f['options']) ? implode(',', (array)$f['options']) : '',
            ];
            // Trim trailing empties for readability.
            while (count($parts) > 3 && end($parts) === '') array_pop($parts);
            $lines[] = implode('|', $parts);
        }
        return implode("\n", $lines);
    }

    private static function decodeFields(string $json): array {
        $arr = json_decode($json, true);
        return is_array($arr) ? $arr : [];
    }

    /**
     * Validate a $_POST-style input array against a form's fields.
     * Returns ['ok'=>bool, 'errors'=>['name'=>'message'], 'data'=>[...]].
     */
    public static function validateSubmission(array $form, array $input): array {
        $errors = [];
        $data   = [];

        foreach (($form['fields'] ?? []) as $f) {
            $name = $f['name'] ?? '';
            if ($name === '') continue;
            $type     = $f['type'] ?? 'text';
            $required = !empty($f['required']);

            $raw = $input[$name] ?? null;

            // Checkboxes: a single checkbox = presence boolean; missing key = unchecked.
            if ($type === 'checkbox') {
                $checked = !empty($raw);
                if ($required && !$checked) {
                    $errors[$name] = ($f['label'] ?? $name) . ' is required.';
                }
                $data[$name] = $checked;
                continue;
            }

            $value = is_string($raw) ? trim($raw) : '';
            if ($value === '' && $raw !== null && !is_string($raw)) {
                // Defensive: arrays etc. aren't accepted for non-checkbox.
                $value = '';
            }

            if ($value === '') {
                if ($required) {
                    $errors[$name] = ($f['label'] ?? $name) . ' is required.';
                }
                $data[$name] = '';
                continue;
            }

            switch ($type) {
                case 'email':
                    if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
                        $errors[$name] = 'Please enter a valid email address.';
                    }
                    break;
                case 'url':
                    if (!filter_var($value, FILTER_VALIDATE_URL)) {
                        $errors[$name] = 'Please enter a valid URL.';
                    }
                    break;
                case 'number':
                    if (!is_numeric($value)) {
                        $errors[$name] = 'Please enter a number.';
                    }
                    break;
                case 'date':
                    // Accept YYYY-MM-DD; let the browser do the heavy work
                    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
                        $errors[$name] = 'Please enter a date.';
                    }
                    break;
                case 'select':
                case 'radio':
                    $opts = (array)($f['options'] ?? []);
                    if (!in_array($value, $opts, true)) {
                        $errors[$name] = 'Please choose one of the listed options.';
                    }
                    break;
                case 'tel':
                    if (!preg_match('/^[0-9+()\-.\s]{4,40}$/', $value)) {
                        $errors[$name] = 'Please enter a valid phone number.';
                    }
                    break;
                case 'textarea':
                    if (mb_strlen($value) > 8000) {
                        $errors[$name] = 'Please keep this under 8000 characters.';
                    }
                    break;
                case 'file':
                    // File validation happens out-of-band — see
                    // FormsAPI::handleFileUploads() called from the
                    // public router. The DSL only records "file is
                    // required"; the actual file lives in $_FILES.
                    $value = ''; // placeholder; real path set later
                    break;
                case 'text':
                default:
                    if (mb_strlen($value) > 500) {
                        $errors[$name] = 'Please keep this under 500 characters.';
                    }
                    break;
            }

            $data[$name] = $value;
        }

        return [
            'ok'     => empty($errors),
            'errors' => $errors,
            'data'   => $data,
        ];
    }

    /**
     * Render one field as HTML for the public form. Uses the shared
     * design-system classes (.field / .field-label / etc.) so the
     * markup is identical between admin previews and the live form.
     */
    public static function renderField(array $field, $value = null, ?string $error = null): string {
        $name        = $field['name'] ?? '';
        $type        = $field['type'] ?? 'text';
        $label       = $field['label'] ?? $name;
        $required    = !empty($field['required']);
        $placeholder = $field['placeholder'] ?? '';

        if ($name === '') return '';

        $id   = 'f_' . preg_replace('/[^a-z0-9_]/i', '', $name);
        $req  = $required ? ' required' : '';
        $star = $required ? ' <span class="field-required">*</span>' : '';

        $valueAttr = is_string($value) ? e($value) : '';
        $checked   = (bool)$value;

        ob_start();
        echo '<div class="field">';

        if ($type === 'checkbox') {
            echo '<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;">';
            echo '<input type="checkbox" id="' . e($id) . '" name="' . e($name) . '" value="1"'
                . ($checked ? ' checked' : '') . $req . ' style="margin-top:3px;">';
            echo '<span class="field-label" style="margin:0;">' . e($label) . $star . '</span>';
            echo '</label>';
        } else {
            echo '<label class="field-label" for="' . e($id) . '">' . e($label) . $star . '</label>';

            $phAttr = $placeholder !== '' ? ' placeholder="' . e($placeholder) . '"' : '';

            switch ($type) {
                case 'textarea':
                    echo '<textarea id="' . e($id) . '" name="' . e($name) . '"' . $req . $phAttr . '>'
                       . $valueAttr . '</textarea>';
                    break;

                case 'select':
                    echo '<select id="' . e($id) . '" name="' . e($name) . '"' . $req . '>';
                    if ($placeholder !== '') {
                        echo '<option value="">' . e($placeholder) . '</option>';
                    } elseif (!$required) {
                        echo '<option value="">—</option>';
                    }
                    foreach ((array)($field['options'] ?? []) as $opt) {
                        $sel = (is_string($value) && $value === $opt) ? ' selected' : '';
                        echo '<option value="' . e($opt) . '"' . $sel . '>' . e($opt) . '</option>';
                    }
                    echo '</select>';
                    break;

                case 'file':
                    echo '<input type="file" id="' . e($id) . '" name="' . e($name) . '"'
                       . $req . ' accept="image/*,application/pdf,.doc,.docx,.txt">';
                    echo '<div class="field-hint">Max 10 MB. Images, PDF, or document files.</div>';
                    break;

                case 'radio':
                    echo '<div class="form-radio-group" style="display:flex;flex-direction:column;gap:6px;">';
                    foreach ((array)($field['options'] ?? []) as $i => $opt) {
                        $optId = $id . '_' . $i;
                        $sel = (is_string($value) && $value === $opt) ? ' checked' : '';
                        echo '<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:400;">';
                        echo '<input type="radio" id="' . e($optId) . '" name="' . e($name) . '" value="' . e($opt) . '"'
                           . $sel . ($required ? ' required' : '') . '>';
                        echo '<span>' . e($opt) . '</span>';
                        echo '</label>';
                    }
                    echo '</div>';
                    break;

                default:
                    $inputType = $type;
                    if (!in_array($type, ['text','email','tel','url','number','date'], true)) {
                        $inputType = 'text';
                    }
                    echo '<input type="' . e($inputType) . '" id="' . e($id) . '" name="' . e($name) . '"'
                       . ($valueAttr !== '' ? ' value="' . $valueAttr . '"' : '')
                       . $req . $phAttr . '>';
                    break;
            }
        }

        if ($error !== null && $error !== '') {
            echo '<div class="field-error">' . e($error) . '</div>';
        }

        echo '</div>';
        return (string) ob_get_clean();
    }

    /** Pretty submission reference: SUB-XXXXXXXX (8 hex chars). */
    public static function generateRef(): string {
        return 'SUB-' . strtoupper(bin2hex(random_bytes(4)));
    }

    /**
     * Return a slug unique within the tenant's forms_definitions
     * table. $excludeId lets the edit page keep its own slug.
     */
    public static function slugify(string $title, ?int $excludeId = null): string {
        $base = strtolower(trim(preg_replace('/[^a-z0-9]+/i', '-', $title), '-'));
        if ($base === '') $base = 'form';
        if (mb_strlen($base) > 60) $base = substr($base, 0, 60);

        $tid = current_tenant_id();
        $slug = $base;
        $i = 2;
        while (true) {
            $clash = Database::row(
                "SELECT id FROM forms_definitions
                  WHERE tenant_id = ? AND slug = ?" . ($excludeId ? " AND id <> ?" : ""),
                $excludeId ? [$tid, $slug, $excludeId] : [$tid, $slug]
            );
            if (!$clash) return $slug;
            $slug = $base . '-' . $i++;
            if ($i > 999) return $base . '-' . bin2hex(random_bytes(3));
        }
    }

    /**
     * Process file fields after a successful validateSubmission.
     * Writes each file via Uploads::handle into uploads/forms/.
     * Updates $data in place (returns merged data + errors).
     */
    public static function handleFileUploads(array $form, array $data): array {
        $errors = [];
        foreach (($form['fields'] ?? []) as $f) {
            if (($f['type'] ?? '') !== 'file') continue;
            $name     = $f['name']  ?? '';
            $required = !empty($f['required']);
            if ($name === '') continue;

            $fileMeta = $_FILES[$name] ?? null;
            if (!$fileMeta || (int)($fileMeta['error'] ?? 4) === UPLOAD_ERR_NO_FILE) {
                if ($required) {
                    $errors[$name] = ($f['label'] ?? $name) . ' is required.';
                }
                continue;
            }

            $result = Uploads::handle($name, 'forms', [
                'max_bytes'     => 10 * 1024 * 1024,
                'allowed_exts'  => ['jpg','jpeg','png','gif','webp','pdf','doc','docx','txt'],
            ]);

            if (!empty($result['ok'])) {
                $data[$name] = [
                    'path'     => $result['path'],
                    'original' => $result['original'] ?? '',
                    'size'     => $result['size']     ?? 0,
                    'mime'     => $result['mime']     ?? '',
                ];
            } else {
                $errors[$name] = $result['error'] ?? 'Upload failed.';
            }
        }
        return ['data' => $data, 'errors' => $errors];
    }

    /**
     * POST the submission payload to every active webhook for the
     * form. Best-effort: failures are logged to forms_webhook_log
     * but don't fail the submit.
     *
     * Adds an HMAC-SHA256 signature header so receivers can verify
     * the payload. The shared secret is APP_SECRET (set in .env).
     */
    public static function dispatchWebhooks(int $formId, int $submissionId, array $payload): void {
        $tid = current_tenant_id();
        $hooks = Database::rows(
            "SELECT * FROM forms_webhooks
              WHERE tenant_id = ? AND form_id = ? AND is_active = 1",
            [$tid, $formId]
        );
        if (!$hooks) return;

        $body      = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        $secret    = defined('APP_SECRET') ? (string) APP_SECRET : '';
        $signature = $secret !== '' ? hash_hmac('sha256', $body, $secret) : '';

        foreach ($hooks as $hook) {
            $statusCode = null;
            $response   = null;
            $error      = null;

            // SSRF guard: only POST to a public http(s) endpoint. Refuse
            // private/loopback/link-local/reserved targets and non-http
            // schemes, and pin curl to the vetted IP to defeat DNS
            // rebinding between this check and curl's own resolution.
            $vet = self::vetWebhookUrl((string)$hook['url']);
            if ($vet === null) {
                $error = 'Refused: webhook URL is not a public http(s) endpoint.';
            } else {
                try {
                    $ch = curl_init($hook['url']);
                    curl_setopt_array($ch, [
                        CURLOPT_RETURNTRANSFER => true,
                        CURLOPT_POST           => true,
                        CURLOPT_POSTFIELDS     => $body,
                        CURLOPT_HTTPHEADER     => array_filter([
                            'Content-Type: application/json',
                            'User-Agent: Slate-Forms/0.1',
                            'X-Slate-Form: ' . $formId,
                            'X-Slate-Submission: ' . $submissionId,
                            $signature !== '' ? self::WEBHOOK_SIGNATURE_HEADER . ': sha256=' . $signature : null,
                        ]),
                        CURLOPT_TIMEOUT        => 10,
                        CURLOPT_CONNECTTIMEOUT => 5,
                        CURLOPT_FOLLOWLOCATION => false,
                        CURLOPT_PROTOCOLS      => CURLPROTO_HTTP | CURLPROTO_HTTPS,
                        CURLOPT_REDIR_PROTOCOLS=> CURLPROTO_HTTP | CURLPROTO_HTTPS,
                        CURLOPT_RESOLVE        => [$vet['host'] . ':' . $vet['port'] . ':' . $vet['ip']],
                        CURLOPT_SSL_VERIFYPEER => true,
                        CURLOPT_SSL_VERIFYHOST => 2,
                    ]);
                    $response = curl_exec($ch);
                    if ($response === false) {
                        $error = curl_error($ch);
                    } else {
                        $statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
                        // Cap body size we log to keep the log table sane.
                        if (is_string($response) && strlen($response) > 4000) {
                            $response = substr($response, 0, 4000) . '...[truncated]';
                        }
                    }
                    curl_close($ch);
                } catch (\Throwable $e) {
                    $error = $e->getMessage();
                }
            }

            try {
                Database::insert('forms_webhook_log', [
                    'tenant_id'     => $tid,
                    'webhook_id'    => (int)$hook['id'],
                    'submission_id' => $submissionId,
                    'status_code'   => $statusCode,
                    'response_body' => $response,
                    'error'         => $error,
                ]);
            } catch (\Throwable $e) {
                if (function_exists('slate_log')) {
                    slate_log('Forms: webhook log insert failed: ' . $e->getMessage(), 'error');
                }
            }
        }
    }

    /**
     * SSRF guard for an outbound webhook URL. Returns
     * ['host'=>, 'port'=>, 'ip'=>] for a safe, public http(s) target, or
     * null if it must be refused: non-http(s) scheme, no host, unresolvable,
     * or resolves to any private / loopback / link-local / reserved IP.
     */
    private static function vetWebhookUrl(string $url): ?array {
        $parts = parse_url($url);
        if (!is_array($parts)) return null;

        $scheme = strtolower((string)($parts['scheme'] ?? ''));
        if ($scheme !== 'http' && $scheme !== 'https') return null;

        $host = (string)($parts['host'] ?? '');
        if ($host === '') return null;
        $port = (int)($parts['port'] ?? ($scheme === 'https' ? 443 : 80));

        // Collect every IP the host resolves to (or the literal IP itself).
        $ips = [];
        if (filter_var($host, FILTER_VALIDATE_IP)) {
            $ips = [$host];
        } else {
            $v4 = @gethostbynamel($host);
            if (is_array($v4)) $ips = $v4;
            $aaaa = @dns_get_record($host, DNS_AAAA);
            if (is_array($aaaa)) {
                foreach ($aaaa as $rec) {
                    if (!empty($rec['ipv6'])) $ips[] = $rec['ipv6'];
                }
            }
        }
        if (!$ips) return null;

        // Every resolved address must be a public, non-reserved IP.
        foreach ($ips as $ip) {
            if (!filter_var($ip, FILTER_VALIDATE_IP,
                    FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
                return null;
            }
        }

        // Pin to the first vetted address so curl talks to exactly what we
        // validated (defeats DNS rebinding between this check and the call).
        return ['host' => $host, 'port' => $port, 'ip' => $ips[0]];
    }

    /** Best-effort client IP, respecting common proxy headers. */
    public static function clientIp(): string {
        $candidates = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'];
        foreach ($candidates as $key) {
            if (!empty($_SERVER[$key])) {
                $val = (string)$_SERVER[$key];
                if (str_contains($val, ',')) {
                    $val = trim(explode(',', $val)[0]);
                }
                if (filter_var($val, FILTER_VALIDATE_IP)) return $val;
            }
        }
        return '';
    }
}
