<?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: text, email, tel, url, number, date, textarea,
 * select, radio, checkbox, file, signature.
 * Deferred to follow-up sessions: multi-step, conditional logic,
 * payment.
 */

class FormsAPI {

    const SUPPORTED_TYPES = [
        'text', 'email', 'tel', 'url', 'number', 'date', 'time',
        'textarea', 'select', 'radio', 'checkbox', 'range', 'rating',
        'file', 'signature', 'heading', 'hidden', 'calc', 'step',
    ];

    /** Field types that collect no submission value (display/layout only). */
    const DISPLAY_TYPES = ['heading', 'step'];

    /** Condition operators usable in a field's show_if rule. */
    const CONDITION_OPS = ['eq', 'ne', 'filled', 'empty', 'gt', 'lt'];

    /** Max decoded bytes for a captured signature PNG (~700 KB). */
    const SIGNATURE_MAX_BYTES = 700 * 1024;

    /** Bumped to cache-bust public JS/CSS assets. */
    const ASSET_VERSION = '0.9.3';

    /** 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,
                    `settings_json`    TEXT 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",
                'settings_json'     => "TEXT 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;
            } elseif (in_array($type, ['range', 'rating'], true)) {
                // Numeric config rides in the options column: range = "min,max,step", rating = "maxStars".
                $opts = $optionsRaw !== ''
                      ? array_values(array_filter(array_map('trim', explode(',', $optionsRaw)), fn($s) => $s !== ''))
                      : [];
                if (!empty($opts)) $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_map(fn($o) => is_array($o) ? (string)($o['value'] ?? '') : (string)$o, (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 : [];
    }

    /**
     * Normalise a JSON field list from the visual builder into the canonical
     * field array. This is the richer counterpart to parseFieldDsl(): it
     * preserves advanced metadata that the 6-column DSL can't carry —
     * `show_if` (conditional logic), `formula` (calculated), `step`
     * (multi-step). Returns ['fields'=>[...], 'errors'=>[...]].
     */
    public static function normalizeFields($arr): array {
        $out = []; $errors = []; $used = [];
        if (!is_array($arr)) return ['fields' => [], 'errors' => ['Invalid field data.']];

        foreach ($arr as $f) {
            if (!is_array($f)) continue;
            $type = strtolower((string)($f['type'] ?? ''));
            if (!in_array($type, self::SUPPORTED_TYPES, true)) continue;

            $name = strtolower(trim((string)($f['name'] ?? '')));
            if (!preg_match('/^[a-z][a-z0-9_]{0,49}$/', $name) || isset($used[$name])) {
                $name = self::uniqueFieldName((string)($f['label'] ?? $type), $used);
            }
            $used[$name] = true;

            $field = [
                'type'     => $type,
                'name'     => $name,
                'label'    => (string)($f['label'] ?? ucwords(str_replace('_', ' ', $name))),
                'required' => !empty($f['required']),
            ];
            $ph = trim((string)($f['placeholder'] ?? ''));
            if ($ph !== '') $field['placeholder'] = $ph;

            // Field-level leading icon for text-like inputs ('' / 'auto' = by type).
            $ic = (string)($f['icon'] ?? '');
            if ($ic !== '' && $ic !== 'auto' && in_array($type, ['text','email','tel','url','number','date','time'], true)) {
                $field['icon'] = ($ic === 'none') ? 'none' : preg_replace('/[^a-z0-9-]/i', '', $ic);
            }

            if (isset($f['options']) && is_array($f['options'])) {
                $opts = [];
                foreach ($f['options'] as $o) {
                    if (is_array($o)) {
                        // Choice-card option: {value, label?, icon?, desc?}.
                        $val = trim((string)($o['value'] ?? $o['label'] ?? ''));
                        if ($val === '') continue;
                        $opt = ['value' => $val];
                        if (!empty($o['label']) && trim((string)$o['label']) !== $val) $opt['label'] = trim((string)$o['label']);
                        if (!empty($o['icon'])) $opt['icon'] = preg_replace('/[^a-z0-9-]/i', '', (string)$o['icon']);
                        if (!empty($o['desc'])) $opt['desc'] = trim((string)$o['desc']);
                        $opts[] = (count($opt) === 1) ? $val : $opt;   // collapse to plain string if no extras
                    } else {
                        $s = trim((string)$o);
                        if ($s !== '') $opts[] = $s;
                    }
                }
                if ($opts) $field['options'] = $opts;
            }
            if (in_array($type, ['select', 'radio'], true)) {
                if (empty($field['options'])) $errors[] = "Field '{$name}' needs an options list.";
                if (!empty($f['card'])) $field['card'] = true;   // render as choice cards
            }

            // Conditional logic — show this field only when a rule matches.
            if (!empty($f['show_if']) && is_array($f['show_if']) && !empty($f['show_if']['field'])) {
                $op = (string)($f['show_if']['op'] ?? 'eq');
                if (in_array($op, self::CONDITION_OPS, true)) {
                    $field['show_if'] = [
                        'field' => strtolower(preg_replace('/[^a-z0-9_]/i', '', (string)$f['show_if']['field'])),
                        'op'    => $op,
                        'value' => (string)($f['show_if']['value'] ?? ''),
                    ];
                }
            }
            // Calculated field — a formula over numeric fields.
            if ($type === 'calc') {
                $field['formula'] = self::sanitizeFormula((string)($f['formula'] ?? ''));
            }
            // Multi-step grouping (1-based step index).
            if (isset($f['step']) && is_numeric($f['step'])) {
                $field['step'] = max(1, (int)$f['step']);
            }
            // Half-width layout (two fields per row). Not for full-width types.
            if (!empty($f['half']) && !in_array($type, self::DISPLAY_TYPES, true)
                && !in_array($type, ['signature', 'file'], true) && empty($field['card'])) {
                $field['half'] = true;
            }

            $out[] = $field;
        }
        if (!$out) $errors[] = 'Add at least one field.';
        return ['fields' => $out, 'errors' => $errors];
    }

