<?php

declare(strict_types=1);

namespace PpPhp\Core\Events;

/**
 * The complete, categorised API surface an event author can call — every PHP
 * helper/class-method available in a server event and every `pp.*` client API
 * available in a JavaScript event, plus a library of ready-to-use snippets.
 *
 * This is the knowledge base behind the Events IntelliSense: it is harvested
 * from the actual generated-app runtime (core/Helpers.php, core/DB.php,
 * core/Security.php, the pp_* functions and the window.pp client object baked
 * by BuildWriter) so a completion the editor offers is always something the
 * generated app really exposes.
 *
 * Each entry: [label, insertText, kind, detail (signature), documentation,
 * category, example]. `insertText` may carry snippet tab-stops (${1:name}, $0)
 * which the editor expands.
 */
final class EventApiReference
{
    /** Kinds the editor renders with distinct icons. */
    public const KIND_METHOD = 'method';
    public const KIND_FUNCTION = 'function';
    public const KIND_PROPERTY = 'property';
    public const KIND_KEYWORD = 'keyword';
    public const KIND_SNIPPET = 'snippet';

    /**
     * Build one catalogue row.
     *
     * @return array<string, string>
     */
    private static function row(
        string $label,
        string $insertText,
        string $kind,
        string $detail,
        string $documentation,
        string $category,
        string $example = ''
    ): array {
        return [
            'label' => $label,
            'insertText' => $insertText,
            'kind' => $kind,
            'detail' => $detail,
            'documentation' => $documentation,
            'category' => $category,
            'example' => $example,
        ];
    }

