/* ============================================================
   PPphp Designer — Model operations
   Pure helpers over the persisted designer document.
   A PAGE = { id,name,type,title,layout,settings,components:[ COMPONENT ] }
   A COMPONENT = { id,type,label,props,style,visibility,children? }
   ============================================================ */
(function () {
    'use strict';
    var PPD = window.PPD = window.PPD || {};
    var store = PPD.store;

    function uid(type) { return (type || 'cmp') + '_' + Math.random().toString(36).slice(2, 8); }
    function title(v) { return String(v || '').replace(/_/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); }); }

    /* ---- resolve the active PAGE from current UI context ---- */
    function slug(v) { return String(v || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 't'; }

    function activePage() {
        var d = store.designer();
        var u = store.ui();
        if (u.scope === 'template') {        // editing a shared Default Page Template
            return ensureTemplate(u.pageType || 'list');
        }
        if (u.scope === 'common') {
            return (d.common && d.common[u.commonPage]) || null;
        }
        var byTable = (d.pages && d.pages[u.table]) || null;
        if (!byTable) return null;
        var node = byTable[u.pageType];
        if (!node) return null;
        var name = node.defaultPage || Object.keys(node.pages || {})[0];
        return (node.pages && node.pages[name]) || null;
    }

    /* ---- ensure a page exists for the active context (lazy via backend template if missing) ---- */
    function hasActivePage() { return !!activePage(); }

    // "Lock page" (Properties → Advanced) freezes the LAYOUT: no add / delete /
    // move / duplicate of components. Property edits stay available so you can
    // still tweak a locked page's styling. Structural mutations call lockedGuard()
    // which no-ops (with a toast) when the active page is locked.
    function isPageLocked() { var p = activePage(); return !!(p && p.settings && p.settings.locked); }
    var _lockNoticeAt = 0;
    function lockedGuard() {
        if (!isPageLocked()) return false;
        var now = Date.now();
        if (PPD.toast && now - _lockNoticeAt > 1500) { _lockNoticeAt = now; PPD.toast('Page is locked — uncheck “Lock page” in Properties to edit the layout', 'warn'); }
        return true;
    }

    /* ---- component tree walking ---- */
    function components(page) { return (page && page.components) || []; }

    function find(page, id) {
        var hit = null;
        (function walk(list, parent) {
            (list || []).forEach(function (c) {
                if (hit) return;
                if (c.id === id) { hit = { node: c, parent: parent, list: list }; return; }
                if (c.children) walk(c.children, c);
            });
        })(components(page), null);
        return hit;
    }

    function flatten(page) {
        var out = [];
        (function walk(list, depth, parentId) {
            (list || []).forEach(function (c) {
                out.push({ node: c, depth: depth, parentId: parentId });
                if (c.children) walk(c.children, depth + 1, c.id);
            });
        })(components(page), 0, '');
        return out;
    }

    /* ---- new component factory (delegates default props to registry) ---- */
    function create(type) {
        var def = (PPD.registry && PPD.registry.get(type)) || {};
        var props = def.defaults ? def.defaults() : {};
        var node = {
            id: uid(type),
            type: type,
            label: def.label || title(type),
            props: props,
            style: { class: '', customCss: '' },
            visibility: { roles: [], condition: '', visible: true }
        };
        if (def.container) node.children = [];
        return node;
    }

    /* ---- mutations (all routed through store.mutate for history+dirty) ---- */
    function add(type, opts) {
        opts = opts || {};
        if (lockedGuard()) return null;
        var node = create(type);
        if (typeof opts.tab === 'number') node.tab = opts.tab;   // which tab panel (tabs container)
        store.mutate(function () {
            var page = activePage();
            if (!page) return;
            page.pristine = false;
            if (opts.parentId) {
                var h = find(page, opts.parentId);
                if (h && h.node) { h.node.children = h.node.children || []; insertAt(h.node.children, node, opts.index); return; }
            }
            insertAt(page.components, node, opts.index);
        });
        return node.id;
    }

    function insertAt(list, node, index) {
        if (typeof index === 'number' && index >= 0 && index <= list.length) list.splice(index, 0, node);
        else list.push(node);
    }

    function remove(id) {
        if (lockedGuard()) return;
        store.mutate(function () {
            var page = activePage(); if (!page) return; page.pristine = false;
            var h = find(page, id); if (!h) return;
            var i = h.list.indexOf(h.node);
            if (i > -1) h.list.splice(i, 1);
        });
    }

    // Assign a node to a tab panel (tabs container). tab=null clears it.
    function setTab(id, tab) {
        if (lockedGuard()) return;
        store.mutate(function () {
            var page = activePage(); if (!page) return; page.pristine = false;
            var h = find(page, id); if (!h || !h.node) return;
            if (typeof tab === 'number') h.node.tab = tab; else delete h.node.tab;
        });
    }

    // Generic guarded mutation on one node (used by structural editors such as
    // the Cell Grid, whose ops must rewrite props AND children in one undo step).
    function withNode(id, fn) {
        if (lockedGuard()) return;
        store.mutate(function () {
            var page = activePage(); if (!page) return; page.pristine = false;
            var h = find(page, id);
            if (h && h.node) fn(h.node);
        });
    }

    function duplicate(id) {
        if (lockedGuard()) return null;
        var newId = null;
        store.mutate(function () {
            var page = activePage(); if (!page) return; page.pristine = false;
            var h = find(page, id); if (!h) return;
            var copy = reId(store.clone(h.node));
            newId = copy.id;
            var i = h.list.indexOf(h.node);
            h.list.splice(i + 1, 0, copy);
        });
        return newId;
    }
    function reId(node) {
        node.id = uid(node.type);
        if (node.children) node.children = node.children.map(reId);
        return node;
    }

    function move(id, targetId, position) {
        // position: 'before' | 'after' relative to targetId (same list-level move)
        if (lockedGuard()) return;
        store.mutate(function () {
            var page = activePage(); if (!page) return; page.pristine = false;
            var src = find(page, id); var dst = find(page, targetId);
            if (!src || !dst) return;
            var node = src.node;
            var si = src.list.indexOf(node);
            if (si > -1) src.list.splice(si, 1);
            var di = dst.list.indexOf(dst.node);
            if (di < 0) { dst.list.push(node); return; }
            dst.list.splice(position === 'after' ? di + 1 : di, 0, node);
        });
    }

    function updateProps(id, patch) {
        store.mutate(function () {
            var page = activePage(); if (!page) return; page.pristine = false;
            var h = find(page, id); if (!h) return;
            h.node.props = Object.assign({}, h.node.props, patch);
        });
    }
    function updateNode(id, patch) {
        store.mutate(function () {
            var page = activePage(); if (!page) return; page.pristine = false;
            var h = find(page, id); if (!h) return;
            Object.keys(patch).forEach(function (k) {
                if (k === 'props') h.node.props = Object.assign({}, h.node.props, patch.props);
                else if (k === 'style') h.node.style = Object.assign({}, h.node.style, patch.style);
                else if (k === 'visibility') h.node.visibility = Object.assign({}, h.node.visibility, patch.visibility);
                else h.node[k] = patch[k];
            });
        });
    }
    function updatePage(patch) {
        store.mutate(function () {
            var page = activePage(); if (!page) return; page.pristine = false;
            Object.keys(patch).forEach(function (k) {
                if (k === 'layout') page.layout = Object.assign({}, page.layout, patch.layout);
                else if (k === 'settings') page.settings = Object.assign({}, page.settings, patch.settings);
                else page[k] = patch[k];
            });
        });
    }

    /* ---- install a freshly templated page returned by backend ---- */
    function installTemplatedPage(table, pageType, page) {
        if (page && typeof page === 'object' && page.pristine == null) page.pristine = true;  // a fresh template install is pristine until edited
        store.mutate(function (d) {
            if (table) {
                d.pages = d.pages || {};
                d.pages[table] = d.pages[table] || {};
                d.pages[table][pageType] = { defaultPage: pageType, pages: {} };
                d.pages[table][pageType].pages[pageType] = page;
            } else {
                d.common = d.common || {};
                d.common[pageType] = page;
            }
        });
    }

    /* ---- one-time data healing for documents saved by the old runtime ---- */
    function migrate() {
        var d = store.designer();
        var DEFAULT_ACTIONS = ['view', 'edit', 'delete'];
        var changed = false;
        function isCorrupt(a) {
            if (a == null) return true;
            if (typeof a === 'string') return a === '[object Object]';
            if (typeof a === 'object') return !(a.type || a.action || a.id);
            return true;
        }
        function fixGrid(c) {
            var p = c.props || (c.props = {});
            if (!Array.isArray(p.actions)) return;
            // only rewrite when something is actually broken — leave valid object-actions
            // (with their events/eventContext for the Events module) untouched
            if (!p.actions.some(isCorrupt)) return;
            var out = [], seen = {};
            p.actions.forEach(function (a) {
                if (isCorrupt(a)) return;
                var key = typeof a === 'string' ? a : (a.type || a.action || a.id);
                if (seen[key]) return;
                seen[key] = 1; out.push(a); // keep originals (string or rich object)
            });
            p.actions = out.length ? out : DEFAULT_ACTIONS.slice();
            changed = true;
        }
        // Backfill card-grid props added after a document was first saved, so
        // older Cards pages/templates get the newer features (per-part styling,
        // conditional formatting) without a manual reset.
        function fixCardGrid(c) {
            var p = c.props || (c.props = {});
            if (typeof p.partStyles !== 'object' || p.partStyles === null || Array.isArray(p.partStyles)) { p.partStyles = {}; changed = true; }
            if (!Array.isArray(p.cardRules)) { p.cardRules = []; changed = true; }
        }
        // Backfill props added to a component AFTER a page was first saved, from
        // the registry defaults (missing keys only). Without this a newly-added
        // prop reads as `undefined` on old pages — which the inspector shows as
        // "unchecked" but render()/build treat as their own default, so the
        // canvas checkbox and the generated app disagree (e.g. the calendar
        // sidebar showing despite "Show side panel" looking off). Generic, but
        // scoped to smart components whose props keep growing.
        function backfillDefaults(c, type) {
            if (c.type !== type) return;
            var def = PPD.registry && PPD.registry.get(type);
            var defs = def && def.defaults ? def.defaults() : null;
            if (!defs) return;
            var p = c.props || (c.props = {});
            Object.keys(defs).forEach(function (k) {
                if (!(k in p)) { p[k] = defs[k]; changed = true; }
            });
        }
        function walk(comps) {
            (comps || []).forEach(function (c) {
                if (!c || typeof c !== 'object') return;
                // Every component needs a stable id — the canvas selects, styles and
                // deletes by `data-ppd-id`. Seeded/imported components (e.g. the
                // standalone Home page) can arrive without one, which makes them
                // unselectable; mint one so they behave like any built component.
                if (!c.id) { c.id = uid(c.type); changed = true; }
                if (c.type === 'table-grid' || c.type === 'grid') fixGrid(c);
                if (c.type === 'card-grid') fixCardGrid(c);
                // Drop the stray "Custom button" placeholder that was seeded into the
                // default toolbar for testing — remove it from the center zone of every
                // list-toolbar (template + all pages) on load.
                if (c.type === 'list-toolbar' && c.props && Array.isArray(c.props.center) && c.props.center.indexOf('custom') > -1) {
                    c.props.center = c.props.center.filter(function (k) { return k !== 'custom'; }); changed = true;
                }
                // Self-heal saved pages when a component gains NEW props: seed any
                // default keys the stored node is missing (never overwrites existing
                // values). Scoped to an allowlist so unrelated components are never
                // touched — add a type here when you extend its defaults() so old
                // saved instances pick up the new props on next load. See cerebrum
                // "already-saved pages lack new props" gotcha.
                ['calendar-app', 'calendar', 'cause-list', 'ai-assist', 'summary', 'chart', 'deadline-tracker', 'doc-generator', 'fee-calculator', 'limitation-calculator', 'daily-cause-list', 'activity-timeline', 'portal-link'].forEach(function (bt) { backfillDefaults(c, bt); });
                if (Array.isArray(c.children)) walk(c.children);
            });
        }
        Object.keys(d.pages || {}).forEach(function (t) {
            var byType = d.pages[t] || {};
            Object.keys(byType).forEach(function (pt) {
                var pgs = (byType[pt] && byType[pt].pages) || {};
                Object.keys(pgs).forEach(function (pn) { walk((pgs[pn] || {}).components); });
            });
        });
        Object.keys(d.common || {}).forEach(function (k) { walk((d.common[k] || {}).components); });
        Object.keys(d.templates || {}).forEach(function (k) { walk((d.templates[k] || {}).components); });   // heal Default Templates too

        // Blueprint upgrade: the Default List Template should carry the full toolbar
        // as placeholders (Inline Add / Spreadsheet) so the designer sees where every
        // button lives. Older saved templates predate these — backfill them into the
        // left zone (after "add"), preserving any custom entries. Real tables are
        // never touched here; reconcileListPage() trims them per Pages config.
        (function healListTemplateToolbar() {
            var tpl = d.templates && d.templates.list;
            if (!tpl || !Array.isArray(tpl.components)) return;
            tpl.components.forEach(function (c) {
                if (!c || c.type !== 'list-toolbar') return;
                var p = c.props || (c.props = {});
                var left = Array.isArray(p.left) ? p.left.map(String) : ['add'];
                if (left.indexOf('views') < 0) left.unshift('views');   // blueprint: template houses the Saved-views menu
                var at = left.indexOf('add');                     // keep them next to Add New
                ['inline_add', 'spreadsheet'].forEach(function (k) {
                    if (left.indexOf(k) > -1) { at = left.indexOf(k); return; }
                    at = (at < 0 ? left.length - 1 : at) + 1;
                    left.splice(at, 0, k);
                });
                if (JSON.stringify(left) !== JSON.stringify(p.left || [])) { p.left = left; changed = true; }
                // RIGHT — the Table/Cards toggle leads, the Download-PDF sits after Print.
                var right = Array.isArray(p.right) ? p.right.map(String) : [];
                if (right.indexOf('view_toggle') < 0) right.unshift('view_toggle');
                if (right.indexOf('pdf') < 0) {
                    var pp = right.indexOf('print');
                    right.splice(pp < 0 ? right.length : pp + 1, 0, 'pdf');
                }
                if (JSON.stringify(right) !== JSON.stringify(p.right || [])) { p.right = right; changed = true; }
            });
        })();
        return changed;
    }

    /* ---- reconcile list pages with the Pages module (toolbar buttons + grid actions) ---- */
    var DEFAULT_FUNC = { spreadsheet: false, updateSelected: false, inlineEdit: false, inlineAdd: false, deleteRecords: true, copyRecords: false };
    var TB_STD_LEFT = ['views', 'add', 'delete_selected', 'update_selected', 'inline_add', 'spreadsheet'];
    var TB_STD_RIGHT = ['view_toggle', 'record_info', 'export', 'page_size', 'print', 'pdf', 'refresh', 'density_toggle', 'columns_toggle'];
    var GRID_STD_ACTIONS = ['view', 'edit', 'inline-edit', 'copy', 'delete'];

    function actKey(a) { return typeof a === 'string' ? a : (a && (a.type || a.action || a.id)); }

    // Re-derive the active list page's toolbar from its (freshly changed) Pages
    // config — used when a List setting (e.g. Saved views) is toggled in the
    // inspector so the canvas toolbar reflects it immediately. Idempotent.
    function reconcileActiveList() {
        var pg = activePage();
        if (!pg || (pg.type || pg.pageType) !== 'list') return false;
        var cfg = store.pagesConfigFor(pg.table);
        if (!cfg) return false;
        return reconcileListPage(pg, cfg);
    }

    function reconcileListPage(page, cfg) {
        var pt = cfg.pageTypes || ['list'];
        var fn = Object.assign({}, DEFAULT_FUNC, cfg.functionality || {});
        var has = function (t) { return pt.indexOf(t) > -1; };
        var changed = false;

        (page.components || []).forEach(function (c) {
            if (!c || typeof c !== 'object') return;

            if (c.type === 'list-toolbar') {
                var p = c.props || (c.props = {});
                var ls = (cfg.settings && cfg.settings.list) || {};
                // LEFT — only the always-available actions (bulk actions live in the selection bar now)
                var left = [];
                if (ls.savedViews) left.push('views');                  // Saved-views menu — gated by the Saved views setting
                if (has('add')) left.push('add');
                if (fn.inlineAdd) left.push('inline_add');
                if (fn.spreadsheet) left.push('spreadsheet');
                left = left.concat((p.left || []).filter(function (k) { return TB_STD_LEFT.indexOf(k) < 0; }));
                if (JSON.stringify(left) !== JSON.stringify(p.left || [])) { p.left = left; changed = true; }
                // RIGHT (view toggle first, counter, utilities, columns toggle last)
                var pr = p.right || [];
                var right = [];
                if (has('cards')) right.push('view_toggle');            // Table/Cards switch — gated by the Cards page-type
                if (pr.indexOf('record_info') > -1) right.push('record_info');
                if (has('export')) right.push('export');
                right.push('page_size');
                if (has('print')) right.push('print');
                if (has('pdf')) right.push('pdf');                      // Download-PDF — gated by the PDF page-type
                if (pr.indexOf('refresh') > -1) right.push('refresh');
                if (pr.indexOf('density_toggle') > -1) right.push('density_toggle');
                if (pr.indexOf('columns_toggle') > -1) right.push('columns_toggle');
                right = right.concat(pr.filter(function (k) { return TB_STD_RIGHT.indexOf(k) < 0; }));
                if (JSON.stringify(right) !== JSON.stringify(p.right || [])) { p.right = right; changed = true; }
            }

            if (c.type === 'selection-bar') {   // bulk actions gated by the Pages module
                var sp = c.props || (c.props = {});
                var sa = [];
                if (fn.deleteRecords) sa.push('delete_selected');
                if (fn.updateSelected) sa.push('update_selected');
                sa.push('export_selected');
                var STD_BULK = ['delete_selected', 'update_selected', 'export_selected'];
                sa = sa.concat((sp.actions || []).filter(function (k) { return STD_BULK.indexOf(k) < 0; }));
                if (JSON.stringify(sa) !== JSON.stringify(sp.actions || [])) { sp.actions = sa; changed = true; }
            }

            if (c.type === 'table-grid' || c.type === 'grid' || c.type === 'card-grid') {
                var gp = c.props || (c.props = {});
                var cur = Array.isArray(gp.actions) ? gp.actions : [];
                var isCard = c.type === 'card-grid';
                var want = [];
                if (has('view')) want.push('view');
                if (has('edit')) want.push('edit');
                if (!isCard && fn.inlineEdit) want.push('inline-edit');   // inline edit is grid-only
                if (fn.copyRecords) want.push('copy');
                if (fn.deleteRecords) want.push('delete');
                // map desired keys to existing entries (preserve rich object actions incl. events), keep customs
                var byKey = {}; cur.forEach(function (a) { var k = actKey(a); if (k) byKey[k] = a; });
                var next = want.map(function (k) { return byKey[k] != null ? byKey[k] : k; });
                next = next.concat(cur.filter(function (a) { var k = actKey(a); return k && GRID_STD_ACTIONS.indexOf(k) < 0; }));
                var curKeys = cur.map(actKey).join(',');
                var nextKeys = next.map(actKey).join(',');
                if (nextKeys !== curKeys) { gp.actions = next; changed = true; }
            }
        });
        return changed;
    }

    // reconcile a single table's list page(s) if it has a Pages config
    function syncTable(table) {
        var cfg = store.pagesConfigFor(table);
        if (!cfg) return false;
        var node = (store.designer().pages || {})[table];
        if (!node || !node.list) return false;
        var pgs = node.list.pages || {};
        var changed = false;
        Object.keys(pgs).forEach(function (pn) { if (reconcileListPage(pgs[pn], cfg)) changed = true; });
        return changed;
    }

    // reconcile every configured table on load
    function syncWithPages() {
        var d = store.designer();
        var changed = false;
        Object.keys(d.pages || {}).forEach(function (table) { if (syncTable(table)) changed = true; });
        return changed;
    }

    /* ============================================================
       Default List Page Template (shared master)
       Edited once via the "Default List Page" item; on save it is
       propagated to every list-enabled table, binding that table's
       fields (columns) + Pages-module buttons/actions.
       ============================================================ */
    function tcmp(type, label, props, children) {
        var n = { id: uid(type), type: type, label: label, props: props || {}, style: { class: '', customCss: '' }, visibility: { roles: [], condition: '', visible: true } };
        if (children) n.children = children;
        return n;
    }
    // Fields the user flagged for a given page ('list', 'cards', …) in the Fields
    // module. A field opts OUT only via an explicit false flag; an unset flag
    // defaults to visible. Falls back to every enabled field when the table
    // carries no per-page choices, so a scaffold is never empty and NEVER
    // silently capped. Mirrors DesignerTemplate::pageFields() on the server.
    function fieldsForPage(table, page) {
        var all = (store.fieldsFor(table) || []).filter(function (f) { return f && f.enabled !== false; });
        var picked = all.filter(function (f) { return !f.pages || f.pages[page] !== false; });
        return picked.length ? picked : all;
    }
    function buildListMaster() {
        return {
            id: 'tpl_list', name: 'list', type: 'list', title: 'Default List Page', table: '', isTemplate: true,
            layout: { device: 'desktop', width: 'fluid', theme: 'default' },
            settings: { customCss: '', customJs: '', locked: false },
            components: [
                tcmp('breadcrumb', 'Breadcrumb', { items: ['Home', 'Records'], showHome: true }),
                // The template is a BLUEPRINT: it carries the full standard toolbar
                // as placeholders (Inline Add / Spreadsheet included) so the designer
                // sees where every button lives. Real tables get reconcileListPage()
                // which trims these to what each table's Pages config enables.
                tcmp('list-toolbar', 'Table Toolbar', { left: ['views', 'add', 'inline_add', 'spreadsheet'], center: ['search', 'filter'], right: ['view_toggle', 'record_info', 'export', 'page_size', 'print', 'pdf', 'refresh', 'density_toggle', 'columns_toggle'], config: {}, exportFormats: ['xlsx', 'word', 'pdf', 'csv'], pageSizes: [10, 20, 50, 'all'], pageSizeDefault: 20, searchPlaceholder: 'Search records...', searchStyle: 'glass', recordInfo: 'Displaying %from% – %to% of %total%', density: 'compact' }),
                tcmp('filter-chips', 'Filter Chips', { chips: ['Status: Active', 'Search: john'], showClear: true }),
                tcmp('selection-bar', 'Selection Bar', { actions: ['delete_selected', 'update_selected', 'export_selected'], countText: '%n% selected', showClear: true }),
                tcmp('table-grid', 'Data Grid', { selectionMode: 'multiple', selection: true, showActions: true, showTotals: true, density: 'compact', striped: true, hover: true, bordered: true, stickyHeader: true, rowNumbers: false, headerFilters: true, wrapText: false, responsive: 'scroll', actions: ['view', 'edit', 'inline-edit', 'copy', 'delete'], caption: '', rows: {}, conditionalRules: [] }),
                tcmp('pagination', 'Pagination', { pageSize: 20, position: 'bottom' }),
                tcmp('buttons', 'Page Actions', { buttons: ['home'] })
            ]
        };
    }
    function ensureListTemplate() {
        var d = store.designer();
        if (!d.templates) d.templates = {};
        if (!d.templates.list) d.templates.list = buildListMaster();
        return d.templates.list;
    }
    function ensureListTemplateSeed() {
        var d = store.designer();
        var had = !!(d.templates && d.templates.list);
        ensureListTemplate();
        return !had;   // true when the master was just created
    }
    // idempotently bring an existing master up to date with newly-added template features
    function upgradeListTemplate() {
        var m = ensureListTemplate();
        var changed = false;
        var comps = m.components || (m.components = []);
        var hasType = function (t) { return comps.some(function (c) { return c.type === t; }); };
        var idxType = function (t) { for (var i = 0; i < comps.length; i++) if (comps[i].type === t) return i; return -1; };

        comps.forEach(function (c) {
            c.props = c.props || {};
            if (c.type === 'list-toolbar') {
                if (!c.props.recordInfo) { c.props.recordInfo = 'Displaying %from% – %to% of %total%'; changed = true; }
                var right = c.props.right || [];
                if (right.indexOf('record_info') < 0) { right.unshift('record_info'); changed = true; }
                if (right.indexOf('columns_toggle') < 0) { right.push('columns_toggle'); changed = true; }
                var insBefore = function (key, before) { if (right.indexOf(key) < 0) { var bi = right.indexOf(before); if (bi > -1) right.splice(bi, 0, key); else right.push(key); changed = true; } };
                insBefore('refresh', 'columns_toggle');
                insBefore('density_toggle', 'columns_toggle');
                c.props.right = right;
                // bulk actions no longer belong in the toolbar (moved to selection bar)
                var left = (c.props.left || []).filter(function (k) { return k !== 'delete_selected' && k !== 'update_selected'; });
                if (left.length !== (c.props.left || []).length) { c.props.left = left; changed = true; }
            }
            if (c.type === 'buttons') {   // Page Actions: Back-to-List → Back-to-Home, drop Refresh (now in toolbar)
                var btns = (c.props.buttons || []).slice();
                var bi2 = btns.indexOf('back'); if (bi2 > -1) { btns[bi2] = 'home'; changed = true; }
                var ri = btns.indexOf('refresh'); if (ri > -1) { btns.splice(ri, 1); changed = true; }
                if (!btns.length) { btns = ['home']; changed = true; }
                c.props.buttons = btns;
            }
        });

        // insert the new components (filter chips + selection bar) just before the grid
        var gi = idxType('table-grid'); if (gi < 0) gi = idxType('grid');
        if (gi < 0) gi = comps.length;
        if (!hasType('selection-bar')) { comps.splice(gi, 0, tcmp('selection-bar', 'Selection Bar', { actions: ['delete_selected', 'update_selected', 'export_selected'], countText: '%n% selected', showClear: true })); changed = true; gi = idxType('table-grid'); if (gi < 0) gi = idxType('grid'); if (gi < 0) gi = comps.length; }
        if (!hasType('filter-chips')) { comps.splice(gi, 0, tcmp('filter-chips', 'Filter Chips', { chips: ['Status: Active', 'Search: john'], showClear: true })); changed = true; }
        // Filter Tabs (Query → Additional WHERE): a stylable strip at the very top.
        // The generated app only renders it when the table actually defines tabs.
        if (!hasType('where-tabs')) { comps.unshift(tcmp('where-tabs', 'Filter Tabs', { size: 'normal' })); changed = true; }
        return changed;
    }
    // True when a table already has a saved (materialized) page of this type.
    function hasSavedPage(table, type) {
        var d = store.designer();
        var slot = d.pages && d.pages[table] && d.pages[table][type];
        return !!(slot && slot.pages && Object.keys(slot.pages).length);
    }
    // True when a table's page is still an UNMODIFIED copy of the default
    // template (every materialized variant carries pristine:true — cleared the
    // moment the developer edits it). Pages saved before pristine-tracking
    // existed have no flag → treated as customised (never auto-overwritten).
    function isPristine(table, type) {
        var d = store.designer();
        var slot = d.pages && d.pages[table] && d.pages[table][type];
        if (!slot || !slot.pages) return false;
        var names = Object.keys(slot.pages);
        return names.length > 0 && names.every(function (k) { return slot.pages[k] && slot.pages[k].pristine; });
    }
    // Should (re)materializing the default template SKIP this table's page?
    //   'missing'  — skip if a page already exists (boot seed: never touch).
    //   'pristine' — skip only if the page was CUSTOMISED (editing a Default
    //                Template propagates to still-default tables, preserves edits).
    // Missing pages are always (re)created.
    function skipSeed(table, type, mode) {
        if (!hasSavedPage(table, type)) return false;
        if (mode === 'pristine') return !isPristine(table, type);
        return true;   // 'missing' (default): keep the existing page
    }

    // Materialize the master template onto every list-enabled table.
    // mode: see skipSeed() — protects a developer's customised design.
    function applyListTemplateToTables(mode) {
        var d = store.designer();
        var master = ensureListTemplate();
        (store.tables() || []).forEach(function (t) {
            var table = t.name;
            if ((store.pageTypesFor(table) || []).indexOf('list') < 0) return;
            if (skipSeed(table, 'list', mode)) return;
            var page = JSON.parse(JSON.stringify(master));
            page.isTemplate = false; page.pristine = true;   // fresh default → an edit clears this
            page.id = slug(table) + '_list'; page.name = 'list'; page.type = 'list';
            page.table = table; page.title = title(table) + ' List';
            var fields = store.fieldsFor(table);
            (page.components || []).forEach(function (c) {
                c.id = c.type + '_' + slug(table) + '_' + Math.random().toString(36).slice(2, 6);
                if (c.type === 'breadcrumb') c.props.items = ['Home', title(table)];
                if (c.type === 'table-grid' || c.type === 'grid') {
                    var fl = fieldsForPage(table, 'list');
                    if (!fl.length) fl = [{ name: 'id', label: 'ID' }, { name: 'name', label: 'Name' }];
                    c.props.columns = fl.map(function (f) { return { field: f.name, label: f.label || title(f.name), visible: true, sortable: true }; });
                }
            });
            var cfg = store.pagesConfigFor(table);
            if (cfg) reconcileListPage(page, cfg);   // filter buttons/actions by the Pages module
            d.pages = d.pages || {};
            d.pages[table] = d.pages[table] || {};
            d.pages[table]['list'] = { defaultPage: 'list', pages: { list: page } };
        });
    }

    /* ============================================================
       Default Add / Edit Page Templates (shared masters)
       A responsive form: section card > 12-col form grid > fields,
       plus form action buttons. Fields are bound per table on save.
       ============================================================ */
    function inferInput(t) {
        t = String(t || '').toLowerCase();
        if (t.indexOf('text') > -1) return 'textarea';
        if (t.indexOf('int') > -1 || t.indexOf('decimal') > -1 || t.indexOf('float') > -1 || t.indexOf('double') > -1) return 'number';
        if ((t.indexOf('datetime') > -1) || t.indexOf('timestamp') > -1) return 'datetime-local';
        if (t.indexOf('date') > -1) return 'date';
        if (t.indexOf('time') > -1) return 'time';
        if (t.indexOf('email') > -1) return 'email';
        if (t.indexOf('tinyint(1)') > -1 || t.indexOf('bool') > -1) return 'checkbox';
        if (t.indexOf('enum') > -1) return 'select';
        return 'text';
    }
    // Wrap field components in a CELL GRID (layout-grid): `cols` columns, one
    // field per cell (fields are 12/12 inside their cell), long-text fields on
    // a full merged row. Every default form starts from this skeleton so users
    // merge / split / restyle cells instead of re-formatting from scratch.
    // Mirrors the PHP FormPageTemplate seed (core/Designer/Templates).
    function formGridFor(fieldNodes, cols) {
        cols = cols || 2;
        var cells = {}, children = [], r = 0, c = 0;
        (fieldNodes || []).forEach(function (n) {
            var long = ((n.props || {}).inputType === 'textarea');
            if (long && cols > 1) {
                if (c > 0) { r++; c = 0; }        // start a fresh row
                cells[r + ':0'] = { cs: cols };    // merge across the row
                n.tab = r * cols;
                children.push(n);
                r++;                               // row fully consumed
            } else {
                n.tab = r * cols + c;
                children.push(n);
                c++;
                if (c >= cols) { r++; c = 0; }
            }
        });
        var rows = Math.max(1, r + (c > 0 ? 1 : 0));
        // gap '' = "Match form": the grid follows the form's Spacing setting, so
        // changing Spacing on the Fields component still re-spaces the form.
        return tcmp('layout-grid', 'Form Grid', { rows: rows, cols: cols, gap: '', colWidths: '', cells: cells }, children);
    }
    function buildFormTemplate(type) {
        var isAdd = type === 'add', isView = type === 'view';
        var mode = isView ? 'view' : 'edit';
        var titleName = isAdd ? 'Default Add Page' : isView ? 'Default View Page' : 'Default Edit Page';
        var heading = isAdd ? 'New Record' : isView ? 'Record Details' : 'Edit Record';
        var crumb = isAdd ? 'Add' : isView ? 'View' : 'Edit';
        var buttons = isAdd ? ['save', 'save_new', 'cancel'] : isView ? ['edit', 'print', 'delete', 'back'] : ['save', 'cancel', 'back'];
        var sf = function (label, field, inputType, spans) { return tcmp('field', label, { field: field, label: label, inputType: inputType, mode: mode, spans: spans }); };
        return {
            id: 'tpl_' + type, name: type, type: type, title: titleName, table: '', isTemplate: true,
            layout: { device: 'desktop', width: 'fixed', theme: 'default' },
            settings: { customCss: '', customJs: '', locked: false },
            components: [
                tcmp('breadcrumb', 'Breadcrumb', { items: ['Home', 'Records', crumb] }),
                tcmp('section', 'Form Card', { heading: heading, variant: 'card', icon: isAdd ? 'bi-plus-circle' : isView ? 'bi-eye' : 'bi-pencil-square' }, [
                    tcmp('form', 'Fields', { fieldSpan: { desktop: 6, tablet: 6, mobile: 12 }, gap: isView ? 'roomy' : 'normal', labelPosition: isView ? 'left' : 'top', clientValidation: !isView, warnUnsaved: !isView, hotkeys: !isView, autofocusFirst: isAdd, requiredHint: !isView, stickyActions: isAdd }, [
                        // The default form is a CELL GRID: 2 columns, each field
                        // in its own selectable/mergeable cell (12/12 inside it),
                        // long-text fields on a full merged row. Sample fields
                        // are preview only — replaced per-table on save.
                        formGridFor([
                            sf('Full Name', 'name', 'text', { desktop: 12, tablet: 12, mobile: 12 }),
                            sf('Email', 'email', 'email', { desktop: 12, tablet: 12, mobile: 12 }),
                            sf('Phone', 'phone', 'tel', { desktop: 12, tablet: 12, mobile: 12 }),
                            sf('Date', 'date', 'date', { desktop: 12, tablet: 12, mobile: 12 }),
                            sf('Status', 'status', 'select', { desktop: 12, tablet: 12, mobile: 12 }),
                            sf('Active', 'active', 'checkbox', { desktop: 12, tablet: 12, mobile: 12 }),
                            sf('Notes', 'notes', 'textarea', { desktop: 12, tablet: 12, mobile: 12 })
                        ], 2)
                    ])
                ]),
                tcmp('buttons', isView ? 'Record Actions' : 'Form Actions', { buttons: buttons, buttonConfig: isView ? { edit: { variant: 'primary' }, delete: { variant: 'danger' } } : {} })
            ]
        };
    }
    // Print and PDF share one paper-document blueprint (view-mode fields on a sized sheet)
    function buildPaperTemplate(type) {
        var isPdf = type === 'pdf';
        var vf = function (label, field, inputType, spans) { return tcmp('field', label, { field: field, label: label, inputType: inputType, mode: 'view', spans: spans }); };
        return {
            id: 'tpl_' + type, name: type, type: type, title: isPdf ? 'Default PDF Page' : 'Default Print Page', table: '', isTemplate: true,
            layout: { device: 'desktop', width: 'fixed', theme: 'print', paper: { size: 'A4', orientation: 'portrait', margin: '18mm' } },
            settings: { customCss: '', customJs: '', locked: false },
            components: [
                tcmp('print-header', isPdf ? 'PDF Header' : 'Print Header', { org: 'Your Organization', title: 'Record Details', subtitle: '', showLogo: true, showDate: true, showRule: true }),
                tcmp('form', 'Details', { fieldSpan: { desktop: 6, tablet: 6, mobile: 12 }, gap: 'tight', labelPosition: 'left' }, [
                    // Paper documents use the same Cell-Grid skeleton (view-mode
                    // fields), so print/PDF layouts are cell-arrangeable too.
                    formGridFor([
                        vf('Full Name', 'name', 'text', { desktop: 12, tablet: 12, mobile: 12 }),
                        vf('Email', 'email', 'email', { desktop: 12, tablet: 12, mobile: 12 }),
                        vf('Phone', 'phone', 'tel', { desktop: 12, tablet: 12, mobile: 12 }),
                        vf('Date', 'date', 'date', { desktop: 12, tablet: 12, mobile: 12 }),
                        vf('Status', 'status', 'select', { desktop: 12, tablet: 12, mobile: 12 }),
                        vf('Active', 'active', 'checkbox', { desktop: 12, tablet: 12, mobile: 12 }),
                        vf('Notes', 'notes', 'textarea', { desktop: 12, tablet: 12, mobile: 12 })
                    ], 2)
                ]),
                tcmp('print-footer', isPdf ? 'PDF Footer' : 'Print Footer', { left: '', center: isPdf ? 'Generated document' : 'Confidential', right: 'Page %page% of %pages%', showRule: true })
            ]
        };
    }
    function buildPrintTemplate() { return buildPaperTemplate('print'); }
    function buildPdfTemplate() { return buildPaperTemplate('pdf'); }
    function ensureFormTemplate(type) {
        var d = store.designer();
        if (!d.templates) d.templates = {};
        if (!d.templates[type]) d.templates[type] = (type === 'print') ? buildPrintTemplate() : (type === 'pdf') ? buildPdfTemplate() : buildFormTemplate(type);
        return d.templates[type];
    }

    /* ---------- Import / Export data-operation templates ---------- */
    function buildImportTemplate() {
        return {
            id: 'tpl_import', name: 'import', type: 'import', title: 'Default Import Page', table: '', isTemplate: true,
            layout: { device: 'desktop', width: 'fluid', theme: 'default' }, settings: { customCss: '', customJs: '', locked: false },
            components: [
                tcmp('breadcrumb', 'Breadcrumb', { items: ['Home', 'Records', 'Import'] }),
                tcmp('heading', 'Heading', { text: 'Import Records', level: 'h2' }),
                tcmp('text', 'Subtitle', { text: 'Upload a file or paste rows copied from Excel, CSV, or another source.' }),
                tcmp('workflow-steps', 'Steps', { steps: ['Source', 'Mapping', 'Preview', 'Import'], active: 0 }),
                tcmp('section', 'Import Source', { heading: '1 · Import Source', variant: 'card' }, [
                    tcmp('tabs', 'Source Tabs', { tabs: ['Copy / Paste', 'Upload File'] }, [
                        tcmp('field', 'Paste Data', { field: 'paste', label: 'Paste rows from Excel / CSV / JSON / XML', inputType: 'textarea', placeholder: 'Paste tab, comma or semicolon separated rows…', spans: { desktop: 12, tablet: 12, mobile: 12 } })
                    ]),
                    tcmp('upload', 'Upload File', { accept: '.csv,.tsv,.txt,.xlsx,.json,.xml' }),
                    tcmp('buttons', 'Source Actions', { buttons: ['sample'], buttonConfig: {} })
                ]),
                tcmp('section', 'Import Options', { heading: '2 · Options', variant: 'card' }, [
                    tcmp('form', 'Options', { fieldSpan: { desktop: 6, tablet: 6, mobile: 12 }, labelPosition: 'top', gap: 'normal' }, [
                        tcmp('field', 'Delimiter', { field: 'delimiter', label: 'Delimiter', inputType: 'select', spans: { desktop: 6, tablet: 6, mobile: 12 } }),
                        tcmp('field', 'Import Mode', { field: 'mode', label: 'Import mode', inputType: 'select', spans: { desktop: 6, tablet: 6, mobile: 12 } }),
                        // Insert / skip / update need a column to match rows on.
                        tcmp('field', 'Match existing on', { field: 'match_field', label: 'Match existing on', inputType: 'select', spans: { desktop: 6, tablet: 6, mobile: 12 } }),
                        tcmp('field', 'First row headers', { field: 'headers', label: 'First row contains column headers', inputType: 'checkbox', spans: { desktop: 6, tablet: 6, mobile: 12 } }),
                        tcmp('field', 'Validate', { field: 'validate', label: 'Validate before import (import nothing if any row fails)', inputType: 'checkbox', spans: { desktop: 6, tablet: 6, mobile: 12 } })
                    ])
                ]),
                tcmp('section', 'Field Mapping', { heading: '3 · Field Mapping', variant: 'card' }, [
                    tcmp('field-mapper', 'Mapper', {})
                ]),
                tcmp('section', 'Preview', { heading: '4 · Preview', variant: 'card' }, [
                    tcmp('table-grid', 'Preview', { selection: false, showActions: false, headerFilters: false, showTotals: false, density: 'compact' })
                ]),
                // Mirrors the tiles the wizard shows above the mapping step.
                tcmp('summary', 'Import Summary', { items: [{ label: 'Fields matched', value: '—', icon: 'bi-layout-three-columns' }, { label: 'Rows found', value: '—', icon: 'bi-list-ol' }, { label: 'Unmapped fields', value: '—', icon: 'bi-exclamation-triangle', tone: 'warn' }, { label: 'Source', value: '—', icon: 'bi-box-arrow-in-down' }] }),
                tcmp('buttons', 'Import Actions', { buttons: ['import', 'cancel'], buttonConfig: { import: { variant: 'primary' } } })
            ]
        };
    }
    function buildExportTemplate() {
        return {
            id: 'tpl_export', name: 'export', type: 'export', title: 'Default Export Page', table: '', isTemplate: true,
            layout: { device: 'desktop', width: 'fluid', theme: 'default' }, settings: { customCss: '', customJs: '', locked: false },
            components: [
                tcmp('breadcrumb', 'Breadcrumb', { items: ['Home', 'Records', 'Export'] }),
                tcmp('heading', 'Heading', { text: 'Export Records', level: 'h2' }),
                tcmp('text', 'Subtitle', { text: 'Choose records, fields, and format before exporting your data.' }),
                tcmp('workflow-steps', 'Steps', { steps: ['Records', 'Fields', 'Format', 'Export'], active: 0 }),
                tcmp('section', 'Records', { heading: '1 · Records', variant: 'card' }, [
                    // Order matches the generated screen; "Selected records" is
                    // pre-chosen there when rows were ticked on the list.
                    tcmp('radio-group', 'Scope', { label: 'Which records to export', options: ['Selected records', 'Filtered records', 'Current page only', 'All records'], value: 'Filtered records' })
                ]),
                tcmp('section', 'Fields', { heading: '2 · Fields', variant: 'card' }, [
                    tcmp('field-selector', 'Fields', { fields: [] })
                ]),
                tcmp('section', 'Format', { heading: '3 · Format', variant: 'card' }, [
                    // Only formats the DataOps registry can actually produce.
                    tcmp('format-selector', 'Format', { formats: ['csv', 'xlsx', 'word', 'json', 'xml'], value: 'csv' }),
                    tcmp('field', 'Include headers', { field: 'headers', label: 'Include the header row', inputType: 'checkbox', spans: { desktop: 12, tablet: 12, mobile: 12 } })
                ]),
                tcmp('section', 'Preview', { heading: '4 · Preview', variant: 'card' }, [
                    tcmp('table-grid', 'Preview', { selection: false, showActions: false, headerFilters: false, showTotals: false, density: 'compact' })
                ]),
                // Mirrors the tiles on the generated Export screen.
                tcmp('summary', 'Export Summary', { items: [{ label: 'Fields available', value: '—', icon: 'bi-layout-three-columns' }, { label: 'Rows matched', value: '—', icon: 'bi-list-ol' }] }),
                tcmp('buttons', 'Export Actions', { buttons: ['export', 'cancel'], buttonConfig: { export: { variant: 'primary' } } })
            ]
        };
    }
    function ensureBuilt(type, builder) {
        var d = store.designer(); if (!d.templates) d.templates = {};
        if (!d.templates[type]) d.templates[type] = builder();
        return d.templates[type];
    }
    // Does a page design contain a component of the given type (deep)?
    function pageHasComp(page, type) {
        var found = false;
        (function walk(list) { (list || []).forEach(function (c) { if (c.type === type) found = true; if (c.children) walk(c.children); }); })(page && page.components);
        return found;
    }
    // Migrate legacy Cards pages that were built before the card-grid component
    // existed (they carried a plain table-grid). Rebuild the master + every
    // table's cards page as the real card gallery. Returns true if anything changed.
    function upgradeCardsTemplate() {
        var d = store.designer(); var changed = false;
        if (d.templates && d.templates.cards && !pageHasComp(d.templates.cards, 'card-grid')) {
            delete d.templates.cards; changed = true;
        }
        (store.tables() || []).forEach(function (t) {
            var slot = d.pages && d.pages[t.name] && d.pages[t.name].cards;
            if (!slot) return;
            var pgs = slot.pages || {};
            var stale = Object.keys(pgs).some(function (k) { return !pageHasComp(pgs[k], 'card-grid'); });
            if (stale) { delete d.pages[t.name].cards; changed = true; }
        });
        return changed;
    }
    // Rebuild form-page masters saved before the Cell Grid existed (they hold
    // a flat field list). Deleting them makes ensureFormTemplate() recreate the
    // grid-based default; seedTemplates() then flows it to pristine pages.
    function upgradeFormTemplates() {
        var d = store.designer(); var changed = false;
        ['add', 'edit', 'view', 'print', 'pdf'].forEach(function (t) {
            if (d.templates && d.templates[t] && !pageHasComp(d.templates[t], 'layout-grid')) {
                delete d.templates[t]; changed = true;
            }
        });
        return changed;
    }
    function ensureDataOpSeed(type, builder) {
        var d = store.designer(); var had = !!(d.templates && d.templates[type]);
        ensureBuilt(type, builder);
        return !had;
    }
    // bind a cloned import/export template to a table (breadcrumb, heading, grid columns, field list)
    function bindDataOp(comps, table, fields, type) {
        var crumb = type === 'import' ? 'Import' : 'Export';
        (comps || []).forEach(function (c) {
            c.id = c.type + '_' + slug(table) + '_' + Math.random().toString(36).slice(2, 6);
            if (c.type === 'breadcrumb') c.props.items = ['Home', title(table), crumb];
            if (c.type === 'heading') c.props.text = crumb + ' ' + title(table);
            if (c.type === 'table-grid' || c.type === 'grid') {
                c.props.columns = (fields.length ? fields : [{ name: 'id', label: 'ID' }, { name: 'name', label: 'Name' }]).slice(0, 6).map(function (f) { return { field: f.name, label: f.label || title(f.name), visible: true, sortable: true }; });
            }
            if (c.type === 'field-selector') c.props.fields = fields.map(function (f) { return f.name; });
            if (c.type === 'summary') {
                (c.props.items || []).forEach(function (it) { if (/field/i.test(it.label || '')) it.value = String(fields.length); });
            }
            if (c.children) bindDataOp(c.children, table, fields, type);
        });
    }
    function applyDataOpTemplate(type, mode) {
        var d = store.designer();
        var master = ensureBuilt(type, type === 'import' ? buildImportTemplate : buildExportTemplate);
        (store.tables() || []).forEach(function (t) {
            var table = t.name;
            if ((store.pageTypesFor(table) || []).indexOf(type) < 0) return;
            if (skipSeed(table, type, mode)) return;
            var page = JSON.parse(JSON.stringify(master));
            page.isTemplate = false; page.pristine = true;
            page.id = slug(table) + '_' + type; page.name = type; page.type = type; page.table = table;
            page.title = title(table) + ' ' + (type === 'import' ? 'Import' : 'Export');
            bindDataOp(page.components, table, store.fieldsFor(table), type);
            d.pages = d.pages || {}; d.pages[table] = d.pages[table] || {};
            d.pages[table][type] = { defaultPage: type, pages: {} };
            d.pages[table][type].pages[type] = page;
        });
    }
    function ensureFormTemplateSeed(type) {
        var d = store.designer();
        var had = !!(d.templates && d.templates[type]);
        ensureFormTemplate(type);
        return !had;
    }

    /* ---------- Cards template (records rendered as a responsive gallery) ---------- */
    function buildCardsTemplate() {
        return {
            id: 'tpl_cards', name: 'cards', type: 'cards', title: 'Default Cards Page', table: '', isTemplate: true,
            layout: { device: 'desktop', width: 'fluid', theme: 'default' },
            settings: { customCss: '', customJs: '', locked: false },
            components: [
                tcmp('breadcrumb', 'Breadcrumb', { items: ['Home', 'Records'], showHome: true }),
                tcmp('list-toolbar', 'Table Toolbar', { left: ['add'], center: ['search', 'filter'], right: ['record_info', 'export', 'page_size', 'refresh'], config: {}, exportFormats: ['xlsx', 'word', 'pdf', 'csv'], pageSizes: [12, 24, 48, 'all'], pageSizeDefault: 24, searchPlaceholder: 'Search records...', searchStyle: 'glass', recordInfo: 'Displaying %from% – %to% of %total%', density: 'normal' }),
                tcmp('filter-chips', 'Filter Chips', { chips: [], showClear: true }),
                tcmp('selection-bar', 'Selection Bar', { actions: ['delete_selected', 'update_selected', 'export_selected'], countText: '%n% selected', showClear: true }),
                tcmp('card-grid', 'Card Gallery', { columns: [], titleField: '', subtitleField: '', showAvatar: true, showActions: true, actions: ['view', 'edit', 'delete'], colsDesktop: 3, colsTablet: 2, colsMobile: 1, density: 'normal', selection: true }),
                tcmp('pagination', 'Pagination', { pageSize: 24, position: 'bottom' }),
                tcmp('buttons', 'Page Actions', { buttons: ['home'] })
            ]
        };
    }
    function applyCardsTemplateToTables(mode) {
        var d = store.designer();
        var master = ensureBuilt('cards', buildCardsTemplate);
        (store.tables() || []).forEach(function (t) {
            var table = t.name;
            if ((store.pageTypesFor(table) || []).indexOf('cards') < 0) return;
            if (skipSeed(table, 'cards', mode)) return;
            var page = JSON.parse(JSON.stringify(master));
            page.isTemplate = false; page.pristine = true;
            page.id = slug(table) + '_cards'; page.name = 'cards'; page.type = 'cards';
            page.table = table; page.title = title(table) + ' Cards';
            var fields = store.fieldsFor(table);
            var fl = fields.length ? fields : [{ name: 'id', label: 'ID' }, { name: 'name', label: 'Name' }];
            (page.components || []).forEach(function (c) {
                c.id = c.type + '_' + slug(table) + '_' + Math.random().toString(36).slice(2, 6);
                if (c.type === 'breadcrumb') c.props.items = ['Home', title(table)];
                if (c.type === 'card-grid') {
                    var cardFl = fieldsForPage(table, 'cards'); if (!cardFl.length) cardFl = fl;
                    c.props.columns = cardFl.map(function (f) { return { field: f.name, label: f.label || title(f.name), visible: true }; });
                    var tf = cardFl.filter(function (f) { return f.name !== 'id'; })[0] || cardFl[0];
                    c.props.titleField = (tf && tf.name) || 'id';
                }
            });
            var cfg = store.pagesConfigFor(table);
            if (cfg) reconcileListPage(page, cfg);   // toolbar buttons + card actions via the Pages module
            d.pages = d.pages || {};
            d.pages[table] = d.pages[table] || {};
            d.pages[table]['cards'] = { defaultPage: 'cards', pages: { cards: page } };
        });
    }

    function isFormType(type) { return type === 'add' || type === 'edit' || type === 'view' || type === 'print' || type === 'pdf'; }
    function ensureTemplate(type) {
        if (type === 'import') return ensureBuilt('import', buildImportTemplate);
        if (type === 'export') return ensureBuilt('export', buildExportTemplate);
        if (type === 'cards') return ensureBuilt('cards', buildCardsTemplate);
        return isFormType(type) ? ensureFormTemplate(type) : ensureListTemplate();
    }

    function fillForm(comps, table, fields, type) {
        (comps || []).forEach(function (c) {
            c.id = c.type + '_' + slug(table) + '_' + Math.random().toString(36).slice(2, 6);
            if (c.type === 'breadcrumb') c.props.items = ['Home', title(table), type === 'add' ? 'Add' : type === 'view' ? 'View' : 'Edit'];
            if (c.type === 'print-header') { c.props.title = title(table) + ' Details'; }
            if (c.type === 'print-footer' && !c.props.left) { c.props.left = title(table); }
            if (c.type === 'form') {
                var fmode = (type === 'view' || type === 'print' || type === 'pdf') ? 'view' : 'edit';
                var fl = (fields.length ? fields : [{ name: 'id', label: 'ID' }, { name: 'name', label: 'Name' }]).filter(function (f) {
                    return !f.pages || f.pages[type] !== false;   // honour Fields-module per-page visibility
                });
                // The table's real fields go into a fresh CELL GRID (replacing
                // the master's sample grid): 2 columns, textarea fields on a
                // full merged row, every cell selectable / mergeable / styleable.
                c.children = [formGridFor(fl.map(function (f) {
                    return tcmp('field', f.label || title(f.name), {
                        field: f.name, label: f.label || title(f.name), inputType: inferInput(f.type),
                        mode: fmode, required: false, placeholder: '', help: '',
                        // default the field's control from the Fields module (viewAs / editAs)
                        editControl: f.editControl || '', editSettings: f.editSettings || {},
                        viewControl: f.viewControl || '', viewSettings: f.viewSettings || {},
                        spans: { desktop: 12, tablet: 12, mobile: 12 }   // fields fill their cell
                    });
                }), 2)];
            } else if (c.children) { fillForm(c.children, table, fields, type); }
        });
    }
    function applyFormTemplateToTables(type, mode) {
        var d = store.designer();
        var master = ensureFormTemplate(type);
        (store.tables() || []).forEach(function (t) {
            var table = t.name;
            if ((store.pageTypesFor(table) || []).indexOf(type) < 0) return;
            if (skipSeed(table, type, mode)) return;
            var page = JSON.parse(JSON.stringify(master));
            page.isTemplate = false; page.pristine = true;
            page.id = slug(table) + '_' + type; page.name = type; page.type = type;
            var typeLabel = { add: 'Add', edit: 'Edit', view: 'View', print: 'Print', pdf: 'PDF' }[type] || type;
            page.table = table; page.title = title(table) + ' ' + typeLabel;
            fillForm(page.components, table, store.fieldsFor(table), type);
            d.pages = d.pages || {};
            d.pages[table] = d.pages[table] || {};
            d.pages[table][type] = { defaultPage: type, pages: {} };
            d.pages[table][type].pages[type] = page;
        });
    }
    // seed all default templates; return true if any were newly created
    /**
     * Bring already-stored Import/Export templates in line with what the build
     * now generates. Projects created before the DataOps rework hold the old
     * shape, and templates are only *seeded* when absent — without this they
     * would keep advertising formats the runtime cannot produce.
     * Returns true when anything changed.
     */
    function upgradeDataOpTemplates() {
        var changed = false;
        var walk = function (comps, fn) {
            (comps || []).forEach(function (c) { if (!c) return; fn(c); if (c.children) walk(c.children, fn); });
        };
        var registry = ((window.PpDesignerBootstrap || {}).dataFormats || []).map(function (f) { return f.id; });

        // ── Import ──
        var imp = ensureBuilt('import', buildImportTemplate);
        walk(imp.components, function (c) {
            c.props = c.props || {};
            if (c.type === 'upload' && String(c.props.accept || '').indexOf('.tsv') < 0) {
                c.props.accept = '.csv,.tsv,.txt,.xlsx,.json,.xml'; changed = true;
            }
            if (c.type === 'form') {
                var kids = c.children || (c.children = []);
                var idxOf = function (f) { for (var i = 0; i < kids.length; i++) if (kids[i].props && kids[i].props.field === f) return i; return -1; };
                var modeAt = idxOf('mode');
                // Sits directly after Import mode, as in a freshly seeded template.
                if (idxOf('match_field') < 0 && modeAt > -1) {
                    kids.splice(modeAt + 1, 0, tcmp('field', 'Match existing on', { field: 'match_field', label: 'Match existing on', inputType: 'select', spans: { desktop: 6, tablet: 6, mobile: 12 } }));
                    changed = true;
                }
            }
            if (c.type === 'summary' && (c.props.items || []).some(function (i) { return i.label === 'Warnings' || i.label === 'Fields'; })) {
                c.props.items = [{ label: 'Fields matched', value: '—', icon: 'bi-layout-three-columns' }, { label: 'Rows found', value: '—', icon: 'bi-list-ol' }, { label: 'Unmapped fields', value: '—', icon: 'bi-exclamation-triangle', tone: 'warn' }, { label: 'Source', value: '—', icon: 'bi-box-arrow-in-down' }];
                changed = true;
            }
            // The wizard commits from the mapping step; there is no separate
            // "validate" action (it is a checkbox in Options).
            if (c.type === 'buttons' && (c.props.buttons || []).indexOf('validate') > -1) {
                c.props.buttons = c.props.buttons.filter(function (b) { return b !== 'validate'; });
                changed = true;
            }
        });

        // ── Export ──
        var exp = ensureBuilt('export', buildExportTemplate);
        walk(exp.components, function (c) {
            c.props = c.props || {};
            if (c.type === 'format-selector') {
                var fmts = (c.props.formats || []).map(function (f) { return String(f).toLowerCase(); });
                var valid = registry.length ? registry : ['csv', 'xlsx', 'excel_html', 'word', 'json', 'xml'];
                var cleaned = fmts.filter(function (f) { return valid.indexOf(f) > -1; });
                // Drop PDF/HTML — there is no ExportFormat for them.
                if (cleaned.length !== fmts.length || !cleaned.length) {
                    c.props.formats = cleaned.length ? cleaned : ['csv', 'xlsx', 'word', 'json', 'xml'];
                    c.props.value = c.props.formats.indexOf(String(c.props.value).toLowerCase()) > -1
                        ? String(c.props.value).toLowerCase() : c.props.formats[0];
                    changed = true;
                }
            }
            // Scope order follows the generated screen (Selected first — it is
            // pre-chosen there when rows were ticked on the list).
            if (c.type === 'radio-group' && (c.props.options || []).indexOf('Current page') > -1) {
                c.props.options = ['Selected records', 'Filtered records', 'Current page only', 'All records'];
                if (['Selected records', 'Filtered records', 'Current page only', 'All records'].indexOf(c.props.value) < 0) {
                    c.props.value = 'Filtered records';
                }
                changed = true;
            }
            if (c.type === 'summary' && (c.props.items || []).some(function (i) { return i.label === 'Est. size' || i.label === 'Format'; })) {
                c.props.items = [{ label: 'Fields available', value: '—', icon: 'bi-layout-three-columns' }, { label: 'Rows matched', value: '—', icon: 'bi-list-ol' }];
                changed = true;
            }
            // The screen previews inline; there is no separate Preview action.
            if (c.type === 'buttons' && (c.props.buttons || []).indexOf('preview') > -1) {
                c.props.buttons = c.props.buttons.filter(function (b) { return b !== 'preview'; });
                changed = true;
            }
        });
        // The header-row toggle lives beside the format tiles.
        (exp.components || []).forEach(function (sec) {
            if (sec.type !== 'section' || !sec.children) return;
            var isFormat = sec.children.some(function (k) { return k.type === 'format-selector'; });
            var hasHdr = sec.children.some(function (k) { return k.props && k.props.field === 'headers'; });
            if (isFormat && !hasHdr) {
                sec.children.push(tcmp('field', 'Include headers', { field: 'headers', label: 'Include the header row', inputType: 'checkbox', spans: { desktop: 12, tablet: 12, mobile: 12 } }));
                changed = true;
            }
        });

        return changed;
    }

    function seedTemplates() {
        // Pre-Cell-Grid form masters are rebuilt first; the fresh grid default
        // then flows to every page still holding the untouched old default
        // ('pristine' mode) — customised pages keep their design and adopt the
        // grid via Reset-to-default.
        var formUp = upgradeFormTemplates();
        var a = ensureListTemplateSeed(); var b = ensureFormTemplateSeed('add'); var c = ensureFormTemplateSeed('edit'); var v = ensureFormTemplateSeed('view'); var pr = ensureFormTemplateSeed('print');
        var pd = ensureFormTemplateSeed('pdf');
        var im = ensureDataOpSeed('import', buildImportTemplate); var ex = ensureDataOpSeed('export', buildExportTemplate);
        var dataUp = upgradeDataOpTemplates();   // realign pre-DataOps templates
        var cardsUpgraded = upgradeCardsTemplate();   // drop legacy table-grid cards pages first
        var cd = ensureDataOpSeed('cards', buildCardsTemplate);
        var up = upgradeListTemplate();
        // Boot-time seeding NEVER overwrites an existing saved page ('missing'
        // mode) — it only fills in pages that don't exist yet. A developer's
        // work survives every reload, template upgrade, and the cards migration
        // (which recreates only the stale pages it deletes).
        if (a || up) applyListTemplateToTables('missing');
        var formMode = formUp ? 'pristine' : 'missing';
        if (b || formUp) applyFormTemplateToTables('add', formMode);
        if (c || formUp) applyFormTemplateToTables('edit', formMode);
        if (v || formUp) applyFormTemplateToTables('view', formMode);
        if (pr || formUp) applyFormTemplateToTables('print', formMode);
        if (pd || formUp) applyFormTemplateToTables('pdf', formMode);
        // Upgraded templates flow to pages still using the untouched default
        // ('pristine'); a developer's customised page keeps its design and adopts
        // the new default via Reset-to-default.
        if (im) applyDataOpTemplate('import', 'missing');
        else if (dataUp) applyDataOpTemplate('import', 'pristine');
        if (ex) applyDataOpTemplate('export', 'missing');
        else if (dataUp) applyDataOpTemplate('export', 'pristine');
        if (cd || cardsUpgraded) applyCardsTemplateToTables('missing');
        return a || b || c || v || pr || pd || im || ex || cd || cardsUpgraded || up || dataUp || formUp;
    }
    // Build ONE fresh default page for a table + type from the client-side
    // master templates (the modern section > form > fields + buttons design).
    // Returns null for types the client doesn't build (caller falls back to
    // the server template — common/auth/dashboard pages).
    function buildDefaultPageFor(table, type) {
        if (!table) return null;
        if (isFormType(type)) {
            var master = ensureFormTemplate(type);
            var page = JSON.parse(JSON.stringify(master));
            page.isTemplate = false; page.pristine = true;
            var lbl = { add: 'Add', edit: 'Edit', view: 'View', print: 'Print', pdf: 'PDF' }[type] || type;
            page.id = slug(table) + '_' + type; page.name = type; page.type = type;
            page.table = table; page.title = title(table) + ' ' + lbl;
            fillForm(page.components, table, store.fieldsFor(table), type);
            return page;
        }
        if (type === 'list') {
            var lm = ensureListTemplate();
            var lp = JSON.parse(JSON.stringify(lm));
            lp.isTemplate = false; lp.pristine = true;
            lp.id = slug(table) + '_list'; lp.name = 'list'; lp.type = 'list';
            lp.table = table; lp.title = title(table) + ' List';
            var fields = store.fieldsFor(table);
            (lp.components || []).forEach(function (c) {
                c.id = c.type + '_' + slug(table) + '_' + Math.random().toString(36).slice(2, 6);
                if (c.type === 'breadcrumb') c.props.items = ['Home', title(table)];
                if (c.type === 'table-grid' || c.type === 'grid') {
                    var fl = fieldsForPage(table, 'list');
                    if (!fl.length) fl = [{ name: 'id', label: 'ID' }, { name: 'name', label: 'Name' }];
                    c.props.columns = fl.map(function (f) { return { field: f.name, label: f.label || title(f.name), visible: true, sortable: true }; });
                }
            });
            var cfg = store.pagesConfigFor(table);
            if (cfg) reconcileListPage(lp, cfg);
            return lp;
        }
        if (type === 'cards') {
            var cm = ensureBuilt('cards', buildCardsTemplate);
            var cp = JSON.parse(JSON.stringify(cm));
            cp.isTemplate = false; cp.pristine = true; cp.id = slug(table) + '_cards'; cp.name = 'cards'; cp.type = 'cards';
            cp.table = table; cp.title = title(table) + ' Cards';
            var cf = store.fieldsFor(table);
            var cfl = cf.length ? cf : [{ name: 'id', label: 'ID' }, { name: 'name', label: 'Name' }];
            (cp.components || []).forEach(function (c) {
                c.id = c.type + '_' + slug(table) + '_' + Math.random().toString(36).slice(2, 6);
                if (c.type === 'breadcrumb') c.props.items = ['Home', title(table)];
                if (c.type === 'card-grid') {
                    var cardFl2 = fieldsForPage(table, 'cards'); if (!cardFl2.length) cardFl2 = cfl;
                    c.props.columns = cardFl2.map(function (f) { return { field: f.name, label: f.label || title(f.name), visible: true }; });
                    var tf = cardFl2.filter(function (f) { return f.name !== 'id'; })[0] || cardFl2[0];
                    c.props.titleField = (tf && tf.name) || 'id';
                }
            });
            var ccfg = store.pagesConfigFor(table);
            if (ccfg) reconcileListPage(cp, ccfg);   // toolbar buttons + card actions from the Pages module (parity with list + applyCardsTemplateToTables)
            return cp;
        }
        if (type === 'import' || type === 'export') {
            var dm = ensureBuilt(type, type === 'import' ? buildImportTemplate : buildExportTemplate);
            var dp = JSON.parse(JSON.stringify(dm));
            dp.isTemplate = false; dp.pristine = true; dp.id = slug(table) + '_' + type; dp.name = type; dp.type = type;
            dp.table = table; dp.title = title(table) + ' ' + (type === 'import' ? 'Import' : 'Export');
            bindDataOp(dp.components, table, store.fieldsFor(table), type);
            return dp;
        }
        return null;
    }

    // Propagate the currently-edited Default Template to its tables (called on
    // save while editing a template). 'pristine' mode updates only tables still
    // using the untouched default — a developer's customised pages are preserved
    // (matches the left panel's "applies to every table that has no page design
    // of its own"). Customised pages adopt the new default via Reset-to-default.
    function applyTemplate(type) {
        if (type === 'import' || type === 'export') applyDataOpTemplate(type, 'pristine');
        else if (type === 'cards') applyCardsTemplateToTables('pristine');
        else if (isFormType(type)) applyFormTemplateToTables(type, 'pristine');
        else applyListTemplateToTables('pristine');
    }

    /**
     * "Apply to all pages": mirror a field component's control configuration
     * (edit/view control + settings, and the toggle itself) onto every field
     * component bound to the same column across ALL page designs of the
     * active table. Lets a Lookup Wizard (or any control) be configured once.
     */
    function propagateFieldControl(sourceNode) {
        var page = activePage();
        var table = page && page.table;
        var fieldName = sourceNode && sourceNode.props && sourceNode.props.field;
        if (!table || !fieldName) return;
        var patch = {
            fcApplyAll: true,
            editControl:  sourceNode.props.editControl || '',
            editSettings: Object.assign({}, sourceNode.props.editSettings || {}),
            viewControl:  sourceNode.props.viewControl || '',
            viewSettings: Object.assign({}, sourceNode.props.viewSettings || {}),
        };
        store.mutate(function (d) {
            var slot = d.pages && d.pages[table];
            if (!slot) return;
            var hit = false;
            var walk = function (comps) {
                (comps || []).forEach(function (c) {
                    if (!c || typeof c !== 'object') return;
                    if (c.type === 'field' && c.props && c.props.field === fieldName && c !== sourceNode) {
                        c.props = Object.assign({}, c.props, patch); hit = true;
                    }
                    if (c.children) walk(c.children);
                });
            };
            Object.keys(slot).forEach(function (pageType) {
                var pages = slot[pageType] && slot[pageType].pages;
                if (!pages) return;
                Object.keys(pages).forEach(function (pn) {
                    hit = false;
                    walk(pages[pn] && pages[pn].components);
                    if (hit && pages[pn]) pages[pn].pristine = false;   // a control was propagated here → it's now customised
                });
            });
        });
    }

    PPD.model = {
        uid: uid, title: title, migrate: migrate, syncWithPages: syncWithPages, syncTable: syncTable,
        ensureFormTemplate: ensureFormTemplate, ensureFormTemplateSeed: ensureFormTemplateSeed, applyFormTemplateToTables: applyFormTemplateToTables,
        ensureTemplate: ensureTemplate, seedTemplates: seedTemplates, applyTemplate: applyTemplate,
        ensureListTemplate: ensureListTemplate, ensureListTemplateSeed: ensureListTemplateSeed, upgradeListTemplate: upgradeListTemplate, applyListTemplateToTables: applyListTemplateToTables,
        activePage: activePage, hasActivePage: hasActivePage, isPageLocked: isPageLocked,
        components: components, find: find, flatten: flatten, create: create,
        add: add, remove: remove, duplicate: duplicate, move: move, setTab: setTab, withNode: withNode,
        updateProps: updateProps, updateNode: updateNode, updatePage: updatePage,
        propagateFieldControl: propagateFieldControl,
        installTemplatedPage: installTemplatedPage, buildDefaultPageFor: buildDefaultPageFor,
        applyStarter: applyStarter, reconcileActiveList: reconcileActiveList
    };

    // Replace the ACTIVE page's components with a starter-layout blueprint (a flat
    // list of {type, props}). Each gets a fresh id; the page is switched to the
    // chromeless "landing" frame. Persists + re-renders via store.mutate.
    function applyStarter(starter, mode) {
        if (!starter || !Array.isArray(starter.components)) return false;
        var append = mode === 'append';
        // A standalone project renders its pages chromeless (the starter's own
        // Navbar/Footer ARE the page frame). A DB project renders custom pages
        // INSIDE the app shell (navbar + sidebar + footer), so a starter must keep
        // chrome='shell' and drop the structural components the shell already
        // provides — otherwise the page shows a second navbar/footer.
        var standalone = !!(window.PpDesignerBootstrap || {}).standalone;
        var shellDupes = { 'navbar': 1, 'footer': 1, 'back-to-top': 1, 'cookie-consent': 1, 'announcement-bar': 1 };
        var ok = false;
        store.mutate(function () {
            var page = activePage();
            if (!page) return;
            page.pristine = false;
            page.layout = page.layout || {};
            // Replace sets the page frame + dark to the starter's; append keeps the
            // page's own frame/dark and just adds the sections below.
            if (!append) {
                page.layout.chrome = standalone ? (starter.chrome || 'blank') : 'shell';
                page.layout.dark = standalone ? !!starter.dark : !!page.layout.dark;
            }
            var src = standalone ? starter.components : starter.components.filter(function (c) { return !shellDupes[c.type]; });
            var mapped = src.map(function (c) {
                return { id: uid(c.type || 'cmp'), type: c.type, label: c.label || '', props: JSON.parse(JSON.stringify(c.props || {})), children: [], style: c.style ? JSON.parse(JSON.stringify(c.style)) : { class: '', customCss: '' } };
            });
            page.components = append ? (page.components || []).concat(mapped) : mapped;
            ok = true;
        });
        return ok;
    }
})();