    /** Unique snake_case field name derived from a label, avoiding $used keys. */
    private static function uniqueFieldName(string $label, array $used): string {
        $base = strtolower(preg_replace('/[^a-z0-9]+/i', '_', trim($label)));
        $base = trim($base, '_');
        if ($base === '' || !preg_match('/^[a-z]/', $base)) $base = 'field_' . $base;
        $base = substr(trim($base, '_'), 0, 50) ?: 'field';
        $name = $base; $i = 2;
        while (isset($used[$name])) { $name = $base . '_' . $i++; }
        return $name;
    }

    /**
     * Evaluate a field's show_if rule against the submitted/known input.
     * Returns true when the field should be shown (and therefore validated).
     * A field with no rule is always shown. Mirrors the public-form JS.
     */
    public static function conditionMet(array $field, array $input): bool {
        $cond = $field['show_if'] ?? null;
        if (!is_array($cond) || empty($cond['field'])) return true;
        $other = $input[$cond['field']] ?? null;
        $cur   = is_array($other) ? '' : trim((string)($other ?? ''));
        $val   = trim((string)($cond['value'] ?? ''));
        switch ($cond['op']) {
            case 'eq':     return $cur === $val;
            case 'ne':     return $cur !== $val;
            case 'filled': return $cur !== '';
            case 'empty':  return $cur === '';
            case 'gt':     return is_numeric($cur) && is_numeric($val) && (float)$cur >  (float)$val;
            case 'lt':     return is_numeric($cur) && is_numeric($val) && (float)$cur <  (float)$val;
        }
        return true;
    }

    /** Keep only chars allowed in a calc formula: field refs, numbers, + - * / ( ) . */
    public static function sanitizeFormula(string $formula): string {
        return trim(preg_replace('/[^a-z0-9_{}+\-*\/().,\s]/i', '', $formula));
    }

    /**
     * Safely evaluate a calc formula like "{qty} * {price}" against $data.
     * Field references {name} are replaced with their numeric value (0 if
     * missing/non-numeric). Supports + - * / ( ) only — no eval, a tiny
     * shunting-yard parser. Returns a float, or null on a malformed formula.
     */
    public static function evalFormula(string $formula, array $data): ?float {
        $formula = self::sanitizeFormula($formula);
        if ($formula === '') return null;
        // Substitute {field} refs with their numeric value.
        $expr = preg_replace_callback('/\{([a-z0-9_]+)\}/i', function ($m) use ($data) {
            $v = $data[$m[1]] ?? 0;
            if (is_bool($v)) $v = $v ? 1 : 0;
            return is_numeric($v) ? (string)(float)$v : '0';
        }, $formula);
        if (!preg_match('/^[0-9+\-*\/().\s]*$/', $expr)) return null;

        // Tokenise.
        preg_match_all('/\d+\.?\d*|[+\-*\/()]/', $expr, $tok);
        $tokens = $tok[0];
        if (!$tokens) return null;

        // Shunting-yard → RPN.
        $prec = ['+' => 1, '-' => 1, '*' => 2, '/' => 2];
        $out = []; $ops = [];
        foreach ($tokens as $t) {
            if (is_numeric($t)) {
                $out[] = (float)$t;
            } elseif (isset($prec[$t])) {
                while ($ops && end($ops) !== '(' && isset($prec[end($ops)]) && $prec[end($ops)] >= $prec[$t]) {
                    $out[] = array_pop($ops);
                }
                $ops[] = $t;
            } elseif ($t === '(') {
                $ops[] = $t;
            } elseif ($t === ')') {
                while ($ops && end($ops) !== '(') $out[] = array_pop($ops);
                if (!$ops) return null;
                array_pop($ops);
            }
        }
        while ($ops) { $op = array_pop($ops); if ($op === '(') return null; $out[] = $op; }

        // Evaluate RPN.
        $stack = [];
        foreach ($out as $t) {
            if (is_float($t)) { $stack[] = $t; continue; }
            if (count($stack) < 2) return null;
            $b = array_pop($stack); $a = array_pop($stack);
            switch ($t) {
                case '+': $stack[] = $a + $b; break;
                case '-': $stack[] = $a - $b; break;
                case '*': $stack[] = $a * $b; break;
                case '/': $stack[] = $b != 0.0 ? $a / $b : 0.0; break;
            }
        }
        return count($stack) === 1 ? (float)$stack[0] : null;
    }