    /**
     * The full PHP (server event) API.
     *
     * @return array<int, array<string, string>>
     */
    public static function php(): array
    {
        $m = self::KIND_METHOD;
        $f = self::KIND_FUNCTION;
        $k = self::KIND_KEYWORD;

        return [
            // ── Database — the quick façade (recommended) ──────────────────
            self::row('dbValue', "dbValue('SELECT \${1:col} FROM \${2:table} WHERE id = ?', [\${3:\$id}], \${4:null})", $f,
                'dbValue(string $sql, array $params = [], mixed $default = null): mixed',
                'Return a single value (first column of the first row) from a prepared query, or the default when there is no row. Perfect for a scalar lookup.',
                'Database',
                "\$price = dbValue('SELECT price FROM products WHERE id = ?', [\$data['product_id']], 0);"),
            self::row('dbRow', "dbRow('SELECT * FROM \${1:table} WHERE id = ?', [\${2:\$id}])", $f,
                'dbRow(string $sql, array $params = []): ?array',
                'Return the first matching row as an associative array, or null when nothing matches.',
                'Database',
                "\$user = dbRow('SELECT * FROM users WHERE email = ?', [\$email]);"),
            self::row('dbAll', "dbAll('SELECT * FROM \${1:table} WHERE \${2:1=1}', [\${3:}])", $f,
                'dbAll(string $sql, array $params = []): array',
                'Return every matching row as an array of associative arrays (empty array when none).',
                'Database',
                "foreach (dbAll('SELECT * FROM items WHERE order_id = ?', [\$id]) as \$item) { /* … */ }"),
            self::row('dbCount', "dbCount('SELECT COUNT(*) FROM \${1:table} WHERE \${2:col} = ?', [\${3:\$value}])", $f,
                'dbCount(string $sql, array $params = []): int',
                'Return a row count as an int. Handy for "does this already exist?" checks before saving.',
                'Database',
                "if (dbCount('SELECT COUNT(*) FROM users WHERE email = ?', [\$email]) > 0) { /* duplicate */ }"),
            self::row('dbLookup', "dbLookup('SELECT \${1:name} FROM \${2:table} WHERE id = ?', \${3:\$id})", $f,
                'dbLookup(string $sql, mixed ...$args): mixed',
                'Like dbValue but takes its parameters as loose arguments instead of an array — the shortest way to fetch one value.',
                'Database',
                "\$name = dbLookup('SELECT name FROM categories WHERE id = ?', \$data['category_id']);"),
            self::row('dbExec', "dbExec('UPDATE \${1:table} SET \${2:col} = ? WHERE id = ?', [\${3:\$value}, \${4:\$id}])", $f,
                'dbExec(string $sql, array $params = []): int',
                'Run an INSERT / UPDATE / DELETE and return the number of affected rows.',
                'Database',
                "dbExec('UPDATE stock SET qty = qty - 1 WHERE id = ?', [\$data['product_id']]);"),
            self::row('dbReady', 'dbReady()', $f,
                'dbReady(): bool',
                'True when a database connection is available. Guard optional DB work so an event never fatals without a connection.',
                'Database'),

            // ── Database — the DB class (full control) ─────────────────────
            self::row('DB::Insert', "DB::Insert('\${1:table}', [\${2:'column' => \$value}])", $m,
                'DB::Insert(string $table, array $data): int|false',
                'Insert one record from an associative array. Returns the new auto-increment id, or false on failure. Values are always bound (safe).',
                'Database',
                "\$id = DB::Insert('logs', ['message' => 'Created', 'created_at' => now()]);"),
            self::row('DB::Update', "DB::Update('\${1:table}', [\${2:'column' => \$value}], ['id' => \${3:\$id}])", $m,
                'DB::Update(string $table, array $data, array $where): int',
                'Update rows matching the associative $where. Returns affected-row count.',
                'Database',
                "DB::Update('orders', ['status' => 'paid'], ['id' => \$data['id']]);"),
            self::row('DB::Delete', "DB::Delete('\${1:table}', ['id' => \${2:\$id}])", $m,
                'DB::Delete(string $table, array $where): int',
                'Delete rows matching the associative $where. Returns affected-row count.',
                'Database'),
            self::row('DB::Query', "DB::Query('\${1:SQL}', [\${2:}])", $m,
                'DB::Query(string $sql, array $params = []): mixed',
                'Run any prepared statement. Returns a result for SELECT, or a bool for write statements.',
                'Database'),
            self::row('DB::FetchOne', "DB::FetchOne('SELECT \${1:col} FROM \${2:table} WHERE id = ?', [\${3:\$id}])", $m,
                'DB::FetchOne(string $sql, array $params = []): mixed',
                'Fetch the first column of the first row (the class-level equivalent of dbValue).',
                'Database'),
            self::row('DB::FetchAll', "DB::FetchAll('SELECT * FROM \${1:table}', [\${2:}])", $m,
                'DB::FetchAll(string $sql, array $params = []): array',
                'Fetch every row as associative arrays (the class-level equivalent of dbAll).',
                'Database'),
            self::row('DB::LastInsertId', 'DB::LastInsertId()', $m,
                'DB::LastInsertId(): int',
                'The auto-increment id created by the most recent insert on this connection.',
                'Database'),

            // ── Current user & security ────────────────────────────────────
            self::row('Security::CurrentUserData', 'Security::CurrentUserData()', $m,
                'Security::CurrentUserData(): array',
                'The signed-in user’s row as an associative array (id, username, email, role, …). Empty for a guest.',
                'User & security',
                "\$uid = Security::CurrentUserData()['id'] ?? 0;"),
            self::row('Security::CurrentUsername', 'Security::CurrentUsername()', $m,
                'Security::CurrentUsername(): string',
                'The signed-in user’s username (empty for a guest).',
                'User & security'),
            self::row('Security::CurrentUserGroup', 'Security::CurrentUserGroup()', $m,
                'Security::CurrentUserGroup(): string',
                'The user’s primary group/role name.',
                'User & security'),
            self::row('Security::IsLoggedIn', 'Security::IsLoggedIn()', $m,
                'Security::IsLoggedIn(): bool',
                'True when a real user is signed in.',
                'User & security'),
            self::row('Security::IsAdmin', 'Security::IsAdmin()', $m,
                'Security::IsAdmin(): bool',
                'True when the current user is an administrator.',
                'User & security'),
            self::row('Security::IsGuest', 'Security::IsGuest()', $m,
                'Security::IsGuest(): bool',
                'True when nobody is signed in.',
                'User & security'),
            self::row('Security::HasPermission', "Security::HasPermission('\${1:table}', '\${2:S}')", $m,
                'Security::HasPermission(string $table, string $letter): bool',
                'Check a table permission. Letters: A add, D delete, E edit, S search/list, P print, I import, M admin.',
                'User & security',
                "if (!Security::HasPermission('invoices', 'E')) return false;"),
            self::row('Security::RequireAuth', 'Security::RequireAuth()', $m,
                'Security::RequireAuth(): void',
                'Stop and redirect to the login page unless a user is signed in.',
                'User & security'),
            self::row('Security::Flash', "Security::Flash('\${1:success}', '\${2:message}')", $m,
                'Security::Flash(string $type, string $message): void',
                'Queue a one-time flash message (success|error|info|warning) shown on the next page.',
                'User & security'),

            // ── Email, SMS & notifications ─────────────────────────────────
            self::row('pp_send_email', "pp_send_email('\${1:\$to}', '\${2:Subject}', '\${3:<p>Body</p>}')", $f,
                'pp_send_email(string $to, string $subject, string $body, array $opts = []): bool',
                'Send an email through the project’s configured mailer. HTML body supported. Great in an afterAdd / afterEdit event.',
                'Email & notify',
                "pp_send_email(\$data['email'], 'Welcome!', '<p>Thanks for signing up.</p>');"),
            self::row('Email::send', "Email::send(['to' => '\${1:\$to}', 'subject' => '\${2:Subject}', 'body' => '\${3:Body}'])", $m,
                'Email::send(array $message): bool',
                'Send an email from an options array (to, subject, body, cc, bcc, replyTo, attachments).',
                'Email & notify'),
            self::row('pp_send_sms', "pp_send_sms('\${1:\$phone}', '\${2:message}')", $f,
                'pp_send_sms(string $to, string $message): bool',
                'Send an SMS through the project’s configured SMS gateway.',
                'Email & notify'),
            self::row('Notification::Add', "Notification::Add(['user_id' => \${1:\$userId}, 'message' => '\${2:message}'])", $m,
                'Notification::Add(array $data): int|false',
                'Create an in-app notification (rings the bell). Keys: user_id (null=everyone), message, title, url, icon, type (info|success|warning|error).',
                'Email & notify',
                "Notification::Add(['user_id' => \$ownerId, 'title' => 'New order', 'message' => 'Order #'.\$id.' was placed', 'type' => 'success', 'url' => '/orders/view.php?id='.\$id]);"),
            self::row('Notification::AddForRole', "Notification::AddForRole('\${1:admin}', ['title' => '\${2:New record}', 'message' => '\${3:message}', 'type' => '\${4:info}'])", $m,
                'Notification::AddForRole(string $role, array $data): int',
                'Notify every user with a role. $data keys: message, title, url, icon, type.',
                'Email & notify',
                "Notification::AddForRole('admin', ['title' => 'New order', 'message' => 'Order #'.\$id, 'type' => 'success', 'url' => '/orders/view.php?id='.\$id]);"),
            self::row('Notification::AddForGroup', "Notification::AddForGroup(\${1:\$groupId}, ['message' => '\${2:message}', 'type' => '\${3:info}'])", $m,
                'Notification::AddForGroup(int $groupId, array $data): int',
                'Notify every member of a group.',
                'Email & notify'),
            self::row('Notification::AddForUsers', "Notification::AddForUsers([\${1:\$ids}], ['message' => '\${2:message}', 'type' => '\${3:info}'])", $m,
                'Notification::AddForUsers(array $userIds, array $data): int',
                'Notify a specific list of user ids.',
                'Email & notify'),
            self::row('Notification::AddForEveryone', "Notification::AddForEveryone(['title' => '\${1:Announcement}', 'message' => '\${2:message}', 'type' => '\${3:info}'])", $m,
                'Notification::AddForEveryone(array $data): int|false',
                'Broadcast to everyone (one row, shown to all users).',
                'Email & notify'),
            self::row('Notification::AddForRecordOwner', "Notification::AddForRecordOwner(\${1:\$values}, '\${2:created_by}', ['message' => '\${3:message}', 'type' => '\${4:info}'])", $m,
                'Notification::AddForRecordOwner(array $row, string $ownerField, array $data): int|false',
                "Notify the record's owner — pass the row and the column holding their user id (e.g. created_by, assigned_to).",
                'Email & notify',
                "Notification::AddForRecordOwner(\$values, 'created_by', ['title' => 'Status updated', 'message' => 'Your request was approved', 'type' => 'success']);"),
            self::row('Notification::Count', "Notification::Count(\${1:\$userId})", $m,
                'Notification::Count(int $userId): int',
                'Number of unread notifications for a user.',
                'Email & notify'),

            // ── File / Image attachments ───────────────────────────────────
            // A File/Image field's COLUMN stores a StorageRef (JSON), not a path:
            //   {"driver":"local","key":"public/customers/ab12.jpg","name":"photo.jpg","size":18234,"mime":"image/jpeg"}
            // (a JSON array for multi-file fields). Files live under
            //   Files/public/<folder>/<file>   — direct URL
            //   Files/private/<folder>/<file>  — streamed via api/file.php (auth)
            // <folder> defaults to the table name. Cloud drivers (S3/Drive/OneDrive)
            // store the same shape with driver + provider key. Use the helpers below
            // to get a URL/list without caring which backend it is.
            self::row('pp_file_url', "pp_file_url(\$row['\${1:pic}'])", $f,
                'pp_file_url(mixed $value, array $opts = []): string',
                'The first attached file’s ready-to-use URL for a File/Image column — public link, signed cloud URL, or app-proxied private stream, resolved automatically. Pass ["thumb"=>true] for the thumbnail.',
                'File / Image',
                "\$img = pp_file_url(\$row['pic']); // <img src=\"...\"> or email it"),
            self::row('pp_file_urls', "pp_file_urls(\$row['\${1:pics}'])", $f,
                'pp_file_urls(mixed $value, array $opts = []): array',
                'Every attached file’s URL for a multi-file File/Image column (in order). ["thumb"=>true] returns thumbnails.',
                'File / Image',
                "foreach (pp_file_urls(\$row['gallery']) as \$u) { /* … */ }"),
            self::row('pp_file_refs', "pp_file_refs(\$row['\${1:pic}'])", $f,
                'pp_file_refs(mixed $value): array',
                'The decoded attachment list for a File/Image column — each item has name, size, mime, key, driver (+ w/h/alt for images). Use this to read metadata or build a custom gallery.',
                'File / Image',
                "foreach (pp_file_refs(\$row['pic']) as \$a) { echo \$a['name'].' — '.\$a['size']; }"),
            self::row('pp_store_upload', "pp_store_upload(\$_FILES['\${1:file}'])", $f,
                'pp_store_upload(array $file, array $opts = []): array',
                'Store a raw \$_FILES upload from your own code (server actions/webhooks) — the LEGACY /uploads helper. For File/Image FIELDS you don’t call this: the field uploads itself and stores a StorageRef; read it with pp_file_url().',
                'File / Image',
                "\$up = pp_store_upload(\$_FILES['photo']); if (\$up['success']) \$data['photo'] = \$up['url'];"),
            self::row('pp_send_file', "pp_send_file(\${1:\$key})", $f,
                'pp_send_file(string $path, array $opts = []): void',
                'Stream a stored file to the browser (download). Pass ["inline" => true] to display in-browser. Private File/Image attachments are already served this way via api/file.php.',
                'File / Image'),
            self::row('pp_delete_file', "pp_delete_file(\${1:\$key})", $f,
                'pp_delete_file(string $path): bool',
                'Delete a single stored file. Fires beforeFileDelete — return false there to keep it. To clean up a whole File/Image column use pp_upload_purge().',
                'File / Image'),
            self::row('pp_upload_purge', "pp_upload_purge(\$row['\${1:pic}'])", $f,
                'pp_upload_purge(mixed $value): void',
                'Delete every backing file for a File/Image column value (any backend). Handy in a beforeDelete event to remove a record’s attachments.',
                'File / Image',
                "// tables.customers.page.beforeDelete\npp_upload_purge(\$record['pic']);"),

            // ── Real-time (live push) ──────────────────────────────────────
            self::row('pp_broadcast', "pp_broadcast('\${1:channel}', '\${2:event}', \${3:\$payload})", $f,
                'pp_broadcast(string $channel, string $event, mixed $payload = null, ?int $userId = null): int',
                'Push a live update to every browser subscribed to a channel — no page refresh. Pass a $userId to deliver privately to one person. Fire it from any server event (e.g. afterAddSave) to make the app real-time. Returns the message id.',
                'Real-time',
                "// tables.orders.page.afterAddSave — tell every open dashboard\npp_broadcast('orders', 'created', ['ref' => \$data['ref'], 'amount' => \$data['amount']]);"),
            self::row('pp_broadcast_user', "pp_broadcast_user(\${1:\$userId}, '\${2:event}', \${3:\$payload})", $f,
                'pp_broadcast_user(int $userId, string $event, mixed $payload = null): int',
                'Send a live update to ONE user across all their open tabs — the private-channel shortcut for pp_broadcast on "user.<id>".',
                'Real-time',
                "pp_broadcast_user(\$assigneeId, 'assigned', ['task' => \$data['title']]);"),

            // ── Server → client bridge (proxy) ─────────────────────────────
            self::row('pp_set_proxy', "pp_set_proxy('\${1:key}', \${2:\$value})", $f,
                'pp_set_proxy(string $key, mixed $value): void',
                'Pass a PHP value out to the page’s JavaScript. Read it in a client event with pp.getProxy("key") or pp.proxy.key.',
                'Server → client',
                "pp_set_proxy('discount', \$rate); // then in JS: pp.getProxy('discount')"),
            self::row('pp_get_proxy', "pp_get_proxy('\${1:key}', \${2:null})", $f,
                'pp_get_proxy(string $key, mixed $default = null): mixed',
                'Read back a proxy value earlier set with pp_set_proxy().',
                'Server → client'),

            // ── Dates & time ───────────────────────────────────────────────
            self::row('now', 'now()', $f,
                'now(): string',
                'Current date and time as "Y-m-d H:i:s" — ready to store in a DATETIME column.',
                'Dates & text',
                "\$data['updated_at'] = now();"),
            self::row('today', 'today()', $f,
                'today(): string',
                'Current date as "Y-m-d".',
                'Dates & text'),
            self::row('timeAgo', "timeAgo(\${1:\$datetime})", $f,
                'timeAgo(string $datetime): string',
                'Human "3 minutes ago" style relative time from a datetime string.',
                'Dates & text'),
            self::row('formatDate', "formatDate(\${1:\$datetime}, '\${2:M j, Y}')", $f,
                'formatDate(string $datetime, string $format = "M j, Y, g:i a"): string',
                'Format a datetime string with any PHP date() pattern.',
                'Dates & text'),

            // ── Text ───────────────────────────────────────────────────────
            self::row('slug', "slug(\${1:\$text})", $f,
                'slug(string $text): string',
                'URL-friendly slug ("Hello World" → "hello-world").',
                'Dates & text'),
            self::row('truncate', "truncate(\${1:\$text}, \${2:100})", $f,
                'truncate(string $text, int $length = 100, string $ellipsis = "…"): string',
                'Shorten text to a length, adding an ellipsis.',
                'Dates & text'),
            self::row('plain', "plain(\${1:\$html})", $f,
                'plain(string $text, int $maxLength = 0): string',
                'Strip HTML/formatting to plain text (optionally truncated).',
                'Dates & text'),

            // ── Request / arrays / validation ──────────────────────────────
            self::row('get', "get('\${1:key}', \${2:null})", $f,
                'get(string $key, mixed $default = null): mixed',
                'Read a value from the query string ($_GET) safely.',
                'Request & data'),
            self::row('post', "post('\${1:key}', \${2:null})", $f,
                'post(string $key, mixed $default = null): mixed',
                'Read a value from the submitted form ($_POST) safely.',
                'Request & data'),
            self::row('first', "first(\${1:\$array})", $f,
                'first(array $array): mixed',
                'First element of an array (or null).',
                'Request & data'),
            self::row('last', "last(\${1:\$array})", $f,
                'last(array $array): mixed',
                'Last element of an array (or null).',
                'Request & data'),
            self::row('isEmail', "isEmail(\${1:\$value})", $f,
                'isEmail(string $email): bool',
                'True when the string is a valid email address.',
                'Request & data',
                "if (!isEmail(\$data['email'])) return false;"),
            self::row('isUrl', "isUrl(\${1:\$value})", $f,
                'isUrl(string $url): bool',
                'True when the string is a valid URL.',
                'Request & data'),
            self::row('isJson', "isJson(\${1:\$value})", $f,
                'isJson(string $string): bool',
                'True when the string is valid JSON.',
                'Request & data'),

            // ── Localisation ───────────────────────────────────────────────
            self::row('pp_t', "pp_t('\${1:key}', '\${2:Default text}')", $f,
                'pp_t(string $key, string $default = ""): string',
                'Translate a label/message using the project’s active language, falling back to the default.',
                'Localisation'),

            // ── Control flow ───────────────────────────────────────────────
            self::row('return false', 'return false;', $k,
                'return false;',
                'Cancel the current action (when the event supports it — e.g. beforeAdd/beforeEdit/beforeDelete stops the save/delete).',
                'Control flow',
                "if (\$data['price'] < 0) { pp_set_proxy('error', 'Price cannot be negative'); return false; }"),
            self::row('return true', 'return true;', $k,
                'return true;',
                'Continue the current action explicitly (default when nothing is returned).',
                'Control flow'),
            self::row('redirect', "redirect('\${1:/path}')", $f,
                'redirect(string $path): void',
                'Send the user to another page and stop execution.',
                'Control flow'),

            // ── SQL variables (dynamic WHERE clauses) ──────────────────────
            self::row('SQL var :userid', "DB::PrepareSQL(\"SELECT * FROM \${1:orders} WHERE \${2:owner_id} = :userid\")", $f,
                'SQL variables — :userid · :username · :user.field · :session.key · :get.x · :master.field · :language',
                "Dynamic, injection-safe placeholders expanded by DB::PrepareSQL() (and in WHERE tabs, Lookup Wizard, charts). :userid/:username = the logged-in user; :user.field = a column of their row; :session.key = a session value; :get.x/:post.x = a request param; arrays become an IN list. Values are quoted for you — never add quotes.",
                'SQL variables',
                "\$mine = dbAll(DB::PrepareSQL('SELECT * FROM tickets WHERE assigned_to = :userid'));"),
            self::row('SQL var :session', "WHERE \${1:region} = :session.\${2:region}", $k,
                'WHERE col = :session.key   ·   also :user.field, :master.field, :cookie.key',
                "Filter by a session value (store it e.g. in After Login). Use in a Designer WHERE tab, a Lookup Wizard WHERE, a chart WHERE, a Master/Detail relation filter, or your own DB::PrepareSQL query. Strings are auto-quoted.",
                'SQL variables',
                "assigned_to = :userid AND status = 'open'"),
            self::row('SQL var :field', "WHERE \${1:country_id} = :field.\${2:country}", $k,
                'WHERE col = :field.name   ·   another field on the SAME form (Add/Edit)',
                "Read another field's value on the current form — the cascading-dropdown case. In a Lookup Wizard WHERE, the list re-filters live when that field changes (the control sends its value). For other tables, wrap a sub-query: id IN (SELECT … WHERE x = :field.y).",
                'SQL variables',
                "country_id = :field.country"),
            self::row('SQL var dates', "WHERE created BETWEEN :startofmonth AND :today", $k,
                'Dynamic dates — :today :now :yesterday :tomorrow :startofmonth :endofmonth :startofweek :startofyear · :daysago.N :daysfromnow.N :hoursago.N :monthsago.N',
                "Rolling-window date filters with no PHP. Also :userrole, :ip, :cookie.key, :uuid. All resolve at run time and are quoted for you. Great for WHERE tabs like “This month” or “Last 30 days”.",
                'SQL variables',
                "created >= :daysago.30        // the last 30 days"),

            // ── pp() — server companion to pp.js (drives the browser live) ──
            self::row('pp()', 'pp()', $f,
                'pp(): PpApi   ·   the server-side facade paired with pp.js (window.Runner)',
                "One per-request object for context (user/role/can/page/param), permission-aware data (find/insert/update/delete), and a command bus that drives the browser live. Its commands are applied by pp.js after pp.run() / a Custom Button server stage.",
                'pp() · server API',
                "pp()->toast('success', 'Saved')->setControl('total', 42)->reloadGrid();"),
            self::row('pp()->toast', "pp()->toast('\${1:success}', '\${2:message}')", $m,
                "pp()->toast(type, message): PpApi   ·   type: success|error|warning|info",
                'Queue a toast that appears in the browser when the request returns.',
                'pp() · commands',
                "pp()->toast('success', 'Record approved.');"),
            self::row('pp()->setControl', "pp()->setControl('\${1:field}', \${2:\$value})", $m,
                'pp()->setControl(field, value): PpApi   ·   also setData([field => value, …])',
                'Set a field\'s value in the browser from PHP — no client-after code needed.',
                'pp() · commands',
                "pp()->setControl('status', 'Approved')->setControl('approved_by', pp()->username());"),
            self::row('pp()->message', "pp()->message('\${1:field}', '\${2:message}', '\${3:error}')", $m,
                'pp()->message(field, message[, tone]) · hide(field) · show(field) · focus(field) · addClass · addStyle',
                'Drive a field from the server: inline message (tone error|success|info), visibility, focus, styling.',
                'pp() · commands',
                "pp()->message('email', 'Already registered', 'error')->focus('email');"),
            self::row('pp()->reloadGrid', 'pp()->reloadGrid()', $m,
                'pp()->reloadGrid() · reload() · redirect(url) · openForm(config)',
                'Reload the list/grid or page, navigate, or pop a pp.dialog.form() — all from PHP.',
                'pp() · commands',
                "pp()->toast('success', 'Done')->reloadGrid();"),
            self::row('pp()->user', 'pp()->user()', $m,
                'pp()->user(): array · userId() · username() · role() · can(action, table)',
                'The current user + a boolean permission check (no redirect).',
                'pp() · context',
                "if (!pp()->can('edit', 'orders')) { pp()->toast('error', 'Not allowed'); return; }"),
            self::row('pp()->param', "pp()->param('\${1:key}', \${2:null})", $m,
                'pp()->param(key[, default]) · params() · page() · id()',
                'Read what Client Before sent (or the page context: table/action/id).',
                'pp() · context',
                "\$who = pp()->param('who', 'friend');"),
            self::row('pp()->find', "pp()->find('\${1:table}', \${2:\$id})", $m,
                'pp()->find(table, id[, key]): ?array   ·   row-level-security applied',
                'Fetch one record safely. Also insert(table,data), update(table,id,data), delete(table,id) — all permission-gated.',
                'pp() · data',
                "\$o = pp()->find('orders', pp()->id());\npp()->update('orders', \$o['id'], ['status' => 'shipped']);"),
            self::row('pp()->table', "pp()->table('\${1:orders}')->where('\${2:status}', '\${3:open}')->get()", $m,
                'pp()->table(name): PpQuery   ·   fluent, safe, row-security-aware query builder',
                "Chain: ->where(col[,op],val) ->whereIn ->whereNull ->whereRaw ->orderBy(col,dir) ->select ->limit ->offset, then a terminal ->get() / first() / value(col) / count() / exists() / pluck(col) / paginate(perPage,page) / toSql().",
                'pp() · data',
                "\$open = pp()->table('orders')->where('status', 'open')->where('total', '>', 100)->orderBy('created', 'desc')->limit(20)->get();"),
            self::row('pp()->table->paginate', "pp()->table('\${1:orders}')->orderBy('\${2:id}')->paginate(\${3:20}, \${4:1})", $m,
                'PpQuery->paginate(perPage, page): {data, total, page, perPage, pages}',
                'Page a result set — total count + the current slice in one call.',
                'pp() · data',
                "\$page = pp()->table('orders')->orderBy('id')->paginate(20, (int) pp()->param('page', 1));"),
            self::row('pp()->record', 'pp()->record()', $m,
                'pp()->record(): ?array   ·   the record the current page/button acts on (PHPRunner getCurrentRecord)',
                "The current record, read from the page context (table + id) — no need to pass an id. On a list button it's null (use selectedRecords()).",
                'pp() · data',
                "\$r = pp()->record();\nif (\$r) pp()->toast('info', 'Editing ' . \$r['name']);"),
            self::row('pp()->selectedRecords', 'pp()->selectedRecords()', $m,
                'pp()->selectedRecords(): array   ·   selectedKeys() · eachSelected(fn)   (List page ticked rows)',
                "The rows the user ticked on a List page (PHPRunner getNextSelectedRecord). eachSelected() iterates them. Keys flow automatically from a Custom Button / pp.run.",
                'pp() · data',
                "pp()->eachSelected(function (\$row) { pp()->update('orders', \$row['id'], ['status' => 'shipped']); });\npp()->toast('success', 'Shipped')->reloadGrid();"),
            self::row('pp()->table->join', "pp()->table('\${1:orders}')->join('\${2:customers}', '\${3:orders.customer_id}', '=', '\${4:customers.id}')->get()", $m,
                'PpQuery->join(table, first, op, second) · leftJoin(...)   ·   qualified cols (table.col)',
                'Join another table. Use qualified column names in select()/where()/orderBy(); row-security still applies to the base table.',
                'pp() · data',
                "\$rows = pp()->table('orders')\n    ->select('orders.id', 'customers.name')\n    ->leftJoin('customers', 'orders.customer_id', '=', 'customers.id')\n    ->where('orders.status', 'open')->get();"),
            self::row('pp()->table->sum', "pp()->table('\${1:orders}')->where('\${2:status}', '\${3:open}')->sum('\${4:total}')", $m,
                'PpQuery->sum(col) · avg(col) · min(col) · max(col) · count()   ·   over the current filter + joins',
                'Single-value aggregates. Respect where() and joins (ignore group/order/limit).',
                'pp() · data',
                "\$revenue = pp()->table('orders')->where('status', 'paid')->sum('total');"),
            self::row('pp()->table->groupBy', "pp()->table('\${1:orders}')->selectRaw('\${2:customer_id, COUNT(*) AS n}')->groupBy('\${3:customer_id}')->having('\${4:COUNT(*) > ?}', [\${5:5}])->get()", $m,
                'PpQuery->groupBy(...cols) · having(rawSql, params) · selectRaw(expr) · distinct()',
                'Grouped reporting. selectRaw() for expressions, having() is raw-but-bound, distinct() de-dupes.',
                'pp() · data',
                "\$top = pp()->table('orders')->selectRaw('customer_id, SUM(total) AS spent')\n    ->groupBy('customer_id')->having('SUM(total) > ?', [1000])->get();"),
            self::row('pp()->table->whereBetween', "pp()->table('\${1:orders}')->whereBetween('\${2:created}', '\${3:2026-01-01}', '\${4:2026-12-31}')->get()", $m,
                'PpQuery->whereBetween(col, from, to) · whereNull(col) · whereNotNull(col) · whereRaw(sql, params)',
                'More where helpers — range, null checks, and a bound raw escape hatch.',
                'pp() · data',
                "\$q1 = pp()->table('orders')->whereBetween('created', '2026-01-01', '2026-03-31')->count();"),
            self::row('pp()->table->update', "pp()->table('\${1:orders}')->where('\${2:status}', '\${3:open}')->update(['\${4:status}' => '\${5:closed}'])", $m,
                'PpQuery->update(data): int · delete(): int   ·   bulk write terminals',
                "UPDATE / DELETE the matching rows and return the affected count. Permission-gated ('edit'/'delete') and row-security scoped. REFUSES an unfiltered write — you must ->where(...) first (or have a row-level rule), so a stray call can never touch the whole table.",
                'pp() · data',
                "\$n = pp()->table('orders')->where('status', 'pending')->where('created', '<', \$cutoff)->update(['status' => 'expired']);\npp()->toast('info', \$n . ' order(s) expired')->reloadGrid();"),
            self::row('pp()->table->delete', "pp()->table('\${1:logs}')->where('\${2:created}', '<', \${3:\$cutoff})->delete()", $m,
                'PpQuery->delete(): int   ·   permission-gated, filter-required',
                "DELETE the matching rows, returns how many. Needs 'delete' rights and a ->where(...) filter (refuses an unfiltered delete).",
                'pp() · data',
                "\$gone = pp()->table('sessions')->where('expires', '<', now())->delete();"),
            self::row('pp()->upsert', "pp()->upsert('\${1:settings}', ['\${2:user_id}' => \${3:\$uid}, '\${4:theme}' => '\${5:dark}'], ['\${6:user_id}'])", $m,
                'pp()->upsert(table, data, conflictCols=[\'id\'][, updateCols]): int   ·   insert-or-update',
                "Insert a row, or update it if one already exists on the conflict column(s) — driver-aware (MySQL ON DUPLICATE KEY, Postgres/SQLite ON CONFLICT, a portable fallback elsewhere). Needs 'add' + 'edit' rights. By default all non-conflict columns are updated.",
                'pp() · data',
                "pp()->upsert('settings', ['user_id' => pp()->userId(), 'theme' => 'dark'], ['user_id']);"),
            self::row('pp()->table->increment', "pp()->table('\${1:posts}')->where('\${2:id}', \${3:\$id})->increment('\${4:views}')", $m,
                'PpQuery->increment(col[, amount][, extra]) · decrement(...): int   ·   atomic counter',
                "Atomically add to (or subtract from) a numeric column — SET col = col ± ? — on the matching rows. Optional 2nd arg is the step (default 1); optional 3rd array sets other columns in the same UPDATE. Permission-gated ('edit') and filter-required.",
                'pp() · data',
                "pp()->table('posts')->where('id', \$id)->increment('views');\npp()->table('wallets')->where('user_id', \$uid)->decrement('balance', 50, ['updated' => now()]);"),
            self::row('pp()->transaction', "pp()->transaction(function (\$pp) {\n    \${1:}\n})", $m,
                'pp()->transaction(fn): mixed   ·   also begin() / commit() / rollback()',
                'Run work in a DB transaction — commits on success, rolls back (and re-throws) on any exception. Nesting is safe (a nested call joins the outer transaction).',
                'pp() · data',
                "pp()->transaction(function (\$pp) {\n    \$id = \$pp->insert('orders', ['customer_id' => \$pp->param('cid')]);\n    \$pp->insert('order_items', ['order_id' => \$id, 'sku' => 'A1']);\n});"),
            self::row('pp()->trySavepoint', "pp()->trySavepoint('\${1:row}', function (\$pp) {\n    \${2:}\n})", $m,
                'pp()->trySavepoint(name, fn): bool   ·   savepoint(name) · rollbackTo(name) · releaseSavepoint(name)',
                "Partial rollback inside a transaction: run fn in a savepoint; on failure roll back just that savepoint and CONTINUE (returns false) — the 'skip the bad row, keep the rest' pattern. The manual trio is there for full control. Must be inside pp()->transaction().",
                'pp() · data',
                "pp()->transaction(function (\$pp) {\n    foreach (\$pp->param('rows') as \$r) {\n        \$pp->trySavepoint('row', fn(\$pp) => \$pp->insert('items', \$r));\n    }\n});"),

            // ── pp() — more browser commands ───────────────────────────────
            self::row('pp()->openUrl', "pp()->openUrl('\${1:https://…}', \${2:true})", $m,
                'pp()->openUrl(url[, newTab]) · download(url) · redirect(url)',
                'Navigate, open a new tab, or trigger a file download from the server.',
                'pp() · commands',
                "pp()->download(pp()->url('api/pdf.php?id=' . pp()->id()));"),
            self::row('pp()->scrollTo', "pp()->scrollTo('\${1:field}')", $m,
                'pp()->scrollTo(field) · highlight(field) · focus(field)',
                'Bring a field into view, flash it to draw the eye, or focus it — from PHP.',
                'pp() · commands',
                "pp()->message('email', 'Check this', 'error')->scrollTo('email')->highlight('email');"),
            self::row('pp()->emit', "pp()->emit('\${1:name}', \${2:\$detail})", $m,
                "pp()->emit(name, detail) · call(fn, args) · log(value) · closeDialog()",
                "Extensible escape hatches: fire a DOM event the author listens for (document.addEventListener('pp:name', …)), call a global JS function by name, log to the console, or close the dialog.",
                'pp() · commands',
                "pp()->emit('order-approved', ['id' => pp()->id()]);   // author JS reacts however it likes"),
        ];
    }

