/**
 * pp.js – client‑side utility library for pp‑php.
 *
 * Namespaces:
 *   pp.ajax    – fetch wrappers with built‑in loading state
 *   pp.dialog  – SweetAlert2 shortcuts (toast, confirm, prompt, alert)
 *   pp.form    – form value getters/setters, simple validation
 *   pp.util    – debounce, throttle, date format, deep clone, UUID
 *
 * Dependency: SweetAlert2 (must be loaded before this script)
 */

(function (global) {
    'use strict';

    // ── Private helpers ────────────────────────────────────────────────
    const isObj = v => v && typeof v === 'object';
    const noop  = () => {};

    // ── pp.ajax ────────────────────────────────────────────────────────
    const ajax = {
        /**
         * GET request.
         * @param {string} url
         * @param {object} [options] – fetch options
         * @returns {Promise<any>} parsed JSON
         */
        async get(url, options = {}) {
            const res = await fetch(url, { method: 'GET', ...options });
            return res.json();
        },

        /**
         * POST request with JSON body.
         * @param {string} url
         * @param {any} data – sent as JSON
         * @param {object} [options] – extra fetch options
         * @returns {Promise<any>} parsed JSON
         */
        async post(url, data, options = {}) {
            const res = await fetch(url, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json', ...options.headers },
                body: JSON.stringify(data),
                ...options,
            });
            return res.json();
        },

        /**
         * Upload a file (multipart).
         * @param {string} url
         * @param {FormData} formData
         * @param {function} [onProgress] – receives 0–100
         * @returns {Promise<any>}
         */
        async upload(url, formData, onProgress = noop) {
            return new Promise((resolve, reject) => {
                const xhr = new XMLHttpRequest();
                xhr.open('POST', url);
                xhr.upload.addEventListener('progress', e => {
                    if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
                });
                xhr.addEventListener('load', () => {
                    try { resolve(JSON.parse(xhr.responseText)); }
                    catch (e) { reject(new Error('Invalid JSON response')); }
                });
                xhr.addEventListener('error', () => reject(new Error('Upload failed')));
                xhr.send(formData);
            });
        },

        /**
         * GET with automatic loading spinner (SweetAlert2).
         * @param {string} url
         * @param {string} [loadingText] – e.g. "Loading..."
         * @returns {Promise<any>}
         */
        async load(url, loadingText = 'Loading…') {
            Swal.fire({ title: loadingText, allowOutsideClick: false, didOpen: () => Swal.showLoading() });
            try {
                const data = await ajax.get(url);
                Swal.close();
                return data;
            } catch (err) {
                Swal.fire('Error', err.message, 'error');
                throw err;
            }
        },
    };

    // ── pp.dialog ──────────────────────────────────────────────────────
    // SweetAlert2 gives the rich version; when it isn't on the page (e.g. a lean
    // generated page), every method degrades to a native browser dialog / a tiny
    // built-in toast so pp.dialog is always safe to call — no hard dependency.
    const hasSwal = () => typeof global.Swal !== 'undefined';
    const nativeToast = (icon, title, timer) => {
        try {
            let host = document.getElementById('pp-toast-host');
            if (!host) {
                host = document.createElement('div');
                host.id = 'pp-toast-host';
                host.style.cssText = 'position:fixed;top:16px;right:16px;z-index:99999;display:flex;flex-direction:column;gap:8px;pointer-events:none';
                document.body.appendChild(host);
            }
            const t = document.createElement('div');
            const tone = { success: '#198754', error: '#dc3545', warning: '#fd7e14', info: '#0d6efd', question: '#6c757d' }[icon] || '#0d6efd';
            t.style.cssText = 'pointer-events:auto;background:#fff;color:#1a1d23;border-left:4px solid ' + tone + ';box-shadow:0 8px 24px -8px rgba(0,0,0,.3);border-radius:8px;padding:10px 14px;font:14px system-ui,sans-serif;max-width:320px;opacity:0;transform:translateY(-6px);transition:.2s';
            t.textContent = title;
            host.appendChild(t);
            requestAnimationFrame(() => { t.style.opacity = '1'; t.style.transform = 'none'; });
            setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 250); }, timer);
        } catch (e) { /* no DOM — ignore */ }
    };

    const dialog = {
        /**
         * Success / error toast.
         * @param {'success'|'error'|'warning'|'info'|'question'} icon
         * @param {string} title
         * @param {number} [timer=3000]
         */
        toast(icon, title, timer = 3000) {
            if (hasSwal()) { Swal.fire({ icon, title, toast: true, position: 'top-end', showConfirmButton: false, timer }); }
            else { nativeToast(icon, title, timer); }
        },

        /**
         * Confirmation dialog.
         * @param {string} title
         * @param {string} text
         * @param {object} [options]
         * @returns {Promise<boolean>} true if confirmed
         */
        async confirm(title, text, options = {}) {
            if (!hasSwal()) return global.confirm([title, text].filter(Boolean).join('\n\n'));
            const result = await Swal.fire({
                title,
                text,
                icon: 'question',
                showCancelButton: true,
                confirmButtonColor: options.confirmColor || '#198754',
                cancelButtonColor: options.cancelColor || '#6c757d',
                confirmButtonText: options.confirmText || 'Yes',
                cancelButtonText: options.cancelText || 'Cancel',
                ...options,
            });
            return result.isConfirmed;
        },

        /**
         * Prompt for a text value.
         * @param {string} title
         * @param {string} [placeholder]
         * @returns {Promise<string|null>}
         */
        async prompt(title, placeholder = '') {
            if (!hasSwal()) return global.prompt(title, '');
            const { value } = await Swal.fire({
                title,
                input: 'text',
                inputPlaceholder: placeholder,
                showCancelButton: true,
                confirmButtonText: 'OK',
            });
            return value;
        },

        /**
         * Simple alert.
         * @param {string} title
         * @param {string} [text]
         * @param {'success'|'error'|'warning'|'info'} [icon]
         */
        async alert(title, text = '', icon = 'info') {
            if (!hasSwal()) { global.alert([title, text].filter(Boolean).join('\n\n')); return; }
            await Swal.fire({ icon, title, text, confirmButtonText: 'OK' });
        },

        /**
         * Multi-field input dialog — collect several values at once, PHPRunner-style.
         * @param {object} opts
         *   {string}  title   dialog heading
         *   {string}  header  text above the fields
         *   {Array}   fields  [{ name, label?, type?, value?, options? }]
         *                     type: text|textarea|number|email|date|password|checkbox|select
         *                     options: ['A','B'] or [{value,label}] (implies a select)
         *   {string}  ok      OK button label (default 'OK')
         *   {string}  cancel  Cancel label (default 'Cancel'; '' hides it)
         *   {string}  bottom  text below the fields
         * @returns {Promise<object|null>} { name: value, … } on OK, or null on Cancel.
         */
        async form(opts = {}) {
            const fields = opts.fields || [];
            if (!hasSwal()) {
                const out = {};
                for (const f of fields) {
                    const label = f.label || f.name;
                    if (f.type === 'checkbox') { out[f.name] = global.confirm(label); continue; }
                    const v = global.prompt(label, f.value != null ? String(f.value) : '');
                    if (v === null) return null;
                    out[f.name] = v;
                }
                return out;
            }
            const esc = util.escapeHtml;
            const rowHtml = (f) => {
                const id = 'ppf_' + f.name, val = f.value != null ? String(f.value) : '';
                const lab = '<label for="' + id + '" style="display:block;text-align:left;font-size:.85rem;font-weight:600;margin:8px 2px 4px">' + esc(f.label || f.name) + '</label>';
                if (f.type === 'checkbox') return '<label style="display:flex;gap:8px;align-items:center;text-align:left;margin:10px 2px"><input type="checkbox" id="' + id + '"' + (f.value ? ' checked' : '') + '> ' + esc(f.label || f.name) + '</label>';
                if (f.type === 'textarea') return lab + '<textarea id="' + id + '" class="swal2-textarea" style="margin:0;display:block;width:100%">' + esc(val) + '</textarea>';
                if (f.type === 'select' || (f.options && f.options.length)) {
                    const opts = (f.options || []).map((o) => {
                        const ov = (o && o.value !== undefined) ? o.value : o;
                        const ol = (o && o.label !== undefined) ? o.label : o;
                        return '<option value="' + esc(String(ov)) + '"' + (String(ov) === val ? ' selected' : '') + '>' + esc(String(ol)) + '</option>';
                    }).join('');
                    return lab + '<select id="' + id + '" class="swal2-select" style="margin:0;display:block;width:100%">' + opts + '</select>';
                }
                return lab + '<input id="' + id + '" type="' + esc(f.type || 'text') + '" value="' + esc(val) + '" class="swal2-input" style="margin:0;display:block;width:100%">';
            };
            const res = await Swal.fire({
                title: opts.title || '',
                html: (opts.header ? '<p style="margin:0 0 6px">' + esc(opts.header) + '</p>' : '')
                    + fields.map(rowHtml).join('')
                    + (opts.bottom ? '<p style="margin-top:10px;font-size:.85rem;color:#6b7280">' + esc(opts.bottom) + '</p>' : ''),
                focusConfirm: false,
                showCancelButton: opts.cancel !== '',
                confirmButtonText: opts.ok || 'OK',
                cancelButtonText: opts.cancel || 'Cancel',
                preConfirm: () => {
                    const out = {};
                    fields.forEach((f) => { const el = document.getElementById('ppf_' + f.name); if (el) out[f.name] = (f.type === 'checkbox') ? el.checked : el.value; });
                    return out;
                },
            });
            return res.isConfirmed ? res.value : null;
        },
    };

    // ── pp.form ────────────────────────────────────────────────────────
    const form = {
        /**
         * Get value from a form element by CSS selector or element.
         */
        getValue(el) {
            const element = typeof el === 'string' ? document.querySelector(el) : el;
            if (!element) return null;
            if (element.type === 'checkbox') return element.checked;
            if (element.type === 'radio') {
                const checked = document.querySelector(`input[name="${element.name}"]:checked`);
                return checked ? checked.value : null;
            }
            return element.value;
        },

        /**
         * Set value on a form element by CSS selector or element.
         */
        setValue(el, value) {
            const element = typeof el === 'string' ? document.querySelector(el) : el;
            if (!element) return;
            if (element.type === 'checkbox') { element.checked = !!value; return; }
            if (element.type === 'radio') {
                const radio = document.querySelector(`input[name="${element.name}"][value="${value}"]`);
                if (radio) radio.checked = true;
                return;
            }
            element.value = value;
        },

        /**
         * Simple validation: required, minlength, email, pattern.
         * @param {HTMLElement} el
         * @param {object} rules – { required:true, minlength:2, email:true, pattern:/regex/ }
         * @returns {{ valid: boolean, message: string }}
         */
        validate(el, rules = {}) {
            const value = form.getValue(el);
            if (rules.required && (!value || (typeof value === 'string' && !value.trim()))) {
                return { valid: false, message: rules.requiredMessage || 'This field is required.' };
            }
            if (rules.minlength && (value || '').length < rules.minlength) {
                return { valid: false, message: `Minimum ${rules.minlength} characters required.` };
            }
            if (rules.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
                return { valid: false, message: 'Please enter a valid email address.' };
            }
            if (rules.pattern && !rules.pattern.test(value)) {
                return { valid: false, message: rules.patternMessage || 'Invalid format.' };
            }
            return { valid: true, message: '' };
        },

        /**
         * Serialise a <form> to a plain object.
         * @param {string|HTMLFormElement} formSelector
         * @returns {object}
         */
        toObject(formSelector) {
            const f = typeof formSelector === 'string' ? document.querySelector(formSelector) : formSelector;
            if (!f) return {};
            const data = new FormData(f);
            const obj = {};
            data.forEach((v, k) => { obj[k] = v; });
            return obj;
        },
    };

    // ── pp.util ────────────────────────────────────────────────────────
    const util = {
        /**
         * Debounce a function.
         * @param {function} fn
         * @param {number} ms
         * @returns {function}
         */
        debounce(fn, ms = 300) {
            let timer;
            return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), ms); };
        },

        /**
         * Throttle a function.
         * @param {function} fn
         * @param {number} ms
         * @returns {function}
         */
        throttle(fn, ms = 100) {
            let last = 0;
            return function (...args) {
                const now = Date.now();
                if (now - last >= ms) { last = now; fn.apply(this, args); }
            };
        },

        /**
         * Format a date.
         * @param {Date|string} date
         * @param {string} [format='M j, Y, g:i a']
         * @returns {string}
         */
        formatDate(date, format = 'M j, Y, g:i a') {
            const d = new Date(date);
            if (isNaN(d.getTime())) return '';
            const map = {
                Y: d.getFullYear(), y: d.getFullYear().toString().slice(-2),
                F: d.toLocaleString('default', { month: 'long' }),
                M: d.toLocaleString('default', { month: 'short' }),
                m: String(d.getMonth() + 1).padStart(2, '0'),
                n: d.getMonth() + 1,
                j: d.getDate(),
                d: String(d.getDate()).padStart(2, '0'),
                g: d.getHours() % 12 || 12,
                G: d.getHours(),
                h: String(d.getHours() % 12 || 12).padStart(2, '0'),
                H: String(d.getHours()).padStart(2, '0'),
                i: String(d.getMinutes()).padStart(2, '0'),
                s: String(d.getSeconds()).padStart(2, '0'),
                a: d.getHours() < 12 ? 'am' : 'pm',
                A: d.getHours() < 12 ? 'AM' : 'PM',
            };
            return format.replace(/[YyFmndghHisatA]/g, m => map[m] ?? m);
        },

        /**
         * Deep clone an object.
         * @param {any} obj
         * @returns {any}
         */
        deepClone(obj) { return structuredClone(obj); },

        /**
         * Generate a v4 UUID.
         * @returns {string}
         */
        uuid() {
            return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
                const r = Math.random() * 16 | 0;
                return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
            });
        },

        /**
         * Escape HTML entities.
         * @param {string} str
         * @returns {string}
         */
        escapeHtml(str) {
            const div = document.createElement('div');
            div.appendChild(document.createTextNode(str));
            return div.innerHTML;
        },
    };

    // ── Runner / pp.ctrl — page control access ─────────────────────────
    // A clean, chainable way to reach any field on a page by its DB name — read
    // and write its value, show/hide/enable it, wire events, show inline messages
    // — so event code (e.g. a Custom Button) never has to hand-write DOM plumbing.
    //   var c = Runner.getControl('first_name');   // or Runner.getControl(pageId, 'first_name')
    //   c.getValue();  c.setValue('Ada');  c.hide();  c.setMessage('Required');
    // Fields carry `data-pp-field="<name>"` on their wrapper (added by the build);
    // plain [name] inputs (auth forms, custom HTML) are found too as a fallback.
    const cssEsc = (s) => (global.CSS && CSS.escape) ? CSS.escape(String(s)) : String(s).replace(/["\\\]]/g, '\\$&');

    const pageScope = (pageId) => {
        if (!pageId) return document;
        return document.getElementById(pageId)
            || document.querySelector('[data-pp-page="' + cssEsc(pageId) + '"]')
            || document.querySelector('form[name="' + cssEsc(pageId) + '"]')
            || document;
    };
    const fieldWrap  = (field, scope) => (scope || document).querySelector('[data-pp-field="' + cssEsc(field) + '"]');
    const fieldInput = (field, scope) => {
        const w = fieldWrap(field, scope);
        if (w) return w.querySelector('[name="' + cssEsc(field) + '"]') || w.querySelector('input, select, textarea');
        return (scope || document).querySelector('[name="' + cssEsc(field) + '"]');
    };

    // Persist a record patch through the built-in, permission-gated save endpoint
    // (api/save.php: form-POST table + id + changed fields). Returns Promise<json>.
    function ppSave(table, id, patch) {
        if (!table || !id) return Promise.reject(new Error('save: table and id are required'));
        const body = new URLSearchParams();
        body.set('table', table); body.set('id', id);
        Object.keys(patch || {}).forEach((k) => { if (k !== 'id') body.set(k, patch[k] == null ? '' : patch[k]); });
        return fetch((global.PP_BASE || '') + '/api/save.php', {
            method: 'POST', credentials: 'same-origin',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body: body.toString(),
        }).then((r) => r.json());
    }

    // Remember a field's value the first time we touch it, so control.reset() can
    // restore it even before the DOMContentLoaded sweep runs (e.g. code in an
    // early inline script). Captured once; never overwritten.
    function stampOrig(e) {
        if (e && !e.hasAttribute('data-pp-orig')) {
            e.setAttribute('data-pp-orig', e.type === 'checkbox' ? (e.checked ? '1' : '') : (e.value || ''));
        }
    }

    // A list/grid/detail row wrapper — read its record, its cells, select it, and
    // (on an editable grid) write a cell in place AND persist it to the database.
    function makeRow(tr) {
        if (!tr) return null;
        let rec = null;
        return {
            tr,
            id: tr.getAttribute('data-id'),
            data() { if (rec) return rec; try { rec = JSON.parse(tr.getAttribute('data-pp-rec') || '{}'); } catch (e) { rec = { id: tr.getAttribute('data-id') }; } return rec; },
            get(field) { return this.data()[field]; },
            cell(field) { return tr.querySelector('[data-field="' + cssEsc(field) + '"], [data-col="' + cssEsc(field) + '"]'); },
            text(field) { const td = this.cell(field); return td ? td.textContent.trim() : ''; },
            /** The table this row belongs to (its detail table, or the page table). */
            table() { const d = tr.closest('.pp-detail-grid'); return (d && d.getAttribute('data-pp-md-table')) || (document.body && document.body.getAttribute('data-pp-table')) || ''; },
            /** Update a cell in the DOM + the cached record (no save). */
            set(field, value) {
                const td = this.cell(field);
                if (td) { if (!td.querySelector('input, select, textarea')) td.textContent = (value == null ? '' : value); td.setAttribute('data-raw', value == null ? '' : value); }
                const d = this.data(); d[field] = value;
                try { tr.setAttribute('data-pp-rec', JSON.stringify(d)); } catch (e) { /* ignore */ }
                return this;
            },
            /** Write one cell AND persist it. Returns Promise<{success,…}>. */
            setCell(field, value) { this.set(field, value); const p = {}; p[field] = value; return ppSave(this.table(), this.id, p); },
            /** Write several cells in one save. */
            setValues(patch) { Object.keys(patch || {}).forEach((f) => this.set(f, patch[f])); return ppSave(this.table(), this.id, patch || {}); },
            /** Persist the current cached record (or an explicit patch). */
            save(patch) { return ppSave(this.table(), this.id, patch || this.data()); },
            /** Open this cell's built-in inline editor UI (focus it for the user).
               Enables the grid's click-to-edit mode + fires the app's own mount +
               autosave-on-blur/Enter — nothing is reimplemented here. */
            edit(field) {
                const td = tr.querySelector('[data-field="' + cssEsc(field) + '"]');
                if (!td) return this;                                   // not an editable column
                const table = td.closest('.ppd-grid-table'); if (table) table.classList.add('pp-spreadsheet');
                const cur = td.querySelector('input, select, textarea');
                if (cur) { if (cur.focus) cur.focus(); return this; }   // already editing → just focus
                td.dispatchEvent(new MouseEvent('click', { bubbles: true }));   // app mounts the real editor
                const inp = td.querySelector('input, select, textarea');
                if (inp && inp.focus) inp.focus();
                return this;
            },
            selected() { const cb = tr.querySelector('.ppd-select-row'); return !!(cb && cb.checked); },
            select(on) { const cb = tr.querySelector('.ppd-select-row'); if (cb) { cb.checked = on !== false; cb.dispatchEvent(new Event('change', { bubbles: true })); } return this; },
            highlight(color) { tr.style.background = color || 'var(--pp-primary-soft, #eef2ff)'; return this; },
            addClass(c) { tr.classList.add(c); return this; },
            removeClass(c) { tr.classList.remove(c); return this; },
            on(evt, fn) { tr.addEventListener(evt, fn); return this; },
            /** Print an inline message inside one of this row's cells (PHPRunner's row.setMessage). */
            setMessage(field, msg, tone) {
                const td = this.cell(field); if (!td) return this;
                let m = td.querySelector('.pp-cell-msg');
                if (!m) { m = document.createElement('div'); m.className = 'pp-cell-msg'; m.style.cssText = 'font-size:.75rem;margin-top:2px'; td.appendChild(m); }
                m.style.color = tone === 'success' ? 'var(--pp-success-text,#16a34a)' : (tone === 'info' ? 'var(--pp-primary,#2563eb)' : 'var(--pp-danger-text,#dc2626)');
                m.textContent = msg; return this;
            },
            getMessage(field) { const td = this.cell(field), m = td && td.querySelector('.pp-cell-msg'); return m ? m.textContent : ''; },
            clearMessage(field) { const td = this.cell(field), m = td && td.querySelector('.pp-cell-msg'); if (m) m.remove(); return this; },
        };
    }

    // Named page buttons → a stable selector (the build stamps data-pp-btn; row
    // actions keep their ppd-action-* classes). A button object acts on ALL
    // matches, so Runner.button('edit') covers every row's Edit button.
    const BTN_SEL = {
        add: '[data-pp-btn="add"]', save: '[data-pp-btn="save"]', saveNew: '[data-pp-btn="save_new"]',
        saveClose: '[data-pp-btn="save_close"]', cancel: '[data-pp-btn="cancel"]', back: '[data-pp-btn="back"]',
        reset: '[data-pp-btn="reset"]', deleteSelected: '[data-pp-btn="deleteSelected"]',
        editSelected: '[data-pp-btn="editSelected"]', exportSelected: '[data-pp-btn="exportSelected"]',
        edit: '.ppd-action-edit', 'delete': '.ppd-action-delete', view: '.ppd-action-view', copy: '.ppd-action-copy',
    };
    function makeButton(els) {
        return {
            els,
            exists() { return els.length > 0; },
            count() { return els.length; },
            hide() { els.forEach((e) => { e.style.display = 'none'; }); return this; },
            show() { els.forEach((e) => { e.style.display = ''; }); return this; },
            visible(on) { return on === false ? this.hide() : this.show(); },
            disable() { els.forEach((e) => { if ('disabled' in e) e.disabled = true; e.classList.add('disabled'); e.setAttribute('aria-disabled', 'true'); e.style.pointerEvents = 'none'; e.style.opacity = '.5'; }); return this; },
            enable() { els.forEach((e) => { if ('disabled' in e) e.disabled = false; e.classList.remove('disabled'); e.removeAttribute('aria-disabled'); e.style.pointerEvents = ''; e.style.opacity = ''; }); return this; },
            setText(t) { els.forEach((e) => { const s = e.querySelector('span'); if (s) s.textContent = t; else e.textContent = t; }); return this; },
            addClass(c) { els.forEach((e) => e.classList.add(c)); return this; },
            addStyle(css) { els.forEach((e) => { e.style.cssText += ';' + css; }); return this; },
            on(evt, fn) { els.forEach((e) => e.addEventListener(evt, fn)); return this; },
        };
    }
    function findButtons(name, root) {
        const sel = BTN_SEL[name] || ('[data-pp-btn="' + cssEsc(name) + '"]');
        return Array.prototype.slice.call((root || document).querySelectorAll(sel));
    }

    // A grid API scoped to a container (the main list, OR a Master/Detail block).
    // Reads rows from grid tables AND card views; the document-scoped grid skips
    // rows that belong to a detail block so the main + detail grids never mix.
    const ROW_SEL = '.ppd-grid-table tbody tr[data-id], .pp-card[data-id][data-pp-rec]';
    function makeGrid(scope) {
        scope = scope || document;
        const isDoc = (scope === document);
        const rowEls = () => Array.prototype.filter.call(scope.querySelectorAll(ROW_SEL), (el) => !(isDoc && el.closest('.pp-detail-grid')));
        const selEls = () => Array.prototype.filter.call(scope.querySelectorAll('.ppd-select-row:checked'), (cb) => !(isDoc && cb.closest('.pp-detail-grid')));
        const g = {
            el: isDoc ? null : scope,
            rows() { return rowEls().map(makeRow); },
            getRow(id) { return this.rows().filter((r) => String(r.id) === String(id))[0] || null; },
            getSelectedKeys() { return selEls().map((cb) => cb.value); },
            getSelectedRows() { return selEls().map((cb) => makeRow(cb.closest('tr, .pp-card'))).filter(Boolean); },
            getSelectedRecords() { return this.getSelectedRows().map((r) => r.data()); },
            getAllRecords() { return this.rows().map((r) => r.data()); },
            selectedCount() { return selEls().length; },
            count() { return rowEls().length; },
            getCellValue(id, field) { const r = this.getRow(id); return r ? r.get(field) : null; },
            /** Write one child/row cell in place AND persist it. Returns Promise. */
            setCellValue(id, field, value) { const r = this.getRow(id); return r ? r.setCell(field, value) : Promise.reject(new Error('row ' + id + ' not found')); },
            /** Persist a patch to a row. Returns Promise. */
            saveRow(id, patch) { const r = this.getRow(id); return r ? r.setValues(patch) : Promise.reject(new Error('row ' + id + ' not found')); },
            /** Open a cell's built-in inline editor UI (focus it for the user). */
            editCell(id, field) { const r = this.getRow(id); if (r) r.edit(field); return this; },
            onSelectionChange(fn) { (isDoc ? document : scope).addEventListener('change', (e) => { if (e.target.classList && e.target.classList.contains('ppd-select-row')) fn(g.getSelectedKeys(), g.getSelectedRecords()); }); return g; },
            /** A button within this grid/detail block: detail('items').button('add').hide(). */
            button(name) { return makeButton(findButtons(name, isDoc ? document : scope)); },
            /** PHPRunner-style convenience on a detail page: hide/disable a button by name. */
            hideItem(name) { this.button(name).hide(); return this; },
            showItem(name) { this.button(name).show(); return this; },
            disableItem(name) { this.button(name).disable(); return this; },
            enableItem(name) { this.button(name).enable(); return this; },
            reload() { location.reload(); },
            /* ── data conveniences (records = plain objects) ── */
            each(fn) { this.rows().forEach((r, i) => fn(r, i)); return this; },
            map(fn) { return this.rows().map(fn); },
            filter(fn) { return this.getAllRecords().filter(fn); },
            find(fn) { return this.getAllRecords().filter(fn)[0] || null; },
            pluck(field) { return this.getAllRecords().map((r) => r[field]); },
            /** Sum a numeric column across every row — totals in one call. */
            sum(field) { return this.getAllRecords().reduce((s, r) => s + (parseFloat(r[field]) || 0), 0); },
            avg(field) { const n = this.count(); return n ? this.sum(field) / n : 0; },
            firstRow() { return this.rows()[0] || null; },
            /* ── selection helpers ── */
            selectAll() { Array.prototype.forEach.call((isDoc ? document : scope).querySelectorAll('.ppd-select-row'), (cb) => { if (!(isDoc && cb.closest('.pp-detail-grid')) && !cb.checked) { cb.checked = true; cb.dispatchEvent(new Event('change', { bubbles: true })); } }); return this; },
            clearSelection() { selEls().forEach((cb) => { cb.checked = false; cb.dispatchEvent(new Event('change', { bubbles: true })); }); return this; },
            /** Tick every row whose record matches the predicate: selectWhere(r => r.status==='open'). */
            selectWhere(fn) { this.rows().forEach((r) => { if (fn(r.data(), r)) r.select(true); }); return this; },
        };
        return g;
    }

    function makeControl(field, scope) {
        scope = scope || document;
        const api = {
            name: field,
            get wrap() { return fieldWrap(field, scope); },
            get el() { return fieldInput(field, scope); },        // the primary input/select/textarea
            exists() { return !!(this.el || this.wrap); },
            /* value — on read-only pages (View/print/pdf/card) there's no input, so
               fall back to the displayed text (the <dd>, or the wrapper itself). */
            getValue() {
                const e = this.el;
                if (e) { stampOrig(e); return form.getValue(e); }
                const w = this.wrap;
                if (w) { const dd = w.querySelector('dd'); return (dd || w).textContent.trim(); }
                return null;
            },
            setValue(v) { const e = this.el; if (e) { stampOrig(e); form.setValue(e, v); e.dispatchEvent(new Event('input', { bubbles: true })); e.dispatchEvent(new Event('change', { bubbles: true })); } return this; },
            clear() { return this.setValue(''); },
            getText() { const e = this.el; if (e && e.tagName === 'SELECT' && e.selectedOptions && e.selectedOptions[0]) return e.selectedOptions[0].textContent.trim(); const v = this.getValue(); return String(v == null ? '' : v); },
            /* state */
            focus() { const e = this.el; if (e) e.focus(); return this; },
            disable() { const e = this.el; if (e) e.disabled = true; return this; },
            enable() { const e = this.el; if (e) e.disabled = false; return this; },
            readonly(on) { const e = this.el; if (e) e.readOnly = on !== false; return this; },
            required(on) { const e = this.el; if (e) e.required = on !== false; return this; },
            /* visibility (acts on the whole field, label included) */
            show() { const w = this.wrap; if (w) w.style.display = ''; return this; },
            hide() { const w = this.wrap; if (w) w.style.display = 'none'; return this; },
            visible(on) { return on === false ? this.hide() : this.show(); },
            addClass(c) { const w = this.wrap; if (w) w.classList.add(c); return this; },
            removeClass(c) { const w = this.wrap; if (w) w.classList.remove(c); return this; },
            /** Add inline CSS to the control (PHPRunner-style): addStyle('color:red'). */
            addStyle(css) { const t = this.el || this.wrap; if (t) t.style.cssText += ';' + css; return this; },
            /** Set the whole style string. */
            setStyle(css) { const t = this.el || this.wrap; if (t) t.style.cssText = css; return this; },
            label() { const w = this.wrap, l = w && w.querySelector('label'); return l ? l.textContent.replace('*', '').trim() : ''; },
            /* events */
            on(evt, fn) { const e = this.el; if (e) e.addEventListener(evt, fn); return this; },
            onChange(fn) { return this.on('change', fn); },
            /* inline validation message (matches the build's .pp-field-msg) */
            setMessage(msg, tone) {
                const w = this.wrap; if (!w) return this;
                let m = w.querySelector('.pp-field-msg');
                if (!m) { m = document.createElement('div'); m.className = 'pp-field-msg'; m.style.cssText = 'font-size:.8rem;margin-top:2px'; w.appendChild(m); }
                m.style.color = tone === 'success' ? 'var(--pp-success-text,#16a34a)' : 'var(--pp-danger-text,#dc2626)';
                m.textContent = msg; w.classList.add('pp-has-msg'); return this;
            },
            clearMessage() { const w = this.wrap, m = w && w.querySelector('.pp-field-msg'); if (m) m.remove(); if (w) w.classList.remove('pp-has-msg'); return this; },
            /* ── validation & read-only state (PHPRunner-parity) ── */
            /** The raw display element (input/select/textarea) — PHPRunner's getDispElem(). */
            getDispElem() { return this.el; },
            /** Is the control read-only right now? */
            isReadonly() { const e = this.el; return !!(e && e.readOnly); },
            /** Restore the value the field had when the page loaded. */
            reset() { const e = this.el; if (e && e.hasAttribute('data-pp-orig')) this.setValue(e.getAttribute('data-pp-orig')); return this; },
            /** Validate against simple rules ({required,minlength,email,pattern}); shows/clears the
               inline message and records the invalid state. Returns true when valid. */
            validate(rules) {
                const e = this.el; if (!e) return true;
                const res = form.validate(e, rules || {});
                e.setAttribute('data-pp-invalid', res.valid ? '' : '1');
                if (res.valid) this.clearMessage(); else this.setMessage(res.message, 'error');
                return res.valid;
            },
            /** True if the last validate() failed. */
            invalid() { const e = this.el; return !!(e && e.getAttribute('data-pp-invalid')); },
            /** Remove a previously-added listener (PHPRunner's clearEvent()). */
            off(evt, fn) { const e = this.el; if (e) e.removeEventListener(evt, fn); return this; },
            /** React to every value change — fires fn(value, control) on input & change.
               The reactive backbone behind Runner.compute()/link(). */
            watch(fn) { const e = this.el; if (e) { const h = () => fn(this.getValue(), this); e.addEventListener('input', h); e.addEventListener('change', h); } return this; },
        };
        return api;
    }

    const Runner = {
        /** getControl('field')  OR  getControl(pageId, 'field') — pageId optional. */
        getControl(pageId, field) {
            if (field === undefined) { field = pageId; pageId = null; }
            return makeControl(field, pageScope(pageId));
        },
        /** Shorthand value getters/setters. */
        getControlValue(pageId, field) { return this.getControl(pageId, field).getValue(); },
        setControlValue(pageId, field, value) {
            if (value === undefined) { value = field; field = pageId; pageId = null; }
            return this.getControl(pageId, field).setValue(value);
        },
        /** Every field name present on the page. */
        fields(pageId) { return Array.from(pageScope(pageId).querySelectorAll('[data-pp-field]')).map(w => w.getAttribute('data-pp-field')); },
        /** All field values as one object: { first_name:'…', … }. */
        getData(pageId) { const out = {}; this.fields(pageId).forEach(f => { out[f] = this.getControl(pageId, f).getValue(); }); return out; },
        /** Set many at once: setData({a:1,b:2})  or  setData(pageId, {…}). */
        setData(pageId, data) {
            if (data === undefined) { data = pageId; pageId = null; }
            Object.keys(data || {}).forEach(f => this.getControl(pageId, f).setValue(data[f]));
            return this;
        },
        /** Convenience wrappers. */
        hideField(pageId, field) { return this.getControl(pageId, field).hide(); },
        showField(pageId, field) { return this.getControl(pageId, field).show(); },
        on(pageId, field, evt, fn) { if (fn === undefined) { fn = evt; evt = field; field = pageId; pageId = null; } return this.getControl(pageId, field).on(evt, fn); },
        /** The current page's identity (the table it edits), from the body. */
        getPageId() { return (document.body && document.body.getAttribute('data-pp-table')) || ''; },
        getPageMode() { return (document.body && document.body.getAttribute('data-pp-action')) || ''; },

        // ── list / grid pages (also cards) ──────────────────────────────
        // The main grid, as a scoped grid object. Everything below delegates to it,
        // so it reads grid rows AND card views, and never mixes in detail rows.
        grid() { return makeGrid(document); },
        rows() { return this.grid().rows(); },
        getRow(id) { return this.grid().getRow(id); },
        getSelectedKeys() { return this.grid().getSelectedKeys(); },
        getSelectedRows() { return this.grid().getSelectedRows(); },
        getSelectedRecords() { return this.grid().getSelectedRecords(); },
        getAllRecords() { return this.grid().getAllRecords(); },
        selectedCount() { return this.grid().selectedCount(); },
        getCellValue(id, field) { return this.grid().getCellValue(id, field); },
        /** Open a cell's inline editor UI on the main grid (focus it to type). */
        editCell(id, field) { return this.grid().editCell(id, field); },
        onSelectionChange(fn) { return this.grid().onSelectionChange(fn); },
        reloadGrid() { location.reload(); },

        // ── Search (the list's Quick Search + WHERE tabs) ────────────────
        // A SearchController-style object for the list page's search box. Read or
        // set the query, submit (server-side, authoritative), clear, or filter the
        // already-loaded rows instantly in the browser with no round-trip.
        //   Runner.search().submit('ada');            // server search
        //   Runner.search().filterRows('ada');        // instant client filter
        //   Runner.search().whereTab(1);              // Additional WHERE tab
        search() {
            const self = this;
            const form = document.querySelector('.pp-search-form');
            const input = (form && form.querySelector('input[name="search"], .ppd-grid-search'))
                || document.querySelector('input[name="search"]');
            const clearUrl = form ? form.getAttribute('data-clear-url') : null;
            const api = {
                form, input,
                exists() { return !!input; },
                getValue() { return input ? input.value : ''; },
                setValue(v) { if (input) input.value = (v == null ? '' : v); return api; },
                /** Is the list currently filtered by a search? */
                isSearched() { return !!(form && form.classList.contains('is-searched')); },
                focus() { if (input) input.focus(); return api; },
                /** Run a server-side search (navigates with ?search=…). */
                submit(v) { if (v !== undefined) api.setValue(v); if (form) form.submit(); return api; },
                run(v) { return api.submit(v); },
                /** Back to the full, unfiltered list. */
                clear() { if (clearUrl) global.location.href = clearUrl; else if (form) { api.setValue(''); form.submit(); } return api; },
                /** Instant client-side filter — hide rows whose record (or, if given,
                    just these fields) doesn't contain the text. No server round-trip. */
                filterRows(text, fields) {
                    const q = String(text == null ? '' : text).trim().toLowerCase();
                    self.grid().rows().forEach((r) => {
                        const rec = r.data();
                        const hay = (fields && fields.length)
                            ? fields.map((f) => rec[f]).join(' ')
                            : (r.tr ? r.tr.textContent : Object.keys(rec).map((k) => rec[k]).join(' '));
                        if (r.tr) r.tr.style.display = (!q || String(hay).toLowerCase().indexOf(q) !== -1) ? '' : 'none';
                    });
                    return api;
                },
                clearFilter() { self.grid().rows().forEach((r) => { if (r.tr) r.tr.style.display = ''; }); return api; },
                /** Switch Additional WHERE tab by index; whereTab(-1)/allData() = All data. */
                whereTab(i) {
                    const u = new URL(global.location.href);
                    u.searchParams.delete('page');
                    if (i === null || i < 0) u.searchParams.delete('wtab'); else u.searchParams.set('wtab', i);
                    global.location.href = u.toString(); return api;
                },
                allData() { return api.whereTab(-1); },
                /** The WHERE tabs present: [{ index, label, active }]. */
                whereTabs() {
                    return Array.prototype.map.call(document.querySelectorAll('.pp-where-tabs .nav-link'), (a) => {
                        let idx = null;
                        try { const w = new URL(a.href, global.location.href).searchParams.get('wtab'); idx = (w === null || w === '') ? null : +w; } catch (e) { /* ignore */ }
                        return { index: idx, label: a.textContent.trim(), active: a.classList.contains('active') };
                    });
                },
            };
            return api;
        },
        /** PHPRunner-familiar alias. */
        getSearchController() { return this.search(); },

        // ── Master / Detail ─────────────────────────────────────────────
        // A detail grid on a master's view/edit page, as its own grid object:
        //   Runner.detail('order_items').getAllRecords()
        //   Runner.detail('order_items').getSelectedKeys()
        detail(table) {
            const el = document.querySelector('.pp-detail-grid[data-pp-md-table="' + cssEsc(table) + '"]');
            const g = makeGrid(el || document.createElement('div'));
            g.table = table;
            g.fk = el ? el.getAttribute('data-pp-md-fk') : '';
            g.exists = () => !!el;
            g.el = el;
            return g;
        },
        /** Every detail grid on the page: [{ table, fk, grid }]. */
        details() {
            return Array.prototype.map.call(document.querySelectorAll('.pp-detail-grid[data-pp-md-table]'), (el) => ({
                table: el.getAttribute('data-pp-md-table'),
                fk: el.getAttribute('data-pp-md-fk'),
                grid: makeGrid(el),
            }));
        },
        /** Detail grids as a map keyed by child table (PHPRunner-style getDetailsPages):
         *    Runner.getDetailsPages()['order_items'].hideItem('add'); */
        getDetailsPages() {
            const out = {};
            this.details().forEach((d) => { out[d.table] = d.grid; });
            return out;
        },

        // ── Buttons (Save · Cancel · Add · Edit · Delete · bulk) ─────────
        // A page button by name → a button object acting on every match:
        //   Runner.button('add').hide()      Runner.button('save').disable()
        // Names: add · save · saveNew · saveClose · cancel · back · reset ·
        //        editSelected · deleteSelected · exportSelected · edit · delete · view · copy
        button(name, scope) { return makeButton(findButtons(name, scope)); },
        hideButton(name) { return this.button(name).hide(); },
        showButton(name) { return this.button(name).show(); },
        disableButton(name) { return this.button(name).disable(); },
        enableButton(name) { return this.button(name).enable(); },

        // ── Server → client command bus ──────────────────────────────────
        // Apply the `commands` array the PHP pp() facade queued — the server
        // driving the browser. Called automatically after pp.run() and Custom
        // Button server stages; also callable directly. One bad command is
        // caught so the rest still run.
        apply(commands) {
            if (!Array.isArray(commands)) return this;
            const self = this;
            commands.forEach((c) => {
                if (!c || !c.cmd) return;
                try {
                    switch (c.cmd) {
                        case 'toast': dialog.toast(c.type || 'info', c.message); break;
                        case 'alert': dialog.alert(c.title, c.text || '', c.icon || 'info'); break;
                        case 'setControl': self.setControlValue(c.field, c.value); break;
                        case 'setData': self.setData(c.data || {}); break;
                        case 'hide': self.getControl(c.field).hide(); break;
                        case 'show': self.getControl(c.field).show(); break;
                        case 'message': self.getControl(c.field).setMessage(c.message, c.tone === 'success' ? 'success' : 'error'); break;
                        case 'clearMessage': self.getControl(c.field).clearMessage(); break;
                        case 'focus': self.getControl(c.field).focus(); break;
                        case 'addClass': self.getControl(c.field).addClass(c.class); break;
                        case 'addStyle': self.getControl(c.field).addStyle(c.css); break;
                        case 'reloadGrid': case 'reload': self.reloadGrid(); break;
                        case 'redirect': if (c.url) global.location.href = c.url; break;
                        case 'openForm': if (dialog.form) dialog.form(c.config || {}); break;
                        case 'openUrl': if (c.url) { if (c.newTab) global.open(c.url, '_blank'); else global.location.href = c.url; } break;
                        case 'download': if (c.url) { const a = document.createElement('a'); a.href = c.url; a.download = ''; document.body.appendChild(a); a.click(); a.remove(); } break;
                        case 'scrollTo': { const w = self.getControl(c.field).wrap; if (w && w.scrollIntoView) w.scrollIntoView({ behavior: 'smooth', block: 'center' }); break; }
                        case 'highlight': { const ctl = self.getControl(c.field); const t = ctl.el || ctl.wrap; if (t) { const prev = t.style.transition; t.style.transition = 'background-color .25s'; const bg = t.style.backgroundColor; t.style.backgroundColor = 'var(--pp-primary-soft, #fff3bf)'; setTimeout(() => { t.style.backgroundColor = bg; setTimeout(() => { t.style.transition = prev; }, 300); }, 700); } break; }
                        case 'closeDialog': if (global.Swal && Swal.close) Swal.close(); break;
                        case 'log': try { console.log('[pp]', c.value); } catch (e) {} break;
                        case 'emit': try { document.dispatchEvent(new CustomEvent('pp:' + c.name, { detail: c.detail })); } catch (e) {} break;
                        case 'call': try { const f = global[c.fn]; if (typeof f === 'function') f.apply(global, c.args || []); } catch (e) {} break;
                        case 'setProxy':
                            global.__PP__ = global.__PP__ || {};
                            global.__PP__.proxy = global.__PP__.proxy || {};
                            global.__PP__.proxy[c.key] = c.value;
                            break;
                        default: break;
                    }
                } catch (e) { /* keep applying the rest */ }
            });
            return this;
        },

        // ── Arbitrary page elements (PHPRunner's findItem / toggleItem) ──
        // Reach any element you gave an id (or data-pp-item) — a heading, a panel,
        // a custom block — and show/hide/retext it:  Runner.item('note').hide()
        item(id) {
            const el = document.getElementById(id) || document.querySelector('[data-pp-item="' + cssEsc(id) + '"]');
            return {
                el,
                exists() { return !!el; },
                show() { if (el) el.style.display = ''; return this; },
                hide() { if (el) el.style.display = 'none'; return this; },
                toggle(on) { if (el) el.style.display = (on === false ? 'none' : ''); return this; },
                text(t) { if (!el) return this; if (t === undefined) return el.textContent; el.textContent = t; return this; },
                html(h) { if (!el) return this; if (h === undefined) return el.innerHTML; el.innerHTML = h; return this; },
                addClass(c) { if (el) el.classList.add(c); return this; },
                removeClass(c) { if (el) el.classList.remove(c); return this; },
                on(evt, fn) { if (el) el.addEventListener(evt, fn); return this; },
            };
        },
        /** PHPRunner alias: show/hide a page element by id. */
        toggleItem(id, on) { return this.item(id).toggle(on); },

        // ── Tabs / sections (Bootstrap nav-tabs, [data-pp-tabs], [role=tablist]) ──
        //   Runner.getTabs().activate(2)   ·   Runner.tabs(0).count()
        tabs(n) {
            const groups = Array.prototype.slice.call(document.querySelectorAll('[data-pp-tabs], .nav-tabs, [role="tablist"]'));
            const group = groups[n || 0]; if (!group) return null;
            const links = () => Array.prototype.slice.call(group.querySelectorAll('[data-bs-toggle="tab"], [data-toggle="tab"], [role="tab"], a, button'));
            return {
                el: group,
                count() { return links().length; },
                activate(i) { const l = links()[i]; if (l) l.click(); return this; },
                activateByText(txt) { const l = links().filter((x) => x.textContent.trim() === txt)[0]; if (l) l.click(); return this; },
                showTab(i) { const l = links()[i]; if (l) l.style.display = ''; return this; },
                hideTab(i) { const l = links()[i]; if (l) l.style.display = 'none'; return this; },
            };
        },
        getTabs(n) { return this.tabs(n); },   // PHPRunner-familiar alias

        // ── Unsaved-changes guard (PHPRunner's setPageModified) ──
        // Turn on a "you have unsaved changes" browser prompt when the user tries to
        // leave. Runner.setPageModified(true) to arm, (false) to disarm (e.g. after save).
        setPageModified(mod) {
            this._dirty = mod !== false;
            if (this._dirty && !this._leaveGuard) {
                this._leaveGuard = (e) => { if (this._dirty) { e.preventDefault(); e.returnValue = ''; return ''; } };
                global.addEventListener('beforeunload', this._leaveGuard);
            }
            return this;
        },

        // ── Reactive helpers (beyond PHPRunner) ──────────────────────────
        // Declaratively keep a field in sync with others — the "calculate on the
        // fly" recipe in one line, recomputed live as any dependency changes:
        //   Runner.compute('line_total', ['qty','price'], (q,p) => q*p);
        compute(target, deps, fn) {
            const self = this;
            const run = () => { const vals = deps.map((d) => self.getControlValue(d)); self.setControlValue(target, fn.apply(null, vals)); };
            deps.forEach((d) => self.getControl(d).watch(run));
            run();
            return this;
        },
        /** Mirror one field into another (optionally transformed):
         *    Runner.link('name', 'slug', v => v.toLowerCase().replace(/\s+/g,'-')); */
        link(src, target, transform) {
            const self = this;
            self.getControl(src).watch((v) => self.setControlValue(target, transform ? transform(v) : v));
            return this;
        },
    };

    // Stamp each field's load-time value so control.reset() can restore it.
    function ppInitOriginals() {
        try {
            document.querySelectorAll('[data-pp-field] input, [data-pp-field] select, [data-pp-field] textarea').forEach((el) => {
                if (!el.hasAttribute('data-pp-orig')) el.setAttribute('data-pp-orig', el.type === 'checkbox' ? (el.checked ? '1' : '') : (el.value || ''));
            });
        } catch (e) { /* no DOM — ignore */ }
    }
    if (typeof document !== 'undefined') {
        if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ppInitOriginals);
        else ppInitOriginals();
    }

    // ── Export (MERGE, never clobber) ──────────────────────────────────
    // pp.js and the generated events.js both populate window.pp; merging keeps
    // both APIs whatever the load order. Existing keys win, so we never overwrite
    // a richer sibling — except `form`, whose helper sets are unioned.
    const ppLib = { ajax, dialog, util, ctrl: (pageId, field) => Runner.getControl(pageId, field) };
    const dst = global.pp = global.pp || {};
    Object.keys(ppLib).forEach((k) => { if (!(k in dst)) dst[k] = ppLib[k]; });
    dst.form = Object.assign({}, form, dst.form || {});
    global.Runner = global.Runner || Runner;   // PHPRunner-familiar alias

})(typeof window !== 'undefined' ? window : this);