    /**
     * 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';
            if (in_array($type, self::DISPLAY_TYPES, true)) continue; // display-only, no value
            if ($type === 'calc') continue;                          // recomputed after the loop
            if (!self::conditionMet($f, $input)) continue;            // hidden by its show_if rule
            $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 'time':
                    if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $value)) {
                        $errors[$name] = 'Please enter a valid time.';
                    }
                    break;
                case 'range':
                case 'rating':
                    if (!is_numeric($value)) {
                        $errors[$name] = 'Please choose a value.';
                    } else {
                        // Clamp to the configured bounds (don't trust the client).
                        [$min, $max] = self::rangeBounds($f);
                        $n = (float)$value;
                        if ($n < $min) $n = $min;
                        if ($n > $max) $n = $max;
                        $value = (string)($type === 'rating' ? (int)round($n) : $n);
                    }
                    break;
                case 'hidden':
                    // Preset / passthrough value — accept as-is, capped.
                    if (mb_strlen($value) > 2000) $value = mb_substr($value, 0, 2000);
                    break;
                case 'select':
                case 'radio':
                    $opts = array_map(
                        fn($o) => is_array($o) ? (string)($o['value'] ?? '') : (string)$o,
                        (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 'signature':
                    // The pad posts a PNG data URL in this field. Verify the
                    // shape here (cheap reject of junk); the decode + save to
                    // disk happens in handleSignatures() from the router.
                    if (!preg_match('#^data:image/png;base64,[A-Za-z0-9+/=\s]+$#', $value)) {
                        $errors[$name] = 'Please add your signature.';
                    }
                    // Keep the raw data URL in $value for handleSignatures().
                    break;
                case 'text':
                default:
                    if (mb_strlen($value) > 500) {
                        $errors[$name] = 'Please keep this under 500 characters.';
                    }
                    break;
            }

            $data[$name] = $value;
        }

        // Calculated fields: recompute server-side from the assembled data
        // (never trust the client-submitted result).
        foreach (($form['fields'] ?? []) as $f) {
            if (($f['type'] ?? '') !== 'calc') continue;
            $name = $f['name'] ?? '';
            if ($name === '' || !self::conditionMet($f, $input)) continue;
            $r = self::evalFormula((string)($f['formula'] ?? ''), $data);
            $data[$name] = $r === null ? '' : (string)(round($r, 4) + 0);
        }

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

    /**
     * Bounds for range/rating fields, read from the field's options list.
     * range  → [min, max, step] (options: "min,max,step", default 0,100,1)
     * rating → [1, maxStars, 1] (options: "maxStars", default 5)
     */
    private static function rangeBounds(array $f): array {
        $opts = array_values((array)($f['options'] ?? []));
        if (($f['type'] ?? '') === 'rating') {
            $max = (isset($opts[0]) && (int)$opts[0] > 0) ? min(10, (int)$opts[0]) : 5;
            return [1, $max, 1];
        }
        $min  = (isset($opts[0]) && is_numeric($opts[0])) ? (float)$opts[0] : 0;
        $max  = (isset($opts[1]) && is_numeric($opts[1])) ? (float)$opts[1] : 100;
        $step = (isset($opts[2]) && is_numeric($opts[2]) && (float)$opts[2] > 0) ? (float)$opts[2] : 1;
        if ($max <= $min) $max = $min + 1;
        return [$min, $max, $step];
    }