    /**
     * A compact symbol index for the editor's client-side checker: the bare
     * callable labels for each language (no signatures/snippets). The JS list
     * is what the pp.* "did you mean" spell-check runs against in the browser.
     *
     * @return array{php: array<int,string>, js: array<int,string>}
     */
    public static function symbols(): array
    {
        $labels = static function (array $rows): array {
            $out = [];
            foreach ($rows as $r) {
                $l = (string) ($r['label'] ?? '');
                if ($l !== '' && ($r['kind'] ?? '') !== self::KIND_SNIPPET) {
                    $out[] = $l;
                }
            }
            return array_values(array_unique($out));
        };
        return ['php' => $labels(self::php()), 'js' => $labels(self::js())];
    }

    /**
     * Per-symbol call arity parsed from each signature, for the argument-count
     * check. label => [min, max] where max is -1 for a variadic (…) function.
     *
     * @return array{php: array<string,array{0:int,1:int}>, js: array<string,array{0:int,1:int}>}
     */
    public static function signatures(): array
    {
        $build = static function (array $rows): array {
            $out = [];
            foreach ($rows as $r) {
                if (!in_array($r['kind'] ?? '', [self::KIND_METHOD, self::KIND_FUNCTION], true)) {
                    continue;
                }
                $label = (string) ($r['label'] ?? '');
                $arity = self::arityFromSignature((string) ($r['detail'] ?? ''));
                if ($label !== '' && $arity !== null) {
                    $out[$label] = $arity;
                }
            }
            return $out;
        };
        return ['php' => $build(self::php()), 'js' => $build(self::js())];
    }

