/* Events SPA — PpPhp */
(function () {
    'use strict';

    const boot = window.PpEventsBootstrap;
    const BASE = boot.baseUrl + '/project/' + boot.slug + '/events';

    // ── state ──────────────────────────────────────────────────────
    // Page-wise sections (List/Add/Edit/…) — DISPLAY grouping over the stable
    // storage groups (page/sql/client). Comes from the catalogue; fallback keeps
    // the old flat groups if the bootstrap is missing it.
    const TABLE_SECTIONS = (boot.tableSections && boot.tableSections.length) ? boot.tableSections : [
        { key:'page',   label:'Page Events',   icon:'file-earmark-code', color:'#2563eb', events:[] },
        { key:'sql',    label:'SQL Modifiers',  icon:'database',          color:'#64748b', events:[] },
        { key:'client', label:'Client Events',  icon:'browser-safari',    color:'#d97706', events:[] },
        { key:'fields', label:'Field Events',   icon:'input-cursor-text', color:'#0e7490', events:[] },
    ];
    function tableSectionByKey(k) { return TABLE_SECTIONS.find(s => s.key === k) || null; }

    const S = {
        // tree selection
        scope:       'global',   // 'global' | table-name
        section:     null,       // page-wise section key (list/add/edit/…/fields) when scope=table
        group:       null,       // storage group key within scope (auth, email, page, sql, client, …)
        eventKey:    null,       // event key within group
        field:       null,       // field name (when scope=table, section=fields)
        fieldGroup:  null,       // 'server' | 'client' (when in field scope)
        schedId:     null,       // schedule task id
        actionId:    null,       // server-action id

        // data
        events:      normalizeEvents(boot.events),
        tableFields: {},         // { [tableName]: string[] } — lazy-loaded
        catalogue:   {
            global:  boot.globalGroups,
            table:   boot.tableGroups,
            field:   boot.fieldGroups,
        },

        // editor
        dirty:       false,
        saving:      false,
        saveTimer:   null,
        activeDefinition: null,
        eventContext: null,
        // code checker
        lintTimer:   null,
        lintReq:     0,
        diagnostics: [],
        intellisense: [],
        filteredSuggestions: [],
        suggestionIndex: 0,
        suggestionToken: '',
        suggestionStart: 0,
        suggestionContext: null,
        suggestionQuoted: false,
        suggestionRequest: 0,
        snippet: null,   // active snippet tab-stops: { stops:[{start,end}], final, index, len }
    };

    // ── normalizers ────────────────────────────────────────────────
    function normalizeEvents(raw) {
        if (!raw || typeof raw !== 'object') return {};
        const e = { ...raw };
        if (!e.global)    e.global = {};
        if (!e.tables)    e.tables = {};
        if (!e.global.schedule)  e.global.schedule  = [];
        if (!Array.isArray(e.global.actions)) e.global.actions = [];
        if (!e.global.functions) e.global.functions = { php: { code: '', enabled: false }, js: { code: '', enabled: false } };
        return e;
    }

    function getEventEntry(path, defaultCode) {
        // path: ['global','auth','beforeLogin'] | ['tables','tbl','page','beforeListLoad']
        let node = S.events;
        for (let i = 0; i < path.length - 1; i++) {
            if (!node[path[i]]) node[path[i]] = {};
            node = node[path[i]];
        }
        const last = path[path.length - 1];
        if (!node[last]) node[last] = { code: defaultCode || '', enabled: false };
        return node[last];
    }

    function setEventCode(path, code) {
        let node = S.events;
        for (let i = 0; i < path.length - 1; i++) {
            if (!node[path[i]]) node[path[i]] = {};
            node = node[path[i]];
        }
        const last = path[path.length - 1];
        if (!node[last]) node[last] = { code: '', enabled: false };
        node[last].code    = code;
        node[last].enabled = code.trim().length > 0;
    }

    function countEnabledGlobalGroup(groupKey) {
        const g = S.events.global?.[groupKey] || {};
        return Object.values(g).filter(v => v && v.enabled).length;
    }

    function countEnabledTable(tableName) {
        const t = S.events.tables?.[tableName] || {};
        let n = 0;
        for (const grp of ['page', 'sql', 'client']) {
            const gv = t[grp] || {};
            n += Object.values(gv).filter(v => v && v.enabled).length;
        }
        const fields = t.fields || {};
        for (const fv of Object.values(fields)) {
            for (const grp of ['server', 'client']) {
                const gv = fv[grp] || {};
                n += Object.values(gv).filter(v => v && v.enabled).length;
            }
        }
        return n;
    }

    // Enabled events within a page-wise section (sum over its [group,event] pairs).
    function countEnabledTableFields(tableName) {
        const fields = S.events.tables?.[tableName]?.fields || {};
        let n = 0;
        for (const fv of Object.values(fields)) {
            for (const g of ['server', 'client']) n += Object.values(fv[g] || {}).filter(v => v && v.enabled).length;
        }
        return n;
    }
    function countEnabledTableSection(tableName, sec) {
        if (!sec) return 0;
        if (sec.key === 'fields') return countEnabledTableFields(tableName);
        let n = 0;
        for (const [grp, key] of (sec.events || [])) {
            const e = S.events.tables?.[tableName]?.[grp]?.[key];
            if (e && e.enabled) n++;
        }
        return n;
    }

    function countEnabledTableGroup(tableName, groupKey) {
        if (groupKey === 'fields') {
            const fields = S.events.tables?.[tableName]?.fields || {};
            let n = 0;
            for (const fv of Object.values(fields)) {
                for (const grp of ['server', 'client']) {
                    n += Object.values(fv[grp] || {}).filter(v => v && v.enabled).length;
                }
            }
            return n;
        }
        const g = S.events.tables?.[tableName]?.[groupKey] || {};
        return Object.values(g).filter(v => v && v.enabled).length;
    }

    function scheduleList() {
        return S.events.global?.schedule || [];
    }
    function actionsList() {
        if (!Array.isArray(S.events.global?.actions)) { if (S.events.global) S.events.global.actions = []; }
        return S.events.global?.actions || [];
    }

    // ── DOM refs ───────────────────────────────────────────────────
    const $ = id => document.getElementById(id);
    const evTreeBody    = $('evTreeBody');
    const evPanel       = $('evPanel');
    const evPanelHead   = $('evPanelHead');
    const evPanelTitle  = $('evPanelTitle');
    const evPanelSub    = $('evPanelSub');
    const evPanelIconI  = $('evPanelIconI');
    const evPanelIcon   = $('evPanelIcon');
    const evPanelBody   = $('evPanelBody');
    const evEmpty       = $('evEmpty');
    const evEditor      = $('evEditor');
    const evTa          = $('evTa');
    const evGutterLines = $('evGutterLines');
    const evCrumbScope  = $('evCrumbScope');
    const evCrumbGroup  = $('evCrumbGroup');
    const evCrumbSep2   = $('evCrumbSep2');
    const evCrumbEvent  = $('evCrumbEvent');
    const evLangPill    = $('evLangPill');
    const evSaveStatus  = $('evSaveStatus');
    const evDocsToggle  = $('evDocsToggle');
    const evDocsBody    = $('evDocsBody');
    const evEventInfo   = $('evEventInfo');
    const evSchedHeader = $('evSchedHeader');
    const evSchedName   = $('evSchedName');
    const evSchedCron   = $('evSchedCron');
    const evActionHeader = $('evActionHeader');
    const evActionName   = $('evActionName');
    const evActionHint   = $('evActionHint');
    const evToast       = $('evToast');
    const evIntellisense = $('evIntellisense');
    const evIntellisenseList = $('evIntellisenseList');
    const evIntellisenseDoc = $('evIntellisenseDoc');
    const evDiag         = $('evDiag');
    const evDiagList     = $('evDiagList');
    const evDiagTitle    = $('evDiagTitle');
    const evDiagHide     = $('evDiagHide');
    const evLintPill     = $('evLintPill');
    const evLintPillText = $('evLintPillText');
    const evSquiggles    = $('evSquiggles');
    const evScanBtn      = $('evScanBtn');
    const evScanOv       = $('evScanOv');
    const evScanClose    = $('evScanClose');
    const evScanBody     = $('evScanBody');
    const evScanHeadline = $('evScanHeadline');
    const evScanSub      = $('evScanSub');
    const evScanHeadIc   = $('evScanHeadIc');

    // ── tree render ────────────────────────────────────────────────
    const expandedTables = new Set();
    let globalEventsExpanded = false;
    let tableSearch = '';

    function renderTree() {
        let h = '';

        // Global events — grouped under logical super-section headers.
        const globalGroupMeta = {};
        for (const [key, group] of Object.entries(S.catalogue.global || {})) {
            globalGroupMeta[key] = {
                icon: group.icon || 'lightning-charge',
                color: group.color || '#64748b',
                bg: group.bg || '#f1f5f9',
                label: group.label || key,
            };
        }
        globalGroupMeta.actions = { icon:'lightning-charge-fill', color:'#7c3aed', bg:'#ede9fe', label:'Server Actions' };
        globalGroupMeta.schedule = { icon:'clock', color:'#0891b2', bg:'#cffafe', label:'Scheduled Tasks' };
        globalGroupMeta.functions = { icon:'code-slash', color:'#059669', bg:'#d1fae5', label:'Custom Functions' };

        const gSections = (boot.globalSections && boot.globalSections.length)
            ? boot.globalSections
            : [{ label:'Global Events', groups: Object.keys(globalGroupMeta) }];

        const globalEnabled = Object.keys(globalGroupMeta).reduce((sum, key) => {
            if (key === 'actions') return sum + actionsList().filter(t => t.enabled).length;
            if (key === 'schedule') return sum + scheduleList().filter(t => t.enabled !== false).length;
            if (key === 'functions') return sum + [S.events.global?.functions?.php, S.events.global?.functions?.js].filter(v => v && v.enabled).length;
            return sum + countEnabledGlobalGroup(key);
        }, 0);

        h += `<button type="button" class="ev-tree-group-toggle" data-act="toggle-global"
                      aria-expanded="${globalEventsExpanded ? 'true' : 'false'}" aria-controls="evGlobalGroups"
                      title="${globalEventsExpanded ? 'Collapse' : 'Expand'} global event categories">
                <span class="ev-tree-group-title"><i class="bi bi-globe2"></i> Global Events</span>
                ${globalEnabled > 0 ? `<span class="ev-tree-group-count">${globalEnabled}</span>` : ''}
                <i class="bi bi-chevron-${globalEventsExpanded ? 'up' : 'down'} ev-tree-group-chevron"></i>
              </button>`;

        if (globalEventsExpanded) {
            h += '<div class="ev-global-groups" id="evGlobalGroups">';
            gSections.forEach((sec, si) => {
                h += `<div class="ev-section-hd"${si > 0 ? ' style="margin-top:4px;"' : ''}>${esc(sec.label)}</div>`;
                for (const key of (sec.groups || [])) {
                    const meta = globalGroupMeta[key];
                    if (!meta) continue;
                    let count = 0;
                    if (key === 'actions') count = actionsList().filter(t => t.enabled).length;
                    else if (key === 'schedule') count = scheduleList().filter(t => t.enabled !== false).length;
                    else if (key === 'functions') count = [S.events.global?.functions?.php, S.events.global?.functions?.js].filter(v => v && v.enabled).length;
                    else count = countEnabledGlobalGroup(key);
                    const isActive = S.scope === 'global' && S.group === key;
                    const badge = count > 0
                        ? `<span class="ev-nbadge has">${count}</span>`
                        : `<span class="ev-nbadge">${catalogueGroupSize('global', key)}</span>`;
                    h += `<div class="ev-node${isActive ? ' active' : ''}" data-scope="global" data-group="${key}">
                        <i class="bi bi-${meta.icon}" style="color:${meta.color}"></i>
                        <span class="ev-nlabel">${meta.label}</span>
                        ${badge}
                      </div>`;
                }
            });
            h += '</div>';
        }

        // Tables section
        h += `<div class="ev-table-tools">
                <div class="ev-table-tools-head">
                    <span><i class="bi bi-table"></i> Table Events</span>
                    <span class="ev-table-total">${boot.tables.length}</span>
                </div>
                <label class="ev-table-search" for="evTableSearch">
                    <i class="bi bi-search"></i>
                    <input type="search" id="evTableSearch" value="${esc(tableSearch)}"
                           placeholder="Search tables…" aria-label="Search table events"
                           autocomplete="off" spellcheck="false">
                </label>
              </div>`;

        for (const tbl of boot.tables) {
            const name = tbl.name;
            const icon = tbl.type === 'VIEW' ? 'eye' : 'table';
            const enabled = countEnabledTable(name);
            const isExpanded = expandedTables.has(name);
            const badge = enabled > 0
                ? `<span class="ev-nbadge has">${enabled}</span>`
                : '';

            h += `<div class="ev-table-entry" data-table-name="${esc(name.toLowerCase())}">
              <div class="ev-node" data-scope="table" data-table="${esc(name)}" data-act="toggle-table"
                        style="${(S.scope === name && !isExpanded) ? 'background:var(--ev-psoft)' : ''}">
                <i class="bi bi-${icon}" style="color:#64748b"></i>
                <span class="ev-nlabel">${esc(name)}</span>
                ${badge}
                <i class="bi bi-chevron-${isExpanded ? 'down' : 'right'} ev-tbl-toggle" style="font-size:10px;color:#94a3b8;margin-left:auto;"></i>
              </div>`;

            if (isExpanded) {
                h += `<div class="ev-tbl-children">`;
                for (const sec of TABLE_SECTIONS) {
                    const cnt = countEnabledTableSection(name, sec);
                    const isAct = S.scope === name && S.section === sec.key;
                    h += `<div class="ev-tbl-child${isAct ? ' active' : ''}" data-scope="${esc(name)}" data-section="${sec.key}">
                        <i class="bi bi-${sec.icon}" style="color:${sec.color}"></i>
                        <span>${esc(sec.label)}</span>
                        ${cnt > 0 ? `<span class="ev-nbadge has" style="margin-left:auto">${cnt}</span>` : ''}
                      </div>`;
                }
                h += `</div>`;
            }
            h += `</div>`;
        }

        h += `<div class="ev-table-empty" id="evTableEmpty" hidden>
                <i class="bi bi-search"></i><span>No matching tables</span>
              </div>`;

        evTreeBody.innerHTML = h;
        applyTableFilter();
    }

    function applyTableFilter() {
        const query = tableSearch.trim().toLowerCase();
        const entries = evTreeBody.querySelectorAll('.ev-table-entry');
        let visible = 0;
        entries.forEach(entry => {
            const matches = query === '' || (entry.dataset.tableName || '').includes(query);
            entry.hidden = !matches;
            if (matches) visible++;
        });
        const empty = $('evTableEmpty');
        if (empty) empty.hidden = visible !== 0;
    }

    function catalogueGroupSize(scope, group) {
        if (scope === 'global') {
            if (group === 'actions') return actionsList().length;
            if (group === 'schedule') return scheduleList().length;
            if (group === 'functions') return 2;
            return Object.keys(S.catalogue.global[group]?.events || {}).length;
        }
        return Object.keys(S.catalogue.table[group]?.events || {}).length;
    }

    // ── panel render ───────────────────────────────────────────────
    function renderPanel() {
        const scope = S.scope;
        if (!scope) { emptyPanel(); return; }

        if (scope === 'global') {
            if (!S.group) { emptyPanel(); return; }
            renderGlobalPanel(S.group);
            return;
        }
        // table scope → page-wise section
        if (!S.section) { emptyPanel('Select a section', '← Choose a page section from the tree'); return; }
        if (S.section === 'fields') renderFieldsPanel(scope);
        else renderTableSectionPanel(scope, S.section);
    }

    function emptyPanel(title, sub) {
        evPanelTitle.textContent = title || 'Select a scope';
        evPanelSub.textContent   = sub || '← Choose from the tree';
        evPanelBody.innerHTML    = '';
    }

    // List every event belonging to a page-wise section. Each row carries its
    // real STORAGE group (page/sql/client) so save + toggle target the right key.
    function renderTableSectionPanel(tableName, sectionKey) {
        const sec = tableSectionByKey(sectionKey) || { label: sectionKey, icon: 'code', color: '#64748b' };
        updatePanelHead({ label: sec.label, icon: sec.icon, color: sec.color, bg: '#f1f5f9', sub: tableName });
        if (sec.desc) evPanelSub.textContent = sec.desc;

        let h = '';
        for (const [grp, key] of (sec.events || [])) {
            const grpDef = S.catalogue.table[grp] || {};
            const def = grpDef.events?.[key];
            if (!def) continue;
            const lang = def.lang || grpDef.lang || 'php';
            const entry = getEventEntry(['tables', tableName, grp, key]);
            const isAct = S.scope === tableName && S.group === grp && S.eventKey === key;
            h += `<div class="ev-row${isAct ? ' active' : ''}" data-group="${grp}" data-evkey="${key}">
                <div class="ev-row-tog${entry.enabled ? ' on' : ''}" data-toggrp="${grp}" data-togev="${key}"></div>
                <div class="ev-row-info">
                  <span class="ev-row-label">${def.label || key}</span>
                  <span class="ev-row-lang ${lang}">${lang.toUpperCase()}</span>
                </div>
                <i class="bi bi-chevron-right"></i>
              </div>`;
        }
        if (h === '') h = `<div style="padding:16px;text-align:center;color:#94a3b8;font-size:12px;">No events in this section.</div>`;
        evPanelBody.innerHTML = h;
        wirePanelClicks();
    }

    function renderGlobalPanel(group) {
        const metas = {};
        for (const [key, groupDef] of Object.entries(S.catalogue.global || {})) {
            metas[key] = {
                label: groupDef.label || key,
                icon: groupDef.icon || 'lightning-charge',
                color: groupDef.color || '#64748b',
                bg: groupDef.bg || '#f1f5f9',
            };
        }
        metas.actions = { label:'Server Actions', icon:'lightning-charge-fill', color:'#7c3aed', bg:'#ede9fe' };
        metas.schedule = { label:'Scheduled Tasks', icon:'clock', color:'#0891b2', bg:'#cffafe' };
        metas.functions = { label:'Custom Functions', icon:'code-slash', color:'#059669', bg:'#d1fae5' };
        const meta = metas[group] || { label: group, icon: 'lightning', color:'#64748b', bg:'#f1f5f9' };
        updatePanelHead(meta);

        let h = '';

        if (group === 'actions') {
            evPanelSub.textContent = 'PHP handlers callable from JavaScript via pp.run()';
            h += `<button class="ev-add-btn" id="evAddAction"><i class="bi bi-plus-lg"></i> Add Server Action</button>`;
            const acts = actionsList();
            if (acts.length === 0) {
                h += `<div style="padding:20px 14px;text-align:center;color:#94a3b8;font-size:12px;">
                    No server actions yet.<br>Click + to add one, then call it with pp.run('name').
                  </div>`;
            }
            for (const a of acts) {
                const isAct = S.actionId === a.id;
                h += `<div class="ev-sched-row${isAct ? ' active' : ''}" data-actionid="${esc(a.id)}">
                    <div style="flex:1;min-width:0;">
                      <span class="ev-sched-name">${esc(a.name || 'untitled')}</span>
                      <span class="ev-sched-cron">pp.run('${esc(a.name || 'untitled')}')</span>
                    </div>
                    ${a.enabled ? '' : '<span class="ev-row-lang php" style="opacity:.6">off</span>'}
                    <button class="ev-tbtn danger" style="padding:3px 6px;font-size:11px;" data-delactionid="${esc(a.id)}">
                      <i class="bi bi-trash3"></i>
                    </button>
                  </div>`;
            }
            evPanelBody.innerHTML = h;
            wirePanelClicks();
            return;
        }

        if (group === 'schedule') {
            evPanelSub.textContent = 'Cron-triggered PHP tasks';
            h += `<button class="ev-add-btn" id="evAddSched"><i class="bi bi-plus-lg"></i> Add Scheduled Task</button>`;
            const scheds = scheduleList();
            if (scheds.length === 0) {
                h += `<div style="padding:20px 14px;text-align:center;color:#94a3b8;font-size:12px;">
                    No tasks yet.<br>Click + to add one.
                  </div>`;
            }
            for (const t of scheds) {
                const isAct = S.schedId === t.id;
                h += `<div class="ev-sched-row${isAct ? ' active' : ''}" data-schedid="${esc(t.id)}">
                    <div style="flex:1;min-width:0;">
                      <span class="ev-sched-name">${esc(t.name || 'Untitled Task')}</span>
                      <span class="ev-sched-cron">${esc(t.cron || '* * * * *')}</span>
                    </div>
                    <button class="ev-tbtn danger" style="padding:3px 6px;font-size:11px;" data-delschedid="${esc(t.id)}">
                      <i class="bi bi-trash3"></i>
                    </button>
                  </div>`;
            }
        } else if (group === 'functions') {
            evPanelSub.textContent = 'Shared PHP & JavaScript functions';
            const fnGroups = [
                { key:'php', icon:'filetype-php', label:'PHP Functions', lang:'php', desc:'Reusable PHP functions available on every page' },
                { key:'js',  icon:'filetype-js',  label:'JS Functions',  lang:'js',  desc:'Reusable JavaScript included in the client bundle' },
            ];
            for (const fg of fnGroups) {
                const entry = S.events.global?.functions?.[fg.key];
                const on = entry?.enabled || false;
                const isAct = S.scope === 'global' && S.group === 'functions' && S.eventKey === fg.key;
                h += `<div class="ev-row${isAct ? ' active' : ''}" data-fnkey="${fg.key}">
                    <div class="ev-row-tog${on ? ' on' : ''}" data-togfn="${fg.key}"></div>
                    <div class="ev-row-info">
                      <span class="ev-row-label">${fg.label}</span>
                      <span class="ev-row-lang ${fg.lang}">${fg.lang.toUpperCase()}</span>
                    </div>
                    <i class="bi bi-chevron-right"></i>
                  </div>`;
            }
        } else {
            const grpDef = S.catalogue.global[group] || {};
            const events = grpDef.events || {};
            evPanelSub.textContent = Object.keys(events).length + ' events';
            for (const [key, def] of Object.entries(events)) {
                const entry = getEventEntry(['global', group, key]);
                const isAct = S.scope === 'global' && S.group === group && S.eventKey === key;
                h += `<div class="ev-row${isAct ? ' active' : ''}" data-evkey="${key}">
                    <div class="ev-row-tog${entry.enabled ? ' on' : ''}" data-togev="${key}"></div>
                    <div class="ev-row-info">
                      <span class="ev-row-label">${def.label}</span>
                      <span class="ev-row-lang ${def.lang}">${def.lang.toUpperCase()}</span>
                    </div>
                    <i class="bi bi-chevron-right"></i>
                  </div>`;
            }
        }

        evPanelBody.innerHTML = h;
        wirePanelClicks();
    }

    function renderTableGroupPanel(tableName, groupKey) {
        const grpDef = S.catalogue.table[groupKey] || {};
        const meta = {
            page:   { label:'Page Events',   icon:'file-earmark-code', color:'#2563eb', bg:'#eff6ff' },
            sql:    { label:'SQL Modifiers',  icon:'database',          color:'#64748b', bg:'#f1f5f9' },
            client: { label:'Client Events',  icon:'browser-safari',    color:'#d97706', bg:'#fef9c3' },
        }[groupKey] || { label: groupKey, icon:'code', color:'#64748b', bg:'#f1f5f9' };
        updatePanelHead({ ...meta, sub: tableName });

        const events = grpDef.events || {};
        let h = '';
        for (const [key, def] of Object.entries(events)) {
            const lang = def.lang || grpDef.lang || 'php';
            const entry = getEventEntry(['tables', tableName, groupKey, key]);
            const isAct = S.scope === tableName && S.group === groupKey && S.eventKey === key;
            h += `<div class="ev-row${isAct ? ' active' : ''}" data-evkey="${key}">
                <div class="ev-row-tog${entry.enabled ? ' on' : ''}" data-togev="${key}"></div>
                <div class="ev-row-info">
                  <span class="ev-row-label">${def.label}</span>
                  <span class="ev-row-lang ${lang}">${lang.toUpperCase()}</span>
                </div>
                <i class="bi bi-chevron-right"></i>
              </div>`;
        }
        evPanelBody.innerHTML = h;
        wirePanelClicks();
    }

    function renderFieldsPanel(tableName) {
        updatePanelHead({ label:'Field Events', icon:'input-cursor-text', color:'#0891b2', bg:'#cffafe', sub: tableName });

        const fields = S.tableFields[tableName] || [];
        if (fields.length === 0 && !S.tableFields[tableName]) {
            evPanelBody.innerHTML = `<div style="padding:16px;text-align:center;color:#94a3b8;font-size:12px;">
                <i class="bi bi-arrow-repeat" style="font-size:22px;"></i><br>Loading fields…</div>`;
            loadFields(tableName).then(() => renderPanel());
            return;
        }
        if (fields.length === 0) {
            evPanelBody.innerHTML = `<div style="padding:16px;text-align:center;color:#94a3b8;font-size:12px;">No fields found.</div>`;
            return;
        }

        // If a field is selected, show its events; else show the field list
        if (S.field) {
            renderFieldEventPanel(tableName, S.field);
            return;
        }

        let h = `<div class="ev-panel-actions" style="border-top:0;border-bottom:1px solid var(--ev-border);">
            <button class="ev-btn sm" id="evBackToFields" style="display:none;">← Fields</button>
        </div>`;
        for (const f of fields) {
            const cnt = countFieldEvents(tableName, f);
            h += `<div class="ev-field-item${S.field === f ? ' active' : ''}" data-field="${esc(f)}">
                <i class="bi bi-input-cursor-text"></i>
                <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;">${esc(f)}</span>
                ${cnt > 0 ? `<span class="ev-field-evcount">${cnt}</span>` : ''}
                <i class="bi bi-chevron-right" style="color:#94a3b8;font-size:11px;"></i>
              </div>`;
        }
        evPanelBody.innerHTML = h;
        wirePanelClicks();
    }

    function renderFieldEventPanel(tableName, fieldName) {
        let h = `<div style="padding:6px 10px 2px;border-bottom:1px solid var(--ev-border);">
            <button class="ev-btn sm" id="evBackToFields"><i class="bi bi-arrow-left"></i> Fields</button>
        </div>`;

        for (const [grpKey, grpDef] of Object.entries(S.catalogue.field)) {
            h += `<div class="ev-section-hd" style="padding-top:10px;">${grpDef.label}</div>`;
            for (const [evKey, def] of Object.entries(grpDef.events || {})) {
                const lang = def.lang || grpDef.lang || 'php';
                const entry = getEventEntry(['tables', tableName, 'fields', fieldName, grpKey, evKey]);
                const isAct = S.scope === tableName && S.group === 'fields'
                    && S.field === fieldName && S.fieldGroup === grpKey && S.eventKey === evKey;
                h += `<div class="ev-row${isAct ? ' active' : ''}" data-fevgrp="${grpKey}" data-fevkey="${evKey}">
                    <div class="ev-row-tog${entry.enabled ? ' on' : ''}" data-togfev="${grpKey}" data-togfevkey="${evKey}"></div>
                    <div class="ev-row-info">
                      <span class="ev-row-label">${def.label}</span>
                      <span class="ev-row-lang ${lang}">${lang.toUpperCase()}</span>
                    </div>
                    <i class="bi bi-chevron-right"></i>
                  </div>`;
            }
        }
        evPanelBody.innerHTML = h;
        wirePanelClicks();
    }

    function countFieldEvents(tableName, fieldName) {
        let n = 0;
        const fdata = S.events.tables?.[tableName]?.fields?.[fieldName] || {};
        for (const grpKey of ['server', 'client']) {
            n += Object.values(fdata[grpKey] || {}).filter(v => v && v.enabled).length;
        }
        return n;
    }

    function updatePanelHead({ label, icon, color, bg, sub }) {
        evPanelTitle.textContent = label;
        evPanelSub.textContent   = sub || '';
        evPanelIconI.className   = `bi bi-${icon}`;
        evPanelIcon.style.background = bg || '#f1f5f9';
        evPanelIcon.style.color      = color || '#64748b';
    }

    // ── editor open/close ──────────────────────────────────────────
    function openEvent(def, path, lang, crumb) {
        const entry  = getEventEntry(path, def.stub || '');
        const code   = entry.code || def.stub || '';

        evEmpty.style.display  = 'none';
        evEditor.style.display = 'flex';
        evSchedHeader.style.display = 'none';
        evActionHeader.style.display = 'none';

        evCrumbScope.textContent = crumb.scope;
        evCrumbGroup.textContent = crumb.group;
        evCrumbEvent.textContent = crumb.event;
        evCrumbSep2.style.display = '';

        setLang(lang);
        setCode(code);
        setDirty(false);
        S.activeDefinition = def;
        S.eventContext = null;
        renderDocs(def);
        loadIntellisense(def, lang);
    }

    function openScheduleEvent(task) {
        evEmpty.style.display  = 'none';
        evEditor.style.display = 'flex';
        evSchedHeader.style.display = '';
        evActionHeader.style.display = 'none';

        evCrumbScope.textContent = 'Global';
        evCrumbGroup.textContent = 'Scheduled Tasks';
        evCrumbEvent.textContent = task.name || 'Untitled Task';
        evCrumbSep2.style.display = '';

        evSchedName.value = task.name || '';
        evSchedCron.value = task.cron || '0 * * * *';

        setLang('php');
        setCode(task.code || '<?php\n// Available: no arguments\n// This code runs on the schedule above\n');
        setDirty(false);

        const def = {
            label: 'Scheduled Task',
            lang: 'php',
            desc: 'Runs when the configured cron expression is due.',
            args: {},
            returns: 'void',
        };
        S.activeDefinition = def;
        S.eventContext = null;
        renderDocs(def);
        loadIntellisense(def, 'php');
    }

    function openActionEvent(action) {
        evEmpty.style.display  = 'none';
        evEditor.style.display = 'flex';
        evSchedHeader.style.display = 'none';
        evActionHeader.style.display = '';

        evCrumbScope.textContent = 'Global';
        evCrumbGroup.textContent = 'Server Actions';
        evCrumbEvent.textContent = action.name || 'untitled';
        evCrumbSep2.style.display = '';

        evActionName.value = action.name || '';
        if (evActionHint) evActionHint.textContent = "await pp.run('" + (action.name || 'name') + "', { /* params */ })";

        setLang('php');
        setCode(action.code || "// \$params holds the data sent from pp.run('" + (action.name || 'name') + "', { ... }).\n// Do work, then return any JSON-serialisable value.\n$id = (int) ($params['id'] ?? 0);\n// DB::Update('orders', ['status' => 'approved'], ['id' => $id]);\nreturn ['ok' => true, 'id' => $id];\n");
        setDirty(false);

        const def = {
            label: 'Server Action',
            lang: 'php',
            desc: "Callable from a client event with pp.run('" + (action.name || 'name') + "', params). $params holds the sent data; return any value.",
            args: { '$params': 'array — data sent from pp.run()' },
            returns: 'mixed — returned to the browser as JSON',
        };
        S.activeDefinition = def;
        S.eventContext = null;
        renderDocs(def);
        loadIntellisense(def, 'php');
    }

    function openFunctionEvent(fnKey) {
        const def = {
            php: {
                label:'PHP Functions', lang:'php', desc:'Shared PHP functions available on every generated page. Prefix a function with action_ to call it from JavaScript via pp.run().',
                stub:'<?php\n// Reusable PHP available everywhere.\n// function formatCurrency($value) { return \'$\' . number_format($value, 2); }\n\n// A SERVER ACTION callable from JS with pp.run(\'approve\', { id: 5 }):\nfunction action_approve($params) {\n    $id = (int) ($params[\'id\'] ?? 0);\n    DB::Update(\'orders\', [\'status\' => \'approved\'], [\'id\' => $id]);\n    return [\'ok\' => true, \'status\' => \'approved\'];\n}\n',
            },
            js: {
                label:'JavaScript Functions', lang:'js', desc:'Reusable JavaScript included in the generated client bundle.',
                stub:'// Define reusable helper functions here.\n// function formatCurrency(value) {\n//     return \'$\' + value.toFixed(2);\n// }\n',
            },
        }[fnKey];

        const entry = S.events.global?.functions?.[fnKey] || { code: def.stub, enabled: false };

        evEmpty.style.display  = 'none';
        evEditor.style.display = 'flex';
        evSchedHeader.style.display = 'none';
        evActionHeader.style.display = 'none';

        evCrumbScope.textContent = 'Global';
        evCrumbGroup.textContent = 'Custom Functions';
        evCrumbEvent.textContent = def.label;
        evCrumbSep2.style.display = '';

        setLang(def.lang);
        setCode(entry.code || def.stub);
        setDirty(false);

        S.activeDefinition = def;
        S.eventContext = null;
        renderDocs(def);
        evDocsBody.classList.add('open');
        evDocsToggle.classList.add('open');
        loadIntellisense(def, def.lang);
    }

    // ── code editor ────────────────────────────────────────────────
    function setCode(code) {
        evTa.value = code;
        updateGutter();
        closeIntellisense();
        scheduleLint();
    }

    function updateGutter() {
        const lines = evTa.value.split('\n').length;
        let h = '';
        for (let i = 1; i <= Math.max(lines, 1); i++) h += `<div>${i}</div>`;
        evGutterLines.innerHTML = h;
    }

    function setLang(lang) {
        S.activeLang = lang;   // the single source of truth for the checker's language
        evLangPill.className   = `ev-lang-pill ${lang}`;
        evLangPill.textContent = lang.toUpperCase();
    }

    // ── code checker (Phase 1: syntax + "did you mean") ────────────────
    // PHP is checked on the server (only PHP can parse PHP); JS is checked in
    // the browser. Both produce {line, severity, message, range?, replacement?}.
    function currentLang() {
        return S.activeLang || (S.activeDefinition && S.activeDefinition.lang) || 'php';
    }

    function scheduleLint() {
        clearTimeout(S.lintTimer);
        setPill('checking', 'Checking…');
        S.lintTimer = setTimeout(runLint, 450);
    }

    async function runLint() {
        if (!S.activeDefinition) { renderDiagnostics([]); return; }
        const code = evTa.value;
        const lang = currentLang();
        const reqId = ++S.lintReq;

        if (lang === 'js') {
            renderDiagnostics(jsLint(code), reqId);
            return;
        }
        // PHP → server (also send the event's context vars for a $var did-you-mean)
        const vars = (S.intellisense || [])
            .filter(s => s && s.kind === 'variable' && typeof s.label === 'string')
            .map(s => s.label);
        try {
            const res = await fetch(BASE + '/lint', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
                credentials: 'same-origin',
                body: JSON.stringify({ code, lang: 'php', vars }),
            });
            const json = await res.json();
            if (reqId !== S.lintReq) return;         // a newer edit/event superseded us
            renderDiagnostics(Array.isArray(json.diagnostics) ? json.diagnostics : [], reqId);
        } catch (e) {
            if (reqId === S.lintReq) renderDiagnostics([], reqId);
        }
    }

    // Client-side JavaScript checker. Parses once with Acorn; a good parse also
    // feeds the AST-based argument-count and unused-variable checks.
    function jsLint(code) {
        const diags = [];
        if (!code.trim()) return diags;
        let ast = null;
        if (window.acorn && typeof window.acorn.parse === 'function') {
            try {
                ast = window.acorn.parse(code, {
                    ecmaVersion: 'latest', locations: true,
                    allowReturnOutsideFunction: true,
                    allowAwaitOutsideFunction: true,
                    allowSuperOutsideMethod: true,
                    allowImportExportEverywhere: true,
                });
            } catch (e) {
                if (e && typeof e.pos === 'number') {
                    const start = Math.max(0, Math.min(e.pos, code.length));
                    diags.push({ line: (e.loc && e.loc.line) || lineAt(code, start),
                        severity: 'error', source: 'js',
                        message: 'Syntax error: ' + cleanAcornMsg(e.message),
                        range: [start, jsTokenEnd(code, start)] });
                } else {
                    diags.push({ line: null, severity: 'error', source: 'js', message: 'Syntax error: ' + (e && e.message || 'invalid syntax') });
                }
            }
        } else {
            // Acorn not loaded: catch a syntax error (no location) and stop there.
            try {
                const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
                // eslint-disable-next-line no-new
                new AsyncFunction(code);
            } catch (e) {
                if (e instanceof SyntaxError) diags.push({ line: null, severity: 'error', source: 'js', message: 'Syntax error: ' + e.message });
            }
        }
        jsSemantic(code, diags);          // pp.* did-you-mean (runs even on a broken parse)
        if (ast) jsAstChecks(ast, diags); // arg-count + unused (only when it parses)
        return diags.slice(0, 25);
    }

    // AST walk: flag pp.* calls with the wrong argument count and const/let/var
    // that is declared but never referenced.
    function jsAstChecks(ast, diags) {
        const sigs = (window.PpEventsBootstrap && window.PpEventsBootstrap.signatures) || {};
        const declared = new Map();       // name -> id node
        const uses = new Map();           // name -> reference count

        const memberPath = (node) => {
            if (!node) return null;
            if (node.type === 'Identifier') return node.name;
            if (node.type === 'MemberExpression' && !node.computed && node.property.type === 'Identifier') {
                const base = memberPath(node.object);
                return base ? base + '.' + node.property.name : null;
            }
            return null;
        };
        const line = (node) => (node.loc && node.loc.start && node.loc.start.line) || null;

        walkAst(ast, (node, parent, key) => {
            if (node.type === 'VariableDeclarator' && node.id && node.id.type === 'Identifier') {
                declared.set(node.id.name, node.id);
            }
            if (node.type === 'CallExpression') {
                const path = memberPath(node.callee);
                if (path && sigs[path] && !node.arguments.some(a => a && a.type === 'SpreadElement')) {
                    const min = sigs[path][0], max = sigs[path][1], argc = node.arguments.length;
                    if (argc < min) {
                        diags.push({ line: line(node.callee), severity: 'warning', source: 'js',
                            message: path + '() needs ' + plural(min, 'argument') + ', got ' + argc + '.',
                            range: [node.callee.start, node.callee.end] });
                    } else if (max >= 0 && argc > max) {
                        diags.push({ line: line(node.callee), severity: 'warning', source: 'js',
                            message: path + '() takes at most ' + plural(max, 'argument') + ', got ' + argc + '.',
                            range: [node.callee.start, node.callee.end] });
                    }
                }
            }
            if (node.type === 'Identifier') {
                const isDeclId = parent && parent.type === 'VariableDeclarator' && key === 'id';
                const isMemberProp = parent && parent.type === 'MemberExpression' && key === 'property' && !parent.computed;
                const isPropKey = parent && parent.type === 'Property' && key === 'key' && !parent.computed;
                const isFnName = parent && /Function/.test(parent.type) && key === 'id';
                const isParam = key === 'params';
                if (!isDeclId && !isMemberProp && !isPropKey && !isFnName && !isParam) {
                    uses.set(node.name, (uses.get(node.name) || 0) + 1);
                }
            }
        }, null, null);

        declared.forEach((idNode, name) => {
            if (!(uses.get(name) > 0)) {
                diags.push({ line: line(idNode), severity: 'info', source: 'js',
                    message: name + ' is assigned but never used.',
                    range: [idNode.start, idNode.end] });
            }
        });
    }

    // Minimal recursive AST walker (visits every node, tracks parent + key).
    function walkAst(node, visit, parent, key) {
        if (!node || typeof node.type !== 'string') return;
        visit(node, parent, key);
        for (const k in node) {
            if (k === 'type' || k === 'start' || k === 'end' || k === 'loc') continue;
            const v = node[k];
            if (Array.isArray(v)) {
                for (const c of v) if (c && typeof c.type === 'string') walkAst(c, visit, node, k);
            } else if (v && typeof v.type === 'string') {
                walkAst(v, visit, node, k);
            }
        }
    }

    function plural(count, noun) { return count + ' ' + noun + (count === 1 ? '' : 's'); }

    function cleanAcornMsg(msg) {
        return String(msg || '').replace(/\s*\(\d+:\d+\)\s*$/, '');   // drop the "(line:col)" suffix
    }

    function jsTokenEnd(code, start) {
        // Underline the offending token, else a single character.
        const m = /^[A-Za-z0-9_$]+/.exec(code.slice(start));
        return start + (m ? m[0].length : 1);
    }

    function jsSymbolIndex() {
        if (S._jsIdx !== undefined) return S._jsIdx;
        const labels = (window.PpEventsBootstrap && window.PpEventsBootstrap.symbols
            && window.PpEventsBootstrap.symbols.js) || [];
        if (!labels.length) return (S._jsIdx = null);
        const known = new Set(labels), ns = new Set(), byParent = new Map();
        for (const l of labels) {
            const parts = l.split('.');
            for (let k = 1; k < parts.length; k++) ns.add(parts.slice(0, k).join('.'));
            if (parts.length > 1) {
                const parent = parts.slice(0, -1).join('.');
                if (!byParent.has(parent)) byParent.set(parent, []);
                byParent.get(parent).push(parts[parts.length - 1]);
            }
        }
        return (S._jsIdx = { known, ns, byParent });
    }

    function jsSemantic(code, diags) {
        const idx = jsSymbolIndex();
        if (!idx) return;
        const re = /\bpp\.[A-Za-z0-9_$.]+/g;
        let m;
        while ((m = re.exec(code))) {
            const path = m[0].replace(/\.+$/, '');
            if (idx.known.has(path) || idx.ns.has(path)) continue;
            const dot = path.lastIndexOf('.');
            if (dot < 0) continue;
            const parent = path.slice(0, dot), leaf = path.slice(dot + 1);
            if (!idx.ns.has(parent)) continue;       // only flag members of a KNOWN namespace
            const siblings = idx.byParent.get(parent) || [];
            let best = null, bestD = Infinity;
            for (const s of siblings) {
                const d = lev(leaf.toLowerCase(), s.toLowerCase());
                if (d > 0 && d < bestD) { bestD = d; best = s; }
            }
            const thr = leaf.length <= 4 ? 1 : 2;
            if (best && bestD <= thr) {
                diags.push({ line: lineAt(code, m.index), severity: 'warning', source: 'js',
                    message: 'Unknown pp API ' + path + ' — did you mean ' + parent + '.' + best + '?',
                    range: [m.index, m.index + path.length], replacement: parent + '.' + best });
            }
        }
    }

    // Damerau (OSA) distance — a swap of two adjacent chars costs 1, so common
    // transposition typos (toast→taost) are caught. Mirrors EventLinter::damerau.
    function lev(a, b) {
        const m = a.length, n = b.length;
        if (!m) return n; if (!n) return m;
        const d = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
        for (let j = 0; j <= n; j++) d[0][j] = j;
        for (let i = 1; i <= m; i++)
            for (let j = 1; j <= n; j++) {
                d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1,
                    d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
                if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
                    d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
            }
        return d[m][n];
    }

    function lineAt(code, index) {
        let line = 1;
        for (let i = 0; i < index && i < code.length; i++) if (code[i] === '\n') line++;
        return line;
    }

    function setPill(state, text) {
        if (!evLintPill) return;
        evLintPill.className = 'ev-lintpill ' + state;
        evLintPillText.textContent = text;
        const icon = { ok: 'check-circle-fill', warn: 'exclamation-triangle-fill',
            error: 'x-circle-fill', checking: 'arrow-repeat' }[state] || 'check-circle-fill';
        const i = evLintPill.querySelector('i');
        if (i) i.className = 'bi bi-' + icon;
    }

    function renderDiagnostics(list, reqId) {
        if (reqId !== undefined && reqId !== S.lintReq) return;
        list = list || [];
        S.diagnostics = list;
        const errs = list.filter(d => d.severity === 'error').length;
        const warns = list.filter(d => d.severity === 'warning').length;
        const infos = list.filter(d => d.severity === 'info').length;

        if (errs) setPill('error', errs + (errs === 1 ? ' error' : ' errors'));
        else if (warns) setPill('warn', warns + (warns === 1 ? ' warning' : ' warnings'));
        else if (infos) setPill('ok', infos + (infos === 1 ? ' note' : ' notes'));
        else setPill('ok', 'No problems');

        if (!list.length) { evDiag.hidden = true; evDiagList.innerHTML = ''; decorateGutter([]); drawSquiggles(); return; }

        evDiagTitle.className = 'ev-diag-title ' + (errs ? 'has-error' : warns ? 'has-warn' : 'ok');
        const parts = [];
        if (errs) parts.push(errs + (errs === 1 ? ' error' : ' errors'));
        if (warns) parts.push(warns + (warns === 1 ? ' warning' : ' warnings'));
        if (infos) parts.push(infos + (infos === 1 ? ' note' : ' notes'));
        evDiagTitle.innerHTML = '<i class="bi bi-clipboard-check"></i> ' + parts.join(' · ');

        evDiagList.innerHTML = list.map((d, i) => {
            const loc = d.line ? `Line ${d.line}` : '';
            const icon = d.severity === 'error' ? 'x-circle-fill'
                : d.severity === 'warning' ? 'exclamation-triangle-fill' : 'info-circle-fill';
            const fix = d.replacement ? `<button type="button" class="ev-diag-fix" data-fix="${i}">Fix</button>` : '';
            return `<div class="ev-diag-item ${esc(d.severity)}" data-di="${i}">
                <i class="ev-diag-ic bi bi-${icon}"></i>
                <div class="ev-diag-body"><span class="ev-diag-loc">${esc(loc)}</span><span class="ev-diag-msg">${esc(d.message)}</span></div>
                ${fix}</div>`;
        }).join('');
        evDiag.hidden = false;
        decorateGutter(list);
        drawSquiggles();
    }

    // Colour the gutter line numbers that carry a diagnostic (worst severity wins).
    function decorateGutter(list) {
        if (!evGutterLines) return;
        const rank = { info: 1, warning: 2, error: 3 };
        const worst = {};
        (list || []).forEach(d => {
            if (!d.line) return;
            if (!worst[d.line] || rank[d.severity] > rank[worst[d.line]]) worst[d.line] = d.severity;
        });
        Array.from(evGutterLines.children).forEach((el, idx) => {
            const sev = worst[idx + 1];
            el.className = sev ? 'ev-gline-' + sev : '';
        });
    }

    // Draw wavy underlines beneath every ranged diagnostic. A viewport-fixed
    // overlay is used so nothing clips it; positions are recomputed on scroll.
    function drawSquiggles() {
        if (!evSquiggles) return;
        evSquiggles.innerHTML = '';
        const list = (S.diagnostics || []).filter(d => d.range && Array.isArray(d.range));
        if (!list.length || !evTa) return;
        const taRect = evTa.getBoundingClientRect();
        list.forEach(d => {
            const a = caretPixel(d.range[0]);
            const b = caretPixel(d.range[1]);
            if (!a || !b || Math.abs(a.top - b.top) > 1) return;   // skip multi-line spans
            const top = a.top;                                     // baseline bottom of the line
            if (top < taRect.top + 1 || top > taRect.bottom - 1) return;   // scrolled out of view
            let left = a.left, width = Math.max(b.left - a.left, 6);
            if (left + width < taRect.left || left > taRect.right) return;
            if (left < taRect.left) { width -= (taRect.left - left); left = taRect.left; }
            width = Math.min(width, taRect.right - left);
            if (width <= 0) return;
            const sq = document.createElement('div');
            sq.className = 'ev-squiggle ' + d.severity;
            sq.style.left = Math.round(left) + 'px';
            sq.style.top = Math.round(top) + 'px';
            sq.style.width = Math.round(width) + 'px';
            evSquiggles.appendChild(sq);
        });
    }

    // Viewport pixel position of the BOTTOM-left of the character at `caret`.
    function caretPixel(caret) {
        const p = caretPosition(evTa, caret);   // returns lineBottom + 4
        return { left: p.left, top: p.top - 4 };
    }

    // ── Pre-deploy "Check all events" report ───────────────────────────
    function openScanReport() {
        if (!evScanOv) return;
        evScanOv.hidden = false;
        evScanHeadIc.className = 'ev-scan-ic checking';
        evScanHeadIc.innerHTML = '<i class="bi bi-arrow-repeat"></i>';
        evScanHeadline.textContent = 'Checking every event…';
        evScanSub.textContent = 'Running the checker across the whole project.';
        evScanBody.innerHTML = '';
        fetch(BASE + '/scan', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
            .then(r => r.json())
            .then(renderScanReport)
            .catch(() => {
                evScanHeadIc.className = 'ev-scan-ic error';
                evScanHeadline.textContent = 'Could not run the check';
                evScanSub.textContent = 'Please try again.';
            });
    }

    function renderScanReport(res) {
        const t = (res && res.totals) || { error: 0, warning: 0, info: 0, checked: 0, withIssues: 0 };
        const events = (res && res.events) || [];
        const clean = t.error === 0 && t.warning === 0;

        evScanHeadIc.className = 'ev-scan-ic ' + (t.error ? 'error' : t.warning ? 'warn' : 'ok');
        evScanHeadIc.innerHTML = '<i class="bi bi-' + (t.error ? 'x-circle-fill' : t.warning ? 'exclamation-triangle-fill' : 'check-circle-fill') + '"></i>';

        if (clean && t.info === 0) {
            evScanHeadline.textContent = 'All clear';
            evScanSub.textContent = t.checked + ' event' + (t.checked === 1 ? '' : 's') + ' checked — no problems found.';
            evScanBody.innerHTML = '<div class="ev-scan-empty"><i class="bi bi-check2-circle"></i><div>Every saved event passed. You’re good to build.</div></div>';
            return;
        }

        const bits = [];
        if (t.error) bits.push(t.error + ' error' + (t.error === 1 ? '' : 's'));
        if (t.warning) bits.push(t.warning + ' warning' + (t.warning === 1 ? '' : 's'));
        if (t.info) bits.push(t.info + ' note' + (t.info === 1 ? '' : 's'));
        evScanHeadline.textContent = bits.join(' · ') || 'Notes';
        evScanSub.textContent = 'Across ' + t.withIssues + ' of ' + t.checked + ' event' + (t.checked === 1 ? '' : 's') + '.'
            + (t.error ? ' Fix the errors before building.' : '');

        evScanBody.innerHTML = events.map(ev => {
            const chips = [];
            if (ev.counts.error) chips.push('<span class="ev-scan-chip error">' + ev.counts.error + '</span>');
            if (ev.counts.warning) chips.push('<span class="ev-scan-chip warning">' + ev.counts.warning + '</span>');
            if (ev.counts.info) chips.push('<span class="ev-scan-chip info">' + ev.counts.info + '</span>');
            const rows = ev.diagnostics.map(d => {
                const icon = d.severity === 'error' ? 'x-circle-fill' : d.severity === 'warning' ? 'exclamation-triangle-fill' : 'info-circle-fill';
                const loc = d.line ? 'Line ' + d.line : '';
                return '<div class="ev-scan-diag ' + esc(d.severity) + '"><i class="bi bi-' + icon + '"></i>'
                    + '<span class="ev-scan-loc">' + esc(loc) + '</span><span>' + esc(d.message) + '</span></div>';
            }).join('');
            return '<div class="ev-scan-card">'
                + '<div class="ev-scan-card-head">'
                + '<span class="ev-lang-pill ' + esc(ev.lang) + '">' + esc((ev.lang || '').toUpperCase()) + '</span>'
                + '<b>' + esc(ev.label) + '</b>'
                + (ev.enabled ? '' : '<span class="ev-scan-off">disabled</span>')
                + '<span class="ev-scan-chips">' + chips.join('') + '</span>'
                + '</div>' + rows + '</div>';
        }).join('');
    }

    function applyFix(i) {
        const d = S.diagnostics && S.diagnostics[i];
        if (!d || !d.range || d.replacement === undefined) return;
        const [start, end] = d.range;
        const v = evTa.value;
        if (start < 0 || end > v.length || start > end) return;
        evTa.value = v.slice(0, start) + d.replacement + v.slice(end);
        const caret = start + d.replacement.length;
        evTa.selectionStart = evTa.selectionEnd = caret;
        evTa.focus();
        // Re-run the normal input pipeline (gutter, dirty, autosave) + re-lint.
        evTa.dispatchEvent(new Event('input'));
    }

    function jumpToDiagnostic(d) {
        if (!d) return;
        let pos = d.range ? d.range[0] : 0;
        if (!d.range && d.line) {
            const lines = evTa.value.split('\n');
            pos = 0;
            for (let i = 0; i < d.line - 1 && i < lines.length; i++) pos += lines[i].length + 1;
        }
        evTa.focus();
        evTa.selectionStart = evTa.selectionEnd = Math.min(pos, evTa.value.length);
        // Nudge the caret line into view.
        const lineH = parseInt(getComputedStyle(evTa).lineHeight, 10) || 18;
        evTa.scrollTop = Math.max(0, ((d.line || 1) - 3) * lineH);
    }

    function setDirty(d) {
        S.dirty = d;
        if (d) {
            evSaveStatus.className   = 'ev-status dirty';
            evSaveStatus.innerHTML   = '<i class="bi bi-circle-fill" style="font-size:8px;"></i> Unsaved';
        } else {
            evSaveStatus.className   = 'ev-status saved';
            evSaveStatus.innerHTML   = '<i class="bi bi-check2-circle"></i> Saved';
        }
    }

    // ── docs panel ─────────────────────────────────────────────────
    // Compact "what it does · when it fires · what to return" card in the header.
    function renderEventInfo(def) {
        if (!evEventInfo) return;
        if (!def || !def.desc) { evEventInfo.hidden = true; evEventInfo.innerHTML = ''; return; }
        const ret = (def.returns && def.returns !== 'void')
            ? `<span class="ev-ei-ret"><i class="bi bi-arrow-return-left"></i> ${def.returns}</span>` : '';
        evEventInfo.innerHTML =
            `<i class="bi bi-info-circle-fill ev-ei-icon"></i>`
            + `<div class="ev-ei-text"><span class="ev-ei-desc">${def.desc}</span>${ret}</div>`;
        evEventInfo.hidden = false;
    }

    function renderDocs(def, context) {
        renderEventInfo(def);
        if (!def) { evDocsBody.innerHTML = ''; return; }
        const args = def.args || {};
        const signature = context?.signature || eventSignature(def);
        let h = `<div class="ev-docs-grid">
            <div class="ev-docs-section">
              <h4>Signature &amp; Available Variables</h4>
              <div class="ev-signature">${esc(signature)}</div>`;
        if (Object.keys(args).length === 0) {
            h += `<div class="ev-docs-row" style="color:#6c7086;font-style:italic;">None</div>`;
        }
        for (const [name, desc] of Object.entries(args)) {
            h += `<div class="ev-docs-row">
                <span class="arg-name">${esc(name)}</span>
                <span class="arg-desc">— ${desc}</span>
              </div>`;
        }
        h += `</div><div class="ev-docs-section">
              <h4>Return Value</h4>
              <div class="ev-docs-ret">${def.returns || 'void'}</div>
              ${def.desc ? `<p style="color:#6c7086;font-size:11.5px;margin-top:8px;">${def.desc}</p>` : ''}
            </div>
          </div>`;
        if (def.example) {
            h += `<div class="ev-docs-example">
                <h4><i class="bi bi-code-square"></i> Example</h4>
                <pre>${esc(def.example.replace(/\s+$/, ''))}</pre>
              </div>`;
        }
        if (def.idea) {
            h += `<details class="ev-idea">
                <summary><i class="bi bi-lightbulb"></i> Idea &amp; best practice</summary>
                <div class="ev-idea-body">${def.idea}</div>
              </details>`;
        }
        evDocsBody.innerHTML = h;
    }

    function eventSignature(def) {
        const name = S.eventKey || def.label || 'event';
        return `${name}(${Object.keys(def.args || {}).join(', ')})`;
    }

    // ── IntelliSense ───────────────────────────────────────────────
    async function loadIntellisense(def, lang) {
        const requestId = ++S.suggestionRequest;
        S.intellisense = fallbackSuggestions(def);
        closeIntellisense();
        const params = new URLSearchParams({
            scope: S.scope === 'global' ? 'global' : 'table',
            group: S.group || '',
            event: S.eventKey || (S.group === 'schedule' ? 'scheduledTask' : ''),
            lang: lang || 'php',
        });

        if (S.scope !== 'global') {
            params.set('table', S.scope || '');
            if (S.field) params.set('field', S.field);
            if (S.fieldGroup) params.set('fieldGroup', S.fieldGroup);
        }

        try {
            const response = await fetch(BASE + '/intellisense?' + params.toString(), {
                headers: { 'Accept': 'application/json' },
                credentials: 'same-origin',
            });
            const result = await response.json();
            if (requestId !== S.suggestionRequest) return;

            if (result.success) {
                S.intellisense = Array.isArray(result.suggestions) ? result.suggestions : [];
                S.eventContext = result.context || null;
                renderDocs(def, S.eventContext);
                return;
            }
        } catch (error) {
            // The editor remains usable with event-variable fallbacks.
        }

        if (requestId === S.suggestionRequest) {
            S.intellisense = fallbackSuggestions(def);
        }
    }

    function fallbackSuggestions(def) {
        return Object.entries(def?.args || {}).map(([name, detail]) => ({
            label: name,
            insertText: name,
            kind: 'variable',
            detail,
            documentation: 'Available in this event',
        }));
    }

    // Calls whose indicated argument is a database field name.  These names
    // deliberately mirror the generated client runtime.  Keeping the mapping
    // here makes field completion contextual instead of flooding ordinary
    // IntelliSense with every column in a large schema.
    const FIELD_ARGUMENT_CALLS = Object.freeze({
        'Runner.getControl':       [0, 1],
        'Runner.getControlValue':  [0, 1],
        'Runner.setControlValue':  [0, 1],
        'Runner.hideField':        [0, 1],
        'Runner.showField':        [0, 1],
        'Runner.on':               [0, 1],
        'Runner.getCellValue':     [1],
        'Runner.editCell':         [1],
        'Runner.compute':          [0],
        'Runner.link':             [0, 1],
        'pp.ctrl':                  [0, 1],
        'pp.val':                   [0],
        'pp.form.el':               [0],
        'pp.form.wrap':             [0],
        'pp.form.get':              [0],
        'pp.form.set':              [0],
        'pp.form.show':             [0],
        'pp.form.hide':             [0],
        'pp.form.enable':           [0],
        'pp.form.disable':          [0],
        'pp.form.readonly':         [0],
        'pp.form.required':         [0],
        'pp.form.focus':            [0],
        'pp.form.message':          [0],
        'pp.form.clearMessage':     [0],
    });

    // Return an unterminated single/double quoted string at the caret.  A small
    // lexer is used instead of a regex so apostrophes in comments and escaped
    // quotes do not confuse field completion.
    function activeJsString(text) {
        let quote = null;
        let start = -1;
        let escaped = false;
        let lineComment = false;
        let blockComment = false;

        for (let i = 0; i < text.length; i++) {
            const ch = text[i];
            const next = text[i + 1] || '';

            if (lineComment) {
                if (ch === '\n') lineComment = false;
                continue;
            }
            if (blockComment) {
                if (ch === '*' && next === '/') { blockComment = false; i++; }
                continue;
            }
            if (quote !== null) {
                if (escaped) { escaped = false; continue; }
                if (ch === '\\') { escaped = true; continue; }
                if (ch === quote) { quote = null; start = -1; continue; }
                if (quote !== '`' && ch === '\n') { quote = null; start = -1; }
                continue;
            }

            if (ch === '/' && next === '/') { lineComment = true; i++; continue; }
            if (ch === '/' && next === '*') { blockComment = true; i++; continue; }
            if (ch === "'" || ch === '"' || ch === '`') { quote = ch; start = i; }
        }

        return quote === "'" || quote === '"' ? { quote, start } : null;
    }

    function activeCallOpen(text, end) {
        const stack = [];
        let quote = null;
        let escaped = false;
        let lineComment = false;
        let blockComment = false;

        for (let i = 0; i < end; i++) {
            const ch = text[i];
            const next = text[i + 1] || '';
            if (lineComment) { if (ch === '\n') lineComment = false; continue; }
            if (blockComment) { if (ch === '*' && next === '/') { blockComment = false; i++; } continue; }
            if (quote !== null) {
                if (escaped) { escaped = false; continue; }
                if (ch === '\\') { escaped = true; continue; }
                if (ch === quote) quote = null;
                continue;
            }
            if (ch === '/' && next === '/') { lineComment = true; i++; continue; }
            if (ch === '/' && next === '*') { blockComment = true; i++; continue; }
            if (ch === "'" || ch === '"' || ch === '`') { quote = ch; continue; }
            if (ch === '(') stack.push(i);
            else if (ch === ')' && stack.length) stack.pop();
        }

        return stack.length ? stack[stack.length - 1] : -1;
    }

    // Count only commas belonging to the active call, not commas inside nested
    // arrays, objects or helper calls.
    function callArgumentInfo(text, open, end) {
        let index = 0;
        let start = open + 1;
        let parens = 0;
        let brackets = 0;
        let braces = 0;
        let quote = null;
        let escaped = false;
        let lineComment = false;
        let blockComment = false;

        for (let i = open + 1; i < end; i++) {
            const ch = text[i];
            const next = text[i + 1] || '';
            if (lineComment) { if (ch === '\n') lineComment = false; continue; }
            if (blockComment) { if (ch === '*' && next === '/') { blockComment = false; i++; } continue; }
            if (quote !== null) {
                if (escaped) { escaped = false; continue; }
                if (ch === '\\') { escaped = true; continue; }
                if (ch === quote) quote = null;
                continue;
            }
            if (ch === '/' && next === '/') { lineComment = true; i++; continue; }
            if (ch === '/' && next === '*') { blockComment = true; i++; continue; }
            if (ch === "'" || ch === '"' || ch === '`') { quote = ch; continue; }
            if (ch === '(') parens++;
            else if (ch === ')' && parens > 0) parens--;
            else if (ch === '[') brackets++;
            else if (ch === ']' && brackets > 0) brackets--;
            else if (ch === '{') braces++;
            else if (ch === '}' && braces > 0) braces--;
            else if (ch === ',' && parens === 0 && brackets === 0 && braces === 0) {
                index++;
                start = i + 1;
            }
        }
        return { index, start };
    }

    function fieldArgumentContext(before, caret) {
        if (currentLang() !== 'js') return null;

        const string = activeJsString(before);
        const argumentEnd = string ? string.start : caret;
        const open = activeCallOpen(before, argumentEnd);
        if (open < 0) return null;

        const calleeMatch = before.slice(0, open).match(/([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*$/);
        const callee = calleeMatch ? calleeMatch[1] : '';
        const allowedArguments = FIELD_ARGUMENT_CALLS[callee];
        if (!allowedArguments) return null;

        const argument = callArgumentInfo(before, open, argumentEnd);
        if (!allowedArguments.includes(argument.index)) return null;

        if (string) {
            return {
                token: before.slice(string.start + 1),
                start: string.start + 1,
                caret,
                context: 'fieldArgument',
                quoted: true,
            };
        }

        const segment = before.slice(argument.start, caret);
        const bare = segment.match(/^\s*([A-Za-z0-9_$]*)$/);
        if (!bare) return null;
        const token = bare[1] || '';
        return {
            token,
            start: caret - token.length,
            caret,
            context: 'fieldArgument',
            quoted: false,
        };
    }

    function completionToken() {
        const caret = evTa.selectionStart;
        const before = evTa.value.slice(0, caret);
        const fieldArgument = fieldArgumentContext(before, caret);
        if (fieldArgument) return fieldArgument;

        const bracket = before.match(/(\$[A-Za-z_][A-Za-z0-9_]*\[['"][^'"]*)$/);
        const symbol = before.match(/(\$|(?:\$?[A-Za-z_][A-Za-z0-9_]*)(?:(?:::|->)[A-Za-z0-9_]*)?)$/);
        const match = bracket || symbol;
        const token = match ? match[1] : '';

        return { token, start: caret - token.length, caret, context: null, quoted: false };
    }

    function showIntellisense(force) {
        const part = completionToken();
        const fieldContext = part.context === 'fieldArgument';
        if (!force && part.token === '' && !fieldContext) {
            closeIntellisense();
            return;
        }

        const needle = part.token.toLowerCase();
        const ranked = S.intellisense
            .map(item => {
                const contexts = Array.isArray(item.contexts) ? item.contexts : [];
                const isFieldSuggestion = contexts.includes('fieldArgument');
                if (fieldContext !== isFieldSuggestion) return null;
                const label = String(item.label || '').toLowerCase();
                const insert = String(item.insertText || '').toLowerCase();
                let score = 9;
                if (needle === '') score = 4;
                else if (label.startsWith(needle) || insert.startsWith(needle)) score = 0;
                else if (label.includes(needle) || insert.includes(needle)) score = 2;
                return { item, score };
            })
            .filter(row => row && (force || row.score < 9))
            .sort((a, b) => a.score - b.score || String(a.item.label).localeCompare(String(b.item.label)))
            .slice(0, 40)
            .map(row => row.item);

        if (ranked.length === 0) {
            closeIntellisense();
            return;
        }

        S.filteredSuggestions = ranked;
        S.suggestionIndex = 0;
        S.suggestionToken = part.token;
        S.suggestionStart = part.start;
        S.suggestionContext = part.context || null;
        S.suggestionQuoted = !!part.quoted;
        renderIntellisense();
        positionIntellisense(part.caret);
    }

    function renderIntellisense() {
        evIntellisenseList.innerHTML = S.filteredSuggestions.map((item, index) => {
            const kind = item.kind || 'variable';
            const abbreviation = {
                variable: 'V', field: 'F', method: 'M', function: 'Fn', keyword: 'K', snippet: 'S',
            }[kind] || 'V';

            const cat = item.category ? `<span class="ev-intellisense-cat">${esc(item.category)}</span>` : '';
            return `<button type="button" class="ev-intellisense-item${index === S.suggestionIndex ? ' active' : ''}"
                    data-suggestion-index="${index}" role="option" aria-selected="${index === S.suggestionIndex ? 'true' : 'false'}">
                <span class="ev-intellisense-icon ${esc(kind)}">${abbreviation}</span>
                <span class="ev-intellisense-copy">
                    <span class="ev-intellisense-copy-head">
                        <span class="ev-intellisense-label">${esc(item.label || item.insertText || '')}</span>
                        ${cat}
                    </span>
                    <span class="ev-intellisense-detail">${esc(item.detail || kind)}</span>
                </span>
            </button>`;
        }).join('');

        evIntellisense.classList.add('open');
        evTa.setAttribute('aria-expanded', 'true');
        renderIntellisenseDocumentation();

        evIntellisenseList.querySelectorAll('[data-suggestion-index]').forEach(button => {
            button.addEventListener('mousedown', event => {
                event.preventDefault();
                S.suggestionIndex = Number(button.dataset.suggestionIndex || 0);
                acceptSuggestion();
            });
            button.addEventListener('mousemove', () => {
                const index = Number(button.dataset.suggestionIndex || 0);
                if (index !== S.suggestionIndex) {
                    S.suggestionIndex = index;
                    syncSuggestionSelection();
                }
            });
        });
    }

    function renderIntellisenseDocumentation() {
        const item = S.filteredSuggestions[S.suggestionIndex];
        if (!item) {
            evIntellisenseDoc.innerHTML = '<div class="ev-intellisense-empty">No documentation available.</div>';
            return;
        }

        // Show the snippet with its placeholders stripped (clean code preview).
        const preview = expandSnippet(String(item.insertText || item.label || '')).text;
        const cat = item.category ? `<span class="ev-doc-cat">${esc(item.category)}</span>` : '';
        const example = item.example
            ? `<div class="ev-doc-eg-label">Example</div><code class="ev-doc-eg">${esc(item.example)}</code>`
            : '';
        evIntellisenseDoc.innerHTML = `${cat}<code class="ev-doc-preview">${esc(preview)}</code>
            <strong class="ev-doc-title">${esc(item.detail || item.kind || 'Suggestion')}</strong>
            <div class="ev-doc-description">${esc(item.documentation || '')}</div>${example}`;
    }

    function syncSuggestionSelection() {
        evIntellisenseList.querySelectorAll('[data-suggestion-index]').forEach(button => {
            const active = Number(button.dataset.suggestionIndex || 0) === S.suggestionIndex;
            button.classList.toggle('active', active);
            button.setAttribute('aria-selected', active ? 'true' : 'false');
            if (active) button.scrollIntoView({ block: 'nearest' });
        });
        renderIntellisenseDocumentation();
    }

    function moveSuggestion(delta) {
        const count = S.filteredSuggestions.length;
        if (count === 0) return;
        S.suggestionIndex = (S.suggestionIndex + delta + count) % count;
        syncSuggestionSelection();
    }

    // Expand a snippet insertText: strip tab-stop markers ${1:placeholder} / ${1}
    // / $1 / $0 and report the range to select (the first numbered stop, else the
    // $0 caret, else inside "()"), all relative to the inserted string's start.
    // PHP variables ($data, $item) are untouched — only $ followed by a digit or {digit is a stop.
    function expandSnippet(raw) {
        let out = '';
        const stops = [];   // every {num,start,end} tab-stop, in text order
        let finalStop = null;
        let i = 0;
        while (i < raw.length) {
            const rest = raw.slice(i);
            let mm = rest.match(/^\$\{(\d+):([^}]*)\}/);        // ${1:placeholder}
            if (mm) {
                const at = out.length; out += mm[2];
                if (Number(mm[1]) === 0) { if (finalStop === null) finalStop = at; }
                else stops.push({ num: Number(mm[1]), start: at, end: out.length });
                i += mm[0].length; continue;
            }
            mm = rest.match(/^\$\{(\d+)\}/) || rest.match(/^\$(\d+)/);   // ${1} or $1 / $0
            if (mm) {
                const at = out.length;
                if (Number(mm[1]) === 0) { if (finalStop === null) finalStop = at; }
                else stops.push({ num: Number(mm[1]), start: at, end: at });
                i += mm[0].length; continue;
            }
            out += raw[i]; i++;
        }
        // Visit stops by their number (1,2,3…), keeping text order for ties.
        stops.sort((a, b) => a.num - b.num);
        let sel;
        if (stops.length) sel = { start: stops[0].start, end: stops[0].end };
        else if (finalStop !== null) sel = { start: finalStop, end: finalStop };
        else { const ec = out.indexOf('()'); sel = ec >= 0 ? { start: ec + 1, end: ec + 1 } : { start: out.length, end: out.length }; }
        return { text: out, sel, stops, final: finalStop };
    }

    // Select a tab-stop by index; past the last one, drop to $0 / end and exit.
    function selectSnippetStop(index) {
        const snip = S.snippet;
        if (!snip) return;
        if (index < 0) index = 0;
        if (index >= snip.stops.length) { exitSnippet(true); return; }
        snip.index = index;
        const st = snip.stops[index];
        evTa.selectionStart = Math.min(st.start, evTa.value.length);
        evTa.selectionEnd = Math.min(st.end, evTa.value.length);
        evTa.focus();
    }

    // Leave snippet mode; optionally drop the caret at the $0 stop (else leave it).
    function exitSnippet(placeFinal) {
        const snip = S.snippet;
        S.snippet = null;
        if (snip && placeFinal && snip.final != null) {
            const at = Math.min(snip.final, evTa.value.length);
            evTa.selectionStart = evTa.selectionEnd = at;
            evTa.focus();
        }
    }

    // As the user edits the current stop, shift the later stops (and $0) by the
    // length change so Tab keeps landing in the right place.
    function adjustSnippetOnInput() {
        const snip = S.snippet;
        if (!snip) return;
        const delta = evTa.value.length - snip.len;
        snip.len = evTa.value.length;
        if (delta === 0) return;
        const cur = snip.stops[snip.index];
        const caret = evTa.selectionStart;
        // Edit outside the active stop's neighbourhood → positions are unreliable; bail out.
        if (!cur || caret < cur.start) { S.snippet = null; return; }
        cur.end = caret;
        for (let j = snip.index + 1; j < snip.stops.length; j++) {
            snip.stops[j].start += delta;
            snip.stops[j].end += delta;
        }
        if (snip.final != null && snip.final >= cur.start) snip.final += delta;
    }

    function acceptSuggestion() {
        const item = S.filteredSuggestions[S.suggestionIndex];
        if (!item) return;

        let rawInsert = String(item.insertText || item.label || '');
        if (S.suggestionContext === 'fieldArgument' && !S.suggestionQuoted) {
            rawInsert = "'" + rawInsert.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
        }
        const expanded = expandSnippet(rawInsert);
        const caret = evTa.selectionStart;
        const base = S.suggestionStart;
        const before = evTa.value.slice(0, base);
        const after = evTa.value.slice(caret);
        evTa.value = before + expanded.text + after;

        if (expanded.stops.length) {
            // Enter snippet mode: remember every tab-stop (as absolute offsets) so
            // Tab / Shift+Tab walk between the blanks; the first is selected now.
            S.snippet = {
                stops: expanded.stops.map(s => ({ start: base + s.start, end: base + s.end })),
                final: expanded.final != null ? base + expanded.final : base + expanded.text.length,
                index: -1,
                len: evTa.value.length,
            };
            selectSnippetStop(0);
        } else {
            S.snippet = null;
            // Place the caret at $0 / inside () / at the end.
            evTa.selectionStart = base + expanded.sel.start;
            evTa.selectionEnd = base + expanded.sel.end;
            evTa.focus();
        }
        updateGutter();
        setDirty(true);
        closeIntellisense();

        clearTimeout(S.saveTimer);
        S.saveTimer = setTimeout(() => {
            if (S.dirty) saveCurrentEvent();
        }, 2000);
    }

    function closeIntellisense() {
        if (!evIntellisense) return;
        evIntellisense.classList.remove('open');
        evTa?.setAttribute('aria-expanded', 'false');
        S.filteredSuggestions = [];
        S.suggestionContext = null;
        S.suggestionQuoted = false;
    }

    function isIntellisenseOpen() {
        return evIntellisense?.classList.contains('open');
    }

    function positionIntellisense(caret) {
        const point = caretPosition(evTa, caret);
        const gap = 8;
        evIntellisense.style.left = Math.max(gap, point.left) + 'px';
        evIntellisense.style.top = Math.max(gap, point.top) + 'px';

        requestAnimationFrame(() => {
            const box = evIntellisense.getBoundingClientRect();
            const left = Math.max(gap, Math.min(point.left, window.innerWidth - box.width - gap));
            const roomBelow = window.innerHeight - point.top - gap;
            const preferredTop = roomBelow >= box.height ? point.top : point.top - box.height - 24;
            const top = Math.max(gap, Math.min(preferredTop, window.innerHeight - box.height - gap));
            evIntellisense.style.left = left + 'px';
            evIntellisense.style.top = top + 'px';
        });
    }

    function caretPosition(textarea, caret) {
        const style = getComputedStyle(textarea);
        const mirror = document.createElement('div');
        const copied = [
            'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'letterSpacing',
            'lineHeight', 'textTransform', 'textIndent', 'paddingTop', 'paddingRight',
            'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderRightWidth',
            'borderBottomWidth', 'borderLeftWidth', 'boxSizing', 'wordSpacing',
        ];
        copied.forEach(name => { mirror.style[name] = style[name]; });
        mirror.style.position = 'fixed';
        mirror.style.left = '-9999px';
        mirror.style.top = '0';
        mirror.style.width = textarea.clientWidth + 'px';
        mirror.style.whiteSpace = 'pre-wrap';
        mirror.style.overflowWrap = 'break-word';
        mirror.textContent = textarea.value.slice(0, caret);

        const marker = document.createElement('span');
        marker.textContent = textarea.value.slice(caret, caret + 1) || '\u200b';
        mirror.appendChild(marker);
        document.body.appendChild(mirror);

        const rect = textarea.getBoundingClientRect();
        const lineHeight = parseFloat(style.lineHeight) || 20;
        const result = {
            left: rect.left + marker.offsetLeft - textarea.scrollLeft,
            top: rect.top + marker.offsetTop - textarea.scrollTop + lineHeight + 4,
        };
        mirror.remove();
        return result;
    }

    // ── save / load ────────────────────────────────────────────────
    async function saveCurrentEvent(silent) {
        const code = evTa.value;

        const isSchedule = (S.scope === 'global' && S.group === 'schedule');
        const isFunction = (S.scope === 'global' && S.group === 'functions');
        const isAction   = (S.scope === 'global' && S.group === 'actions');

        if (isAction) {
            saveAction(code);
            return;
        }
        if (isSchedule) {
            saveScheduleTask(code);
            return;
        }
        if (isFunction) {
            saveFunctionCode(S.eventKey, code);
            return;
        }

        const path = currentPath();
        if (!path) return;

        setEventCode(path, code);
        await persistEvents();
        setDirty(false);
        renderTree();
        renderPanel();
        if (!silent) toast('Saved', 'ok');
    }

    function currentPath() {
        const sc = S.scope, gr = S.group, ev = S.eventKey;
        if (!sc || !gr || !ev) return null;
        if (sc === 'global') return ['global', gr, ev];
        if (gr === 'fields') {
            if (!S.field || !S.fieldGroup) return null;
            return ['tables', sc, 'fields', S.field, S.fieldGroup, ev];
        }
        return ['tables', sc, gr, ev];
    }

    async function saveScheduleTask(code) {
        const tasks  = scheduleList();
        const idx    = tasks.findIndex(t => t.id === S.schedId);
        if (idx === -1) return;
        tasks[idx].code    = code;
        tasks[idx].name    = evSchedName.value.trim() || tasks[idx].name;
        tasks[idx].cron    = evSchedCron.value.trim() || tasks[idx].cron;
        tasks[idx].enabled = code.trim().length > 0;
        await persistEvents();
        setDirty(false);
        renderTree();
        renderPanel();
        evCrumbEvent.textContent = tasks[idx].name || 'Untitled Task';
        toast('Task saved', 'ok');
    }

    async function saveAction(code) {
        const acts = actionsList();
        const idx  = acts.findIndex(a => a.id === S.actionId);
        if (idx === -1) return;
        let name = (evActionName.value || '').trim().replace(/[^A-Za-z0-9_]/g, '');
        if (name === '' || !/^[A-Za-z_]/.test(name)) name = acts[idx].name || 'action';
        acts[idx].name    = name;
        acts[idx].code    = code;
        acts[idx].enabled = code.trim().length > 0;
        evActionName.value = name;
        if (evActionHint) evActionHint.textContent = "await pp.run('" + name + "', { /* params */ })";
        await persistEvents();
        setDirty(false);
        renderTree();
        renderPanel();
        evCrumbEvent.textContent = name;
        toast('Server action saved', 'ok');
    }

    async function saveFunctionCode(fnKey, code) {
        if (!S.events.global.functions) S.events.global.functions = {};
        S.events.global.functions[fnKey] = { code, enabled: code.trim().length > 0 };
        await persistEvents();
        setDirty(false);
        renderTree();
        renderPanel();
        toast('Saved', 'ok');
    }

    async function persistEvents() {
        try {
            const r = await fetch(BASE + '/save', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ events: S.events }),
            });
            const j = await r.json();
            if (j.success) S.events = normalizeEvents(j.events);
        } catch (e) {
            toast('Save failed: ' + e.message, 'err');
        }
    }

    async function loadFields(tableName) {
        try {
            const r = await fetch(BASE + '/fields?table=' + encodeURIComponent(tableName));
            const j = await r.json();
            if (j.success) S.tableFields[tableName] = j.fields || [];
            else S.tableFields[tableName] = [];
        } catch {
            S.tableFields[tableName] = [];
        }
    }

    // ── wire events ────────────────────────────────────────────────
    function wirePanelClicks() {
        // toggle switches
        evPanelBody.querySelectorAll('[data-togev]').forEach(el => {
            el.addEventListener('click', e => {
                e.stopPropagation();
                const key = el.dataset.togev;
                // section rows carry their own storage group; else fall back to S.group
                const grp = el.dataset.toggrp || S.group;
                const path = S.scope === 'global' ? ['global', grp, key] : ['tables', S.scope, grp, key];
                if (!grp) return;
                const entry = getEventEntry(path, '');
                entry.enabled = !entry.enabled;
                persistEvents().then(() => { renderTree(); renderPanel(); });
            });
        });

        evPanelBody.querySelectorAll('[data-togfn]').forEach(el => {
            el.addEventListener('click', e => {
                e.stopPropagation();
                const fnKey = el.dataset.togfn;
                const entry = S.events.global?.functions?.[fnKey];
                if (entry) {
                    entry.enabled = !entry.enabled;
                    persistEvents().then(() => renderTree());
                }
            });
        });

        evPanelBody.querySelectorAll('[data-togfev]').forEach(el => {
            el.addEventListener('click', e => {
                e.stopPropagation();
                const grpKey = el.dataset.togfev;
                const evKey  = el.dataset.togfevkey;
                const path   = ['tables', S.scope, 'fields', S.field, grpKey, evKey];
                const entry  = getEventEntry(path, '');
                entry.enabled = !entry.enabled;
                persistEvents().then(() => { renderTree(); renderPanel(); });
            });
        });

        // event rows
        evPanelBody.querySelectorAll('[data-evkey]').forEach(el => {
            el.addEventListener('click', () => {
                const key = el.dataset.evkey;
                flushBeforeLeave();
                // section rows carry their storage group; adopt it before opening
                if (el.dataset.group) S.group = el.dataset.group;
                selectEvent(key);
            });
        });

        // function rows
        evPanelBody.querySelectorAll('[data-fnkey]').forEach(el => {
            el.addEventListener('click', () => {
                flushBeforeLeave();
                S.eventKey = el.dataset.fnkey;
                S.group = 'functions';
                openFunctionEvent(S.eventKey);
                renderPanel();
            });
        });

        // schedule rows
        evPanelBody.querySelectorAll('[data-schedid]').forEach(el => {
            el.addEventListener('click', e => {
                if (e.target.closest('[data-delschedid]')) return;
                flushBeforeLeave();
                S.schedId = el.dataset.schedid;
                const task = scheduleList().find(t => t.id === S.schedId);
                if (task) openScheduleEvent(task);
                renderPanel();
            });
        });

        evPanelBody.querySelectorAll('[data-delschedid]').forEach(el => {
            el.addEventListener('click', async e => {
                e.stopPropagation();
                const id = el.dataset.delschedid;
                if (!(await evConfirm({ title: 'Delete scheduled task?', text: 'This task will be removed from the schedule.', confirmText: 'Delete', danger: true }))) return;
                S.events.global.schedule = scheduleList().filter(t => t.id !== id);
                if (S.schedId === id) { S.schedId = null; evEmpty.style.display='flex'; evEditor.style.display='none'; }
                persistEvents().then(() => { renderTree(); renderPanel(); });
            });
        });

        // add schedule
        const addBtn = $('evAddSched');
        if (addBtn) {
            addBtn.addEventListener('click', () => {
                const task = { id: uid(), name: 'New Task', cron: '0 * * * *', enabled: false, code: '' };
                if (!S.events.global.schedule) S.events.global.schedule = [];
                S.events.global.schedule.push(task);
                S.schedId = task.id;
                openScheduleEvent(task);
                persistEvents().then(() => { renderTree(); renderPanel(); });
            });
        }

        // server action rows
        evPanelBody.querySelectorAll('[data-actionid]').forEach(el => {
            el.addEventListener('click', e => {
                if (e.target.closest('[data-delactionid]')) return;
                flushBeforeLeave();
                S.actionId = el.dataset.actionid;
                const action = actionsList().find(a => a.id === S.actionId);
                if (action) openActionEvent(action);
                renderPanel();
            });
        });

        evPanelBody.querySelectorAll('[data-delactionid]').forEach(el => {
            el.addEventListener('click', async e => {
                e.stopPropagation();
                const id = el.dataset.delactionid;
                if (!(await evConfirm({ title: 'Delete server action?', text: 'This action handler will be removed.', confirmText: 'Delete', danger: true }))) return;
                S.events.global.actions = actionsList().filter(a => a.id !== id);
                if (S.actionId === id) {
                    S.actionId = null;
                    evActionHeader.style.display = 'none';
                    evEmpty.style.display = 'flex';
                    evEditor.style.display = 'none';
                }
                persistEvents().then(() => { renderTree(); renderPanel(); });
            });
        });

        // add server action
        const addActBtn = $('evAddAction');
        if (addActBtn) {
            addActBtn.addEventListener('click', () => {
                const acts = actionsList();
                let n = acts.length + 1, name = 'action' + n;
                while (acts.some(a => a.name === name)) { n++; name = 'action' + n; }
                const action = { id: uid(), name, enabled: false, code: '' };
                acts.push(action);
                S.actionId = action.id;
                openActionEvent(action);
                persistEvents().then(() => { renderTree(); renderPanel(); });
            });
        }

        // back-to-fields button
        const backBtn = $('evBackToFields');
        if (backBtn) {
            backBtn.addEventListener('click', () => {
                S.field = null;
                S.fieldGroup = null;
                S.eventKey = null;
                renderPanel();
            });
        }

        // field rows
        evPanelBody.querySelectorAll('[data-field]').forEach(el => {
            el.addEventListener('click', () => {
                S.field = el.dataset.field;
                renderPanel();
            });
        });

        // field event rows
        evPanelBody.querySelectorAll('[data-fevkey]').forEach(el => {
            el.addEventListener('click', () => {
                flushBeforeLeave();
                const grpKey = el.dataset.fevgrp;
                const evKey  = el.dataset.fevkey;
                S.group      = 'fields';   // so currentPath() targets the field-event storage path
                S.fieldGroup = grpKey;
                S.eventKey   = evKey;
                const def    = S.catalogue.field[grpKey]?.events?.[evKey] || {};
                const path   = ['tables', S.scope, 'fields', S.field, grpKey, evKey];
                openEvent(def, path, def.lang || S.catalogue.field[grpKey]?.lang || 'php', {
                    scope: S.scope,
                    group: S.field + ' / ' + S.catalogue.field[grpKey].label,
                    event: def.label,
                });
                renderPanel();
            });
        });
    }

    function buildTogglePath(evKey) {
        const sc = S.scope, gr = S.group;
        if (sc === 'global') return ['global', gr, evKey];
        return ['tables', sc, gr, evKey];
    }

    function selectEvent(key) {
        S.eventKey = key;
        let def, path;

        if (S.scope === 'global') {
            def  = S.catalogue.global[S.group]?.events?.[key] || {};
            path = ['global', S.group, key];
            openEvent(def, path, def.lang || 'php', {
                scope: 'Global',
                group: S.catalogue.global[S.group]?.label || S.group,
                event: def.label || key,
            });
        } else {
            def  = S.catalogue.table[S.group]?.events?.[key] || {};
            path = ['tables', S.scope, S.group, key];
            openEvent(def, path, def.lang || S.catalogue.table[S.group]?.lang || 'php', {
                scope: S.scope,
                group: S.catalogue.table[S.group]?.label || S.group,
                event: def.label || key,
            });
        }

        renderPanel();
    }

    // ── tree click wiring ──────────────────────────────────────────
    function wireTree() {
        evTreeBody.addEventListener('click', e => {
            const globalToggle = e.target.closest('[data-act="toggle-global"]');
            if (globalToggle) {
                globalEventsExpanded = !globalEventsExpanded;
                renderTree();
                return;
            }

            const node  = e.target.closest('.ev-node');
            const child = e.target.closest('.ev-tbl-child');

            if (node) {
                const act = node.dataset.act;
                if (act === 'toggle-table') {
                    const t = node.dataset.table;
                    if (expandedTables.has(t)) expandedTables.delete(t);
                    else { expandedTables.add(t); }
                    if (!expandedTables.has(t)) {
                        // collapsed: reset table selection if this table
                        if (S.scope === t) { S.scope = null; S.section = null; S.group = null; S.field = null; S.eventKey = null; }
                    }
                    renderTree();
                    return;
                }

                if (node.dataset.scope === 'global') {
                    flushBeforeLeave();
                    S.scope      = 'global';
                    S.section    = null;
                    S.group      = node.dataset.group;
                    S.eventKey   = null;
                    S.field      = null;
                    S.fieldGroup = null;
                    S.schedId    = null;
                    S.actionId   = null;
                    renderTree();
                    renderPanel();
                    evSchedHeader.style.display = 'none';
                    evActionHeader.style.display = 'none';
                }
            }

            if (child) {
                flushBeforeLeave();
                S.scope      = child.dataset.scope;
                S.section    = child.dataset.section;
                S.group      = null;
                S.eventKey   = null;
                S.field      = null;
                S.fieldGroup = null;
                renderTree();
                renderPanel();
                evSchedHeader.style.display = 'none';
                evActionHeader.style.display = 'none';
            }
        });

        evTreeBody.addEventListener('input', e => {
            if (e.target.id !== 'evTableSearch') return;
            tableSearch = e.target.value;
            applyTableFilter();
        });
    }

    // ── textarea behaviours ────────────────────────────────────────
    function wireEditor() {
        evTa.addEventListener('input', () => {
            adjustSnippetOnInput();
            updateGutter();
            setDirty(true);
            showIntellisense(false);
            scheduleLint();
            // auto-save debounce
            clearTimeout(S.saveTimer);
            S.saveTimer = setTimeout(() => {
                if (S.dirty) saveCurrentEvent();
            }, 2000);
        });

        // A click repositions the caret arbitrarily → end snippet mode so its
        // stored blank offsets can't go stale.
        evTa.addEventListener('mousedown', () => { S.snippet = null; });

        // ── code checker interactions ──
        if (evDiagHide) evDiagHide.addEventListener('click', () => { evDiag.hidden = true; });
        if (evLintPill) evLintPill.addEventListener('click', () => {
            if (S.diagnostics && S.diagnostics.length) evDiag.hidden = !evDiag.hidden;
        });
        if (evDiagList) evDiagList.addEventListener('click', e => {
            const fixBtn = e.target.closest('.ev-diag-fix');
            if (fixBtn) { e.stopPropagation(); applyFix(parseInt(fixBtn.dataset.fix, 10)); return; }
            const item = e.target.closest('.ev-diag-item');
            if (item) jumpToDiagnostic(S.diagnostics[parseInt(item.dataset.di, 10)]);
        });

        // ── "Check all events" report ──
        if (evScanBtn) evScanBtn.addEventListener('click', openScanReport);
        if (evScanClose) evScanClose.addEventListener('click', () => { evScanOv.hidden = true; });
        if (evScanOv) evScanOv.addEventListener('click', e => { if (e.target === evScanOv) evScanOv.hidden = true; });
        document.addEventListener('keydown', e => { if (e.key === 'Escape' && evScanOv && !evScanOv.hidden) evScanOv.hidden = true; });

        // sync gutter scroll
        evTa.addEventListener('scroll', () => {
            evGutterLines.style.transform = `translateY(-${evTa.scrollTop}px)`;
            closeIntellisense();
            if (S.squiggleRaf) cancelAnimationFrame(S.squiggleRaf);
            S.squiggleRaf = requestAnimationFrame(drawSquiggles);
        });
        window.addEventListener('resize', () => {
            if (S.squiggleRaf) cancelAnimationFrame(S.squiggleRaf);
            S.squiggleRaf = requestAnimationFrame(drawSquiggles);
        });

        // Tab key → 4 spaces
        evTa.addEventListener('keydown', e => {
            const isMac = navigator.platform.toUpperCase().includes('MAC');
            const ctrl  = isMac ? e.metaKey : e.ctrlKey;

            // Ctrl/Cmd+Space → show all context-aware suggestions
            if ((e.ctrlKey || e.metaKey) && e.code === 'Space') {
                e.preventDefault();
                showIntellisense(true);
                return;
            }

            if (isIntellisenseOpen()) {
                if (e.key === 'ArrowDown') {
                    e.preventDefault();
                    moveSuggestion(1);
                    return;
                }
                if (e.key === 'ArrowUp') {
                    e.preventDefault();
                    moveSuggestion(-1);
                    return;
                }
                if (e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) {
                    e.preventDefault();
                    acceptSuggestion();
                    return;
                }
                if (e.key === 'Escape') {
                    e.preventDefault();
                    closeIntellisense();
                    return;
                }
            }

            // Snippet tab-stops: Tab / Shift+Tab walk between the blanks; Escape
            // leaves snippet mode. Any other caret move ends it (so the stored
            // offsets can't go stale) but still does its normal job.
            if (S.snippet) {
                if (e.key === 'Tab') {
                    e.preventDefault();
                    selectSnippetStop(S.snippet.index + (e.shiftKey ? -1 : 1));
                    return;
                }
                if (e.key === 'Escape') {
                    e.preventDefault();
                    exitSnippet(false);
                    return;
                }
                if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown', 'Enter'].includes(e.key)) {
                    S.snippet = null;   // fall through to the key's normal behaviour
                }
            }

            // Ctrl/Cmd+S → save
            if (ctrl && e.key === 's') {
                e.preventDefault();
                clearTimeout(S.saveTimer);
                saveCurrentEvent();
                return;
            }

            // Ctrl/Cmd+/ → toggle line comment
            if (ctrl && e.key === '/') {
                e.preventDefault();
                toggleComment();
                return;
            }

            // Tab → indent
            if (e.key === 'Tab') {
                e.preventDefault();
                const start = evTa.selectionStart;
                const end   = evTa.selectionEnd;
                const val   = evTa.value;

                if (e.shiftKey) {
                    // dedent
                    const lineStart = val.lastIndexOf('\n', start - 1) + 1;
                    if (val.slice(lineStart, lineStart + 4) === '    ') {
                        evTa.value = val.slice(0, lineStart) + val.slice(lineStart + 4);
                        evTa.selectionStart = evTa.selectionEnd = start - 4;
                    }
                } else {
                    evTa.value = val.slice(0, start) + '    ' + val.slice(end);
                    evTa.selectionStart = evTa.selectionEnd = start + 4;
                }
                updateGutter();
                setDirty(true);
                return;
            }

            // Enter → auto-indent
            if (e.key === 'Enter') {
                e.preventDefault();
                const start = evTa.selectionStart;
                const val   = evTa.value;
                const lineStart = val.lastIndexOf('\n', start - 1) + 1;
                const line = val.slice(lineStart, start);
                const indent = line.match(/^(\s*)/)[1];
                const extra  = line.trim().endsWith('{') ? '    ' : '';
                const ins    = '\n' + indent + extra;
                evTa.value = val.slice(0, start) + ins + val.slice(evTa.selectionEnd);
                evTa.selectionStart = evTa.selectionEnd = start + ins.length;
                updateGutter();
                setDirty(true);
                return;
            }

            // Auto-close brackets & quotes
            const pairs = { '{':'}', '(':')', '[':']', '"':'"', "'":"'" };
            if (pairs[e.key]) {
                const start = evTa.selectionStart;
                const end   = evTa.selectionEnd;
                if (start !== end) {
                    // wrap selection
                    e.preventDefault();
                    const val = evTa.value;
                    const sel = val.slice(start, end);
                    evTa.value = val.slice(0, start) + e.key + sel + pairs[e.key] + val.slice(end);
                    evTa.selectionStart = start + 1;
                    evTa.selectionEnd   = end + 1;
                    setDirty(true);
                }
            }
        });

        evTa.addEventListener('click', () => closeIntellisense());
        evTa.addEventListener('blur', () => setTimeout(closeIntellisense, 100));
        window.addEventListener('resize', closeIntellisense);
    }

    function toggleComment() {
        const start = evTa.selectionStart;
        const end   = evTa.selectionEnd;
        const val   = evTa.value;
        const lineStart = val.lastIndexOf('\n', start - 1) + 1;
        const lineEnd   = val.indexOf('\n', end);
        const line = val.slice(lineStart, lineEnd === -1 ? undefined : lineEnd);
        const trimmed = line.trimStart();
        const isPhp = evLangPill.textContent === 'PHP';
        const prefix = isPhp ? '// ' : '// ';
        let newLine;
        if (trimmed.startsWith('// ')) {
            newLine = line.replace('// ', '');
        } else if (trimmed.startsWith('//')) {
            newLine = line.replace('//', '');
        } else {
            newLine = line.replace(/^(\s*)/, '$1// ');
        }
        const newVal = val.slice(0, lineStart) + newLine + (lineEnd === -1 ? '' : val.slice(lineEnd));
        evTa.value = newVal;
        evTa.selectionStart = start;
        evTa.selectionEnd   = end + (newLine.length - line.length);
        updateGutter();
        setDirty(true);
    }

    // ── toolbar buttons ────────────────────────────────────────────
    function wireToolbar() {
        $('evBtnSave').addEventListener('click', () => {
            clearTimeout(S.saveTimer);
            saveCurrentEvent();
        });

        $('evBtnReset').addEventListener('click', async () => {
            const path = currentPath();
            if (!path) return;
            const eventDef = currentEventDef();
            const stub = eventDef?.stub || '';
            if (!(await evConfirm({ title: 'Reset to the starter template?', text: 'Your current code will be replaced with the default stub for this event.', confirmText: 'Reset', danger: true }))) return;
            setCode(stub);
            setDirty(true);
        });

        $('evBtnCopy').addEventListener('click', () => {
            navigator.clipboard.writeText(evTa.value).then(() => toast('Copied', 'ok'));
        });

        $('evBtnClear').addEventListener('click', async () => {
            if (!(await evConfirm({ title: 'Clear all code?', text: 'This empties the editor for this event.', confirmText: 'Clear', danger: true }))) return;
            setCode('');
            setDirty(true);
        });

        evDocsToggle.addEventListener('click', () => {
            const open = evDocsBody.classList.toggle('open');
            evDocsToggle.classList.toggle('open', open);
        });

        // schedule name/cron changes
        [evSchedName, evSchedCron].forEach(el => {
            el.addEventListener('input', () => setDirty(true));
        });
    }

    function currentEventDef() {
        const sc = S.scope, gr = S.group, ev = S.eventKey;
        if (sc === 'global') return S.catalogue.global[gr]?.events?.[ev];
        if (gr === 'fields') return S.catalogue.field[S.fieldGroup]?.events?.[ev];
        return S.catalogue.table[gr]?.events?.[ev];
    }

    // ── resizers ───────────────────────────────────────────────────
    function wireResizers() {
        const ws = $('evWorkspace');

        function wire(resizer, varName, minW, maxW, targetEl) {
            let startX, startW;
            resizer.addEventListener('mousedown', e => {
                startX = e.clientX;
                startW = parseInt(getComputedStyle(ws).getPropertyValue(varName)) || targetEl.offsetWidth;
                document.body.classList.add('ev-resizing');
                resizer.classList.add('dragging');
                const onMove = ev => {
                    const w = Math.max(minW, Math.min(maxW, startW + (ev.clientX - startX)));
                    ws.style.setProperty(varName, w + 'px');
                    localStorage.setItem('ev_' + varName, w);
                };
                const onUp = () => {
                    document.removeEventListener('mousemove', onMove);
                    document.removeEventListener('mouseup', onUp);
                    document.body.classList.remove('ev-resizing');
                    resizer.classList.remove('dragging');
                };
                document.addEventListener('mousemove', onMove);
                document.addEventListener('mouseup', onUp);
            });

            const saved = localStorage.getItem('ev_' + varName);
            if (saved) ws.style.setProperty(varName, saved + 'px');
        }

        wire($('evResizerTree'),  '--ev-tree-w',  160, 320, $('evTree'));
        wire($('evResizerPanel'), '--ev-panel-w', 200, 400, evPanel);
    }

    // ── helpers ────────────────────────────────────────────────────
    function esc(s) {
        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
    }

    function uid() {
        return Math.random().toString(36).slice(2,9) + Date.now().toString(36);
    }

    // The editor auto-saves; when navigating to another event, silently flush any
    // pending change first so nothing is lost — no "discard?" prompt needed.
    function flushBeforeLeave() {
        if (!S.dirty) return;
        clearTimeout(S.saveTimer);
        saveCurrentEvent(true);
    }

    // Themed confirm dialog (SweetAlert2, dark to match the editor). Resolves to
    // true when the user confirms. Falls back to native confirm if Swal is absent.
    function evConfirm(opts) {
        opts = opts || {};
        if (!window.Swal) return Promise.resolve(window.confirm(opts.text || opts.title || 'Are you sure?'));
        return Swal.fire({
            title: opts.title || 'Are you sure?',
            html: opts.text || '',
            icon: opts.icon || 'warning',
            iconColor: opts.danger ? '#dc2626' : (opts.iconColor || '#f9e2af'),
            showCancelButton: true,
            confirmButtonText: opts.confirmText || 'Yes',
            cancelButtonText: opts.cancelText || 'Cancel',
            reverseButtons: true,
            buttonsStyling: false,
            customClass: {
                popup: 'ev-swal-popup',
                confirmButton: 'ev-swal-btn ev-swal-confirm' + (opts.danger ? ' is-danger' : ''),
                cancelButton: 'ev-swal-btn ev-swal-cancel',
            },
        }).then(r => !!r.isConfirmed);
    }

    let toastTimer;
    function toast(msg, type) {
        clearTimeout(toastTimer);
        evToast.className = 'ev-toast show ' + (type || '');
        evToast.innerHTML = (type === 'ok' ? '<i class="bi bi-check2-circle"></i> ' : '<i class="bi bi-exclamation-circle"></i> ') + esc(msg);
        toastTimer = setTimeout(() => { evToast.classList.remove('show'); }, 2200);
    }

    // Bridge for the in-app Guide: drop an example into the OPEN event editor at
    // the caret. Returns false (with a nudge) when no event is open. Kept here so
    // the guide can stay self-contained and just feature-detect this function.
    window.PpEventsInsertExample = function (code) {
        if (typeof code !== 'string' || code === '') return false;
        if (!evEditor || evEditor.style.display === 'none') {
            toast('Open an event first, then insert.', 'err');
            return false;
        }
        code = code.replace(/^﻿?\s*<\?php\s*\r?\n?/, '');   // drop a leading <?php tag
        const s = evTa.selectionStart, e = evTa.selectionEnd;
        const before = evTa.value.slice(0, s), after = evTa.value.slice(e);
        const sep = (before && !before.endsWith('\n')) ? '\n' : '';
        const tail = code.endsWith('\n') ? '' : '\n';
        evTa.value = before + sep + code + tail + after;
        evTa.selectionStart = evTa.selectionEnd = (before + sep + code).length;
        setDirty(true); updateGutter(); evTa.focus();
        toast('Example inserted', 'ok');
        return true;
    };

    // ── boot ───────────────────────────────────────────────────────
    function init() {
        wireTree();
        wireEditor();
        wireToolbar();
        wireResizers();
        renderTree();
        // auto-open first global group
        S.scope = 'global';
        S.group = 'auth';
        renderTree();
        renderPanel();
    }

    init();

})();