    /** Curated inline-SVG icon set for choice cards. Unknown → 'check'. */
    public static function cardIcon(string $name): string {
        static $icons = [
            'check'     => '<polyline points="20 6 9 17 4 12"/>',
            'user'      => '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
            'users'     => '<circle cx="9" cy="8" r="3.5"/><path d="M2.5 21a6.5 6.5 0 0 1 13 0"/><path d="M16 5.5a3.5 3.5 0 0 1 0 6.9"/><path d="M22 21a6.5 6.5 0 0 0-5-6.3"/>',
            'building'  => '<rect x="4" y="3" width="16" height="18" rx="1.5"/><path d="M9 21v-4h6v4M8 7h2M14 7h2M8 11h2M14 11h2"/>',
            'anchor'    => '<circle cx="12" cy="5" r="2.5"/><path d="M12 7.5V21M5 13a7 7 0 0 0 14 0M3 13h3M18 13h3"/>',
            'boat'      => '<path d="M3 14l9-4 9 4-2.5 6H5.5L3 14z"/><path d="M12 10V4l5 3"/>',
            'star'      => '<polygon points="12 2 15 9 22 9.3 16.5 14 18.5 21 12 17 5.5 21 7.5 14 2 9.3 9 9"/>',
            'heart'     => '<path d="M12 21s-7-4.5-9.5-9A5 5 0 0 1 12 6a5 5 0 0 1 9.5 6c-2.5 4.5-9.5 9-9.5 9z"/>',
            'tag'       => '<path d="M20.6 13.4l-7.2 7.2a2 2 0 0 1-2.8 0L2 12V2h10l8.6 8.6a2 2 0 0 1 0 2.8z"/><circle cx="7" cy="7" r="1.4"/>',
            'box'       => '<path d="M21 8l-9-5-9 5v8l9 5 9-5V8zM3 8l9 5 9-5"/>',
            'card'      => '<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/>',
            'globe'     => '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>',
            'phone'     => '<path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2z"/>',
            'mail'      => '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/>',
            'home'      => '<path d="M3 11l9-7 9 7M5 10v10h14V10"/>',
            'shield'    => '<path d="M12 2l8 3v6c0 5-3.5 9-8 11-4.5-2-8-6-8-11V5z"/>',
            'truck'     => '<path d="M3 6h11v9H3zM14 9h4l3 3v3h-7z"/><circle cx="7" cy="18" r="1.6"/><circle cx="17" cy="18" r="1.6"/>',
            'calendar'  => '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
            'dollar'    => '<line x1="12" y1="2" x2="12" y2="22"/><path d="M17 6.5A4 4 0 0 0 13 4h-2a3.5 3.5 0 0 0 0 7h2a3.5 3.5 0 0 1 0 7h-2a4 4 0 0 1-4-2.5"/>',
            'briefcase' => '<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 13h18"/>',
            'wrench'    => '<path d="M14 7a4 4 0 0 1 5 5l-7 7-3-3 7-7a4 4 0 0 0-5-5z"/>',
            'clock'     => '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
            'hash'      => '<line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/>',
            'map-pin'   => '<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z"/><circle cx="12" cy="10" r="3"/>',
            'lock'      => '<rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
            'search'    => '<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.7" y2="16.7"/>',
            'type'      => '<polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/>',
        ];
        $p = $icons[$name] ?? $icons['check'];
        return '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" '
             . 'stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' . $p . '</svg>';
    }

    /**
     * Resolve the leading icon for a text-like input. Order: explicit field
     * icon ('none' = off) → icon by type (email→mail, tel→phone, …) →
     * a light name/label heuristic for text fields → '' (no icon).
     */
    public static function fieldInputIcon(array $f): string {
        $set = (string)($f['icon'] ?? '');
        if ($set === 'none') return '';
        if ($set !== '' && $set !== 'auto') return $set;

        $type = $f['type'] ?? 'text';
        $byType = ['email' => 'mail', 'tel' => 'phone', 'url' => 'globe',
                   'date' => 'calendar', 'time' => 'clock', 'number' => 'hash'];
        if (isset($byType[$type])) return $byType[$type];

        if ($type === 'text') {
            $h = strtolower(((string)($f['name'] ?? '')) . ' ' . ((string)($f['label'] ?? '')));
            $map = ['name' => 'user', 'phone' => 'phone', 'mobile' => 'phone', 'email' => 'mail',
                    'compan' => 'building', 'business' => 'building', 'organi' => 'building',
                    'address' => 'map-pin', 'street' => 'map-pin', 'city' => 'map-pin', 'zip' => 'map-pin', 'postal' => 'map-pin',
                    'price' => 'dollar', 'amount' => 'dollar', 'cost' => 'dollar', 'budget' => 'dollar', 'total' => 'dollar', 'pay' => 'dollar',
                    'card' => 'card', 'web' => 'globe', 'site' => 'globe', 'search' => 'search', 'password' => 'lock'];
            foreach ($map as $kw => $ic) { if (strpos($h, $kw) !== false) return $ic; }
        }
        return '';
    }

