<?php
/**
 * 芋头CMS - 页面装修数据层
 */

namespace Core\Lib;

require_once __DIR__ . '/builder_components.php';
require_once __DIR__ . '/builder_preflight.php';

class Builder
{
    const PAGE_TYPE_HOME = 'home';
    const SCHEMA_VERSION = '20260718_builder_publish_01';

    private $db;
    private $builderTable = 'builder_page';
    private $historyTable = 'builder_page_history';
    private $presenceTable = 'builder_edit_session';
    private $presenceReady = false;
    private $tableChecked = false;
    private $tableReady = false;
    private $schemaChecked = false;
    private $schemaError = '';
    private $supportedPageTypes = [
        'home',
        'page',
        'article_list',
        'article_detail',
        'product_list',
        'product_detail',
        'image_list',
        'image_detail',
        'download_list',
        'download_detail',
        'video_list',
        'video_detail',
        'recruit_list',
        'recruit_detail',
    ];
    private $referencePageTypes = ['page'];
    private $genericDetailPageTypes = [
        'article_detail',
        'product_detail',
        'image_detail',
        'download_detail',
        'video_detail',
        'recruit_detail',
    ];

    public function __construct($db)
    {
        $this->db = $db;
        $this->ensureSchema();
    }

    private function ensureSchema()
    {
        if ($this->schemaChecked) {
            return $this->schemaError === '';
        }
        $this->schemaChecked = true;
        $lockName = 'enterprise_cms_builder_schema';
        $locked = false;

        try {
            $configTable = $this->db->table('config');
            $version = $this->db->fetch("SELECT config_value FROM {$configTable} WHERE config_key = 'builder_schema_version' LIMIT 1");
            if (($version['config_value'] ?? '') === self::SCHEMA_VERSION) {
                return true;
            }

            $lock = $this->db->fetch('SELECT GET_LOCK(?, 5) AS acquired', [$lockName]);
            $locked = intval($lock['acquired'] ?? 0) === 1;
            if (!$locked) {
                throw new \RuntimeException('页面装修数据库升级锁获取失败');
            }

            $version = $this->db->fetch("SELECT config_value FROM {$configTable} WHERE config_key = 'builder_schema_version' LIMIT 1");
            if (($version['config_value'] ?? '') === self::SCHEMA_VERSION) {
                return true;
            }

            $builderTable = $this->db->table($this->builderTable);
            $historyTable = $this->db->table($this->historyTable);
            $database = (string) ($this->db->getConfig()['database'] ?? '');
            $table = $this->db->fetch(
                'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? LIMIT 1',
                [$database, $builderTable]
            );
            if (!$table) {
                $installed = $this->installTables();
                if (intval($installed['code'] ?? 500) !== 200) {
                    throw new \RuntimeException((string) ($installed['msg'] ?? '页面装修数据表创建失败'));
                }
            }

            $columnRows = $this->db->fetchAll(
                'SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?',
                [$database, $builderTable]
            );
            $columns = array_column($columnRows, 'COLUMN_NAME');
            if (!in_array('published_layout_json', $columns, true)) {
                $this->db->query("ALTER TABLE {$builderTable} ADD COLUMN published_layout_json MEDIUMTEXT NULL AFTER layout_json");
            }
            if (!in_array('published_at', $columns, true)) {
                $this->db->query("ALTER TABLE {$builderTable} ADD COLUMN published_at INT NOT NULL DEFAULT 0 AFTER status");
            }

            $this->db->query(
                "UPDATE {$builderTable}
                 SET published_layout_json = layout_json,
                     published_at = CASE WHEN status = 1 THEN COALESCE(NULLIF(update_time, 0), NULLIF(create_time, 0), UNIX_TIMESTAMP()) ELSE 0 END
                 WHERE status = 1 AND (published_layout_json IS NULL OR published_layout_json = '')"
            );

            $index = $this->db->fetch(
                'SELECT INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1',
                [$database, $historyTable, 'idx_builder_page_time']
            );
            if (!$index) {
                $this->db->query("ALTER TABLE {$historyTable} ADD KEY idx_builder_page_time (builder_page_id, create_time)");
            }

            if ($version) {
                $this->db->update('config', ['config_value' => self::SCHEMA_VERSION, 'update_time' => time()], 'config_key = ?', ['builder_schema_version']);
            } else {
                $this->db->insert('config', [
                    'config_key' => 'builder_schema_version',
                    'config_value' => self::SCHEMA_VERSION,
                    'config_type' => 'text',
                    'config_group' => 'system',
                    'update_time' => time(),
                ]);
            }

            $this->tableChecked = false;
            $this->tableReady = false;
            return true;
        } catch (\Throwable $e) {
            $this->schemaError = $e->getMessage();
            error_log('EnterpriseCMS builder schema upgrade failed: ' . $this->schemaError);
            return false;
        } finally {
            if ($locked) {
                try {
                    $this->db->fetch('SELECT RELEASE_LOCK(?) AS released', [$lockName]);
                } catch (\Throwable $e) {
                }
            }
        }
    }

    public function isReady()
    {
        if ($this->schemaError !== '') {
            return false;
        }
        if ($this->tableChecked) {
            return $this->tableReady;
        }

        $this->tableChecked = true;
        try {
            $tableName = $this->db->table($this->builderTable);
            $config = $this->db->getConfig();
            $row = $this->db->fetch(
                'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? LIMIT 1',
                [$config['database'] ?? '', $tableName]
            );
            $this->tableReady = !empty($row);
        } catch (\Throwable $e) {
            $this->tableReady = false;
        }

        return $this->tableReady;
    }

