<?php

declare(strict_types=1);

namespace PpPhp\Modules\Designer\Controllers;

use PpPhp\Core\Config;
use PpPhp\Core\DB;
use PpPhp\Core\ModuleManager;
use PpPhp\Core\ModuleState;
use PpPhp\Core\ProjectNav;
use PpPhp\Core\Security;
use PpPhp\Core\Events\CustomButtonEvents;
use PpPhp\Core\Designer\TemplateFactory;
use PpPhp\Core\Designer\Registries\ComponentRegistry;
use PpPhp\Core\Designer\FieldControl\FieldControlRuntime;
use PpPhp\Core\IconManager\IconManager;
use PpPhp\Core\Assets\SpaAssetManager;
use PpPhp\Core\Theme\ThemeRegistry;

class DesignerController
{
    /** Reserved pseudo-table key for custom pages not bound to a real table. */
    public const STANDALONE_TABLE = '__standalone__';

    private array $project = [];
    /** True when the open project is standalone (no database). */
    private bool $standalone = false;

    public function __construct(
        private Config $config,
        private ModuleManager $moduleManager
    ) {}

    public function index(string $slug): string
    {
        $this->initProject($slug);

        $project = $this->project;
        $tables = $this->tables($slug);
        $fieldsByTable = $this->fieldsByTable($slug, $tables);
        // Table pickers (lookups, FKs, charts) may also target referenceable
        // system tables like `users`/`groups` — not just project tables. On a
        // standalone project there is no project DB (the connection is still the
        // platform's central DB), so skip this to avoid leaking platform schema.
        $lookupTables = $this->standalone ? $tables : $this->withProjectViews($this->withReferenceableTables($tables, $fieldsByTable), $fieldsByTable);
        $availablePageTypes = $this->availablePageTypes($slug, $tables);
        $pagesState = ModuleState::load($slug, 'pages');
        $pagesConfig = $pagesState['byTable'] ?? [];

        // Misc module state (single source of truth for field placeholders/tooltips
        // via the Label Editor, and per-table list settings via Table Settings).
        // The Designer reads them here and writes back so both stay in sync.
        $miscState = ModuleState::load($slug, 'misc');
        $miscState = (is_array($miscState) && !array_is_list($miscState)) ? $miscState : [];
        // English is always the primary/default language when none are selected.
        $languages = $miscState['languages'] ?? [];
        if (!is_array($languages) || $languages === []) {
            $languages = [['code' => 'en', 'name' => 'English']];
        }
        $defaultLang = $languages[0]['code'] ?? 'en';
        $miscLabels = $miscState['labels'][$defaultLang] ?? new \stdClass();
        $tableSettings = $miscState['byTable'] ?? new \stdClass();
        // Additional WHERE tabs per table (Query module) → the Designer previews and
        // styles them on the list page's "Filter Tabs" component.
        $queryState = ModuleState::load($slug, 'query');
        $whereTabsByTable = [];
        foreach ((array) ($queryState['byTable'] ?? []) as $__t => $__c) {
            $__wt = array_values(array_filter((array) ($__c['whereTabs'] ?? []), 'is_array'));
            if ($__wt !== []) $whereTabsByTable[$__t] = $__wt;
        }
        if ($whereTabsByTable === []) $whereTabsByTable = new \stdClass();
        $designerState = $this->normaliseDesignerState($slug, ModuleState::load($slug, 'designer'), $tables, $fieldsByTable, $pagesState, $availablePageTypes);
        // Whether this project ships a factory snapshot — enables the per-page
        // "Reset to factory" button (currently offered on the dashboard page).
        $hasFactory = is_file(\PpPhp\Core\ProjectLocator::dir($slug) . '/factory/config/designer.json');

        $navItems = ProjectNav::items('/designer', $this->standalone);
        $standalone = $this->standalone;   // exposed to the view/bootstrap
        $baseUrl = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $user = $_SESSION['user'];
        $pageTitle = 'Designer';

        // Full backend component catalogue + icon-pack stylesheets for the SPA.
        $componentCatalogue = $this->componentCatalogue();
        $iconCssHtml = SpaAssetManager::renderIconCss($baseUrl);

        // Default design for the Menu (landing) page. Served from the SAME class
        // the build renders, so seeding the canvas and building the app cannot
        // produce different pages.
        $menuPageDefault = \PpPhp\Core\Designer\Defaults\MenuPageDefault::components(
            $this->tables($slug),
            ['appName' => (string) ($project['name'] ?? ''), 'auth' => true]
        );

        // Field-control plugins (View / Edit / Filter) for the field inspector + live preview.
        $pluginCatalogue = FieldControlRuntime::catalogue();

        // Themes (built-in presets + project custom) + the project default.
        $themeCatalogue = ThemeRegistry::catalogue($slug);
        $themeDefault = ThemeRegistry::activeDefault($slug);

        // App brand (Style → Layout → Logo) — single source of truth the auth
        // templates render instead of per-template placeholder brands.
        $appBrand = $this->appBrand($slug);

        // Master–Detail relations (Tables module → Relation modal) so the
        // canvas renders the Details column exactly like the generated list.
        $relations = array_values(array_filter(
            (array) (ModuleState::load($slug, 'tables')['relations'] ?? []),
            static fn ($r) => is_array($r) && ($r['master_table'] ?? '') !== '' && ($r['detail_table'] ?? '') !== ''
        ));

        ob_start();
        include __DIR__ . '/../Views/index.php';
        return ob_get_clean();
    }

    /**
     * Icon picker search across every registered icon pack (FontAwesome Pro,
     * Bootstrap, Material, …). Returns groups of {name,label,className,…}.
     */
    public function icons(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);

        $query = trim((string)($_GET['q'] ?? ''));
        $pack  = ($_GET['pack'] ?? '') !== '' ? (string)$_GET['pack'] : null;
        $limit = max(1, min(4000, (int)($_GET['limit'] ?? 300)));

        // Only packs that render as icon fonts via the loaded stylesheets.
        $renderable = ['fontawesome-pro', 'bootstrap-icons', 'material-symbols'];

        // Which packs to include: one (a tab) or all (balanced so every pack is represented).
        $packs = ($pack !== null && in_array($pack, $renderable, true)) ? [$pack] : $renderable;
        $perPack = count($packs) > 1 ? max(1, intdiv($limit, count($packs))) : $limit;

        $groups = [];
        foreach ($packs as $p) {
            $picker = IconManager::picker($query, $p, $perPack + 40);
            foreach (($picker['groups'] ?? []) as $group) {
                if (($group['pack'] ?? '') !== $p) {
                    continue;
                }
                $group['icons'] = array_slice($group['icons'] ?? [], 0, $perPack);
                if (!empty($group['icons'])) {
                    $groups[] = $group;
                }
            }
        }