    /** Render select/radio as rich choice cards (icon + title + description). */
    private static function renderChoiceCards(array $field, $value, string $id, string $name, bool $required): string {
        ob_start();
        echo '<div class="forms-cards" role="radiogroup">';
        foreach ((array)($field['options'] ?? []) as $i => $o) {
            $val  = is_array($o) ? (string)($o['value'] ?? '') : (string)$o;
            if ($val === '') continue;
            $lbl  = is_array($o) ? (string)($o['label'] ?? $o['value'] ?? '') : (string)$o;
            $icon = is_array($o) ? (string)($o['icon'] ?? '') : '';
            $desc = is_array($o) ? (string)($o['desc'] ?? '') : '';
            $oid  = $id . '_' . $i;
            $sel  = (is_string($value) && $value === $val) ? ' checked' : '';
            echo '<div class="forms-card-wrap">';
            echo   '<input type="radio" id="' . e($oid) . '" name="' . e($name) . '" value="' . e($val) . '"' . $sel . ($required ? ' required' : '') . '>';
            echo   '<label class="forms-card" for="' . e($oid) . '">';
            if ($icon !== '') echo '<span class="forms-card-ico">' . self::cardIcon($icon) . '</span>';
            echo     '<span class="forms-card-body"><span class="forms-card-title">' . e($lbl) . '</span>';
            if ($desc !== '') echo '<span class="forms-card-desc">' . e($desc) . '</span>';
            echo     '</span>';
            echo     '<span class="forms-card-check" aria-hidden="true">' . self::cardIcon('check') . '</span>';
            echo   '</label>';
            echo '</div>';
        }
        echo '</div>';
        return (string) ob_get_clean();
    }