    public function installTables()
    {
        try {
            $builderTable = $this->db->table($this->builderTable);
            $historyTable = $this->db->table($this->historyTable);
            $presenceTable = $this->db->table($this->presenceTable);

            $this->db->query("
                CREATE TABLE IF NOT EXISTS {$builderTable} (
                    id INT PRIMARY KEY AUTO_INCREMENT,
                    page_key VARCHAR(60) NOT NULL COMMENT '页面标识，如 home/page_12',
                    page_type VARCHAR(20) NOT NULL DEFAULT 'home' COMMENT '页面类型：home/page',
                    ref_id INT NOT NULL DEFAULT 0 COMMENT '关联ID：首页为0，单页为page.id',
                    title VARCHAR(150) DEFAULT '' COMMENT '装修标题',
                    layout_json MEDIUMTEXT COMMENT '布局JSON',
                    published_layout_json MEDIUMTEXT COMMENT '已发布布局JSON，与草稿隔离',
                    status TINYINT DEFAULT 1 COMMENT '状态：0草稿 1发布',
                    published_at INT NOT NULL DEFAULT 0 COMMENT '最近发布时间',
                    is_enabled TINYINT DEFAULT 1 COMMENT '是否启用装修布局',
                    version_no INT DEFAULT 1 COMMENT '版本号',
                    create_time INT COMMENT '创建时间',
                    update_time INT COMMENT '更新时间',
                    UNIQUE KEY uniq_page_key (page_key),
                    KEY idx_page_type_ref_id (page_type, ref_id)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
            ");

            $this->db->query("
                CREATE TABLE IF NOT EXISTS {$historyTable} (
                    id INT PRIMARY KEY AUTO_INCREMENT,
                    builder_page_id INT NOT NULL COMMENT '主表ID',
                    version_no INT NOT NULL COMMENT '版本号',
                    layout_json MEDIUMTEXT COMMENT '历史布局JSON',
                    create_time INT COMMENT '保存时间',
                    KEY idx_builder_page_id (builder_page_id),
                    KEY idx_builder_page_time (builder_page_id, create_time)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
            ");
            $this->db->query("
                CREATE TABLE IF NOT EXISTS {$presenceTable} (
                    id INT PRIMARY KEY AUTO_INCREMENT,
                    page_key VARCHAR(60) NOT NULL,
                    editor_token VARCHAR(80) NOT NULL,
                    admin_id INT NOT NULL DEFAULT 0,
                    admin_name VARCHAR(80) NOT NULL DEFAULT '',
                    version_no INT NOT NULL DEFAULT 0,
                    last_seen INT NOT NULL DEFAULT 0,
                    create_time INT NOT NULL DEFAULT 0,
                    UNIQUE KEY uniq_editor_token (editor_token),
                    KEY idx_builder_presence (page_key, last_seen)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
            ");

            $this->tableChecked = false;
            $this->tableReady = false;

            if ($this->isReady()) {
                return ['code' => 200, 'msg' => '页面装修数据表创建成功'];
            }

            return ['code' => 500, 'msg' => '数据表创建后校验失败'];
        } catch (\Throwable $e) {
            return ['code' => 500, 'msg' => '数据表创建失败：' . $e->getMessage()];
        }
    }

    public function makePageKey($pageType, $refId = 0)
    {
        $pageType = $this->normalizePageType($pageType);
        $refId = intval($refId);
        if ($pageType === self::PAGE_TYPE_HOME) {
            return self::PAGE_TYPE_HOME;
        }

        return $pageType . '_' . max(0, $refId);
    }

    public function getSupportedPageTypes()
    {
        return $this->supportedPageTypes;
    }

    private function ensurePresenceTable()
    {
        if ($this->presenceReady) {
            return true;
        }
        try {
            $table = $this->db->table($this->presenceTable);
            $this->db->query("CREATE TABLE IF NOT EXISTS {$table} (id INT PRIMARY KEY AUTO_INCREMENT,page_key VARCHAR(60) NOT NULL,editor_token VARCHAR(80) NOT NULL,admin_id INT NOT NULL DEFAULT 0,admin_name VARCHAR(80) NOT NULL DEFAULT '',version_no INT NOT NULL DEFAULT 0,last_seen INT NOT NULL DEFAULT 0,create_time INT NOT NULL DEFAULT 0,UNIQUE KEY uniq_editor_token (editor_token),KEY idx_builder_presence (page_key,last_seen)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
            $this->presenceReady = true;
        } catch (\Throwable $e) {
            $this->presenceReady = false;
        }
        return $this->presenceReady;
    }

    public function heartbeatEditSession($pageType, $refId, $editorToken, $adminId, $adminName, $versionNo = 0, $now = null)
    {
        if (!$this->ensurePresenceTable()) {
            return ['code' => 500, 'msg' => '装修协作状态不可用'];
        }
        $editorToken = trim((string) $editorToken);
        if (!preg_match('/^[A-Za-z0-9._-]{12,80}$/', $editorToken)) {
            return ['code' => 422, 'msg' => '编辑会话标识无效'];
        }
        $pageKey = $this->makePageKey($pageType, $refId);
        $adminName = mb_substr(trim((string) $adminName), 0, 80, 'UTF-8');
        if ($adminName === '') {
            $adminName = '装修协作用户';
        }
        $now = $now === null ? time() : intval($now);
        $this->db->delete($this->presenceTable, 'last_seen < ?', [$now - 180]);
        $existing = $this->db->fetch("SELECT id FROM {$this->db->table($this->presenceTable)} WHERE editor_token = ? LIMIT 1", [$editorToken]);
        $data = ['page_key' => $pageKey, 'admin_id' => intval($adminId), 'admin_name' => $adminName, 'version_no' => max(0, intval($versionNo)), 'last_seen' => $now];
        if ($existing) {
            $this->db->update($this->presenceTable, $data, 'id = ?', [intval($existing['id'])]);
        } else {
            $data['editor_token'] = $editorToken;
            $data['create_time'] = $now;
            $this->db->insert($this->presenceTable, $data);
        }
        $rows = $this->db->fetchAll("SELECT editor_token,admin_id,admin_name,version_no,last_seen FROM {$this->db->table($this->presenceTable)} WHERE page_key = ? AND last_seen >= ? ORDER BY last_seen DESC", [$pageKey, $now - 45]);
        foreach ($rows as &$row) {
            $row['admin_id'] = intval($row['admin_id']);
            $row['version_no'] = intval($row['version_no']);
            $row['last_seen'] = intval($row['last_seen']);
            $row['is_current'] = hash_equals((string) $row['editor_token'], $editorToken);
            unset($row['editor_token']);
        }
        unset($row);
        return ['code' => 200, 'msg' => '协作状态已更新', 'editors' => $rows, 'active_count' => count($rows)];
    }

    public function leaveEditSession($editorToken)
    {
        if (!$this->ensurePresenceTable()) {
            return ['code' => 500, 'msg' => '装修协作状态不可用'];
        }
        $editorToken = trim((string) $editorToken);
        if (!preg_match('/^[A-Za-z0-9._-]{12,80}$/', $editorToken)) {
            return ['code' => 422, 'msg' => '编辑会话标识无效'];
        }
        $this->db->delete($this->presenceTable, 'editor_token = ?', [$editorToken]);
        return ['code' => 200, 'msg' => '已退出装修协作'];
    }

    public function normalizePageType($pageType)
    {
        $pageType = trim((string) $pageType);
        if (in_array($pageType, $this->supportedPageTypes, true)) {
            return $pageType;
        }

        return self::PAGE_TYPE_HOME;
    }

    public function requiresReferenceId($pageType)
    {
        return in_array($this->normalizePageType($pageType), $this->referencePageTypes, true);
    }

    public function getPageByType($pageType, $refId = 0)
    {
        if (!$this->isReady()) {
            return null;
        }

        $pageKey = $this->makePageKey($pageType, $refId);
        return $this->db->fetch(
            "SELECT * FROM {$this->db->table($this->builderTable)} WHERE page_key = ? LIMIT 1",
            [$pageKey]
        );
    }

    public function getLayoutByType($pageType, $refId = 0)
    {
        $page = $this->getPageByType($pageType, $refId);
        $normalizedType = $this->normalizePageType($pageType);
        if ((!$page || intval($page['is_enabled']) !== 1 || intval($page['status']) !== 1)
            && intval($refId) > 0
            && in_array($normalizedType, $this->genericDetailPageTypes, true)) {
            $page = $this->getPageByType($normalizedType, 0);
        }
        if (!$page || intval($page['is_enabled']) !== 1 || intval($page['status']) !== 1) {
            return null;
        }

        $publishedJson=trim((string)($page['published_layout_json']??''));$layout = json_decode($publishedJson!==''?$publishedJson:(string)$page['layout_json'], true);
        return is_array($layout) ? $this->normalizeLayout($layout, (string) ($page['title'] ?? '')) : null;
    }

    public function getDraftByType($pageType, $refId = 0)
    {
        $page = $this->getPageByType($pageType, $refId);
        if (!$page) {
            return null;
        }

        $layout = json_decode((string) $page['layout_json'], true);
        if (!is_array($layout)) {
            $layout = $this->getDefaultLayout((string) ($page['title'] ?? ''));
        } else {
            $layout = $this->normalizeLayout($layout, (string) ($page['title'] ?? ''));
        }

        $page['layout'] = $layout;
        return $page;
    }

    public function getDefaultLayout($title = '')
    {
        $title = trim((string) $title);
        return [
            'page' => [
                'title' => $title !== '' ? $title : '页面装修',
                'seo_title' => '',
                'seo_keywords' => '',
                'seo_description' => '',
                'background_color' => '',
                'background_image' => '',
                'background_mode' => 'default',
                'font_family' => '',
                'theme_color' => '#5f7eeb',
                'theme_surface' => 'clean',
                'browser_title_visible' => 1,
                'favicon_url' => '',
                'page_width' => 1200,
                'page_width_mode' => '1200',
                'page_padding' => 0,
                'layout_preset' => 'classic',
                'mobile_enabled' => 1,
                'device_mode' => 'pc',
                'mobile_page_width' => 375,
                'mobile_page_padding' => 12,
                'footer_background_color' => '',
                'footer_text_color' => '',
                'footer_text_align' => 'center',
                'footer_padding_top' => 20,
                'footer_padding_bottom' => 20,
                'advanced_options' => [
                    'production_info' => 1,
                    'icp' => 1,
                    'public_security' => 0,
                    'admin_login' => 1,
                    'mobile_site' => 1,
                ],
                'site_widgets' => [
                    'floating_service' => 0,
                    'site_sidebar' => 0,
                    'top_ad' => 0,
                    'bottom_float' => 0,
                    'member_bar' => 0,
                    'home_popup' => 0,
                    'elevator_nav' => 0,
                    'welcome_screen' => 0,
                    'counter' => 0,
                    'background_music' => 0,
                ],
                'site_widget_configs' => [
                    'top_ad' => [
                        'text' => '网站顶部广告：立即领取专属咨询通道',
                        'link_url' => '',
                    ],
                    'member_bar' => [
                        'login_text' => '会员登录',
                        'register_text' => '立即注册',
                    ],
                    'floating_service' => [
                        'guestbook_url' => '',
                        'phone' => '',
                        'qq' => '',
                        'wechat' => '',
                        'email' => '',
                    ],
                    'site_sidebar' => [
                        'product_url' => '',
                        'contact_label' => '联系',
                    ],
                    'elevator_nav' => [
                        'product_url' => '',
                        'contact_label' => '联系',
                    ],
                    'bottom_float' => [
                        'text' => '立即获取专属方案',
                        'button_text' => '立即咨询',
                        'button_url' => '',
                    ],
                    'counter' => [
                        'label' => '本页访问',
                    ],
                    'background_music' => [
                        'audio_url' => '',
                        'button_text' => '播放音乐',
                        'autoplay' => 0,
                    ],
                    'home_popup' => [
                        'title' => '首页弹窗',
                        'content' => '这里可以展示活动信息、优惠券或者首页欢迎文案。',
                        'button_text' => '立即查看',
                        'button_url' => '',
                    ],
                    'welcome_screen' => [
                        'title' => 'WELCOME',
                        'content' => '欢迎页已开启，适合活动场景或品牌开场。',
                        'button_text' => '进入网站',
                    ],
                ],
                'frontend_overrides' => [],
                'frontend_extra_sections' => [],
                'frontend_canvas_blocks' => [],
                'frontend_hidden_paths' => [],
                'frontend_free_layout_hidden_paths' => [],
                'frontend_deleted_paths' => [],
                'frontend_locked_paths' => [],
                'frontend_block_orders' => [],
                'frontend_mobile_hidden_paths' => [],
                'frontend_mobile_deleted_paths' => [],
                'frontend_mobile_overrides' => [],
                'frontend_editor_state' => [
                    'library_panel_mode' => 'modules',
                    'library_module_tab' => 'new_module',
                    'style_panel_tab' => 'theme',
                    'style_panel_part' => 'layout',
                    'settings_panel_tab' => 'basic',
                    'material_scope' => 'scene',
                    'material_category' => 'product_showcase',
                    'material_search' => '',
                    'library_expanded_groups' => [
                        'common' => true,
                        'basic' => true,
                        'layout' => false,
                        'product' => false,
                        'interactive' => false,
                        'advanced' => false,
                    ],
                    'dock_position' => ['left' => 18, 'top' => 118],
                    'panel_positions' => [
                        'library' => ['left' => 14, 'top' => 82],
                        'settings' => ['left' => 0, 'top' => 82],
                    ],
                    'material_modal_position' => null,
                    'business_modal_position' => null,
                ],
            ],
            'sections' => [],
        ];
    }

    public function normalizeLayout(array $layout, $fallbackTitle = '')
    {
        $normalized = $this->getDefaultLayout($fallbackTitle);

        if (isset($layout['page']) && is_array($layout['page'])) {
            $normalized['page'] = array_merge($normalized['page'], $layout['page']);
            $normalized['page']['advanced_options'] = $this->normalizeBooleanMap(
                $layout['page']['advanced_options'] ?? null,
                $normalized['page']['advanced_options']
            );
            $normalized['page']['site_widgets'] = $this->normalizeBooleanMap(
                $layout['page']['site_widgets'] ?? null,
                $normalized['page']['site_widgets']
            );
            $normalized['page']['site_widget_configs'] = $this->normalizeWidgetConfigs(
                $layout['page']['site_widget_configs'] ?? null,
                $normalized['page']['site_widget_configs']
            );
            $normalized['page']['frontend_overrides'] = is_array($layout['page']['frontend_overrides'] ?? null)
                ? $layout['page']['frontend_overrides']
                : [];
            $normalized['page']['frontend_mobile_overrides'] = is_array($layout['page']['frontend_mobile_overrides'] ?? null)
                ? $layout['page']['frontend_mobile_overrides']
                : [];
            $normalized['page']['frontend_canvas_blocks'] = is_array($layout['page']['frontend_canvas_blocks'] ?? null)
                ? $layout['page']['frontend_canvas_blocks']
                : [];
            $normalized['page']['frontend_extra_sections'] = is_array($layout['page']['frontend_extra_sections'] ?? null)
                ? $layout['page']['frontend_extra_sections']
                : [];
            $normalized['page']['frontend_hidden_paths'] = is_array($layout['page']['frontend_hidden_paths'] ?? null)
                ? $layout['page']['frontend_hidden_paths']
                : [];
            $normalized['page']['frontend_free_layout_hidden_paths'] = is_array($layout['page']['frontend_free_layout_hidden_paths'] ?? null)
                ? $layout['page']['frontend_free_layout_hidden_paths']
                : [];
            $normalized['page']['frontend_deleted_paths'] = is_array($layout['page']['frontend_deleted_paths'] ?? null)
                ? $layout['page']['frontend_deleted_paths']
                : [];
            $normalized['page']['frontend_locked_paths'] = is_array($layout['page']['frontend_locked_paths'] ?? null)
                ? $layout['page']['frontend_locked_paths']
                : [];
            $normalized['page']['frontend_block_orders'] = is_array($layout['page']['frontend_block_orders'] ?? null)
                ? $layout['page']['frontend_block_orders']
                : [];
            $normalized['page']['frontend_mobile_hidden_paths'] = is_array($layout['page']['frontend_mobile_hidden_paths'] ?? null)
                ? $layout['page']['frontend_mobile_hidden_paths']
                : [];
            $normalized['page']['frontend_mobile_deleted_paths'] = is_array($layout['page']['frontend_mobile_deleted_paths'] ?? null)
                ? $layout['page']['frontend_mobile_deleted_paths']
                : [];
            $normalized['page']['frontend_editor_state'] = is_array($layout['page']['frontend_editor_state'] ?? null)
                ? $layout['page']['frontend_editor_state']
                : $normalized['page']['frontend_editor_state'];
        }

        if (isset($layout['sections']) && is_array($layout['sections'])) {
            foreach ($layout['sections'] as $index => $section) {
                if (!is_array($section)) {
                    continue;
                }

                if (!isset($section['layout']) || !is_array($section['layout'])) {
                    $type = trim((string) ($section['type'] ?? ''));
                    $section['layout'] = BuilderComponents::getDefaultSectionLayout($type, intval($index));
                }
                $normalizedSection = BuilderComponents::normalizeSection($section);
                if ($normalizedSection) {
                    $normalized['sections'][] = $normalizedSection;
                }
            }
        }

        return $normalized;
    }

    private function normalizeBooleanMap($value, array $defaults)
    {
        if (!is_array($value)) {
            return $defaults;
        }

        foreach ($defaults as $key => $defaultValue) {
            $defaults[$key] = !empty($value[$key]) ? 1 : 0;
        }

        return $defaults;
    }

    private function normalizeWidgetConfigs($value, array $defaults)
    {
        if (!is_array($value)) {
            return $defaults;
        }

        foreach ($defaults as $widgetKey => $fields) {
            if (!isset($value[$widgetKey]) || !is_array($value[$widgetKey])) {
                continue;
            }

            foreach ($fields as $fieldKey => $fieldDefault) {
                if (!array_key_exists($fieldKey, $value[$widgetKey])) {
                    continue;
                }

                if (is_int($fieldDefault)) {
                    $defaults[$widgetKey][$fieldKey] = !empty($value[$widgetKey][$fieldKey]) ? 1 : 0;
                } else {
                    $defaults[$widgetKey][$fieldKey] = trim((string) $value[$widgetKey][$fieldKey]);
                }
            }
        }

        return $defaults;
    }

    private function validateLayoutValue($value, $path, $depth, &$nodes)
    {
        $nodes++;
        if ($nodes > 50000) {
            return '布局节点数量超过限制';
        }
        if ($depth > 16) {
            return '布局嵌套层级超过限制';
        }
        if (is_array($value)) {
            if (count($value) > 2000) {
                return $path . ' 数组项目过多';
            }
            foreach ($value as $key => $item) {
                $error = $this->validateLayoutValue($item, $path . '.' . $key, $depth + 1, $nodes);
                if ($error !== '') {
                    return $error;
                }
            }
            return '';
        }
        if (!is_string($value)) {
            return '';
        }
        if (strlen($value) > 1024 * 1024) {
            return $path . ' 文本内容过大';
        }

        $key = strtolower((string) preg_replace('/^.*\./', '', $path));
        $decoded = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        if (preg_match('/(?:html|content|rich_text|markup)$/', $key)
            && preg_match('~<\s*(?:script|object|embed|meta|base)\b|\son[a-z0-9_-]+\s*=|\ssrcdoc\s*=|javascript\s*:|data\s*:\s*text/html|expression\s*\(|@import\b|url\s*\(\s*[\'\"]?\s*javascript\s*:~i', $decoded)) {
            return $path . ' 包含不安全的 HTML 或脚本';
        }
        if (preg_match('/(?:href|src|url|link|action)$/', $key) && !$this->isSafeBuilderUrl($decoded)) {
            return $path . ' URL 协议不安全';
        }
        if (preg_match('/(?:style|css)$/', $key)
            && preg_match('~expression\s*\(|@import\b|url\s*\(\s*[\'\"]?\s*javascript\s*:|behavior\s*:|-moz-binding\s*:~i', $decoded)) {
            return $path . ' 包含不安全的 CSS';
        }
        return '';
    }

    private function isSafeBuilderUrl($url)
    {
        $url = preg_replace('/[\x00-\x20\x7f]+/', '', trim((string) $url));
        if ($url === '' || $url[0] === '/' || $url[0] === '#' || $url[0] === '?' || strpos($url, '{$') === 0) {
            return true;
        }
        if (strpos($url, '//') === 0) {
            return true;
        }
        if (!preg_match('/^([a-z][a-z0-9+.-]*):/i', $url, $match)) {
            return true;
        }
        return in_array(strtolower($match[1]), ['http', 'https', 'mailto', 'tel'], true);
    }

    public function validateLayout(array $layout)
    {
        $definitions = BuilderComponents::getDefinitions();
        if (isset($layout['sections']) && !is_array($layout['sections'])) {
            return ['code' => 400, 'msg' => '布局 sections 结构无效'];
        }
        foreach (($layout['sections'] ?? []) as $index => $section) {
            $type = is_array($section) ? trim((string) ($section['type'] ?? '')) : '';
            if ($type === '') {
                return ['code' => 400, 'msg' => '第' . ($index + 1) . '个组件缺少 type'];
            }
            if (!isset($definitions[$type])) {
                return ['code' => 422, 'msg' => '第' . ($index + 1) . '个组件类型不受支持'];
            }
        }

        $normalized = $this->normalizeLayout($layout);
        $json = json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if ($json === false) {
            return ['code' => 400, 'msg' => '布局 JSON 编码失败'];
        }
        if (strlen($json) > 8 * 1024 * 1024) {
            return ['code' => 413, 'msg' => '布局数据超过 8MB 限制'];
        }
        if (!isset($normalized['sections']) || !is_array($normalized['sections'])) {
            return ['code' => 400, 'msg' => '布局 sections 结构无效'];
        }

        foreach ($normalized['sections'] as $index => $section) {
            if (empty($section['type'])) {
                return ['code' => 400, 'msg' => '第' . ($index + 1) . '个组件缺少 type'];
            }
            if (!isset($definitions[$section['type']])) {
                return ['code' => 422, 'msg' => '第' . ($index + 1) . '个组件类型不受支持'];
            }
            if (!isset($section['props']) || !is_array($section['props'])) {
                return ['code' => 400, 'msg' => '第' . ($index + 1) . '个组件 props 必须为对象'];
            }
        }

        $nodes = 0;
        $error = $this->validateLayoutValue($normalized, 'layout', 0, $nodes);
        if ($error !== '') {
            return ['code' => 422, 'msg' => $error];
        }

        return ['code' => 200, 'msg' => 'ok', 'layout' => $normalized];
    }

    public function preflightLayout(array $layout)
    {
        $validation = $this->validateLayout($layout);
        if (intval($validation['code'] ?? 500) !== 200) {
            return [
                'code' => 200,
                'msg' => '体检完成，存在阻断问题',
                'data' => BuilderPreflight::invalidLayoutReport($validation['msg'] ?? '布局无效'),
            ];
        }

        $report = BuilderPreflight::inspect($validation['layout'], $this->db, dirname(__DIR__, 2));
        return [
            'code' => 200,
            'msg' => $report['can_publish'] ? '发布体检通过' : '体检完成，存在阻断问题',
            'data' => $report,
        ];
    }

    public function savePage($pageType, $refId, $title, array $layout, $enabled = 1, $status = 1, $expectedVersion = 0)
    {
        if (!$this->isReady()) {
            return ['code' => 500, 'msg' => '页面装修数据表尚未安装'];
        }

        $pageType = trim((string) $pageType);
        $refId = intval($refId);
        $title = trim((string) $title);
        $pageKey = $this->makePageKey($pageType, $refId);
        $now = time();

        $validation = $this->validateLayout($layout);
        if (intval($validation['code']) !== 200) {
            return $validation;
        }

        $layout = $validation['layout'];
        $pageTitle = trim((string) ($layout['page']['title'] ?? ''));
        if ($pageTitle === '' && $title !== '') {
            $layout['page']['title'] = $title;
            $pageTitle = $title;
        }
        if ($title === '') {
            $title = $pageTitle;
        }

        $layoutJson = json_encode($layout, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        $existing = $this->getPageByType($pageType, $refId);

          if ($existing) {
              $expectedVersion = intval($expectedVersion);
              $currentVersion = intval($existing['version_no']);
              if ($expectedVersion > 0 && $expectedVersion !== $currentVersion) {
                  return ['code' => 409, 'msg' => '页面草稿已在其他标签页更新，请重新加载后再编辑', 'id' => intval($existing['id']), 'version_no' => $currentVersion];
              }
              $nextVersion = intval($existing['version_no']) + 1;
              $nextStatus = intval($status);
            if ($nextStatus === 0 && intval($existing['status']) === 1) {
                // Keep the published record published when saving a draft from the frontend editor.
                  $nextStatus = 1;
              }
              $publishedMatches=intval($status)!==1||hash_equals((string)($existing['published_layout_json']??''),$layoutJson);
              if(hash_equals((string)$existing['layout_json'],$layoutJson)&&$publishedMatches&&trim((string)$existing['title'])===$title&&intval($existing['is_enabled'])===intval($enabled)&&intval($existing['status'])===$nextStatus){return['code'=>200,'msg'=>'装修内容没有变化','id'=>intval($existing['id']),'version_no'=>$currentVersion,'unchanged'=>true];}
              $latestHistory=$this->db->fetch("SELECT MAX(create_time) create_time FROM {$this->db->table($this->historyTable)} WHERE builder_page_id=?",[intval($existing['id'])]);
              $shouldSnapshot=intval($status)===1||intval($latestHistory['create_time']??0)<$now-300;
              $snapshotLayout=intval($status)===1&&trim((string)($existing['published_layout_json']??''))!==''?(string)$existing['published_layout_json']:(string)$existing['layout_json'];

            $updateData=[
                'title' => $title,
                'layout_json' => $layoutJson,
                'is_enabled' => intval($enabled),
                'status' => $nextStatus,
                'version_no' => $nextVersion,
                'update_time' => $now,
            ];if(intval($status)===1){$updateData['published_layout_json']=$layoutJson;$updateData['published_at']=$now;}
            $where='id = ?';$whereParams=[intval($existing['id'])];if($expectedVersion>0){$where.=' AND version_no = ?';$whereParams[]=$expectedVersion;}
            $updated=$this->db->update($this->builderTable,$updateData,$where,$whereParams);
            if($updated!==1){$latest=$this->getPageByType($pageType,$refId);return['code'=>409,'msg'=>'页面草稿已在其他标签页更新，请重新加载后再编辑','id'=>intval($existing['id']),'version_no'=>intval($latest['version_no']??$currentVersion)];}
            if($shouldSnapshot){$this->db->insert($this->historyTable,['builder_page_id'=>intval($existing['id']),'version_no'=>$currentVersion,'layout_json'=>$snapshotLayout,'create_time'=>$now]);$this->trimHistory(intval($existing['id']));}

            return ['code' => 200, 'msg' => '装修页面已更新', 'id' => intval($existing['id']), 'version_no' => $nextVersion];
        }

        if (intval($expectedVersion) > 0) {
            return ['code' => 409, 'msg' => '页面草稿状态已变化，请重新加载后再编辑', 'version_no' => 0];
        }

        $id = $this->db->insert($this->builderTable, [
            'page_key' => $pageKey,
            'page_type' => $pageType,
            'ref_id' => $refId,
            'title' => $title,
            'layout_json' => $layoutJson,
            'published_layout_json' => intval($status)===1?$layoutJson:'',
            'status' => intval($status),
            'published_at' => intval($status)===1?$now:0,
            'is_enabled' => intval($enabled),
            'version_no' => 1,
            'create_time' => $now,
            'update_time' => $now,
        ]);

        return ['code' => 200, 'msg' => '装修页面已创建', 'id' => intval($id), 'version_no' => 1];
    }

    public function saveDraft($pageType, $refId, $title, array $layout, $enabled = 1, $expectedVersion = 0)
    {
        return $this->savePage($pageType, $refId, $title, $layout, $enabled, 0, $expectedVersion);
    }

    public function publishPage($pageType, $refId, $title, array $layout, $enabled = 1, $expectedVersion = 0)
    {
        $preflight = $this->preflightLayout($layout);
        if (empty($preflight['data']['can_publish'])) {
            return [
                'code' => 422,
                'msg' => '发布前体检未通过，请先修复阻断问题',
                'preflight' => $preflight['data'],
            ];
        }
        return $this->savePage($pageType, $refId, $title, $layout, $enabled, 1, $expectedVersion);
    }

    public function syncWidgetToAllPages($widgetKey, array $widgetConfig = null, $widgetEnabled = null)
    {
        if (!$this->isReady()) {
            return ['code' => 500, 'msg' => '页面装修数据表不可用'];
        }

        $widgetKey = trim((string) $widgetKey);
        $defaultPage = $this->getDefaultLayout('')['page'];
        if ($widgetKey === '' || !array_key_exists($widgetKey, $defaultPage['site_widgets'])) {
            return ['code' => 422, 'msg' => '挂件标识不受支持'];
        }

        $pages = $this->db->fetchAll("SELECT * FROM {$this->db->table($this->builderTable)} ORDER BY id ASC");
        $applyWidget = function ($json, $title) use ($widgetKey, $widgetConfig, $widgetEnabled) {
            $layout = json_decode((string) $json, true);
            $layout = is_array($layout) ? $this->normalizeLayout($layout, $title) : $this->getDefaultLayout($title);
            if ($widgetEnabled !== null) {
                $layout['page']['site_widgets'][$widgetKey] = intval($widgetEnabled) ? 1 : 0;
            }
            if ($widgetConfig !== null) {
                $layout['page']['site_widget_configs'][$widgetKey] = $widgetConfig;
            }
            return $this->validateLayout($layout);
        };
        $updated = 0;
        $unchanged = 0;
        $pageVersions = [];
        $now = time();

        $this->db->beginTransaction();
        try {
            foreach ($pages as $page) {
                $pageId = intval($page['id']);
                $currentVersion = intval($page['version_no'] ?? 0);
                $title = (string) ($page['title'] ?? '');
                $draftResult = $applyWidget($page['layout_json'] ?? '', $title);
                if (intval($draftResult['code'] ?? 500) !== 200) {
                    throw new \InvalidArgumentException((string) ($draftResult['msg'] ?? '挂件草稿校验失败'));
                }
                $draftJson = json_encode($draftResult['layout'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                $data = ['layout_json' => $draftJson];

                if (intval($page['status'] ?? 0) === 1) {
                    $publishedSource = trim((string) ($page['published_layout_json'] ?? '')) !== ''
                        ? $page['published_layout_json']
                        : ($page['layout_json'] ?? '');
                    $publishedResult = $applyWidget($publishedSource, $title);
                    if (intval($publishedResult['code'] ?? 500) !== 200) {
                        throw new \InvalidArgumentException((string) ($publishedResult['msg'] ?? '挂件发布版本校验失败'));
                    }
                    $data['published_layout_json'] = json_encode($publishedResult['layout'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                }

                $draftSame = hash_equals((string) ($page['layout_json'] ?? ''), (string) $data['layout_json']);
                $publishedSame = !isset($data['published_layout_json'])
                    || hash_equals((string) ($page['published_layout_json'] ?? ''), (string) $data['published_layout_json']);
                if ($draftSame && $publishedSame) {
                    $pageVersions[(string) $pageId] = $currentVersion;
                    $unchanged++;
                    continue;
                }

                $nextVersion = $currentVersion + 1;
                $this->db->insert($this->historyTable, [
                    'builder_page_id' => $pageId,
                    'version_no' => $currentVersion,
                    'layout_json' => (string) ($page['layout_json'] ?? ''),
                    'create_time' => $now,
                ]);
                $data['version_no'] = $nextVersion;
                $data['update_time'] = $now;
                $affected = $this->db->update(
                    $this->builderTable,
                    $data,
                    'id = ? AND version_no = ?',
                    [$pageId, $currentVersion]
                );
                if (intval($affected) !== 1) {
                    throw new \RuntimeException('页面版本已变化，批量同步已回滚');
                }
                $this->trimHistory($pageId);
                $pageVersions[(string) $pageId] = $nextVersion;
                $updated++;
            }
            $this->db->commit();
        } catch (\Throwable $e) {
            $this->db->rollBack();
            return [
                'code' => $e instanceof \InvalidArgumentException ? 422 : 409,
                'msg' => '挂件同步失败，所有页面均未修改：' . $e->getMessage(),
                'updated' => 0,
                'rolled_back' => true,
            ];
        }

        return [
            'code' => 200,
            'msg' => '挂件设置已同步到所有页面',
            'updated' => $updated,
            'unchanged' => $unchanged,
            'page_versions' => $pageVersions,
        ];
    }

      private function trimHistory($builderPageId,$keep=100)
      {
          $keep=max(10,min(500,intval($keep)));$rows=$this->db->fetchAll("SELECT id FROM {$this->db->table($this->historyTable)} WHERE builder_page_id=? ORDER BY id DESC LIMIT {$keep}",[intval($builderPageId)]);if(count($rows)<$keep)return 0;$cutoff=intval($rows[count($rows)-1]['id']);return$this->db->delete($this->historyTable,'builder_page_id=? AND id<?',[intval($builderPageId),$cutoff]);
      }

      public function getHistory($builderPageId)
    {
        if (!$this->isReady()) {
            return [];
        }

        return $this->db->fetchAll(
              "SELECT * FROM {$this->db->table($this->historyTable)} WHERE builder_page_id = ? ORDER BY id DESC LIMIT 100",
            [intval($builderPageId)]
        );
    }

    public function clearHistory($builderPageId)
    {
        if (!$this->isReady()) {
            return ['code' => 500, 'msg' => '页面装修数据表尚未安装'];
        }

        $builderPageId = intval($builderPageId);
        if ($builderPageId <= 0) {
            return ['code' => 400, 'msg' => '页面编号无效'];
        }

        $deleted = $this->db->delete($this->historyTable, 'builder_page_id = ?', [$builderPageId]);

        return ['code' => 200, 'msg' => '当前页面回滚记录已清空', 'deleted' => intval($deleted)];
    }
    public function restoreHistory($builderPageId, $historyId, $expectedVersion = 0)
    {
        if (!$this->isReady()) {
            return ['code' => 500, 'msg' => '页面装修数据表尚未安装'];
        }

        $builderPageId = intval($builderPageId);
        $historyId = intval($historyId);
        if ($builderPageId <= 0 || $historyId <= 0) {
            return ['code' => 400, 'msg' => '回滚参数错误'];
        }

        $page = $this->db->fetch(
            "SELECT * FROM {$this->db->table($this->builderTable)} WHERE id = ? LIMIT 1",
            [$builderPageId]
        );
        if (!$page) {
            return ['code' => 404, 'msg' => '当前装修页面不存在'];
        }
        $expectedVersion = intval($expectedVersion);
        $currentVersion = intval($page['version_no']);
        if ($expectedVersion > 0 && $expectedVersion !== $currentVersion) {
            return ['code' => 409, 'msg' => '页面草稿已在其他标签页更新，不能恢复旧状态', 'version_no' => $currentVersion];
        }

        $history = $this->db->fetch(
            "SELECT * FROM {$this->db->table($this->historyTable)} WHERE id = ? AND builder_page_id = ? LIMIT 1",
            [$historyId, $builderPageId]
        );
        if (!$history) {
            return ['code' => 404, 'msg' => '历史版本不存在'];
        }

        $layout = json_decode((string) $history['layout_json'], true);
        if (!is_array($layout)) {
            return ['code' => 500, 'msg' => '历史版本数据已损坏'];
        }

        $validation = $this->validateLayout($layout);
        if (intval($validation['code']) !== 200) {
            return $validation;
        }

        $now = time();
        $restoredLayout = $validation['layout'];
        $title = trim((string) ($restoredLayout['page']['title'] ?? $page['title'] ?? ''));
        $nextVersion = $currentVersion + 1;
        $where = 'id = ?';
        $params = [intval($page['id'])];
        if ($expectedVersion > 0) {
            $where .= ' AND version_no = ?';
            $params[] = $expectedVersion;
        }
        $updated = $this->db->update($this->builderTable, [
            'title' => $title,
            'layout_json' => json_encode($restoredLayout, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
            'version_no' => $nextVersion,
            'update_time' => $now,
        ], $where, $params);
        if ($updated !== 1) {
            $latest = $this->db->fetch("SELECT version_no FROM {$this->db->table($this->builderTable)} WHERE id = ? LIMIT 1", [intval($page['id'])]);
            return ['code' => 409, 'msg' => '页面草稿已在其他标签页更新，历史版本未恢复', 'version_no' => intval($latest['version_no'] ?? $currentVersion)];
        }
        $this->db->insert($this->historyTable, [
            'builder_page_id' => intval($page['id']),
            'version_no' => $currentVersion,
            'layout_json' => (string) $page['layout_json'],
            'create_time' => $now,
        ]);
        $this->trimHistory(intval($page['id']));

        return [
            'code' => 200,
            'msg' => '历史版本已回滚',
            'version_no' => $nextVersion,
        ];
    }
}