    /** @return array{0:int,1:int}|null [min, max] — max -1 = variadic. */
    private static function arityFromSignature(string $sig): ?array
    {
        $open = strpos($sig, '(');
        if ($open === false) {
            return null;    // a property / non-callable
        }
        $depth = 0;
        $end = -1;
        for ($i = $open, $L = strlen($sig); $i < $L; $i++) {
            if ($sig[$i] === '(') {
                $depth++;
            } elseif ($sig[$i] === ')') {
                if (--$depth === 0) { $end = $i; break; }
            }
        }
        if ($end < 0) {
            return null;
        }
        $inner = trim(substr($sig, $open + 1, $end - $open - 1));
        if ($inner === '') {
            return [0, 0];
        }
        $min = 0;
        $max = 0;
        $variadic = false;
        $seenOptional = false;
        foreach (self::splitTopLevel($inner) as $param) {
            $param = trim($param);
            if ($param === '') {
                continue;
            }
            $max++;
            $isVariadic = str_contains($param, '...');
            // Optional: a PHP default ($x = …), a JS optional (name?: type), or variadic.
            $isOptional = $isVariadic
                || str_contains($param, '=')
                || preg_match('/\?\s*:/', $param) === 1;
            if ($isVariadic) {
                $variadic = true;
            }
            if (!$isOptional && !$seenOptional) {
                $min++;
            } else {
                $seenOptional = true;
            }
        }
        return [$min, $variadic ? -1 : $max];
    }