    /**
     * 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 {
        // Display-only + special-shell fields are rendered before the
        // standard .field wrapper (no label row).
        $t0 = $field['type'] ?? 'text';
        // Conditional-visibility hooks for the public-form JS.
        $cond = ' data-fname="' . e((string)($field['name'] ?? '')) . '"';
        if (!empty($field['show_if'])) $cond .= ' data-showif="' . e(json_encode($field['show_if'])) . '"';

        if ($t0 === 'heading') {
            $sub = trim((string)($field['placeholder'] ?? ''));
            return '<div class="forms-heading"' . $cond . '><h3>' . e((string)($field['label'] ?? ''))
                 . '</h3>' . ($sub !== '' ? '<p>' . e($sub) . '</p>' : '') . '</div>';
        }
        if ($t0 === 'hidden') {
            $hv = is_string($value) && $value !== '' ? $value : (string)($field['placeholder'] ?? '');
            return '<input type="hidden" name="' . e((string)($field['name'] ?? '')) . '" value="' . e($hv) . '">';
        }
        $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' . (!empty($field['half']) ? ' field--half' : '') . '"' . $cond . '>';

        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':
                    if (!empty($field['card'])) { echo self::renderChoiceCards($field, $value, $id, $name, $required); break; }
                    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 'signature':
                    // Draw-or-type signature pad. The hidden input holds a PNG
                    // data URL produced client-side; handleSignatures() decodes
                    // and stores it. Required is enforced server-side and by the
                    // pad's own submit guard (hidden inputs skip native required).
                    $reqAttr = $required ? ' data-sig-required="1"' : '';
                    echo '<div class="sig-pad" data-sig>';
                    echo   '<div class="sig-tabs">';
                    echo     '<button type="button" class="sig-tab is-active" data-sig-mode-btn="draw">Draw</button>';
                    echo     '<button type="button" class="sig-tab" data-sig-mode-btn="type">Type</button>';
                    echo     '<button type="button" class="sig-clear" data-sig-clear>Clear</button>';
                    echo   '</div>';
                    echo   '<div class="sig-stage">';
                    echo     '<canvas id="' . e($id) . '" class="sig-canvas" width="640" height="200" role="img" aria-label="' . e($label) . ' signature pad"></canvas>';
                    echo     '<input type="text" class="sig-type-input" maxlength="60" placeholder="Type your full name" autocomplete="name" hidden>';
                    echo   '</div>';
                    // Repopulate after a failed submit so the signature isn't lost.
                    $sigInitial = (is_string($value) && str_starts_with($value, 'data:image/png;base64,')) ? $value : '';
                    echo   '<input type="hidden" name="' . e($name) . '" value="' . e($sigInitial) . '" data-sig-value' . $reqAttr . '>';
                    echo   '<input type="hidden" name="' . e($name) . '__mode" value="draw" data-sig-mode>';
                    echo   '<input type="hidden" name="' . e($name) . '__name" value="" data-sig-name>';
                    echo '</div>';   // close .sig-pad (hint goes BELOW the pad, not inside it)
                    echo   '<div class="field-hint">Sign with your mouse or finger, or switch to <strong>Type</strong>.</div>';
                    echo self::signatureAssetTag();
                    break;

                case 'radio':
                    if (!empty($field['card'])) { echo self::renderChoiceCards($field, $value, $id, $name, $required); break; }
                    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;

                case 'calc':
                    $cv = is_string($value) ? $value : '';
                    echo '<output class="forms-calc-out" data-calc-out>' . e($cv !== '' ? $cv : '—') . '</output>';
                    echo '<input type="hidden" name="' . e($name) . '" value="' . e($cv) . '"'
                       . ' data-formula="' . e((string)($field['formula'] ?? '')) . '">';
                    echo '<div class="field-hint">Calculated automatically.</div>';
                    break;

                case 'range':
                    [$rmin, $rmax, $rstep] = self::rangeBounds($field);
                    $rval = is_string($value) && $value !== '' ? (float)$value : ($rmin + ($rmax - $rmin) / 2);
                    echo '<div class="forms-range">';
                    echo   '<input type="range" id="' . e($id) . '" name="' . e($name) . '"'
                       . ' min="' . e((string)$rmin) . '" max="' . e((string)$rmax) . '" step="' . e((string)$rstep) . '"'
                       . ' value="' . e((string)$rval) . '" oninput="this.nextElementSibling.value=this.value">';
                    echo   '<output>' . e((string)$rval) . '</output>';
                    echo '</div>';
                    break;

                case 'rating':
                    [, $rmaxStars] = self::rangeBounds($field);
                    $cur = is_string($value) && $value !== '' ? (int)$value : 0;
                    echo '<div class="forms-rating" role="radiogroup" aria-label="' . e($label) . '">';
                    for ($s = (int)$rmaxStars; $s >= 1; $s--) {
                        $rid = $id . '_' . $s;
                        echo '<input type="radio" id="' . e($rid) . '" name="' . e($name) . '" value="' . $s . '"'
                           . ($cur === $s ? ' checked' : '') . ($required ? ' required' : '') . '>';
                        echo '<label for="' . e($rid) . '" title="' . $s . ' / ' . (int)$rmaxStars . '">★</label>';
                    }
                    echo '</div>';
                    break;

                default:
                    $inputType = $type;
                    if (!in_array($type, ['text','email','tel','url','number','date','time'], true)) {
                        $inputType = 'text';
                    }
                    $ico = self::fieldInputIcon($field);
                    // date/time have a native picker control on the right, so the
                    // validation check would overlap it — skip the check there.
                    $native = in_array($inputType, ['date', 'time'], true);
                    echo '<div class="forms-inp' . ($native ? ' forms-inp-native' : '') . '">';
                    if ($ico !== '') echo '<span class="forms-inp-ico">' . self::cardIcon($ico) . '</span>';
                    echo '<input type="' . e($inputType) . '" id="' . e($id) . '" name="' . e($name) . '"'
                       . ($valueAttr !== '' ? ' value="' . $valueAttr . '"' : '')
                       . $req . $phAttr . ($ico !== '' ? ' class="has-ico"' : '') . '>';
                    if (!$native) echo '<span class="forms-inp-status" aria-hidden="true"></span>';
                    echo '</div>';
                    break;
            }
        }

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

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

    /**
     * Per-form appearance settings (decoded from forms_definitions.settings_json),
     * with sane defaults. Controls the public form's look + behaviour.
     */
    public static function formSettings(?string $json): array {
        $s = is_string($json) && $json !== '' ? json_decode($json, true) : null;
        if (!is_array($s)) $s = [];
        return [
            'density'      => (($s['density'] ?? 'compact') === 'comfortable') ? 'comfortable' : 'compact',
            'animate'      => array_key_exists('animate',  $s) ? (bool)$s['animate']  : true,
            'validate'     => array_key_exists('validate', $s) ? (bool)$s['validate'] : true,
            'rail'         => array_key_exists('rail',     $s) ? (bool)$s['rail']     : true,
            'accent'       => self::sanitizeHex((string)($s['accent'] ?? '')),
            'accent_hover' => self::sanitizeHex((string)($s['accent_hover'] ?? '')),
            'field_style'  => (($s['field_style'] ?? 'outline') === 'filled') ? 'filled' : 'outline',
            'shape'        => in_array($s['shape'] ?? 'rounded', ['rounded', 'pill', 'square'], true) ? ($s['shape'] ?? 'rounded') : 'rounded',
            'labels'       => (($s['labels'] ?? 'show') === 'hide') ? 'hide' : 'show',
            // Attach a branded PDF of the submission to the admin + submitter
            // emails. Off by default so existing forms are unchanged.
            'pdf_attach'   => array_key_exists('pdf_attach', $s) ? (bool)$s['pdf_attach'] : false,
        ];
    }