        echo json_encode([
            'success' => true,
            'query'   => $query,
            'groups'  => $groups,
            'total'   => array_sum(array_map(static fn(array $g): int => count($g['icons'] ?? []), $groups)),
        ]);
        exit;
    }

    /**
     * Live field-control preview: render a field with a chosen View/Edit/Filter
     * plugin so the designer canvas shows the real control HTML.
     */
    public function fieldPreview(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);

        $input = json_decode(file_get_contents('php://input'), true) ?? [];
        $category = strtolower((string)($input['category'] ?? 'edit'));
        $control  = (string)($input['control'] ?? '');
        $field    = (string)($input['field'] ?? 'field');
        $settings = is_array($input['settings'] ?? null) ? $input['settings'] : [];
        $value    = $input['value'] ?? null;

        if (!in_array($category, ['view', 'edit', 'filter'], true)) {
            http_response_code(400);
            echo json_encode(['success' => false, 'message' => 'Invalid category.']);
            exit;
        }

        $result = FieldControlRuntime::render($category, $control, $field, $value, $settings);
        echo json_encode(['success' => true] + $result);
        exit;
    }

    /**
     * Live option search for the Lookup Wizard's AJAX mode inside the config
     * modal's Test preview. Settings come from the designer (trusted author);
     * identifiers are whitelisted inside the plugin.
     */
    public function lookupOptions(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);

        $input = json_decode(file_get_contents('php://input'), true) ?? [];
        $settings = is_array($input['settings'] ?? null) ? $input['settings'] : [];
        $settings['_search'] = (string) ($input['q'] ?? '');
        if (isset($input['parent'])) $settings['_parentValue'] = (string) $input['parent'];

        \PpPhp\Core\PluginRegistry::edit();   // load plugin classes
        $options = class_exists('\PpPhp\Plugins\LookupWizard')
            ? \PpPhp\Plugins\LookupWizard::options($settings)
            : [];
        echo json_encode(['success' => true, 'options' => $options]);
        exit;
    }

    /**
     * Live data for canvas KPI/Chart widgets (true WYSIWYG while designing).
     * Identifiers are whitelisted; the WHERE is designer-authored (same trust
     * as Events sqlWhere) and hardened the same way as the build output.
     */
    public function widgetData(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);

        $in  = json_decode(file_get_contents('php://input'), true) ?? [];
        $t   = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($in['table'] ?? ''));
        $agg = strtolower((string) ($in['agg'] ?? 'count'));
        if (!in_array($agg, ['count', 'sum', 'avg', 'min', 'max'], true)) $agg = 'count';
        $vc  = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($in['column'] ?? $in['valueColumn'] ?? ''));
        $aggSql = $agg === 'count' ? 'COUNT(*)' : strtoupper($agg) . "(`$vc`)";
        $w = trim(str_replace(["\0", ';'], '', (string) ($in['where'] ?? '')));
        $w = preg_replace('/^(where|and)\s+/i', '', $w) ?? $w;
        $whereSql = $w === '' ? '' : ' WHERE (' . $w . ')';

        if ($t === '' || ($agg !== 'count' && $vc === '')) {
            echo json_encode(['success' => false, 'error' => 'unbound']);
            exit;
        }

        try {
            if (($in['kind'] ?? 'kpi') === 'chart') {
                $lc = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($in['labelColumn'] ?? ''));
                if ($lc === '') { echo json_encode(['success' => false, 'error' => 'unbound']); exit; }
                $order = in_array($in['orderBy'] ?? '', ['value DESC', 'value ASC', 'label ASC', 'label DESC'], true)
                    ? str_replace(['value', 'label'], ['v', 'l'], (string) $in['orderBy']) : 'v DESC';
                $limit = max(1, min(50, (int) ($in['limit'] ?? 10)));
                $rows = \PpPhp\Core\DB::FetchAll(
                    "SELECT `$lc` AS l, $aggSql AS v FROM `$t`$whereSql GROUP BY `$lc` ORDER BY $order LIMIT $limit"
                ) ?: [];
                echo json_encode([
                    'success' => true,
                    'labels'  => array_map(static fn ($r) => (string) ($r['l'] ?? ''), $rows),
                    'values'  => array_map(static fn ($r) => (float) ($r['v'] ?? 0), $rows),
                ]);
            } else {
                $v = (float) (\PpPhp\Core\DB::FetchOne("SELECT $aggSql FROM `$t`$whereSql") ?: 0);
                echo json_encode(['success' => true, 'value' => $v]);
            }
        } catch (\Throwable $e) {
            echo json_encode(['success' => false, 'error' => 'query']);
        }
        exit;
    }

    /**
     * Assign a field's Edit / View controls ONCE, at the field level — the build
     * applies them to every page (add, edit, list, view, print, export). Stored
     * in the `fields` ModuleState (byTable[t].editAs/viewAs[field] = {type,
     * settings}); BuildEngine::extractFields already reads exactly this shape, so
     * no per-page component assignment is needed. Powers the Designer's
     * double-click "Field controls" modal.
     */
    public function saveFieldControl(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);

        $in    = json_decode(file_get_contents('php://input'), true) ?? [];
        $table = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($in['table'] ?? ''));
        $field = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($in['field'] ?? ''));
        if ($table === '' || $field === '') {
            http_response_code(400);
            echo json_encode(['success' => false, 'message' => 'Table and field are required.']);
            exit;
        }

        // Valid control ids per category, so only real plugins are ever stored.
        $cat = FieldControlRuntime::catalogue();
        $valid = [
            'edit'   => array_column($cat['edit'] ?? [], 'id'),
            'view'   => array_column($cat['view'] ?? [], 'id'),
            'filter' => array_column($cat['filter'] ?? [], 'id'),
        ];

        $normalise = function ($node, array $valid): ?array {
            if (!is_array($node)) return null;
            $type = (string) ($node['type'] ?? '');
            if ($type === '' || !in_array($type, $valid, true)) return null;   // '' = auto (unset)
            $settings = is_array($node['settings'] ?? null) ? $node['settings'] : [];
            // Keep scalars/arrays only (plugin settings are simple key→value).
            $clean = [];
            foreach ($settings as $k => $v) {
                if (!is_string($k)) continue;
                if (is_scalar($v) || $v === null) { $clean[$k] = $v; }
                elseif (is_array($v)) { $clean[$k] = array_values(array_filter($v, 'is_scalar')); }
            }
            return ['type' => $type, 'settings' => (object) $clean];
        };

        $edit   = $normalise($in['edit'] ?? null,   $valid['edit']);
        $view   = $normalise($in['view'] ?? null,   $valid['view']);
        $filter = $normalise($in['filter'] ?? null, $valid['filter']);

        // Field-level validation stack: an ordered list of rules
        // [{type, params, message}]. Only real installed rule ids are kept, and
        // params/message are reduced to scalars — same discovery model as the
        // controls above, enforced client + server on save by the build.
        $valIds = array_column($cat['validation'] ?? [], 'id');
        $validation = [];
        foreach ((is_array($in['validation'] ?? null) ? $in['validation'] : []) as $rule) {
            if (!is_array($rule)) continue;
            $type = (string) ($rule['type'] ?? '');
            if ($type === '' || !in_array($type, $valIds, true)) continue;
            $params = [];
            foreach ((is_array($rule['params'] ?? null) ? $rule['params'] : []) as $pk => $pv) {
                if (is_string($pk) && (is_scalar($pv) || $pv === null)) $params[$pk] = $pv;
            }
            $entry = [
                'type'    => $type,
                'params'  => (object) $params,
                'message' => (string) ($rule['message'] ?? ''),
            ];
            // Optional conditional gate { field, op, value }.
            $w = is_array($rule['when'] ?? null) ? $rule['when'] : null;
            $wf = $w ? preg_replace('/[^A-Za-z0-9_]/', '', (string) ($w['field'] ?? '')) : '';
            if ($wf !== '') {
                $entry['when'] = [
                    'field' => $wf,
                    'op'    => in_array(($w['op'] ?? '=='), ['==', '!=', 'empty', 'notEmpty', 'in', 'contains'], true) ? (string) $w['op'] : '==',
                    'value' => (string) ($w['value'] ?? ''),
                ];
            }
            $validation[] = $entry;
        }

        // Optional PER-PAGE overrides. Each page validates against its category.
        $pageCat = ['list' => 'view', 'add' => 'edit', 'edit' => 'edit', 'view' => 'view', 'search' => 'filter'];
        $pagesIn = is_array($in['pages'] ?? null) ? $in['pages'] : [];
        $pages = [];
        foreach ($pageCat as $page => $catKey) {
            $pages[$page] = $normalise($pagesIn[$page] ?? null, $valid[$catKey]);
        }

        $state = ModuleState::load($slug, 'fields');
        if (!is_array($state)) $state = [];
        if (!isset($state['byTable'][$table]) || !is_array($state['byTable'][$table])) {
            $state['byTable'][$table] = ['activeDevice' => 'desktop', 'devices' => [], 'searchSettings' => (object) []];
        }
        // Per-page bucket: pageAs[field][page] = {type,settings} (dropped when null).
        if (!isset($state['byTable'][$table]['pageAs']) || !is_array($state['byTable'][$table]['pageAs'])) {
            $state['byTable'][$table]['pageAs'] = [];
        }
        $fieldPages = [];
        foreach ($pages as $page => $val) { if ($val !== null) $fieldPages[$page] = $val; }
        if ($fieldPages === []) {
            unset($state['byTable'][$table]['pageAs'][$field]);
        } else {
            $state['byTable'][$table]['pageAs'][$field] = $fieldPages;
        }
        if ($state['byTable'][$table]['pageAs'] === []) $state['byTable'][$table]['pageAs'] = (object) [];

        // Validation stack bucket: validationAs[field] = [ {type,params,message}, … ].
        if (!isset($state['byTable'][$table]['validationAs']) || !is_array($state['byTable'][$table]['validationAs'])) {
            $state['byTable'][$table]['validationAs'] = [];
        }
        if ($validation === []) {
            unset($state['byTable'][$table]['validationAs'][$field]);
        } else {
            $state['byTable'][$table]['validationAs'][$field] = $validation;
        }
        if ($state['byTable'][$table]['validationAs'] === []) {
            $state['byTable'][$table]['validationAs'] = (object) [];
        }

        foreach (['editAs' => $edit, 'viewAs' => $view, 'filterAs' => $filter] as $bucket => $val) {
            if (!isset($state['byTable'][$table][$bucket]) || !is_array($state['byTable'][$table][$bucket])) {
                $state['byTable'][$table][$bucket] = [];
            }
            if ($val === null) {
                unset($state['byTable'][$table][$bucket][$field]);          // auto / cleared
            } else {
                $state['byTable'][$table][$bucket][$field] = $val;
            }
            // Force an OBJECT when empty so it never round-trips as a JS array
            // (which silently drops string keys — the same trap Search Settings hit).
            if ($state['byTable'][$table][$bucket] === []) {
                $state['byTable'][$table][$bucket] = (object) [];
            }
        }
        $state['activeTable'] = $table;
        ModuleState::save($slug, 'fields', $state);

        echo json_encode([
            'success' => true,
            'field'   => [
                'name'           => $field,
                'editControl'    => $edit['type'] ?? '',
                'editSettings'   => $edit['settings'] ?? (object) [],
                'viewControl'    => $view['type'] ?? '',
                'viewSettings'   => $view['settings'] ?? (object) [],
                'filterControl'  => $filter['type'] ?? '',
                'filterSettings' => $filter['settings'] ?? (object) [],
                'validations'    => $validation,
                'pageAs'         => $fieldPages === [] ? (object) [] : $fieldPages,
            ],
        ]);
        exit;
    }

    /**
     * The project's brand from the Style designer (Layout → Logo): text +
     * optional image URL, project-shell overlay included. Falls back to the
     * project name so new projects are never branded "Northstar".
     */
    private function appBrand(string $slug): array
    {
        $logo = [];
        try {
            $resolved = ThemeRegistry::resolvedForBuild($slug, ThemeRegistry::activeDefault($slug)) ?? [];
            $logo = (array) ((($resolved['shell'] ?? [])['logo']) ?? []);
        } catch (\Throwable) {
        }
        $name = trim((string) ($logo['text'] ?? ''));
        if ($name === '') {
            $name = (string) ($this->project['name'] ?? ucfirst($slug));
        }

        return ['name' => $name, 'image' => trim((string) ($logo['image'] ?? ''))];
    }

    /** @return array<int, array<string, mixed>> flat component catalogue for the palette */
    private function componentCatalogue(): array
    {
        try {
            $registry = ComponentRegistry::default();
            return array_values($registry->toArray());
        } catch (\Throwable $e) {
            return [];
        }
    }

    public function load(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);

        $tables = $this->tables($slug);
        $fieldsByTable = $this->fieldsByTable($slug, $tables);
        $availablePageTypes = $this->availablePageTypes($slug, $tables);
        $pagesState = ModuleState::load($slug, 'pages');
        $designerState = $this->normaliseDesignerState($slug, ModuleState::load($slug, 'designer'), $tables, $fieldsByTable, $pagesState, $availablePageTypes);
        // Referenceable system tables (users/groups) + project VIEWS for the pickers.
        $lookupTables = $this->withProjectViews($this->withReferenceableTables($tables, $fieldsByTable), $fieldsByTable);

        echo json_encode([
            'success' => true,
            'tables' => $tables,
            'lookupTables' => $lookupTables,
            'fieldsByTable' => $fieldsByTable,
            'availablePageTypes' => $availablePageTypes,
            'pagesState' => $pagesState,
            'pagesConfig' => $pagesState['byTable'] ?? [],
            'designer' => $designerState,
        ]);
        exit;
    }

    public function save(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);

        $input = json_decode(file_get_contents('php://input'), true) ?? [];
        $state = $input['designer'] ?? null;

        // ── Incremental save ──────────────────────────────────────────────
        // The client sends only the page slots it changed, plus the token it
        // received for the state those changes were built on. A stale token
        // means the stored design moved on (another tab, another user), so we
        // refuse and ask for the whole document rather than merge blindly.
        if (!is_array($state) && isset($input['ops']) && is_array($input['ops'])) {
            $stored = ModuleState::load($slug, 'designer');
            if (!is_array($stored) || $stored === []) {
                echo json_encode(['success' => false, 'needFull' => true, 'message' => 'No stored design yet.']);
                exit;
            }
            if ((string) ($input['baseToken'] ?? '') !== self::stateToken($stored)) {
                echo json_encode(['success' => false, 'needFull' => true, 'message' => 'Design changed elsewhere.']);
                exit;
            }
            foreach ($input['ops'] as $op) {
                if (!is_array($op) || !isset($op['path']) || !is_array($op['path'])) {
                    http_response_code(400);
                    echo json_encode(['success' => false, 'message' => 'Malformed change.']);
                    exit;
                }
                if (!self::applyOp($stored, $op['path'], $op['value'] ?? null)) {
                    // unknown root or unsafe key → make the client resend in full
                    echo json_encode(['success' => false, 'needFull' => true, 'message' => 'Unsupported change path.']);
                    exit;
                }
            }
            $stored['version']   = $stored['version'] ?? '1.0';
            $stored['updatedAt'] = date('c');
            $stored['updatedBy'] = $_SESSION['user']['username'] ?? 'user';
            ModuleState::save($slug, 'designer', $stored);
            $this->syncButtonsToEvents($slug, $stored);
            echo json_encode([
                'success'    => true,
                'message'    => 'Designer layout saved.',
                'stateToken' => self::stateToken($stored),
                'applied'    => count($input['ops']),
            ]);
            exit;
        }

        if (!is_array($state)) {
            http_response_code(400);
            echo json_encode(['success' => false, 'message' => 'Designer state is required.']);
            exit;
        }

        $state['version'] = $state['version'] ?? '1.0';
        $state['updatedAt'] = date('c');
        $state['updatedBy'] = $_SESSION['user']['username'] ?? 'user';

        ModuleState::save($slug, 'designer', $state);
        $this->syncButtonsToEvents($slug, $state);
        echo json_encode([
            'success'    => true,
            'message'    => 'Designer layout saved.',
            'stateToken' => self::stateToken($state),
        ]);
        exit;
    }

    /**
     * Mirror every Custom Button's 3-way handlers into the isolated `buttons`
     * subtree of the events store, so button logic authored in the Designer is
     * recorded as a first-class event and shows up in the Events module. Guarded
     * so a bridge hiccup can never fail the designer save.
     */
    private function syncButtonsToEvents(string $slug, array $designer): void
    {
        try {
            $events  = ModuleState::load($slug, 'events') ?: [];
            $updated = CustomButtonEvents::mirrorIntoEvents($designer, $events);
            if ($updated !== $events) {
                ModuleState::save($slug, 'events', $updated);
            }
        } catch (\Throwable $e) {
            // non-fatal — the design is already saved
        }
    }

    /**
     * Create a new BLANK custom page under a table. Custom pages are stored as
     * `custom_<slug>` page-types in the designer state so they slot into the exact
     * same one-page-per-type model every other page uses — selection, the
     * per-component inspector and the build renderer all work unchanged. Several
     * can coexist per table (a mobile-tailored list, a report, a chart screen…),
     * each shown as its own ribbon tab. The page starts empty; the user drops
     * components from the palette into the responsive grid.
     */
    public function addPage(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);

        $input = json_decode(file_get_contents('php://input'), true) ?? [];
        $label = trim((string) ($input['label'] ?? '')) ?: 'New Page';
        // A "Standalone" custom page is not bound to a single table (static pages,
        // multi-table dashboards…). It is stored under the reserved sentinel table
        // key and built as an auth-gated page whose components carry their own data.
        $standalone = !empty($input['standalone']) || ($input['table'] ?? '') === self::STANDALONE_TABLE;
        $table = $standalone ? self::STANDALONE_TABLE : trim((string) ($input['table'] ?? ''));

        $tables = $this->tables($slug);
        $tableNames = array_column($tables, 'name');
        if (!$standalone && ($table === '' || !in_array($table, $tableNames, true))) {
            http_response_code(400);
            echo json_encode(['success' => false, 'message' => 'A valid table is required (or choose Standalone).']);
            exit;
        }

        $fieldsByTable = $this->fieldsByTable($slug, $tables);
        $state = ModuleState::load($slug, 'designer') ?: [];
        $state['pages'] = is_array($state['pages'] ?? null) ? $state['pages'] : [];
        $state['pages'][$table] = is_array($state['pages'][$table] ?? null) ? $state['pages'][$table] : [];

        // A unique custom_<slug> key derived from the label.
        $base = trim((string) preg_replace('/[^a-z0-9]+/', '_', strtolower($label)), '_') ?: 'page';
        $pt = 'custom_' . $base;
        for ($i = 2; isset($state['pages'][$table][$pt]); $i++) {
            $pt = 'custom_' . $base . '_' . $i;
        }

        $page = $this->defaultPage($table, $pt, $standalone ? [] : ($fieldsByTable[$table] ?? []));
        $page['title'] = $label;   // the user's name (not the auto title-case)
        $page['type']  = 'custom'; // generic type so any type-based chrome treats it as custom
        $page['name']  = $pt;
        if ($standalone) $page['standalone'] = true;
        $state['pages'][$table][$pt] = ['defaultPage' => $pt, 'pages' => [$pt => $page]];

        $state['version']   = $state['version'] ?? '1.0';
        $state['updatedAt'] = date('c');
        $state['updatedBy'] = $_SESSION['user']['username'] ?? 'user';
        ModuleState::save($slug, 'designer', $state);

        // Auto-add a navigation menu item so the page is reachable once built.
        // Standalone pages are reachable directly (generated/__standalone__/<pt>.php)
        // and the user links them where they want, so no auto menu item is added.
        $menu = $standalone ? null : $this->addPageMenuItem($slug, $table, $pt, $label);

        echo json_encode([
            'success'            => true,
            'table'              => $table,
            'pageType'           => $pt,
            'label'              => $label,
            'page'               => $page,
            'availablePageTypes' => $this->availablePageTypes($slug, $tables),
            'menu'               => $menu,
        ]);
        exit;
    }

    /**
     * Auto-add a navigation menu item that points to a newly-created custom page
     * so it is reachable in the generated app the moment it is built. The nav menu
     * is owned by the Style module (themes state); we append a page-link item and
     * re-normalise through MenuModel. Idempotent — a link to the same page is never
     * duplicated (safe if the same page is re-created after a delete).
     *
     * @return array<string,mixed> the normalised menu, for the SPA to refresh its sidebar preview
     */
    private function addPageMenuItem(string $slug, string $table, string $pageType, string $label): array
    {
        $themes = ModuleState::load($slug, 'themes') ?: [];
        $menu = is_array($themes['menu'] ?? null) ? $themes['menu'] : ['items' => []];
        $menu['items'] = is_array($menu['items'] ?? null) ? array_values($menu['items']) : [];

        foreach ($menu['items'] as $it) {
            $lk = (array) ($it['link'] ?? []);
            if (($lk['kind'] ?? '') === 'page' && ($lk['page'] ?? '') === $table && ($lk['subpage'] ?? '') === $pageType) {
                return \PpPhp\Core\Theme\MenuModel::normalize($menu); // already linked
            }
        }

        $menu['items'][] = [
            'id'           => 'mi_' . bin2hex(random_bytes(4)),
            'type'         => 'hyperlink',
            'label'        => $label,
            'icon'         => 'bi-file-earmark-text',
            'link'         => ['kind' => 'page', 'page' => $table, 'subpage' => $pageType, 'url' => '', 'params' => '', 'target' => 'self'],
            'roles'        => [],
            'dividerStyle' => 'line',
            'children'     => [],
        ];
        $themes['menu'] = \PpPhp\Core\Theme\MenuModel::normalize($menu);
        ModuleState::save($slug, 'themes', $themes);
        return $themes['menu'];
    }

    /** Rename a custom page: updates its title and its nav menu item(s). The
     *  `custom_<slug>` key (and therefore its URL) is kept stable, so existing
     *  links, bookmarks and the generated file don't break. */
    public function renamePage(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);

        $input = json_decode(file_get_contents('php://input'), true) ?? [];
        $table = trim((string) ($input['table'] ?? ''));
        $pt    = trim((string) ($input['pageType'] ?? ''));
        $label = trim((string) ($input['label'] ?? ''));
        if ($table === '' || !str_starts_with($pt, 'custom_') || $label === '') {
            http_response_code(400);
            echo json_encode(['success' => false, 'message' => 'A custom page and a new name are required.']);
            exit;
        }

        $state = ModuleState::load($slug, 'designer') ?: [];
        if (!isset($state['pages'][$table][$pt]) || !is_array($state['pages'][$table][$pt])) {
            http_response_code(404);
            echo json_encode(['success' => false, 'message' => 'Page not found.']);
            exit;
        }
        foreach (array_keys((array) ($state['pages'][$table][$pt]['pages'] ?? [])) as $name) {
            $state['pages'][$table][$pt]['pages'][$name]['title'] = $label;
        }
        $state['updatedAt'] = date('c');
        ModuleState::save($slug, 'designer', $state);

        $menu = $this->applyMenuMutation($slug, $table, $pt, $label);
        echo json_encode(['success' => true, 'table' => $table, 'pageType' => $pt, 'label' => $label, 'menu' => $menu]);
        exit;
    }

    /**
     * Restore ONE page (typically the dashboard) from the project's factory
     * snapshot (factory/config/designer.json) into the live design — surgical, so
     * other pages' edits are untouched, and no rebuild (the user rebuilds via Build).
     * Backs up the current designer.json first. Owner-gated via initProject.
     */
    public function restoreFactoryPage(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);

        $input = json_decode(file_get_contents('php://input'), true) ?? [];
        $table = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($input['table'] ?? ''));
        $pageType = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($input['pageType'] ?? 'dashboard'));
        if ($table === '' || $pageType === '') {
            echo json_encode(['success' => false, 'message' => 'Missing table or page type.']);
            exit;
        }

        $projDir = \PpPhp\Core\ProjectLocator::dir($slug);
        $factoryFile = $projDir . '/factory/config/designer.json';
        if (!is_file($factoryFile)) {
            echo json_encode(['success' => false, 'message' => 'This project has no factory snapshot to restore from.']);
            exit;
        }
        $factory = json_decode((string) file_get_contents($factoryFile), true);
        $factoryPage = $factory['pages'][$table][$pageType] ?? null;
        if (!is_array($factoryPage)) {
            echo json_encode(['success' => false, 'message' => "The factory snapshot has no {$pageType} page for {$table}."]);
            exit;
        }

        $state = ModuleState::load($slug, 'designer');
        if (!is_array($state) || $state === []) $state = ['version' => 1, 'pages' => []];
        // Back up the current design once so a mistaken reset is recoverable.
        $configFile = $projDir . '/config/designer.json';
        if (is_file($configFile)) {
            @copy($configFile, $projDir . '/config/_pre_dashreset_' . date('Ymd_His') . '.json');
        }
        $state['pages'][$table][$pageType] = $factoryPage;
        $state['updatedAt'] = date('c');
        ModuleState::save($slug, 'designer', $state);

        echo json_encode(['success' => true, 'message' => 'Dashboard restored to factory default. Reloading…']);
        exit;
    }

    /** Delete a custom page: removes it from the designer state and its nav menu
     *  item(s). The generated file is cleaned up on the next build. Only custom
     *  pages can be deleted — the built-in page types are never removable here. */
    public function deletePage(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);

        $input = json_decode(file_get_contents('php://input'), true) ?? [];
        $table = trim((string) ($input['table'] ?? ''));
        $pt    = trim((string) ($input['pageType'] ?? ''));
        if ($table === '' || !str_starts_with($pt, 'custom_')) {
            http_response_code(400);
            echo json_encode(['success' => false, 'message' => 'A custom page is required.']);
            exit;
        }

        $state = ModuleState::load($slug, 'designer') ?: [];
        if (isset($state['pages'][$table][$pt])) {
            unset($state['pages'][$table][$pt]);
            $state['updatedAt'] = date('c');
            ModuleState::save($slug, 'designer', $state);
        }
        $menu = $this->applyMenuMutation($slug, $table, $pt, null);
        $tables = $this->tables($slug);
        echo json_encode([
            'success'            => true,
            'table'              => $table,
            'pageType'           => $pt,
            'menu'               => $menu,
            'availablePageTypes' => $this->availablePageTypes($slug, $tables),
        ]);
        exit;
    }

    /** Recursively update or remove the nav menu items that link to a custom page.
     *  $newLabel === null removes matching items; otherwise it renames them.
     *
     *  @return array<string,mixed> the re-normalised menu */
    private function applyMenuMutation(string $slug, string $table, string $pageType, ?string $newLabel): array
    {
        $themes = ModuleState::load($slug, 'themes') ?: [];
        $menu = is_array($themes['menu'] ?? null) ? $themes['menu'] : ['items' => []];
        $walk = function (array $items) use (&$walk, $table, $pageType, $newLabel): array {
            $out = [];
            foreach ($items as $it) {
                $lk = (array) ($it['link'] ?? []);
                $match = ($lk['kind'] ?? '') === 'page' && ($lk['page'] ?? '') === $table && ($lk['subpage'] ?? '') === $pageType;
                if ($match && $newLabel === null) {
                    continue; // delete
                }
                if ($match) {
                    $it['label'] = $newLabel; // rename
                }
                if (is_array($it['children'] ?? null)) {
                    $it['children'] = $walk($it['children']);
                }
                $out[] = $it;
            }
            return $out;
        };
        $menu['items'] = $walk(is_array($menu['items'] ?? null) ? $menu['items'] : []);
        $themes['menu'] = \PpPhp\Core\Theme\MenuModel::normalize($menu);
        ModuleState::save($slug, 'themes', $themes);
        return $themes['menu'];
    }

    /**
     * Fingerprint of a stored design, ignoring the fields every save rewrites.
     * The client treats it as opaque: it stores whatever we return and hands it
     * back with the next delta, so key order can never matter.
     */
    private static function stateToken(array $state): string
    {
        unset($state['updatedAt'], $state['updatedBy']);
        return substr(md5((string) json_encode($state)), 0, 16);
    }

    /**
     * Apply one delta op to $state. Only the page containers may be written,
     * and only at slot depth, so a crafted path cannot reach anything else.
     * `null` deletes the slot. Returns false when the path is not allowed.
     */
    private static function applyOp(array &$state, array $path, mixed $value): bool
    {
        $depths = ['pages' => 3, 'templates' => 2, 'common' => 2];   // incl. the root
        $root = (string) ($path[0] ?? '');
        if (!isset($depths[$root]) || count($path) < 2 || count($path) > $depths[$root]) {
            return false;
        }
        foreach ($path as $seg) {
            // plain identifiers only — no traversal, no numeric-key surprises
            if (!is_string($seg) || $seg === '' || !preg_match('/^[A-Za-z0-9_.-]+$/', $seg)) return false;
        }
        if ($value !== null && !is_array($value)) return false;

        $ref = &$state;
        $last = count($path) - 1;
        foreach ($path as $i => $seg) {
            if ($i === $last) {
                if ($value === null) { unset($ref[$seg]); } else { $ref[$seg] = $value; }
                break;
            }
            if (!isset($ref[$seg]) || !is_array($ref[$seg])) $ref[$seg] = [];
            $ref = &$ref[$seg];
        }
        return true;
    }

    public function template(string $slug): void
    {
        header('Content-Type: application/json');
        $this->initProject($slug);

        $input = json_decode(file_get_contents('php://input'), true) ?? [];
        $table = (string)($input['table'] ?? '');
        $pageType = (string)($input['pageType'] ?? 'list');
        $variant = preg_replace('/[^a-z0-9_-]/', '', strtolower((string)($input['variant'] ?? '')));
        $fields = $this->fieldsByTable($slug, [['name' => $table, 'type' => 'TABLE']])[$table] ?? [];

        if ($table === '' && !in_array($pageType, ['menu', 'login', 'profile', 'register', 'forgot_password', 'change_password', 'reset_password', 'change_password_success', 'reset_password_success', 'central_dashboard', 'calendar', 'admin_area', 'two_factor'], true)) {
            http_response_code(400);
            echo json_encode(['success' => false, 'message' => 'Table is required for this template.']);
            exit;
        }

        $brand = $this->appBrand($slug);
        echo json_encode([
            'success' => true,
            'page' => $this->defaultPage($table, $pageType, $fields, [
                'variant' => $variant,
                'brand' => $brand['name'],
                'brandImage' => $brand['image'],
                // The Menu default is composed from the project's real tables.
                'tables' => $this->tables($slug),
                'appName' => $brand['name'],
            ]),
        ]);
        exit;
    }

    private function initProject(string $slug): void
    {
        Security::RequireAuth();
        DB::Init();

        $project = DB::Select('projects', ['slug' => $slug, 'created_by' => Security::CurrentUserData()['id']]);
        if (empty($project) && Security::IsAdmin()) {
            $project = DB::Select('projects', ['slug' => $slug]);
        }
        if (empty($project)) {
            http_response_code(404);
            echo 'Project not found.';
            exit;
        }

        $this->project = $project[0];
        // Standalone (database-free) projects have no project DB — the Designer
        // is their home (standalone + common pages). Skip the project connection;
        // tables() returns [] so the palette/tables list is simply empty.
        $this->standalone = \PpPhp\Core\ProjectDatabaseProfile::isStandaloneSlug($slug);
        if ($this->standalone) {
            return;
        }
        DB::Init($slug);
        DB::SetConnection($slug);
    }

    private function tables(string $slug): array
    {
        if (!empty($this->standalone)) {
            return [];
        }
        $tablesState = ModuleState::load($slug, 'tables');
        $selected = $tablesState['selectedTables'] ?? [];
        $tables = [];

        if (!empty($selected)) {
            foreach ($selected as $name) {
                $tables[] = ['name' => $name, 'type' => 'TABLE'];
            }
            return $tables;
        }

        try {
            $rows = DB::Query('SHOW FULL TABLES')->fetchAll(\PDO::FETCH_NUM);
            foreach ($rows as $row) {
                $tables[] = ['name' => $row[0], 'type' => ($row[1] ?? '') === 'VIEW' ? 'VIEW' : 'TABLE'];
            }
            usort($tables, fn($a, $b) => $a['name'] <=> $b['name']);
        } catch (\Throwable $e) {
            $tables = [];
        }

        return $tables;
    }

    /**
     * Project tables PLUS a curated set of system tables that are valid lookup /
     * foreign-key targets (users, groups) — so the Designer's table pickers can
     * reference them even though they aren't project tables. Their columns are
     * added to $fieldsByTable (a name→columns lookup; safe to extend) so the
     * key/display column pickers populate. Tables not present in this project's
     * database are skipped.
     *
     * @param array<int, array<string, mixed>> $tables
     * @param array<string, mixed>             $fieldsByTable  (mutated by reference)
     * @return array<int, array<string, mixed>>  table list including the ref tables
     */
    private function withReferenceableTables(array $tables, array &$fieldsByTable): array
    {
        $have = [];
        foreach ($tables as $t) {
            $have[(string) ($t['name'] ?? '')] = true;
        }
        $out = $tables;
        foreach (\PpPhp\Core\SystemTables::referenceable() as $name) {
            if (isset($have[$name])) {
                continue;   // already a project table
            }
            // Don't offer sensitive auth columns as lookup key/display targets.
            $hidden = ['password', 'totp_secret', 'twofa_methods', 'remember_token', 'password_hash'];
            $cols = [];
            try {
                $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
                foreach ((\PpPhp\Core\DB::FetchAll("SHOW COLUMNS FROM `{$safe}`") ?: []) as $col) {
                    $cn = (string) $col['Field'];
                    if (in_array(strtolower($cn), $hidden, true)) {
                        continue;
                    }
                    $cols[] = ['name' => $cn, 'type' => (string) ($col['Type'] ?? '')];
                }
            } catch (\Throwable) {
                continue;   // table not in this database — skip silently
            }
            if ($cols === []) {
                continue;
            }
            $fieldsByTable[$name] = $cols;
            $out[] = ['name' => $name, 'type' => 'TABLE', 'system' => true];
        }
        return $out;
    }

    /**
     * Add the project database's VIEWS (e.g. a reporting view like `case_report_v`)
     * to the table-picker list and their columns to $fieldsByTable, so dashboard
     * components (charts / cause list / deadline tracker / calendar) can be BOUND to
     * a view and show it selected in the inspector — instead of looking hardcoded
     * because the picker had no matching option. Views feed the pickers only; they
     * stay out of the main Tables sidebar (which lists CRUD tables). Fails silently
     * on databases whose driver doesn't speak `SHOW FULL TABLES` (non-MySQL).
     *
     * @param array<int, array<string, mixed>> $tables
     * @param array<string, mixed>             $fieldsByTable  (mutated by reference)
     * @return array<int, array<string, mixed>>
     */
    private function withProjectViews(array $tables, array &$fieldsByTable): array
    {
        if (!empty($this->standalone)) {
            return $tables;
        }
        $have = [];
        foreach ($tables as $t) $have[(string) ($t['name'] ?? '')] = true;
        $out = $tables;
        try {
            $rows = DB::FetchAll("SHOW FULL TABLES WHERE Table_type = 'VIEW'") ?: [];
        } catch (\Throwable) {
            return $out;   // driver has no SHOW FULL TABLES (Postgres/SQLite/…) — skip
        }
        foreach ($rows as $row) {
            // SHOW FULL TABLES returns [Tables_in_<db> => name, Table_type => 'VIEW'].
            $name = '';
            foreach ($row as $k => $v) { if (stripos((string) $k, 'Tables_in_') === 0) { $name = (string) $v; break; } }
            if ($name === '' || isset($have[$name])) continue;
            $cols = [];
            try {
                $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
                foreach ((DB::FetchAll("SHOW COLUMNS FROM `{$safe}`") ?: []) as $col) {
                    $cols[] = ['name' => (string) $col['Field'], 'type' => (string) ($col['Type'] ?? '')];
                }
            } catch (\Throwable) {
                continue;
            }
            if ($cols === []) continue;
            $fieldsByTable[$name] = $cols;
            $out[] = ['name' => $name, 'type' => 'VIEW', 'view' => true];
        }
        return $out;
    }

    private function fieldsByTable(string $slug, array $tables): array
    {
        $fieldsState = ModuleState::load($slug, 'fields');
        $byTable = $fieldsState['byTable'] ?? [];
        $result = [];

        foreach ($tables as $table) {
            $name = $table['name'];
            $tableConfig = $byTable[$name] ?? [];
            $device = $tableConfig['activeDevice'] ?? 'desktop';
            $configured = $tableConfig['devices'][$device] ?? $tableConfig['devices']['desktop'] ?? $tableConfig['fields'] ?? $tableConfig;
            $fields = [];

            // Real column types from the live schema — the fields state only
            // stores page flags, and the canvas needs types for its smart
            // default controls (dates → DatePicker, numbers → Number, …)
            $dbTypes = [];
            try {
                $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
                foreach ((DB::FetchAll("SHOW COLUMNS FROM `{$safe}`") ?: []) as $col) {
                    $dbTypes[(string) $col['Field']] = (string) $col['Type'];
                }
            } catch (\Throwable) {
                // schema unavailable — types fall back to stored values
            }

            if (is_array($configured) && !empty($configured)) {
                foreach ($configured as $key => $field) {
                    if (is_array($field)) {
                        $fieldName = $field['name'] ?? $field['field'] ?? (is_string($key) ? $key : 'field_' . $key);
                        if (in_array($fieldName, ['activeDevice', 'devices', 'viewAs', 'editAs', 'searchSettings'], true)) {
                            continue;
                        }
                        $fields[] = [
                            'name' => $fieldName,
                            'label' => $field['label'] ?? ucwords(str_replace('_', ' ', $fieldName)),
                            'type' => $field['type'] ?? $field['dbType'] ?? $dbTypes[$fieldName] ?? 'text',
                            'enabled' => $field['enabled'] ?? true,
                            'pages' => array_intersect_key($field, array_flip(['list','add','edit','view','print','export','import','cards','search','dashboard'])),
                        ];
                    } elseif (is_string($field)) {
                        $fields[] = ['name' => $field, 'label' => ucwords(str_replace('_', ' ', $field)), 'type' => 'text', 'enabled' => true, 'pages' => []];
                    } elseif (is_bool($field) && is_string($key)) {
                        $fields[] = ['name' => $key, 'label' => ucwords(str_replace('_', ' ', $key)), 'type' => 'text', 'enabled' => true, 'pages' => []];
                    }
                }
            }

            if (empty($fields)) {
                try {
                    $rows = DB::Query('DESCRIBE `' . str_replace('`', '``', $name) . '`')->fetchAll(\PDO::FETCH_ASSOC);
                    foreach ($rows as $row) {
                        $fields[] = [
                            'name' => $row['Field'],
                            'label' => ucwords(str_replace('_', ' ', $row['Field'])),
                            'type' => $row['Type'] ?? 'text',
                            'enabled' => true,
                            'pages' => [],
                        ];
                    }
                } catch (\Throwable $e) {
                    $fields = [];
                }
            }

            // attach each field's assigned view/edit/filter controls (Fields module → viewAs/editAs/filterAs)
            $viewAs = is_array($tableConfig['viewAs'] ?? null) ? $tableConfig['viewAs'] : [];
            $editAs = is_array($tableConfig['editAs'] ?? null) ? $tableConfig['editAs'] : [];
            $filterAs = is_array($tableConfig['filterAs'] ?? null) ? $tableConfig['filterAs'] : [];
            $validationAs = is_array($tableConfig['validationAs'] ?? null) ? $tableConfig['validationAs'] : [];
            $pageAsAll = is_array($tableConfig['pageAs'] ?? null) ? $tableConfig['pageAs'] : [];
            // Fields module → Search Settings (per field). 'filterPanel' drives which
            // fields appear in the list-page Filter (canvas preview + generated app).
            $searchSettings = is_array($tableConfig['searchSettings'] ?? null) ? $tableConfig['searchSettings'] : [];
            foreach ($fields as &$f) {
                $va = $viewAs[$f['name']] ?? [];
                $ea = $editAs[$f['name']] ?? [];
                $fa = $filterAs[$f['name']] ?? [];
                $f['viewControl'] = $va['type'] ?? '';
                $f['viewSettings'] = is_array($va['settings'] ?? null) ? $va['settings'] : [];
                $f['editControl'] = $ea['type'] ?? '';
                $f['editSettings'] = is_array($ea['settings'] ?? null) ? $ea['settings'] : [];
                $f['filterControl'] = $fa['type'] ?? '';
                $f['filterSettings'] = is_array($fa['settings'] ?? null) ? $fa['settings'] : [];
                $f['validations'] = is_array($validationAs[$f['name']] ?? null) ? array_values($validationAs[$f['name']]) : [];
                $f['pageAs'] = is_array($pageAsAll[$f['name']] ?? null) ? $pageAsAll[$f['name']] : (object) [];
                $ss = is_array($searchSettings[$f['name']] ?? null) ? $searchSettings[$f['name']] : [];
                $f['filterPanel'] = (bool) ($ss['filterPanel'] ?? false);
                $f['searchable'] = (bool) ($ss['searchable'] ?? true);
            }
            unset($f);

            $result[$name] = $fields;
        }

        return $result;
    }

    private function availablePageTypes(string $slug, array $tables): array
    {
        $fieldsState = ModuleState::load($slug, 'fields');
        $byTable = $fieldsState['byTable'] ?? [];
        // Tab order in the Designer ribbon. `cards` is a first-class page like the
        // rest (its own canvas + card-grid component), rendered as the Cards view
        // on the generated list page.
        // Dashboard sits right after the core CRUD group so it stays visible in the
        // ribbon (a data-heavy table's dashboard is a hero page, not something to
        // hunt for behind the tab overflow).
        $valid = ['list','add','edit','view','dashboard','print','cards','import','export','search','custom'];
        // Page-level types that are NOT per-field toggles (the Fields module never
        // emits them), so they must be surfaced from the Pages module directly.
        $pageLevel = ['cards'];
        $pagesState = ModuleState::load($slug, 'pages');
        $result = [];

        foreach ($tables as $table) {
            $tableName = $table['name'];
            $cfg = $byTable[$tableName] ?? [];
            $device = $cfg['activeDevice'] ?? 'desktop';
            $deviceFields = $cfg['devices'][$device] ?? $cfg['devices']['desktop'] ?? [];
            $enabled = [];

            foreach ($deviceFields as $fieldPages) {
                if (!is_array($fieldPages)) continue;
                foreach ($fieldPages as $pageType => $isEnabled) {
                    if ($isEnabled && in_array($pageType, $valid, true)) {
                        $enabled[$pageType] = true;
                    }
                }
            }

            $pagesTypes = (array) ($pagesState['byTable'][$tableName]['pageTypes'] ?? []);

            // Always surface page-level types (Cards) enabled in the Pages module,
            // regardless of per-field flags.
            foreach ($pagesTypes as $pageType) {
                if (in_array($pageType, $pageLevel, true) && in_array($pageType, $valid, true)) {
                    $enabled[$pageType] = true;
                }
            }

            if (empty($enabled)) {
                foreach ($pagesTypes as $pageType) {
                    if (in_array($pageType, $valid, true)) $enabled[$pageType] = true;
                }
            }

            if (empty($enabled)) {
                foreach (['list','add','edit','view'] as $pageType) $enabled[$pageType] = true;
            }

            $ordered = array_values(array_filter($valid, fn($p) => isset($enabled[$p])));
            $result[$tableName] = $ordered;
        }

        // User-added custom pages live in the designer state as `custom_<slug>`
        // page-types (created via addPage). Surface them after the standard set so
        // they render as ribbon tabs and survive reloads — the state is the source
        // of truth, so nothing here couples to the Pages module.
        $designerPages = (array) (ModuleState::load($slug, 'designer')['pages'] ?? []);
        foreach ($result as $tableName => &$types) {
            foreach (array_keys((array) ($designerPages[$tableName] ?? [])) as $pt) {
                if (is_string($pt) && str_starts_with($pt, 'custom_') && !in_array($pt, $types, true)) {
                    $types[] = $pt;
                }
            }
        }
        unset($types);

        // Standalone (table-less) custom pages live under the sentinel key, exposed
        // as their own "table" so the sidebar + ribbon can list and open them.
        $stand = array_values(array_filter(
            array_keys((array) ($designerPages[self::STANDALONE_TABLE] ?? [])),
            static fn ($pt) => is_string($pt) && str_starts_with($pt, 'custom_')
        ));
        if ($stand) $result[self::STANDALONE_TABLE] = $stand;

        return $result;
    }

    private function normaliseDesignerState(string $slug, array $state, array $tables, array $fieldsByTable, array $pagesState, array $availablePageTypes = []): array
    {
        if (!isset($state['version'])) {
            $state = [
                'version' => '1.0',
                'active' => ['scope' => 'table', 'table' => $tables[0]['name'] ?? '', 'pageType' => 'list', 'pageName' => 'list'],
                'pages' => [],
                'common' => [],
            ];
        }

        foreach ($tables as $table) {
            $tableName = $table['name'];
            $enabledPages = $availablePageTypes[$tableName] ?? $pagesState['byTable'][$tableName]['pageTypes'] ?? ['list', 'add', 'edit', 'view'];
            if (!is_array($enabledPages) || empty($enabledPages)) {
                $enabledPages = ['list'];
            }
            foreach ($enabledPages as $pageType) {
                if (!isset($state['pages'][$tableName][$pageType]['pages'][$pageType])) {
                    $state['pages'][$tableName][$pageType]['defaultPage'] = $pageType;
                    $state['pages'][$tableName][$pageType]['pages'][$pageType] = $this->defaultPage($tableName, $pageType, $fieldsByTable[$tableName] ?? []);
                }
            }

            // The Pages module's functionality flags drive the grid's row
            // actions — keep every list-page design in sync so the canvas
            // matches what the Build will generate.
            $fnFlags = (array) ($pagesState['byTable'][$tableName]['functionality'] ?? []);
            foreach (($state['pages'][$tableName]['list']['pages'] ?? []) as $pName => $pg) {
                if (is_array($pg)) {
                    $state['pages'][$tableName]['list']['pages'][$pName] = $this->syncGridActions($pg, $fnFlags);
                }
            }
        }

        $brand = $this->appBrand($slug);
        // `tables` matters for the Menu default — without it the seeding below
        // produced the "no modules yet" variant even on a project with tables,
        // and then SAVED it, so the canvas showed an empty landing page.
        $brandOptions = [
            'brand' => $brand['name'],
            'brandImage' => $brand['image'],
            'tables' => $tables,
            'appName' => $brand['name'],
            // Seed the Calendar common page from any existing ModuleState config
            // (the Tables "Create Calendar" modal) so the Designer starts as a
            // faithful mirror of the current settings rather than bare defaults.
            'calendar' => (array) ModuleState::load($slug, 'calendar'),
        ];
        foreach (['menu', 'login', 'profile', 'register', 'forgot_password', 'change_password', 'reset_password', 'change_password_success', 'reset_password_success', 'central_dashboard', 'calendar', 'admin_area', 'two_factor'] as $commonPage) {
            if (!isset($state['common'][$commonPage])) {
                $state['common'][$commonPage] = $this->defaultPage('', $commonPage, [], $brandOptions);
            }
        }

        return $state;
    }

    /** Append/remove grid row actions according to the Pages functionality flags. */
    private function syncGridActions(array $page, array $fn): array
    {
        $walk = function (array $components) use (&$walk, $fn): array {
            foreach ($components as &$c) {
                if (!is_array($c)) continue;
                if (in_array($c['type'] ?? '', ['table-grid', 'grid'], true)) {
                    $actions = array_values((array) ($c['props']['actions'] ?? ['view', 'edit', 'delete']));
                    $sync = [
                        'inline-edit' => !empty($fn['inlineEdit']),
                        'copy'        => !empty($fn['copyRecords']),
                    ];
                    foreach ($sync as $key => $enabled) {
                        $has = false;
                        foreach ($actions as $action) {
                            if ($this->gridActionKey($action) === $key) {
                                $has = true;
                                break;
                            }
                        }
                        if ($enabled && !$has) {
                            // insert before 'delete' so the danger action stays last
                            $delPos = $this->gridActionPosition($actions, 'delete');
                            $newAction = $this->usesStructuredGridActions($actions)
                                ? $this->structuredGridAction($key)
                                : $key;
                            array_splice($actions, $delPos ?? count($actions), 0, [$newAction]);
                        } elseif (!$enabled && $has) {
                            $actions = array_values(array_filter(
                                $actions,
                                fn (mixed $action): bool => $this->gridActionKey($action) !== $key
                            ));
                        }
                    }
                    $c['props']['actions'] = array_values($actions);
                }
                if (is_array($c['children'] ?? null)) $c['children'] = $walk($c['children']);
            }
            return $components;
        };
        $page['components'] = $walk((array) ($page['components'] ?? []));
        return $page;
    }

    /** Return the stable action key without flattening rich Designer action metadata. */
    private function gridActionKey(mixed $action): ?string
    {
        if (is_string($action) || is_int($action)) {
            $key = trim((string) $action);
            return $key !== '' ? $key : null;
        }

        if (!is_array($action)) {
            return null;
        }

        foreach (['type', 'action', 'id', 'name'] as $property) {
            $value = $action[$property] ?? null;
            if (is_string($value) || is_int($value)) {
                $key = trim((string) $value);
                if ($key !== '') {
                    return $key;
                }
            }
        }

        return null;
    }

    private function gridActionPosition(array $actions, string $key): ?int
    {
        foreach ($actions as $position => $action) {
            if ($this->gridActionKey($action) === $key) {
                return $position;
            }
        }

        return null;
    }

    private function usesStructuredGridActions(array $actions): bool
    {
        foreach ($actions as $action) {
            if (is_array($action) && $this->gridActionKey($action) !== null) {
                return true;
            }
        }

        return false;
    }

    /** Build the same rich shape emitted by ListPageTemplate for synchronized actions. */
    private function structuredGridAction(string $key): array
    {
        $definitions = [
            'inline-edit' => ['id' => 'inline_edit_btn', 'text' => 'Inline Edit', 'icon' => 'pencil-square'],
            'copy' => ['id' => 'copy_btn', 'text' => 'Copy', 'icon' => 'copy'],
        ];
        $definition = $definitions[$key] ?? [
            'id' => str_replace('-', '_', $key) . '_btn',
            'text' => ucwords(str_replace(['-', '_'], ' ', $key)),
            'icon' => 'circle',
        ];

        return [
            'id' => $definition['id'],
            'name' => $definition['id'],
            'type' => $key,
            'text' => $definition['text'],
            'icon' => $definition['icon'],
            'eventContext' => '$button->getCurrentRecord()',
            'style' => [],
            'events' => ['context' => '$button->getCurrentRecord()'],
        ];
    }

    private function defaultPage(string $table, string $pageType, array $fields, array $options = []): array
    {
        // The Menu (landing) page has its own default, shared verbatim with the
        // build. Returning it here is what makes the Designer canvas and the
        // generated page identical — one tree, two consumers.
        if ($table === '' && $pageType === 'menu') {
            return \PpPhp\Core\Designer\Defaults\MenuPageDefault::page(
                (array) ($options['tables'] ?? []),
                ['appName' => (string) ($options['appName'] ?? ''), 'auth' => true]
            );
        }

        // The Admin Area (dynamic permissions) is a smart page: one admin-area
        // component whose props are the only editable chrome. The build emits the
        // fully-working console and honours these settings.
        if ($table === '' && $pageType === 'admin_area') {
            return [
                'id'         => 'common_admin_area',
                'name'       => 'admin_area',
                'type'       => 'admin_area',
                'title'      => 'Admin Area',
                'layout'     => ['device' => 'desktop', 'width' => 'fluid', 'theme' => 'default'],
                'components' => [
                    $this->component('admin-area', 'Admin Area', [
                        'heading'        => 'Admin Area',
                        'subtitle'       => 'Manage users, groups and permissions — changes take effect immediately.',
                        'exitLabel'      => 'Exit Admin Area',
                        'accent'         => '',
                        'tabPermissions' => true,
                        'tabUsers'       => true,
                        'tabAssign'      => true,
                        'tabGroups'      => true,
                        'tabGuide'       => true,
                    ]),
                ],
                'settings'   => ['customCss' => '', 'customJs' => '', 'locked' => false],
            ];
        }

        // The Calendar is a smart page like the Admin Area: one calendar-app
        // component whose props are the customizable chrome/behaviour. The build
        // generates the fully-working standalone calendar and honours these
        // settings. Seeded from the project's existing calendar ModuleState so
        // the Designer opens as a mirror of the current configuration.
        if ($table === '' && $pageType === 'calendar') {
            $cal = (array) ($options['calendar'] ?? []);
            $view = (string) ($cal['defaultView'] ?? 'month');
            if (!in_array($view, ['month', 'week', 'day', 'agenda', 'year'], true)) $view = 'month';
            return [
                'id'         => 'common_calendar',
                'name'       => 'calendar',
                'type'       => 'calendar',
                'title'      => 'Calendar',
                'layout'     => ['device' => 'desktop', 'width' => 'fluid', 'theme' => 'default'],
                'components' => [
                    $this->component('calendar-app', 'Calendar', [
                        // Header region
                        'heading'        => 'Calendar',
                        'subtitle'       => 'Plan events, schedule meetings and share calendars across your team.',
                        'menuLabel'      => (string) ($cal['menuLabel'] ?? 'Calendar'),
                        'headerAlign'    => 'left',
                        'accent'         => '',
                        'headingFont'    => '',
                        'headingSize'    => '',
                        'headingColor'   => '',
                        'baseFont'       => '',
                        // Toolbar region
                        'newEventLabel'  => 'New event',
                        'newIcon'        => 'bi-plus-lg',
                        'newBg'          => '', 'newColor' => '', 'newFont' => '', 'newSize' => '',
                        'showNewEvent'   => true,
                        'defaultView'    => $view,
                        'viewWeek'       => true,
                        'viewDay'        => true,
                        'viewAgenda'     => true,
                        'viewYear'       => true,
                        'showToday'      => true,
                        'showNav'        => true,
                        'showIcs'        => true,
                        // Sidebar region
                        'showSidebar'    => true,
                        'sideCreate'     => true,
                        'sideMiniMonth'  => true,
                        'sideCalendars'  => true,
                        'sideCategories' => true,
                        'sideUpcoming'   => true,
                        'createLabel'    => 'Create',
                        'createIcon'     => 'bi-plus-lg',
                        'createBg'       => '', 'createColor' => '', 'createFont' => '', 'createSize' => '',
                        // Grid region
                        'firstDay'       => ((int) ($cal['firstDay'] ?? 0)) === 1 ? '1' : '0',
                        'weekendShading' => false,
                        'todayColor'     => '',
                        'gridLine'       => '',
                        // Footer region
                        'showFooter'     => false,
                        'footerText'     => '',
                        // Notifications
                        'allowPersonal'  => !isset($cal['allowPersonal']) || !empty($cal['allowPersonal']),
                        'appReminders'   => !isset($cal['appReminders']) || !empty($cal['appReminders']),
                        'emailReminders' => !empty($cal['emailReminders']),
                    ]),
                ],
                'settings'   => ['customCss' => '', 'customJs' => '', 'locked' => false],
            ];
        }

        $safeTable = $table !== '' ? $table : 'app';
        $title = $table !== '' ? ucwords(str_replace('_', ' ', $table)) . ' ' . ucfirst($pageType) : ucfirst($pageType);
        $visibleFields = array_values(array_filter($fields, fn($f) => ($f['enabled'] ?? true) !== false));
        // Normalize genuine SQL long-text column types to the designer 'textarea'
        // so a synthesised default (Reset to Default) matches the build. Fields
        // may arrive typed with the raw SQL type (e.g. 'text', 'mediumtext') when
        // the Fields module never set one; bare 'text' now means a short input
        // everywhere, so convert real long-text columns explicitly.
        foreach ($visibleFields as &$__vf) {
            $__t = preg_replace('/\(.*/', '', strtolower(trim((string) ($__vf['type'] ?? ''))));
            if (in_array($__t, ['text', 'tinytext', 'mediumtext', 'longtext'], true)) {
                $__vf['type'] = 'textarea';
            }
        }
        unset($__vf);
        if (empty($visibleFields)) {
            $visibleFields = [
                ['name' => 'id', 'label' => 'ID', 'type' => 'int'],
                ['name' => 'name', 'label' => 'Name', 'type' => 'varchar'],
                ['name' => 'created_at', 'label' => 'Created At', 'type' => 'datetime'],
            ];
        }

        $template = TemplateFactory::make($table, $pageType, $visibleFields, $options);
        if ($template !== null) {
            return $template->toArray();
        }

        $page = [
            'id' => $safeTable . '_' . $pageType,
            'name' => $pageType,
            'type' => $pageType,
            'title' => $title,
            'layout' => ['device' => 'desktop', 'width' => 'fluid', 'theme' => 'default'],
            'components' => [],
            'settings' => ['customCss' => '', 'customJs' => '', 'locked' => false],
        ];

        if (in_array($pageType, ['add', 'edit', 'view', 'register', 'login'], true)) {
            $page['components'][] = $this->component('section', 'Main Form', ['variant' => 'card']);
            $__formFields = array_values(array_filter($visibleFields, fn($f) => (($f['pages'][$pageType] ?? true) !== false))) ?: $visibleFields;
            foreach ($__formFields as $field) {
                $page['components'][] = $this->component('field', $field['label'] ?? $field['name'], [
                    'field' => $field['name'],
                    'mode' => $pageType === 'view' ? 'view' : 'edit',
                    'inputType' => $this->inputType($field['type'] ?? 'text'),
                    'required' => false,
                ]);
            }
            $page['components'][] = $this->component('buttons', 'Form Buttons', ['buttons' => $pageType === 'view' ? ['back', 'edit', 'print'] : ['save', 'cancel']]);
            return $page;
        }

        if ($pageType === 'dashboard') {
            $page['components'][] = $this->component('stat-card', 'Total Records', ['metric' => 'count', 'icon' => 'database']);
            $page['components'][] = $this->component('chart', 'Records Chart', ['chartType' => 'bar']);
            $page['components'][] = $this->component('table-grid', 'Recent Records', ['fields' => array_column(array_slice($visibleFields, 0, 5), 'name')]);
            return $page;
        }

        if ($pageType === 'import') {
            $page['components'][] = $this->component('section', 'Import Records', ['variant' => 'card']);
            $page['components'][] = $this->component('upload', 'CSV / Excel Upload', ['accept' => '.csv,.xlsx']);
            $page['components'][] = $this->component('buttons', 'Import Buttons', ['buttons' => ['import', 'cancel']]);
            return $page;
        }

        if ($pageType === 'export') {
            $page['components'][] = $this->component('section', 'Export Options', ['variant' => 'card']);
            $page['components'][] = $this->component('field-selector', 'Choose Fields', ['fields' => array_column($visibleFields, 'name')]);
            $page['components'][] = $this->component('buttons', 'Export Buttons', ['buttons' => ['csv', 'excel', 'pdf']]);
            return $page;
        }

        // A "custom" page is a BLANK canvas the user builds from scratch by
        // dropping components from the palette into the responsive grid — for a
        // report, chart, a mobile-tailored list, or any bespoke screen. It shares
        // the exact page structure of every other page, so selection, the
        // per-component inspector and the build renderer all work unchanged; the
        // only difference is it starts empty. (Multiple named custom pages can
        // live under one table via the existing ['pages'][name] map.)
        if ($pageType === 'custom' || str_starts_with($pageType, 'custom_')) {
            return $page;
        }

        $page['components'][] = $this->component('breadcrumb', 'Breadcrumb', ['showHome' => true, 'breadcrumbStyle' => 'classic', 'items' => ['Home', $table, ucfirst($pageType)]]);
        $page['components'][] = $this->component('toolbar', 'Page Toolbar', ['buttons' => ['add', 'delete', 'print', 'export', 'import']]);
        $page['components'][] = $this->component('search', 'Simple Search', ['mode' => 'quick', 'placeholder' => 'Search records...']);
        // Every field flagged for the List page in the Fields module — no arbitrary
        // cap (a table can legitimately surface many columns). Fall back to all
        // enabled fields when none carry an explicit per-page choice.
        $__gridFields = array_values(array_filter($visibleFields, fn($f) => (($f['pages']['list'] ?? true) !== false))) ?: $visibleFields;
        $page['components'][] = $this->component('table-grid', 'Data Grid', ['fields' => array_column($__gridFields, 'name'), 'columns' => array_map(fn($f) => ['field' => $f['name'], 'label' => $f['label'] ?? $f['name'], 'visible' => true, 'sortable' => true, 'filter' => false, 'width' => 'auto', 'align' => '', 'format' => '', 'aggregate' => ''], $__gridFields), 'selection' => true, 'showActions' => true, 'showTotals' => true, 'showPageSize' => true, 'showTotalsText' => true, 'bulkActions' => ['delete_selected', 'update_selected'], 'actions' => ['view', 'edit', 'inline-edit', 'delete'], 'actionDisplay' => 'icon_text', 'density' => 'compact']);
        $page['components'][] = $this->component('pagination', 'Pagination', ['pageSize' => 20]);

        return $page;
    }

    private function component(string $type, string $label, array $props = []): array
    {
        return [
            'id' => $type . '_' . bin2hex(random_bytes(3)),
            'type' => $type,
            'label' => $label,
            'props' => $props,
            'style' => ['class' => '', 'customCss' => ''],
            'visibility' => ['roles' => [], 'condition' => ''],
        ];
    }

    private function inputType(string $dbType): string
    {
        $type = strtolower($dbType);
        return str_contains($type, 'int') || str_contains($type, 'decimal') ? 'number'
            : (str_contains($type, 'date') || str_contains($type, 'time') ? 'date'
            : (str_contains($type, 'text') ? 'textarea' : 'text'));
    }
}