    /** Split on top-level commas, respecting ()/[]/{} nesting. @return array<int,string> */
    private static function splitTopLevel(string $s): array
    {
        $out = [];
        $buf = '';
        $depth = 0;
        for ($i = 0, $L = strlen($s); $i < $L; $i++) {
            $c = $s[$i];
            if (str_contains('([{', $c)) {
                $depth++;
            } elseif (str_contains(')]}', $c)) {
                $depth--;
            }
            if ($c === ',' && $depth === 0) {
                $out[] = $buf;
                $buf = '';
            } else {
                $buf .= $c;
            }
        }
        if (trim($buf) !== '') {
            $out[] = $buf;
        }
        return $out;
    }

    /**
     * The full JavaScript (client event) API — the window.pp object.
     *
     * @return array<int, array<string, string>>
     */
    public static function js(): array
    {
        $m = self::KIND_METHOD;
        $p = self::KIND_PROPERTY;
        $k = self::KIND_KEYWORD;

        return [
            // ── Page & record context ──────────────────────────────────────
            self::row('pp.page', 'pp.page', $p,
                'pp.page: { table, action, id }',
                'The current page context: table name, action (list|add|edit|view), and the record id (from the URL).',
                'Page & record',
                "if (pp.page.action === 'edit') { /* … */ }"),
            self::row('pp.record', 'pp.record()', $m,
                'pp.record(): object|null',
                'The current record on a view/edit page (set server-side with pp_set_proxy("record", $row) — automatic on generated pages).',
                'Page & record'),

            // ── Server bridge ──────────────────────────────────────────────
            self::row('pp.run', "await pp.run('\${1:action}', { \${2:id: pp.page.id} })", $m,
                'pp.run(action: string, params?: object): Promise<any>',
                'Call a named server action — runs PHP function action_<name>($params) from Custom Functions → PHP — and resolves to its return value.',
                'Server bridge',
                "const total = await pp.run('recalcTotal', { orderId: pp.page.id });"),
            self::row('pp.getProxy', "pp.getProxy('\${1:key}', \${2:null})", $m,
                'pp.getProxy(key: string, def?: any): any',
                'Read a value passed from PHP via pp_set_proxy("key", $value).',
                'Server bridge',
                "const msg = pp.getProxy('error', ''); if (msg) pp.dialog.toast('error', msg);"),
            self::row('pp.proxy', 'pp.proxy', $p,
                'pp.proxy: object',
                'The whole object of values passed from PHP events (read pp.proxy.key).',
                'Server bridge'),

            // ── Real-time (live push) ──────────────────────────────────────
            self::row('pp.realtime.subscribe', "pp.realtime.subscribe('\${1:channel}')", $m,
                'pp.realtime.subscribe(channel: string): void',
                'Start receiving live messages on a channel (server→client push over SSE, with automatic polling fallback + reconnect). Pair with pp.realtime.on to handle them.',
                'Real-time',
                "pp.realtime.subscribe('orders');"),
            self::row('pp.realtime.on', "pp.realtime.on('\${1:event}', function (data, meta) {\n    \${2:// handle it}\n})", $m,
                'pp.realtime.on(event: string, handler: function): void',
                'Handle a live message. The handler gets (data, meta) where meta is { channel, event, id }. Runs whenever a matching message arrives on a subscribed channel.',
                'Real-time',
                "pp.realtime.on('created', function (data, meta) {\n    pp.dialog.toast('info', 'New order ' + data.ref);\n});"),
            self::row('pp.realtime.off', "pp.realtime.off('\${1:event}', \${2:handler})", $m,
                'pp.realtime.off(event: string, handler: function): void',
                'Stop handling an event (removes a handler previously added with pp.realtime.on).',
                'Real-time'),
            self::row('pp.realtime.unsubscribe', "pp.realtime.unsubscribe('\${1:channel}')", $m,
                'pp.realtime.unsubscribe(channel: string): void',
                'Stop receiving messages on a channel.',
                'Real-time'),
            self::row('pp.realtime.publish', "pp.realtime.publish('\${1:channel}', '\${2:event}', \${3:data})", $m,
                'pp.realtime.publish(channel: string, event: string, data: any): Promise<any>',
                'Send a message FROM the browser to a channel (bidirectional). The server validates it through the onClientPublish event, then broadcasts it to everyone — great for chat, cursors, or typing indicators.',
                'Real-time',
                "// Send a chat line\npp.realtime.publish('room.' + roomId, 'chat', { text: input.value });"),
            self::row('pp.realtime.presence', "pp.realtime.presence('\${1:channel}')", $m,
                'pp.realtime.presence(channel: string): void',
                'Join a channel’s presence set (“who is here”). Fires the onPresenceChange event as people come and go; the current members are also available from pp.realtime.whoIsHere().',
                'Real-time',
                "pp.realtime.presence('room.' + roomId);"),
            self::row('pp.realtime.whoIsHere', "pp.realtime.whoIsHere('\${1:channel}')", $m,
                'pp.realtime.whoIsHere(channel: string): array',
                'The members currently present on a channel — an array of { member, name }. Populated once you call pp.realtime.presence(channel).',
                'Real-time',
                "var here = pp.realtime.whoIsHere('room.42'); // [{member, name}, …]"),
            self::row('pp.realtime.status', 'pp.realtime.status()', $m,
                'pp.realtime.status(): string',
                'The live-connection state: "open" (SSE), "polling" (fallback), "connecting", or "closed".',
                'Real-time'),

            // ── HTTP ───────────────────────────────────────────────────────
            self::row('pp.ajax.get', "await pp.ajax.get('\${1:/api/…}')", $m,
                'pp.ajax.get(url: string): Promise<any>',
                'GET a URL and resolve the parsed JSON response.',
                'HTTP'),
            self::row('pp.ajax.post', "await pp.ajax.post('\${1:/api/…}', { \${2:} })", $m,
                'pp.ajax.post(url: string, data: object): Promise<any>',
                'POST JSON to a URL and resolve the parsed JSON response.',
                'HTTP'),

            // ── Dialogs ────────────────────────────────────────────────────
            self::row('pp.dialog.toast', "pp.dialog.toast('\${1:success}', '\${2:Saved}')", $m,
                'pp.dialog.toast(type: string, msg: string): void',
                'Show a corner toast (type: success|error|info|warning). Uses SweetAlert2 when present.',
                'Dialogs',
                "pp.dialog.toast('success', 'Record updated');"),
            self::row('pp.dialog.confirm', "if (await pp.dialog.confirm('\${1:Are you sure?}', '\${2:}')) {\n    \${0}\n}", $m,
                'pp.dialog.confirm(title: string, text?: string): Promise<boolean>',
                'Ask the user to confirm; resolves true when they accept. Await it before a destructive action.',
                'Dialogs',
                "if (await pp.dialog.confirm('Delete this?', 'This cannot be undone')) { /* … */ }"),
            self::row('pp.dialog.alert', "pp.dialog.alert('\${1:Title}', '\${2:Message}')", $m,
                'pp.dialog.alert(title: string, text?: string): void',
                'Show a simple alert dialog.',
                'Dialogs'),

            // ── Form fields (add / edit) ───────────────────────────────────
            self::row('pp.val', "pp.val('\${1:field}')", $m,
                'pp.val(name: string, value?: any): any',
                'Read a field value; pass a second argument to set it. Shorthand for pp.form.get / pp.form.set.',
                'Form fields',
                "const qty = pp.val('quantity'); pp.val('total', qty * price);"),
            self::row('pp.form.get', "pp.form.get('\${1:field}')", $m,
                'pp.form.get(name: string): any',
                'Read a field’s current value (checkbox → boolean).',
                'Form fields'),
            self::row('pp.form.set', "pp.form.set('\${1:field}', \${2:value})", $m,
                'pp.form.set(name: string, value: any): boolean',
                'Set a field value and fire its change event (so dependent logic runs).',
                'Form fields'),
            self::row('pp.form.show', "pp.form.show('\${1:field}')", $m,
                'pp.form.show(name: string): pp.form',
                'Show a field row. Chainable.',
                'Form fields'),
            self::row('pp.form.hide', "pp.form.hide('\${1:field}')", $m,
                'pp.form.hide(name: string): pp.form',
                'Hide a field row. Chainable.',
                'Form fields',
                "pp.val('type') === 'company' ? pp.form.show('vat') : pp.form.hide('vat');"),
            self::row('pp.form.enable', "pp.form.enable('\${1:field}')", $m,
                'pp.form.enable(name: string): pp.form',
                'Enable a field. Chainable (also disable()).',
                'Form fields'),
            self::row('pp.form.disable', "pp.form.disable('\${1:field}')", $m,
                'pp.form.disable(name: string): pp.form',
                'Disable a field. Chainable.',
                'Form fields'),
            self::row('pp.form.readonly', "pp.form.readonly('\${1:field}', \${2:true})", $m,
                'pp.form.readonly(name: string, on?: boolean): pp.form',
                'Make a field read-only (on defaults to true).',
                'Form fields'),
            self::row('pp.form.required', "pp.form.required('\${1:field}', \${2:true})", $m,
                'pp.form.required(name: string, on?: boolean): pp.form',
                'Mark a field required / not required.',
                'Form fields'),
            self::row('pp.form.focus', "pp.form.focus('\${1:field}')", $m,
                'pp.form.focus(name: string): pp.form',
                'Move keyboard focus to a field.',
                'Form fields'),
            self::row('pp.form.message', "pp.form.message('\${1:field}', '\${2:message}')", $m,
                'pp.form.message(name: string, msg: string): pp.form',
                'Show an inline message under a field (pass "" to clear). Great for live validation.',
                'Form fields',
                "if (!ok) pp.form.message('email', 'Already taken'); else pp.form.message('email', '');"),
            self::row('pp.form.data', 'pp.form.data()', $m,
                'pp.form.data(): object',
                'Every current form value as an object of name → value.',
                'Form fields'),

            // ── List grid ──────────────────────────────────────────────────
            self::row('pp.grid.selectedIds', 'pp.grid.selectedIds()', $m,
                'pp.grid.selectedIds(): string[]',
                'IDs of the checked rows on a list page.',
                'List grid'),
            self::row('pp.grid.selectedRecords', 'pp.grid.selectedRecords()', $m,
                'pp.grid.selectedRecords(): object[]',
                'Full record objects for the checked rows.',
                'List grid'),
            self::row('pp.grid.allRecords', 'pp.grid.allRecords()', $m,
                'pp.grid.allRecords(): object[]',
                'Record objects for every visible row on the current page.',
                'List grid'),
            self::row('pp.grid.reload', 'pp.grid.reload()', $m,
                'pp.grid.reload(): void',
                'Reload the list page (e.g. after a bulk action).',
                'List grid'),

            // ── Toolbar / row buttons ──────────────────────────────────────
            self::row('pp.button.getCurrentRecord', 'pp.button.getCurrentRecord()', $m,
                'pp.button.getCurrentRecord(): object|null',
                'The record for the current single-record (view/edit) page — for a custom button.',
                'Buttons'),
            self::row('pp.button.getSelectedRecords', 'pp.button.getSelectedRecords()', $m,
                'pp.button.getSelectedRecords(): object[]',
                'Records of the checked rows — for a custom toolbar button.',
                'Buttons'),
            self::row('pp.button.getSelectedIds', 'pp.button.getSelectedIds()', $m,
                'pp.button.getSelectedIds(): string[]',
                'IDs of the checked rows — for a custom toolbar button.',
                'Buttons'),

            // ── Browser / DOM ──────────────────────────────────────────────
            self::row('document.querySelector', "document.querySelector('\${1:selector}')", $m,
                'document.querySelector(selector: string): Element|null',
                'Find the first element matching a CSS selector (standard DOM).',
                'Browser'),
            self::row('console.log', "console.log(\${1:})", $m,
                'console.log(...args): void',
                'Write to the browser developer console for debugging.',
                'Browser'),
            self::row('return false', 'return false;', $k,
                'return false;',
                'Cancel the default action when the event supports it (e.g. beforeFormSubmit stops the submit).',
                'Control flow'),

            // ── Runner — page controls (add/edit/view) ─────────────────────
            self::row('Runner.getControl', "Runner.getControl('\${1:field}')", $m,
                'Runner.getControl(field: string): Control   ·   or getControl(pageId, field)',
                'Reach a field on the page by its database name — read/write its value, show/hide it, wire events. Chainable. On View/print/pdf pages it reads the displayed value.',
                'Runner · controls',
                "var c = Runner.getControl('first_name'); c.setValue('Ada').focus();"),
            self::row('Runner.getControlValue', "Runner.getControlValue('\${1:field}')", $m,
                'Runner.getControlValue(field: string): any',
                "A field's current value, without an intermediate variable.",
                'Runner · controls',
                "params.email = Runner.getControlValue('email');"),
            self::row('Runner.setControlValue', "Runner.setControlValue('\${1:field}', \${2:value})", $m,
                'Runner.setControlValue(field: string, value: any): Control',
                'Set one field. Fires change, so plugins react.',
                'Runner · controls',
                "Runner.setControlValue('full_name', result.name);"),
            self::row('Runner.getData', 'Runner.getData()', $m,
                'Runner.getData(): object',
                'Every field on the page as one object: { first_name:"Ada", email:"…" }.',
                'Runner · controls',
                'var d = Runner.getData(); if (!d.email) { /* … */ }'),
            self::row('Runner.setData', "Runner.setData({ \${1:field}: \${2:value} })", $m,
                'Runner.setData(values: object): Runner',
                'Set many fields at once.',
                'Runner · controls'),
            self::row('control.getValue', "Runner.getControl('\${1:field}').getValue()", $m,
                'control.getValue(): any',
                "Read a control's value (text, select, checkbox, radio, plugin).",
                'Runner · control methods'),
            self::row('control.setValue', "Runner.getControl('\${1:field}').setValue(\${2:value})", $m,
                'control.setValue(v): control',
                "Write a control's value (fires change).",
                'Runner · control methods'),
            self::row('control.hide', "Runner.getControl('\${1:field}').hide()", $m,
                'control.hide() / show() / visible(bool): control',
                'Hide or show the whole field (label included).',
                'Runner · control methods'),
            self::row('control.disable', "Runner.getControl('\${1:field}').disable()", $m,
                'control.disable() / enable() / readonly(b) / required(b): control',
                'Toggle the input state.',
                'Runner · control methods'),
            self::row('control.setMessage', "Runner.getControl('\${1:field}').setMessage('\${2:message}')", $m,
                'control.setMessage(msg[, "success"]) / clearMessage(): control',
                'Show or clear an inline message under the field.',
                'Runner · control methods',
                "Runner.getControl('email').setMessage('Enter a valid email').focus();"),
            self::row('control.on', "Runner.getControl('\${1:field}').on('\${2:change}', function () { \${3:} })", $m,
                'control.on(event, fn) / onChange(fn): control',
                'Attach an event listener to the control.',
                'Runner · control methods'),
            self::row('control.addStyle', "Runner.getControl('\${1:field}').addStyle('\${2:color: red}')", $m,
                'control.addStyle(css) / setStyle(css): control',
                'Add inline CSS to a control (PHPRunner-style). Great for highlighting a field on the View page.',
                'Runner · control methods',
                "Runner.getControl('status').addStyle('color:#dc2626;font-weight:700');"),

            // ── Runner — page buttons ──────────────────────────────────────
            self::row('Runner.button', "Runner.button('\${1:add}')", $m,
                "Runner.button(name): Button   ·   .hide() .show() .disable() .enable() .setText() .addStyle()",
                "A page button by name — acts on every match. Names: add · save · saveNew · saveClose · cancel · back · reset · editSelected · deleteSelected · exportSelected · edit · delete · view · copy.",
                'Runner · buttons',
                "Runner.button('save').disable();"),
            self::row('Runner.hideButton', "Runner.hideButton('\${1:add}')", $m,
                'Runner.hideButton(name) / showButton(name): Button',
                'Hide or show a page button by name.',
                'Runner · buttons',
                "Runner.hideButton('deleteSelected');   // hide bulk delete"),
            self::row('Runner.disableButton', "Runner.disableButton('\${1:save}')", $m,
                'Runner.disableButton(name) / enableButton(name): Button',
                'Disable or enable a page button by name.',
                'Runner · buttons',
                "Runner.disableButton('save');"),
            self::row('detail.button', "Runner.detail('\${1:detail_table}').button('add').hide()", $m,
                'detailGrid.button(name): Button   ·   scoped to that child grid',
                "Reach a button inside a Master/Detail child grid — e.g. hide its Add button when a record shouldn't get new children.",
                'Runner · buttons',
                "Runner.detail('order_items').button('add').hide();"),

            // ── Runner — list / grid + Master/Detail ───────────────────────
            self::row('Runner.getSelectedKeys', 'Runner.getSelectedKeys()', $m,
                'Runner.getSelectedKeys(): string[]',
                'Ids of the ticked rows on a list page.',
                'Runner · grid',
                "params.ids = Runner.getSelectedKeys();"),
            self::row('Runner.getSelectedRecords', 'Runner.getSelectedRecords()', $m,
                'Runner.getSelectedRecords(): object[]',
                "The ticked rows' records: [{ id, name, … }].",
                'Runner · grid'),
            self::row('Runner.getAllRecords', 'Runner.getAllRecords()', $m,
                'Runner.getAllRecords(): object[]',
                'Every visible row/card record on the page.',
                'Runner · grid'),
            self::row('Runner.getRow', "Runner.getRow(\${1:id})", $m,
                'Runner.getRow(id): Row   ·   row.get("field"), row.text("field"), row.select(), row.highlight()',
                'One grid/card row by id, as a row object.',
                'Runner · grid'),
            self::row('Runner.getCellValue', "Runner.getCellValue(\${1:id}, '\${2:field}')", $m,
                'Runner.getCellValue(id, field): any',
                "One cell's value for a given row.",
                'Runner · grid'),
            self::row('Runner.onSelectionChange', 'Runner.onSelectionChange(function (keys, records) { \${1:} })', $m,
                'Runner.onSelectionChange(fn): void',
                'Fires whenever the row selection changes.',
                'Runner · grid'),
            self::row('Runner.reloadGrid', 'Runner.reloadGrid()', $m,
                'Runner.reloadGrid(): void',
                'Refresh the list after a server change.',
                'Runner · grid'),
            self::row('Runner.detail', "Runner.detail('\${1:detail_table}')", $m,
                'Runner.detail(table): Grid   ·   Master/Detail child grid on a view/edit page',
                'A detail (child) grid as its own grid object — .getAllRecords(), .getSelectedKeys(), .getCellValue(id, field), .count(). Two-way with the master.',
                'Runner · master/detail',
                "var items = Runner.detail('order_items').getAllRecords();"),
            self::row('Runner.details', 'Runner.details()', $m,
                'Runner.details(): [{ table, fk, grid }]',
                'Every detail grid on the page.',
                'Runner · master/detail'),
            self::row('Runner.getDetailsPages', "Runner.getDetailsPages()['\${1:order_items}'].hideItem('add')", $m,
                'Runner.getDetailsPages(): { table: grid }   ·   PHPRunner-style keyed map',
                "Detail grids as a map keyed by child table. Each value is a full grid object, so you can hide/disable its buttons or read/write its rows.",
                'Runner · master/detail',
                "Runner.getDetailsPages()['order_items'].hideItem('add');"),
            self::row('detail.hideItem', "Runner.detail('\${1:order_items}').hideItem('\${2:add}')", $m,
                "grid.hideItem(name) / showItem / disableItem / enableItem",
                "PHPRunner-style shortcut to hide/disable a detail-grid button (add, delete, edit…) by name.",
                'Runner · master/detail',
                "Runner.detail('order_items').hideItem('add');"),
            self::row('detail.setCellValue', "Runner.detail('\${1:detail_table}').setCellValue(\${2:id}, '\${3:field}', \${4:value})", $m,
                'grid.setCellValue(id, field, value): Promise   ·   also on the main grid',
                'Write one child (or list) cell in place AND persist it to the database via the permission-gated save endpoint. Two-way editing.',
                'Runner · master/detail',
                "await Runner.detail('order_items').setCellValue(itemId, 'qty', 5);"),
            self::row('row.setCell', "Runner.detail('\${1:detail_table}').getRow(\${2:id}).setCell('\${3:field}', \${4:value})", $m,
                'row.setCell(field, value): Promise   ·   row.setValues(patch) · row.save()',
                "Edit a row's cell(s) and save. `setValues({a,b})` saves several at once; `save()` persists the whole row.",
                'Runner · master/detail',
                "await Runner.detail('order_items').getRow(id).setValues({ qty: 3, price: 9.5 });"),
            self::row('grid.editCell', "Runner.detail('\${1:detail_table}').editCell(\${2:id}, '\${3:field}')", $m,
                'grid.editCell(id, field)   ·   also Runner.editCell(id, field) on the main grid',
                "Open a cell's built-in inline editor and focus it for the user to type — reuses the grid's own mount + autosave-on-blur/Enter. Great for “add a row, then jump the cursor into the first cell”.",
                'Runner · master/detail',
                "Runner.editCell(rowId, 'note');   // focus the cell so the user can type"),

            // ── Runner — reactive helpers (live calculated fields) ─────────
            self::row('Runner.compute', "Runner.compute('\${1:line_total}', ['\${2:qty}', '\${3:price}'], function (\${4:q}, \${5:p}) { return \${6:q * p}; })", $m,
                'Runner.compute(target, [deps], fn): void   ·   recomputes live',
                "Keep a field in sync with others — the “calculate on the fly” recipe in one line. `fn` gets each dependency's value and returns the target's value; it re-runs whenever any dependency changes.",
                'Runner · controls',
                "Runner.compute('line_total', ['qty','price'], (q,p) => (+q)*(+p));"),
            self::row('Runner.link', "Runner.link('\${1:name}', '\${2:slug}', function (v) { return \${3:v}; })", $m,
                'Runner.link(src, target[, transform]): void',
                'Mirror one field into another, optionally transformed. Live as the source changes.',
                'Runner · controls',
                "Runner.link('name','slug', v => v.toLowerCase().replace(/\\s+/g,'-'));"),
            self::row('control.validate', "Runner.getControl('\${1:field}').validate({ \${2:required}: true })", $m,
                'control.validate(rules): bool   ·   invalid(): bool · reset() · isReadonly()',
                "Validate a field against simple rules ({required, minlength, email, pattern}); shows/clears the inline message and records the state. `reset()` restores the load-time value; `isReadonly()` reads the state.",
                'Runner · control methods',
                "if (!Runner.getControl('email').validate({ email: true })) return false;"),
            self::row('control.watch', "Runner.getControl('\${1:field}').watch(function (value) { \${2:} })", $m,
                'control.watch(fn): control   ·   fires fn(value, control) on every change',
                'React to every value change (input & change) — the reactive backbone behind compute()/link().',
                'Runner · control methods',
                "Runner.getControl('qty').watch(v => console.log('qty is now', v));"),

            // ── Runner — page elements, tabs, unsaved-changes ──────────────
            self::row('Runner.item', "Runner.item('\${1:element_id}')", $m,
                'Runner.item(id): Item   ·   .show() .hide() .toggle(on) .text() .html() .addClass() .on()',
                "Reach any element you gave an id (PHPRunner's findItem/toggleItem) — a heading, panel or custom block — and show/hide/retext it.",
                'Runner · page',
                "Runner.item('legal_notice').toggle(Runner.getControlValue('country') === 'US');"),
            self::row('Runner.getTabs', "Runner.getTabs(\${1:0}).activate(\${2:1})", $m,
                'Runner.getTabs(n): Tabs   ·   .activate(i) .activateByText() .count() .showTab() .hideTab()',
                'Drive tab groups (Bootstrap nav-tabs / [data-pp-tabs] / [role=tablist]) on Add/Edit/View pages.',
                'Runner · page',
                "Runner.getTabs().activate(2);   // open the third tab"),
            self::row('Runner.setPageModified', 'Runner.setPageModified(true)', $m,
                'Runner.setPageModified(bool): void',
                "Arm a “you have unsaved changes” prompt when the user tries to leave the page; call with false after saving to disarm.",
                'Runner · page',
                "Runner.getControl('notes').watch(() => Runner.setPageModified(true));"),

            // ── Runner — grid data conveniences & selection ────────────────
            self::row('grid.sum', "Runner.detail('\${1:order_items}').sum('\${2:amount}')", $m,
                'grid.sum(field) / avg(field): number   ·   also on the main grid via Runner.grid()',
                'Total (or average) a numeric column across every row — instant order totals, no loop.',
                'Runner · grid',
                "Runner.setControlValue('total', Runner.detail('order_items').sum('line_total'));"),
            self::row('grid.filter', "Runner.detail('\${1:order_items}').filter(function (r) { return \${2:r.status === 'open'}; })", $m,
                'grid.filter(fn) / find(fn): records   ·   each(fn) · map(fn) · pluck(field)',
                'Query the rows as plain records — filter, find, map, pluck a column, or iterate.',
                'Runner · grid',
                "var open = Runner.grid().filter(r => r.status === 'open');"),
            self::row('grid.selectWhere', "Runner.detail('\${1:order_items}').selectWhere(function (r) { return \${2:r.status === 'open'}; })", $m,
                'grid.selectWhere(fn) / selectAll() / clearSelection(): grid',
                'Tick rows by predicate, tick all, or clear the selection programmatically.',
                'Runner · grid',
                "Runner.grid().selectWhere(r => r.overdue);   // tick every overdue row"),
            self::row('row.setMessage', "Runner.detail('\${1:order_items}').getRow(\${2:id}).setMessage('\${3:field}', '\${4:message}')", $m,
                'row.setMessage(field, msg[, tone]) / getMessage(field) / clearMessage(field)',
                'Print an inline message inside one of a row\'s cells — great for per-row validation feedback.',
                'Runner · grid',
                "row.setMessage('qty', 'Above stock', 'error');"),

            // ── Dialog — multi-field input ─────────────────────────────────
            self::row('pp.dialog.form', "await pp.dialog.form({ title: '\${1:Title}', fields: [{ name: '\${2:email}' }] })", $m,
                'pp.dialog.form({title, header?, fields:[{name,label?,type?,value?,options?}], ok?, cancel?}): Promise<obj|null>',
                'Collect several values in one dialog (PHPRunner-style) — text/textarea/number/email/date/checkbox/select. Resolves to { name: value } on OK, or null on Cancel. Ideal for Custom Buttons.',
                'Runner · dialog',
                "var v = await pp.dialog.form({ title:'Email results', fields:[{name:'email',type:'email'},{name:'subject'}] });\nif (v) params.email = v.email;"),

            // ── Runner — search (list quick-search + WHERE tabs) ───────────
            self::row('Runner.search', 'Runner.search()', $m,
                'Runner.search(): Search   ·   getValue() setValue() submit() clear() isSearched() · also getSearchController()',
                "The list page's Quick Search as an object. submit([text]) runs a server search; clear() returns to the full list; isSearched() tells you if it's filtered.",
                'Runner · search',
                "Runner.search().submit('ada');   // search the list for 'ada'"),
            self::row('search.filterRows', "Runner.search().filterRows('\${1:text}')", $m,
                'search.filterRows(text[, fields]): Search   ·   clearFilter()',
                "Instant client-side filter — hide rows that don't match, no server round-trip. Pass a fields array to match only those columns. Great for a live search-as-you-type box.",
                'Runner · search',
                "Runner.getControl('quickfind').watch(v => Runner.search().filterRows(v, ['name','email']));"),
            self::row('search.whereTab', "Runner.search().whereTab(\${1:1})", $m,
                'search.whereTab(i) / allData() / whereTabs(): Search',
                'Switch the list\'s Additional WHERE tab by index (whereTab(-1) or allData() = All data); whereTabs() lists them.',
                'Runner · search',
                "Runner.search().whereTab(0);   // open the first filter tab"),
        ];
    }