    /**
     * Build a branded submission PDF (bytes) for a form + decoded data row.
     * Single entry point used by both the public submit path (email
     * attachment) and the admin "Download PDF" action. Branding is pulled
     * from the global site settings; the accent prefers the form's own
     * appearance accent, then the brand accent.
     *
     * @param array $form forms_definitions row (with `fields` or `fields_json`)
     */
    public static function submissionPdf(array $form, array $data, string $ref): string {
        require_once __DIR__ . '/lib/FormsPdf.php';

        if (!isset($form['fields']) || !is_array($form['fields'])) {
            $form['fields'] = self::decodeFields((string)($form['fields_json'] ?? ''));
        }

        $set   = self::formSettings($form['settings_json'] ?? null);
        $brand = FormsPdf::brandFromSettings();

        return FormsPdf::render($form, $data, $ref, [
            'brand'  => $brand,
            'accent' => $set['accent'] !== '' ? $set['accent'] : ($brand['accent'] ?? ''),
        ]);
    }

    /** Validate a #rrggbb colour; return '' if not a valid hex. */
    public static function sanitizeHex(string $s): string {
        $s = trim($s);
        return preg_match('/^#?([0-9a-f]{6})$/i', $s, $m) ? '#' . strtolower($m[1]) : '';
    }

