<?php

declare(strict_types=1);

namespace PpPhp\Core\Events;

/**
 * Master catalogue of all events available in PPphp.
 * Each event carries: label, description, language (php|js),
 * arguments map, return-value documentation, and a default stub.
 */
class EventCatalogue
{
    public const LANG_PHP = 'php';
    public const LANG_JS  = 'js';

    /**
     * Merge the per-event "idea" (best-practice tip, foldable in the editor)
     * and a fallback "example" into a groups array. Keyed by event name (unique
     * across the whole catalogue). An example already defined on the event wins.
     *
     * @param array<string,mixed> $groups
     * @return array<string,mixed>
     */
    public static function enrich(array $groups): array
    {
        $map = self::enrichment();
        foreach ($groups as &$group) {
            if (!isset($group['events']) || !is_array($group['events'])) {
                continue;
            }
            foreach ($group['events'] as $ek => &$ev) {
                if (!is_array($ev) || !isset($map[$ek])) {
                    continue;
                }
                if (!empty($map[$ek]['idea']) && empty($ev['idea'])) {
                    $ev['idea'] = $map[$ek]['idea'];
                }
                if (!empty($map[$ek]['example']) && empty($ev['example'])) {
                    $ev['example'] = $map[$ek]['example'];
                }
            }
            unset($ev);
        }
        unset($group);
        return $groups;
    }

    /**
     * Per-event enrichment: a short "idea" (when to reach for it + a best
     * practice) shown in a foldable footer, and an "example" for events that
     * ship without one. Every example is written to pass the built-in checker.
     *
     * @return array<string, array{idea?:string, example?:string}>
     */
    public static function enrichment(): array
    {
        return [
            // ── Authentication ──────────────────────────────────────────────
            'afterSocialLogin' => [
                'idea' => 'A returning user just signed in via a social/enterprise provider. Sync profile fields from the provider, or record which provider they used.',
            ],
            'afterActivate' => [
                'idea' => 'The account’s email is now verified. Unlock features, send a “you’re all set” email, or move them out of a pending group.',
            ],
            'beforePasswordReminder' => [
                'idea' => 'Fires before a reset link is sent. Rate-limit requests per account/IP, or quietly no-op for unknown emails to avoid leaking who has an account.',
            ],
            'beforePasswordReset' => [
                'idea' => 'Runs before a new password is accepted. Enforce your password policy or block reuse of the previous password here.',
            ],
            'afterPasswordChange' => [
                'idea' => 'The password just changed (from the profile, not a reset link). Invalidate other sessions and email a security notice.',
            ],
            'beforeLogin' => [
                'idea' => 'Your first line of defence. Return <code>false</code> to deny, or a URL to redirect (e.g. force SSO). Ideal for IP allow-lists, maintenance lockouts, or throttling. Keep it fast — it runs on every attempt.',
                'example' => "<?php\n// Block logins outside office hours (server time)\n\$hour = (int) date('G');\nif (\$hour < 8 || \$hour > 20) {\n    return false;\n}\n",
            ],
            'socialLoginRequested' => [
                'idea' => 'Return the provider authorization URL to start OAuth; return nothing to fall back to password login. Persist your OAuth <code>state</code> here so <b>Social Login Started</b> can verify it.',
                'example' => "<?php\n// Only allow Google + Microsoft in this app\nif (!in_array(\$provider, ['google', 'microsoft'], true)) {\n    return null;\n}\n// return \$authorizationUrl;\n",
            ],
            'socialLoginStarted' => [
                'idea' => 'A good audit point — the user is leaving for the provider. Log the attempt with the intent (login vs register).',
                'example' => "<?php\ndbExec('INSERT INTO auth_log (provider, intent, at) VALUES (?, ?, NOW())', [\$provider, \$intent]);\n",
            ],
            'socialLoginFailed' => [
                'idea' => 'Fires on provider errors, cancelled consent, or an unverified email. Surface a friendly reason and alert security on repeated failures.',
                'example' => "<?php\nerror_log(\"Social login failed via {\$provider}: {\$reason}\");\n",
            ],
            'afterSocialRegister' => [
                'idea' => 'A brand-new account was just created from a social sign-in. Seed defaults, send a welcome email, or assign a starter group here.',
                'example' => "<?php\n// Give every social sign-up the default role\ndbExec('UPDATE users SET role = ? WHERE id = ?', ['member', \$user['id']]);\n",
            ],
            'afterLogin' => [
                'idea' => 'Runs once a session is established. Stamp last-seen, warm a cache, or redirect by role. Avoid heavy work — the user is waiting on the redirect.',
                'example' => "<?php\ndbExec('UPDATE users SET last_login = NOW() WHERE id = ?', [\$user['id']]);\n",
            ],
            'onLoginFailed' => [
                'idea' => 'The place to count failures and drive lockouts or CAPTCHAs. The username is whatever was typed — treat it as untrusted.',
                'example' => "<?php\nerror_log('Failed login for ' . \$username . ' — ' . \$reason);\n",
            ],
            'beforeLogout' => [
                'idea' => 'Last chance to act while the session still exists — clear a “remember me” token or log the sign-out.',
                'example' => "<?php\ndbExec('UPDATE users SET remember_token = NULL WHERE id = ?', [\$user['id']]);\n",
            ],
            'afterLogout' => [
                'idea' => 'The session is gone. Use it for analytics or to redirect somewhere friendlier than the login page.',
                'example' => "<?php\nerror_log('User ' . (\$user['username'] ?? '?') . ' signed out');\n",
            ],
            'beforeRegister' => [
                'idea' => 'Validate or enrich the signup before the account is created. Return an errors array to reject (e.g. block throwaway email domains).',
                'example' => "<?php\n\$email = strtolower((string) (\$data['email'] ?? ''));\nif (str_ends_with(\$email, '@example.com')) {\n    return ['email' => 'Please use a work email address'];\n}\n",
            ],
            'afterRegister' => [
                'idea' => 'Account exists now — send the welcome/verification email, create related rows, or notify an admin.',
                'example' => "<?php\ndbExec('INSERT INTO profiles (user_id, created_at) VALUES (?, NOW())', [\$user['id']]);\n",
            ],
            'onRegistrationFailed' => [
                'idea' => 'Registration was rejected (duplicate email, weak password, validation). Log the reason to spot friction in your signup flow.',
                'example' => "<?php\nerror_log('Registration failed: ' . \$reason);\n",
            ],
            'afterPasswordReminder' => [
                'idea' => 'A reset link was just emailed. Log it for support, or rate-limit repeated requests from the same account.',
                'example' => "<?php\ndbExec('UPDATE users SET reminder_sent_at = NOW() WHERE id = ?', [\$user['id']]);\n",
            ],
            'afterPasswordReset' => [
                'idea' => 'The password has changed via a reset link. Invalidate other sessions and notify the user their password changed.',
                'example' => "<?php\nerror_log('Password reset completed for user ' . \$user['id']);\n",
            ],

            // ── Email ────────────────────────────────────────────────────────
            'beforeSendEmail' => [
                'idea' => 'Rewrite any outgoing mail before it leaves — add a BCC archive, inject an unsubscribe footer, or return <code>false</code> to suppress it entirely.',
                'example' => "<?php\n// Append a standard footer to every outgoing email\n\$body .= \"\\n\\n—\\nSent by \" . (\$_SERVER['HTTP_HOST'] ?? 'our app');\n",
            ],
            'afterSendEmail' => [
                'idea' => 'Confirmation that mail was handed to the transport. Record deliverability metrics or thread IDs here.',
                'example' => "<?php\ndbExec('INSERT INTO mail_log (recipient, subject, at) VALUES (?, ?, NOW())', [\$to, \$subject]);\n",
            ],
            'onEmailFailed' => [
                'idea' => 'The transport rejected the message. Queue a retry or alert an admin — never fail the user’s action just because mail bounced.',
                'example' => "<?php\nerror_log(\"Email to {\$to} failed: {\$error}\");\n",
            ],

            // ── File & Storage ───────────────────────────────────────────────
            'beforeFileUpload' => [
                'idea' => 'Enforce your own rules before a byte is stored — cap size, restrict types, or scan. Return <code>false</code> to reject. The server checks magic bytes regardless.',
            ],
            'afterFileUpload' => [
                'idea' => 'The file is stored and you have its reference. Make a thumbnail, extract metadata, or link it to a record.',
            ],
            'beforeFileDownload' => [
                'idea' => 'Gate downloads of private files — check the current user may see this one. Return <code>false</code> to deny.',
            ],
            'beforeFileDelete' => [
                'idea' => 'Return <code>false</code> to veto a delete (e.g. keep files referenced by other rows). Great for soft-delete or retention policies.',
            ],

            // ── API & Webhooks ───────────────────────────────────────────────
            'beforeApiRequest' => [
                'idea' => 'The gatekeeper for your JSON API — authenticate the token, rate-limit, or short-circuit with an error before any handler runs.',
            ],
            'beforeApiResponse' => [
                'idea' => 'Shape the outgoing payload — add CORS headers, wrap in an envelope, or strip fields the client shouldn’t see.',
            ],
            'afterApiRequest' => [
                'idea' => 'Runs after the response is sent — the right spot for metrics and structured request logging without slowing the caller.',
                'example' => "<?php\ndbExec('INSERT INTO api_log (path, status, at) VALUES (?, ?, NOW())', [\$path, \$status]);\n",
            ],
            'onWebhookReceive' => [
                'idea' => 'Entry point for inbound webhooks (Stripe, GitHub…). <b>Always verify the signature</b> before trusting the payload, then act idempotently.',
            ],

            // ── Application ──────────────────────────────────────────────────
            'appInit' => [
                'idea' => 'Runs once at the very start of every request — register helpers, set a timezone, or load config. Keep it lean; it is on the hot path.',
                'example' => "<?php\ndate_default_timezone_set('Asia/Kathmandu');\n",
            ],
            'beforeProcess' => [
                'idea' => 'A global pre-flight before any page handler. Enforce app-wide rules like maintenance mode or a forced-password-change gate.',
                'example' => "<?php\n// App-wide maintenance switch\nif (dbValue('SELECT value FROM settings WHERE key = ?', ['maintenance'], '0') === '1') {\n    // header('Location: /maintenance.html'); exit;\n}\n",
            ],
            'jsOnLoad' => [
                'idea' => 'Client-side code that runs on every page — set up global listeners, analytics, or shortcuts. This is JavaScript, not PHP.',
                'example' => "// Confirm before leaving a page with unsaved edits\nwindow.addEventListener('beforeunload', (e) => {\n    if (document.querySelector('.is-dirty')) e.preventDefault();\n});\n",
            ],
            'menuModify' => [
                'idea' => 'Reshape the sidebar per user — hide items by role, add a badge count, or reorder. Runs on every page render, so keep lookups cheap.',
            ],

            // ── Table · Page events ──────────────────────────────────────────
            'afterListLoad' => [
                'idea' => 'The rows are fetched (by reference) — decorate them for display: add computed columns, format money, or attach a per-row flag. Don’t run a query per row.',
            ],
            'beforeAddLoad' => [
                'idea' => 'Pre-fill the Add form before it renders — default a status, stamp the current user, or carry a value from the query string.',
                'example' => "<?php\n// Default the owner to the signed-in user\n\$data['owner_id'] = Security::CurrentUserData()['id'] ?? null;\n",
            ],
            'beforeAddSave' => [
                'idea' => '<b>Validate and shape</b> the new row. <code>\$data</code> is by reference — edit it to change what’s written. Return an errors array to reject, or <code>false</code> to cancel.',
            ],
            'afterAddSave' => [
                'idea' => 'The row exists and you have its id. Create child rows, send a notification, or write an audit entry.',
            ],
            'beforeEditLoad' => [
                'idea' => 'Prepare the Edit form — you can block editing of a locked record, or decorate values before they’re shown.',
                'example' => "<?php\n// Stop anyone editing a finalised invoice\nif ((\$data['status'] ?? '') === 'paid') {\n    return false;\n}\n",
            ],
            'beforeEditSave' => [
                'idea' => 'Guard and adjust an update. You get both the old and new values — perfect for optimistic-lock checks or “what changed” diffs.',
            ],
            'afterEditSave' => [
                'idea' => 'The update is committed. Cascade the change, bust a cache, or log the diff between old and new.',
                'example' => "<?php\n// Log a status change\nif ((\$oldvalues['status'] ?? null) !== (\$data['status'] ?? null)) {\n    dbExec('INSERT INTO status_log (record_id, status, at) VALUES (?, ?, NOW())', [\$data['id'], \$data['status']]);\n}\n",
            ],
            'beforeDelete' => [
                'idea' => 'Return <code>false</code> to veto the delete — the safest place to enforce “can’t delete while referenced” rules.',
            ],
            'afterDelete' => [
                'idea' => 'The row is gone. Clean up dependents, remove attachments, or write an audit trail.',
                'example' => "<?php\ndbExec('DELETE FROM comments WHERE post_id = ?', [\$deleted['id']]);\n",
            ],
            'beforeViewLoad' => [
                'idea' => 'Runs before a single record renders — enforce row-level access, or add a computed value for the View page.',
            ],
            'beforeSearch' => [
                'idea' => 'Inspect or tweak the search terms before the query runs. Use <b>SQL Modifiers → sqlWhere</b> for hard security filters that users can’t bypass.',
                'example' => "<?php\n// Log what people search for\nerror_log('Search on this table: ' . json_encode(\$searchParams));\n",
            ],
            'beforeExport' => [
                'idea' => 'Filter or reshape rows just before export — drop sensitive columns, or restrict what a non-admin can download.',
            ],
            'afterExport' => [
                'idea' => 'The export finished. Record who exported what — useful for data-access audits.',
                'example' => "<?php\ndbExec('INSERT INTO export_log (rows, at) VALUES (?, NOW())', [\$count]);\n",
            ],
            'beforeImport' => [
                'idea' => 'Validate and normalise each incoming row before it’s written — trim, map columns, or skip bad lines. Return <code>false</code> on a row to skip it.',
            ],
            'afterImport' => [
                'idea' => 'The import batch is done — you have counts of inserted/updated/skipped. Summarise the run or notify the uploader.',
                'example' => "<?php\nerror_log(\"Import done: {\$inserted} new, {\$updated} updated, {\$skipped} skipped\");\n",
            ],
            'onPrint' => [
                'idea' => 'Customise the print/PDF view — inject a header, watermark, or hide screen-only columns.',
            ],
            'processValues' => [
                'idea' => 'A single place to transform values shared by add and edit — normalise a phone number, uppercase a code, compute a slug.',
            ],
            'onCopyLoad' => [
                'idea' => 'Fires when a record is duplicated — clear fields that must be unique (reference numbers, timestamps) so the copy saves cleanly.',
                'example' => "<?php\n// Don't carry a unique reference into the copy\nunset(\$data['ref']);\n\$data['status'] = 'draft';\n",
            ],
            'afterBulkDelete' => [
                'idea' => 'Several rows were deleted at once — reconcile counters or clean dependents in one batch rather than per row.',
                'example' => "<?php\nerror_log(count(\$ids) . ' rows bulk-deleted from this table');\n",
            ],

            // ── Table · SQL modifiers ────────────────────────────────────────
            'sqlWhere' => [
                'idea' => 'Add a WHERE fragment applied to <b>every</b> list/search/export query — the correct place for row-level security a user cannot bypass.',
            ],
            'sqlOrderBy' => [
                'idea' => 'Override the default sort — e.g. pinned rows first, then newest. Return a column list the database understands.',
            ],

            // ── Table · Client events (JS) ───────────────────────────────────
            'afterListRender' => [
                'idea' => 'The grid is in the DOM — attach tooltips, colour rows, or wire custom buttons. Runs on every page/sort/filter, so keep it idempotent.',
            ],
            'onRowClick' => [
                'idea' => 'React to a row click — open a drawer, preview, or navigate. Ignore clicks on links/buttons inside the row.',
            ],
            'onRowSelect' => [
                'idea' => 'Fires as checkboxes change — enable a bulk-action bar or show a running total of the selection.',
            ],
            'beforeFormSubmit' => [
                'idea' => 'Last client-side gate before submit — do quick UX validation and <code>return false</code> to stop it. Always re-check on the server too.',
            ],
            'afterDeleteSuccess' => [
                'idea' => 'The delete came back OK — show a toast, remove the row with an animation, or refresh a counter.',
            ],
            'onPageInit' => [
                'idea' => 'Runs once when a page loads — the client-side counterpart to a page load. Read <code>pp.page</code> to branch by list/add/edit/view.',
            ],

            // ── Field · Server events (PHP) ──────────────────────────────────
            'format' => [
                'idea' => 'Transform a value for <b>display only</b> — currency, dates, badges. Return the HTML/string to show; the stored value is untouched.',
                'example' => "<?php\n// Show a coloured status pill\n\$map = ['paid' => 'success', 'due' => 'warning'];\n\$cls = \$map[\$value] ?? 'secondary';\nreturn '<span class=\"badge text-bg-' . \$cls . '\">' . htmlspecialchars((string) \$value) . '</span>';\n",
            ],
            'calculate' => [
                'idea' => 'Derive a value from the rest of the row (line total, age, full name). Return the computed value; runs on add and edit.',
            ],
            'validate' => [
                'idea' => 'Field-level validation. Return <code>true</code> to accept, or an error message string to reject — shown right under the field.',
            ],
            'beforeSet' => [
                'idea' => 'Sanitise a single field before it’s written — trim, normalise case, strip formatting. Return the cleaned value.',
                'example' => "<?php\n// Store phone numbers as digits only\nreturn preg_replace('/\\\\D+/', '', (string) \$value);\n",
            ],
            'afterSet' => [
                'idea' => 'React once a field’s value is set — keep a denormalised copy in sync, or flag the row for review.',
                'example' => "<?php\nif (\$value !== \$oldvalue) {\n    error_log('Field changed on record ' . (\$record['id'] ?? '?'));\n}\n",
            ],

            // ── Field · Client events (JS) ───────────────────────────────────
            'onChange' => [
                'idea' => 'The workhorse of interactive forms — show/hide dependent fields, recalculate a total, or fetch options as the value changes.',
            ],
            'onFocus' => [
                'idea' => 'The field gained focus — show a hint, highlight related fields, or pre-select the text for quick replacement.',
                'example' => "// Select all text so the user can type over it\nel.select && el.select();\n",
            ],
            'onBlur' => [
                'idea' => 'Focus left the field — a natural spot for “nice to have” validation or trimming without nagging mid-typing.',
                'example' => "// Trim whitespace when the user leaves the field\nel.value = (el.value || '').trim();\n",
            ],
            'onKeyup' => [
                'idea' => 'Fires on every keystroke — perfect for live counters or debounced lookups. Debounce network calls so you don’t hit the server on each key.',
                'example' => "// Live character counter\nconst max = 280;\npp.dialog.toast('info', (el.value.length) + '/' + max);\n",
            ],
        ];
    }