    /**
     * Ready-to-use PHP snippets — whole patterns an author can drop in and
     * fill the blanks (tab-stops ${1:…}, final caret $0).
     *
     * @return array<int, array<string, string>>
     */
    public static function phpSnippets(): array
    {
        $s = self::KIND_SNIPPET;

        return [
            self::row('Guard: validate & cancel', <<<'PHP'
if (${1:$data['price']} < 0) {
    pp_set_proxy('error', '${2:Price cannot be negative.}');
    return false;
}
$0
PHP, $s, 'Reject invalid input and stop the save', 'In a beforeAdd/beforeEdit event, validate a value and cancel with a message the page can show.', 'Snippets'),

            self::row('Prevent duplicate', <<<'PHP'
if (dbCount('SELECT COUNT(*) FROM ${1:users} WHERE ${2:email} = ?', [${3:$data['email']}]) > 0) {
    pp_set_proxy('error', '${4:That value already exists.}');
    return false;
}
$0
PHP, $s, 'Block a save when a value already exists', 'Count existing rows before insert/update and cancel on a duplicate.', 'Snippets'),

            self::row('Look up a related value', <<<'PHP'
$${1:name} = dbValue('SELECT ${2:name} FROM ${3:categories} WHERE id = ?', [${4:$data['category_id']}], ${5:''});
$0
PHP, $s, 'Fetch one value from another table', 'Read a single column from a related row into a variable.', 'Snippets'),

            self::row('Loop related rows', <<<'PHP'
foreach (dbAll('SELECT * FROM ${1:items} WHERE ${2:order_id} = ?', [${3:$data['id']}]) as $${4:item}) {
    ${0}
}
PHP, $s, 'Iterate every matching row', 'Fetch and loop over related rows.', 'Snippets'),

            self::row('Send a welcome email', <<<'PHP'
pp_send_email(
    ${1:$data['email']},
    '${2:Welcome!}',
    '${3:<p>Thanks for joining us.</p>}'
);
$0
PHP, $s, 'Email the record after it is saved', 'Best in an afterAdd event once the record exists.', 'Snippets'),

            self::row('Notify a user', <<<'PHP'
Notification::Add([
    'user_id' => ${1:$ownerId},
    'title'   => '${2:Heads up}',
    'message' => '${3:Something happened}',
    'type'    => '${4:info}',
    'url'     => '${5:/path}',
]);
$0
PHP, $s, 'Add an in-app notification (bell)', 'Ping one user. type = info | success | warning | error picks the colour + icon.', 'Snippets'),

            self::row('Notify a role', <<<'PHP'
Notification::AddForRole('${1:admin}', [
    'title'   => '${2:New record}',
    'message' => '${3:A record was just added}',
    'type'    => '${4:success}',
]);
$0
PHP, $s, 'Notify everyone with a role', 'Broadcast to every user whose role matches (e.g. admin, manager).', 'Snippets'),

            self::row('Notify everyone', <<<'PHP'
Notification::AddForEveryone([
    'title'   => '${1:Announcement}',
    'message' => '${2:Message for all users}',
    'type'    => '${3:info}',
]);
$0
PHP, $s, 'Broadcast to all users', 'One notification delivered to every active user.', 'Snippets'),

            self::row('Notify the record owner', <<<'PHP'
Notification::AddForRecordOwner($data, '${1:created_by}', [
    'title'   => '${2:Update}',
    'message' => '${3:Your record was updated}',
    'type'    => '${4:success}',
]);
$0
PHP, $s, 'Notify whoever created the record', 'Reads the owner id from the given column and pings that user.', 'Snippets'),

            self::row('Set a default before save', <<<'PHP'
if (empty(${1:$data['status']})) {
    ${1:$data['status']} = '${2:new}';
}
$0
PHP, $s, 'Fill a field when it is blank', 'Provide a default value in a beforeAdd event.', 'Snippets'),

            self::row('Write an audit log', <<<'PHP'
DB::Insert('${1:activity_log}', [
    'user_id'    => Security::CurrentUserData()['id'] ?? 0,
    'action'     => '${2:updated}',
    'record_id'  => ${3:$data['id']},
    'created_at' => now(),
]);
$0
PHP, $s, 'Record who did what, when', 'Insert a row into your own audit table.', 'Snippets'),

            self::row('Try / catch', <<<'PHP'
try {
    ${1:// work}
} catch (\Throwable $e) {
    pp_set_proxy('error', $e->getMessage());
    ${0}
}
PHP, $s, 'Guard risky work', 'Wrap fallible code so a failure never breaks the page.', 'Snippets'),
        ];
    }

    /**
     * Ready-to-use JavaScript snippets.
     *
     * @return array<int, array<string, string>>
     */
    public static function jsSnippets(): array
    {
        $s = self::KIND_SNIPPET;

        return [
            self::row('Confirm then run action', <<<'JS'
if (await pp.dialog.confirm('${1:Are you sure?}', '${2:This cannot be undone.}')) {
    const result = await pp.run('${3:action}', { id: pp.page.id });
    pp.dialog.toast('success', '${4:Done}');
    ${0}
}
JS, $s, 'Ask, call the server, then toast', 'Confirm a destructive action, run a server action, and report success.', 'Snippets'),

            self::row('Show/hide a field by another', <<<'JS'
if (pp.val('${1:type}') === '${2:company}') {
    pp.form.show('${3:vat_number}');
} else {
    pp.form.hide('${3:vat_number}');
}
$0
JS, $s, 'Toggle a field based on a choice', 'In a field On-Change event, reveal or hide a dependent field.', 'Snippets'),

            self::row('Compute a field', <<<'JS'
const ${1:qty} = Number(pp.val('${1:qty}')) || 0;
const ${2:price} = Number(pp.val('${2:price}')) || 0;
pp.val('${3:total}', ${1:qty} * ${2:price});
$0
JS, $s, 'Derive one field from others', 'Recalculate a total whenever an input changes.', 'Snippets'),

            self::row('Live-validate a field', <<<'JS'
const ok = await pp.run('${1:checkUnique}', { value: pp.val('${2:email}') });
pp.form.message('${2:email}', ok ? '' : '${3:Already taken}');
$0
JS, $s, 'Check the server and message under a field', 'Ask a server action whether a value is allowed and show an inline message.', 'Snippets'),

            self::row('Bulk action on selected rows', <<<'JS'
const ids = pp.grid.selectedIds();
if (!ids.length) { pp.dialog.toast('info', '${1:Select some rows first}'); return; }
await pp.run('${2:bulkAction}', { ids: ids });
pp.grid.reload();
$0
JS, $s, 'Act on the checked list rows', 'Collect selected ids, call a server action, then refresh the grid.', 'Snippets'),

            self::row('Highlight a field (View)', <<<'JS'
Runner.getControl('${1:status}').addStyle('color:#dc2626;font-weight:700');
$0
JS, $s, 'Make a field stand out', 'On a View page, colour a field to draw the eye — the PHPRunner "make it red" pattern.', 'Snippets · Runner'),

            self::row('Hide/disable a button', <<<'JS'
Runner.hideButton('${1:deleteSelected}');   // or: Runner.disableButton('save')
$0
JS, $s, 'Hide or disable a page button', "Hide the bulk Delete, disable Save until a condition is met, and so on.", 'Snippets · Runner'),

            self::row('Detail: hide Add for a master', <<<'JS'
// In a page-load event on the master's Edit page
if (${1:someCondition}) {
    Runner.detail('${2:order_items}').button('add').hide();
}
$0
JS, $s, 'Lock a child grid', "When a record shouldn't take new children (closed order, locked invoice…), hide the detail Add button.", 'Snippets · Runner'),

            self::row('Master/Detail: total children onto master', <<<'JS'
const items = Runner.detail('${1:order_items}').getAllRecords();
const total = items.reduce((s, it) => s + (+it.qty) * (+it.price), 0);
Runner.setControlValue('${2:total}', total);
$0
JS, $s, 'Sum child rows into a master field', 'Read the detail grid and write the computed total back onto the master.', 'Snippets · Runner'),

            self::row('Show field based on another (Runner)', <<<'JS'
Runner.getControl('${1:type}').onChange(function () {
    Runner.getControl('${2:vat_number}').visible(Runner.getControlValue('${1:type}') === '${3:company}');
});
$0
JS, $s, 'Reveal a dependent field', 'Show or hide one field whenever another changes — with Runner.', 'Snippets · Runner'),

            self::row('Save a child cell', <<<'JS'
await Runner.detail('${1:order_items}').setCellValue(${2:itemId}, '${3:qty}', ${4:5});
$0
JS, $s, 'Write + persist a child cell', 'Set one child grid cell and save it through the permission-gated endpoint.', 'Snippets · Runner'),

            self::row('Cascading lookup', <<<'JS'
Runner.getControl('${1:country}').onChange(async function () {
    const opts = await pp.run('${2:citiesFor}', { ${1:country}: Runner.getControlValue('${1:country}') });
    const sel = Runner.getControl('${3:city}').el;
    if (sel) sel.innerHTML = opts.map(o => '<option>' + pp.util.escapeHtml(o) + '</option>').join('');
});
$0
JS, $s, 'Filter a child list by the parent', 'When one field changes, reload another field\'s options from the server.', 'Snippets · Runner'),

            self::row('Debounced uniqueness check', <<<'JS'
const check = pp.util.debounce(async function () {
    const ok = await pp.run('${1:emailAvailable}', { value: Runner.getControlValue('${2:email}') });
    Runner.getControl('${2:email}').setMessage(ok ? '' : '${3:Already taken}', ok ? 'success' : 'error');
}, 400);
Runner.getControl('${2:email}').on('input', check);
$0
JS, $s, 'Ask the server as the user types', 'Debounced live validation with an inline message under the field.', 'Snippets · Runner'),

            self::row('Highlight matching rows', <<<'JS'
Runner.rows().forEach(function (row) {
    if (${1:(+row.get('balance') || 0) < 0}) row.highlight('${2:rgba(220,38,38,.12)}');
});
$0
JS, $s, 'Colour rows by a condition', 'Walk the grid and highlight rows that match a rule.', 'Snippets · Runner'),

            self::row('Block save if detail empty', <<<'JS'
if (Runner.detail('${1:order_items}').count() === 0) {
    pp.dialog.toast('warning', '${2:Add at least one line item first.}');
    return false;
}
$0
JS, $s, 'Require child rows before saving', 'Stop the save when a Master/Detail child grid has no rows.', 'Snippets · Runner'),
        ];
    }
}