    private static function hexToRgb(string $hex): array {
        $hex = ltrim($hex, '#');
        return [hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2))];
    }

    /** Darken a hex colour by a fraction (0..1). */
    public static function darkenHex(string $hex, float $amt): string {
        [$r, $g, $b] = self::hexToRgb($hex);
        return sprintf('#%02x%02x%02x', max(0, (int)round($r * (1 - $amt))),
                       max(0, (int)round($g * (1 - $amt))), max(0, (int)round($b * (1 - $amt))));
    }

    /**
     * Build the public-form card's class + style attributes from settings:
     * density / field style / corner shape / hidden labels + custom accent
     * (overrides the --f-accent / --f-accent-d / --f-ring CSS variables).
     */
    public static function publicCardAttrs(array $set): string {
        $cls = 'forms-public-card';
        if (($set['density'] ?? '') === 'compact')   $cls .= ' forms-density-compact';
        if (($set['field_style'] ?? '') === 'filled') $cls .= ' forms-fields-filled';
        if (($set['shape'] ?? '') === 'pill')         $cls .= ' forms-shape-pill';
        elseif (($set['shape'] ?? '') === 'square')   $cls .= ' forms-shape-square';
        if (($set['labels'] ?? '') === 'hide')        $cls .= ' forms-hide-labels';

        $style = '';
        $a = (string)($set['accent'] ?? '');
        if ($a !== '') {
            $d = ($set['accent_hover'] ?? '') !== '' ? $set['accent_hover'] : self::darkenHex($a, 0.12);
            [$r, $g, $b] = self::hexToRgb($a);
            $style = '--f-accent:' . $a . ';--f-accent-d:' . $d . ';--f-ring:rgba(' . $r . ',' . $g . ',' . $b . ',.18);';
        }
        return 'class="' . e($cls) . '"' . ($style !== '' ? ' style="' . e($style) . '"' : '');
    }

    /**
     * Render the inner body of the public form: every field, plus — when the
     * field list contains `step` markers — a multi-step wizard (numbered rail,
     * progress bar, grouped step fieldsets, Back/Next nav). The submit button
     * is included. $opts: ['rail' => bool]. Single-step forms render flat.
     */
    public static function renderFormBody(array $fields, array $values, array $errors, string $submitLabel, array $opts = []): string {
        $hasSteps = false;
        foreach ($fields as $f) { if (($f['type'] ?? '') === 'step') { $hasSteps = true; break; } }

        ob_start();

        if (!$hasSteps) {
            foreach ($fields as $f) {
                $name = $f['name'] ?? '';
                echo self::renderField($f, $values[$name] ?? '', $errors[$name] ?? null);
            }
            echo '<button type="submit" class="btn btn-primary btn-lg btn-block" data-forms-submit>'
               . e($submitLabel) . '</button>';
            return (string) ob_get_clean();
        }

        // Group into steps: a `step` marker starts a new step (label = title, placeholder = subtitle).
        $steps = [];
        $cur   = ['title' => '', 'sub' => '', 'fields' => []];
        $seen  = false;
        foreach ($fields as $f) {
            if (($f['type'] ?? '') === 'step') {
                if ($seen || $cur['fields']) $steps[] = $cur;
                $cur  = ['title' => (string)($f['label'] ?? ''), 'sub' => (string)($f['placeholder'] ?? ''), 'fields' => []];
                $seen = true;
            } else {
                $cur['fields'][] = $f;
            }
        }
        $steps[] = $cur;
        $steps = array_values(array_filter($steps, fn($s) => !empty($s['fields']) || $s['title'] !== ''));
        $total = max(1, count($steps));

        echo '<div class="forms-steps" data-forms-steps data-total="' . $total . '">';
        if (($opts['rail'] ?? true) && $total > 1) {
            echo '<ol class="forms-rail" data-forms-rail>';
            foreach ($steps as $i => $st) {
                echo '<li class="forms-rail-item' . ($i === 0 ? ' is-active' : '') . '" data-rail="' . $i . '">'
                   . '<span class="forms-rail-dot">' . ($i + 1) . '</span></li>';
            }
            echo '</ol>';
        }
        echo   '<div class="forms-steps-bar"><div class="forms-steps-bar-fill" data-forms-progress></div></div>';
        foreach ($steps as $i => $st) {
            echo '<fieldset class="forms-step" data-forms-step="' . $i . '"' . ($i > 0 ? ' hidden' : '') . '>';
            $bare = ($st['title'] === '' && $st['sub'] === '');
            echo   '<div class="forms-step-head' . ($bare ? ' is-bare' : '') . '">';
            echo     '<div class="forms-step-eyebrow"><span class="forms-step-dot"></span>Step ' . ($i + 1) . ' of ' . $total . '</div>';
            if ($st['title'] !== '') echo '<h2 class="forms-step-title">' . e($st['title']) . '</h2>';
            if ($st['sub'] !== '')   echo '<p class="forms-step-sub">' . e($st['sub']) . '</p>';
            echo   '</div>';
            foreach ($st['fields'] as $f) {
                $name = $f['name'] ?? '';
                echo self::renderField($f, $values[$name] ?? '', $errors[$name] ?? null);
            }
            echo '</fieldset>';
        }
        echo   '<div class="forms-steps-nav">';
        echo     '<button type="button" class="btn" data-forms-back hidden>Back</button>';
        echo     '<button type="button" class="btn btn-primary" data-forms-next>Next</button>';
        echo     '<button type="submit" class="btn btn-primary btn-lg" data-forms-submit hidden>' . e($submitLabel) . '</button>';
        echo   '</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];
    }

    /**
     * Emit the signature-pad <script> exactly once per request, no matter
     * how many signature fields a form has. Returns '' on subsequent calls.
     */
    private static function signatureAssetTag(): string {
        static $emitted = false;
        if ($emitted) return '';
        $emitted = true;
        return '<script src="' . e(plugin_url('forms', 'assets/js/signature.js'))
             . '?v=' . self::ASSET_VERSION . '" defer></script>';
    }

    /**
     * Process signature fields after validateSubmission. Each signature
     * arrives as a PNG data URL in $data[$name]; decode it, enforce a size
     * cap, write a hardened PNG into uploads/forms/, and replace the value
     * with a structured array {signature, path, mode, name}. Mirrors
     * handleFileUploads so the router can treat them the same way.
     */
    public static function handleSignatures(array $form, array $data): array {
        $errors = [];
        foreach (($form['fields'] ?? []) as $f) {
            if (($f['type'] ?? '') !== 'signature') continue;
            $name     = $f['name'] ?? '';
            $required = !empty($f['required']);
            if ($name === '') continue;

            $raw = is_string($data[$name] ?? null) ? trim((string)$data[$name]) : '';
            if ($raw === '') {
                if ($required) $errors[$name] = ($f['label'] ?? $name) . ' is required.';
                $data[$name] = '';
                continue;
            }

            if (!preg_match('#^data:image/png;base64,(.+)$#s', $raw, $m)) {
                $errors[$name] = 'Signature could not be read. Please sign again.';
                continue;
            }
            $bytes = base64_decode(preg_replace('/\s+/', '', $m[1]), true);
            if ($bytes === false || $bytes === '') {
                $errors[$name] = 'Signature could not be read. Please sign again.';
                continue;
            }
            if (strlen($bytes) > self::SIGNATURE_MAX_BYTES) {
                $errors[$name] = 'Signature image is too large.';
                continue;
            }
            // Confirm it really is a PNG (magic bytes) before trusting it.
            if (substr($bytes, 0, 8) !== "\x89PNG\r\n\x1a\n") {
                $errors[$name] = 'Signature must be a PNG image.';
                continue;
            }

            $dir = Uploads::publicUploadDir('forms');
            $file = 'sig_' . bin2hex(random_bytes(8)) . '.png';
            if (@file_put_contents($dir . '/' . $file, $bytes) === false) {
                $errors[$name] = 'Could not save the signature.';
                continue;
            }

            $mode = (($_POST[$name . '__mode'] ?? '') === 'type') ? 'type' : 'draw';
            $typed = trim((string)($_POST[$name . '__name'] ?? ''));
            $data[$name] = [
                'signature' => true,
                'path'      => '/uploads/forms/' . $file,
                'mode'      => $mode,
                'name'      => $mode === 'type' ? mb_substr($typed, 0, 60) : '',
                'size'      => strlen($bytes),
            ];
        }
        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 '';
    }
}