    // ---------------------------------------------------------------
    // Global event groups
    // ---------------------------------------------------------------

    public static function globalGroups(): array
    {
        return [
            'auth' => [
                'label'  => 'Authentication',
                'icon'   => 'shield-lock',
                'color'  => '#7c3aed',
                'events' => [
                    'beforeLogin' => [
                        'label'   => 'Before Login',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called before a login attempt is validated. Can block or redirect the login.',
                        'args'    => ['$username' => 'string — the username entered', '$password' => 'string — the raw password entered'],
                        'returns' => 'Return <code>false</code> to deny. Return a URL string to redirect. Return nothing to continue.',
                        'stub'    => "<?php\n// Available: \$username, \$password\n// Return false to block login\n// Return 'https://...' to redirect instead\n",
                    ],
                    'socialLoginRequested' => [
                        'label'   => 'Social Login Requested',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called when a generated Login page requests a configured social or enterprise identity provider.',
                        'args'    => ['$provider' => 'string — provider key such as google, microsoft, facebook, apple, github, linkedin, or okta'],
                        'returns' => 'Return the provider authorization URL to redirect. Return nothing when the provider is not configured.',
                        'stub'    => "<?php\n// Available: \$provider\n// Create the provider authorization URL, persist OAuth state, then return the URL.\n// return \$authorizationUrl;\n",
                    ],
                    'socialLoginStarted' => [
                        'label'   => 'Social Login Started',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called just before the browser is redirected to a social / enterprise identity provider to begin sign-in.',
                        'args'    => ['$provider' => 'string — provider key (google, microsoft, facebook, apple, github, linkedin, okta)', '$intent' => 'string — "login" or "register"'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Available: \$provider, \$intent\n// The user is being sent to \$provider to authenticate. Log the attempt here.\n",
                    ],
                    'socialLoginFailed' => [
                        'label'   => 'Social Login Failed',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called when a social / enterprise sign-in fails (provider error, cancelled consent, unverified email, …).',
                        'args'    => ['$provider' => 'string — provider key', '$reason' => 'string — the failure message'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Available: \$provider, \$reason\n// Log the failed social sign-in or alert security.\n",
                    ],
                    'afterSocialLogin' => [
                        'label'   => 'After Social Login',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called after an existing account signs in successfully through a social / enterprise provider.',
                        'args'    => ['$provider' => 'string — provider key', '$identity' => 'array — the provider profile (id, email, name, …)', '$user' => 'array — the matched local user record'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Available: \$provider, \$identity, \$user\n// A returning user just signed in via \$provider.\n",
                        'example' => "<?php\n// Record the provider used on the account's last sign-in\n\PpPhp\Core\DB::Update('users', ['last_provider' => \$provider], ['id' => \$user['id']]);\n",
                    ],
                    'afterSocialRegister' => [
                        'label'   => 'After Social Register',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called after a brand-new local account is auto-created from a social / enterprise sign-in.',
                        'args'    => ['$provider' => 'string — provider key', '$identity' => 'array — the provider profile', '$user_id' => 'int — the id of the newly created local user'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Available: \$provider, \$identity, \$user_id\n// A new user was created from a social sign-in — send a welcome email, provision resources, etc.\n",
                    ],
                    'afterLogin' => [
                        'label'   => 'After Login',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called after a successful login. The user session is already set.',
                        'args'    => ['$user' => 'array — the authenticated user record (id, username, email, group, …)'],
                        'returns' => 'Return a URL string to override the default post-login redirect.',
                        'stub'    => "<?php\n// Available: \$user\n// Return a URL to override the redirect destination\n",
                    ],
                    'onLoginFailed' => [
                        'label'   => 'On Login Failed',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called after a failed login attempt.',
                        'args'    => ['$username' => 'string', '$reason' => 'string — "credentials" | "blocked" | "suspended"'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Available: \$username, \$reason\n// Log the attempt, send an alert, etc.\n",
                    ],
                    'beforeLogout' => [
                        'label'   => 'Before Logout',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called before the session is destroyed.',
                        'args'    => ['$user' => 'array — the current user record'],
                        'returns' => 'Return <code>false</code> to cancel the logout.',
                        'stub'    => "<?php\n// Available: \$user\n// Return false to prevent logout\n",
                    ],
                    'afterLogout' => [
                        'label'   => 'After Logout',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called after the session has been destroyed.',
                        'args'    => ['$user' => 'array — the user who just logged out'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Available: \$user\n",
                    ],
                    'beforeRegister' => [
                        'label'   => 'Before Register',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called before a new user account is created via self-registration.',
                        'args'    => ['$data' => 'array (by reference) — the submitted registration fields'],
                        'returns' => 'Return an associative array of field→error to reject with field errors. Return <code>false</code> to silently cancel.',
                        'stub'    => "<?php\n// Modify \$data to transform values before saving\n// Return ['email' => 'Domain not allowed'] to reject\n// Return false to silently cancel\n",
                    ],
                    'afterRegister' => [
                        'label'   => 'After Register',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called after a new user account has been created.',
                        'args'    => ['$user' => 'array — the newly created user record'],
                        'returns' => 'Return a URL string to override the post-registration redirect.',
                        'stub'    => "<?php\n// Available: \$user\n// Send welcome email, provision resources, etc.\n",
                    ],
                    'onRegistrationFailed' => [
                        'label'   => 'On Registration Failed',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called when self-registration fails validation or cannot create the account.',
                        'args'    => ['$data' => 'array — submitted registration fields', '$errors' => 'array — validation or persistence errors'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Log registration failures or notify an administrator\n// Available: \$data, \$errors\n",
                    ],
                    'afterActivate' => [
                        'label'   => 'After Account Activated',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called after a user confirms their account through the email activation link (only when account activation is enabled).',
                        'args'    => ['$user' => 'array — the account that was just activated (id, username, email, …)'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Available: \$user\n// The account is now active. Welcome them, grant a starter role, provision resources, etc.\n",
                        'example' => "<?php\n// Send a welcome email once the account is verified\nif (function_exists('pp_send_email')) {\n    pp_send_email(\$user['email'] ?? '', 'Welcome aboard!', '<p>Your account is now active. Enjoy!</p>');\n}\n",
                    ],
                    'beforePasswordReminder' => [
                        'label'   => 'Before Password Reminder',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'On the Forgot Password page, called after the account is found and before the reset link email is sent. Return false to skip sending.',
                        'args'    => ['$user' => 'array — the matched account (id, username)', '$message' => 'string (by reference) — message to show if you cancel'],
                        'returns' => 'Return <code>false</code> to cancel sending (the page shows $message, or a neutral default).',
                        'stub'    => "<?php\n// \$user matched the submitted email. Return false to block the reset email.\n// Example: throttle to one reset request per hour.\n// \$message = 'Please wait before requesting another reset.';\n// return false;\n",
                        'example' => "<?php\n// Only allow resets for active accounts\n\$active = \PpPhp\Core\DB::FetchOne('SELECT status FROM users WHERE id = ?', [\$user['id']]);\nif (\$active === 'disabled') { \$message = 'This account is disabled.'; return false; }\n",
                    ],
                    'afterPasswordReminder' => [
                        'label'   => 'After Password Reminder',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called after the password-reset link email has been sent.',
                        'args'    => ['$user' => 'array — the account that was emailed'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// The reset link was emailed to \$user. Log it, notify security, etc.\n\PpPhp\Core\DB::Insert('audit_log', ['action' => 'password_reset_requested', 'user_id' => \$user['id'], 'at' => date('Y-m-d H:i:s')]);\n",
                    ],
                    'beforePasswordReset' => [
                        'label'   => 'Before Password Reset',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'On the Reset Password page, called after a valid token is confirmed and immediately before the new password is written.',
                        'args'    => ['$user' => 'array — the account whose password is about to change'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// \$user's password is about to be reset via a valid link. Prepare/log here.\n",
                        'example' => "<?php\n// Remember the previous password hash so you can enforce \"no reuse\"\n\$prev = \PpPhp\Core\DB::FetchOne('SELECT password FROM users WHERE id = ?', [\$user['id']]);\n\PpPhp\Core\DB::Insert('password_history', ['user_id' => \$user['id'], 'hash' => \$prev, 'at' => date('Y-m-d H:i:s')]);\n",
                    ],
                    'afterPasswordReset' => [
                        'label'   => 'After Password Reset',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called after the password was successfully changed through the reset-link flow.',
                        'args'    => ['$user' => 'array — the account whose password was reset'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Password was reset for \$user. Notify them and invalidate other sessions.\nif (function_exists('pp_send_email')) {\n    pp_send_email(\$user['email'] ?? '', 'Your password was changed', '<p>If this was not you, contact support immediately.</p>');\n}\n",
                    ],
                    'afterPasswordChange' => [
                        'label'   => 'After Password Change',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called after a signed-in user changes their own password on the Change Password page (distinct from the forgot-password reset flow).',
                        'args'    => ['$user' => 'array — the user who changed their password'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// \$user changed their password while signed in. Log it / send a confirmation email.\n",
                        'example' => "<?php\n// Email a security confirmation whenever the password changes\nif (function_exists('pp_send_email')) {\n    pp_send_email(\$user['email'] ?? '', 'Password changed',\n        '<p>Your password was just changed. If this was not you, reset it immediately.</p>');\n}\n",
                    ],
                ],
            ],

            'email' => [
                'label'  => 'Email',
                'icon'   => 'envelope',
                'color'  => '#0284c7',
                'events' => [
                    'beforeSendEmail' => [
                        'label'   => 'Before Send Email',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called before any automated email is dispatched by the platform.',
                        'args'    => [
                            '$to'      => 'string — recipient address',
                            '$subject' => 'string — email subject (by reference)',
                            '$body'    => 'string — HTML body (by reference)',
                            '$data'    => 'array — context data (record, user, action, …)',
                        ],
                        'returns' => 'Return <code>false</code> to cancel sending.',
                        'stub'    => "<?php\n// Modify \$subject and \$body to customise the email\n// Return false to cancel sending\n",
                    ],
                    'afterSendEmail' => [
                        'label'   => 'After Send Email',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called after an email has been sent successfully.',
                        'args'    => ['$to' => 'string', '$subject' => 'string', '$result' => 'array — driver response'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Available: \$to, \$subject, \$result\n",
                    ],
                    'onEmailFailed' => [
                        'label'   => 'On Email Failed',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called when an email fails to send.',
                        'args'    => ['$to' => 'string', '$subject' => 'string', '$error' => 'string — error message from the driver'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Available: \$to, \$subject, \$error\n// Log the failure, notify admin, etc.\n",
                    ],
                ],
            ],

            'file' => [
                'label'  => 'File & Storage',
                'icon'   => 'file-earmark-arrow-up',
                'color'  => '#0d9488',
                'events' => [
                    'beforeFileUpload' => [
                        'label'   => 'Before File Upload',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Fired by pp_store_upload() / api/upload.php before a validated file is written to the uploads store. Inspect or adjust $file, or block the upload.',
                        'args'    => ['$file' => 'array (by reference) — { name, ext, size, type }'],
                        'returns' => 'Return <code>false</code> to reject the upload. Change $file[\'name\'] to store a different display name.',
                        'stub'    => "<?php\n// \$file = ['name'=>..., 'ext'=>..., 'size'=>..., 'type'=>...] (by reference).\n// Block anything over 2 MB:\nif ((\$file['size'] ?? 0) > 2 * 1024 * 1024) return false;\n",
                        'example' => "<?php\n// Restrict this app to images only, and normalise the display name\nif (!in_array(\$file['ext'] ?? '', ['jpg','jpeg','png','webp'], true)) return false;\n\$file['name'] = preg_replace('/[^A-Za-z0-9._-]/', '_', \$file['name'] ?? 'image');\n",
                    ],
                    'afterFileUpload' => [
                        'label'   => 'After File Upload',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Fired after a file has been stored. $file carries the final url and server path.',
                        'args'    => ['$file' => 'array — { success, path, url, name, size, ext }'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// The file is stored. \$file['url'] is the web path, \$file['path'] the disk path.\n// Record it, generate a thumbnail, scan it, etc.\n",
                        'example' => "<?php\n// Keep a record of every uploaded file\n\PpPhp\Core\DB::Insert('uploads', [\n    'url'  => \$file['url'],\n    'name' => \$file['name'],\n    'size' => \$file['size'],\n    'by'   => Security::CurrentUserData()['id'] ?? null,\n    'at'   => date('Y-m-d H:i:s'),\n]);\n",
                    ],
                    'beforeFileDownload' => [
                        'label'   => 'Before File Download',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Fired by pp_send_file() / api/file.php before a stored file is streamed. Enforce access control or swap the file.',
                        'args'    => ['$file' => 'array (by reference) — { path, requested, name }'],
                        'returns' => 'Return <code>false</code> to deny the download (403).',
                        'stub'    => "<?php\n// \$file['path'] is the resolved file (inside /uploads). Return false to deny.\n// if (!Security::HasPermission('documents', 'S')) return false;\n",
                        'example' => "<?php\n// Only the owner may download their files (looked up in an uploads table)\n\$me = Security::CurrentUserData()['id'] ?? 0;\n\$owner = \PpPhp\Core\DB::FetchOne('SELECT by FROM uploads WHERE url LIKE ?', ['%' . basename(\$file['path'] ?? '')]);\nif ((int) \$owner !== (int) \$me) return false;\n",
                    ],
                    'beforeFileDelete' => [
                        'label'   => 'Before File Delete',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Fired by pp_delete_file() before a stored file is removed from disk.',
                        'args'    => ['$file' => 'array (by reference) — { path, name }'],
                        'returns' => 'Return <code>false</code> to cancel the deletion.',
                        'stub'    => "<?php\n// \$file['path'] is about to be deleted. Return false to keep it.\n",
                        'example' => "<?php\n// Soft-delete: keep the row, just mark it, and refuse the disk delete\n\PpPhp\Core\DB::Update('uploads', ['deleted_at' => date('Y-m-d H:i:s')], ['url' => pp_upload_url(\$file['path'])]);\nreturn false; // keep the physical file for now\n",
                    ],
                ],
            ],

            'api' => [
                'label'  => 'API & Webhooks',
                'icon'   => 'plug',
                'color'  => '#ea580c',
                'events' => [
                    'beforeApiRequest' => [
                        'label'   => 'Before API Request',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Fired for every request under /api/ (list, save, delete, event, upload, webhook…) before the endpoint runs. A central place to rate-limit, log, or block API traffic.',
                        'args'    => ['$endpoint' => 'string — the api script, e.g. "save.php"', '$method' => 'string — GET | POST | …', '$request' => 'array — $_REQUEST'],
                        'returns' => 'Return <code>false</code> to reject the request with 403.',
                        'stub'    => "<?php\n// Runs before any /api/ endpoint. Return false to block.\n// if (\$endpoint === 'delete.php' && !Security::IsAdmin()) return false;\n",
                        'example' => "<?php\n// Simple per-user rate limit on write endpoints\nif (in_array(\$endpoint, ['save.php', 'delete.php'], true)) {\n    \$key = 'apihits_' . (Security::CurrentUserData()['id'] ?? 0);\n    \$_SESSION[\$key] = (\$_SESSION[\$key] ?? 0) + 1;\n    if (\$_SESSION[\$key] > 100) return false; // 403\n}\n",
                    ],
                    'beforeApiResponse' => [
                        'label'   => 'Before API Response',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Fired for every /api/ response whose body is JSON, just before it is sent. Rewrite the body or change the HTTP status. (Applies to ALL endpoints automatically.)',
                        'args'    => ['$body' => 'array (by reference) — the decoded JSON response', '$status' => 'int (by reference) — HTTP status code', '$endpoint' => 'string — the api script'],
                        'returns' => 'void (modify $body / $status by reference)',
                        'stub'    => "<?php\n// Wrap every API response in a standard envelope.\n\$body = ['data' => \$body, 'meta' => ['ts' => time(), 'endpoint' => \$endpoint]];\n",
                        'example' => "<?php\n// Add a request id + server time to every API payload\n\$body['_meta'] = ['request_id' => bin2hex(random_bytes(6)), 'served_at' => date('c')];\n",
                    ],
                    'afterApiRequest' => [
                        'label'   => 'After API Request',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Fired for every /api/ request after the response has been finalised. Observe-only — good for access logs and metrics.',
                        'args'    => ['$endpoint' => 'string — the api script', '$response' => 'mixed — the response body that was sent'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Runs after an /api/ response is sent. \$response is the final body.\n// error_log('[api] ' . \$endpoint . ' -> ' . json_encode(\$response));\n",
                    ],
                    'onWebhookReceive' => [
                        'label'   => 'On Webhook Receive',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Fired when an external service POSTs to api/webhook.php (public — no login). VERIFY the signature here, then act on the payload. Add ?source=stripe to route different providers.',
                        'args'    => ['$payload' => 'array — decoded JSON body (or form fields)', '$headers' => 'array — request headers', '$raw' => 'string — the raw request body (for signature checks)', '$source' => 'string — the ?source= query value'],
                        'returns' => 'Return <code>false</code> to reject with 403. Return an array to send it back as the JSON response. Otherwise a default {success:true} is sent.',
                        'stub'    => "<?php\n// \$source lets you route providers: /api/webhook.php?source=stripe\n// ALWAYS verify the signature before trusting \$payload.\n// Example (pseudo):\n// \$sig = \$headers['Stripe-Signature'] ?? '';\n// if (!my_verify(\$raw, \$sig)) return false;\nreturn ['received' => true];\n",
                        'example' => "<?php\n// Handle a payment webhook: verify, then update the order\nif (\$source === 'stripe') {\n    // if (!stripe_verify(\$raw, \$headers['Stripe-Signature'] ?? '')) return false;\n    if ((\$payload['type'] ?? '') === 'checkout.session.completed') {\n        \$ref = \$payload['data']['object']['client_reference_id'] ?? '';\n        \PpPhp\Core\DB::Update('orders', ['status' => 'paid'], ['ref' => \$ref]);\n    }\n}\nreturn ['ok' => true];\n",
                    ],
                ],
            ],

            'app' => [
                'label'  => 'Application',
                'icon'   => 'app-indicator',
                'color'  => '#0891b2',
                'events' => [
                    'appInit' => [
                        'label'   => 'After App Initialized',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Fired once on every page request after the application is fully booted. Use for global bootstrapping, tenant resolution, feature flags, etc.',
                        'args'    => ['$app' => 'object — application context'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Runs on every request after app boot\n// \$app->bind('currency', 'USD');\n",
                    ],
                    'beforeProcess' => [
                        'label'   => 'Before Process',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called before the current page handles its request. Use it for redirects, logging, or application-wide request guards.',
                        'args'    => ['$page' => 'array — current page context', '$request' => 'array — request data', '$user' => 'array — current user'],
                        'returns' => 'Return a URL string to redirect. Return <code>false</code> to stop processing.',
                        'stub'    => "<?php\n// Runs before page processing\n// Return false to stop, or return '/path' to redirect\n",
                    ],
                    'jsOnLoad' => [
                        'label'   => 'JavaScript OnLoad (Global)',
                        'lang'    => self::LANG_JS,
                        'desc'    => 'JavaScript injected into every generated page at DOMContentLoaded time. Use for global analytics, third-party widgets, or shared utilities.',
                        'args'    => ['page' => 'object — {table, action, id?}'],
                        'returns' => 'void',
                        'stub'    => "// Runs on every page after DOM is ready\n// console.log('Page loaded:', page);\n",
                    ],
                    'menuModify' => [
                        'label'   => 'Menu Item: Modify',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Called on every page just before the sidebar navigation renders. Add, remove, reorder, or hide menu items based on the user or context. $items is modified by reference.',
                        'args'    => ['$items' => 'array (by reference) — nav items [{label, url, icon, children, …}]', '$user' => 'array — the current user'],
                        'returns' => 'void (modify $items by reference)',
                        'stub'    => "<?php\n// \$items is the sidebar menu (by reference). Reorder / filter / add here.\n// Hide a menu entry for non-admins:\n\$items = array_values(array_filter(\$items, function (\$m) use (\$user) {\n    return !((\$m['label'] ?? '') === 'Settings' && (\$user['role'] ?? '') !== 'admin');\n}));\n",
                        'example' => "<?php\n// Append a custom external link to the menu\n\$items[] = ['label' => 'Help Center', 'url' => 'https://help.example.com', 'icon' => 'bi-question-circle'];\n",
                    ],
                ],
            ],
            'realtime' => [
                'label' => 'Real-time',
                'icon'  => 'broadcast',
                'color' => '#0ea5e9',
                'events' => [
                    'beforeBroadcast' => [
                        'label'   => 'Before Broadcast',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Fires inside pp_broadcast() just before a live message is pushed. Reshape the payload, or return false to cancel the broadcast.',
                        'args'    => ['$channel' => 'string — the channel being broadcast to', '$event' => 'string — the event name', '$payload' => 'mixed (by reference) — the data to send; edit it to change what subscribers receive'],
                        'returns' => 'Return <code>false</code> to cancel. Edit <code>$payload</code> by reference to change it. Return nothing to send as-is.',
                        'stub'    => "<?php\n// Available: \$channel, \$event, \$payload (by reference)\n// Return false to cancel the broadcast.\n",
                        'example' => "<?php\n// Never leak internal fields to subscribers\nif (is_array(\$payload)) {\n    unset(\$payload['internal_note'], \$payload['cost']);\n}\n",
                        'idea'    => 'One central place to sanitise every outgoing live message — strip private fields, add a server timestamp, or throttle noisy channels. Return <code>false</code> to drop a message entirely.',
                    ],
                    'onChannelAuth' => [
                        'label'   => 'Channel: Authorize',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'Runs when a browser asks to subscribe to a channel. Return false to deny — your gate for private/sensitive channels.',
                        'args'    => ['$channel' => 'string — the channel being requested', '$user' => 'array — the current user (empty when not signed in)'],
                        'returns' => 'Return <code>false</code> to deny the subscription. Return nothing / true to allow.',
                        'stub'    => "<?php\n// Available: \$channel, \$user\n// Return false to deny a private channel.\n",
                        'example' => "<?php\n// Only admins may listen on the \"audit\" channel\nif (\$channel === 'audit' && (\$user['role'] ?? '') !== 'admin') {\n    return false;\n}\n",
                        'idea'    => 'Treat channels like URLs — anything sensitive must be authorised here. A common pattern: allow <code>user.&lt;id&gt;</code> only when it matches the signed-in user, so people can’t eavesdrop on each other.',
                    ],
                    'onClientPublish' => [
                        'label'   => 'On Client Publish',
                        'lang'    => self::LANG_PHP,
                        'desc'    => 'A browser sent a message with pp.realtime.publish(). Authorise it (return false to reject) or reshape $data before it is broadcast to everyone on the channel.',
                        'args'    => ['$channel' => 'string — the target channel', '$event' => 'string — the event name the client sent', '$data' => 'mixed (by reference) — the client payload; edit it before it is relayed', '$user' => 'array — the sender (empty when not signed in)'],
                        'returns' => 'Return <code>false</code> to reject the message. Edit <code>$data</code> by reference to sanitise it. Return nothing to relay it.',
                        'stub'    => "<?php\n// Available: \$channel, \$event, \$data (by reference), \$user\n// Return false to reject a client message.\n",
                        'example' => "<?php\n// Trust nothing from the browser: cap length + stamp the sender\nif (\$event === 'chat') {\n    \$data = ['text' => mb_substr((string) (\$data['text'] ?? ''), 0, 500), 'from' => \$user['username'] ?? 'guest'];\n}\n",
                        'idea'    => 'This is your security boundary for browser-sent messages — <b>never</b> relay client data unchecked. Validate the channel, cap sizes, strip HTML, and stamp the real sender from <code>$user</code> rather than trusting anything the client claims.',
                    ],
                    'onPresenceChange' => [
                        'label'   => 'On Presence Change',
                        'lang'    => self::LANG_JS,
                        'desc'    => 'The set of people present on a channel changed (someone joined or left). Use it to render a “who’s online” list or avatars.',
                        'args'    => ['$channel' => 'string — the channel', '$members' => 'array — current members [{ member, name }]'],
                        'returns' => 'void',
                        'stub'    => "// Available: \$channel, \$members ([{ member, name }])\n",
                        'example' => "// Update a live “online” count\nvar el = document.getElementById('online-count');\nif (el) el.textContent = \$members.length + ' online';\n",
                        'idea'    => 'Fires whenever the member list changes. Join <code>pp.realtime.presence(channel)</code> first; read the full list any time with <code>pp.realtime.whoIsHere(channel)</code>. Perfect for online indicators and collaborative avatars.',
                    ],
                    'onRealtimeConnect' => [
                        'label'   => 'On Connect',
                        'lang'    => self::LANG_JS,
                        'desc'    => 'The live connection just opened (or reconnected). A good place to (re)subscribe to channels and refresh anything that may have changed while offline.',
                        'args'    => ['$status' => 'string — "open" (SSE) or "polling" (fallback transport)'],
                        'returns' => 'void',
                        'stub'    => "// Available: \$status\n// The realtime connection is live. Subscribe to your channels here.\n",
                        'example' => "// Subscribe once the connection is up\npp.realtime.subscribe('orders');\n",
                        'idea'    => 'Do your <code>pp.realtime.subscribe(...)</code> calls here rather than on page load — this fires again after an automatic reconnect, so subscriptions survive a dropped connection.',
                    ],
                    'onRealtimeMessage' => [
                        'label'   => 'On Message',
                        'lang'    => self::LANG_JS,
                        'desc'    => 'A live message arrived on a subscribed channel — the heart of a real-time UI. Update the DOM, show a toast, or bump a counter.',
                        'args'    => ['$data' => 'mixed — the payload sent by pp_broadcast()', '$meta' => 'object — { channel, event, id }'],
                        'returns' => 'void',
                        'stub'    => "// Available: \$data, \$meta ({ channel, event, id })\n",
                        'example' => "// Toast every new order as it happens\nif (\$meta.event === 'created') {\n    pp.dialog.toast('info', 'New order ' + \$data.ref);\n}\n",
                        'idea'    => 'Prefer a specific <code>pp.realtime.on(event, handler)</code> for a single event; use this catch-all when a page reacts to many event types. Keep handlers quick and idempotent — messages can arrive in bursts after a reconnect.',
                    ],
                    'onRealtimeDisconnect' => [
                        'label'   => 'On Disconnect',
                        'lang'    => self::LANG_JS,
                        'desc'    => 'The live connection dropped. It will reconnect automatically — use this only to reflect an “offline” state in the UI.',
                        'args'    => ['$reason' => 'string — a short reason, when known'],
                        'returns' => 'void',
                        'stub'    => "// Available: \$reason\n// Connection lost — it will auto-reconnect. Show an offline hint if you like.\n",
                        'example' => "// Dim a “live” badge while we’re offline\nvar b = document.getElementById('live-badge');\nif (b) b.classList.add('is-offline');\n",
                        'idea'    => 'Don’t reload or re-fetch here — the client retries with backoff on its own, and <b>On Connect</b> fires again when it’s back. Just show a subtle indicator so users know updates paused.',
                    ],
                ],
            ],
        ];
    }

    // ---------------------------------------------------------------
    // Table-level event groups
    // ---------------------------------------------------------------

    public static function tableGroups(): array
    {
        return [
            'page' => [
                'label'  => 'Page Events',
                'icon'   => 'file-earmark-code',
                'color'  => '#2563eb',
                'lang'   => self::LANG_PHP,
                'events' => [
                    'afterListLoad' => [
                        'label'   => 'After List Load',
                        'desc'    => 'Called after the current page of list records is fetched. Add computed columns to each row, or compute a summary. $records is the current page only; $total is the full count.',
                        'args'    => ['$records' => 'array (by reference) — the rows for this page', '$total' => 'int — total matching row count'],
                        'returns' => 'void (modify $records by reference)',
                        'stub'    => "<?php\n// \$records is by reference — add a computed column to each row.\nforeach (\$records as &\$row) {\n    \$row['full_name'] = trim((\$row['first_name'] ?? '') . ' ' . (\$row['last_name'] ?? ''));\n}\nunset(\$row);\n",
                        'example' => "<?php\n// Publish a page total to the browser (read it in onPageInit via pp.getProxy)\n\$sum = array_sum(array_map(fn(\$r) => (float) (\$r['amount'] ?? 0), \$records));\npp_set_proxy('pageTotal', \$sum);\n",
                    ],
                    'beforeAddLoad' => [
                        'label'   => 'Before Add Load',
                        'desc'    => 'Called before the Add form renders. Inject default field values.',
                        'args'    => ['$defaults' => 'array (by reference) — default field values'],
                        'returns' => 'void (modify $defaults by reference)',
                        'stub'    => "<?php\n// Pre-fill fields for the Add form\n\$defaults['created_by'] = \$user['username'];\n\$defaults['status'] = 'draft';\n",
                    ],
                    'beforeAddSave' => [
                        'label'   => 'Before Add Save',
                        'desc'    => 'Called before a new record is inserted. Add/override values in $data, stamp audit fields, or block the save.',
                        'args'    => ['$data' => 'array (by reference) — the values about to be inserted', '$errors' => 'array — field→message errors gathered so far'],
                        'returns' => 'Modify $data in place. Return <code>false</code> to cancel the save, or an array of <code>[\'field\' => \'message\']</code> to reject with validation errors.',
                        'stub'    => "<?php\n// \$data is by reference — changes here are saved.\n\$data['created_by'] = Security::CurrentUserData()['id'] ?? null;\n\$data['created_at'] = date('Y-m-d H:i:s');\n\n// Reject with a field message:\n// if (empty(\$data['email'])) return ['email' => 'Email is required'];\n// Or cancel silently: return false;\n",
                        'example' => "<?php\n// Generate a unique reference and enforce a business rule\n\$data['ref'] = 'INV-' . date('Y') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);\n\nif ((float) (\$data['amount'] ?? 0) <= 0) {\n    return ['amount' => 'Amount must be greater than zero'];\n}\n",
                    ],
                    'afterAddSave' => [
                        'label'   => 'After Add Save',
                        'desc'    => 'Called after a new record is successfully inserted. The record now has its primary key ($id). Good for notifications, related-table updates, or audit logs.',
                        'args'    => ['$record' => 'array — the inserted values', '$id' => 'int|string — the new primary key'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// The record now exists with id = \$id.\n// Email::send([...]); DB::Insert('audit', [...]); etc.\n",
                        'example' => "<?php\n// Notify ops and write an audit trail after a new order is created\nEmail::send([\n    'to' => 'ops@example.com',\n    'subject' => 'New order ' . (\$record['ref'] ?? \$id),\n    'body' => 'A new order was placed (id ' . \$id . ').',\n]);\n\PpPhp\Core\DB::Insert('audit_log', [\n    'entity' => 'order', 'entity_id' => \$id, 'action' => 'created',\n    'user_id' => Security::CurrentUserData()['id'] ?? null,\n    'at' => date('Y-m-d H:i:s'),\n]);\n",
                    ],
                    'beforeEditLoad' => [
                        'label'   => 'Before Edit Load',
                        'desc'    => 'Called after the record is fetched for editing, before the form renders.',
                        'args'    => ['$record' => 'array (by reference)'],
                        'returns' => 'void (modify $record by reference)',
                        'stub'    => "<?php\n// Decrypt, transform, or add virtual fields before the Edit form renders\n",
                    ],
                    'beforeEditSave' => [
                        'label'   => 'Before Edit Save',
                        'desc'    => 'Called before an existing record is updated. Compare $data with $old to detect changes, stamp audit fields, or block the update.',
                        'args'    => ['$data' => 'array (by reference) — the new values', '$id' => 'int|string — primary key', '$old' => 'array — the record before editing', '$errors' => 'array — field→message errors so far'],
                        'returns' => 'Modify $data in place. Return <code>false</code> to cancel, or <code>[\'field\' => \'message\']</code> to reject with errors.',
                        'stub'    => "<?php\n// \$data = new values, \$old = the record before editing, \$id = primary key.\n\$data['updated_at'] = date('Y-m-d H:i:s');\n\n// React to a specific change:\n// if ((\$old['status'] ?? '') !== (\$data['status'] ?? '')) { /* log status change */ }\n",
                        'example' => "<?php\n// Block editing a locked record, and record who changed the status\nif (!empty(\$old['locked'])) return false;\nif ((\$old['status'] ?? '') !== (\$data['status'] ?? '')) {\n    \$data['status_changed_by'] = Security::CurrentUserData()['id'] ?? null;\n}\n",
                    ],
                    'afterEditSave' => [
                        'label'   => 'After Edit Save',
                        'desc'    => 'Called after an existing record is successfully updated.',
                        'args'    => ['$record' => 'array — the updated record', '$id' => 'int|string', '$old' => 'array — record before the edit'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Available: \$record, \$id, \$old\n",
                    ],
                    'beforeDelete' => [
                        'label'   => 'Before Delete',
                        'desc'    => 'Called before a record is deleted (fires once per row, including bulk delete). Enforce referential or business rules here.',
                        'args'    => ['$record' => 'array — the row about to be deleted', '$id' => 'int|string — its primary key'],
                        'returns' => 'Return <code>false</code> to cancel the deletion of this row.',
                        'stub'    => "<?php\n// Available: \$record, \$id. Return false to block the delete.\n// if ((\$record['status'] ?? '') === 'locked') return false;\n",
                        'example' => "<?php\n// Don't allow deleting a customer that still has orders\n\$open = (int) \PpPhp\Core\DB::FetchOne(\n    'SELECT COUNT(*) FROM orders WHERE customer_id = ?', [\$id]\n);\nif (\$open > 0) return false;\n",
                    ],
                    'afterDelete' => [
                        'label'   => 'After Delete',
                        'desc'    => 'Called after a record has been permanently deleted.',
                        'args'    => ['$id' => 'int|string', '$record' => 'array — snapshot before deletion'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Cascade soft-deletes, clean up files, update counts, etc.\n",
                    ],
                    'beforeViewLoad' => [
                        'label'   => 'Before View Load',
                        'desc'    => 'Called after the single record is fetched for the View page, before it renders. Enrich $record, or publish values to the browser with pp_set_proxy().',
                        'args'    => ['$record' => 'array (by reference) — the record being viewed'],
                        'returns' => 'void (modify $record by reference)',
                        'stub'    => "<?php\n// Enrich the record before it is displayed.\n// Pass a value to JavaScript with pp_set_proxy() — read it in a\n// client (JS) event as pp.proxy.fullName or pp.getProxy('fullName').\npp_set_proxy('fullName', trim((\$record['first_name'] ?? '') . ' ' . (\$record['last_name'] ?? '')));\npp_set_proxy('record', \$record); // enables pp.record() on the client\n",
                        'example' => "<?php\n// Load related data and expose it to the view's JavaScript\n\$orders = \PpPhp\Core\DB::FetchAll(\n    'SELECT id, ref, amount FROM orders WHERE customer_id = ? ORDER BY created_at DESC LIMIT 5',\n    [\$record['id']]\n);\npp_set_proxy('recentOrders', \$orders); // read via pp.getProxy('recentOrders') in onPageInit\n",
                    ],
                    'beforeSearch' => [
                        'label'   => 'Before Search',
                        'desc'    => 'Called before a search/filter is applied to the list.',
                        'args'    => ['$params' => 'array (by reference) — search parameters'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Sanitize or transform search parameters\n",
                    ],
                    'beforeExport' => [
                        'label'   => 'Before Export',
                        'desc'    => 'Called just before an export file is generated. Use it to allow or deny the export based on the format or the current user.',
                        'args'    => ['$format' => 'string — the export format, e.g. "csv"'],
                        'returns' => 'Return <code>false</code> to cancel the export.',
                        'stub'    => "<?php\n// Available: \$format (e.g. 'csv')\n// Return false to block the export.\n// if (!Security::HasPermission('orders', 'S')) return false;\n",
                        'example' => "<?php\n// Only managers may export, and only CSV\n\$me = Security::CurrentUserData();\nif (\$format !== 'csv' || (\$me['group'] ?? '') !== 'manager') {\n    return false; // cancels the export\n}\n",
                    ],
                    'afterExport' => [
                        'label'   => 'After Export',
                        'desc'    => 'Called after an export file has been generated and sent.',
                        'args'    => ['$filename' => 'string — path to the generated file', '$format' => 'string'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Log the export, email the file, etc.\n",
                    ],
                    'beforeImport' => [
                        'label'   => 'Before Import',
                        'desc'    => 'Called before an uploaded file is imported into this table. Inspect the file and the column mapping; block the import if needed.',
                        'args'    => ['$file' => 'string — path to the uploaded import file', '$map' => 'array — the column → field mapping the user chose'],
                        'returns' => 'Return <code>false</code> to cancel the import.',
                        'stub'    => "<?php\n// Available: \$file, \$map\n// Return false to block the import (e.g. wrong columns, or no permission).\n// if (!Security::HasPermission('TABLE', 'I')) return false;\n",
                        'example' => "<?php\n// Require the mapping to include an email column\nif (!in_array('email', \$map, true)) {\n    return false; // cancels the import\n}\n",
                    ],
                    'afterImport' => [
                        'label'   => 'After Import',
                        'desc'    => 'Called after an import finishes, with the row counts. Runs once for the whole file (per-row saves also fire the normal after-add / after-edit events).',
                        'args'    => ['$imported' => 'int — rows inserted', '$updated' => 'int — rows updated', '$skipped' => 'int — rows skipped', '$failed' => 'int — rows that errored'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Available: \$imported, \$updated, \$skipped, \$failed\n// Notify an admin or write an audit entry summarising the import.\n",
                    ],
                    'onPrint' => [
                        'label'   => 'On Print',
                        'desc'    => 'Called on the printable page after the rows are fetched, before they are rendered. Modify the row set for print output.',
                        'args'    => ['$records' => 'array (by reference) — every row to be printed', '$type' => 'string — "print"'],
                        'returns' => 'void (modify $records by reference)',
                        'stub'    => "<?php\n// Adjust the rows shown on the printable page (\$records is by reference)\nforeach (\$records as &\$row) {\n    \$row['printed_at'] = date('Y-m-d H:i');\n}\nunset(\$row);\n",
                        'example' => "<?php\n// Only print rows that are marked ready, and add a running number\n\$records = array_values(array_filter(\$records, fn(\$r) => (\$r['status'] ?? '') === 'ready'));\nforeach (\$records as \$i => &\$row) { \$row['line_no'] = \$i + 1; }\nunset(\$row);\n",
                    ],
                    'processValues' => [
                        'label'   => 'Process Record Values',
                        'desc'    => 'Runs once per row before it is shown on the list, view, and print pages. Transform values for DISPLAY ONLY — the stored data is never changed. $action tells you which page is rendering.',
                        'args'    => ['$record' => 'array (by reference) — the row being displayed', '$action' => 'string — "list" | "view" | "print"'],
                        'returns' => 'void (modify $record by reference)',
                        'stub'    => "<?php\n// Display-only transform (does NOT change stored data). \$action = list|view|print.\n\$record['status_label'] = match (\$record['status'] ?? '') {\n    'a' => 'Active', 'i' => 'Inactive', default => (\$record['status'] ?? ''),\n};\n",
                        'example' => "<?php\n// Add a currency-formatted column, and flag big amounts on the list page\n\$record['amount_fmt'] = '\$' . number_format((float) (\$record['amount'] ?? 0), 2);\nif (\$action === 'list' && (float) (\$record['amount'] ?? 0) > 1000) {\n    \$record['amount_fmt'] .= ' (VIP)';\n}\n",
                    ],
                    'onCopyLoad' => [
                        'label'   => 'Copy Page: OnLoad',
                        'desc'    => 'Called when the Copy (duplicate) page loads. Modify default field values for the copy before it is rendered.',
                        'args'    => ['$source' => 'array — the original record', '$defaults' => 'array (by reference) — pre-filled copy values'],
                        'returns' => 'void (modify $defaults by reference)',
                        'stub'    => "<?php\n// Adjust values when duplicating a record\n// \$defaults['name'] = 'Copy of ' . \$source['name'];\n// unset(\$defaults['created_at']);\n",
                    ],
                    'afterBulkDelete' => [
                        'label'   => 'After Bulk Delete',
                        'desc'    => 'Called after a group of records has been deleted via the bulk-action checkbox.',
                        'args'    => ['$ids' => 'array — deleted primary key values', '$records' => 'array — snapshots of deleted records'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Available: \$ids, \$records\n// Cascade cleanup after bulk delete\n",
                    ],
                ],
            ],

            'sql' => [
                'label'  => 'SQL Modifiers',
                'icon'   => 'database',
                'color'  => '#64748b',
                'lang'   => self::LANG_PHP,
                'events' => [
                    'sqlWhere' => [
                        'label'   => 'Custom WHERE',
                        'desc'    => 'Return an extra WHERE condition that is AND-ed into the list & export queries for this table. The classic way to do row-level security (show each user only their own rows).',
                        'args'    => ['$user' => 'array — the logged-in user ($_SESSION user)', '$params' => 'array — the request ($_GET) context'],
                        'returns' => 'Return a SQL condition string WITHOUT the "WHERE" keyword. Return \'\' / nothing to add nothing. IMPORTANT: build it from trusted values only.',
                        'stub'    => "<?php\n// Return a condition (no 'WHERE' keyword) that is AND-ed to the query.\n\$me = (int) (\$user['id'] ?? 0);\nreturn \"owner_id = \$me\";\n",
                        'example' => "<?php\n// Managers see everything; everyone else sees only their team's rows\nif ((\$user['group'] ?? '') === 'manager') return '';\n\$team = (int) (\$user['team_id'] ?? 0);\nreturn \"team_id = \$team\";\n",
                    ],
                    'sqlOrderBy' => [
                        'label'   => 'Custom ORDER BY',
                        'desc'    => 'Override the default sort order for the list query on this table.',
                        'args'    => ['$user' => 'array — the logged-in user', '$params' => 'array — the request context'],
                        'returns' => 'Return an ORDER BY fragment WITHOUT the "ORDER BY" keyword, e.g. <code>created_at DESC, name ASC</code>. Return \'\' to keep the default sort.',
                        'stub'    => "<?php\n// Return an ORDER BY fragment (no 'ORDER BY' keyword).\nreturn 'created_at DESC, id DESC';\n",
                        'example' => "<?php\n// Pin pinned rows to the top, then newest first\nreturn 'is_pinned DESC, created_at DESC';\n",
                    ],
                ],
            ],

            'client' => [
                'label'  => 'Client Events',
                'icon'   => 'browser-safari',
                'color'  => '#d97706',
                'lang'   => self::LANG_JS,
                'events' => [
                    'afterListRender' => [
                        'label'   => 'After List Render',
                        'desc'    => 'Runs in the browser once the list grid is in the DOM. Enhance the rendered table (row colours, tooltips, badges).',
                        'args'    => ['data' => 'object — { table }', 'container' => 'HTMLElement — the .ppd-grid-table-wrap element'],
                        'returns' => 'void',
                        'stub'    => "// The grid is now in the page. Enhance it here.\n// container = the list wrapper element; pp.grid.allRecords() = the row data.\ndocument.querySelectorAll('.ppd-grid-table tbody tr').forEach(function (tr) {\n    tr.style.cursor = 'pointer';\n});\n",
                        'example' => "// Tint overdue rows red using the per-row record data\npp.grid.rows().forEach(function (tr) {\n    var rec = pp.grid.rowData(tr);\n    if (rec && rec.due_date && new Date(rec.due_date) < new Date()) {\n        tr.style.background = '#fef2f2';\n    }\n});\n",
                    ],
                    'onRowClick' => [
                        'label'   => 'On Row Click',
                        'desc'    => 'Fires when the user clicks anywhere on a list row (except on a button, link, or checkbox). record holds that row\'s data.',
                        'args'    => ['record' => 'object — the clicked row\'s values (id + listed fields)', 'row' => 'HTMLElement — the <tr> element', 'event' => 'MouseEvent'],
                        'returns' => 'Return <code>false</code> to suppress the default behaviour.',
                        'stub'    => "// record = the clicked row's data. row = the <tr>. event = the click.\npp.dialog.toast('info', 'You clicked #' + record.id);\n// pp.run('openDrawer', { id: record.id });\n",
                        'example' => "// Open a details drawer by calling a server action with the row id\nasync function run() {\n    const detail = await pp.run('loadDetail', { id: record.id });\n    pp.dialog.alert('Order ' + record.id, detail.summary);\n}\nrun();\n",
                    ],
                    'onRowSelect' => [
                        'label'   => 'On Row Select',
                        'desc'    => 'Fires whenever a row checkbox (or the select-all checkbox) is toggled on the list page. records/ids reflect the CURRENT selection.',
                        'args'    => ['records' => 'array — the currently selected rows\' data', 'ids' => 'array — their primary key values'],
                        'returns' => 'void',
                        'stub'    => "// records = selected rows' data, ids = their primary keys.\n// (Same as pp.grid.selectedRecords() / pp.grid.selectedIds().)\nif (ids.length) {\n    const total = records.reduce((s, r) => s + Number(r.amount || 0), 0);\n    pp.dialog.toast('info', ids.length + ' selected · total ' + total);\n}\n",
                        'example' => "// Enable a custom \"Approve selected\" button only when rows are ticked,\n// and wire it to a server action.\nconst btn = document.getElementById('approveBtn');\nif (btn) {\n    btn.disabled = ids.length === 0;\n    btn.onclick = async () => {\n        await pp.run('approveMany', { ids: ids });\n        pp.grid.reload();\n    };\n}\n",
                    ],
                    'beforeFormSubmit' => [
                        'label'   => 'Before Form Submit',
                        'desc'    => 'Fires when the Add or Edit form is submitted, before it is sent to the server. The last chance to validate on the client or stop the submission.',
                        'args'    => ['data' => 'object — the current form values', 'form' => 'HTMLFormElement', 'action' => 'string — "add" | "edit"'],
                        'returns' => 'Return <code>false</code> to cancel the submission (the page stays on the form).',
                        'stub'    => "// data = current form values, action = 'add' | 'edit'.\n// Return false to stop the submit.\nif (!data.email) {\n    pp.form.message('email', 'Email is required');\n    return false;\n}\n",
                        'example' => "// Confirm a risky change before saving\nif (action === 'edit' && data.status === 'cancelled') {\n    if (!confirm('Really cancel this order?')) return false;\n}\n",
                    ],
                    'afterDeleteSuccess' => [
                        'label'   => 'After Delete Success',
                        'desc'    => 'Runs in the browser after a row is deleted from the list via the inline delete action (requires jQuery on the page). Use it to refresh totals or show a toast.',
                        'args'    => ['id' => 'null — the deleted id is not passed; read the grid instead'],
                        'returns' => 'void',
                        'stub'    => "// A row was just deleted from the list.\npp.dialog.toast('success', 'Record deleted');\n// pp.grid.reload(); // or update a counter, chart, etc.\n",
                        'example' => "// After a delete, update an \"N records\" badge in the toolbar\nvar badge = document.querySelector('.pp-count-badge');\nif (badge) badge.textContent = pp.grid.rows().length + ' records';\n",
                    ],
                    'onPageInit' => [
                        'label'   => 'On Page Init',
                        'desc'    => 'Fired once when this generated page becomes ready in the browser. Use page.table and page.action to target List, Add, Edit, View, Print, Import, Export, Cards, Search, Dashboard, or Custom pages.',
                        'args'    => ['page' => 'object — { table, action, id? }; action identifies the generated page type'],
                        'returns' => 'void',
                        'stub'    => "// Runs once for each generated page load.\n// page = { table, action, id? }; pp.page exposes the same context.\nif (page.table !== 'your_table') return;\n\nswitch (page.action) {\n    case 'list':\n        // One-time list setup. Use After List Render for AJAX re-renders.\n        break;\n    case 'add':\n    case 'edit':\n        // Wire form controls; always check control.exists().\n        break;\n    case 'view':\n        // Enhance the read-only record view.\n        break;\n}\n",
                        'example' => "// One shared event can safely target several generated page types.\nswitch (page.action) {\n    case 'add':\n        if (Runner.getControl('email').exists()) Runner.getControl('email').focus();\n        break;\n    case 'edit':\n        pp.dialog.toast('info', 'Editing record ' + (page.id || ''));\n        break;\n    case 'list':\n    case 'cards':\n        document.body.classList.add('records-workspace');\n        break;\n}\n",
                        'pageActions' => [
                            'list'      => ['label' => 'List', 'purpose' => 'One-time list toolbar, filters, keyboard, and page-level setup.', 'followup' => 'Use After List Render for rows or markup replaced by AJAX.'],
                            'add'       => ['label' => 'Add', 'purpose' => 'Initialize a new-record form, defaults, focus, and dependent controls.', 'followup' => 'Use Before Form Submit for final client validation; Before Add Save for trusted server rules.'],
                            'edit'      => ['label' => 'Edit', 'purpose' => 'Initialize an existing-record form; page.id may identify the record.', 'followup' => 'Use Before Edit Save for authorization-sensitive enforcement.'],
                            'view'      => ['label' => 'View', 'purpose' => 'Enhance the read-only record view, links, copy actions, or presentation.', 'followup' => 'Do not treat hidden browser controls as access control.'],
                            'print'     => ['label' => 'Print', 'purpose' => 'Prepare print-only headings, dates, notes, and expanded sections.', 'followup' => 'Keep interactive-only behavior out of the print branch.'],
                            'import'    => ['label' => 'Import', 'purpose' => 'Initialize upload/paste guidance and import workflow helpers.', 'followup' => 'Validate again on the server before importing records.'],
                            'export'    => ['label' => 'Export', 'purpose' => 'Prepare export options, notices, and duplicate-click protection.', 'followup' => 'Enforce export permissions and field scope on the server.'],
                            'cards'     => ['label' => 'Cards', 'purpose' => 'Initialize the card-results page and page-level interactions.', 'followup' => 'Use After List Render for cards that refresh after search or paging.'],
                            'search'    => ['label' => 'Search', 'purpose' => 'Initialize the dedicated search page and search guidance.', 'followup' => 'Use Before Search to normalize trusted query conditions.'],
                            'dashboard' => ['label' => 'Dashboard', 'purpose' => 'Start dashboard-level UI, observers, and lightweight refresh behavior.', 'followup' => 'Pause expensive refresh work while the page is hidden.'],
                            'custom'    => ['label' => 'Custom', 'purpose' => 'Initialize a generated custom page and its client interactions.', 'followup' => 'Feature-detect optional DOM elements before using them.'],
                        ],
                        'guidance' => [
                            'Guard with page.table and page.action before touching controls or page-specific markup.',
                            'Use a switch when one event handles several page types; keep each branch short and explicit.',
                            'Call control.exists() before getValue(), focus(), visible(), or other control methods.',
                            'Make DOM inserts idempotent: reuse a stable id or data attribute so navigation and partial refreshes cannot duplicate them.',
                            'On Page Init runs once. Put row/card behavior that must survive AJAX paging, filtering, or sorting in After List Render.',
                            'Hidden, disabled, or read-only controls improve UX only. Enforce authorization and ownership in PHP save events.',
                            'Debounce rapid input and cancel stale fetches with AbortController when implementing live lookups.',
                            'Prefer accessible status regions, keyboard-safe controls, and reduced-motion fallbacks.',
                            'Store only non-sensitive convenience state in sessionStorage; never persist passwords, tokens, or protected record data there.',
                        ],
                        'examples' => [
                            ['title' => 'Branch by generated page type', 'code' => "switch (page.action) {\n    case 'list': console.info('List is ready'); break;\n    case 'add': console.info('Add form is ready'); break;\n    case 'edit': console.info('Editing', page.id); break;\n    case 'view': console.info('Viewing', page.id); break;\n    case 'print': window.print(); break;\n}"],
                            ['title' => 'Safely initialize an Add or Edit control', 'code' => "if (['add', 'edit'].includes(page.action)) {\n    const email = Runner.getControl('email');\n    if (email.exists()) {\n        email.placeholder('name@example.com');\n        if (page.action === 'add') email.focus();\n    }\n}"],
                            ['title' => 'Create an idempotent page notice', 'code' => "if (!document.getElementById('project-page-note')) {\n    const note = document.createElement('div');\n    note.id = 'project-page-note';\n    note.className = 'alert alert-info';\n    note.textContent = 'Review the record before continuing.';\n    document.querySelector('main')?.prepend(note);\n}"],
                            ['title' => 'Respect reduced-motion preferences', 'code' => "const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;\ndocument.documentElement.classList.toggle('reduce-motion', reduced);"],
                            ['title' => 'Pause dashboard work in a background tab', 'code' => "if (page.action === 'dashboard') {\n    document.addEventListener('visibilitychange', () => {\n        console.info(document.hidden ? 'Pause refresh' : 'Resume refresh');\n    });\n}"],
                        ],
                    ],
                ],
            ],
        ];
    }

    /**
     * Groups the global event categories under logical super-headers for the
     * Events tree (DISPLAY only — each group's key is unchanged). Ordered by the
     * app flow: Application lifecycle first, then users, then external I/O, then
     * automation. 'schedule' and 'functions' are special groups handled by the SPA.
     *
     * @return list<array{key:string,label:string,groups:list<string>}>
     */
    public static function globalSections(): array
    {
        return [
            ['key' => 'app',        'label' => 'Application',        'groups' => ['app']],
            ['key' => 'security',   'label' => 'Users & Security',   'groups' => ['auth']],
            ['key' => 'io',         'label' => 'Integrations & I/O', 'groups' => ['email', 'file', 'api', 'realtime']],
            ['key' => 'automation', 'label' => 'Automation & Code',  'groups' => ['actions', 'schedule', 'functions']],
        ];
    }

    /**
     * Page-wise presentation of the table events. This is a DISPLAY grouping
     * only — each event's STORAGE group (page / sql / client) is unchanged, so
     * saved code and the generated fire-calls keep working. The Events SPA uses
     * this to organise the tree + panel by page type (List, Add, Edit, …); it
     * pulls each event's definition from tableGroups() by [group, event].
     *
     * @return list<array{key:string,label:string,icon:string,color:string,desc:string,events:list<array{0:string,1:string}>}>
     */
    public static function tableSections(): array
    {
        return [
            ['key' => 'list', 'label' => 'List Page', 'icon' => 'list-ul', 'color' => '#2563eb',
             'desc' => 'Runs on the records list / grid page.',
             'events' => [
                 ['page', 'beforeSearch'], ['page', 'afterListLoad'], ['page', 'afterBulkDelete'],
                 ['client', 'afterListRender'], ['client', 'onRowClick'], ['client', 'onRowSelect'],
             ]],
            ['key' => 'add', 'label' => 'Add Page', 'icon' => 'plus-circle', 'color' => '#059669',
             'desc' => 'Runs when creating a new record.',
             'events' => [
                 ['page', 'beforeAddLoad'], ['page', 'beforeAddSave'], ['page', 'afterAddSave'],
                 ['page', 'onCopyLoad'],
             ]],
            ['key' => 'edit', 'label' => 'Edit Page', 'icon' => 'pencil-square', 'color' => '#d97706',
             'desc' => 'Runs when editing an existing record.',
             'events' => [
                 ['page', 'beforeEditLoad'], ['page', 'beforeEditSave'], ['page', 'afterEditSave'],
             ]],
            ['key' => 'form', 'label' => 'Add / Edit Form', 'icon' => 'ui-checks-grid', 'color' => '#7c3aed',
             'desc' => 'Client-side form events shared by Add & Edit.',
             'events' => [
                 ['client', 'beforeFormSubmit'],
             ]],
            ['key' => 'view', 'label' => 'View Page', 'icon' => 'eye', 'color' => '#0891b2',
             'desc' => 'Runs on the single-record view page.',
             'events' => [
                 ['page', 'beforeViewLoad'],
             ]],
            ['key' => 'delete', 'label' => 'Delete', 'icon' => 'trash3', 'color' => '#dc2626',
             'desc' => 'Runs when a record is deleted.',
             'events' => [
                 ['page', 'beforeDelete'], ['page', 'afterDelete'], ['client', 'afterDeleteSuccess'],
             ]],
            ['key' => 'print', 'label' => 'Print Page', 'icon' => 'printer', 'color' => '#475569',
             'desc' => 'Runs on the printable / PDF view.',
             'events' => [
                 ['page', 'onPrint'],
             ]],
            ['key' => 'export', 'label' => 'Export', 'icon' => 'box-arrow-down', 'color' => '#0d9488',
             'desc' => 'Runs when exporting records (CSV / Excel / …).',
             'events' => [
                 ['page', 'beforeExport'], ['page', 'afterExport'],
             ]],
            ['key' => 'import', 'label' => 'Import', 'icon' => 'box-arrow-in-up', 'color' => '#0ea5e9',
             'desc' => 'Runs when importing records from an uploaded file (CSV / Excel / …).',
             'events' => [
                 ['page', 'beforeImport'], ['page', 'afterImport'],
             ]],
            ['key' => 'record', 'label' => 'Records & Values', 'icon' => 'arrow-repeat', 'color' => '#64748b',
             'desc' => 'Per-row value formatting shared by list, view & print.',
             'events' => [
                 ['page', 'processValues'],
             ]],
            ['key' => 'sql', 'label' => 'SQL & Data', 'icon' => 'database', 'color' => '#334155',
             'desc' => 'Modify the list query (extra WHERE and ORDER BY).',
             'events' => [
                 ['sql', 'sqlWhere'], ['sql', 'sqlOrderBy'],
             ]],
            ['key' => 'general', 'label' => 'General (Client)', 'icon' => 'globe2', 'color' => '#6366f1',
             'desc' => 'Client-side events that apply across pages.',
             'events' => [
                 ['client', 'onPageInit'],
             ]],
            ['key' => 'fields', 'label' => 'Field Events', 'icon' => 'input-cursor-text', 'color' => '#0e7490',
             'desc' => 'Per-field server & client events (format, validate, onChange…).',
             'events' => []],   // special: rendered from the field-event catalogue
        ];
    }

    // ---------------------------------------------------------------
    // Field-level event groups
    // ---------------------------------------------------------------

    public static function fieldGroups(): array
    {
        return [
            'server' => [
                'label'  => 'Server Events',
                'icon'   => 'server',
                'color'  => '#2563eb',
                'lang'   => self::LANG_PHP,
                'events' => [
                    'format' => [
                        'label'   => 'Format',
                        'desc'    => 'Transform the stored value for display in list, view, and print pages.',
                        'args'    => ['$value' => 'mixed — raw database value', '$record' => 'array — full row'],
                        'returns' => 'Return the formatted string (may include HTML).',
                        'stub'    => "<?php\n// Transform \$value for display\n// return '<strong>' . htmlspecialchars(\$value) . '</strong>';\nreturn \$value;\n",
                    ],
                    'calculate' => [
                        'label'   => 'Calculate',
                        'desc'    => 'Compute this field\'s value from other fields in the row when the list is displayed (a virtual/computed column). Runs per row on the list page.',
                        'args'    => ['$record' => 'array — the full row', '$value' => 'mixed — the current stored value'],
                        'returns' => 'Return the value to display for this field.',
                        'stub'    => "<?php\n// Derive this field from other columns in the row.\nreturn (float) (\$record['qty'] ?? 0) * (float) (\$record['unit_price'] ?? 0);\n",
                        'example' => "<?php\n// Days since the record was created\n\$created = strtotime((string) (\$record['created_at'] ?? 'now'));\nreturn (int) ((time() - \$created) / 86400) . ' days';\n",
                    ],
                    'validate' => [
                        'label'   => 'Validate',
                        'desc'    => 'Server-side validation for THIS field, run on every Add and Edit save. This is the trustworthy check (the browser can be bypassed). $id is null on Add.',
                        'args'    => ['$value' => 'mixed — the submitted value for this field', '$record' => 'array — all submitted fields', '$id' => 'int|string|null — null on Add, the primary key on Edit'],
                        'returns' => 'Return an error message string to reject. Return <code>null</code> / nothing to accept.',
                        'stub'    => "<?php\n// Return an error string to reject, or null/nothing to accept.\nif (strlen(trim((string) \$value)) < 5) {\n    return 'Must be at least 5 characters';\n}\n",
                        'example' => "<?php\n// Enforce a unique SKU across the table (ignore the current row on edit)\n\$sql = 'SELECT COUNT(*) FROM products WHERE sku = ?' . (\$id ? ' AND id <> ?' : '');\n\$args = \$id ? [\$value, \$id] : [\$value];\nif ((int) \PpPhp\Core\DB::FetchOne(\$sql, \$args) > 0) {\n    return 'That SKU is already in use';\n}\n",
                    ],
                    'beforeSet' => [
                        'label'   => 'Before Set',
                        'desc'    => 'Transform the value before it is written to the database.',
                        'args'    => ['$value' => 'mixed (by reference)', '$record' => 'array'],
                        'returns' => 'void (modify $value by reference) — or return the new value.',
                        'stub'    => "<?php\n// Transform \$value before it is saved\n// \$value = strtolower(trim(\$value));\n",
                    ],
                    'afterSet' => [
                        'label'   => 'After Set',
                        'desc'    => 'Called after the value has been written. Useful for cascade updates.',
                        'args'    => ['$value' => 'mixed — the stored value', '$id' => 'int|string', '$record' => 'array'],
                        'returns' => 'void',
                        'stub'    => "<?php\n// Available: \$value, \$id, \$record\n",
                    ],
                ],
            ],
            'client' => [
                'label'  => 'Client Events',
                'icon'   => 'cursor',
                'color'  => '#d97706',
                'lang'   => self::LANG_JS,
                'events' => [
                    'onChange' => [
                        'label'   => 'On Change',
                        'desc'    => 'Fires when the field value changes in the form.',
                        'args'    => ['value' => 'any — the new value', 'form' => 'pp.form — { get, set, show, hide, enable, disable, readonly, required, focus, message, data }'],
                        'returns' => 'void',
                        'stub'    => "// value = the new value. form = pp.form (or use pp.* directly).\n// Show a dependent field + compute another from this one:\nif (value === 'other') pp.form.show('other_detail'); else pp.form.hide('other_detail');\n// pp.val('total', Number(value) * 1.2);\n// pp.form.message('email', /@/.test(value) ? '' : 'Enter a valid email');\n",
                        'example' => "// Live line total: qty * price -> total (attach to qty AND price)\nconst qty = Number(pp.val('quantity')) || 0;\nconst price = Number(pp.val('unit_price')) || 0;\npp.val('line_total', (qty * price).toFixed(2));\n",
                    ],
                    'onFocus' => [
                        'label'   => 'On Focus',
                        'desc'    => 'Fires when the field receives focus.',
                        'args'    => ['value' => 'any', 'form' => 'object — form API'],
                        'returns' => 'void',
                        'stub'    => "// Available: value, form\n",
                    ],
                    'onBlur' => [
                        'label'   => 'On Blur',
                        'desc'    => 'Fires when the field loses focus.',
                        'args'    => ['value' => 'any', 'form' => 'object — form API'],
                        'returns' => 'void',
                        'stub'    => "// Available: value, form\n",
                    ],
                    'onKeyup' => [
                        'label'   => 'On Key Up',
                        'desc'    => 'Fires on each keyup while typing in the field.',
                        'args'    => ['value' => 'string', 'event' => 'KeyboardEvent', 'form' => 'object — form API'],
                        'returns' => 'void',
                        'stub'    => "// Filter or autocomplete as the user types\n",
                    ],
                ],
            ],
        ];
    }
}
