<?php
require_once __DIR__ . '/template-pack.php';
require_once __DIR__ . '/template-variation.php';

function app_config()
{
    static $config = null;

    if ($config === null) {
        $configFile = __DIR__ . '/config.php';
        $config = file_exists($configFile) ? require $configFile : array();
    }

    return is_array($config) ? $config : array();
}

function db_config()
{
    $config = app_config();
    return isset($config['db']) && is_array($config['db']) ? $config['db'] : array();
}

function db_is_ready()
{
    $db = db_config();
    return !empty($db['host']) && !empty($db['name']) && isset($db['user']);
}

function db_connection()
{
    static $pdo = null;

    if ($pdo instanceof PDO) {
        return $pdo;
    }

    if (!db_is_ready()) {
        throw new Exception('数据库配置不完整，请先检查 app/config.php。');
    }

    $db = db_config();
    $dsn = 'mysql:host=' . $db['host'] . ';port=' . $db['port'] . ';dbname=' . $db['name'] . ';charset=' . $db['charset'];

    $options = array(
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false
    );

    $pdo = new PDO($dsn, $db['user'], isset($db['pass']) ? $db['pass'] : '', $options);
    return $pdo;
}

function find_admin_by_username($username)
{
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT id, username, display_name, password, status, role, initialized_at FROM admins WHERE username = :username LIMIT 1');
    $stmt->execute(array(':username' => $username));
    return $stmt->fetch();
}

function is_password_hash_value($value)
{
    if ($value === '' || !function_exists('password_get_info')) {
        return false;
    }

    $info = password_get_info($value);
    return isset($info['algo']) && !empty($info['algo']);
}

function hash_admin_password($plainPassword)
{
    if (!function_exists('password_hash')) {
        throw new Exception('当前 PHP 环境不支持 password_hash，无法完成密码哈希化。');
    }

    $hash = password_hash($plainPassword, PASSWORD_DEFAULT);
    if ($hash === false) {
        throw new Exception('管理员密码哈希生成失败。');
    }

    return $hash;
}

function upgrade_admin_password_hash($id, $plainPassword)
{
    $pdo = db_connection();
    $stmt = $pdo->prepare('UPDATE admins SET password = :password, updated_at = :updated_at WHERE id = :id LIMIT 1');
    $stmt->execute(array(
        ':password' => hash_admin_password($plainPassword),
        ':updated_at' => date('Y-m-d H:i:s'),
        ':id' => (int)$id
    ));
}

function verify_admin_password($plainPassword, $storedPassword)
{
    if ($storedPassword === '') {
        return false;
    }

    if (!is_password_hash_value($storedPassword)) {
        return false;
    }

    return password_verify($plainPassword, $storedPassword);
}

function authenticate_admin($username, $password)
{
    $admin = find_admin_by_username($username);

    if (!$admin) {
        return false;
    }

    if (!isset($admin['status']) || $admin['status'] !== 'enabled') {
        return false;
    }

    if (!verify_admin_password($password, isset($admin['password']) ? (string)$admin['password'] : '')) {
        return false;
    }

    if (!is_password_hash_value(isset($admin['password']) ? (string)$admin['password'] : '')) {
        upgrade_admin_password_hash(isset($admin['id']) ? (int)$admin['id'] : 0, $password);
        $admin = find_admin_by_username($username);
    }

    return $admin;
}

function list_admins($keyword)
{
    $pdo = db_connection();
    $sql = 'SELECT id, username, display_name, status, role, initialized_at, created_at, updated_at FROM admins WHERE 1=1';
    $params = array();

    if ($keyword !== '') {
        $sql .= ' AND (username LIKE :keyword_username OR display_name LIKE :keyword_display_name)';
        $params[':keyword_username'] = '%' . $keyword . '%';
        $params[':keyword_display_name'] = '%' . $keyword . '%';
    }

    $sql .= ' ORDER BY id DESC';

    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll();
}

function write_admin_operation_log($operatorAdminId, $operatorUsername, $targetAdminId, $targetUsername, $action, $detail)
{
    $pdo = db_connection();
    $stmt = $pdo->prepare('INSERT INTO admin_operation_logs (operator_admin_id, operator_username, target_admin_id, target_username, action, detail, created_at) VALUES (:operator_admin_id, :operator_username, :target_admin_id, :target_username, :action, :detail, :created_at)');
    $stmt->execute(array(
        ':operator_admin_id' => (int)$operatorAdminId,
        ':operator_username' => (string)$operatorUsername,
        ':target_admin_id' => (int)$targetAdminId,
        ':target_username' => (string)$targetUsername,
        ':action' => (string)$action,
        ':detail' => (string)$detail,
        ':created_at' => date('Y-m-d H:i:s')
    ));
}

function log_admin_action($action, $detail)
{
    $adminId = isset($_SESSION['admin_id']) ? (int)$_SESSION['admin_id'] : 0;
    $adminUsername = isset($_SESSION['admin_username']) ? (string)$_SESSION['admin_username'] : '';
    write_admin_operation_log($adminId, $adminUsername, 0, '', (string)$action, (string)$detail);
}

function list_admin_operation_logs($targetAdminId, $keyword)
{
    $pdo = db_connection();
    $sql = 'SELECT id, operator_admin_id, operator_username, target_admin_id, target_username, action, detail, created_at FROM admin_operation_logs WHERE 1=1';
    $params = array();

    if ($targetAdminId > 0) {
        $sql .= ' AND (target_admin_id = :target_admin_id OR operator_admin_id = :operator_admin_id)';
        $params[':target_admin_id'] = (int)$targetAdminId;
        $params[':operator_admin_id'] = (int)$targetAdminId;
    }

    if ($keyword !== '') {
        $sql .= ' AND (operator_username LIKE :keyword_operator OR target_username LIKE :keyword_target OR action LIKE :keyword_action OR detail LIKE :keyword_detail)';
        $params[':keyword_operator'] = '%' . $keyword . '%';
        $params[':keyword_target'] = '%' . $keyword . '%';
        $params[':keyword_action'] = '%' . $keyword . '%';
        $params[':keyword_detail'] = '%' . $keyword . '%';
    }

    $sql .= ' ORDER BY id DESC';

    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll();
}

function admin_status_options()
{
    return array(
        'enabled' => '启用',
        'disabled' => '停用'
    );
}

function create_admin($data)
{
    $pdo = db_connection();

    $checkStmt = $pdo->prepare('SELECT id FROM admins WHERE username = :username LIMIT 1');
    $checkStmt->execute(array(':username' => $data['username']));
    if ($checkStmt->fetch()) {
        throw new Exception('管理员账号 ' . $data['username'] . ' 已存在，请更换账号。');
    }

    $now = date('Y-m-d H:i:s');
    $role = isset($data['role']) && in_array($data['role'], array('super_admin', 'user'), true) ? $data['role'] : 'user';
    $stmt = $pdo->prepare('INSERT INTO admins (username, display_name, password, status, role, created_at, updated_at) VALUES (:username, :display_name, :password, :status, :role, :created_at, :updated_at)');
    $stmt->execute(array(
        ':username' => $data['username'],
        ':display_name' => $data['display_name'],
        ':password' => hash_admin_password($data['password']),
        ':status' => $data['status'],
        ':role' => $role,
        ':created_at' => $now,
        ':updated_at' => $now
    ));

    return $pdo->lastInsertId();
}

function create_admins_table_if_not_exists()
{
    $pdo = db_connection();
    $sql = "CREATE TABLE IF NOT EXISTS `admins` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `username` VARCHAR(100) NOT NULL,
        `display_name` VARCHAR(100) NOT NULL,
        `password` VARCHAR(255) NOT NULL,
        `status` VARCHAR(20) NOT NULL DEFAULT 'enabled',
        `role` VARCHAR(20) NOT NULL DEFAULT 'user',
        `initialized_at` DATETIME DEFAULT NULL,
        `created_at` DATETIME NOT NULL,
        `updated_at` DATETIME NOT NULL,
        PRIMARY KEY (`id`),
        UNIQUE KEY `uk_admin_username` (`username`),
        KEY `idx_admin_status` (`status`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    $pdo->exec($sql);
}

function create_admin_operation_logs_table_if_not_exists()
{
    $pdo = db_connection();
    $sql = "CREATE TABLE IF NOT EXISTS `admin_operation_logs` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `operator_admin_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `operator_username` VARCHAR(100) NOT NULL DEFAULT '',
        `target_admin_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `target_username` VARCHAR(100) NOT NULL DEFAULT '',
        `action` VARCHAR(50) NOT NULL,
        `detail` VARCHAR(255) NOT NULL DEFAULT '',
        `created_at` DATETIME NOT NULL,
        PRIMARY KEY (`id`),
        KEY `idx_operator_admin_id` (`operator_admin_id`),
        KEY `idx_target_admin_id` (`target_admin_id`),
        KEY `idx_action` (`action`),
        KEY `idx_created_at` (`created_at`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    $pdo->exec($sql);
}

function ensure_admin_storage_ready()
{
    static $checked = false;
    if ($checked) return;
    $checked = true;
    if (!table_exists('admins')) {
        create_admins_table_if_not_exists();
    }
    if (!table_exists('admin_operation_logs')) {
        create_admin_operation_logs_table_if_not_exists();
    }
}

function create_menus_table_if_not_exists()
{
    $pdo = db_connection();
    $sql = "CREATE TABLE IF NOT EXISTS `menus` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `domain_id` BIGINT UNSIGNED NOT NULL,
        `menu_name` VARCHAR(120) NOT NULL,
        `menu_slug` VARCHAR(150) NOT NULL,
        `custom_url` VARCHAR(500) NOT NULL DEFAULT '',
        `seo_keywords` VARCHAR(255) NOT NULL DEFAULT '',
        `seo_description` VARCHAR(500) NOT NULL DEFAULT '',
        `site_language` VARCHAR(20) NOT NULL DEFAULT 'zh-CN',
        `show_in_nav` TINYINT(1) NOT NULL DEFAULT 1,
        `sort_order` INT NOT NULL DEFAULT 0,
        `is_home` TINYINT(1) NOT NULL DEFAULT 0,
        `status` VARCHAR(20) NOT NULL DEFAULT 'enabled',
        `created_at` DATETIME NOT NULL,
        `updated_at` DATETIME NOT NULL,
        PRIMARY KEY (`id`),
        UNIQUE KEY `uk_menu_domain_slug` (`domain_id`, `menu_slug`),
        KEY `idx_menu_domain` (`domain_id`),
        KEY `idx_menu_user_id` (`user_id`),
        KEY `idx_menu_nav` (`domain_id`, `show_in_nav`, `status`, `sort_order`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    $pdo->exec($sql);
}

function create_content_menu_relations_table_if_not_exists()
{
    $pdo = db_connection();
    $sql = "CREATE TABLE IF NOT EXISTS `content_menu_relations` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `content_id` BIGINT UNSIGNED NOT NULL,
        `menu_id` BIGINT UNSIGNED NOT NULL,
        `created_at` DATETIME NOT NULL,
        PRIMARY KEY (`id`),
        UNIQUE KEY `uk_content_menu` (`content_id`, `menu_id`),
        KEY `idx_content_menu_menu` (`menu_id`),
        KEY `idx_content_menu_content` (`content_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    $pdo->exec($sql);
}

function create_tags_table_if_not_exists()
{
    $pdo = db_connection();
    $sql = "CREATE TABLE IF NOT EXISTS `tags` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `domain_id` BIGINT UNSIGNED NOT NULL,
        `name` VARCHAR(120) NOT NULL,
        `slug` VARCHAR(180) NOT NULL,
        `seo_title` VARCHAR(255) NOT NULL DEFAULT '',
        `seo_keywords` VARCHAR(255) NOT NULL DEFAULT '',
        `seo_description` VARCHAR(500) NOT NULL DEFAULT '',
        `status` VARCHAR(20) NOT NULL DEFAULT 'enabled',
        `created_at` DATETIME NOT NULL,
        `updated_at` DATETIME NOT NULL,
        PRIMARY KEY (`id`),
        UNIQUE KEY `uk_tag_domain_slug` (`domain_id`, `slug`),
        UNIQUE KEY `uk_tag_domain_name` (`domain_id`, `name`),
        KEY `idx_tag_domain_id` (`domain_id`),
        KEY `idx_tag_status` (`status`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    $pdo->exec($sql);
}

function create_content_tags_table_if_not_exists()
{
    $pdo = db_connection();
    $sql = "CREATE TABLE IF NOT EXISTS `content_tags` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `content_id` BIGINT UNSIGNED NOT NULL,
        `tag_id` BIGINT UNSIGNED NOT NULL,
        `created_at` DATETIME NOT NULL,
        PRIMARY KEY (`id`),
        UNIQUE KEY `uk_content_tag` (`content_id`, `tag_id`),
        KEY `idx_content_tags_content` (`content_id`),
        KEY `idx_content_tags_tag` (`tag_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    $pdo->exec($sql);
}

function ensure_tag_storage_ready()
{
    static $checked = false;
    if ($checked) return;
    $checked = true;
    if (!table_exists('tags')) {
        create_tags_table_if_not_exists();
    }
    if (!table_exists('content_tags')) {
        create_content_tags_table_if_not_exists();
    }
}

function ensure_menu_storage_ready()
{
    static $checked = false;
    if ($checked) return;
    $checked = true;
    if (!table_exists('menus')) {
        create_menus_table_if_not_exists();
    }
    if (!table_exists('content_menu_relations')) {
        create_content_menu_relations_table_if_not_exists();
    }
    if (!column_exists('menus', 'custom_url')) {
        $pdo = db_connection();
        $pdo->exec("ALTER TABLE `menus` ADD COLUMN `custom_url` VARCHAR(500) NOT NULL DEFAULT '' AFTER `menu_slug`");
    }
    if (!column_exists('menus', 'site_language')) {
        $pdo = db_connection();
        $pdo->exec("ALTER TABLE `menus` ADD COLUMN `site_language` VARCHAR(20) NOT NULL DEFAULT 'zh-CN' AFTER `seo_description`");
    }
}

function menu_status_options()
{
    return array(
        'enabled' => '启用',
        'disabled' => '停用'
    );
}

function normalize_menu_slug($value)
{
    return normalize_site_build_slug($value);
}

function ensure_default_home_menu_exists($domainId)
{
    ensure_menu_storage_ready();
    $domainId = (int)$domainId;
    if ($domainId <= 0) {
        return 0;
    }
    $existing = find_home_menu_by_domain($domainId);
    if ($existing) {
        return isset($existing['id']) ? (int)$existing['id'] : 0;
    }
    $pdo = db_connection();
    $stmt = $pdo->prepare("SELECT id FROM menus WHERE domain_id = :did AND menu_slug = 'index' AND user_id = :uid LIMIT 1");
    $stmt->execute(array(':did' => $domainId, ':uid' => current_user_id()));
    $indexMenu = $stmt->fetch(PDO::FETCH_ASSOC);
    $stmt->closeCursor();
    if ($indexMenu) {
        $upd = $pdo->prepare("UPDATE menus SET is_home = 1, sort_order = 999 WHERE id = :id LIMIT 1");
        $upd->execute(array(':id' => (int)$indexMenu['id']));
        return (int)$indexMenu['id'];
    }
    $texts = domain_language_texts(find_domain_by_id($domainId));
    return create_menu(array(
        'domain_id' => $domainId,
        'menu_name' => $texts['home_menu_name'],
        'menu_slug' => 'index',
        'seo_keywords' => $texts['home_menu_name'],
        'seo_description' => $texts['home_menu_seo_description'],
        'show_in_nav' => 1,
        'sort_order' => 999,
        'is_home' => 1,
        'status' => 'enabled'
    ));
}

function list_menus($domainId = 0, $status = '')
{
    ensure_menu_storage_ready();
    $pdo = db_connection();
    $sql = 'SELECT m.*, d.domain AS domain_name, d.site_name AS domain_site_name FROM menus m LEFT JOIN domains d ON d.id = m.domain_id WHERE m.user_id = :uid';
    $params = array(':uid' => current_user_id());
    if ((int)$domainId > 0) {
        $sql .= ' AND m.domain_id = :domain_id';
        $params[':domain_id'] = (int)$domainId;
    }
    if ($status !== '' && $status !== 'all') {
        $sql .= ' AND m.status = :status';
        $params[':status'] = trim((string)$status);
    }
    $sql .= ' ORDER BY m.domain_id ASC, m.sort_order DESC, m.id DESC';
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function list_enabled_menu_options($domainId)
{
    static $cache = array();
    $domainId = (int)$domainId;
    if (array_key_exists($domainId, $cache)) {
        return $cache[$domainId];
    }
    ensure_menu_storage_ready();
    ensure_default_home_menu_exists($domainId);
    $pdo = db_connection();
    $stmt = $pdo->prepare("SELECT id, menu_name, menu_slug, custom_url, is_home FROM menus WHERE domain_id = :domain_id AND status = 'enabled' ORDER BY sort_order DESC, id DESC");
    $stmt->execute(array(':domain_id' => $domainId));
    $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
    $cache[$domainId] = $result;
    return $result;
}

function list_nav_menus_by_domain($domainId)
{
    static $cache = array();
    $domainId = (int)$domainId;
    if (array_key_exists($domainId, $cache)) {
        return $cache[$domainId];
    }
    ensure_menu_storage_ready();
    ensure_default_home_menu_exists($domainId);
    $pdo = db_connection();
    $stmt = $pdo->prepare("SELECT * FROM menus WHERE domain_id = :domain_id AND status = 'enabled' AND show_in_nav = 1 ORDER BY is_home DESC, sort_order DESC, id DESC");
    $stmt->execute(array(':domain_id' => $domainId));
    $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
    $cache[$domainId] = $result;
    return $result;
}

function find_menu_by_id($id)
{
    ensure_menu_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT * FROM menus WHERE id = :id LIMIT 1');
    $stmt->execute(array(':id' => (int)$id));
    return $stmt->fetch(PDO::FETCH_ASSOC);
}

function find_home_menu_by_domain($domainId)
{
    static $cache = array();
    $domainId = (int)$domainId;
    if (array_key_exists($domainId, $cache)) {
        return $cache[$domainId];
    }
    ensure_menu_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT * FROM menus WHERE domain_id = :domain_id AND is_home = 1 LIMIT 1');
    $stmt->execute(array(':domain_id' => $domainId));
    $result = $stmt->fetch(PDO::FETCH_ASSOC);
    $cache[$domainId] = $result;
    return $result;
}

function find_menu_by_domain_and_slug($domainId, $menuSlug)
{
    ensure_menu_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT * FROM menus WHERE domain_id = :domain_id AND menu_slug = :menu_slug LIMIT 1');
    $stmt->execute(array(
        ':domain_id' => (int)$domainId,
        ':menu_slug' => normalize_menu_slug($menuSlug)
    ));
    return $stmt->fetch(PDO::FETCH_ASSOC);
}

function normalize_menu_data($data)
{
    $statusOptions = menu_status_options();
    $status = isset($data['status']) ? trim((string)$data['status']) : 'enabled';
    if (!isset($statusOptions[$status])) {
        $status = 'enabled';
    }
    $domainId = isset($data['domain_id']) ? (int)$data['domain_id'] : 0;
    $domainRecord = $domainId > 0 ? find_domain_by_id($domainId) : null;
    return array(
        'domain_id' => $domainId,
        'menu_name' => isset($data['menu_name']) ? trim((string)$data['menu_name']) : '',
        'menu_slug' => isset($data['menu_slug']) ? normalize_menu_slug($data['menu_slug']) : '',
        'seo_keywords' => isset($data['seo_keywords']) ? trim((string)$data['seo_keywords']) : '',
        'seo_description' => isset($data['seo_description']) ? trim((string)$data['seo_description']) : '',
        'site_language' => isset($data['site_language']) ? normalize_domain_language($data['site_language']) : domain_site_language($domainRecord),
        'show_in_nav' => !empty($data['show_in_nav']) ? 1 : 0,
        'sort_order' => isset($data['sort_order']) ? (int)$data['sort_order'] : 0,
        'is_home' => !empty($data['is_home']) ? 1 : 0,
        'custom_url' => isset($data['custom_url']) ? trim((string)$data['custom_url']) : '',
        'status' => $status
    );
}

function validate_menu_data($data, $ignoreId = 0)
{
    if ((int)$data['domain_id'] <= 0) {
        throw new Exception('请选择菜单所属域名。');
    }
    if ($data['menu_name'] === '') {
        throw new Exception('请填写菜单名称。');
    }
    $hasCustomUrl = isset($data['custom_url']) && trim((string)$data['custom_url']) !== '';
    if ((int)$data['is_home'] !== 1 && !$hasCustomUrl && $data['menu_slug'] === '') {
        throw new Exception('请填写菜单别名或自定义链接。');
    }
    if ((int)$data['is_home'] === 1) {
        $data['menu_slug'] = 'index';
    }
    $checkSlug = (int)$data['is_home'] === 1 ? 'index' : $data['menu_slug'];
    if ($checkSlug !== '') {
        $pdo = db_connection();
        $sql = 'SELECT id, menu_slug FROM menus WHERE domain_id = :domain_id AND menu_slug = :menu_slug';
        $params = array(':domain_id' => (int)$data['domain_id'], ':menu_slug' => $checkSlug);
        if ((int)$ignoreId > 0) {
            $sql .= ' AND id <> :id';
            $params[':id'] = (int)$ignoreId;
        }
        $sql .= ' LIMIT 1';
        $stmt = $pdo->prepare($sql);
        $stmt->execute($params);
        $dup = $stmt->fetch(PDO::FETCH_ASSOC);
        $stmt->closeCursor();
        if ($dup) {
            throw new Exception('当前域名下该菜单别名已存在（domain_id=' . (int)$data['domain_id'] . ', slug=' . $checkSlug . ', 冲突menu_id=' . $dup['id'] . '）。');
        }
    }
}

function create_menu($data)
{
    ensure_menu_storage_ready();
    $data = normalize_menu_data($data);
    if ((int)$data['is_home'] === 1) {
        $data['menu_slug'] = 'index';
    }
    validate_menu_data($data, 0);
    $pdo = db_connection();
    $now = date('Y-m-d H:i:s');
    if ((int)$data['is_home'] === 1) {
        $reset = $pdo->prepare('UPDATE menus SET is_home = 0 WHERE domain_id = :domain_id');
        $reset->execute(array(':domain_id' => (int)$data['domain_id']));
    }
    $stmt = $pdo->prepare('INSERT INTO menus (user_id, domain_id, menu_name, menu_slug, custom_url, seo_keywords, seo_description, site_language, show_in_nav, sort_order, is_home, status, created_at, updated_at) VALUES (:uid, :domain_id, :menu_name, :menu_slug, :custom_url, :seo_keywords, :seo_description, :site_language, :show_in_nav, :sort_order, :is_home, :status, :created_at, :updated_at)');
    $stmt->execute(array(
        ':uid' => current_user_id(),
        ':domain_id' => (int)$data['domain_id'],
        ':menu_name' => $data['menu_name'],
        ':menu_slug' => $data['menu_slug'],
        ':custom_url' => $data['custom_url'],
        ':seo_keywords' => $data['seo_keywords'],
        ':seo_description' => $data['seo_description'],
        ':site_language' => $data['site_language'],
        ':show_in_nav' => (int)$data['show_in_nav'],
        ':sort_order' => (int)$data['sort_order'],
        ':is_home' => (int)$data['is_home'],
        ':status' => $data['status'],
        ':created_at' => $now,
        ':updated_at' => $now
    ));
    return (int)$pdo->lastInsertId();
}

function update_menu($id, $data)
{
    ensure_menu_storage_ready();
    $existing = find_menu_by_id((int)$id);
    if (!$existing) {
        throw new Exception('未找到对应菜单。');
    }
    $data = normalize_menu_data($data);
    if ((int)$data['is_home'] === 1) {
        $data['menu_slug'] = 'index';
    }
    validate_menu_data($data, (int)$id);
    $pdo = db_connection();
    if ((int)$data['is_home'] === 1) {
        $reset = $pdo->prepare('UPDATE menus SET is_home = 0 WHERE domain_id = :domain_id AND id <> :id');
        $reset->execute(array(':domain_id' => (int)$data['domain_id'], ':id' => (int)$id));
    }
    $stmt = $pdo->prepare('UPDATE menus SET domain_id = :domain_id, menu_name = :menu_name, menu_slug = :menu_slug, custom_url = :custom_url, seo_keywords = :seo_keywords, seo_description = :seo_description, site_language = :site_language, show_in_nav = :show_in_nav, sort_order = :sort_order, is_home = :is_home, status = :status, updated_at = :updated_at WHERE id = :id LIMIT 1');
    $stmt->execute(array(
        ':domain_id' => (int)$data['domain_id'],
        ':menu_name' => $data['menu_name'],
        ':menu_slug' => $data['menu_slug'],
        ':custom_url' => $data['custom_url'],
        ':seo_keywords' => $data['seo_keywords'],
        ':seo_description' => $data['seo_description'],
        ':site_language' => $data['site_language'],
        ':show_in_nav' => (int)$data['show_in_nav'],
        ':sort_order' => (int)$data['sort_order'],
        ':is_home' => (int)$data['is_home'],
        ':status' => $data['status'],
        ':updated_at' => date('Y-m-d H:i:s'),
        ':id' => (int)$id
    ));
    return true;
}

function delete_menu($id)
{
    ensure_menu_storage_ready();
    $existing = find_menu_by_id((int)$id);
    if (!$existing) {
        return false;
    }
    $pdo = db_connection();
    if (!empty($existing['is_home'])) {
        $domainStillExists = false;
        if ((int)$existing['domain_id'] > 0) {
            $chk = $pdo->prepare('SELECT id FROM domains WHERE id = :id LIMIT 1');
            $chk->execute(array(':id' => (int)$existing['domain_id']));
            $domainStillExists = (bool)$chk->fetch();
        }
        if ($domainStillExists) {
            throw new Exception('默认首页菜单不允许删除，可改为停用或设置其他菜单为首页。');
        }
    }
    $cleanup = $pdo->prepare('DELETE FROM content_menu_relations WHERE menu_id = :menu_id');
    $cleanup->execute(array(':menu_id' => (int)$id));
    $taskCleanup = $pdo->prepare('UPDATE article_generation_tasks SET target_menu_id = 0 WHERE target_menu_id = :menu_id');
    $taskCleanup->execute(array(':menu_id' => (int)$id));
    if (column_exists('article_generation_tasks', 'target_menu_ids')) {
        $menuIdStr = (string)(int)$id;
        $tasks = $pdo->query('SELECT id, target_menu_ids FROM article_generation_tasks WHERE target_menu_ids IS NOT NULL AND target_menu_ids != \'\'')->fetchAll(PDO::FETCH_ASSOC);
        foreach ($tasks as $t) {
            $ids = parse_target_menu_ids($t['target_menu_ids']);
            $filtered = array_values(array_filter($ids, function ($v) use ($id) { return (int)$v !== (int)$id; }));
            $newVal = serialize_target_menu_ids($filtered);
            if ($newVal !== $t['target_menu_ids']) {
                $pdo->prepare('UPDATE article_generation_tasks SET target_menu_ids = :val WHERE id = :id')->execute(array(':val' => $newVal, ':id' => (int)$t['id']));
            }
        }
    }
    $stmt = $pdo->prepare('DELETE FROM menus WHERE id = :id LIMIT 1');
    $stmt->execute(array(':id' => (int)$id));
    return $stmt->rowCount() > 0;
}

function list_content_menu_ids($contentId)
{
    ensure_menu_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT menu_id FROM content_menu_relations WHERE content_id = :content_id ORDER BY id ASC');
    $stmt->execute(array(':content_id' => (int)$contentId));
    return array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN));
}

function replace_content_menu_relations($contentId, $menuIds)
{
    ensure_menu_storage_ready();
    $pdo = db_connection();
    $delete = $pdo->prepare('DELETE FROM content_menu_relations WHERE content_id = :content_id');
    $delete->execute(array(':content_id' => (int)$contentId));
    if (empty($menuIds)) {
        return;
    }
    $insert = $pdo->prepare('INSERT INTO content_menu_relations (content_id, menu_id, created_at) VALUES (:content_id, :menu_id, :created_at)');
    $now = date('Y-m-d H:i:s');
    foreach ($menuIds as $menuId) {
        if ((int)$menuId <= 0) {
            continue;
        }
        $insert->execute(array(
            ':content_id' => (int)$contentId,
            ':menu_id' => (int)$menuId,
            ':created_at' => $now
        ));
    }
}

function find_primary_menu_for_content($contentId)
{
    static $cache = array();
    $contentId = (int)$contentId;
    if (array_key_exists($contentId, $cache)) {
        return $cache[$contentId];
    }
    ensure_menu_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare("SELECT m.* FROM content_menu_relations r INNER JOIN menus m ON m.id = r.menu_id WHERE r.content_id = :content_id ORDER BY m.is_home DESC, m.sort_order DESC, m.id DESC LIMIT 1");
    $stmt->execute(array(':content_id' => $contentId));
    $result = $stmt->fetch(PDO::FETCH_ASSOC);
    $cache[$contentId] = $result;
    return $result;
}

function list_published_contents_by_menu($menuId, $limit = 50)
{
    static $cache = array();
    $menuId = (int)$menuId;
    $limit = max(1, min(100, (int)$limit));
    $cacheKey = $menuId . '|' . $limit;
    if (array_key_exists($cacheKey, $cache)) {
        return $cache[$cacheKey];
    }
    ensure_menu_storage_ready();
    ensure_content_storage_ready();
    $pdo = db_connection();
    $sql = "SELECT c.id, c.title, c.path, c.seo_title, c.seo_description, c.seo_keywords, c.cover_image, c.updated_at, c.static_file_path
        FROM content_menu_relations r
        INNER JOIN contents c ON c.id = r.content_id
        WHERE r.menu_id = :menu_id AND c.status = 'published'
        ORDER BY c.id DESC LIMIT " . $limit;
    $stmt = $pdo->prepare($sql);
    $stmt->execute(array(':menu_id' => $menuId));
    $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
    $cache[$cacheKey] = $result;
    return $result;
}

function count_published_contents_by_menu($menuId)
{
    static $cache = array();
    $menuId = (int)$menuId;
    if (array_key_exists($menuId, $cache)) {
        return $cache[$menuId];
    }
    ensure_menu_storage_ready();
    ensure_content_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare("SELECT COUNT(*) FROM content_menu_relations r INNER JOIN contents c ON c.id = r.content_id WHERE r.menu_id = :menu_id AND c.status = 'published'");
    $stmt->execute(array(':menu_id' => $menuId));
    $result = (int)$stmt->fetchColumn();
    $cache[$menuId] = $result;
    return $result;
}

function list_published_contents_by_menu_page($menuId, $page = 1, $pageSize = 10)
{
    static $cache = array();
    $menuId = (int)$menuId;
    $page = max(1, (int)$page);
    $pageSize = max(1, min(100, (int)$pageSize));
    $cacheKey = $menuId . '|' . $page . '|' . $pageSize;
    if (array_key_exists($cacheKey, $cache)) {
        return $cache[$cacheKey];
    }
    ensure_menu_storage_ready();
    ensure_content_storage_ready();
    $pdo = db_connection();
    $offset = ($page - 1) * $pageSize;
    $sql = "SELECT c.id, c.title, c.path, c.seo_title, c.seo_description, c.seo_keywords, c.cover_image, c.updated_at, c.static_file_path
        FROM content_menu_relations r
        INNER JOIN contents c ON c.id = r.content_id
        WHERE r.menu_id = :menu_id AND c.status = 'published'
        ORDER BY c.id DESC LIMIT " . $pageSize . " OFFSET " . $offset;
    $stmt = $pdo->prepare($sql);
    $stmt->execute(array(':menu_id' => $menuId));
    $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
    $cache[$cacheKey] = $result;
    return $result;
}

function render_archive_pagination_html($baseUrl, $currentPage, $totalPages)
{
    $currentPage = max(1, (int)$currentPage);
    $totalPages = max(1, (int)$totalPages);
    if ($totalPages <= 1) {
        return '';
    }
    $baseUrl = trim((string)$baseUrl);
    $html = '<div class="archive-pagination">';
    if ($currentPage > 1) {
        $prevUrl = $currentPage === 2 ? $baseUrl : archive_page_url($baseUrl, $currentPage - 1);
        $html .= '<a class="archive-page-link" href="' . e($prevUrl) . '">上一页</a>';
    }
    for ($i = 1; $i <= $totalPages; $i++) {
        if ($i === 1 || $i === $totalPages || abs($i - $currentPage) <= 2) {
            $pageUrl = $i === 1 ? $baseUrl : archive_page_url($baseUrl, $i);
            $html .= '<a class="archive-page-link' . ($i === $currentPage ? ' is-active' : '') . '" href="' . e($pageUrl) . '">' . $i . '</a>';
        } elseif ($i === 2 && $currentPage > 4) {
            $html .= '<span class="archive-page-dots">...</span>';
        } elseif ($i === $totalPages - 1 && $currentPage < $totalPages - 3) {
            $html .= '<span class="archive-page-dots">...</span>';
        }
    }
    if ($currentPage < $totalPages) {
        $nextUrl = archive_page_url($baseUrl, $currentPage + 1);
        $html .= '<a class="archive-page-link" href="' . e($nextUrl) . '">下一页</a>';
    }
    $html .= '</div>';
    return $html;
}

function archive_page_url($baseUrl, $page)
{
    $baseUrl = trim((string)$baseUrl);
    $page = max(1, (int)$page);
    if ($page <= 1) {
        return $baseUrl;
    }
    // 动态模式（/index.php?slug=xxx）：附加 &page=N
    if (strpos($baseUrl, '/index.php') === 0) {
        $sep = strpos($baseUrl, '?') === false ? '?' : '&';
        return $baseUrl . $sep . 'page=' . $page;
    }
    // 伪静态模式：/slug/page/N/
    return rtrim($baseUrl, '/') . '/page/' . $page . '/';
}

function menu_public_url_path($path)
{
    $path = normalize_menu_slug($path);
    if ($path === '' || $path === 'index') {
        return '/';
    }
    if (pseudo_static_enabled() || archive_static_index_exists($path)) {
        return '/' . $path . '/';
    }
    return '/index.php?slug=' . rawurlencode($path);
}

function article_archive_public_url_path($path = 'articles')
{
    $path = normalize_content_path($path);
    if ($path === '') {
        $path = 'articles';
    }
    $path = trim($path, '/');
    if (pseudo_static_enabled() || archive_static_index_exists($path)) {
        return '/' . $path . '/';
    }
    return '/index.php?slug=' . rawurlencode($path);
}

/**
 * 检查当前请求域名站点根下是否存在 {path}/index.html 静态文件。
 * 用于栏目/标签/聚合页的链接生成与「伪静态 OFF + 已生成静态」语义判断。
 * 按 HTTP_HOST + path 缓存，避免一次请求重复 stat。
 */
function archive_static_index_exists($path)
{
    static $cache = array();
    $path = trim((string)$path, '/');
    if ($path === '') { return false; }
    $host = isset($_SERVER['HTTP_HOST']) ? strtolower((string)$_SERVER['HTTP_HOST']) : '';
    if ($host === '') { return false; }
    $key = $host . '|' . $path;
    if (array_key_exists($key, $cache)) { return $cache[$key]; }

    static $domainCache = array();
    if (!array_key_exists($host, $domainCache)) {
        try {
            $domainCache[$host] = find_domain_by_host($host);
        } catch (Exception $e) {
            $domainCache[$host] = null;
        }
    }
    $domain = $domainCache[$host];
    if (!$domain) { return $cache[$key] = false; }
    $siteRoot = domain_static_site_root($domain);
    $file = $siteRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path) . DIRECTORY_SEPARATOR . 'index.html';
    return $cache[$key] = is_file($file);
}

function render_frontend_menu_nav_html($menus, $currentPath = '', $currentMenuId = 0)
{
    if (!is_array($menus) || empty($menus)) {
        return '';
    }
    $currentPath = normalize_content_path($currentPath);
    $html = '';
    foreach ($menus as $menu) {
        $menuId = isset($menu['id']) ? (int)$menu['id'] : 0;
        $isHome = !empty($menu['is_home']);
        $hasCustomUrl = isset($menu['custom_url']) && trim((string)$menu['custom_url']) !== '';
        $url = $hasCustomUrl ? trim((string)$menu['custom_url']) : ($isHome ? '/' : menu_public_url_path(trim((string)$menu['menu_slug'])));
        $isActive = ($currentMenuId > 0 && $currentMenuId === $menuId) || ($isHome && is_homepage_content_path($currentPath)) || (!$isHome && !$hasCustomUrl && trim((string)$menu['menu_slug']) === $currentPath);
        $html .= '<a href="' . e($url) . '"' . ($isActive ? ' class="is-active"' : '') . ' title="' . e(isset($menu['menu_name']) ? (string)$menu['menu_name'] : '') . '">' . e(isset($menu['menu_name']) ? (string)$menu['menu_name'] : '') . '</a>';
    }
    return $html;
}

function resolve_frontend_menu_page($domainId, $path, $page = 1)
{
    $path = normalize_content_path($path);
    if ($path === '') {
        return null;
    }
    // 解析伪静态分页格式：category-slug/page/2
    $parsedPage = (int)$page;
    if (preg_match('#^(.+)/page/(\d+)$#i', $path, $m)) {
        $path = trim($m[1], '/');
        $parsedPage = max(1, (int)$m[2]);
    }
    $menu = find_menu_by_domain_and_slug((int)$domainId, $path);
    if (!$menu || !isset($menu['status']) || $menu['status'] !== 'enabled') {
        return null;
    }
    return array(
        '__page_type' => 'menu_archive',
        'menu_id' => isset($menu['id']) ? (int)$menu['id'] : 0,
        'domain_id' => (int)$domainId,
        'path' => $path,
        'page' => max(1, $parsedPage)
    );
}

function default_archive_styles()
{
    return 'body{margin:0;font-family:"PingFang SC","Microsoft YaHei",sans-serif;background:#f8fafc;color:#1e293b}*{box-sizing:border-box}a{text-decoration:none;color:inherit}.archive-header{padding:28px clamp(18px,4vw,48px) 34px;background:linear-gradient(180deg,#ffffff 0%,#eff6ff 100%);border-bottom:1px solid rgba(148,163,184,.2)}.archive-topbar,.archive-layout,.archive-footer{max-width:1180px;margin:0 auto}.archive-topbar{display:flex;justify-content:space-between;align-items:center;gap:16px;color:#64748b}.archive-brand{font-size:20px;font-weight:700;color:#0f172a}.archive-nav{display:flex;align-items:center;justify-content:flex-end;gap:12px;flex:1;flex-wrap:wrap}.archive-nav a{display:inline-flex;align-items:center;justify-content:center;min-height:38px;padding:0 14px;border-radius:999px;background:rgba(255,255,255,.72);border:1px solid rgba(148,163,184,.22);color:#334155;transition:all .2s ease}.archive-nav a:hover{background:rgba(219,234,254,.86);border-color:rgba(96,165,250,.28);color:#1d4ed8}.archive-nav a.is-active{background:#1d4ed8;border-color:#1d4ed8;color:#fff;font-weight:700}.archive-layout{display:flex;flex-direction:column;gap:18px;padding:32px clamp(18px,4vw,48px) 40px}.archive-content{display:grid;grid-template-columns:minmax(0,1.8fr) 320px;gap:24px;align-items:start}.archive-main-wrap{min-width:0}.archive-breadcrumb{display:flex;gap:10px;flex-wrap:wrap;font-size:13px;color:#64748b;padding:0 4px;width:100%}.archive-breadcrumb a,.archive-breadcrumb span{color:#64748b}.archive-main,.archive-side-card{background:#fff;border-radius:24px;box-shadow:0 18px 46px rgba(15,23,42,.07)}.archive-main{padding:clamp(24px,4vw,36px)}.archive-main-header{margin-bottom:22px}.archive-main-header h1{margin:0;font-size:24px;line-height:1.18;color:#0f172a}.archive-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.article-list-card-link{display:flex;flex-direction:column;gap:12px}.article-list-thumb{position:relative;display:block;aspect-ratio:1.6 / 1;border-radius:18px;overflow:hidden;background:linear-gradient(135deg,#e2e8f0 0%,#f8fafc 100%)}.article-list-thumb img,.article-list-thumb-placeholder{width:100%;height:100%}.article-list-thumb img{object-fit:cover}.article-list-thumb-placeholder{display:flex;align-items:center;justify-content:center;padding:16px;text-align:center;color:#475569;line-height:1.6}.article-list-body{display:block;min-width:0}.article-list-title{display:block;min-width:0;font-size:20px;font-weight:400;line-height:1.45;color:#0f172a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.article-list-time{position:absolute;top:12px;left:12px;padding:6px 10px;border-radius:999px;background:rgba(15,23,42,.78);color:#fff;font-size:12px;letter-spacing:.4px}.article-list-summary{display:none}.archive-side{display:flex;flex-direction:column;gap:18px;align-self:start}.archive-side-card{padding:24px}.archive-side-card h3{margin:0 0 14px;font-size:20px;color:#0f172a}.random-article-list{display:flex;flex-direction:column;gap:12px}.random-article-link{display:flex;align-items:center;gap:10px;width:100%;min-width:0;color:#334155;line-height:1.7;padding-bottom:12px;border-bottom:1px dashed rgba(148,163,184,.2)}.random-article-link:last-child{padding-bottom:0;border-bottom:0}.random-article-index{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;min-width:34px;height:28px;padding:0 10px;border-radius:10px;background:#e2e8f0;color:#475569;font-size:13px;font-weight:600;line-height:1}.random-article-text{display:block;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.archive-pagination{display:flex;flex-wrap:wrap;gap:10px;margin-top:28px}.archive-page-link,.archive-page-current,.archive-page-dots{display:inline-flex;align-items:center;justify-content:center;min-width:42px;height:42px;padding:0 14px;border-radius:999px;background:#fff;border:1px solid rgba(148,163,184,.24);color:#0f172a}.archive-page-current{background:#2563eb;border-color:#2563eb;color:#fff}.archive-ad-wrap,.article-ad-slot{margin-bottom:18px}.article-ad-slot img{max-width:100%;display:block;border-radius:18px}.archive-footer{max-width:1180px;margin:32px auto 0;padding:24px clamp(18px,4vw,48px) 36px;border-top:1px solid rgba(148,163,184,.18);color:#94a3b8;font-size:13px;text-align:center}.archive-footer p{margin:0}.archive-footer a{color:#3b82f6;transition:color .2s}.archive-footer a:hover{color:#1d4ed8}@media (max-width: 960px){.archive-content{grid-template-columns:1fr}.archive-grid{grid-template-columns:1fr 1fr}}@media (max-width: 640px){.archive-header{padding:22px 18px 26px}.archive-layout{padding:24px 18px 32px}.archive-grid{grid-template-columns:1fr}.archive-topbar{flex-direction:column;align-items:flex-start}.archive-nav{width:100%}}';
}

function default_archive_header_html($siteName, $domainName, $navHtml, $siteLogoHtml = '')
{
    $brandContent = ($siteLogoHtml !== '') ? $siteLogoHtml : e($siteName);
    return '<header class="archive-header"><div class="archive-topbar"><a class="archive-brand" href="/">' . $brandContent . '</a><nav class="archive-nav">' . $navHtml . '</nav>' . build_search_box_html() . '</div></header>';
}

function default_archive_breadcrumb_html($items)
{
    $html = '<nav class="archive-breadcrumb" aria-label="breadcrumb">';
    foreach ((array)$items as $index => $item) {
        if ($index > 0) {
            $html .= '<span>/</span>';
        }
        $label = isset($item['label']) ? (string)$item['label'] : '';
        $url = isset($item['url']) ? trim((string)$item['url']) : '';
        $html .= $url !== '' ? '<a href="' . e($url) . '">' . e($label) . '</a>' : '<span>' . e($label) . '</span>';
    }
    return $html . '</nav>';
}

function default_archive_main_card_html($heading, $cardsHtml, $paginationHtml = '', $topAds = '')
{
    return '<div class="archive-main">' . $topAds . '<div class="archive-main-header"><h1>' . e($heading) . '</h1></div><div class="archive-grid">' . $cardsHtml . '</div>' . $paginationHtml . '</div>';
}

function default_archive_sidebar_html($randomLinks, $sidebarAds = '')
{
    return '<aside class="archive-side">' . ($sidebarAds !== '' ? '<div class="archive-ad-wrap">' . $sidebarAds . '</div>' : '') . '<div class="archive-side-card"><div class="random-article-list">' . $randomLinks . '</div></div></aside>';
}

function default_archive_footer_html($text)
{
    $sitemapLinks = build_sitemap_footer_html();
    return '<footer class="archive-footer"><p>' . $text . '</p>' . $sitemapLinks . '</footer>';
}

function render_default_archive_page($options)
{
    $title = isset($options['title']) ? (string)$options['title'] : '';
    $metaKeywords = isset($options['meta_keywords']) ? (string)$options['meta_keywords'] : '';
    $metaDescription = isset($options['meta_description']) ? (string)$options['meta_description'] : '';
    $siteName = isset($options['site_name']) ? (string)$options['site_name'] : '';
    $domainName = isset($options['domain_name']) ? (string)$options['domain_name'] : '';
    $navHtml = isset($options['nav_html']) ? (string)$options['nav_html'] : '';
    $breadcrumbItems = isset($options['breadcrumb_items']) ? (array)$options['breadcrumb_items'] : array();
    $heading = isset($options['heading']) ? (string)$options['heading'] : '';
    $cardsHtml = isset($options['cards_html']) ? (string)$options['cards_html'] : '';
    $paginationHtml = isset($options['pagination_html']) ? (string)$options['pagination_html'] : '';
    $topAds = isset($options['top_ads']) ? (string)$options['top_ads'] : '';
    $sidebarAds = isset($options['sidebar_ads']) ? (string)$options['sidebar_ads'] : '';
    $randomLinks = isset($options['random_links']) ? (string)$options['random_links'] : '';
    $footerText = isset($options['footer_text']) ? (string)$options['footer_text'] : '';
    $templatePackCode = isset($options['template_pack']) ? trim((string)$options['template_pack']) : '';
    $domainRecord = isset($options['domain_record']) && is_array($options['domain_record']) ? $options['domain_record'] : array('site_name' => $siteName);
    $siteLogoHtml = build_site_logo_html($domainRecord);
    $siteLogoUrl = normalize_logo_url_to_relative(isset($domainRecord['logo_url']) ? (string)$domainRecord['logo_url'] : '');

    $breadcrumbHtml = default_archive_breadcrumb_html($breadcrumbItems);
    $mainCardHtml = default_archive_main_card_html($heading, $cardsHtml, $paginationHtml, $topAds);
    $sidebarHtml = default_archive_sidebar_html($randomLinks, $sidebarAds);
    
    // 获取站点语言信息
    $siteLanguage = $domainRecord ? domain_site_language($domainRecord) : 'zh-CN';
    $siteLocale = $siteLanguage;
    $siteLanguageShort = strtolower(substr(str_replace('_', '-', $siteLanguage), 0, 2));
    
    // 生成栏目页 SEO head
    $archiveSeoHead = '';
    $archiveRuntimeCfg = app_runtime_config();
    if (isset($archiveRuntimeCfg['seo_head']['enabled']) && $archiveRuntimeCfg['seo_head']['enabled']) {
        $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
        $archivePath = isset($options['path']) ? (string)$options['path'] : '/';
        $archiveSeoHead = build_seo_head_html(array(
            'domain_name' => $domainName,
            'title' => $heading,
            'seo_title' => $title,
            'seo_description' => $metaDescription,
            'cover_image' => '',
            'path' => $archivePath
        ), false, array(
            'public_path' => $archivePath,
            'site_name' => $siteName,
            'primary_menu' => null
        ));
    }

    $archiveVars = array(
        '{{seo_title}}' => $title,
        '{{seo_keywords}}' => $metaKeywords,
        '{{seo_description}}' => $metaDescription,
        '{{seo_head}}' => $archiveSeoHead,
        '{{site_name}}' => $siteName,
        '{{site_logo}}' => $siteLogoHtml,
        '{{site_logo_url}}' => $siteLogoUrl,
        '{{site_language}}' => $siteLanguage,
        '{{site_locale}}' => $siteLocale,
        '{{html_lang}}' => $siteLanguageShort,
        '{{domain}}' => $domainName,
        '{{menu_nav}}' => $navHtml,
        '{{title}}' => $heading,
        '{{breadcrumb}}' => $breadcrumbHtml,
        '{{archive_content}}' => $mainCardHtml,
        '{{archive_sidebar}}' => $sidebarHtml,
        '{{archive_pagination}}' => $paginationHtml,
        '{{archive_cards}}' => $cardsHtml,
        '{{article_ad_top}}' => $topAds,
        '{{article_ad_sidebar}}' => $sidebarAds,
        '{{random_articles}}' => $randomLinks,
        '{{footer_text}}' => $footerText,
        '{{year}}' => date('Y'),
        '{{search_box}}' => build_search_box_html(),
        '{{menu_keywords}}' => isset($options['menu_keywords']) ? (string)$options['menu_keywords'] : '',
        '{{menu_description}}' => isset($options['menu_description']) ? (string)$options['menu_description'] : '',
        '{{path}}' => isset($options['path']) ? (string)$options['path'] : ''
    );

    if ($templatePackCode !== '' && load_template_pack_manifest($templatePackCode)) {
        $packHtml = render_template_pack_page($templatePackCode, 'archive', $archiveVars);
        if ($packHtml !== '') {
            return $packHtml;
        }
    }

    $archiveTemplate = find_enabled_archive_template();
    if ($archiveTemplate && isset($archiveTemplate['html_content']) && trim($archiveTemplate['html_content']) !== '') {
        return render_template_html(
            $archiveTemplate['html_content'],
            isset($archiveTemplate['css_content']) ? $archiveTemplate['css_content'] : '',
            $archiveVars
        );
    }

    $styles = default_archive_styles();
    $headerHtml = default_archive_header_html($siteName, $domainName, $navHtml, $siteLogoHtml);
    $breadcrumbHtml = default_archive_breadcrumb_html($breadcrumbItems);
    $mainCardHtml = default_archive_main_card_html($heading, $cardsHtml, $paginationHtml, $topAds);
    $sidebarHtml = default_archive_sidebar_html($randomLinks, $sidebarAds);
    $footerHtml = default_archive_footer_html($footerText);
    $runtimeCfg = app_runtime_config();
    $analyticsSnippet = isset($runtimeCfg['analytics_code']) ? trim((string)$runtimeCfg['analytics_code']) : '';
    return '<!DOCTYPE html><html lang="' . e($siteLanguageShort) . '"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>' . e($title) . '</title><meta name="keywords" content="' . e($metaKeywords) . '"><meta name="description" content="' . e($metaDescription) . '">' . ($analyticsSnippet !== '' ? "\n" . $analyticsSnippet : '') . '<style>' . $styles . '</style></head><body><div class="archive-shell">' . $headerHtml . '<main class="archive-layout">' . $breadcrumbHtml . '<div class="archive-content"><section class="archive-main-wrap">' . $mainCardHtml . '</section>' . $sidebarHtml . '</div></main>' . $footerHtml . '</div></body></html>';
}

function build_default_menu_archive_page_html($domain, $menu, $currentPage = 1)
{
    $siteName = isset($domain['site_name']) ? trim((string)$domain['site_name']) : '';
    if ($siteName === '') {
        $siteName = isset($domain['domain']) ? trim((string)$domain['domain']) : '内容中心';
    }
    $domainName = isset($domain['domain']) ? trim((string)$domain['domain']) : '';
    $texts = domain_language_texts($domain);
    $menuName = isset($menu['menu_name']) ? trim((string)$menu['menu_name']) : $texts['articles_menu_name'];
    $currentPage = max(1, (int)$currentPage);
    $title = $menuName . ($currentPage > 1 ? ' - 第' . $currentPage . '页' : '') . ' - ' . $siteName;
    $keywords = isset($menu['seo_keywords']) ? trim((string)$menu['seo_keywords']) : $menuName;
    $rCfg = app_runtime_config();
    $pageSize = isset($rCfg['archive_page_size']) && (int)$rCfg['archive_page_size'] > 0 ? (int)$rCfg['archive_page_size'] : 10;
    $totalItems = count_published_contents_by_menu(isset($menu['id']) ? (int)$menu['id'] : 0);
    $totalPages = max(1, (int)ceil($totalItems / $pageSize));
    if ($currentPage > $totalPages) {
        $currentPage = $totalPages;
    }
    $items = list_published_contents_by_menu_page(isset($menu['id']) ? (int)$menu['id'] : 0, $currentPage, $pageSize);
    $cardsHtml = render_article_list_cards_html($items);
    $navMenus = list_nav_menus_by_domain(isset($domain['id']) ? (int)$domain['id'] : 0);
    $navHtml = render_frontend_menu_nav_html($navMenus, isset($menu['menu_slug']) ? (string)$menu['menu_slug'] : '', isset($menu['id']) ? (int)$menu['id'] : 0);
    $randomLinks = render_random_article_links_html(list_random_article_contents(isset($domain['id']) ? (int)$domain['id'] : 0, 0, 8));
    $sidebarAds = render_article_ads_block_html('sidebar', isset($domain['id']) ? (int)$domain['id'] : 0);
    $topAds = render_article_ads_block_html('article_top', isset($domain['id']) ? (int)$domain['id'] : 0);
    $baseUrl = !empty($menu['is_home']) ? '/' : menu_public_url_path(trim((string)$menu['menu_slug']));
    $paginationHtml = render_archive_pagination_html($baseUrl, $currentPage, $totalPages);
    $metaDescription = isset($menu['seo_description']) && trim((string)$menu['seo_description']) !== '' ? trim((string)$menu['seo_description']) : $menuName . '栏目页';
    $menuKeywords = isset($menu['seo_keywords']) ? trim((string)$menu['seo_keywords']) : '';
    $menuDescription = isset($menu['seo_description']) ? trim((string)$menu['seo_description']) : '';
    $archivePath = !empty($menu['is_home']) ? '/' : menu_public_url_path(trim((string)$menu['menu_slug']));
    return render_default_archive_page(array(
        'title' => $title,
        'meta_keywords' => $keywords,
        'meta_description' => $metaDescription,
        'site_name' => $siteName,
        'domain_name' => $domainName,
        'nav_html' => $navHtml,
        'breadcrumb_items' => array(
            array('label' => $texts['home_breadcrumb'], 'url' => '/'),
            array('label' => $menuName)
        ),
        'heading' => $menuName,
        'cards_html' => $cardsHtml,
        'pagination_html' => $paginationHtml,
        'top_ads' => $topAds,
        'sidebar_ads' => $sidebarAds,
        'random_links' => $randomLinks,
        'footer_text' => ($texts['articles_footer_text'] === '默认文章聚合页' ? ('当前栏目共收录 ' . (int)$totalItems . ' 篇内容，当前第 ' . (int)$currentPage . ' / ' . (int)$totalPages . ' 页') : ($texts['articles_footer_text'] . ' · ' . (int)$totalItems . ' items')),
        'template_pack' => domain_template_pack($domain),
        'menu_keywords' => $menuKeywords,
        'menu_description' => $menuDescription,
        'path' => $archivePath,
        'domain_record' => $domain
    ));
}

function create_article_ads_table_if_not_exists()
{
    $pdo = db_connection();
    $sql = "CREATE TABLE IF NOT EXISTS `article_ads` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `name` VARCHAR(120) NOT NULL,
        `slot` VARCHAR(30) NOT NULL,
        `ad_type` VARCHAR(20) NOT NULL,
        `content_html` LONGTEXT NULL,
        `image_url` VARCHAR(500) NOT NULL DEFAULT '',
        `link_url` VARCHAR(500) NOT NULL DEFAULT '',
        `status` VARCHAR(20) NOT NULL DEFAULT 'enabled',
        `sort_order` INT NOT NULL DEFAULT 0,
        `created_at` DATETIME NOT NULL,
        `updated_at` DATETIME NOT NULL,
        PRIMARY KEY (`id`),
        KEY `idx_article_ads_user_id` (`user_id`),
        KEY `idx_article_ads_slot_status` (`slot`, `status`),
        KEY `idx_article_ads_sort_order` (`sort_order`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    $pdo->exec($sql);
}

function ensure_article_ad_storage_ready()
{
    static $checked = false;
    if ($checked) return;
    $checked = true;
    if (!table_exists('article_ads')) {
        create_article_ads_table_if_not_exists();
    }
    if (!table_exists('article_ad_domains')) {
        create_article_ad_domains_table_if_not_exists();
    }
}

// ========== 模块管理（基础组件自由组合） ==========

function module_widget_types()
{
    return array(
        'heading' => array('label' => '标题',           'icon' => 'H',  'input' => 'text'),
        'text'    => array('label' => '段落文本',     'icon' => '¶',  'input' => 'textarea'),
        'image'   => array('label' => '图片',         'icon' => '🖼', 'input' => 'text', 'placeholder' => 'https://example.com/photo.jpg'),
        'video'   => array('label' => '视频地址',     'icon' => '▶',  'input' => 'text', 'placeholder' => 'https://example.com/video.mp4'),
        'url'     => array('label' => '超链接',       'icon' => '🔗', 'input' => 'url'),
        'button'  => array('label' => '按钮',         'icon' => '🔘', 'input' => 'button'),
        'divider' => array('label' => '分割线',       'icon' => '—',  'input' => 'none'),
        'div'     => array('label' => '容器',         'icon' => '▦',  'input' => 'container')
    );
}

function create_site_modules_table_if_not_exists()
{
    $pdo = db_connection();
    $sql = "CREATE TABLE IF NOT EXISTS `site_modules` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `domain_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=全局',
        `mod_name` VARCHAR(120) NOT NULL DEFAULT '',
        `mod_code` VARCHAR(60) NOT NULL DEFAULT '' COMMENT '变量编码，生成 {{mod_xxx}}',
        `mod_items` LONGTEXT NULL COMMENT 'JSON: 组件列表',
        `sort_order` INT NOT NULL DEFAULT 0,
        `status` VARCHAR(20) NOT NULL DEFAULT 'enabled',
        `mod_engine` VARCHAR(20) NOT NULL DEFAULT 'legacy',
        `mod_html` LONGTEXT NULL,
        `mod_css` LONGTEXT NULL,
        `mod_project_json` LONGTEXT NULL,
        `created_at` DATETIME NOT NULL,
        `updated_at` DATETIME NOT NULL,
        PRIMARY KEY (`id`),
        UNIQUE KEY `uk_mod_code` (`mod_code`),
        KEY `idx_mod_domain` (`domain_id`),
        KEY `idx_mod_status` (`status`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    $pdo->exec($sql);
}

function ensure_component_storage_ready()
{
    static $checked = false;
    if ($checked) return;
    $checked = true;
    if (!table_exists('site_modules')) {
        create_site_modules_table_if_not_exists();
    }
    $pdo = db_connection();
    if (!column_exists('site_modules', 'mod_engine')) {
        $pdo->exec("ALTER TABLE `site_modules` ADD COLUMN `mod_engine` VARCHAR(20) NOT NULL DEFAULT 'legacy' AFTER `status`");
    }
    if (!column_exists('site_modules', 'mod_html')) {
        $pdo->exec("ALTER TABLE `site_modules` ADD COLUMN `mod_html` LONGTEXT NULL AFTER `mod_engine`");
    }
    if (!column_exists('site_modules', 'mod_css')) {
        $pdo->exec("ALTER TABLE `site_modules` ADD COLUMN `mod_css` LONGTEXT NULL AFTER `mod_html`");
    }
    if (!column_exists('site_modules', 'mod_project_json')) {
        $pdo->exec("ALTER TABLE `site_modules` ADD COLUMN `mod_project_json` LONGTEXT NULL AFTER `mod_css`");
    }
}

function list_modules($domainId = 0)
{
    ensure_component_storage_ready();
    $pdo = db_connection();
    $sql = 'SELECT * FROM site_modules WHERE 1=1';
    $params = array();
    if ($domainId > 0) {
        $sql .= ' AND domain_id = :domain_id';
        $params[':domain_id'] = (int)$domainId;
    }
    $sql .= ' ORDER BY sort_order ASC, id DESC';
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function find_module_by_id($id)
{
    ensure_component_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT * FROM site_modules WHERE id = :id LIMIT 1');
    $stmt->execute(array(':id' => (int)$id));
    return $stmt->fetch(PDO::FETCH_ASSOC);
}

function generate_unique_mod_code()
{
    ensure_component_storage_ready();
    $pdo = db_connection();
    $maxRetry = 20;
    for ($i = 0; $i < $maxRetry; $i++) {
        $code = 'm' . base_convert(time(), 10, 36) . substr(str_shuffle('abcdefghijklmnopqrstuvwxyz0123456789'), 0, 3);
        $stmt = $pdo->prepare('SELECT COUNT(*) FROM site_modules WHERE mod_code = :code');
        $stmt->execute(array(':code' => $code));
        if ((int)$stmt->fetchColumn() === 0) {
            return $code;
        }
        usleep(1000);
    }
    return 'm' . bin2hex(random_bytes(4));
}

function save_module($data, $id = 0)
{
    ensure_component_storage_ready();
    $pdo = db_connection();
    $now = date('Y-m-d H:i:s');
    $userId = isset($_SESSION['admin_id']) ? (int)$_SESSION['admin_id'] : 0;

    $modItems = isset($data['mod_items']) ? $data['mod_items'] : '[]';
    if (is_array($modItems)) {
        $modItems = json_encode($modItems, JSON_UNESCAPED_UNICODE);
    }
    $modEngine = isset($data['mod_engine']) ? trim((string)$data['mod_engine']) : 'legacy';
    if (!in_array($modEngine, array('legacy', 'grapesjs'))) { $modEngine = 'legacy'; }
    $modHtml = isset($data['mod_html']) ? (string)$data['mod_html'] : '';
    $modCss = isset($data['mod_css']) ? (string)$data['mod_css'] : '';
    $modProjectJson = isset($data['mod_project_json']) ? (string)$data['mod_project_json'] : '';

    if ($id > 0) {
        $stmt = $pdo->prepare('UPDATE site_modules SET mod_name = :name, mod_code = :code, domain_id = :domain_id, mod_items = :items, sort_order = :sort, status = :status, mod_engine = :engine, mod_html = :html, mod_css = :css, mod_project_json = :project_json, updated_at = :updated WHERE id = :id');
        $stmt->execute(array(
            ':name' => trim((string)(isset($data['mod_name']) ? $data['mod_name'] : '')),
            ':code' => trim((string)(isset($data['mod_code']) ? $data['mod_code'] : '')),
            ':domain_id' => isset($data['domain_id']) ? (int)$data['domain_id'] : 0,
            ':items' => $modItems,
            ':sort' => isset($data['sort_order']) ? (int)$data['sort_order'] : 0,
            ':status' => isset($data['status']) ? (string)$data['status'] : 'enabled',
            ':engine' => $modEngine,
            ':html' => $modHtml,
            ':css' => $modCss,
            ':project_json' => $modProjectJson,
            ':updated' => $now,
            ':id' => (int)$id
        ));
        return $id;
    } else {
        $stmt = $pdo->prepare('INSERT INTO site_modules (user_id, domain_id, mod_name, mod_code, mod_items, sort_order, status, mod_engine, mod_html, mod_css, mod_project_json, created_at, updated_at) VALUES (:uid, :domain_id, :name, :code, :items, :sort, :status, :engine, :html, :css, :project_json, :created, :updated)');
        $stmt->execute(array(
            ':uid' => $userId,
            ':domain_id' => isset($data['domain_id']) ? (int)$data['domain_id'] : 0,
            ':name' => trim((string)(isset($data['mod_name']) ? $data['mod_name'] : '')),
            ':code' => trim((string)(isset($data['mod_code']) ? $data['mod_code'] : '')),
            ':items' => $modItems,
            ':sort' => isset($data['sort_order']) ? (int)$data['sort_order'] : 0,
            ':status' => isset($data['status']) ? (string)$data['status'] : 'enabled',
            ':engine' => $modEngine,
            ':html' => $modHtml,
            ':css' => $modCss,
            ':project_json' => $modProjectJson,
            ':created' => $now,
            ':updated' => $now
        ));
        return (int)$pdo->lastInsertId();
    }
}

function delete_module($id)
{
    ensure_component_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare('DELETE FROM site_modules WHERE id = :id LIMIT 1');
    $stmt->execute(array(':id' => (int)$id));
    return $stmt->rowCount() > 0;
}

function get_modules_for_domain($domainId)
{
    static $cache = array();
    $domainId = (int)$domainId;
    if (array_key_exists($domainId, $cache)) {
        return $cache[$domainId];
    }
    ensure_component_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare("SELECT * FROM site_modules WHERE status = 'enabled' AND (domain_id = 0 OR domain_id = :did) ORDER BY sort_order ASC, id ASC");
    $stmt->execute(array(':did' => $domainId));
    $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
    $cache[$domainId] = $result;
    return $result;
}

function _sanitize_widget_item($raw, $widgetTypes)
{
    $wt = isset($raw['widget']) ? trim((string)$raw['widget']) : '';
    if ($wt === '' || !isset($widgetTypes[$wt])) { return null; }
    $label = isset($raw['label']) ? trim((string)$raw['label']) : '';
    $style = isset($raw['style']) && is_array($raw['style']) ? $raw['style'] : array();
    if ($wt === 'url' || $wt === 'button') {
        $val = array(
            'text' => isset($raw['value']['text']) ? (string)$raw['value']['text'] : '',
            'href' => isset($raw['value']['href']) ? (string)$raw['value']['href'] : ''
        );
    } elseif ($wt === 'divider' || $wt === 'div') {
        $val = '';
    } else {
        $val = isset($raw['value']) ? (string)$raw['value'] : '';
    }
    $item = array('widget' => $wt, 'label' => $label, 'value' => $val, 'style' => $style);
    if ($wt === 'div' && isset($raw['children']) && is_array($raw['children'])) {
        $item['children'] = array();
        foreach ($raw['children'] as $child) {
            $ci = _sanitize_widget_item($child, $widgetTypes);
            if ($ci !== null) { $item['children'][] = $ci; }
        }
    }
    return $item;
}

function render_module_widget_html($widget, $value, $style = array(), $children = array())
{
    $type = (string)$widget;
    $esc = function ($v) { return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); };
    $inl = _build_widget_inline_style($style);

    switch ($type) {
        case 'heading': case 'h1': case 'h2': case 'h3': case 'h4':
            $lvl = 'h1';
            if ($type !== 'heading') { $lvl = $type; }
            elseif (!empty($style['headingLevel'])) { $lvl = 'h' . max(1, min(4, intval($style['headingLevel']))); }
            return '<' . $lvl . ' class="mod-' . $lvl . '"' . $inl . '>' . $esc($value) . '</' . $lvl . '>';
        case 'text':
            return '<div class="mod-text"' . $inl . '>' . nl2br($esc($value)) . '</div>';
        case 'image':
            $src = $esc($value);
            if ($src === '') return '';
            $imgParts = array('max-width:100%','height:auto');
            if (!empty($style['borderRadius'])) $imgParts[] = 'border-radius:' . intval($style['borderRadius']) . 'px;overflow:hidden';
            if (!empty($style['bgColor'])) $imgParts[] = 'background-color:' . $esc($style['bgColor']);
            if (!empty($style['padding'])) $imgParts[] = 'padding:' . intval($style['padding']) . 'px';
            if (!empty($style['float']) && in_array($style['float'], array('left','right'))) {
                $imgParts[] = 'float:' . $style['float'];
                $imgParts[] = ($style['float'] === 'left') ? 'margin:0 16px 12px 0' : 'margin:0 0 12px 16px';
            }
            if (!empty($style['imgWidth'])) {
                $w = $style['imgWidth'];
                $imgParts[] = 'width:' . (strpos($w, '%') !== false ? $esc($w) : intval($w) . 'px');
            }
            if (!empty($style['imgHeight'])) {
                $h = $style['imgHeight'];
                $imgParts[] = 'height:' . (strtolower(trim($h)) === 'auto' ? 'auto' : (strpos($h, '%') !== false ? $esc($h) : intval($h) . 'px'));
            }
            if (!empty($style['textAlign']) && $style['textAlign'] === 'center' && empty($style['float'])) {
                $imgParts[] = 'display:block;margin-left:auto;margin-right:auto';
            }
            $imgStyle = ' style="' . implode(';', $imgParts) . '"';
            return '<img src="' . $src . '" alt="" class="mod-img" loading="lazy"' . $imgStyle . '>';
        case 'video':
            $src = $esc($value);
            if ($src === '') { return ''; }
            if (preg_match('#\.(mp4|webm|ogg)(\?|$)#i', $src)) {
                return '<video src="' . $src . '" controls class="mod-video" preload="metadata"' . $inl . '></video>';
            }
            return '<iframe src="' . $src . '" class="mod-video-iframe" frameborder="0" allowfullscreen loading="lazy"' . $inl . '></iframe>';
        case 'url':
            $urlData = is_array($value) ? $value : array('text' => $value, 'href' => $value);
            $text = $esc(isset($urlData['text']) ? $urlData['text'] : '');
            $href = $esc(isset($urlData['href']) ? $urlData['href'] : '#');
            return '<a href="' . $href . '" class="mod-link"' . $inl . '>' . ($text !== '' ? $text : $href) . '</a>';
        case 'button':
            $btnText = is_array($value) ? (isset($value['text']) ? $value['text'] : '按钮') : ((string)$value !== '' ? (string)$value : '按钮');
            $btnHref = is_array($value) ? (isset($value['href']) ? $value['href'] : '#') : '#';
            $btnTarget = !empty($style['target']) ? ' target="' . $esc($style['target']) . '"' : '';
            $btnParts = array('display:inline-block','padding:10px 24px','text-decoration:none','font-weight:600','cursor:pointer','border:none','font-size:14px','line-height:1.5');
            if (!empty($style['color'])) $btnParts[] = 'color:' . $esc($style['color']); else $btnParts[] = 'color:#fff';
            if (!empty($style['bgColor'])) $btnParts[] = 'background-color:' . $esc($style['bgColor']); else $btnParts[] = 'background-color:#6366f1';
            if (!empty($style['borderRadius'])) $btnParts[] = 'border-radius:' . intval($style['borderRadius']) . 'px'; else $btnParts[] = 'border-radius:6px';
            if (!empty($style['fontSize'])) $btnParts[] = 'font-size:' . intval($style['fontSize']) . 'px';
            if (!empty($style['padding'])) $btnParts[] = 'padding:' . intval($style['padding']) . 'px';
            $wrap = '';
            if (!empty($style['textAlign'])) {
                $wrap = '<div style="text-align:' . $esc($style['textAlign']) . '">';
            }
            $btnStyle = ' style="' . implode(';', $btnParts) . '"';
            $btnHtml = '<a href="' . $esc($btnHref) . '" class="mod-btn"' . $btnTarget . $btnStyle . '>' . $esc($btnText) . '</a>';
            return $wrap !== '' ? $wrap . $btnHtml . '</div>' : $btnHtml;
        case 'divider':
            return '<hr class="mod-divider"' . $inl . '>';
        case 'div':
            $cols = isset($style['columns']) ? intval($style['columns']) : 1;
            $rows = isset($style['rows']) ? intval($style['rows']) : 1;
            $gap = isset($style['gap']) ? intval($style['gap']) : 10;
            $divStyle = 'display:grid;grid-template-columns:repeat(' . $cols . ',1fr);grid-template-rows:repeat(' . $rows . ',auto);gap:' . $gap . 'px;';
            if (!empty($style['bgColor'])) $divStyle .= 'background-color:' . $esc($style['bgColor']) . ';';
            if (!empty($style['padding'])) $divStyle .= 'padding:' . intval($style['padding']) . 'px;';
            if (!empty($style['margin'])) $divStyle .= 'margin:' . intval($style['margin']) . 'px;';
            if (!empty($style['borderRadius'])) $divStyle .= 'border-radius:' . intval($style['borderRadius']) . 'px;';
            if (!empty($style['textAlign'])) $divStyle .= 'text-align:' . $esc($style['textAlign']) . ';';
            $inner = '';
            if (is_array($children)) {
                foreach ($children as $ci) {
                    $cw = isset($ci['widget']) ? (string)$ci['widget'] : '';
                    $cv = isset($ci['value']) ? $ci['value'] : '';
                    $cs = isset($ci['style']) && is_array($ci['style']) ? $ci['style'] : array();
                    $cc = isset($ci['children']) && is_array($ci['children']) ? $ci['children'] : array();
                    $inner .= render_module_widget_html($cw, $cv, $cs, $cc);
                }
            }
            $tag = (isset($style['containerTag']) && $style['containerTag'] === 'p') ? 'p' : 'div';
            return '<' . $tag . ' class="mod-container" style="' . $divStyle . '">' . $inner . '</' . $tag . '>';
        default:
            return '';
    }
}

function _build_widget_inline_style($style)
{
    if (!is_array($style) || empty($style)) { return ''; }
    $esc = function ($v) { return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); };
    $parts = array();
    if (!empty($style['color']))        $parts[] = 'color:' . $esc($style['color']);
    if (!empty($style['bgColor']))      $parts[] = 'background-color:' . $esc($style['bgColor']);
    if (!empty($style['fontSize']))     $parts[] = 'font-size:' . intval($style['fontSize']) . 'px';
    if (!empty($style['textAlign']))    $parts[] = 'text-align:' . $esc($style['textAlign']);
    if (!empty($style['padding']))      $parts[] = 'padding:' . intval($style['padding']) . 'px';
    if (!empty($style['borderRadius'])) $parts[] = 'border-radius:' . intval($style['borderRadius']) . 'px';
    if (!empty($style['float']) && in_array($style['float'], array('left','right'))) {
        $parts[] = 'float:' . $style['float'];
        if ($style['float'] === 'left') $parts[] = 'margin:0 16px 12px 0';
        else $parts[] = 'margin:0 0 12px 16px';
    }
    if (!empty($style['imgWidth'])) {
        $w = $style['imgWidth'];
        $parts[] = 'width:' . (strpos($w, '%') !== false ? $esc($w) : intval($w) . 'px');
    }
    if (!empty($style['imgHeight'])) {
        $h = $style['imgHeight'];
        if (strtolower(trim($h)) === 'auto') { $parts[] = 'height:auto'; }
        else { $parts[] = 'height:' . (strpos($h, '%') !== false ? $esc($h) : intval($h) . 'px'); }
    }
    return !empty($parts) ? ' style="' . implode(';', $parts) . '"' : '';
}

function build_module_template_vars($domainId)
{
    static $cache = array();
    $domainId = (int)$domainId;
    if (array_key_exists($domainId, $cache)) {
        return $cache[$domainId];
    }
    $modules = get_modules_for_domain($domainId);
    $vars = array();

    foreach ($modules as $mod) {
        $code = trim((string)$mod['mod_code']);
        if ($code === '') { continue; }
        $varKey = '{{mod_' . $code . '}}';
        $safeCode = htmlspecialchars($code, ENT_QUOTES, 'UTF-8');
        $engine = isset($mod['mod_engine']) ? (string)$mod['mod_engine'] : 'legacy';
        if ($engine === 'grapesjs') {
            $html = (string)(isset($mod['mod_html']) ? $mod['mod_html'] : '');
            $css = (string)(isset($mod['mod_css']) ? $mod['mod_css'] : '');
            $baseCss = '.bee-fw{width:100vw!important;margin-left:calc(-50vw + 50%)!important;overflow:hidden;box-sizing:border-box;display:flex;flex-direction:column;align-items:center;}.bee-fw>*{max-width:100%;}';
            $style = '<style>' . $baseCss . ($css !== '' ? $css : '') . '</style>';
            $vars[$varKey] = '<div class="mod mod-' . $safeCode . ' mod-visual">' . $style . $html . '</div>';
            continue;
        }
        $items = json_decode((string)$mod['mod_items'], true);
        if (!is_array($items)) { $items = array(); }

        $html = '';
        foreach ($items as $item) {
            $widget = isset($item['widget']) ? (string)$item['widget'] : '';
            $val = isset($item['value']) ? $item['value'] : '';
            $stl = isset($item['style']) && is_array($item['style']) ? $item['style'] : array();
            $ch = isset($item['children']) && is_array($item['children']) ? $item['children'] : array();
            $html .= render_module_widget_html($widget, $val, $stl, $ch);
        }

        $vars[$varKey] = '<div class="mod mod-' . $safeCode . '">' . $html . '</div>';
    }

    $cache[$domainId] = $vars;
    return $vars;
}

function create_article_ad_domains_table_if_not_exists()
{
    $pdo = db_connection();
    $sql = "CREATE TABLE IF NOT EXISTS `article_ad_domains` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `ad_id` BIGINT UNSIGNED NOT NULL,
        `domain_id` BIGINT UNSIGNED NOT NULL,
        `created_at` DATETIME NOT NULL,
        PRIMARY KEY (`id`),
        UNIQUE KEY `uk_article_ad_domain` (`ad_id`, `domain_id`),
        KEY `idx_article_ad_domain_domain` (`domain_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    $pdo->exec($sql);
}

function article_ad_slot_options()
{
    return array(
        'article_top' => '文章开头',
        'article_middle' => '文章中间',
        'article_bottom' => '文章结尾',
        'sidebar' => '侧栏广告'
    );
}

function article_ad_type_options()
{
    return array(
        'text' => '文本/HTML',
        'image' => '图片链接',
        'js' => 'JS代码'
    );
}

function article_ad_status_options()
{
    return array(
        'enabled' => '启用',
        'disabled' => '停用'
    );
}

function list_article_ads($slot = '', $status = '')
{
    ensure_article_ad_storage_ready();
    $pdo = db_connection();
    $sql = 'SELECT * FROM article_ads WHERE user_id = :uid';
    $params = array(':uid' => current_user_id());
    if ($slot !== '' && $slot !== 'all') {
        $sql .= ' AND slot = :slot';
        $params[':slot'] = $slot;
    }
    if ($status !== '' && $status !== 'all') {
        $sql .= ' AND status = :status';
        $params[':status'] = $status;
    }
    $sql .= ' ORDER BY sort_order DESC, id DESC';
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function list_published_article_contents_by_domain($domainId, $limit, $keyword = '')
{
    ensure_content_storage_ready();
    $pdo = db_connection();
    $limit = max(1, min(100, (int)$limit));
    $sql = "SELECT c.id, c.title, c.path, c.seo_title, c.seo_description, c.seo_keywords, c.cover_image, c.updated_at, c.source_keyword, c.static_file_path
        FROM contents c
        LEFT JOIN templates t ON t.id = c.template_id
        WHERE c.domain_id = :domain_id AND c.status = 'published' AND (t.type = 'article' OR c.source_type = 'article_ai')";
    $params = array(':domain_id' => (int)$domainId);
    $keyword = trim((string)$keyword);
    if ($keyword !== '') {
        $sql .= ' AND (c.title LIKE :kw_title OR c.seo_keywords LIKE :kw_keywords OR c.source_keyword LIKE :kw_source)';
        $params[':kw_title'] = '%' . $keyword . '%';
        $params[':kw_keywords'] = '%' . $keyword . '%';
        $params[':kw_source'] = '%' . $keyword . '%';
    }
    $sql .= ' ORDER BY c.id DESC LIMIT ' . $limit;
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function is_article_list_path($path)
{
    $path = normalize_content_path($path);
    return $path === 'articles' || $path === 'article' || $path === 'news';
}

function render_article_list_cards_html($items)
{
    if (!is_array($items) || empty($items)) {
        return '<div class="article-list-empty">当前暂无文章内容。</div>';
    }
    $html = '';
    foreach ($items as $item) {
        $title = isset($item['title']) ? (string)$item['title'] : '';
        $path = isset($item['path']) ? content_public_url_path((string)$item['path'], content_has_static_file($item)) : '#';
        $coverImage = isset($item['cover_image']) ? trim((string)$item['cover_image']) : '';
        $updatedAt = isset($item['updated_at']) ? trim((string)$item['updated_at']) : '';
        $displayTime = $updatedAt !== '' ? date('Y-m-d', strtotime($updatedAt)) : '';
        $desc = isset($item['seo_description']) ? trim((string)$item['seo_description']) : '';
        $imageHtml = $coverImage !== ''
            ? '<img src="' . e($coverImage) . '" alt="' . e($title) . '" loading="lazy">'
            : '<span class="article-list-thumb-placeholder">' . e($title !== '' ? $title : '文章') . '</span>';
        $descHtml = $desc !== '' ? '<span class="article-list-desc" style="font-size:13px;color:#6B7B8D;line-height:1.6;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden">' . e(mb_substr($desc, 0, 80)) . '</span>' : '';
        $html .= '<article class="article-list-card"><a class="article-list-card-link" href="' . e($path) . '" title="' . e($title) . '"><span class="article-list-thumb">' . $imageHtml . ($displayTime !== '' ? '<span class="article-list-time">' . e($displayTime) . '</span>' : '') . '</span><span class="article-list-body"><span class="article-list-title">' . e($title) . '</span>' . $descHtml . '</span></a></article>';
    }
    return $html;
}

function build_default_article_list_page_html($domain, $keyword = '')
{
    $siteName = isset($domain['site_name']) ? trim((string)$domain['site_name']) : '';
    if ($siteName === '') {
        $siteName = isset($domain['domain']) ? trim((string)$domain['domain']) : '文章中心';
    }
    $domainName = isset($domain['domain']) ? trim((string)$domain['domain']) : '';
    $texts = domain_language_texts($domain);
    $items = list_published_article_contents_by_domain(isset($domain['id']) ? (int)$domain['id'] : 0, 24, $keyword);
    $navMenus = list_nav_menus_by_domain(isset($domain['id']) ? (int)$domain['id'] : 0);
    $navHtml = render_frontend_menu_nav_html($navMenus, 'articles', 0);
    $randomLinks = render_random_article_links_html(list_random_article_contents(isset($domain['id']) ? (int)$domain['id'] : 0, 0, 8));
    $cardsHtml = render_article_list_cards_html($items);
    $sidebarAds = render_article_ads_block_html('sidebar', isset($domain['id']) ? (int)$domain['id'] : 0);
    $topAds = render_article_ads_block_html('article_top', isset($domain['id']) ? (int)$domain['id'] : 0);
    $title = $keyword !== '' ? ($keyword . ($texts['tag_articles_prefix'] === '相关文章' ? '相关文章列表' : ' Related Articles') . ' - ' . $siteName) : ($texts['articles_title'] . ' - ' . $siteName);
    $metaDescription = $keyword !== '' ? ($keyword . $texts['tag_description_suffix']) : ($texts['articles_heading']);
    $heading = $keyword !== '' ? ($keyword . ' ' . $texts['tag_articles_prefix']) : $texts['articles_heading'];
    return render_default_archive_page(array(
        'title' => $title,
        'meta_keywords' => $texts['article_list_meta_keywords'] . ',' . $siteName,
        'meta_description' => $metaDescription,
        'site_name' => $siteName,
        'domain_name' => $domainName,
        'nav_html' => $navHtml,
        'breadcrumb_items' => array(
            array('label' => $texts['home_breadcrumb'], 'url' => '/'),
            array('label' => $texts['articles_menu_name'])
        ),
        'heading' => $heading,
        'cards_html' => $cardsHtml,
        'pagination_html' => '',
        'top_ads' => $topAds,
        'sidebar_ads' => $sidebarAds,
        'random_links' => $randomLinks,
        'footer_text' => $texts['articles_footer_text'],
        'template_pack' => domain_template_pack($domain),
        'domain_record' => $domain
    ));
}

function find_tag_by_domain_and_slug($domainId, $slug, $excludeId = 0)
{
    ensure_tag_storage_ready();
    $pdo = db_connection();
    $sql = 'SELECT * FROM tags WHERE domain_id = :domain_id AND slug = :slug';
    $params = array(
        ':domain_id' => (int)$domainId,
        ':slug' => normalize_tag_slug($slug)
    );
    if ((int)$excludeId > 0) {
        $sql .= ' AND id <> :exclude_id';
        $params[':exclude_id'] = (int)$excludeId;
    }
    $sql .= ' LIMIT 1';
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    $tag = $stmt->fetch(PDO::FETCH_ASSOC);
    if ($tag) {
        return $tag;
    }
    $stmt = $pdo->prepare('SELECT * FROM tags WHERE domain_id = :domain_id ORDER BY id DESC');
    $stmt->execute(array(':domain_id' => (int)$domainId));
    $slug = normalize_tag_slug($slug);
    foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $item) {
        $storedSlug = tag_effective_slug($item);
        $derivedSlug = tag_name_derived_slug($item);
        if ($storedSlug === $slug || ($derivedSlug !== '' && $derivedSlug === $slug)) {
            return $item;
        }
    }
    return null;
}

function find_tag_by_domain_and_name($domainId, $name)
{
    ensure_tag_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT * FROM tags WHERE domain_id = :domain_id AND name = :name LIMIT 1');
    $stmt->execute(array(
        ':domain_id' => (int)$domainId,
        ':name' => trim((string)$name)
    ));
    return $stmt->fetch(PDO::FETCH_ASSOC);
}

function upsert_tag($domainId, $name)
{
    ensure_tag_storage_ready();
    $domainId = (int)$domainId;
    $name = trim((string)$name);
    if ($domainId <= 0 || $name === '') {
        return 0;
    }
    $existing = find_tag_by_domain_and_name($domainId, $name);
    $now = date('Y-m-d H:i:s');
    $pdo = db_connection();
    if ($existing) {
        $stmt = $pdo->prepare('UPDATE tags SET updated_at = :updated_at, status = :status WHERE id = :id LIMIT 1');
        $stmt->execute(array(
            ':updated_at' => $now,
            ':status' => 'enabled',
            ':id' => (int)$existing['id']
        ));
        return (int)$existing['id'];
    }
    $slug = generate_unique_tag_slug($domainId, $name);
    $stmt = $pdo->prepare('INSERT INTO tags (domain_id, name, slug, seo_title, seo_keywords, seo_description, status, created_at, updated_at) VALUES (:domain_id, :name, :slug, :seo_title, :seo_keywords, :seo_description, :status, :created_at, :updated_at)');
    $stmt->execute(array(
        ':domain_id' => $domainId,
        ':name' => $name,
        ':slug' => $slug,
        ':seo_title' => $name,
        ':seo_keywords' => $name,
        ':seo_description' => $name . '相关内容聚合页',
        ':status' => 'enabled',
        ':created_at' => $now,
        ':updated_at' => $now
    ));
    return (int)$pdo->lastInsertId();
}

function rebuild_tag_slugs($domainId = 0, $force = false)
{
    ensure_tag_storage_ready();
    $pdo = db_connection();
    $sql = 'SELECT * FROM tags';
    $params = array();
    if ((int)$domainId > 0) {
        $sql .= ' WHERE domain_id = :domain_id';
        $params[':domain_id'] = (int)$domainId;
    }
    $sql .= ' ORDER BY domain_id ASC, id ASC';
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    $items = $stmt->fetchAll(PDO::FETCH_ASSOC);
    $updatedCount = 0;
    $skippedCount = 0;
    $details = array();
    foreach ($items as $item) {
        $tagId = isset($item['id']) ? (int)$item['id'] : 0;
        $currentSlug = isset($item['slug']) ? trim((string)$item['slug']) : '';
        $targetSlug = generate_unique_tag_slug(isset($item['domain_id']) ? (int)$item['domain_id'] : 0, isset($item['name']) ? (string)$item['name'] : '', $tagId);
        if (!$force && $currentSlug === $targetSlug) {
            $skippedCount++;
            continue;
        }
        $updateStmt = $pdo->prepare('UPDATE tags SET slug = :slug, updated_at = :updated_at WHERE id = :id LIMIT 1');
        $updateStmt->execute(array(
            ':slug' => $targetSlug,
            ':updated_at' => date('Y-m-d H:i:s'),
            ':id' => $tagId
        ));
        $updatedCount++;
        $details[] = array(
            'id' => $tagId,
            'domain_id' => isset($item['domain_id']) ? (int)$item['domain_id'] : 0,
            'name' => isset($item['name']) ? (string)$item['name'] : '',
            'old_slug' => $currentSlug,
            'new_slug' => $targetSlug
        );
    }
    return array(
        'total_count' => count($items),
        'updated_count' => $updatedCount,
        'skipped_count' => $skippedCount,
        'details' => $details
    );
}

function rebuild_historical_content_tags($domainId = 0)
{
    ensure_tag_storage_ready();
    ensure_content_storage_ready();
    $pdo = db_connection();
    $sql = "SELECT c.id, c.domain_id, c.title, c.seo_keywords
        FROM contents c
        LEFT JOIN templates t ON t.id = c.template_id
        WHERE c.status = 'published' AND (t.type = 'article' OR c.source_type = 'article_ai')";
    $params = array();
    if ((int)$domainId > 0) {
        $sql .= ' AND c.domain_id = :domain_id';
        $params[':domain_id'] = (int)$domainId;
    }
    $sql .= ' ORDER BY c.domain_id ASC, c.id ASC';
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    $items = $stmt->fetchAll(PDO::FETCH_ASSOC);
    $processedCount = 0;
    $taggedCount = 0;
    $skippedCount = 0;
    $details = array();
    foreach ($items as $item) {
        $processedCount++;
        $contentId = isset($item['id']) ? (int)$item['id'] : 0;
        $currentDomainId = isset($item['domain_id']) ? (int)$item['domain_id'] : 0;
        $keywords = isset($item['seo_keywords']) ? trim((string)$item['seo_keywords']) : '';
        $keywordItems = article_keyword_list_from_string($keywords);
        if ($contentId <= 0 || $currentDomainId <= 0 || empty($keywordItems)) {
            $skippedCount++;
            continue;
        }
        sync_content_tags($contentId, $currentDomainId, $keywords);
        $taggedCount++;
        $details[] = array(
            'content_id' => $contentId,
            'domain_id' => $currentDomainId,
            'title' => isset($item['title']) ? (string)$item['title'] : '',
            'keywords' => $keywords,
            'tag_count' => count($keywordItems)
        );
    }
    return array(
        'total_count' => count($items),
        'processed_count' => $processedCount,
        'tagged_count' => $taggedCount,
        'skipped_count' => $skippedCount,
        'details' => $details
    );
}

function replace_content_tag_relations($contentId, $tagIds)
{
    ensure_tag_storage_ready();
    $pdo = db_connection();
    $contentId = (int)$contentId;
    $tagIds = array_values(array_unique(array_filter(array_map('intval', (array)$tagIds), function ($tagId) {
        return $tagId > 0;
    })));
    $stmt = $pdo->prepare('DELETE FROM content_tags WHERE content_id = :content_id');
    $stmt->execute(array(':content_id' => $contentId));
    if (empty($tagIds)) {
        return;
    }
    $insert = $pdo->prepare('INSERT INTO content_tags (content_id, tag_id, created_at) VALUES (:content_id, :tag_id, :created_at)');
    $now = date('Y-m-d H:i:s');
    foreach ($tagIds as $tagId) {
        $insert->execute(array(
            ':content_id' => $contentId,
            ':tag_id' => $tagId,
            ':created_at' => $now
        ));
    }
}

function sync_content_tags($contentId, $domainId, $keywords)
{
    ensure_tag_storage_ready();
    $items = article_keyword_list_from_string($keywords);
    $tagIds = array();
    foreach ($items as $item) {
        $tagId = upsert_tag((int)$domainId, $item);
        if ($tagId > 0) {
            $tagIds[] = $tagId;
        }
    }
    replace_content_tag_relations((int)$contentId, $tagIds);
}

function list_tags_by_content($contentId)
{
    static $cache = array();
    $contentId = (int)$contentId;
    if (array_key_exists($contentId, $cache)) {
        return $cache[$contentId];
    }
    ensure_tag_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT t.* FROM content_tags ct INNER JOIN tags t ON t.id = ct.tag_id WHERE ct.content_id = :content_id ORDER BY t.name ASC');
    $stmt->execute(array(':content_id' => $contentId));
    $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
    $cache[$contentId] = $result;
    return $result;
}

function count_published_contents_by_tag($domainId, $tagId)
{
    ensure_tag_storage_ready();
    ensure_content_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare("SELECT COUNT(*) FROM content_tags ct INNER JOIN contents c ON c.id = ct.content_id WHERE ct.tag_id = :tag_id AND c.domain_id = :domain_id AND c.status = 'published'");
    $stmt->execute(array(
        ':tag_id' => (int)$tagId,
        ':domain_id' => (int)$domainId
    ));
    return (int)$stmt->fetchColumn();
}

function list_published_contents_by_tag_page($domainId, $tagId, $page, $pageSize)
{
    ensure_tag_storage_ready();
    ensure_content_storage_ready();
    $page = max(1, (int)$page);
    $pageSize = max(1, min(100, (int)$pageSize));
    $offset = ($page - 1) * $pageSize;
    $pdo = db_connection();
    $sql = "SELECT c.id, c.title, c.path, c.seo_title, c.seo_description, c.seo_keywords, c.cover_image, c.updated_at, c.static_file_path
        FROM content_tags ct
        INNER JOIN contents c ON c.id = ct.content_id
        WHERE ct.tag_id = :tag_id AND c.domain_id = :domain_id AND c.status = 'published'
        ORDER BY c.id DESC LIMIT " . $pageSize . " OFFSET " . $offset;
    $stmt = $pdo->prepare($sql);
    $stmt->execute(array(
        ':tag_id' => (int)$tagId,
        ':domain_id' => (int)$domainId
    ));
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function build_default_tag_archive_page_html($domain, $tag, $currentPage = 1)
{
    $siteName = isset($domain['site_name']) ? trim((string)$domain['site_name']) : '';
    if ($siteName === '') {
        $siteName = isset($domain['domain']) ? trim((string)$domain['domain']) : '标签聚合页';
    }
    $domainName = isset($domain['domain']) ? trim((string)$domain['domain']) : '';
    $tagName = isset($tag['name']) ? trim((string)$tag['name']) : '标签';
    $rCfg = app_runtime_config();
    $pageSize = isset($rCfg['archive_page_size']) && (int)$rCfg['archive_page_size'] > 0 ? (int)$rCfg['archive_page_size'] : 10;
    $currentPage = max(1, (int)$currentPage);
    $totalItems = count_published_contents_by_tag(isset($domain['id']) ? (int)$domain['id'] : 0, isset($tag['id']) ? (int)$tag['id'] : 0);
    $totalPages = max(1, (int)ceil($totalItems / $pageSize));
    if ($currentPage > $totalPages) {
        $currentPage = $totalPages;
    }
    $items = list_published_contents_by_tag_page(isset($domain['id']) ? (int)$domain['id'] : 0, isset($tag['id']) ? (int)$tag['id'] : 0, $currentPage, $pageSize);
    $randomLinks = render_random_article_links_html(list_random_article_contents(isset($domain['id']) ? (int)$domain['id'] : 0, 0, 8));
    $cardsHtml = render_article_list_cards_html($items);
    $navMenus = list_nav_menus_by_domain(isset($domain['id']) ? (int)$domain['id'] : 0);
    $navHtml = render_frontend_menu_nav_html($navMenus, '', 0);
    $sidebarAds = render_article_ads_block_html('sidebar', isset($domain['id']) ? (int)$domain['id'] : 0);
    $topAds = render_article_ads_block_html('article_top', isset($domain['id']) ? (int)$domain['id'] : 0);
    $title = $tagName . '相关内容 - ' . $siteName;
    $paginationHtml = render_archive_pagination_html(tag_archive_public_url_path(tag_effective_slug($tag)), $currentPage, $totalPages);
    $metaDescription = $tagName . '相关内容聚合页';
    return render_default_archive_page(array(
        'title' => $title,
        'meta_keywords' => $tagName . ',' . $siteName . ',标签聚合',
        'meta_description' => $metaDescription,
        'site_name' => $siteName,
        'domain_name' => $domainName,
        'nav_html' => $navHtml,
        'breadcrumb_items' => array(
            array('label' => '首页', 'url' => '/'),
            array('label' => '文章列表', 'url' => '/articles/'),
            array('label' => $tagName)
        ),
        'heading' => $tagName . ' 相关文章',
        'cards_html' => $cardsHtml,
        'pagination_html' => $paginationHtml,
        'top_ads' => $topAds,
        'sidebar_ads' => $sidebarAds,
        'random_links' => $randomLinks,
        'footer_text' => '标签聚合页',
        'template_pack' => domain_template_pack($domain),
        'domain_record' => $domain
    ));
}

function list_enabled_article_ads_by_slot($slot, $domainId = 0)
{
    static $cache = array();
    $slot = trim((string)$slot);
    $domainId = (int)$domainId;
    $cacheKey = $slot . '|' . $domainId;
    if (array_key_exists($cacheKey, $cache)) {
        return $cache[$cacheKey];
    }
    ensure_article_ad_storage_ready();
    $pdo = db_connection();
    $sql = "SELECT DISTINCT a.* FROM article_ads a
        LEFT JOIN article_ad_domains ad ON ad.ad_id = a.id
        WHERE a.slot = :slot AND a.status = 'enabled'";
    $params = array(':slot' => $slot);
    if ($domainId > 0) {
        $sql .= ' AND (ad.domain_id IS NULL OR ad.domain_id = :domain_id)';
        $params[':domain_id'] = $domainId;
    }
    $sql .= ' ORDER BY a.sort_order DESC, a.id DESC';
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
    $cache[$cacheKey] = $result;
    return $result;
}

function find_article_ad_by_id($id)
{
    ensure_article_ad_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT * FROM article_ads WHERE id = :id LIMIT 1');
    $stmt->execute(array(':id' => (int)$id));
    return $stmt->fetch(PDO::FETCH_ASSOC);
}

function normalize_article_ad_data($data)
{
    return array(
        'name' => isset($data['name']) ? trim((string)$data['name']) : '',
        'slot' => isset($data['slot']) ? trim((string)$data['slot']) : '',
        'ad_type' => isset($data['ad_type']) ? trim((string)$data['ad_type']) : '',
        'content_html' => isset($data['content_html']) ? trim((string)$data['content_html']) : '',
        'image_url' => isset($data['image_url']) ? trim((string)$data['image_url']) : '',
        'link_url' => isset($data['link_url']) ? trim((string)$data['link_url']) : '',
        'status' => isset($data['status']) ? trim((string)$data['status']) : 'enabled',
        'sort_order' => isset($data['sort_order']) ? (int)$data['sort_order'] : 0,
        'domain_ids' => isset($data['domain_ids']) && is_array($data['domain_ids']) ? array_values(array_unique(array_map('intval', $data['domain_ids']))) : array()
    );
}

function validate_article_ad_data($data)
{
    $slotOptions = article_ad_slot_options();
    $typeOptions = article_ad_type_options();
    $statusOptions = article_ad_status_options();
    if ($data['name'] === '') {
        throw new Exception('请填写广告名称。');
    }
    if (!isset($slotOptions[$data['slot']])) {
        throw new Exception('请选择有效的广告位置。');
    }
    if (!isset($typeOptions[$data['ad_type']])) {
        throw new Exception('请选择有效的广告类型。');
    }
    if (!isset($statusOptions[$data['status']])) {
        throw new Exception('请选择有效的广告状态。');
    }
    if ($data['ad_type'] === 'image' && $data['image_url'] === '') {
        throw new Exception('图片广告必须填写图片地址。');
    }
    if (($data['ad_type'] === 'text' || $data['ad_type'] === 'js') && $data['content_html'] === '') {
        throw new Exception('当前广告类型必须填写广告内容。');
    }
}

function create_article_ad($data)
{
    ensure_article_ad_storage_ready();
    $data = normalize_article_ad_data($data);
    validate_article_ad_data($data);
    $pdo = db_connection();
    $now = date('Y-m-d H:i:s');
    $stmt = $pdo->prepare('INSERT INTO article_ads (user_id, name, slot, ad_type, content_html, image_url, link_url, status, sort_order, created_at, updated_at) VALUES (:uid, :name, :slot, :ad_type, :content_html, :image_url, :link_url, :status, :sort_order, :created_at, :updated_at)');
    $stmt->execute(array(
        ':uid' => current_user_id(),
        ':name' => $data['name'],
        ':slot' => $data['slot'],
        ':ad_type' => $data['ad_type'],
        ':content_html' => $data['content_html'],
        ':image_url' => $data['image_url'],
        ':link_url' => $data['link_url'],
        ':status' => $data['status'],
        ':sort_order' => $data['sort_order'],
        ':created_at' => $now,
        ':updated_at' => $now
    ));
    $adId = (int)$pdo->lastInsertId();
    replace_article_ad_domains($adId, $data['domain_ids']);
    return $adId;
}

function update_article_ad($id, $data)
{
    ensure_article_ad_storage_ready();
    if (!find_article_ad_by_id((int)$id)) {
        throw new Exception('未找到对应广告。');
    }
    $data = normalize_article_ad_data($data);
    validate_article_ad_data($data);
    $pdo = db_connection();
    $stmt = $pdo->prepare('UPDATE article_ads SET name = :name, slot = :slot, ad_type = :ad_type, content_html = :content_html, image_url = :image_url, link_url = :link_url, status = :status, sort_order = :sort_order, updated_at = :updated_at WHERE id = :id LIMIT 1');
    $stmt->execute(array(
        ':name' => $data['name'],
        ':slot' => $data['slot'],
        ':ad_type' => $data['ad_type'],
        ':content_html' => $data['content_html'],
        ':image_url' => $data['image_url'],
        ':link_url' => $data['link_url'],
        ':status' => $data['status'],
        ':sort_order' => $data['sort_order'],
        ':updated_at' => date('Y-m-d H:i:s'),
        ':id' => (int)$id
    ));
    replace_article_ad_domains((int)$id, $data['domain_ids']);
    return true;
}

function delete_article_ad($id)
{
    ensure_article_ad_storage_ready();
    $pdo = db_connection();
    $cleanup = $pdo->prepare('DELETE FROM article_ad_domains WHERE ad_id = :ad_id');
    $cleanup->execute(array(':ad_id' => (int)$id));
    $stmt = $pdo->prepare('DELETE FROM article_ads WHERE id = :id LIMIT 1');
    $stmt->execute(array(':id' => (int)$id));
    return $stmt->rowCount() > 0;
}

function list_article_ad_domain_ids($adId)
{
    ensure_article_ad_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT domain_id FROM article_ad_domains WHERE ad_id = :ad_id ORDER BY id ASC');
    $stmt->execute(array(':ad_id' => (int)$adId));
    return array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN));
}

function replace_article_ad_domains($adId, $domainIds)
{
    ensure_article_ad_storage_ready();
    $pdo = db_connection();
    $delete = $pdo->prepare('DELETE FROM article_ad_domains WHERE ad_id = :ad_id');
    $delete->execute(array(':ad_id' => (int)$adId));
    if (empty($domainIds)) {
        return;
    }
    $insert = $pdo->prepare('INSERT INTO article_ad_domains (ad_id, domain_id, created_at) VALUES (:ad_id, :domain_id, :created_at)');
    $now = date('Y-m-d H:i:s');
    foreach ($domainIds as $domainId) {
        if ((int)$domainId <= 0) {
            continue;
        }
        $insert->execute(array(
            ':ad_id' => (int)$adId,
            ':domain_id' => (int)$domainId,
            ':created_at' => $now
        ));
    }
}

function initialize_application_schema()
{
    ensure_admin_storage_ready();
    ensure_template_storage_ready();
    ensure_domain_storage_ready();
    ensure_ai_model_storage_ready();
    ensure_menu_storage_ready();
    ensure_content_storage_ready();
    ensure_publish_log_storage_ready();
    ensure_site_build_task_storage_ready();
    ensure_article_generation_task_storage_ready();
    ensure_article_ad_storage_ready();
}

function find_admin_by_id($id)
{
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT id, username, display_name, password, status, role, initialized_at, created_at, updated_at FROM admins WHERE id = :id LIMIT 1');
    $stmt->execute(array(':id' => (int)$id));
    return $stmt->fetch();
}

function enabled_admin_count()
{
    $pdo = db_connection();
    $stmt = $pdo->query("SELECT COUNT(*) AS total FROM admins WHERE status = 'enabled'");
    $row = $stmt->fetch();
    return isset($row['total']) ? (int)$row['total'] : 0;
}

function update_admin($id, $data, $currentAdminId)
{
    $pdo = db_connection();
    $adminId = (int)$id;
    $existingAdmin = find_admin_by_id($adminId);

    if (!$existingAdmin) {
        throw new Exception('未找到对应的管理员记录。');
    }

    $checkStmt = $pdo->prepare('SELECT id FROM admins WHERE username = :username AND id <> :id LIMIT 1');
    $checkStmt->execute(array(
        ':username' => $data['username'],
        ':id' => $adminId
    ));

    if ($checkStmt->fetch()) {
        throw new Exception('管理员账号 ' . $data['username'] . ' 已存在，请更换账号。');
    }

    if (isset($data['status']) && $data['status'] === 'disabled' && isset($existingAdmin['status']) && $existingAdmin['status'] === 'enabled') {
        if ($adminId === (int)$currentAdminId) {
            throw new Exception('当前登录管理员不允许禁用自己的账号。');
        }

        if (enabled_admin_count() <= 1) {
            throw new Exception('系统至少需要保留 1 个启用状态的管理员账号，最后一个启用管理员不可禁用。');
        }
    }

    $role = isset($data['role']) && in_array($data['role'], array('super_admin', 'user'), true) ? $data['role'] : (isset($existingAdmin['role']) ? $existingAdmin['role'] : 'user');
    $params = array(
        ':id' => $adminId,
        ':username' => $data['username'],
        ':display_name' => $data['display_name'],
        ':status' => $data['status'],
        ':role' => $role,
        ':updated_at' => date('Y-m-d H:i:s')
    );

    $sql = 'UPDATE admins SET username = :username, display_name = :display_name, status = :status, role = :role, updated_at = :updated_at';

    if (isset($data['password']) && $data['password'] !== '') {
        $sql .= ', password = :password';
        $params[':password'] = hash_admin_password($data['password']);
    }

    $sql .= ' WHERE id = :id LIMIT 1';

    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return true;
}

function toggle_admin_status($id, $targetStatus, $currentAdminId)
{
    $statusOptions = admin_status_options();
    if (!isset($statusOptions[$targetStatus])) {
        throw new Exception('管理员状态值无效。');
    }

    $admin = find_admin_by_id($id);
    if (!$admin) {
        throw new Exception('未找到对应的管理员记录。');
    }

    if ((int)$id === (int)$currentAdminId && $targetStatus === 'disabled') {
        throw new Exception('当前登录管理员不允许禁用自己的账号。');
    }

    if ($targetStatus === 'disabled' && isset($admin['status']) && $admin['status'] === 'enabled') {
        $enabledTotal = enabled_admin_count();

        if ($enabledTotal <= 1) {
            throw new Exception('系统至少需要保留 1 个启用状态的管理员账号，当前账号不能被停用。');
        }
    }

    $pdo = db_connection();
    $stmt = $pdo->prepare('UPDATE admins SET status = :status, updated_at = :updated_at WHERE id = :id LIMIT 1');
    $stmt->execute(array(
        ':status' => $targetStatus,
        ':updated_at' => date('Y-m-d H:i:s'),
        ':id' => (int)$id
    ));

    return array(
        'id' => isset($admin['id']) ? (int)$admin['id'] : 0,
        'username' => isset($admin['username']) ? $admin['username'] : '',
        'status' => $targetStatus
    );
}

function delete_admin($id, $currentAdminId)
{
    $admin = find_admin_by_id($id);
    if (!$admin) {
        throw new Exception('未找到对应的管理员记录。');
    }

    if ((int)$id === (int)$currentAdminId) {
        throw new Exception('当前登录管理员不允许删除自己的账号。');
    }

    if (isset($admin['status']) && $admin['status'] === 'enabled' && enabled_admin_count() <= 1) {
        throw new Exception('系统至少需要保留 1 个启用状态的管理员账号，最后一个启用管理员不可删除。');
    }

    $pdo = db_connection();
    $stmt = $pdo->prepare('DELETE FROM admins WHERE id = :id LIMIT 1');
    $stmt->execute(array(':id' => (int)$id));

    return array(
        'id' => isset($admin['id']) ? (int)$admin['id'] : 0,
        'username' => isset($admin['username']) ? $admin['username'] : '',
        'status' => isset($admin['status']) ? $admin['status'] : ''
    );
}

function reset_admin_password($id, $newPassword)
{
    $admin = find_admin_by_id($id);
    if (!$admin) {
        throw new Exception('未找到对应的管理员记录。');
    }

    $pdo = db_connection();
    $stmt = $pdo->prepare('UPDATE admins SET password = :password, updated_at = :updated_at WHERE id = :id LIMIT 1');
    $stmt->execute(array(
        ':password' => hash_admin_password($newPassword),
        ':updated_at' => date('Y-m-d H:i:s'),
        ':id' => (int)$id
    ));

    return array(
        'id' => isset($admin['id']) ? (int)$admin['id'] : 0,
        'username' => isset($admin['username']) ? $admin['username'] : ''
    );
}

function verify_admin_identity_by_id($id, $password)
{
    $admin = find_admin_by_id($id);
    if (!$admin) {
        return false;
    }

    return verify_admin_password($password, isset($admin['password']) ? (string)$admin['password'] : '');
}

function ai_model_status_options()
{
    return array(
        'enabled' => '启用',
        'disabled' => '停用'
    );
}

function ai_provider_catalog()
{
    return array(
        'deepseek' => array(
            'name' => 'DeepSeek',
            'icon' => 'DS',
            'description' => 'DeepSeek 官方 OpenAI 兼容接口',
            'api_url' => 'https://api.deepseek.com/chat/completions',
            'requires_endpoint_id' => false,
            'models' => array(
                'deepseek-chat' => array('name' => 'DeepSeek Chat (V3)', 'desc' => '通用对话模型，适合日常内容生成'),
                'deepseek-reasoner' => array('name' => 'DeepSeek Reasoner (R1)', 'desc' => '推理增强模型，自动启用深度思考')
            )
        ),
        'volcengine' => array(
            'name' => '火山引擎',
            'icon' => 'HY',
            'description' => '火山方舟 豆包大模型 OpenAI 兼容接口',
            'api_url' => 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
            'requires_endpoint_id' => true,
            'models' => array(
                'doubao-pro' => array('name' => '豆包 Pro', 'desc' => '高性能通用模型'),
                'doubao-lite' => array('name' => '豆包 Lite', 'desc' => '轻量快速模型')
            )
        ),
        'xfyun_spark' => array(
            'name' => '讯飞星火',
            'icon' => 'XF',
            'description' => 'Spark Lite 免费模型，使用 APIPassword 认证',
            'api_url' => 'https://spark-api-open.xf-yun.com/v1/chat/completions',
            'requires_endpoint_id' => false,
            'models' => array(
                'lite' => array('name' => 'Spark Lite', 'desc' => '免费 · 轻量通用对话模型')
            )
        ),
        'xfyun_maas' => array(
            'name' => '讯飞MaaS',
            'icon' => 'XM',
            'description' => '讯飞 MaaS 推理服务平台，使用 APIKey 认证',
            'api_url' => 'https://maas-api.cn-huabei-1.xf-yun.com/v2/chat/completions',
            'requires_endpoint_id' => false,
            'models' => array(
                'xop35qwen2b' => array('name' => 'Qwen3.5-2B', 'desc' => '免费 · 通义千问 3.5 2B 轻量推理模型')
            )
        ),
        'baishan' => array(
            'name' => '白山智算',
            'icon' => 'BS',
            'description' => '白山云 LLM 模型服务平台 OpenAI 兼容接口',
            'api_url' => 'https://api.edgefn.net/v1/chat/completions',
            'requires_endpoint_id' => false,
            'models' => array(
                'KAT-Coder-Exp-72B-1010' => array('name' => 'KAT-Coder-Exp-72B-1010', 'desc' => '免费 · 72B 代码生成模型，适合代码与逻辑任务'),
                'DeepSeek-R1-0528-Qwen3-8B' => array('name' => 'DeepSeek-R1-0528-Qwen3-8B', 'desc' => '免费 · 轻量推理模型，基于 Qwen3-8B 蒸馏'),
                'MiniMax-M2.5' => array('name' => 'MiniMax-M2.5', 'desc' => '付费 · MiniMax 最新通用大模型'),
                'GLM-5' => array('name' => 'GLM-5', 'desc' => '付费 · 智谱 GLM-5 通用对话模型'),
                'GLM-5.1' => array('name' => 'GLM-5.1', 'desc' => '付费 · 智谱 GLM-5.1 增强版')
            )
        ),
        'openai_compatible' => array(
            'name' => '自定义 OpenAI 兼容',
            'icon' => 'AI',
            'description' => '接入任何兼容 OpenAI 聊天补全格式的第三方 API',
            'api_url' => '',
            'requires_endpoint_id' => false,
            'allows_custom_url' => true,
            'allows_custom_model' => true,
            'models' => array()
        )
    );
}

function normalize_openai_api_url($url)
{
    $url = rtrim(trim((string)$url), '/');
    if ($url === '') {
        return '';
    }
    if (preg_match('#/chat/completions$#i', $url)) {
        return $url;
    }
    if (preg_match('#/v\d+$#i', $url)) {
        return $url . '/chat/completions';
    }
    return $url . '/v1/chat/completions';
}

function ai_model_default_request_config($actualModelName, $modelKey)
{
    $config = array(
        'model' => $actualModelName,
        'messages' => array(
            array('role' => 'system', 'content' => '你是蜜蜂AI建站平台的智能写作助手。'),
            array('role' => 'user', 'content' => '请输出测试成功。')
        ),
        'stream' => false
    );

    if ($modelKey === 'deepseek-reasoner') {
        $config['thinking'] = array('type' => 'enabled');
    }

    return json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

function ai_model_test_prompt_default()
{
    return '请用一句话回复：AI 接口测试成功。';
}

function build_ai_model_test_payload($model, $prompt)
{
    $prompt = trim((string)$prompt);
    if ($prompt === '') {
        $prompt = ai_model_test_prompt_default();
    }

    $payload = array(
        'model' => isset($model['model_name']) ? (string)$model['model_name'] : '',
        'messages' => array(
            array(
                'role' => 'system',
                'content' => '你是蜜蜂AI建站平台的接口测试助手。'
            ),
            array(
                'role' => 'user',
                'content' => $prompt
            )
        ),
        'stream' => false
    );

    $modelKey = isset($model['request_headers']) ? (string)$model['request_headers'] : '';
    if ($modelKey === 'deepseek-reasoner' || (isset($model['model_name']) && $model['model_name'] === 'deepseek-reasoner')) {
        $payload['thinking'] = array('type' => 'enabled');
    }

    return $payload;
}

function execute_ai_model_test_request($model, $prompt)
{
    require_license_guard('测试AI模型', false, 'ai_generate');
    if (!function_exists('curl_init')) {
        throw new Exception('当前 PHP 环境未启用 cURL 扩展，无法执行模型测试请求。');
    }

    $apiUrl = isset($model['api_url']) ? trim((string)$model['api_url']) : '';
    $apiKey = isset($model['api_key']) ? trim((string)$model['api_key']) : '';

    if ($apiUrl === '' || !filter_var($apiUrl, FILTER_VALIDATE_URL)) {
        throw new Exception('当前模型配置的 API 地址无效。');
    }

    if ($apiKey === '') {
        throw new Exception('当前模型未配置 API Key。');
    }

    $payload = build_ai_model_test_payload($model, $prompt);
    $payloadJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    if ($payloadJson === false) {
        throw new Exception('测试请求体生成失败。');
    }

    $headers = array(
        'Content-Type: application/json',
        'Accept: application/json',
        'Authorization: Bearer ' . $apiKey
    );

    $ch = curl_init($apiUrl);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $payloadJson);
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_TIMEOUT, 60);
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);

    $responseBody = curl_exec($ch);
    $curlError = curl_error($ch);
    $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($responseBody === false) {
        throw new Exception('调用 AI 接口失败：' . $curlError);
    }

    $decoded = json_decode($responseBody, true);
    $assistantText = '';
    $errorMessage = '';

    if (is_array($decoded)) {
        if (isset($decoded['choices'][0]['message']['content'])) {
            $assistantText = (string)$decoded['choices'][0]['message']['content'];
        }
        if (isset($decoded['error']['message'])) {
            $errorMessage = (string)$decoded['error']['message'];
        }
    }

    return array(
        'request_payload' => $payload,
        'request_payload_json' => $payloadJson,
        'http_code' => $httpCode,
        'response_body' => $responseBody,
        'response_data' => $decoded,
        'assistant_text' => $assistantText,
        'error_message' => $errorMessage,
        'success' => $httpCode >= 200 && $httpCode < 300 && $errorMessage === ''
    );
}

function test_ai_model_by_id($id, $prompt)
{
    $model = find_ai_model_by_id($id);
    if (!$model) {
        throw new Exception('未找到对应的AI模型配置。');
    }

    $result = execute_ai_model_test_request($model, $prompt);
    $result['model'] = $model;
    return $result;
}

function find_ai_model_by_provider($providerCode)
{
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT id, name, code, provider, model_name, api_url, api_method, api_key, request_headers, request_config, status, remark, created_at, updated_at FROM ai_models WHERE code = :code AND user_id = :uid LIMIT 1');
    $stmt->execute(array(':code' => (string)$providerCode, ':uid' => current_user_id()));
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    if ($row && isset($row['api_key'])) {
        $row['api_key'] = decrypt_value($row['api_key']);
    }
    return $row;
}

function save_provider_config($providerCode, $data)
{
    require_license_guard('保存AI模型配置', false, 'ai_generate');
    $catalog = ai_provider_catalog();
    if (!isset($catalog[$providerCode])) {
        throw new Exception('未知的服务商。');
    }
    $provider = $catalog[$providerCode];

    $allowsCustomModel = !empty($provider['allows_custom_model']);
    $allowsCustomUrl = !empty($provider['allows_custom_url']);

    $modelKey = isset($data['model_key']) ? trim((string)$data['model_key']) : '';
    $customModel = isset($data['custom_model']) ? trim((string)$data['custom_model']) : '';
    $customApiUrl = isset($data['custom_api_url']) ? trim((string)$data['custom_api_url']) : '';

    if ($allowsCustomModel) {
        if ($customModel === '') {
            throw new Exception('请输入模型名称。');
        }
        $modelKey = $customModel;
        $displayName = $customModel;
    } else {
        if ($modelKey === '' || !isset($provider['models'][$modelKey])) {
            throw new Exception('请选择有效的模型。');
        }
        $displayName = $provider['models'][$modelKey]['name'];
    }

    if ($allowsCustomUrl) {
        if ($customApiUrl !== '') {
            $customApiUrl = normalize_openai_api_url($customApiUrl);
            if (!filter_var($customApiUrl, FILTER_VALIDATE_URL)) {
                throw new Exception('请输入有效的 API 地址。');
            }
            $apiUrl = $customApiUrl;
        } elseif (isset($provider['api_url']) && $provider['api_url'] !== '') {
            $apiUrl = $provider['api_url'];
        } else {
            throw new Exception('请输入有效的 API 地址。');
        }
    } else {
        $apiUrl = $provider['api_url'];
    }

    $apiKey = isset($data['api_key']) ? trim((string)$data['api_key']) : '';
    $remark = isset($data['remark']) ? trim((string)$data['remark']) : '';

    $actualModelName = $modelKey;
    if ($provider['requires_endpoint_id']) {
        $endpointId = isset($data['endpoint_id']) ? trim((string)$data['endpoint_id']) : '';
        if ($endpointId === '') {
            throw new Exception('请填写接入点ID (Endpoint ID)。');
        }
        $actualModelName = $endpointId;
    }

    $existing = find_ai_model_by_provider($providerCode);

    if ($apiKey === '' && $existing && isset($existing['api_key']) && $existing['api_key'] !== '') {
        $apiKey = (string)$existing['api_key'];
    }
    if ($apiKey === '') {
        throw new Exception('API Key 不可用（可能因系统重装导致密钥丢失），请重新输入 API Key。');
    }

    $pdo = db_connection();

    if ($existing) {
        $stmt = $pdo->prepare('UPDATE ai_models SET name=:name, provider=:provider, model_name=:model_name, api_url=:api_url, api_method=:api_method, api_key=:api_key, request_headers=:request_headers, request_config=:request_config, status=:status, remark=:remark, updated_at=:updated_at WHERE id=:id LIMIT 1');
        $stmt->execute(array(
            ':name' => $displayName,
            ':provider' => $provider['name'],
            ':model_name' => $actualModelName,
            ':api_url' => $apiUrl,
            ':api_method' => 'POST',
            ':api_key' => encrypt_value($apiKey),
            ':request_headers' => $modelKey,
            ':request_config' => ai_model_default_request_config($actualModelName, $modelKey),
            ':status' => 'enabled',
            ':remark' => $remark,
            ':updated_at' => date('Y-m-d H:i:s'),
            ':id' => (int)$existing['id']
        ));
        log_admin_action('ai_model_config_update', '更新AI模型配置 ID:' . (int)$existing['id'] . ' 服务商:' . $provider['name']);
        return (int)$existing['id'];
    } else {
        $now = date('Y-m-d H:i:s');
        $stmt = $pdo->prepare('INSERT INTO ai_models (user_id, name, code, provider, model_name, api_url, api_method, api_key, request_headers, request_config, status, remark, created_at, updated_at) VALUES (:uid, :name, :code, :provider, :model_name, :api_url, :api_method, :api_key, :request_headers, :request_config, :status, :remark, :created_at, :updated_at)');
        $stmt->execute(array(
            ':uid' => current_user_id(),
            ':name' => $displayName,
            ':code' => $providerCode,
            ':provider' => $provider['name'],
            ':model_name' => $actualModelName,
            ':api_url' => $apiUrl,
            ':api_method' => 'POST',
            ':api_key' => encrypt_value($apiKey),
            ':request_headers' => $modelKey,
            ':request_config' => ai_model_default_request_config($actualModelName, $modelKey),
            ':status' => 'enabled',
            ':remark' => $remark,
            ':created_at' => $now,
            ':updated_at' => $now
        ));
        $newId = (int)$pdo->lastInsertId();
        log_admin_action('ai_model_config_create', '创建AI模型配置 ID:' . $newId . ' 服务商:' . $provider['name']);
        return $newId;
    }
}

function list_ai_models($keyword, $status)
{
    $pdo = db_connection();
    $sql = 'SELECT id, name, code, provider, model_name, api_url, api_method, status, remark, created_at, updated_at FROM ai_models WHERE user_id = :uid';
    $params = array(':uid' => current_user_id());

    if ($keyword !== '') {
        $sql .= ' AND (name LIKE :keyword_name OR code LIKE :keyword_code OR provider LIKE :keyword_provider OR model_name LIKE :keyword_model_name)';
        $params[':keyword_name'] = '%' . $keyword . '%';
        $params[':keyword_code'] = '%' . $keyword . '%';
        $params[':keyword_provider'] = '%' . $keyword . '%';
        $params[':keyword_model_name'] = '%' . $keyword . '%';
    }

    if ($status !== '' && $status !== 'all') {
        $sql .= ' AND status = :status';
        $params[':status'] = $status;
    }

    $sql .= ' ORDER BY id DESC';
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll();
}

function create_ai_model($data)
{
    require_license_guard('创建AI模型', false, 'ai_generate');
    ensure_ai_model_storage_ready();
    $catalogOptions = ai_model_catalog_options();
    $modelName = isset($data['model_name']) ? trim((string)$data['model_name']) : '';
    if ($modelName === '' || !isset($catalogOptions[$modelName])) {
        throw new Exception('请选择可用的模型。');
    }
    $info = $catalogOptions[$modelName];
    $apiKey = isset($data['api_key']) ? trim((string)$data['api_key']) : '';
    $endpointId = isset($data['endpoint_id']) ? trim((string)$data['endpoint_id']) : '';
    $actualModelName = $modelName;
    if (!empty($info['requires_endpoint_id']) && $endpointId !== '') {
        $actualModelName = $endpointId;
    }
    $now = date('Y-m-d H:i:s');
    $pdo = db_connection();
    $stmt = $pdo->prepare('INSERT INTO ai_models (name, code, provider, model_name, api_url, api_method, api_key, request_headers, request_config, status, remark, created_at, updated_at) VALUES (:name, :code, :provider, :model_name, :api_url, :api_method, :api_key, :request_headers, :request_config, :status, :remark, :created_at, :updated_at)');
    $stmt->execute(array(
        ':name' => isset($info['name']) ? $info['name'] : $modelName,
        ':code' => isset($info['provider_code']) ? $info['provider_code'] : '',
        ':provider' => isset($info['provider']) ? $info['provider'] : '',
        ':model_name' => $actualModelName,
        ':api_url' => isset($data['api_url']) ? trim((string)$data['api_url']) : '',
        ':api_method' => 'POST',
        ':api_key' => encrypt_value($apiKey),
        ':request_headers' => $modelName,
        ':request_config' => ai_model_default_request_config($actualModelName, $modelName),
        ':status' => isset($data['status']) ? trim((string)$data['status']) : 'enabled',
        ':remark' => isset($data['remark']) ? trim((string)$data['remark']) : '',
        ':created_at' => $now,
        ':updated_at' => $now
    ));
    $newId = (int)$pdo->lastInsertId();
    log_admin_action('ai_model_create', '创建AI模型 ID:' . $newId . ' 模型:' . $modelName);
    return $newId;
}

function update_ai_model($id, $data)
{
    require_license_guard('更新AI模型', false, 'ai_generate');
    $existing = find_ai_model_by_id($id);
    if (!$existing) {
        throw new Exception('指定的AI模型不存在。');
    }
    $catalogOptions = ai_model_catalog_options();
    $modelName = isset($data['model_name']) ? trim((string)$data['model_name']) : '';
    if ($modelName === '' || !isset($catalogOptions[$modelName])) {
        throw new Exception('请选择可用的模型。');
    }
    $info = $catalogOptions[$modelName];
    $apiKey = isset($data['api_key']) ? trim((string)$data['api_key']) : '';
    $endpointId = isset($data['endpoint_id']) ? trim((string)$data['endpoint_id']) : '';
    $actualModelName = $modelName;
    if (!empty($info['requires_endpoint_id']) && $endpointId !== '') {
        $actualModelName = $endpointId;
    }
    if ($apiKey === '' && isset($_POST['keep_api_key']) && $_POST['keep_api_key'] === '1') {
        $apiKey = '';
    }
    $params = array(
        ':name' => isset($info['name']) ? $info['name'] : $modelName,
        ':provider' => isset($info['provider']) ? $info['provider'] : '',
        ':model_name' => $actualModelName,
        ':api_url' => isset($data['api_url']) ? trim((string)$data['api_url']) : '',
        ':api_method' => 'POST',
        ':request_headers' => $modelName,
        ':request_config' => ai_model_default_request_config($actualModelName, $modelName),
        ':status' => isset($data['status']) ? trim((string)$data['status']) : 'enabled',
        ':remark' => isset($data['remark']) ? trim((string)$data['remark']) : '',
        ':updated_at' => date('Y-m-d H:i:s'),
        ':id' => (int)$id
    );
    $pdo = db_connection();
    if ($apiKey !== '') {
        $stmt = $pdo->prepare('UPDATE ai_models SET name=:name, provider=:provider, model_name=:model_name, api_url=:api_url, api_method=:api_method, api_key=:api_key, request_headers=:request_headers, request_config=:request_config, status=:status, remark=:remark, updated_at=:updated_at WHERE id=:id LIMIT 1');
        $params[':api_key'] = encrypt_value($apiKey);
    } else {
        $stmt = $pdo->prepare('UPDATE ai_models SET name=:name, provider=:provider, model_name=:model_name, api_url=:api_url, api_method=:api_method, request_headers=:request_headers, request_config=:request_config, status=:status, remark=:remark, updated_at=:updated_at WHERE id=:id LIMIT 1');
    }
    $stmt->execute($params);
    log_admin_action('ai_model_update', '更新AI模型 ID:' . (int)$id);
    return true;
}

function find_ai_model_by_id($id)
{
    $pdo = db_connection();
    $uid = current_user_id();
    if ($uid > 0) {
        $stmt = $pdo->prepare('SELECT id, name, code, provider, model_name, api_url, api_method, api_key, request_headers, request_config, status, remark, created_at, updated_at FROM ai_models WHERE id = :id AND user_id = :uid LIMIT 1');
        $stmt->execute(array(':id' => (int)$id, ':uid' => $uid));
    } else {
        $stmt = $pdo->prepare('SELECT id, name, code, provider, model_name, api_url, api_method, api_key, request_headers, request_config, status, remark, created_at, updated_at FROM ai_models WHERE id = :id LIMIT 1');
        $stmt->execute(array(':id' => (int)$id));
    }
    $row = $stmt->fetch();
    if ($row && isset($row['api_key'])) {
        $row['api_key'] = decrypt_value($row['api_key']);
    }
    return $row;
}

function list_enabled_ai_model_options()
{
    $pdo = db_connection();
    $uid = current_user_id();
    $stmt = $pdo->prepare("SELECT id, name, code, provider, model_name FROM ai_models WHERE status = 'enabled' AND user_id = :uid ORDER BY id DESC");
    $stmt->execute(array(':uid' => $uid));
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function find_domain_by_id($id)
{
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT * FROM domains WHERE id = :id LIMIT 1');
    $stmt->execute(array(':id' => (int)$id));
    return $stmt->fetch(PDO::FETCH_ASSOC);
}

function list_domain_groups()
{
    $defaults = array('未命名分组');
    try {
        $pdo = db_connection();
        $uid = current_user_id();
        $stmt = $pdo->prepare("SELECT DISTINCT group_name FROM domains WHERE user_id = :uid AND group_name <> '' ORDER BY group_name ASC");
        $stmt->execute(array(':uid' => $uid));
        $dbGroups = $stmt->fetchAll(PDO::FETCH_COLUMN);
    } catch (Exception $e) {
        $dbGroups = array();
    }
    $merged = $defaults;
    foreach ($dbGroups as $g) {
        $g = trim((string)$g);
        if ($g !== '' && !in_array($g, $merged, true)) {
            $merged[] = $g;
        }
    }
    return $merged;
}

function normalize_logo_url_to_relative($logoUrl)
{
    $logoUrl = trim((string)$logoUrl);
    if ($logoUrl === '') {
        return '';
    }
    $parsed = parse_url($logoUrl);
    if (isset($parsed['host']) && isset($parsed['path'])) {
        return $parsed['path'] . (isset($parsed['query']) ? '?' . $parsed['query'] : '');
    }
    return $logoUrl;
}

function build_site_logo_html($domain)
{
    $logoUrl = isset($domain['logo_url']) ? trim((string)$domain['logo_url']) : '';
    $siteName = isset($domain['site_name']) ? (string)$domain['site_name'] : '';
    if ($logoUrl !== '') {
        $logoUrl = normalize_logo_url_to_relative($logoUrl);
        return '<img src="' . e($logoUrl) . '" alt="' . e($siteName) . '" class="site-logo" style="max-height:40px;width:auto;display:inline-block;vertical-align:middle;">';
    }
    return e($siteName);
}

function domain_language_options()
{
    return array(
        'zh-CN' => '中文（简体）',
        'zh-TW' => '中文（繁体）',
        'en-US' => 'English (US)',
        'en-GB' => 'English (UK)',
        'ja-JP' => '日本語',
        'ko-KR' => '한국어',
    );
}

function normalize_domain_language($value)
{
    $value = trim((string)$value);
    $options = domain_language_options();
    return isset($options[$value]) ? $value : 'zh-CN';
}

function domain_site_language($domain)
{
    if (is_array($domain) && !empty($domain['site_language'])) {
        return normalize_domain_language($domain['site_language']);
    }
    return 'zh-CN';
}

function domain_language_texts($domain)
{
    $lang = domain_site_language($domain);
    if ($lang === 'en-US' || $lang === 'en-GB') {
        return array(
            'home_menu_name' => 'Home',
            'home_menu_seo_description' => 'Default home menu',
            'articles_menu_name' => 'Articles',
            'articles_title' => 'Article List',
            'articles_heading' => 'Latest Articles',
            'articles_footer_text' => 'Default article archive page',
            'tag_articles_prefix' => 'Related Articles',
            'tag_description_suffix' => 'related content archive',
            'home_breadcrumb' => 'Home',
            'article_list_meta_keywords' => 'article list,news center',
        );
    }
    return array(
        'home_menu_name' => '首页',
        'home_menu_seo_description' => '默认首页菜单',
        'articles_menu_name' => '文章列表',
        'articles_title' => '文章列表',
        'articles_heading' => '最新文章列表',
        'articles_footer_text' => '默认文章聚合页',
        'tag_articles_prefix' => '相关文章',
        'tag_description_suffix' => '相关内容聚合页',
        'home_breadcrumb' => '首页',
        'article_list_meta_keywords' => '文章列表,资讯中心',
    );
}

function normalize_domain_record_input($data)
{
    return array(
        'domain' => strtolower(trim((string)(isset($data['domain']) ? $data['domain'] : ''))),
        'site_name' => trim((string)(isset($data['site_name']) ? $data['site_name'] : '')),
        'group_name' => trim((string)(isset($data['group_name']) ? $data['group_name'] : (isset($data['group']) ? $data['group'] : ''))),
        'template_pack' => trim((string)(isset($data['template_pack']) ? $data['template_pack'] : (isset($data['template']) ? $data['template'] : ''))),
        'status' => trim((string)(isset($data['status']) ? $data['status'] : 'enabled')),
        'remark' => trim((string)(isset($data['remark']) ? $data['remark'] : '')),
        'logo_url' => trim((string)(isset($data['logo_url']) ? $data['logo_url'] : '')),
        'site_language' => normalize_domain_language(isset($data['site_language']) ? $data['site_language'] : 'zh-CN'),
        'site_root' => '',
    );
}

function validate_domain_record_input($data, $domainId = 0)
{
    $record = normalize_domain_record_input($data);
    if ($record['domain'] === '') {
        throw new Exception('域名不能为空。');
    }
    if (!preg_match('/^[a-zA-Z0-9]([a-zA-Z0-9\-]*\.)+[a-zA-Z]{2,}$/', $record['domain'])) {
        throw new Exception($domainId > 0 ? '域名格式不正确，请输入有效域名（如 example.com）。' : '域名格式不正确');
    }
    if ($record['site_name'] === '') {
        throw new Exception('站点名称不能为空。');
    }
    if ($record['group_name'] === '') {
        throw new Exception('请选择域名分组。');
    }
    if ($record['template_pack'] === '') {
        throw new Exception('请选择模板套件。');
    }
    $statuses = domain_status_options();
    if (!isset($statuses[$record['status']])) {
        $record['status'] = 'enabled';
    }
    return $record;
}

function create_domain_record($data)
{
    require_license_guard('新增域名', false, 'domain_manage');
    ensure_domains_table_has_site_root();
    ensure_domains_table_has_logo_url();
    ensure_domains_table_has_site_language();
    ensure_domains_table_has_template_pack();
    ensure_domains_table_has_variation();
    $record = validate_domain_record_input($data, 0);
    $pdo = db_connection();
    $checkStmt = $pdo->prepare('SELECT id FROM domains WHERE domain = :domain AND user_id = :uid LIMIT 1');
    $checkStmt->execute(array(':domain' => $record['domain'], ':uid' => current_user_id()));
    if ($checkStmt->fetch()) {
        throw new Exception('已存在');
    }
    $now = date('Y-m-d H:i:s');
    $insertStmt = $pdo->prepare('INSERT INTO domains (user_id, domain, site_name, logo_url, site_language, site_root, group_name, template_name, template_pack, status, remark, created_at, updated_at) VALUES (:uid, :domain, :site_name, :logo_url, :site_language, :site_root, :group_name, :template_name, :template_pack, :status, :remark, :created_at, :updated_at)');
    $insertStmt->execute(array(
        ':uid' => current_user_id(),
        ':domain' => $record['domain'],
        ':site_name' => $record['site_name'],
        ':logo_url' => $record['logo_url'],
        ':site_language' => $record['site_language'],
        ':site_root' => $record['site_root'],
        ':group_name' => $record['group_name'],
        ':template_name' => $record['template_pack'],
        ':template_pack' => $record['template_pack'],
        ':status' => $record['status'],
        ':remark' => $record['remark'],
        ':created_at' => $now,
        ':updated_at' => $now,
    ));
    $id = (int)$pdo->lastInsertId();
    return find_domain_by_id($id);
}

function update_domain_record($domainId, $data)
{
    require_license_guard('更新域名', false, 'domain_manage');
    $domainId = (int)$domainId;
    if ($domainId <= 0) {
        throw new Exception('缺少有效的域名ID，无法编辑。');
    }
    ensure_domains_table_has_site_root();
    ensure_domains_table_has_logo_url();
    ensure_domains_table_has_site_language();
    ensure_domains_table_has_template_pack();
    ensure_domains_table_has_variation();
    $existing = find_domain_by_id($domainId);
    if (!$existing) {
        throw new Exception('未找到对应的域名记录。');
    }
    $record = validate_domain_record_input($data, $domainId);
    $pdo = db_connection();
    $checkStmt = $pdo->prepare('SELECT id FROM domains WHERE domain = :domain AND id <> :id LIMIT 1');
    $checkStmt->execute(array(':domain' => $record['domain'], ':id' => $domainId));
    if ($checkStmt->fetch()) {
        throw new Exception('该域名已被其他记录使用，请更换后再保存。');
    }
    $updateStmt = $pdo->prepare('UPDATE domains SET domain = :domain, site_name = :site_name, logo_url = :logo_url, site_language = :site_language, site_root = :site_root, group_name = :group_name, template_name = :template_name, template_pack = :template_pack, status = :status, remark = :remark, updated_at = :updated_at WHERE id = :id LIMIT 1');
    $updateStmt->execute(array(
        ':domain' => $record['domain'],
        ':site_name' => $record['site_name'],
        ':logo_url' => $record['logo_url'],
        ':site_language' => $record['site_language'],
        ':site_root' => $record['site_root'],
        ':group_name' => $record['group_name'],
        ':template_name' => $record['template_pack'],
        ':template_pack' => $record['template_pack'],
        ':status' => $record['status'],
        ':remark' => $record['remark'],
        ':updated_at' => date('Y-m-d H:i:s'),
        ':id' => $domainId,
    ));

    // ── 切换模板套件 / 站点根 / 域名时，旧静态文件已与新设置不匹配，
    //    必须清空，避免 nginx try_files 或 PHP 兜底命中旧 HTML 显示旧模板
    $oldPack = isset($existing['template_pack']) ? (string)$existing['template_pack'] : '';
    $newPack = (string)$record['template_pack'];
    $oldDomain = isset($existing['domain']) ? (string)$existing['domain'] : '';
    $oldSiteRoot = isset($existing['site_root']) ? (string)$existing['site_root'] : '';
    $needPurge = ($oldPack !== $newPack)
        || ($oldDomain !== (string)$record['domain'])
        || ($oldSiteRoot !== (string)$record['site_root']);
    if ($needPurge) {
        $purged = purge_domain_static_files($existing);
        // 标记已发布内容需要重新发布
        try {
            $pdo->prepare('UPDATE contents SET static_file_path = NULL, static_published_at = NULL WHERE domain_id = :id')
                ->execute(array(':id' => $domainId));
        } catch (Exception $e) {
            // 忽略：static_file_path 字段缺失等情况
        }
        write_publish_log(array(
            'domain_id' => $domainId,
            'content_id' => 0,
            'publish_scope' => 'domain',
            'publish_action' => 'auto_purge_on_change',
            'status' => 'success',
            'message' => '模板/域名/站点根变更，已自动清理旧静态文件',
            'meta_json' => json_encode(array(
                'old_pack' => $oldPack,
                'new_pack' => $newPack,
                'purged_files' => $purged
            ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
        ));
    }

    return find_domain_by_id($domainId);
}

/**
 * 清空指定域名的静态发布目录（site/{domain}/ 下所有文件）。
 * 切换模板套件、域名、站点根目录后必调用，避免旧 HTML 被 nginx/PHP 命中。
 * @return int 已删除文件数（用于日志）
 */
function purge_domain_static_files($domain)
{
    if (!is_array($domain) || empty($domain)) {
        return 0;
    }
    $siteRoot = domain_static_site_root($domain);
    if ($siteRoot === '' || !is_dir($siteRoot)) {
        return 0;
    }
    $base = site_base_directory();
    // 安全护栏：必须位于 site/ 目录下，避免误删根目录
    if (strpos($siteRoot, $base) !== 0 || strlen($siteRoot) <= strlen($base) + 1) {
        return 0;
    }
    $count = 0;
    $iter = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($siteRoot, RecursiveDirectoryIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST
    );
    foreach ($iter as $file) {
        $path = $file->getPathname();
        if ($file->isFile()) {
            if (@unlink($path)) { $count++; }
        } elseif ($file->isDir()) {
            @rmdir($path);
        }
    }
    return $count;
}

function ensure_domains_table_has_site_root()
{
    static $checked = false;
    if ($checked) return;
    $checked = true;
    if (!table_exists('domains')) {
        return;
    }
    if (!column_exists('domains', 'site_root')) {
        $pdo = db_connection();
        $pdo->exec("ALTER TABLE `domains` ADD COLUMN `site_root` VARCHAR(500) NOT NULL DEFAULT '' AFTER `site_name`");
    }
}

function ensure_domains_table_has_logo_url()
{
    static $checked = false;
    if ($checked) return;
    $checked = true;
    if (!table_exists('domains')) {
        return;
    }
    if (!column_exists('domains', 'logo_url')) {
        $pdo = db_connection();
        $pdo->exec("ALTER TABLE `domains` ADD COLUMN `logo_url` VARCHAR(500) NOT NULL DEFAULT '' AFTER `site_name`");
    }
}

function ensure_domains_table_has_site_language()
{
    static $checked = false;
    if ($checked) return;
    $checked = true;
    if (!table_exists('domains')) {
        return;
    }
    if (!column_exists('domains', 'site_language')) {
        $pdo = db_connection();
        $pdo->exec("ALTER TABLE `domains` ADD COLUMN `site_language` VARCHAR(20) NOT NULL DEFAULT 'zh-CN' AFTER `logo_url`");
    }
}

function normalize_domain_site_root($value)
{
    $value = trim((string)$value);
    if ($value === '') {
        return '';
    }
    $value = str_replace('/', DIRECTORY_SEPARATOR, $value);
    $value = preg_replace('#[\\/]+#', DIRECTORY_SEPARATOR, $value);
    return rtrim((string)$value, " \\//");
}

function default_shared_site_root()
{
    return normalize_domain_site_root(dirname(__DIR__));
}

function site_base_directory()
{
    return default_shared_site_root() . DIRECTORY_SEPARATOR . 'site';
}

function domain_static_site_root($domain)
{
    $base = site_base_directory();
    $configured = isset($domain['site_root']) ? trim((string)$domain['site_root']) : '';
    if ($configured !== '') {
        $safeName = preg_replace('/[^a-zA-Z0-9._\-]/', '_', $configured);
        $dir = $base . DIRECTORY_SEPARATOR . $safeName;
    } else {
        $domainName = isset($domain['domain']) ? trim((string)$domain['domain']) : '';
        if ($domainName === '') {
            return $base;
        }
        $safeName = preg_replace('/[^a-zA-Z0-9._\-]/', '_', $domainName);
        $dir = $base . DIRECTORY_SEPARATOR . $safeName;
    }
    // 不在此处创建目录；仅返回应当使用的目录路径。
    // 目录的实际创建推迟到发布流程（publish_content_static / publish_domain_archive_pages）中执行。
    return $dir;
}

function domain_uses_runtime_static_root($domain)
{
    return false;
}

function domain_uses_auto_site_root($domain)
{
    $configured = isset($domain['site_root']) ? trim((string)$domain['site_root']) : '';
    return $configured === '';
}

function check_domain_site_root_status($domain)
{
    $domainName = isset($domain['domain']) ? (string)$domain['domain'] : '';
    $siteRoot = domain_static_site_root($domain);
    $configured = !domain_uses_auto_site_root($domain);
    $exists = $siteRoot !== '' && file_exists($siteRoot);
    $isDirectory = $exists && is_dir($siteRoot);
    $writable = $isDirectory && is_writable($siteRoot);
    $message = '';

    if ($siteRoot === '') {
        $message = '站点目录为空。';
    } elseif (!$exists) {
        $message = '站点目录不存在，请先在服务器中创建。';
    } elseif (!$isDirectory) {
        $message = '当前配置不是目录，请检查站点根目录。';
    } elseif (!$writable) {
        $message = '目录存在，但 PHP 当前无写入权限。';
    } else {
        $message = $configured ? '站点目录存在且可写，发布到 site/' . basename($siteRoot) . '/。' : '使用域名自动分配目录 site/' . basename($siteRoot) . '/，目录存在且可写。';
    }

    return array(
        'domain' => $domainName,
        'site_root' => $siteRoot,
        'configured' => $configured,
        'exists' => $exists,
        'is_directory' => $isDirectory,
        'writable' => $writable,
        'ok' => $isDirectory && $writable,
        'message' => $message
    );
}

function normalize_site_build_slug($value)
{
    $value = strtolower(trim((string)$value));
    $value = preg_replace('/[^a-z0-9]+/', '-', $value);
    $value = trim((string)$value, '-');
    return $value;
}

function transliterate_tag_slug($value)
{
    $value = trim((string)$value);
    if ($value === '') {
        return '';
    }
    $nodeScript = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'tag-pinyin' . DIRECTORY_SEPARATOR . 'slugify.js';
    static $cachedNodeBinary = null;
    static $nodeChecked = false;
    $shellExecAvailable = function_exists('shell_exec')
        && !in_array('shell_exec', array_map('trim', explode(',', (string)ini_get('disable_functions'))), true);
    if ($shellExecAvailable && file_exists($nodeScript)) {
        if (!$nodeChecked) {
            $nodeChecked = true;
            $candidates = PHP_OS_FAMILY === 'Windows'
                ? array('node', 'C:\\Program Files\\nodejs\\node.exe', 'C:\\Program Files (x86)\\nodejs\\node.exe')
                : array('/usr/local/bin/node', '/usr/bin/node', 'node');
            foreach ($candidates as $bin) {
                $test = @shell_exec('"' . $bin . '" -v 2>/dev/null');
                if (is_string($test) && strpos(trim($test), 'v') === 0) {
                    $cachedNodeBinary = $bin;
                    break;
                }
            }
        }
        if ($cachedNodeBinary !== null) {
            $command = '"' . $cachedNodeBinary . '" "' . $nodeScript . '" ' . escapeshellarg($value);
            $output = @shell_exec($command);
            if (is_string($output)) {
                $output = trim($output);
                if ($output !== '') {
                    return normalize_site_build_slug($output);
                }
            }
        }
    }
    if (function_exists('transliterator_transliterate')) {
        $converted = transliterator_transliterate('Any-Latin; Latin-ASCII; Lower(); [:Nonspacing Mark:] Remove; [:Punctuation:] Remove;', $value);
        if (is_string($converted) && trim($converted) !== '') {
            return normalize_site_build_slug($converted);
        }
    }
    if (function_exists('iconv')) {
        $converted = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
        if (is_string($converted) && trim($converted) !== '') {
            return normalize_site_build_slug($converted);
        }
    }
    return normalize_site_build_slug($value);
}

function normalize_tag_slug($value)
{
    $slug = transliterate_tag_slug($value);
    if ($slug === '') {
        $slug = 'tag';
    }
    return $slug;
}

function generate_unique_tag_slug($domainId, $baseSlug, $excludeId = 0)
{
    ensure_tag_storage_ready();
    $domainId = (int)$domainId;
    $baseSlug = normalize_tag_slug($baseSlug);
    if ($baseSlug === '') {
        $baseSlug = 'tag';
    }
    $candidate = $baseSlug;
    $suffix = 2;
    while (find_tag_by_domain_and_slug($domainId, $candidate, $excludeId)) {
        $candidate = $baseSlug . '-' . $suffix;
        $suffix++;
    }
    return $candidate;
}

function tag_public_url_path($slug)
{
    // 标签页不参与静态化（数据量大时不适合），始终采用伪静态 URL 形式 /tag/{slug}/，
    // 由 nginx 伪静态规则或 PHP 路由解析（始终走 PHP 实时渲染）。
    $slug = normalize_tag_slug($slug);
    return '/tag/' . $slug . '/';
}

function is_tag_archive_path($path)
{
    return parse_tag_archive_path($path) !== null;
}

function parse_tag_archive_path($path)
{
    $path = trim(str_replace('\\', '/', (string)$path), '/');
    if ($path === '') {
        return null;
    }
    $segments = array_values(array_filter(explode('/', $path), 'strlen'));
    if (count($segments) < 2) {
        return null;
    }
    if (strtolower((string)$segments[0]) !== 'tag') {
        return null;
    }
    $slug = normalize_tag_slug($segments[1]);
    if ($slug === '') {
        return null;
    }
    $page = 1;
    if (count($segments) >= 4 && strtolower((string)$segments[2]) === 'page') {
        $page = max(1, (int)$segments[3]);
    }
    return array(
        'slug' => $slug,
        'page' => $page
    );
}

function tag_archive_public_url_path($slug, $page = 1)
{
    $base = tag_public_url_path($slug);
    $page = max(1, (int)$page);
    if ($page <= 1) {
        return $base;
    }
    return rtrim($base, '/') . '/page/' . $page . '/';
}

function tag_effective_slug($tag)
{
    if (is_array($tag) && isset($tag['slug'])) {
        return normalize_tag_slug((string)$tag['slug']);
    }
    if (is_array($tag) && isset($tag['name']) && trim((string)$tag['name']) !== '') {
        return normalize_tag_slug((string)$tag['name']);
    }
    return normalize_tag_slug((string)$tag);
}

function tag_name_derived_slug($tag)
{
    if (is_array($tag) && isset($tag['name']) && trim((string)$tag['name']) !== '') {
        return normalize_tag_slug((string)$tag['name']);
    }
    return '';
}

function generate_unique_content_slug($baseSlug)
{
    $baseSlug = normalize_site_build_slug($baseSlug);
    if ($baseSlug === '') {
        $baseSlug = 'page';
    }

    $candidate = $baseSlug;
    $pdo = db_connection();
    $index = 1;

    while (true) {
        $stmt = $pdo->prepare('SELECT id FROM contents WHERE slug = :slug LIMIT 1');
        $stmt->execute(array(':slug' => $candidate));
        if (!$stmt->fetch()) {
            return $candidate;
        }
        $index++;
        $candidate = $baseSlug . '-' . $index;
    }
}

function normalize_content_path($value)
{
    $value = strtolower(trim((string)$value));
    $value = str_replace('\\', '/', $value);
    $segments = explode('/', $value);
    $normalized = array();

    foreach ($segments as $segment) {
        $segment = normalize_site_build_slug($segment);
        if ($segment !== '') {
            $normalized[] = $segment;
        }
    }

    return implode('/', $normalized);
}

function content_slug_from_path($path)
{
    $path = normalize_content_path($path);
    if ($path === '') {
        return '';
    }
    $segments = explode('/', $path);
    return trim((string)end($segments));
}

function normalize_public_asset_url($path)
{
    $path = trim((string)$path);
    if ($path === '') {
        return '';
    }
    if (preg_match('#^(https?:)?//#i', $path)) {
        return $path;
    }
    $path = str_replace('\\', '/', $path);
    return '/' . ltrim($path, '/');
}

/**
 * 判断「伪静态」开关是否开启。
 * ON  → 前端生成 /foo.html、/tiyu/、/tag/x/、/articles/ 这类干净 URL（需要 nginx 伪静态规则配合）
 * OFF → 前端生成 /index.php?slug=foo 这类原生动态 URL（不依赖任何服务器重写规则即可访问）
 */
function pseudo_static_enabled()
{
    static $cached = null;
    if ($cached !== null) {
        return $cached;
    }
    $cfg = app_runtime_config();
    $cached = !empty($cfg['pseudo_static']['enabled']);
    return $cached;
}

/**
 * 生成内容公开 URL 路径。
 *
 * 链接格式决策矩阵：
 *   伪静态开关 ON              → 永远 /foo.html
 *   开关 OFF + 已发布静态文件   → /foo.html（让 nginx try_files 命中磁盘静态文件）
 *   开关 OFF + 未发布静态文件   → /index.php?slug=foo（动态渲染）
 *
 * @param string    $path           内容路径（不含 .html 后缀）
 * @param bool|null $hasStaticFile  该内容是否已生成静态文件；null = 仅依据全局开关判断
 */
function content_public_url_path($path, $hasStaticFile = null)
{
    $path = normalize_content_path($path);
    if ($path === '') {
        return '/';
    }
    if (pseudo_static_enabled() || $hasStaticFile === true) {
        return '/' . $path . '.html';
    }
    return '/index.php?slug=' . rawurlencode($path);
}

/**
 * 判断内容记录是否已生成静态文件。
 * 优先看 DB 标记 static_file_path，否则按 HTTP_HOST 查域名再探磁盘。
 */
function content_has_static_file($content)
{
    if (!is_array($content)) {
        return false;
    }
    if (!empty($content['static_file_path'])) {
        return true;
    }
    return false;
}

function is_homepage_template_type($templateType)
{
    return trim((string)$templateType) === 'homepage';
}

function is_article_source_type($sourceType)
{
    $sourceType = trim((string)$sourceType);
    return $sourceType === 'article_ai' || $sourceType === 'article_manual' || strpos($sourceType, 'article') === 0;
}

function template_looks_like_homepage($template)
{
    if (!is_array($template)) {
        return false;
    }
    if (is_homepage_template_type(isset($template['type']) ? $template['type'] : '')) {
        return true;
    }

    $htmlContent = isset($template['html_content']) ? (string)$template['html_content'] : '';
    if ($htmlContent === '') {
        return false;
    }

    $homepageMarkers = array(
        '{{hero_title}}',
        '{{hero_subtitle}}',
        '{{services_title}}',
        '{{feature_section_title}}',
        '{{cta_title}}'
    );

    foreach ($homepageMarkers as $marker) {
        if (strpos($htmlContent, $marker) !== false) {
            return true;
        }
    }

    return false;
}

function is_homepage_template_id($templateId)
{
    $template = find_template_by_id((int)$templateId);
    if (!$template) {
        return false;
    }
    return template_looks_like_homepage($template);
}

function content_effective_public_path($content)
{
    if (isset($content['template_type']) && is_homepage_template_type($content['template_type'])) {
        return '';
    }
    if (isset($content['template_id']) && is_homepage_template_id((int)$content['template_id'])) {
        return '';
    }
    return isset($content['path']) ? normalize_content_path((string)$content['path']) : '';
}

function content_legacy_redirect_path($content)
{
    $path = isset($content['path']) ? normalize_content_path((string)$content['path']) : '';
    if ($path === '') {
        return '';
    }
    if (isset($content['template_type']) && is_homepage_template_type($content['template_type']) && !is_homepage_content_path($path)) {
        return $path;
    }
    return '';
}

function normalize_template_vars($templateVars)
{
    $normalized = array();
    if (!is_array($templateVars)) {
        return $normalized;
    }

    foreach ($templateVars as $key => $value) {
        $key = trim((string)$key);
        if ($key === '') {
            continue;
        }
        if (strpos($key, '{{') !== 0 || substr($key, -2) !== '}}') {
            $key = '{{' . trim($key, '{} ') . '}}';
        }
        $normalized[$key] = is_scalar($value) ? (string)$value : json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    }

    return $normalized;
}

function encode_template_vars_json($templateVars)
{
    $normalized = normalize_template_vars($templateVars);
    if (empty($normalized)) {
        return '';
    }

    $json = json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    return $json === false ? '' : $json;
}

function decode_template_vars_json($json)
{
    $json = trim((string)$json);
    if ($json === '') {
        return array();
    }

    $data = json_decode($json, true);
    return is_array($data) ? normalize_template_vars($data) : array();
}

function homepage_template_variable_schema()
{
    return array(
        '{{hero_badge}}' => array('label' => '首屏标签', 'type' => 'text', 'default' => '橙色科技感首页模板'),
        '{{hero_title}}' => array('label' => '首屏主标题', 'type' => 'text', 'default' => ''),
        '{{hero_subtitle}}' => array('label' => '首屏副标题', 'type' => 'textarea', 'default' => ''),
        '{{hero_cta_primary_text}}' => array('label' => '首屏主按钮文案', 'type' => 'text', 'default' => '立即获取方案'),
        '{{hero_cta_primary_link}}' => array('label' => '首屏主按钮链接', 'type' => 'link', 'default' => '#contact'),
        '{{hero_cta_secondary_text}}' => array('label' => '首屏次按钮文案', 'type' => 'text', 'default' => '查看服务内容'),
        '{{hero_cta_secondary_link}}' => array('label' => '首屏次按钮链接', 'type' => 'link', 'default' => '#services'),
        '{{hero_keyword_text}}' => array('label' => '首屏关键词说明', 'type' => 'text', 'default' => ''),
        '{{hero_path_text}}' => array('label' => '首屏路径说明', 'type' => 'text', 'default' => '/'),
        '{{hero_stat_1_no}}' => array('label' => '首屏卡片1序号', 'type' => 'text', 'default' => '500+'),
        '{{hero_stat_1_title}}' => array('label' => '首屏卡片1标题', 'type' => 'text', 'default' => '覆盖全终端的响应式布局'),
        '{{hero_stat_1_desc}}' => array('label' => '首屏卡片1描述', 'type' => 'textarea', 'default' => ''),
        '{{hero_stat_2_no}}' => array('label' => '首屏卡片2序号', 'type' => 'text', 'default' => '99.9%'),
        '{{hero_stat_2_title}}' => array('label' => '首屏卡片2标题', 'type' => 'text', 'default' => '稳定运行保障业务不中断'),
        '{{hero_stat_2_desc}}' => array('label' => '首屏卡片2描述', 'type' => 'textarea', 'default' => ''),
        '{{hero_stat_3_no}}' => array('label' => '首屏卡片3序号', 'type' => 'text', 'default' => '24h'),
        '{{hero_stat_3_title}}' => array('label' => '首屏卡片3标题', 'type' => 'text', 'default' => '全天候技术支持快速响应'),
        '{{hero_stat_3_desc}}' => array('label' => '首屏卡片3描述', 'type' => 'textarea', 'default' => ''),
        '{{hero_stat_4_no}}' => array('label' => '首屏卡片4序号', 'type' => 'text', 'default' => '10年+'),
        '{{hero_stat_4_title}}' => array('label' => '首屏卡片4标题', 'type' => 'text', 'default' => '行业深耕经验沉淀'),
        '{{hero_stat_4_desc}}' => array('label' => '首屏卡片4描述', 'type' => 'textarea', 'default' => ''),
        '{{hero_stat_5_no}}' => array('label' => '首屏卡片5序号', 'type' => 'text', 'default' => ''),
        '{{hero_stat_5_title}}' => array('label' => '首屏卡片5标题', 'type' => 'text', 'default' => ''),
        '{{hero_stat_5_desc}}' => array('label' => '首屏卡片5描述', 'type' => 'textarea', 'default' => ''),
        '{{hero_stat_6_no}}' => array('label' => '首屏卡片6序号', 'type' => 'text', 'default' => ''),
        '{{hero_stat_6_title}}' => array('label' => '首屏卡片6标题', 'type' => 'text', 'default' => ''),
        '{{hero_stat_6_desc}}' => array('label' => '首屏卡片6描述', 'type' => 'textarea', 'default' => ''),
        '{{hero_stat_7_no}}' => array('label' => '首屏卡片7序号', 'type' => 'text', 'default' => ''),
        '{{hero_stat_7_title}}' => array('label' => '首屏卡片7标题', 'type' => 'text', 'default' => ''),
        '{{hero_stat_7_desc}}' => array('label' => '首屏卡片7描述', 'type' => 'textarea', 'default' => ''),
        '{{hero_stat_8_no}}' => array('label' => '首屏卡片8序号', 'type' => 'text', 'default' => ''),
        '{{hero_stat_8_title}}' => array('label' => '首屏卡片8标题', 'type' => 'text', 'default' => ''),
        '{{hero_stat_8_desc}}' => array('label' => '首屏卡片8描述', 'type' => 'textarea', 'default' => ''),
        '{{panel_badge}}' => array('label' => '右侧面板标签', 'type' => 'text', 'default' => '站点概览'),
        '{{panel_title}}' => array('label' => '右侧面板标题', 'type' => 'text', 'default' => ''),
        '{{panel_desc}}' => array('label' => '右侧面板描述', 'type' => 'textarea', 'default' => ''),
        '{{panel_item_1_title}}' => array('label' => '右侧面板项目1标题', 'type' => 'text', 'default' => 'H5'),
        '{{panel_item_1_desc}}' => array('label' => '右侧面板项目1描述', 'type' => 'textarea', 'default' => ''),
        '{{panel_item_2_title}}' => array('label' => '右侧面板项目2标题', 'type' => 'text', 'default' => 'SEO'),
        '{{panel_item_2_desc}}' => array('label' => '右侧面板项目2描述', 'type' => 'textarea', 'default' => ''),
        '{{trust_1_title}}' => array('label' => '信任条1标题', 'type' => 'text', 'default' => '毫秒级响应极速加载'),
        '{{trust_1_desc}}' => array('label' => '信任条1描述', 'type' => 'textarea', 'default' => ''),
        '{{trust_2_title}}' => array('label' => '信任条2标题', 'type' => 'text', 'default' => '专业设计提升品牌形象'),
        '{{trust_2_desc}}' => array('label' => '信任条2描述', 'type' => 'textarea', 'default' => ''),
        '{{trust_3_title}}' => array('label' => '信任条3标题', 'type' => 'text', 'default' => '多终端自适应完美呈现'),
        '{{trust_3_desc}}' => array('label' => '信任条3描述', 'type' => 'textarea', 'default' => ''),
        '{{services_badge}}' => array('label' => '服务区标签', 'type' => 'text', 'default' => '核心内容'),
        '{{services_title}}' => array('label' => '服务区标题', 'type' => 'text', 'default' => '聚焦服务展示与转化信息表达'),
        '{{services_intro}}' => array('label' => '服务区引导语', 'type' => 'textarea', 'default' => ''),
        '{{service_1_title}}' => array('label' => '服务模块1标题', 'type' => 'text', 'default' => ''),
        '{{service_1_desc}}' => array('label' => '服务模块1描述', 'type' => 'textarea', 'default' => ''),
        '{{service_2_title}}' => array('label' => '服务模块2标题', 'type' => 'text', 'default' => ''),
        '{{service_2_desc}}' => array('label' => '服务模块2描述', 'type' => 'textarea', 'default' => ''),
        '{{service_3_title}}' => array('label' => '服务模块3标题', 'type' => 'text', 'default' => ''),
        '{{service_3_desc}}' => array('label' => '服务模块3描述', 'type' => 'textarea', 'default' => ''),
        '{{service_4_title}}' => array('label' => '服务模块4标题', 'type' => 'text', 'default' => ''),
        '{{service_4_desc}}' => array('label' => '服务模块4描述', 'type' => 'textarea', 'default' => ''),
        '{{service_5_title}}' => array('label' => '服务模块5标题', 'type' => 'text', 'default' => ''),
        '{{service_5_desc}}' => array('label' => '服务模块5描述', 'type' => 'textarea', 'default' => ''),
        '{{service_6_title}}' => array('label' => '服务模块6标题', 'type' => 'text', 'default' => ''),
        '{{service_6_desc}}' => array('label' => '服务模块6描述', 'type' => 'textarea', 'default' => ''),
        '{{service_7_title}}' => array('label' => '服务模块7标题', 'type' => 'text', 'default' => ''),
        '{{service_7_desc}}' => array('label' => '服务模块7描述', 'type' => 'textarea', 'default' => ''),
        '{{service_8_title}}' => array('label' => '服务模块8标题', 'type' => 'text', 'default' => ''),
        '{{service_8_desc}}' => array('label' => '服务模块8描述', 'type' => 'textarea', 'default' => ''),
        '{{feature_section_badge}}' => array('label' => '亮点区标签', 'type' => 'text', 'default' => '核心亮点'),
        '{{feature_section_title}}' => array('label' => '亮点区标题', 'type' => 'text', 'default' => '简洁清晰，但保留足够的科技感与互动反馈'),
        '{{feature_1_no}}' => array('label' => '亮点卡片1序号', 'type' => 'text', 'default' => '3倍提速'),
        '{{feature_1_title}}' => array('label' => '亮点卡片1标题', 'type' => 'text', 'default' => ''),
        '{{feature_1_desc}}' => array('label' => '亮点卡片1描述', 'type' => 'textarea', 'default' => ''),
        '{{feature_2_no}}' => array('label' => '亮点卡片2序号', 'type' => 'text', 'default' => '100%适配'),
        '{{feature_2_title}}' => array('label' => '亮点卡片2标题', 'type' => 'text', 'default' => ''),
        '{{feature_2_desc}}' => array('label' => '亮点卡片2描述', 'type' => 'textarea', 'default' => ''),
        '{{feature_3_no}}' => array('label' => '亮点卡片3序号', 'type' => 'text', 'default' => '0成本迁移'),
        '{{feature_3_title}}' => array('label' => '亮点卡片3标题', 'type' => 'text', 'default' => ''),
        '{{feature_3_desc}}' => array('label' => '亮点卡片3描述', 'type' => 'textarea', 'default' => ''),
        '{{feature_4_no}}' => array('label' => '亮点卡片4序号', 'type' => 'text', 'default' => '全局覆盖'),
        '{{feature_4_title}}' => array('label' => '亮点卡片4标题', 'type' => 'text', 'default' => ''),
        '{{feature_4_desc}}' => array('label' => '亮点卡片4描述', 'type' => 'textarea', 'default' => ''),
        '{{feature_5_no}}' => array('label' => '亮点卡片5序号', 'type' => 'text', 'default' => '智能升级'),
        '{{feature_5_title}}' => array('label' => '亮点卡片5标题', 'type' => 'text', 'default' => ''),
        '{{feature_5_desc}}' => array('label' => '亮点卡片5描述', 'type' => 'textarea', 'default' => ''),
        '{{feature_6_no}}' => array('label' => '亮点卡片6序号', 'type' => 'text', 'default' => ''),
        '{{feature_6_title}}' => array('label' => '亮点卡片6标题', 'type' => 'text', 'default' => ''),
        '{{feature_6_desc}}' => array('label' => '亮点卡片6描述', 'type' => 'textarea', 'default' => ''),
        '{{feature_7_no}}' => array('label' => '亮点卡片7序号', 'type' => 'text', 'default' => ''),
        '{{feature_7_title}}' => array('label' => '亮点卡片7标题', 'type' => 'text', 'default' => ''),
        '{{feature_7_desc}}' => array('label' => '亮点卡片7描述', 'type' => 'textarea', 'default' => ''),
        '{{feature_8_no}}' => array('label' => '亮点卡片8序号', 'type' => 'text', 'default' => ''),
        '{{feature_8_title}}' => array('label' => '亮点卡片8标题', 'type' => 'text', 'default' => ''),
        '{{feature_8_desc}}' => array('label' => '亮点卡片8描述', 'type' => 'textarea', 'default' => ''),
        '{{testimonial_badge}}' => array('label' => '评价区标签', 'type' => 'text', 'default' => '客户评价'),
        '{{testimonial_title}}' => array('label' => '评价区标题', 'type' => 'text', 'default' => '真实客户反馈，帮助你快速建立信任'),
        '{{testimonial_intro}}' => array('label' => '评价区引导语', 'type' => 'textarea', 'default' => ''),
        '{{testimonial_1_quote}}' => array('label' => '评价卡1内容', 'type' => 'textarea', 'default' => ''),
        '{{testimonial_1_name}}' => array('label' => '评价卡1用户', 'type' => 'text', 'default' => ''),
        '{{testimonial_1_role}}' => array('label' => '评价卡1身份', 'type' => 'text', 'default' => ''),
        '{{testimonial_2_quote}}' => array('label' => '评价卡2内容', 'type' => 'textarea', 'default' => ''),
        '{{testimonial_2_name}}' => array('label' => '评价卡2用户', 'type' => 'text', 'default' => ''),
        '{{testimonial_2_role}}' => array('label' => '评价卡2身份', 'type' => 'text', 'default' => ''),
        '{{testimonial_3_quote}}' => array('label' => '评价卡3内容', 'type' => 'textarea', 'default' => ''),
        '{{testimonial_3_name}}' => array('label' => '评价卡3用户', 'type' => 'text', 'default' => ''),
        '{{testimonial_3_role}}' => array('label' => '评价卡3身份', 'type' => 'text', 'default' => ''),
        '{{testimonial_4_quote}}' => array('label' => '评价卡4内容', 'type' => 'textarea', 'default' => ''),
        '{{testimonial_4_name}}' => array('label' => '评价卡4用户', 'type' => 'text', 'default' => ''),
        '{{testimonial_4_role}}' => array('label' => '评价卡4身份', 'type' => 'text', 'default' => ''),
        '{{testimonial_5_quote}}' => array('label' => '评价卡5内容', 'type' => 'textarea', 'default' => ''),
        '{{testimonial_5_name}}' => array('label' => '评价卡5用户', 'type' => 'text', 'default' => ''),
        '{{testimonial_5_role}}' => array('label' => '评价卡5身份', 'type' => 'text', 'default' => ''),
        '{{testimonial_6_quote}}' => array('label' => '评价卡6内容', 'type' => 'textarea', 'default' => ''),
        '{{testimonial_6_name}}' => array('label' => '评价卡6用户', 'type' => 'text', 'default' => ''),
        '{{testimonial_6_role}}' => array('label' => '评价卡6身份', 'type' => 'text', 'default' => ''),
        '{{testimonial_7_quote}}' => array('label' => '评价卡7内容', 'type' => 'textarea', 'default' => ''),
        '{{testimonial_7_name}}' => array('label' => '评价卡7用户', 'type' => 'text', 'default' => ''),
        '{{testimonial_7_role}}' => array('label' => '评价卡7身份', 'type' => 'text', 'default' => ''),
        '{{testimonial_8_quote}}' => array('label' => '评价卡8内容', 'type' => 'textarea', 'default' => ''),
        '{{testimonial_8_name}}' => array('label' => '评价卡8用户', 'type' => 'text', 'default' => ''),
        '{{testimonial_8_role}}' => array('label' => '评价卡8身份', 'type' => 'text', 'default' => ''),
        '{{cta_badge}}' => array('label' => '转化区标签', 'type' => 'text', 'default' => '立即转化'),
        '{{cta_title}}' => array('label' => '转化区标题', 'type' => 'text', 'default' => ''),
        '{{cta_desc}}' => array('label' => '转化区描述', 'type' => 'textarea', 'default' => ''),
        '{{cta_primary_text}}' => array('label' => '转化主按钮文案', 'type' => 'text', 'default' => '免费获取报价'),
        '{{cta_primary_link}}' => array('label' => '转化主按钮链接', 'type' => 'link', 'default' => 'tel:400-123-4567'),
        '{{cta_secondary_text}}' => array('label' => '转化次按钮文案', 'type' => 'text', 'default' => '查看详情'),
        '{{cta_secondary_link}}' => array('label' => '转化次按钮链接', 'type' => 'link', 'default' => '#services'),
        '{{footer_text}}' => array('label' => '页脚文案', 'type' => 'text', 'default' => '')
    );
}

function template_variable_schema_for_type($templateType)
{
    if (is_homepage_template_type($templateType)) {
        return homepage_template_variable_schema();
    }
    return array();
}

function default_template_vars_for_type($templateType, $context)
{
    $defaults = array();
    $schema = template_variable_schema_for_type($templateType);
    foreach ($schema as $key => $meta) {
        $defaults[$key] = isset($meta['default']) ? (string)$meta['default'] : '';
    }

    if (is_homepage_template_type($templateType)) {
        $siteName = isset($context['site_name']) ? trim((string)$context['site_name']) : '';
        $keyword = isset($context['keyword']) ? trim((string)$context['keyword']) : '';
        $domain = isset($context['domain']) ? trim((string)$context['domain']) : '';
        $path = isset($context['path']) ? trim((string)$context['path']) : '/';
        $siteDescription = isset($context['site_description']) ? trim((string)$context['site_description']) : '';

        $defaults['{{hero_title}}'] = $siteName !== '' ? $siteName : ($keyword !== '' ? $keyword : '智能建站解决方案');
        $defaults['{{hero_subtitle}}'] = $siteDescription !== '' ? $siteDescription : ($keyword !== '' ? '围绕“' . $keyword . '”快速生成清晰、专业、可转化的首页内容。' : '围绕企业服务价值快速生成结构化首页内容。');
        $defaults['{{hero_keyword_text}}'] = $keyword !== '' ? $keyword : ($siteName !== '' ? $siteName : '企业官网建设');
        $defaults['{{hero_path_text}}'] = $path !== '' ? $path : '/';
        $defaults['{{hero_stat_1_desc}}'] = '适配手机端浏览与触屏操作，重点信息首屏即可快速触达。';
        $defaults['{{hero_stat_2_desc}}'] = '通过固定模板字段驱动页面生成，减少 AI 布局漂移。';
        $defaults['{{hero_stat_3_desc}}'] = '支持静态输出与多域名发布，降低访问等待与维护成本。';
        $defaults['{{hero_stat_4_desc}}'] = '深耕行业多年，积累丰富项目经验与最佳实践方案。';
        $defaults['{{hero_stat_5_no}}'] = '50+';
        $defaults['{{hero_stat_5_title}}'] = '合作伙伴覆盖多行业';
        $defaults['{{hero_stat_5_desc}}'] = '与多个行业头部企业达成深度合作，验证方案可靠性。';
        $defaults['{{hero_stat_6_no}}'] = '100万+';
        $defaults['{{hero_stat_6_title}}'] = '累计服务页面访问量';
        $defaults['{{hero_stat_6_desc}}'] = '已发布站点累计承载百万级页面浏览，性能稳定可靠。';
        $defaults['{{hero_stat_7_no}}'] = '3分钟';
        $defaults['{{hero_stat_7_title}}'] = '平均建站交付时间';
        $defaults['{{hero_stat_7_desc}}'] = '从输入关键词到页面上线，平均仅需数分钟即可完成。';
        $defaults['{{hero_stat_8_no}}'] = '∞';
        $defaults['{{hero_stat_8_title}}'] = '无限扩展可能';
        $defaults['{{hero_stat_8_desc}}'] = '灵活的模板与模块机制，满足未来任意业务扩展需求。';
        $defaults['{{panel_title}}'] = $siteName !== '' ? $siteName : '企业官网首页';
        $defaults['{{panel_desc}}'] = $siteDescription !== '' ? $siteDescription : '当前首页采用结构化字段驱动，适合品牌展示、SEO 首页承接与转化获客。';
        $defaults['{{panel_item_1_desc}}'] = '手机端优先，按钮与信息区块均适配触屏交互。';
        $defaults['{{panel_item_2_desc}}'] = '标题、关键词、描述与各模块字段均可稳定回填。';
        $defaults['{{trust_1_desc}}'] = '轻量结构减少加载负担，适合静态站点快速输出。';
        $defaults['{{trust_2_desc}}'] = '橙色主调搭配科技几何语言，视觉聚焦更明确。';
        $defaults['{{trust_3_desc}}'] = '桌面端与移动端均保持稳定的展示与阅读体验。';
        $defaults['{{services_intro}}'] = $siteDescription !== '' ? $siteDescription : '通过分层标题、服务卡片与清晰说明，让访客快速理解站点核心业务。';
        $defaults['{{service_1_title}}'] = '核心服务覆盖';
        $defaults['{{service_1_desc}}'] = '围绕主营业务输出清晰服务模块，便于用户快速识别服务范围。';
        $defaults['{{service_2_title}}'] = '目标客户匹配';
        $defaults['{{service_2_desc}}'] = '结合目标客户和业务场景组织文案，提升页面匹配度与咨询转化。';
        $defaults['{{service_3_title}}'] = '持续迭代发布';
        $defaults['{{service_3_desc}}'] = '修改字段后即可重新渲染与静态发布，方便矩阵站点持续维护。';
        $defaults['{{service_4_title}}'] = '全流程支持';
        $defaults['{{service_4_desc}}'] = '从方案规划到上线运营，提供完整的项目交付与售后保障服务。';
        $defaults['{{service_5_title}}'] = '数据安全保障';
        $defaults['{{service_5_desc}}'] = '全链路数据加密与权限隔离，确保业务数据安全合规。';
        $defaults['{{service_6_title}}'] = '多渠道接入';
        $defaults['{{service_6_desc}}'] = '支持 PC、移动端、小程序等多端内容统一管理与发布。';
        $defaults['{{service_7_title}}'] = '智能分析洞察';
        $defaults['{{service_7_desc}}'] = '集成流量统计与用户行为分析，数据驱动站点持续优化。';
        $defaults['{{service_8_title}}'] = '弹性扩容架构';
        $defaults['{{service_8_desc}}'] = '根据业务增长灵活调整资源配置，避免性能瓶颈与资源浪费。';
        $defaults['{{feature_1_title}}'] = '橙色品牌聚焦';
        $defaults['{{feature_1_desc}}'] = '高识别度主色与深色科技底板结合，强化首页主记忆点。';
        $defaults['{{feature_2_title}}'] = '交互动效轻量化';
        $defaults['{{feature_2_desc}}'] = '保留悬停与滚动反馈，同时避免复杂动画带来的性能开销。';
        $defaults['{{feature_3_title}}'] = '信息层次明确';
        $defaults['{{feature_3_desc}}'] = '头图、卖点、服务与转化区块分层清晰，适合 SEO 首页使用。';
        $defaults['{{feature_4_title}}'] = '全终端覆盖';
        $defaults['{{feature_4_desc}}'] = '桌面端、平板与手机端均可完美展示，确保每一位用户都获得一致体验。';
        $defaults['{{feature_5_title}}'] = '持续迭代优化';
        $defaults['{{feature_5_desc}}'] = '支持后期字段编辑与重新发布，方便内容持续更新与 SEO 优化。';
        $defaults['{{feature_6_no}}'] = '安全加固';
        $defaults['{{feature_6_title}}'] = '企业级安全防护';
        $defaults['{{feature_6_desc}}'] = '内置 XSS / CSRF 防护与安全响应头，保障站点运行安全。';
        $defaults['{{feature_7_no}}'] = '极简运维';
        $defaults['{{feature_7_title}}'] = '零依赖静态部署';
        $defaults['{{feature_7_desc}}'] = '纯静态输出无需数据库，CDN 直接分发，运维成本趋近于零。';
        $defaults['{{feature_8_no}}'] = '开放生态';
        $defaults['{{feature_8_title}}'] = '自定义模块扩展';
        $defaults['{{feature_8_desc}}'] = '通过可视化模块与模板变量机制，自由组合页面区块满足个性化需求。';
        $defaults['{{testimonial_badge}}'] = '客户评价';
        $defaults['{{testimonial_title}}'] = '来自真实合作场景的口碑反馈';
        $defaults['{{testimonial_intro}}'] = $siteDescription !== '' ? '围绕“' . ($keyword !== '' ? $keyword : $siteName) . '”的服务能力，以下反馈可用于强化首页信任转化。' : '以下评价模块可用于展示客户合作成果、服务体验与转化信心。';
        $defaults['{{testimonial_1_quote}}'] = '页面结构清晰，上线节奏快，首屏表达比我们之前的官网更聚焦，客户咨询明显更顺畅。';
        $defaults['{{testimonial_1_name}}'] = '张经理';
        $defaults['{{testimonial_1_role}}'] = '制造业企业 · 市场负责人';
        $defaults['{{testimonial_2_quote}}'] = '关键词、描述和模块文案能稳定回填，适合矩阵站批量生成，后期维护压力小很多。';
        $defaults['{{testimonial_2_name}}'] = '李总';
        $defaults['{{testimonial_2_role}}'] = '品牌服务商 · 运营总监';
        $defaults['{{testimonial_3_quote}}'] = '移动端浏览体验很顺，重点信息一屏就能看到，客户对页面专业度的反馈也更好。';
        $defaults['{{testimonial_3_name}}'] = '王女士';
        $defaults['{{testimonial_3_role}}'] = '本地生活服务 · 创始人';
        $defaults['{{testimonial_4_quote}}'] = '从 AI 生成到静态发布的链路很完整，适合需要快速验证多个业务方向的站点场景。';
        $defaults['{{testimonial_4_name}}'] = '陈先生';
        $defaults['{{testimonial_4_role}}'] = '技术服务公司 · 项目负责人';
        $defaults['{{testimonial_5_quote}}'] = '模板风格多样化，可以快速匹配不同行业客户的审美需求，极大提升了交付效率。';
        $defaults['{{testimonial_5_name}}'] = '赵主管';
        $defaults['{{testimonial_5_role}}'] = '广告代理公司 · 设计主管';
        $defaults['{{testimonial_6_quote}}'] = '静态发布速度极快，SEO 收录效果比之前的动态站好了不少，关键词排名明显提升。';
        $defaults['{{testimonial_6_name}}'] = '刘工';
        $defaults['{{testimonial_6_role}}'] = 'SEO 服务团队 · 技术负责人';
        $defaults['{{testimonial_7_quote}}'] = '后台操作简单直观，非技术人员也能独立完成站点的内容更新和发布。';
        $defaults['{{testimonial_7_name}}'] = '周女士';
        $defaults['{{testimonial_7_role}}'] = '教育培训机构 · 行政主管';
        $defaults['{{testimonial_8_quote}}'] = '多站点统一管理非常方便，授权体系也很完善，适合我们这种有多品牌的集团客户。';
        $defaults['{{testimonial_8_name}}'] = '吴总';
        $defaults['{{testimonial_8_role}}'] = '集团企业 · 信息化负责人';
        $defaults['{{cta_title}}'] = '让 ' . ($siteName !== '' ? $siteName : '你的站点') . ' 以更快速度上线，并持续稳定迭代';
        $defaults['{{cta_desc}}'] = '当前域名：' . ($domain !== '' ? $domain : '--') . '，你可以继续编辑字段与 SEO 信息后重新发布首页。';
        $defaults['{{footer_text}}'] = '专注' . ($keyword !== '' ? $keyword : '企业服务') . '领域 | 苏ICP备XXXXXXXX号';
    }

    return normalize_template_vars($defaults);
}

function sanitize_template_var_value_by_type($type, $value)
{
    $value = is_scalar($value) ? trim((string)$value) : '';
    if ($value === '') {
        return '';
    }

    if ($type === 'link') {
        if (preg_match('/^(https?:\/\/|\/|#|tel:)/i', $value)) {
            return $value;
        }
        return '#';
    }

    $value = preg_replace('/\s+/u', ' ', $value);
    $value = trim(strip_tags($value));

    // 检测 AI 模型泄漏的提示词内容（轻量模型常见问题）
    if (is_prompt_leaked_text($value)) {
        return '';
    }

    return $value;
}

function is_prompt_leaked_text($text)
{
    $text = (string)$text;
    if (mb_strlen($text, 'UTF-8') < 10) {
        return false;
    }

    // 强特征：命中一条即判定为泄漏
    $strongPatterns = array(
        '/template_vars/i',
        '/(hero_stat|trust_\d|service_\d|feature_\d|panel_item)/i',
        '/seo_(title|keywords|description)/i',
        '/双大括号/u',
        '/JSON\s*(结构|格式|数组|对象)/iu',
    );
    foreach ($strongPatterns as $pattern) {
        if (preg_match($pattern, $text)) {
            return true;
        }
    }

    // 弱特征：命中 2 条判定为泄漏
    $weakPatterns = array(
        '/构建.{0,6}(首页|标题|描述|模块|页面)/u',
        '/(首页|页面).{0,4}(标题|描述|模块)/u',
        '/核心模块/u',
        '/(模板|字段).{0,4}(变量|编码|字段|渲染|结构)/u',
        '/(请|必须|需要).{0,6}(返回|输出|生成)/u',
        '/(逐行|逐条|逐个).{0,4}输出/u',
        '/不得输出/u',
        '/不要.{0,6}(编号|标记|解释|前缀|多余)/u',
        '/关键词.{0,20}(构建|生成|输出)/u',
        '/(填写|补充).{0,6}(字段|内容|变量)/u',
        '/可直接.{0,4}(渲染|使用|填充)/u',
    );

    $hits = 0;
    foreach ($weakPatterns as $pattern) {
        if (preg_match($pattern, $text)) {
            $hits++;
        }
        if ($hits >= 2) {
            return true;
        }
    }
    return false;
}

function sanitize_template_vars_by_schema($templateVars, $templateType, $context)
{
    $defaults = default_template_vars_for_type($templateType, $context);
    $normalized = normalize_template_vars($templateVars);
    $schema = template_variable_schema_for_type($templateType);

    if (empty($schema)) {
        return $normalized;
    }

    $sanitized = $defaults;
    foreach ($schema as $key => $meta) {
        if (!array_key_exists($key, $normalized)) {
            continue;
        }
        $value = sanitize_template_var_value_by_type(isset($meta['type']) ? (string)$meta['type'] : 'text', $normalized[$key]);
        if ($value !== '') {
            $sanitized[$key] = $value;
        }
    }

    return normalize_template_vars($sanitized);
}

function first_non_empty_text()
{
    $args = func_get_args();
    foreach ($args as $value) {
        $value = is_scalar($value) ? trim((string)$value) : '';
        if ($value !== '') {
            return $value;
        }
    }
    return '';
}

function clean_plain_text($value)
{
    if (is_array($value)) {
        $value = implode(' ', array_map(function ($item) {
            return is_scalar($item) ? (string)$item : '';
        }, $value));
    }

    $value = is_scalar($value) ? (string)$value : '';
    $value = html_entity_decode($value, ENT_QUOTES, 'UTF-8');
    $value = strip_tags($value);
    $value = preg_replace('/[\r\n\t]+/u', ' ', $value);
    $value = preg_replace('/\s+/u', ' ', $value);
    return trim((string)$value);
}

function text_length_utf8($value)
{
    $value = (string)$value;
    if (function_exists('mb_strlen')) {
        return mb_strlen($value, 'UTF-8');
    }
    return strlen($value);
}

function text_substr_utf8($value, $start, $length)
{
    $value = (string)$value;
    if (function_exists('mb_substr')) {
        return mb_substr($value, (int)$start, (int)$length, 'UTF-8');
    }
    return substr($value, (int)$start, (int)$length);
}

function normalize_ai_keywords($value, $fallbackKeyword)
{
    $items = array();

    if (is_array($value)) {
        foreach ($value as $item) {
            $item = clean_plain_text($item);
            if ($item !== '') {
                $items[] = $item;
            }
        }
    } else {
        $value = clean_plain_text($value);
        if ($value !== '') {
            $parts = preg_split('/[，,、|\/；;]+/u', $value);
            if (is_array($parts)) {
                foreach ($parts as $item) {
                    $item = clean_plain_text($item);
                    if ($item !== '') {
                        $items[] = $item;
                    }
                }
            }
        }
    }

    $fallbackParts = preg_split('/[，,、|\/；;\s]+/u', clean_plain_text($fallbackKeyword));
    if (is_array($fallbackParts)) {
        foreach ($fallbackParts as $item) {
            $item = clean_plain_text($item);
            if ($item !== '') {
                $items[] = $item;
            }
        }
    }

    $normalized = array();
    foreach ($items as $item) {
        if (!in_array($item, $normalized, true)) {
            $normalized[] = $item;
        }
    }

    if (empty($normalized) && $fallbackKeyword !== '') {
        $normalized[] = clean_plain_text($fallbackKeyword);
    }

    if (count($normalized) === 1) {
        $base = $normalized[0];
        $normalized[] = $base . '服务';
        $normalized[] = $base . '方案';
    } elseif (count($normalized) === 2) {
        $normalized[] = $normalized[0] . '优化';
    }

    $normalized = array_values(array_filter($normalized, function ($item) {
        return $item !== '';
    }));

    if (count($normalized) > 5) {
        $normalized = array_slice($normalized, 0, 5);
    }

    return implode(',', $normalized);
}

function normalize_ai_description($primary, $secondaryParts, $minimumLength)
{
    $maxLength = 160;
    $primary = clean_plain_text($primary);

    if ($primary !== '') {
        return $primary;
    }

    if (is_array($secondaryParts)) {
        foreach ($secondaryParts as $part) {
            $part = clean_plain_text($part);
            if ($part !== '') {
                return mb_substr($part, 0, $maxLength, 'UTF-8');
            }
        }
    }

    return '';
}

function normalize_ai_testimonials($value, $siteName, $keyword)
{
    $items = array();

    if (is_array($value)) {
        $items = $value;
    }

    $normalized = array();
    foreach ($items as $item) {
        if (!is_array($item)) {
            continue;
        }

        $quote = first_non_empty_text(
            isset($item['quote']) ? $item['quote'] : '',
            isset($item['content']) ? $item['content'] : '',
            isset($item['comment']) ? $item['comment'] : ''
        );
        $name = first_non_empty_text(
            isset($item['name']) ? $item['name'] : '',
            isset($item['user']) ? $item['user'] : '',
            isset($item['customer']) ? $item['customer'] : ''
        );
        $role = first_non_empty_text(
            isset($item['role']) ? $item['role'] : '',
            isset($item['title']) ? $item['title'] : '',
            isset($item['company']) ? $item['company'] : ''
        );

        $quote = clean_plain_text($quote);
        $name = clean_plain_text($name);
        $role = clean_plain_text($role);

        if ($quote === '') {
            continue;
        }

        $normalized[] = array(
            'quote' => $quote,
            'name' => $name,
            'role' => $role
        );

        if (count($normalized) >= 4) {
            break;
        }
    }

    $baseName = first_non_empty_text($siteName, $keyword, '客户');
    $fallbacks = array(
        array(
            'quote' => '合作流程清晰，页面结构稳定，能快速把核心服务和咨询入口展示出来。',
            'name' => '张经理',
            'role' => $baseName . ' · 市场负责人'
        ),
        array(
            'quote' => '关键词与页面模块的匹配度很高，适合 SEO 承接和长期内容迭代。',
            'name' => '李总',
            'role' => $baseName . ' · 运营负责人'
        ),
        array(
            'quote' => '移动端浏览体验流畅，首屏表达更专业，咨询转化效率比以前更好。',
            'name' => '王女士',
            'role' => $baseName . ' · 品牌负责人'
        ),
        array(
            'quote' => '从生成到发布链路完整，适合需要快速搭建首页并反复优化的业务场景。',
            'name' => '陈先生',
            'role' => $baseName . ' · 项目负责人'
        )
    );

    foreach ($fallbacks as $fallback) {
        if (count($normalized) >= 4) {
            break;
        }
        $normalized[] = $fallback;
    }

    return $normalized;
}

function enrich_homepage_template_vars($templateVars, $generated, $context)
{
    $templateVars = normalize_template_vars($templateVars);
    $title = isset($generated['title']) ? trim((string)$generated['title']) : '';
    $seoTitle = isset($generated['seo_title']) ? trim((string)$generated['seo_title']) : '';
    $seoDescription = first_non_empty_text(
        isset($generated['seo_description']) ? $generated['seo_description'] : '',
        isset($generated['description']) ? $generated['description'] : ''
    );
    $siteName = first_non_empty_text(
        isset($generated['site_name']) ? $generated['site_name'] : '',
        isset($context['site_name']) ? $context['site_name'] : ''
    );
    $keyword = first_non_empty_text(
        isset($generated['seo_keywords']) ? $generated['seo_keywords'] : '',
        isset($generated['keywords']) ? $generated['keywords'] : '',
        isset($context['keyword']) ? $context['keyword'] : ''
    );
    $siteDescription = isset($context['site_description']) ? trim((string)$context['site_description']) : '';
    $domain = isset($context['domain']) ? trim((string)$context['domain']) : '';
    $path = isset($context['path']) ? trim((string)$context['path']) : '/';
    $summaryText = first_non_empty_text($siteDescription, $seoDescription, $title, $keyword);
    $testimonials = normalize_ai_testimonials(isset($generated['testimonials']) ? $generated['testimonials'] : array(), $siteName, $keyword);

    $fallbackMap = array(
        '{{hero_badge}}' => first_non_empty_text($keyword, $siteName, 'AI建站首页'),
        '{{hero_title}}' => first_non_empty_text($title, $seoTitle, $siteName, $keyword),
        '{{hero_subtitle}}' => first_non_empty_text($seoDescription, $siteDescription, $title),
        '{{hero_keyword_text}}' => first_non_empty_text($keyword, $siteName),
        '{{hero_path_text}}' => $path !== '' ? $path : '/',
        '{{hero_stat_1_title}}' => first_non_empty_text($keyword !== '' ? $keyword . '方案' : '', '核心解决方案'),
        '{{hero_stat_1_desc}}' => first_non_empty_text($siteDescription, $seoDescription),
        '{{hero_stat_2_title}}' => '高效交付',
        '{{hero_stat_2_desc}}' => first_non_empty_text($summaryText, '围绕目标关键词快速生成首页内容并完成渲染。'),
        '{{hero_stat_3_title}}' => '稳定发布',
        '{{hero_stat_3_desc}}' => first_non_empty_text($domain !== '' ? '适配域名 ' . $domain . ' 的静态化发布与访问承接。' : '', '支持持续编辑与重新发布。'),
        '{{hero_stat_4_title}}' => '经验沉淀',
        '{{hero_stat_4_desc}}' => '深耕行业多年，积累丰富项目经验与最佳实践方案。',
        '{{hero_stat_5_no}}' => '50+',
        '{{hero_stat_5_title}}' => '合作伙伴',
        '{{hero_stat_5_desc}}' => '覆盖多个行业的深度合作伙伴网络。',
        '{{hero_stat_6_no}}' => '100万+',
        '{{hero_stat_6_title}}' => '页面访问量',
        '{{hero_stat_6_desc}}' => '已发布站点累计承载百万级页面浏览量。',
        '{{hero_stat_7_no}}' => '3分钟',
        '{{hero_stat_7_title}}' => '快速交付',
        '{{hero_stat_7_desc}}' => '从关键词输入到页面上线平均仅需数分钟。',
        '{{hero_stat_8_no}}' => '∞',
        '{{hero_stat_8_title}}' => '无限扩展',
        '{{hero_stat_8_desc}}' => '灵活模板机制满足未来任意业务扩展需求。',
        '{{panel_badge}}' => first_non_empty_text($keyword, '站点概览'),
        '{{panel_title}}' => first_non_empty_text($siteName, $title, '首页概览'),
        '{{panel_desc}}' => first_non_empty_text($siteDescription, $seoDescription),
        '{{panel_item_1_title}}' => '核心业务',
        '{{panel_item_1_desc}}' => first_non_empty_text($siteDescription, $seoDescription),
        '{{panel_item_2_title}}' => '目标转化',
        '{{panel_item_2_desc}}' => first_non_empty_text($seoTitle, $title, '提升咨询转化与搜索承接效果。'),
        '{{trust_1_title}}' => '服务聚焦',
        '{{trust_1_desc}}' => first_non_empty_text($siteDescription, $seoDescription),
        '{{trust_2_title}}' => '关键词承接',
        '{{trust_2_desc}}' => first_non_empty_text($keyword !== '' ? '围绕“' . $keyword . '”组织页面标题与模块信息。' : '', $seoTitle),
        '{{trust_3_title}}' => '可持续优化',
        '{{trust_3_desc}}' => '支持后续编辑模板字段、SEO 信息与静态发布。',
        '{{services_badge}}' => '服务内容',
        '{{services_title}}' => first_non_empty_text($title, $siteName, '服务与卖点展示'),
        '{{services_intro}}' => first_non_empty_text($siteDescription, $seoDescription),
        '{{service_1_title}}' => '核心服务',
        '{{service_1_desc}}' => first_non_empty_text($siteDescription, $seoDescription),
        '{{service_2_title}}' => '服务优势',
        '{{service_2_desc}}' => first_non_empty_text($seoDescription, $title),
        '{{service_3_title}}' => '合作支持',
        '{{service_3_desc}}' => first_non_empty_text($domain !== '' ? '当前内容将围绕域名 ' . $domain . ' 进行首页展示与静态发布。' : '', '支持长期更新与内容迭代。'),
        '{{service_4_title}}' => '全流程保障',
        '{{service_4_desc}}' => '从方案规划到上线运营，提供完整的项目交付与售后支持。',
        '{{service_5_title}}' => '数据安全',
        '{{service_5_desc}}' => '全链路数据加密与权限隔离，确保业务数据安全合规。',
        '{{service_6_title}}' => '多渠道接入',
        '{{service_6_desc}}' => '支持 PC、移动端等多端内容统一管理与发布。',
        '{{service_7_title}}' => '智能分析',
        '{{service_7_desc}}' => '集成流量统计与用户行为分析，数据驱动持续优化。',
        '{{service_8_title}}' => '弹性扩容',
        '{{service_8_desc}}' => '根据业务增长灵活调整资源配置，避免性能瓶颈。',
        '{{feature_section_badge}}' => '核心亮点',
        '{{feature_section_title}}' => first_non_empty_text($seoTitle, $title, '为什么选择我们'),
        '{{feature_1_title}}' => '内容聚焦',
        '{{feature_1_desc}}' => first_non_empty_text($siteDescription, $seoDescription),
        '{{feature_2_title}}' => '搜索友好',
        '{{feature_2_desc}}' => first_non_empty_text($keyword !== '' ? '围绕关键词“' . $keyword . '”构建首页标题、描述与核心模块。' : '', $seoTitle),
        '{{feature_3_title}}' => '上线高效',
        '{{feature_3_desc}}' => '生成后可直接预览、编辑并执行静态发布。',
        '{{feature_4_title}}' => '全终端覆盖',
        '{{feature_4_desc}}' => '桌面端、平板与手机端均可完美展示，确保一致的用户体验。',
        '{{feature_5_title}}' => '持续迭代',
        '{{feature_5_desc}}' => '支持后期编辑与重新发布，方便内容持续更新与 SEO 优化。',
        '{{feature_6_no}}' => '安全加固',
        '{{feature_6_title}}' => '安全防护',
        '{{feature_6_desc}}' => '内置 XSS / CSRF 防护与安全响应头，保障站点安全。',
        '{{feature_7_no}}' => '极简运维',
        '{{feature_7_title}}' => '静态部署',
        '{{feature_7_desc}}' => '纯静态输出无需数据库，CDN 直接分发，运维成本极低。',
        '{{feature_8_no}}' => '开放生态',
        '{{feature_8_title}}' => '模块扩展',
        '{{feature_8_desc}}' => '通过可视化模块与模板变量机制，自由组合页面区块。',
        '{{testimonial_badge}}' => '客户评价',
        '{{testimonial_title}}' => first_non_empty_text($siteName !== '' ? $siteName . ' 的合作反馈' : '', $title !== '' ? $title . ' 的客户评价' : '', '真实用户评价'),
        '{{testimonial_intro}}' => first_non_empty_text($seoDescription, $siteDescription, '通过真实评价模块增强页面信任感与咨询转化率。'),
        '{{testimonial_1_quote}}' => isset($testimonials[0]['quote']) ? $testimonials[0]['quote'] : '',
        '{{testimonial_1_name}}' => isset($testimonials[0]['name']) ? $testimonials[0]['name'] : '',
        '{{testimonial_1_role}}' => isset($testimonials[0]['role']) ? $testimonials[0]['role'] : '',
        '{{testimonial_2_quote}}' => isset($testimonials[1]['quote']) ? $testimonials[1]['quote'] : '',
        '{{testimonial_2_name}}' => isset($testimonials[1]['name']) ? $testimonials[1]['name'] : '',
        '{{testimonial_2_role}}' => isset($testimonials[1]['role']) ? $testimonials[1]['role'] : '',
        '{{testimonial_3_quote}}' => isset($testimonials[2]['quote']) ? $testimonials[2]['quote'] : '',
        '{{testimonial_3_name}}' => isset($testimonials[2]['name']) ? $testimonials[2]['name'] : '',
        '{{testimonial_3_role}}' => isset($testimonials[2]['role']) ? $testimonials[2]['role'] : '',
        '{{testimonial_4_quote}}' => isset($testimonials[3]['quote']) ? $testimonials[3]['quote'] : '',
        '{{testimonial_4_name}}' => isset($testimonials[3]['name']) ? $testimonials[3]['name'] : '',
        '{{testimonial_4_role}}' => isset($testimonials[3]['role']) ? $testimonials[3]['role'] : '',
        '{{testimonial_5_quote}}' => isset($testimonials[4]['quote']) ? $testimonials[4]['quote'] : '',
        '{{testimonial_5_name}}' => isset($testimonials[4]['name']) ? $testimonials[4]['name'] : '',
        '{{testimonial_5_role}}' => isset($testimonials[4]['role']) ? $testimonials[4]['role'] : '',
        '{{testimonial_6_quote}}' => isset($testimonials[5]['quote']) ? $testimonials[5]['quote'] : '',
        '{{testimonial_6_name}}' => isset($testimonials[5]['name']) ? $testimonials[5]['name'] : '',
        '{{testimonial_6_role}}' => isset($testimonials[5]['role']) ? $testimonials[5]['role'] : '',
        '{{testimonial_7_quote}}' => isset($testimonials[6]['quote']) ? $testimonials[6]['quote'] : '',
        '{{testimonial_7_name}}' => isset($testimonials[6]['name']) ? $testimonials[6]['name'] : '',
        '{{testimonial_7_role}}' => isset($testimonials[6]['role']) ? $testimonials[6]['role'] : '',
        '{{testimonial_8_quote}}' => isset($testimonials[7]['quote']) ? $testimonials[7]['quote'] : '',
        '{{testimonial_8_name}}' => isset($testimonials[7]['name']) ? $testimonials[7]['name'] : '',
        '{{testimonial_8_role}}' => isset($testimonials[7]['role']) ? $testimonials[7]['role'] : '',
        '{{cta_badge}}' => '立即咨询',
        '{{cta_title}}' => first_non_empty_text($title, $siteName, '立即获取专属方案'),
        '{{cta_desc}}' => first_non_empty_text($seoDescription, $siteDescription),
        '{{footer_text}}' => '专注' . first_non_empty_text($keyword, $title, '企业服务') . '领域 | 苏ICP备XXXXXXXX号'
    );

    foreach ($fallbackMap as $key => $value) {
        if (!isset($templateVars[$key]) || trim((string)$templateVars[$key]) === '') {
            $templateVars[$key] = $value;
        }
    }

    return normalize_template_vars($templateVars);
}

function is_homepage_content_path($path)
{
    $path = normalize_content_path($path);
    return $path === '' || $path === 'index' || $path === 'home';
}

function static_site_output_root()
{
    return dirname(__DIR__) . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'static-sites';
}

function ensure_directory_exists($directory)
{
    if (is_dir($directory)) {
        return true;
    }
    return mkdir($directory, 0777, true);
}

function remove_directory_recursively($directory)
{
    if (!is_dir($directory)) {
        return true;
    }

    $items = scandir($directory);
    if (!is_array($items)) {
        return false;
    }

    foreach ($items as $item) {
        if ($item === '.' || $item === '..') {
            continue;
        }
        $fullPath = $directory . DIRECTORY_SEPARATOR . $item;
        if (is_dir($fullPath)) {
            remove_directory_recursively($fullPath);
            continue;
        }
        if (file_exists($fullPath)) {
            unlink($fullPath);
        }
    }

    return rmdir($directory);
}

function create_publish_logs_table_if_not_exists()
{
    $pdo = db_connection();
    $sql = "CREATE TABLE IF NOT EXISTS `publish_logs` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `domain_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `content_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `publish_scope` VARCHAR(20) NOT NULL DEFAULT 'content',
        `publish_action` VARCHAR(50) NOT NULL DEFAULT '',
        `status` VARCHAR(20) NOT NULL DEFAULT 'success',
        `message` VARCHAR(500) NOT NULL DEFAULT '',
        `meta_json` LONGTEXT NULL,
        `created_at` DATETIME NOT NULL,
        PRIMARY KEY (`id`),
        KEY `idx_publish_logs_domain_id` (`domain_id`),
        KEY `idx_publish_logs_content_id` (`content_id`),
        KEY `idx_publish_logs_scope` (`publish_scope`),
        KEY `idx_publish_logs_status` (`status`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    $pdo->exec($sql);
}

function ensure_publish_log_storage_ready()
{
    static $checked = false;
    if ($checked) return;
    $checked = true;
    if (!table_exists('publish_logs')) {
        create_publish_logs_table_if_not_exists();
    }
}

function create_site_build_tasks_table_if_not_exists()
{
    $pdo = db_connection();
    $sql = "CREATE TABLE IF NOT EXISTS `site_build_tasks` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `task_token` VARCHAR(64) NOT NULL,
        `task_type` VARCHAR(20) NOT NULL DEFAULT 'homepage',
        `domain_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `template_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `model_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `keyword` VARCHAR(255) NOT NULL DEFAULT '',
        `site_name` VARCHAR(255) NOT NULL DEFAULT '',
        `site_description` TEXT NULL,
        `payload_json` LONGTEXT NULL,
        `remark` VARCHAR(255) NOT NULL DEFAULT '',
        `publish_now` TINYINT(1) NOT NULL DEFAULT 0,
        `status` VARCHAR(20) NOT NULL DEFAULT 'pending',
        `current_step` TINYINT UNSIGNED NOT NULL DEFAULT 1,
        `progress_percent` TINYINT UNSIGNED NOT NULL DEFAULT 0,
        `progress_message` VARCHAR(255) NOT NULL DEFAULT '',
        `error_message` VARCHAR(500) NOT NULL DEFAULT '',
        `result_json` LONGTEXT NULL,
        `created_at` DATETIME NOT NULL,
        `started_at` DATETIME NULL,
        `finished_at` DATETIME NULL,
        `updated_at` DATETIME NOT NULL,
        PRIMARY KEY (`id`),
        UNIQUE KEY `uk_site_build_task_token` (`task_token`),
        KEY `idx_site_build_status` (`status`),
        KEY `idx_site_build_created_at` (`created_at`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    $pdo->exec($sql);
}

function ensure_site_build_task_storage_ready()
{
    static $checked = false;
    if ($checked) return;
    $checked = true;
    if (!table_exists('site_build_tasks')) {
        create_site_build_tasks_table_if_not_exists();
        return;
    }

    $pdo = db_connection();
    if (!column_exists('site_build_tasks', 'task_type')) {
        $pdo->exec("ALTER TABLE `site_build_tasks` ADD COLUMN `task_type` VARCHAR(20) NOT NULL DEFAULT 'homepage' AFTER `task_token`");
    }
    if (!column_exists('site_build_tasks', 'payload_json')) {
        $pdo->exec("ALTER TABLE `site_build_tasks` ADD COLUMN `payload_json` LONGTEXT NULL AFTER `site_description`");
    }
}

function article_generation_task_status_options()
{
    return array(
        'draft' => '草稿',
        'enabled' => '启用',
        'paused' => '暂停',
        'running' => '执行中',
        'completed' => '已完成',
        'error' => '异常'
    );
}

function article_generation_task_editable_status_options()
{
    return array(
        'enabled' => '启用',
        'draft' => '草稿'
    );
}

function article_generation_keyword_status_options()
{
    return array(
        'pending' => '待处理',
        'processing' => '处理中',
        'success' => '已生成',
        'failed' => '失败'
    );
}

function create_article_generation_tasks_table_if_not_exists()
{
    $pdo = db_connection();
    $sql = "CREATE TABLE IF NOT EXISTS `article_generation_tasks` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `task_name` VARCHAR(255) NOT NULL,
        `user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `template_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `model_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `custom_prompt` TEXT NULL,
        `article_length` VARCHAR(100) NOT NULL DEFAULT '1200-1800字',
        `path_prefix` VARCHAR(100) NOT NULL DEFAULT 'article',
        `interval_seconds` INT UNSIGNED NOT NULL DEFAULT 0,
        `generate_image` TINYINT(1) NOT NULL DEFAULT 1,
        `publish_now` TINYINT(1) NOT NULL DEFAULT 0,
        `target_menu_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `status` VARCHAR(20) NOT NULL DEFAULT 'draft',
        `last_run_at` DATETIME NULL,
        `next_run_at` DATETIME NULL,
        `last_success_at` DATETIME NULL,
        `last_generated_at` DATETIME NULL,
        `last_error` VARCHAR(500) NOT NULL DEFAULT '',
        `success_count` INT UNSIGNED NOT NULL DEFAULT 0,
        `failed_count` INT UNSIGNED NOT NULL DEFAULT 0,
        `generated_count` INT UNSIGNED NOT NULL DEFAULT 0,
        `remark` VARCHAR(255) NOT NULL DEFAULT '',
        `created_at` DATETIME NOT NULL,
        `updated_at` DATETIME NOT NULL,
        PRIMARY KEY (`id`),
        KEY `idx_article_task_status` (`status`),
        KEY `idx_article_task_next_run` (`next_run_at`),
        KEY `idx_article_task_updated_at` (`updated_at`),
        KEY `idx_article_task_user_id` (`user_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    $pdo->exec($sql);
}

function create_article_generation_task_domains_table_if_not_exists()
{
    $pdo = db_connection();
    $sql = "CREATE TABLE IF NOT EXISTS `article_generation_task_domains` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `task_id` BIGINT UNSIGNED NOT NULL,
        `domain_id` BIGINT UNSIGNED NOT NULL,
        `created_at` DATETIME NOT NULL,
        PRIMARY KEY (`id`),
        UNIQUE KEY `uk_article_task_domain` (`task_id`, `domain_id`),
        KEY `idx_article_task_domain_task` (`task_id`),
        KEY `idx_article_task_domain_domain` (`domain_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    $pdo->exec($sql);
}

function create_article_generation_task_keywords_table_if_not_exists()
{
    $pdo = db_connection();
    $sql = "CREATE TABLE IF NOT EXISTS `article_generation_task_keywords` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `task_id` BIGINT UNSIGNED NOT NULL,
        `keyword` VARCHAR(255) NOT NULL,
        `status` VARCHAR(20) NOT NULL DEFAULT 'pending',
        `attempt_count` INT UNSIGNED NOT NULL DEFAULT 0,
        `last_error` VARCHAR(500) NOT NULL DEFAULT '',
        `content_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `processed_at` DATETIME NULL,
        `created_at` DATETIME NOT NULL,
        `updated_at` DATETIME NOT NULL,
        PRIMARY KEY (`id`),
        KEY `idx_article_task_keyword_task` (`task_id`),
        KEY `idx_article_task_keyword_status` (`status`),
        KEY `idx_article_task_keyword_processed` (`processed_at`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    $pdo->exec($sql);
}

function ensure_article_generation_task_storage_ready()
{
    static $checked = false;
    if ($checked) return;
    $checked = true;
    if (!table_exists('article_generation_tasks')) {
        create_article_generation_tasks_table_if_not_exists();
    }
    if (!column_exists('article_generation_tasks', 'target_menu_id')) {
        $pdo = db_connection();
        $pdo->exec("ALTER TABLE `article_generation_tasks` ADD COLUMN `target_menu_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER `publish_now`");
    }
    if (!column_exists('article_generation_tasks', 'target_menu_ids')) {
        $pdo = db_connection();
        $pdo->exec("ALTER TABLE `article_generation_tasks` ADD COLUMN `target_menu_ids` TEXT NULL AFTER `target_menu_id`");
        $pdo->exec("UPDATE `article_generation_tasks` SET `target_menu_ids` = CASE WHEN `target_menu_id` > 0 THEN CAST(`target_menu_id` AS CHAR) ELSE '' END WHERE `target_menu_ids` IS NULL");
    }
    if (!column_exists('article_generation_tasks', 'user_id')) {
        $pdo = db_connection();
        $pdo->exec("ALTER TABLE `article_generation_tasks` ADD COLUMN `user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER `task_name`, ADD KEY `idx_article_task_user_id` (`user_id`)");
        $first = $pdo->query("SELECT id FROM admins ORDER BY id ASC LIMIT 1")->fetch(PDO::FETCH_ASSOC);
        if ($first) {
            $pdo->prepare("UPDATE `article_generation_tasks` SET `user_id` = :uid WHERE `user_id` = 0")->execute(array(':uid' => (int)$first['id']));
        }
    }
    if (!column_exists('article_generation_tasks', 'insert_images')) {
        $pdo = db_connection();
        $pdo->exec("ALTER TABLE `article_generation_tasks`
            ADD COLUMN `insert_images` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0 AFTER `publish_now`,
            ADD COLUMN `image_folder` VARCHAR(500) NOT NULL DEFAULT '' AFTER `insert_images`,
            ADD COLUMN `image_count` TINYINT UNSIGNED NOT NULL DEFAULT 1 AFTER `image_folder`");
    }
    if (!column_exists('article_generation_tasks', 'reuse_keywords')) {
        $pdo = db_connection();
        $pdo->exec("ALTER TABLE `article_generation_tasks` ADD COLUMN `reuse_keywords` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0 AFTER `remark`");
    }
    if (!column_exists('article_generation_tasks', 'use_folder_cover')) {
        $pdo = db_connection();
        $pdo->exec("ALTER TABLE `article_generation_tasks` ADD COLUMN `use_folder_cover` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0 AFTER `image_count`");
    }
    if (!column_exists('article_generation_tasks', 'cover_watermark')) {
        $pdo = db_connection();
        $pdo->exec("ALTER TABLE `article_generation_tasks` ADD COLUMN `cover_watermark` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1 AFTER `use_folder_cover`");
    }
    if (!column_exists('article_generation_tasks', 'cover_watermark_text')) {
        $pdo = db_connection();
        $pdo->exec("ALTER TABLE `article_generation_tasks` ADD COLUMN `cover_watermark_text` VARCHAR(100) NOT NULL DEFAULT '' AFTER `cover_watermark`");
    }
    if (!column_exists('article_generation_tasks', 'static_output')) {
        $pdo = db_connection();
        $pdo->exec("ALTER TABLE `article_generation_tasks` ADD COLUMN `static_output` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1 AFTER `publish_now`");
    }
    if (!table_exists('article_generation_task_domains')) {
        create_article_generation_task_domains_table_if_not_exists();
    }
    if (!table_exists('article_generation_task_keywords')) {
        create_article_generation_task_keywords_table_if_not_exists();
    }
}

function article_generation_task_status_badge_class($status)
{
    if ($status === 'enabled' || $status === 'completed') {
        return 'success';
    }
    if ($status === 'running') {
        return 'warning';
    }
    if ($status === 'error') {
        return 'danger';
    }
    return 'info';
}

function parse_target_menu_ids($value)
{
    if (is_array($value)) {
        return array_values(array_unique(array_filter(array_map('intval', $value))));
    }
    if (is_string($value) && $value !== '') {
        return array_values(array_unique(array_filter(array_map('intval', preg_split('/[,\s]+/', $value)))));
    }
    return array();
}

function serialize_target_menu_ids($ids)
{
    if (!is_array($ids)) {
        $ids = parse_target_menu_ids($ids);
    }
    return implode(',', $ids);
}

function normalize_article_task_form_data($data)
{
    $statusOptions = article_generation_task_status_options();
    $status = isset($data['status']) ? trim((string)$data['status']) : 'enabled';
    if (!isset($statusOptions[$status])) {
        $status = 'enabled';
    }
    $domainIds = isset($data['domain_ids']) && is_array($data['domain_ids']) ? array_values(array_unique(array_filter(array_map('intval', $data['domain_ids'])))) : array();
    $keywordsText = isset($data['keywords_text']) ? (string)$data['keywords_text'] : '';
    return array(
        'task_name' => isset($data['task_name']) ? trim((string)$data['task_name']) : '',
        'template_id' => isset($data['template_id']) ? (int)$data['template_id'] : 0,
        'model_id' => isset($data['model_id']) ? (int)$data['model_id'] : 0,
        'custom_prompt' => isset($data['custom_prompt']) ? trim((string)$data['custom_prompt']) : '',
        'article_length' => isset($data['article_length']) ? trim((string)$data['article_length']) : '1200-1800字',
        'path_prefix' => isset($data['path_prefix']) ? trim((string)$data['path_prefix']) : 'article',
        'interval_seconds' => isset($data['interval_seconds']) ? max(0, min(86400, (int)$data['interval_seconds'])) : 0,
        'generate_image' => !empty($data['generate_image']) ? 1 : 0,
        'publish_now' => !empty($data['publish_now']) ? 1 : 0,
        'static_output' => isset($data['static_output']) ? (int)(bool)$data['static_output'] : (!empty($data['publish_now']) ? 1 : 0),
        'insert_images' => !empty($data['insert_images']) ? 1 : 0,
        'image_folder' => isset($data['image_folder']) ? trim((string)$data['image_folder']) : '',
        'image_count' => isset($data['image_count']) ? max(1, min(10, (int)$data['image_count'])) : 1,
        'use_folder_cover' => !empty($data['use_folder_cover']) ? 1 : 0,
        'cover_watermark' => !empty($data['cover_watermark']) ? 1 : 0,
        'cover_watermark_text' => isset($data['cover_watermark_text']) ? trim(mb_substr(trim((string)$data['cover_watermark_text']), 0, 100)) : '',
        'target_menu_ids' => isset($data['target_menu_ids']) ? parse_target_menu_ids($data['target_menu_ids']) : (isset($data['target_menu_id']) && (int)$data['target_menu_id'] > 0 ? array((int)$data['target_menu_id']) : array()),
        'status' => $status,
        'remark' => isset($data['remark']) ? trim((string)$data['remark']) : '',
        'reuse_keywords' => !empty($data['reuse_keywords']) ? 1 : 0,
        'domain_ids' => $domainIds,
        'keywords_text' => $keywordsText
    );
}

function validate_article_generation_task_data($data)
{
    if (!is_array($data)) {
        throw new Exception('任务配置数据无效。');
    }
    if (!isset($data['task_name']) || trim((string)$data['task_name']) === '') {
        throw new Exception('请填写任务名称。');
    }
    if (!isset($data['model_id']) || (int)$data['model_id'] <= 0) {
        throw new Exception('请选择 AI 模型。');
    }
    if (!isset($data['domain_ids']) || !is_array($data['domain_ids']) || empty($data['domain_ids'])) {
        throw new Exception('请至少选择一个域名。');
    }
    if (!isset($data['template_id']) || (int)$data['template_id'] <= 0) {
        ensure_domains_table_has_template_pack();
        $allHavePack = true;
        foreach ($data['domain_ids'] as $did) {
            $d = find_domain_by_id((int)$did);
            if (!$d || domain_template_pack($d) === '') {
                $allHavePack = false;
                break;
            }
        }
        if (!$allHavePack) {
            throw new Exception('请选择文章模板，或为所有执行域名绑定模板套件。');
        }
    }
    if (parse_article_keywords_text(isset($data['keywords_text']) ? (string)$data['keywords_text'] : '') === array()) {
        throw new Exception('请至少输入一个关键词。');
    }
}

function article_generation_task_compute_next_run_at($status, $intervalSeconds)
{
    if ($status !== 'enabled' && $status !== 'running') {
        return null;
    }
    return date('Y-m-d H:i:s', time() + max(0, (int)$intervalSeconds));
}

function find_article_generation_task_by_id($id)
{
    ensure_article_generation_task_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT * FROM article_generation_tasks WHERE id = :id LIMIT 1');
    $stmt->execute(array(':id' => (int)$id));
    return $stmt->fetch(PDO::FETCH_ASSOC);
}

function list_article_generation_task_domain_ids($taskId)
{
    ensure_article_generation_task_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT domain_id FROM article_generation_task_domains WHERE task_id = :task_id ORDER BY id ASC');
    $stmt->execute(array(':task_id' => (int)$taskId));
    return array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN));
}

function list_article_generation_task_keywords($taskId, $status = '')
{
    ensure_article_generation_task_storage_ready();
    $pdo = db_connection();
    $sql = 'SELECT * FROM article_generation_task_keywords WHERE task_id = :task_id';
    $params = array(':task_id' => (int)$taskId);
    if ($status !== '') {
        $sql .= ' AND status = :status';
        $params[':status'] = trim((string)$status);
    }
    $sql .= ' ORDER BY id ASC';
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function article_generation_task_keywords_text($taskId, $status = '')
{
    $rows = list_article_generation_task_keywords((int)$taskId, (string)$status);
    $keywords = array();
    foreach ($rows as $row) {
        $keyword = isset($row['keyword']) ? trim((string)$row['keyword']) : '';
        if ($keyword !== '') {
            $keywords[] = $keyword;
        }
    }
    return implode("\n", $keywords);
}

function article_generation_task_keywords_text_editable($taskId)
{
    ensure_article_generation_task_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT keyword FROM article_generation_task_keywords WHERE task_id = :task_id AND status != :status ORDER BY id ASC');
    $stmt->execute(array(':task_id' => (int)$taskId, ':status' => 'success'));
    $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
    $keywords = array();
    foreach ($rows as $row) {
        $keyword = isset($row['keyword']) ? trim((string)$row['keyword']) : '';
        if ($keyword !== '' && !in_array($keyword, $keywords, true)) {
            $keywords[] = $keyword;
        }
    }
    return implode("\n", $keywords);
}

function find_article_generation_task_form_data($id)
{
    $task = find_article_generation_task_by_id((int)$id);
    if (!$task) {
        return null;
    }
    return array(
        'id' => isset($task['id']) ? (int)$task['id'] : 0,
        'task_name' => isset($task['task_name']) ? (string)$task['task_name'] : '',
        'template_id' => isset($task['template_id']) ? (int)$task['template_id'] : 0,
        'model_id' => isset($task['model_id']) ? (int)$task['model_id'] : 0,
        'custom_prompt' => isset($task['custom_prompt']) ? (string)$task['custom_prompt'] : '',
        'article_length' => isset($task['article_length']) ? (string)$task['article_length'] : '1200-1800字',
        'path_prefix' => isset($task['path_prefix']) ? (string)$task['path_prefix'] : 'article',
        'interval_seconds' => isset($task['interval_seconds']) ? (int)$task['interval_seconds'] : 0,
        'generate_image' => !empty($task['generate_image']) ? 1 : 0,
        'publish_now' => !empty($task['publish_now']) ? 1 : 0,
        'static_output' => isset($task['static_output']) ? (int)$task['static_output'] : (!empty($task['publish_now']) ? 1 : 0),
        'insert_images' => !empty($task['insert_images']) ? 1 : 0,
        'image_folder' => isset($task['image_folder']) ? (string)$task['image_folder'] : '',
        'image_count' => isset($task['image_count']) ? (int)$task['image_count'] : 1,
        'use_folder_cover' => !empty($task['use_folder_cover']) ? 1 : 0,
        'cover_watermark' => !empty($task['cover_watermark']) ? 1 : 0,
        'cover_watermark_text' => isset($task['cover_watermark_text']) ? (string)$task['cover_watermark_text'] : '',
        'target_menu_ids' => isset($task['target_menu_ids']) && $task['target_menu_ids'] !== '' ? parse_target_menu_ids($task['target_menu_ids']) : (isset($task['target_menu_id']) && (int)$task['target_menu_id'] > 0 ? array((int)$task['target_menu_id']) : array()),
        'status' => isset($task['status']) ? (string)$task['status'] : 'draft',
        'remark' => isset($task['remark']) ? (string)$task['remark'] : '',
        'reuse_keywords' => !empty($task['reuse_keywords']) ? 1 : 0,
        'domain_ids' => list_article_generation_task_domain_ids((int)$task['id']),
        'keywords_text' => article_generation_task_keywords_text_editable((int)$task['id'])
    );
}

function article_generation_task_summary_sql()
{
    return "SELECT t.*, tpl.name AS template_name, m.name AS model_name,
        COALESCE(domain_stats.domain_count, 0) AS domain_count,
        COALESCE(keyword_stats.keyword_total, 0) AS keyword_total,
        COALESCE(keyword_stats.pending_count, 0) AS pending_count,
        COALESCE(keyword_stats.processing_count, 0) AS processing_count,
        COALESCE(keyword_stats.success_count, 0) AS success_keyword_count,
        COALESCE(keyword_stats.failed_count, 0) AS failed_keyword_count
        FROM article_generation_tasks t
        LEFT JOIN templates tpl ON tpl.id = t.template_id
        LEFT JOIN ai_models m ON m.id = t.model_id
        LEFT JOIN (
            SELECT task_id, COUNT(*) AS domain_count
            FROM article_generation_task_domains
            GROUP BY task_id
        ) AS domain_stats ON domain_stats.task_id = t.id
        LEFT JOIN (
            SELECT task_id,
                COUNT(*) AS keyword_total,
                SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending_count,
                SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) AS processing_count,
                SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS success_count,
                SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed_count
            FROM article_generation_task_keywords
            GROUP BY task_id
        ) AS keyword_stats ON keyword_stats.task_id = t.id";
}

function list_article_generation_tasks($keyword, $status, $filterByUser = true)
{
    ensure_article_generation_task_storage_ready();
    $pdo = db_connection();
    $sql = article_generation_task_summary_sql() . ' WHERE 1=1';
    $params = array();
    if ($filterByUser && !is_super_admin()) {
        $sql .= ' AND t.user_id = :uid';
        $params[':uid'] = current_user_id();
    }
    if ($keyword !== '') {
        $sql .= ' AND (t.task_name LIKE :task_name OR t.remark LIKE :remark)';
        $params[':task_name'] = '%' . $keyword . '%';
        $params[':remark'] = '%' . $keyword . '%';
    }
    if ($status !== '' && $status !== 'all') {
        $sql .= ' AND t.status = :status';
        $params[':status'] = $status;
    }
    $sql .= ' ORDER BY t.id DESC';
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function replace_article_generation_task_domains($taskId, $domainIds)
{
    $pdo = db_connection();
    $stmt = $pdo->prepare('DELETE FROM article_generation_task_domains WHERE task_id = :task_id');
    $stmt->execute(array(':task_id' => (int)$taskId));
    if (empty($domainIds)) {
        return;
    }
    $insert = $pdo->prepare('INSERT INTO article_generation_task_domains (task_id, domain_id, created_at) VALUES (:task_id, :domain_id, :created_at)');
    $now = date('Y-m-d H:i:s');
    foreach ($domainIds as $domainId) {
        $insert->execute(array(
            ':task_id' => (int)$taskId,
            ':domain_id' => (int)$domainId,
            ':created_at' => $now
        ));
    }
}

function replace_article_generation_task_keywords($taskId, $keywordsText)
{
    $pdo = db_connection();
    $keywords = parse_article_keywords_text((string)$keywordsText);
    $pdo->beginTransaction();
    try {
        $stmt = $pdo->prepare('DELETE FROM article_generation_task_keywords WHERE task_id = :task_id AND status IN (\'pending\', \'failed\', \'processing\')');
        $stmt->execute(array(':task_id' => (int)$taskId));
        if (!empty($keywords)) {
            $insert = $pdo->prepare('INSERT INTO article_generation_task_keywords (task_id, keyword, status, attempt_count, last_error, content_id, processed_at, created_at, updated_at) VALUES (:task_id, :keyword, :status, :attempt_count, :last_error, :content_id, :processed_at, :created_at, :updated_at)');
            $now = date('Y-m-d H:i:s');
            foreach ($keywords as $keyword) {
                $insert->execute(array(
                    ':task_id' => (int)$taskId,
                    ':keyword' => $keyword,
                    ':status' => 'pending',
                    ':attempt_count' => 0,
                    ':last_error' => '',
                    ':content_id' => 0,
                    ':processed_at' => null,
                    ':created_at' => $now,
                    ':updated_at' => $now
                ));
            }
        }
        $pdo->commit();
    } catch (Exception $e) {
        $pdo->rollBack();
        throw $e;
    }
}

function create_article_generation_task($data)
{
    ensure_article_generation_task_storage_ready();
    $data = normalize_article_task_form_data($data);
    validate_article_generation_task_data($data);
    $pdo = db_connection();
    $now = date('Y-m-d H:i:s');
    $nextRunAt = article_generation_task_compute_next_run_at($data['status'], $data['interval_seconds']);
    $stmt = $pdo->prepare('INSERT INTO article_generation_tasks (task_name, user_id, template_id, model_id, custom_prompt, article_length, path_prefix, interval_seconds, generate_image, publish_now, static_output, insert_images, image_folder, image_count, use_folder_cover, cover_watermark, cover_watermark_text, target_menu_id, target_menu_ids, status, last_run_at, next_run_at, last_success_at, last_generated_at, last_error, success_count, failed_count, generated_count, remark, reuse_keywords, created_at, updated_at) VALUES (:task_name, :user_id, :template_id, :model_id, :custom_prompt, :article_length, :path_prefix, :interval_seconds, :generate_image, :publish_now, :static_output, :insert_images, :image_folder, :image_count, :use_folder_cover, :cover_watermark, :cover_watermark_text, :target_menu_id, :target_menu_ids, :status, :last_run_at, :next_run_at, :last_success_at, :last_generated_at, :last_error, :success_count, :failed_count, :generated_count, :remark, :reuse_keywords, :created_at, :updated_at)');
    $stmt->execute(array(
        ':task_name' => $data['task_name'],
        ':user_id' => current_user_id(),
        ':template_id' => (int)$data['template_id'],
        ':model_id' => (int)$data['model_id'],
        ':custom_prompt' => $data['custom_prompt'] !== '' ? $data['custom_prompt'] : null,
        ':article_length' => $data['article_length'],
        ':path_prefix' => $data['path_prefix'] !== '' ? $data['path_prefix'] : 'article',
        ':interval_seconds' => (int)$data['interval_seconds'],
        ':generate_image' => !empty($data['generate_image']) ? 1 : 0,
        ':publish_now' => !empty($data['publish_now']) ? 1 : 0,
        ':static_output' => !empty($data['static_output']) ? 1 : 0,
        ':insert_images' => !empty($data['insert_images']) ? 1 : 0,
        ':image_folder' => $data['image_folder'],
        ':image_count' => (int)$data['image_count'],
        ':use_folder_cover' => !empty($data['use_folder_cover']) ? 1 : 0,
        ':cover_watermark' => !empty($data['cover_watermark']) ? 1 : 0,
        ':cover_watermark_text' => isset($data['cover_watermark_text']) ? (string)$data['cover_watermark_text'] : '',
        ':target_menu_id' => !empty($data['target_menu_ids']) ? (int)$data['target_menu_ids'][0] : 0,
        ':target_menu_ids' => serialize_target_menu_ids(isset($data['target_menu_ids']) ? $data['target_menu_ids'] : array()),
        ':status' => $data['status'],
        ':last_run_at' => null,
        ':next_run_at' => $nextRunAt,
        ':last_success_at' => null,
        ':last_generated_at' => null,
        ':last_error' => '',
        ':success_count' => 0,
        ':failed_count' => 0,
        ':generated_count' => 0,
        ':remark' => $data['remark'],
        ':reuse_keywords' => !empty($data['reuse_keywords']) ? 1 : 0,
        ':created_at' => $now,
        ':updated_at' => $now
    ));
    $taskId = (int)$pdo->lastInsertId();
    replace_article_generation_task_domains($taskId, $data['domain_ids']);
    replace_article_generation_task_keywords($taskId, $data['keywords_text']);
    return $taskId;
}

function update_article_generation_task($id, $data)
{
    ensure_article_generation_task_storage_ready();
    $existing = find_article_generation_task_by_id((int)$id);
    if (!$existing) {
        throw new Exception('未找到对应的文章任务。');
    }
    $data = normalize_article_task_form_data($data);
    validate_article_generation_task_data($data);
    $pdo = db_connection();
    $nextRunAt = article_generation_task_compute_next_run_at($data['status'], $data['interval_seconds']);
    $stmt = $pdo->prepare('UPDATE article_generation_tasks SET task_name = :task_name, template_id = :template_id, model_id = :model_id, custom_prompt = :custom_prompt, article_length = :article_length, path_prefix = :path_prefix, interval_seconds = :interval_seconds, generate_image = :generate_image, publish_now = :publish_now, static_output = :static_output, insert_images = :insert_images, image_folder = :image_folder, image_count = :image_count, use_folder_cover = :use_folder_cover, cover_watermark = :cover_watermark, cover_watermark_text = :cover_watermark_text, target_menu_id = :target_menu_id, target_menu_ids = :target_menu_ids, status = :status, next_run_at = :next_run_at, remark = :remark, reuse_keywords = :reuse_keywords, updated_at = :updated_at WHERE id = :id LIMIT 1');
    $stmt->execute(array(
        ':task_name' => $data['task_name'],
        ':template_id' => (int)$data['template_id'],
        ':model_id' => (int)$data['model_id'],
        ':custom_prompt' => $data['custom_prompt'] !== '' ? $data['custom_prompt'] : null,
        ':article_length' => $data['article_length'],
        ':path_prefix' => $data['path_prefix'] !== '' ? $data['path_prefix'] : 'article',
        ':interval_seconds' => (int)$data['interval_seconds'],
        ':generate_image' => !empty($data['generate_image']) ? 1 : 0,
        ':publish_now' => !empty($data['publish_now']) ? 1 : 0,
        ':static_output' => !empty($data['static_output']) ? 1 : 0,
        ':insert_images' => !empty($data['insert_images']) ? 1 : 0,
        ':image_folder' => $data['image_folder'],
        ':image_count' => (int)$data['image_count'],
        ':use_folder_cover' => !empty($data['use_folder_cover']) ? 1 : 0,
        ':cover_watermark' => !empty($data['cover_watermark']) ? 1 : 0,
        ':cover_watermark_text' => isset($data['cover_watermark_text']) ? (string)$data['cover_watermark_text'] : '',
        ':target_menu_id' => !empty($data['target_menu_ids']) ? (int)$data['target_menu_ids'][0] : 0,
        ':target_menu_ids' => serialize_target_menu_ids(isset($data['target_menu_ids']) ? $data['target_menu_ids'] : array()),
        ':status' => $data['status'],
        ':next_run_at' => $nextRunAt,
        ':remark' => $data['remark'],
        ':reuse_keywords' => !empty($data['reuse_keywords']) ? 1 : 0,
        ':updated_at' => date('Y-m-d H:i:s'),
        ':id' => (int)$id
    ));
    replace_article_generation_task_domains((int)$id, $data['domain_ids']);
    replace_article_generation_task_keywords((int)$id, $data['keywords_text']);
    return true;
}

function update_article_generation_task_status($id, $status)
{
    $statusOptions = article_generation_task_status_options();
    if (!isset($statusOptions[$status])) {
        throw new Exception('文章任务状态无效。');
    }
    $task = find_article_generation_task_by_id((int)$id);
    if (!$task) {
        throw new Exception('未找到对应的文章任务。');
    }
    $pdo = db_connection();
    $nextRunAt = article_generation_task_compute_next_run_at($status, isset($task['interval_seconds']) ? (int)$task['interval_seconds'] : 0);
    $stmt = $pdo->prepare('UPDATE article_generation_tasks SET status = :status, next_run_at = :next_run_at, updated_at = :updated_at WHERE id = :id LIMIT 1');
    $stmt->execute(array(
        ':status' => $status,
        ':next_run_at' => $nextRunAt,
        ':updated_at' => date('Y-m-d H:i:s'),
        ':id' => (int)$id
    ));
    return true;
}

function delete_article_generation_task($id)
{
    $task = find_article_generation_task_by_id((int)$id);
    if (!$task) {
        throw new Exception('未找到对应的文章任务。');
    }
    $pdo = db_connection();
    $pdo->prepare('DELETE FROM article_generation_task_keywords WHERE task_id = :task_id')->execute(array(':task_id' => (int)$id));
    $pdo->prepare('DELETE FROM article_generation_task_domains WHERE task_id = :task_id')->execute(array(':task_id' => (int)$id));
    $pdo->prepare('DELETE FROM article_generation_tasks WHERE id = :id LIMIT 1')->execute(array(':id' => (int)$id));
    return true;
}

function fix_legacy_article_cover_paths_in_html($html)
{
    $html = (string)$html;
    if ($html === '') {
        return $html;
    }
    $html = preg_replace('#(<img[^>]+src=["\"])uploads/article-covers/#i', '$1/uploads/article-covers/', $html);
    $html = preg_replace("#(<img[^>]+src=['])uploads/article-covers/#i", '$1/uploads/article-covers/', $html);
    return $html;
}

function fix_legacy_article_cover_path_value($value)
{
    $value = trim((string)$value);
    if ($value === '') {
        return $value;
    }
    if (stripos($value, 'uploads/article-covers/') === 0) {
        return '/' . ltrim($value, '/');
    }
    return $value;
}

function repair_legacy_article_cover_paths($republishPublished)
{
    ensure_content_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->query("SELECT * FROM contents WHERE source_type = 'article_ai' ORDER BY id ASC");
    $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
    $fixedCount = 0;
    $republishedCount = 0;

    foreach ($rows as $row) {
        $contentId = isset($row['id']) ? (int)$row['id'] : 0;
        if ($contentId <= 0) {
            continue;
        }
        $originalBody = isset($row['content_body']) ? (string)$row['content_body'] : '';
        $fixedBody = fix_legacy_article_cover_paths_in_html($originalBody);
        $templateVars = decode_template_vars_json(isset($row['template_vars_json']) ? (string)$row['template_vars_json'] : '');
        $originalCover = isset($row['cover_image']) ? (string)$row['cover_image'] : '';
        $fixedCover = fix_legacy_article_cover_path_value($originalCover);
        $templateChanged = false;

        if (isset($templateVars['{{cover_image}}'])) {
            $newCoverVar = fix_legacy_article_cover_path_value((string)$templateVars['{{cover_image}}']);
            if ($newCoverVar !== (string)$templateVars['{{cover_image}}']) {
                $templateVars['{{cover_image}}'] = $newCoverVar;
                $templateChanged = true;
            }
        }
        if (isset($templateVars['{{content}}'])) {
            $newContentVar = fix_legacy_article_cover_paths_in_html((string)$templateVars['{{content}}']);
            if ($newContentVar !== (string)$templateVars['{{content}}']) {
                $templateVars['{{content}}'] = $newContentVar;
                $templateChanged = true;
            }
        }

        if ($fixedBody === $originalBody && $fixedCover === $originalCover && !$templateChanged) {
            continue;
        }

        $updateStmt = $pdo->prepare('UPDATE contents SET content_body = :content_body, template_vars_json = :template_vars_json, cover_image = :cover_image, updated_at = :updated_at WHERE id = :id LIMIT 1');
        $updateStmt->execute(array(
            ':content_body' => $fixedBody,
            ':template_vars_json' => encode_template_vars_json($templateVars),
            ':cover_image' => $fixedCover,
            ':updated_at' => date('Y-m-d H:i:s'),
            ':id' => $contentId
        ));
        $fixedCount++;

        if ($republishPublished && isset($row['status']) && $row['status'] === 'published') {
            publish_content_static($contentId);
            $republishedCount++;
        }
    }

    return array(
        'fixed_count' => $fixedCount,
        'republished_count' => $republishedCount
    );
}

function find_next_pending_article_generation_keyword($taskId, $reuse = false)
{
    ensure_article_generation_task_storage_ready();
    $pdo = db_connection();
    if ($reuse) {
        $stmt = $pdo->prepare("SELECT * FROM article_generation_task_keywords WHERE task_id = :task_id AND status = 'pending' ORDER BY processed_at ASC, id ASC LIMIT 1");
    } else {
        $stmt = $pdo->prepare("SELECT * FROM article_generation_task_keywords WHERE task_id = :task_id AND status = 'pending' ORDER BY id ASC LIMIT 1");
    }
    $stmt->execute(array(':task_id' => (int)$taskId));
    return $stmt->fetch(PDO::FETCH_ASSOC);
}

function update_article_generation_keyword_state($keywordId, $status, $extra = array())
{
    ensure_article_generation_task_storage_ready();
    $statusOptions = article_generation_keyword_status_options();
    if (!isset($statusOptions[$status])) {
        throw new Exception('关键词状态无效。');
    }
    $attemptCount = isset($extra['attempt_count']) ? (int)$extra['attempt_count'] : null;
    $lastError = isset($extra['last_error']) ? trim((string)$extra['last_error']) : '';
    $contentId = isset($extra['content_id']) ? (int)$extra['content_id'] : 0;
    $processedAt = array_key_exists('processed_at', $extra) ? $extra['processed_at'] : null;
    $pdo = db_connection();
    $stmt = $pdo->prepare('UPDATE article_generation_task_keywords SET status = :status, attempt_count = COALESCE(:attempt_count, attempt_count), last_error = :last_error, content_id = :content_id, processed_at = :processed_at, updated_at = :updated_at WHERE id = :id LIMIT 1');
    $stmt->execute(array(
        ':status' => $status,
        ':attempt_count' => $attemptCount,
        ':last_error' => $lastError,
        ':content_id' => $contentId,
        ':processed_at' => $processedAt,
        ':updated_at' => date('Y-m-d H:i:s'),
        ':id' => (int)$keywordId
    ));
}

function refresh_article_generation_task_stats($taskId, $extra = array())
{
    ensure_article_generation_task_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare("SELECT
        SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS success_total,
        SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed_total,
        SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending_total
        FROM article_generation_task_keywords WHERE task_id = :task_id");
    $stmt->execute(array(':task_id' => (int)$taskId));
    $stats = $stmt->fetch(PDO::FETCH_ASSOC);
    $successTotal = isset($stats['success_total']) ? (int)$stats['success_total'] : 0;
    $failedTotal = isset($stats['failed_total']) ? (int)$stats['failed_total'] : 0;
    $pendingTotal = isset($stats['pending_total']) ? (int)$stats['pending_total'] : 0;
    $status = isset($extra['status']) ? trim((string)$extra['status']) : '';
    if ($status === '') {
        $current = find_article_generation_task_by_id((int)$taskId);
        $status = $current && isset($current['status']) ? (string)$current['status'] : 'draft';
    }
    if ($pendingTotal > 0 && ($status === 'running' || $status === 'error' || $status === 'completed')) {
        $status = 'enabled';
    }
    if ($pendingTotal <= 0 && ($status === 'running' || $status === 'enabled' || $status === 'error')) {
        $status = 'completed';
    }
    $nextRunAt = article_generation_task_compute_next_run_at($status, isset($extra['interval_seconds']) ? (int)$extra['interval_seconds'] : 0);
    $setFragments = array(
        'success_count = :success_count',
        'failed_count = :failed_count',
        'generated_count = :generated_count',
        'last_error = :last_error',
        'next_run_at = :next_run_at',
        'status = :status',
        'updated_at = :updated_at'
    );
    $params = array(
        ':success_count' => $successTotal,
        ':failed_count' => $failedTotal,
        ':generated_count' => $successTotal,
        ':last_error' => isset($extra['last_error']) ? trim((string)$extra['last_error']) : '',
        ':next_run_at' => $nextRunAt,
        ':status' => $status,
        ':updated_at' => date('Y-m-d H:i:s'),
        ':id' => (int)$taskId
    );
    if (isset($extra['last_run_at'])) {
        $setFragments[] = 'last_run_at = :last_run_at';
        $params[':last_run_at'] = $extra['last_run_at'];
    }
    if (isset($extra['last_success_at'])) {
        $setFragments[] = 'last_success_at = :last_success_at';
        $params[':last_success_at'] = $extra['last_success_at'];
    }
    if (isset($extra['last_generated_at'])) {
        $setFragments[] = 'last_generated_at = :last_generated_at';
        $params[':last_generated_at'] = $extra['last_generated_at'];
    }
    $stmt = $pdo->prepare('UPDATE article_generation_tasks SET ' . implode(', ', $setFragments) . ' WHERE id = :id LIMIT 1');
    $stmt->execute($params);
}

function recover_stuck_article_generation_tasks()
{
    ensure_article_generation_task_storage_ready();
    $pdo = db_connection();
    $now = date('Y-m-d H:i:s');

    // 恢复卡死的任务（running 超过 10 分钟）
    $taskThreshold = date('Y-m-d H:i:s', time() - 600);
    $stmt = $pdo->prepare("UPDATE article_generation_tasks SET status = 'enabled', next_run_at = :now, last_error = CONCAT('[自动恢复] 任务执行超时已重置 ', :reset_time), updated_at = :updated_at WHERE status = 'running' AND last_run_at IS NOT NULL AND last_run_at <= :threshold");
    $stmt->execute(array(
        ':now' => $now,
        ':reset_time' => $now,
        ':updated_at' => $now,
        ':threshold' => $taskThreshold
    ));
    $recovered = $stmt->rowCount();

    // 恢复卡死的关键词（processing 超过 5 分钟 → 重置为 pending）
    $kwThreshold = date('Y-m-d H:i:s', time() - 300);
    $stmt2 = $pdo->prepare("UPDATE article_generation_task_keywords SET status = 'pending', last_error = '[自动恢复] 处理超时已重置', updated_at = :updated_at WHERE status = 'processing' AND updated_at <= :threshold");
    $stmt2->execute(array(':updated_at' => $now, ':threshold' => $kwThreshold));
    $recovered += $stmt2->rowCount();

    // 自动重试失败的关键词（attempt_count < 3 → 重置为 pending）
    $stmt3 = $pdo->prepare("UPDATE article_generation_task_keywords SET status = 'pending', updated_at = :updated_at WHERE status = 'failed' AND attempt_count < 3");
    $stmt3->execute(array(':updated_at' => $now));
    $retried = $stmt3->rowCount();
    $recovered += $retried;

    // 如果有关键词被重置为 pending，重新激活已完成的任务
    if ($retried > 0) {
        $stmt4 = $pdo->prepare("UPDATE article_generation_tasks SET status = 'enabled', next_run_at = :now, updated_at = :updated_at WHERE status = 'completed' AND id IN (SELECT DISTINCT task_id FROM article_generation_task_keywords WHERE status = 'pending')");
        $stmt4->execute(array(':now' => $now, ':updated_at' => $now));
        $recovered += $stmt4->rowCount();
    }

    return $recovered;
}

function list_due_article_generation_tasks($limit)
{
    ensure_article_generation_task_storage_ready();
    recover_stuck_article_generation_tasks();
    $pdo = db_connection();
    $limit = max(1, min(20, (int)$limit));
    $sql = article_generation_task_summary_sql() . " WHERE t.status IN ('enabled', 'running') AND (t.next_run_at IS NULL OR t.next_run_at <= :now) ORDER BY COALESCE(t.next_run_at, t.created_at) ASC, t.id ASC LIMIT " . $limit;
    $stmt = $pdo->prepare($sql);
    $stmt->execute(array(':now' => date('Y-m-d H:i:s')));
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function pick_random_images_from_folder($folderPath, $count)
{
    $images = array();
    $folderPath = rtrim(str_replace('\\', '/', $folderPath), '/');
    if ($folderPath === '' || !is_dir($folderPath)) {
        return $images;
    }
    $extensions = array('jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp');
    $files = @scandir($folderPath);
    if (!is_array($files)) {
        return $images;
    }
    $candidates = array();
    foreach ($files as $f) {
        if ($f === '.' || $f === '..') continue;
        $ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
        if (in_array($ext, $extensions, true)) {
            $candidates[] = $f;
        }
    }
    if (empty($candidates)) {
        return $images;
    }
    shuffle($candidates);
    $pick = array_slice($candidates, 0, min($count, count($candidates)));
    foreach ($pick as $filename) {
        $images[] = array(
            'path' => $folderPath . '/' . $filename,
            'filename' => $filename,
            'name' => pathinfo($filename, PATHINFO_FILENAME)
        );
    }
    return $images;
}

function inject_random_images_into_content($contentHtml, $task, $keyword, &$insertedImageUrls = array())
{
    $insertedImageUrls = array();
    if (empty($task['insert_images']) || empty($task['image_folder'])) {
        return $contentHtml;
    }
    $folder = trim((string)$task['image_folder']);
    $count = isset($task['image_count']) ? max(1, min(10, (int)$task['image_count'])) : 1;

    // 支持相对路径（相对于项目根目录）和绝对路径
    if (!preg_match('#^[a-zA-Z]:[/\\\\]#', $folder) && $folder[0] !== '/') {
        $folder = rtrim(str_replace('\\', '/', __DIR__ . '/..'), '/') . '/' . ltrim($folder, '/');
    }

    $images = pick_random_images_from_folder($folder, $count);
    if (empty($images)) {
        return $contentHtml;
    }

    // 将本地图片路径转换为 web 可访问的 URL（相对于站点根目录的 /uploads/article-images/ ）
    $webDir = '/uploads/article-images';
    $physicalDir = rtrim(str_replace('\\', '/', __DIR__ . '/..'), '/') . $webDir;
    if (!is_dir($physicalDir)) {
        @mkdir($physicalDir, 0755, true);
    }

    $imgTags = array();
    foreach ($images as $img) {
        $ext = strtolower(pathinfo($img['filename'], PATHINFO_EXTENSION));
        $destName = substr(md5(uniqid((string)mt_rand(), true) . $img['filename']), 0, 12) . '.' . $ext;
        $destPath = $physicalDir . '/' . $destName;
        @copy($img['path'], $destPath);
        $imgUrl = $webDir . '/' . $destName;
        $insertedImageUrls[] = $imgUrl;
        $altText = $keyword !== '' ? $keyword . ' - ' . $img['name'] : $img['name'];
        $imgTags[] = '<p><img src="' . e($imgUrl) . '" alt="' . e($altText) . '" loading="lazy" style="max-width:100%; height:auto; border-radius:8px;"></p>';
    }

    // 按段落结束标签拆分 HTML，用于定位插入点
    $parts = preg_split('#(</(?:p|h[1-6]|div|section|ul|ol|blockquote)>)#i', $contentHtml, -1, PREG_SPLIT_DELIM_CAPTURE);
    // 收集所有段落结束标签的索引（奇数位）
    $closingIndices = array();
    for ($i = 1; $i < count($parts); $i += 2) {
        $closingIndices[] = $i;
    }

    if (empty($closingIndices)) {
        // 没有段落标签，直接追加到末尾
        return $contentHtml . "\n" . implode("\n", $imgTags);
    }

    $imgCount = count($imgTags);
    if ($imgCount === 1) {
        // 单张图：插在中间段落后面
        $midIdx = $closingIndices[(int)floor(count($closingIndices) / 2)];
        $before = implode('', array_slice($parts, 0, $midIdx + 1));
        $after = implode('', array_slice($parts, $midIdx + 1));
        return $before . "\n" . $imgTags[0] . "\n" . $after;
    }

    // 多张图：平均分布在段落之间
    // 计算每张图应该插在哪个段落结束标签之后
    $totalClosings = count($closingIndices);
    $insertPositions = array(); // key = closingIndices index, value = array of img tags
    for ($n = 0; $n < $imgCount; $n++) {
        // 均匀分布：第 n 张图放在第 pos 个段落后
        $pos = (int)round(($n + 1) * $totalClosings / ($imgCount + 1)) - 1;
        $pos = max(0, min($totalClosings - 1, $pos));
        if (!isset($insertPositions[$pos])) {
            $insertPositions[$pos] = array();
        }
        $insertPositions[$pos][] = $imgTags[$n];
    }

    // 从后向前插入，避免索引偏移
    $result = $parts;
    $keys = array_keys($insertPositions);
    rsort($keys);
    foreach ($keys as $posKey) {
        $realIdx = $closingIndices[$posKey]; // 在 $parts 中的真实索引
        $block = "\n" . implode("\n", $insertPositions[$posKey]);
        $result[$realIdx] = $result[$realIdx] . $block;
    }

    return implode('', $result);
}

function execute_article_generation_for_keyword($task, $keywordRow)
{
    ensure_content_storage_ready();
    $taskId = isset($task['id']) ? (int)$task['id'] : 0;
    if ($taskId <= 0) {
        throw new Exception('文章任务ID无效。');
    }
    $keywordId = isset($keywordRow['id']) ? (int)$keywordRow['id'] : 0;
    $keyword = isset($keywordRow['keyword']) ? trim((string)$keywordRow['keyword']) : '';
    if ($keywordId <= 0 || $keyword === '') {
        throw new Exception('待处理关键词无效。');
    }
    $templateId = isset($task['template_id']) ? (int)$task['template_id'] : 0;
    $template = null;
    if ($templateId > 0) {
        $template = find_template_by_id($templateId);
        if (!$template || !isset($template['type']) || (string)$template['type'] !== 'article') {
            throw new Exception('文章任务绑定模板不可用。');
        }
    }
    $model = find_ai_model_by_id(isset($task['model_id']) ? (int)$task['model_id'] : 0);
    if (!$model || !isset($model['status']) || $model['status'] !== 'enabled') {
        throw new Exception('文章任务绑定 AI 模型不可用。');
    }
    $domainIds = list_article_generation_task_domain_ids($taskId);
    if (empty($domainIds)) {
        throw new Exception('文章任务未绑定可用域名。');
    }
    if ($templateId <= 0) {
        ensure_domains_table_has_template_pack();
        $allHavePack = true;
        foreach ($domainIds as $did) {
            $d = find_domain_by_id((int)$did);
            if (!$d || domain_template_pack($d) === '') {
                $allHavePack = false;
                break;
            }
        }
        if (!$allHavePack) {
            throw new Exception('文章任务未绑定模板，且部分域名未配置模板套件。');
        }
    }
    $domains = array();
    foreach ($domainIds as $domainId) {
        $domain = find_domain_by_id((int)$domainId);
        if ($domain && isset($domain['status']) && $domain['status'] === 'enabled') {
            $domains[] = $domain;
        }
    }
    if (empty($domains)) {
        throw new Exception('文章任务未找到启用域名。');
    }

    $createdItems = array();
    $previewHtml = '';
    $lastAiResult = null;
    $lastGenerated = null;
    foreach ($domains as $domain) {
        $messages = build_article_generation_messages($domain, $template, $keyword, isset($task['custom_prompt']) ? (string)$task['custom_prompt'] : '', isset($task['article_length']) ? (string)$task['article_length'] : '1200-1800字');
        $aiResult = execute_ai_generation_request($model, $messages);
        $generated = parse_article_generation_result($aiResult['assistant_text']);
        $title = isset($generated['title']) ? trim((string)$generated['title']) : '';
        $seoTitle = isset($generated['seo_title']) ? trim((string)$generated['seo_title']) : '';
        $seoKeywords = normalize_ai_keywords(isset($generated['seo_keywords']) ? $generated['seo_keywords'] : '', $keyword);
        $seoDescription = normalize_ai_description(isset($generated['seo_description']) ? $generated['seo_description'] : '', array(
            isset($generated['excerpt']) ? $generated['excerpt'] : '',
            isset($generated['content_html']) ? strip_tags((string)$generated['content_html']) : '',
            $keyword
        ), 120);
        $excerpt = isset($generated['excerpt']) ? trim((string)$generated['excerpt']) : '';
        $contentHtml = isset($generated['content_html']) ? trim((string)$generated['content_html']) : '';
        if ($title === '') {
            $title = $keyword . '：实用指南';
        }
        if ($seoTitle === '') {
            $seoTitle = $title;
        }
        if ($contentHtml === '') {
            throw new Exception('AI 未生成正文内容。');
        }
        // 随机插入本地图片（如果任务配置了图片插入）
        $insertedImageUrls = array();
        $contentHtml = inject_random_images_into_content($contentHtml, $task, $keyword, $insertedImageUrls);
        $slugBase = normalize_site_build_slug($title);
        $effectivePrefix = isset($task['path_prefix']) ? (string)$task['path_prefix'] : 'article';
        $taskMenuIds = isset($task['target_menu_ids']) && $task['target_menu_ids'] !== '' ? parse_target_menu_ids($task['target_menu_ids']) : (!empty($task['target_menu_id']) ? array((int)$task['target_menu_id']) : array());
        if (!empty($taskMenuIds)) {
            foreach ($taskMenuIds as $mid) {
                $menuRow = find_menu_by_id((int)$mid);
                if ($menuRow && isset($menuRow['domain_id']) && (int)$menuRow['domain_id'] === (int)$domain['id'] && !empty($menuRow['menu_slug'])) {
                    $effectivePrefix = trim((string)$menuRow['menu_slug'], " /\\");
                    break;
                }
            }
        }
        $pathValue = generate_unique_content_path((int)$domain['id'], normalize_article_public_path($slugBase, $effectivePrefix));
        // 生成标题图
        $titleImage = '';
        if (!empty($task['generate_image'])) {
            $coverOpts = array('width' => 1200, 'height' => 630);
            $coverOpts['watermark'] = !empty($task['cover_watermark']);
            if (isset($task['cover_watermark_text']) && trim((string)$task['cover_watermark_text']) !== '') {
                $coverOpts['watermark_text'] = trim((string)$task['cover_watermark_text']);
            } elseif (!empty($coverOpts['watermark']) && isset($domain['site_name']) && trim((string)$domain['site_name']) !== '') {
                $coverOpts['watermark_text'] = trim((string)$domain['site_name']);
            }
            $titleImage = generate_article_cover_image($title, $coverOpts);
        }
        $titleImageUrl = normalize_public_asset_url($titleImage);
        // 封面图优先级：use_folder_cover + 有文件夹图 → 文件夹首图；否则 → 标题图
        $coverImageUrl = '';
        if (!empty($task['use_folder_cover']) && !empty($insertedImageUrls)) {
            $coverImageUrl = $insertedImageUrls[0];
        }
        if ($coverImageUrl === '' && $titleImageUrl !== '') {
            $coverImageUrl = $titleImageUrl;
        }
        $renderContent = $titleImageUrl !== ''
            ? '<p><img src="' . e($titleImageUrl) . '" alt="' . e($title) . '" style="max-width:100%; height:auto; border-radius:12px;"></p>' . "\n" . $contentHtml
            : $contentHtml;
        $contentId = create_content(array(
            'user_id' => isset($task['user_id']) && (int)$task['user_id'] > 0 ? (int)$task['user_id'] : current_user_id(),
            'title' => $title,
            'slug' => content_slug_from_path($pathValue),
            'path' => $pathValue,
            'domain_id' => (int)$domain['id'],
            'template_id' => (int)$task['template_id'],
            'seo_title' => $seoTitle,
            'seo_keywords' => $seoKeywords,
            'seo_description' => $seoDescription,
            'content_body' => $renderContent,
            'template_vars' => array(
                '{{title}}' => $title,
                '{{content}}' => $renderContent,
                '{{cover_image}}' => $coverImageUrl,
                '{{excerpt}}' => $excerpt
            ),
            'source_type' => 'article_ai',
            'source_keyword' => $keyword,
            'cover_image' => $coverImageUrl,
            'menu_ids' => isset($task['target_menu_ids']) && $task['target_menu_ids'] !== '' ? parse_target_menu_ids($task['target_menu_ids']) : (!empty($task['target_menu_id']) ? array((int)$task['target_menu_id']) : array()),
            'status' => !empty($task['publish_now']) ? 'published' : 'draft',
            'remark' => isset($task['remark']) && trim((string)$task['remark']) !== '' ? (string)$task['remark'] : 'AI文章任务生成'
        ));
        if (!empty($task['publish_now']) && !empty($task['static_output'])) {
            publish_content_static($contentId);
        }
        $renderVariables = array_merge(default_template_vars_for_type('article', array(
            'site_name' => isset($domain['site_name']) ? (string)$domain['site_name'] : '',
            'domain' => isset($domain['domain']) ? (string)$domain['domain'] : '',
            'path' => content_public_url_path($pathValue)
        )), array(
            '{{site_name}}' => isset($domain['site_name']) ? (string)$domain['site_name'] : '',
            '{{site_logo}}' => build_site_logo_html($domain),
            '{{site_logo_url}}' => normalize_logo_url_to_relative(isset($domain['logo_url']) ? (string)$domain['logo_url'] : ''),
            '{{domain}}' => isset($domain['domain']) ? (string)$domain['domain'] : '',
            '{{title}}' => $title,
            '{{seo_title}}' => $seoTitle,
            '{{seo_keywords}}' => $seoKeywords,
            '{{seo_description}}' => $seoDescription,
            '{{content}}' => $renderContent,
            '{{path}}' => content_public_url_path($pathValue),
            '{{year}}' => date('Y'),
            '{{cover_image}}' => $coverImageUrl,
            '{{excerpt}}' => $excerpt
        ));
        $previewHtml = render_template_html(isset($template['html_content']) ? $template['html_content'] : '', isset($template['css_content']) ? $template['css_content'] : '', $renderVariables);
        $createdItems[] = array(
            'content_id' => $contentId,
            'domain_id' => (int)$domain['id'],
            'domain' => isset($domain['domain']) ? (string)$domain['domain'] : '',
            'keyword' => $keyword,
            'title' => $title,
            'path' => content_public_url_path($pathValue),
            'status' => !empty($task['publish_now']) ? 'published' : 'draft',
            'cover_image' => $coverImageUrl,
            'seo_title' => $seoTitle,
            'seo_description' => $seoDescription
        );
        $lastAiResult = $aiResult;
        $lastGenerated = $generated;
    }

    return array(
        'keyword_id' => $keywordId,
        'keyword' => $keyword,
        'items' => $createdItems,
        'content_id' => !empty($createdItems) ? (int)$createdItems[count($createdItems) - 1]['content_id'] : 0,
        'content_data' => !empty($createdItems) ? $createdItems[count($createdItems) - 1] : array(),
        'template' => $template,
        'model' => $model,
        'generated_data' => is_array($lastGenerated) ? $lastGenerated : array(),
        'ai_result' => is_array($lastAiResult) ? $lastAiResult : array(),
        'preview_html' => $previewHtml
    );
}

function run_article_generation_task_once($taskId)
{
    require_license_guard('执行文章生成任务', false, 'ai_generate');
    ensure_article_generation_task_storage_ready();
    $task = find_article_generation_task_by_id((int)$taskId);
    if (!$task) {
        throw new Exception('未找到对应的文章任务。');
    }
    $now = date('Y-m-d H:i:s');
    update_article_generation_task_status((int)$taskId, 'running');
    $reuseKeywords = !empty($task['reuse_keywords']);
    $keywordRow = find_next_pending_article_generation_keyword((int)$taskId, $reuseKeywords);
    if (!$keywordRow) {
        $endStatus = $reuseKeywords ? 'enabled' : 'completed';
        refresh_article_generation_task_stats((int)$taskId, array(
            'status' => $endStatus,
            'interval_seconds' => isset($task['interval_seconds']) ? (int)$task['interval_seconds'] : 0,
            'last_run_at' => $now
        ));
        $endMsg = $reuseKeywords ? '当前无可用关键词，等待下一轮循环。' : '任务关键词已全部处理完成。';
        return array('task' => find_article_generation_task_by_id((int)$taskId), 'result' => null, 'message' => $endMsg);
    }

    $attemptCount = isset($keywordRow['attempt_count']) ? (int)$keywordRow['attempt_count'] + 1 : 1;
    update_article_generation_keyword_state((int)$keywordRow['id'], 'processing', array(
        'attempt_count' => $attemptCount,
        'last_error' => '',
        'content_id' => 0,
        'processed_at' => null
    ));

    try {
        $result = execute_article_generation_for_keyword($task, $keywordRow);
        $processedAt = date('Y-m-d H:i:s');
        update_article_generation_keyword_state((int)$keywordRow['id'], 'success', array(
            'attempt_count' => $attemptCount,
            'last_error' => '',
            'content_id' => isset($result['content_id']) ? (int)$result['content_id'] : 0,
            'processed_at' => $processedAt
        ));
        if ($reuseKeywords) {
            update_article_generation_keyword_state((int)$keywordRow['id'], 'pending', array(
                'attempt_count' => 0,
                'last_error' => '',
                'content_id' => 0,
                'processed_at' => $processedAt
            ));
        }
        refresh_article_generation_task_stats((int)$taskId, array(
            'status' => 'enabled',
            'interval_seconds' => isset($task['interval_seconds']) ? (int)$task['interval_seconds'] : 0,
            'last_error' => '',
            'last_run_at' => $processedAt,
            'last_success_at' => $processedAt,
            'last_generated_at' => $processedAt
        ));
        return array('task' => find_article_generation_task_by_id((int)$taskId), 'result' => $result, 'message' => '关键词已成功生成文章。');
    } catch (Exception $e) {
        $processedAt = date('Y-m-d H:i:s');
        update_article_generation_keyword_state((int)$keywordRow['id'], 'failed', array(
            'attempt_count' => $attemptCount,
            'last_error' => $e->getMessage(),
            'content_id' => 0,
            'processed_at' => $processedAt
        ));
        refresh_article_generation_task_stats((int)$taskId, array(
            'status' => 'enabled',
            'interval_seconds' => isset($task['interval_seconds']) ? (int)$task['interval_seconds'] : 0,
            'last_error' => $e->getMessage(),
            'last_run_at' => $processedAt
        ));
        return array(
            'task' => find_article_generation_task_by_id((int)$taskId),
            'result' => null,
            'message' => '当前关键词生成失败，任务已保留并等待继续处理后续关键词。',
            'error' => $e->getMessage(),
            'keyword_id' => (int)$keywordRow['id'],
            'keyword' => isset($keywordRow['keyword']) ? (string)$keywordRow['keyword'] : ''
        );
    }
}

function run_due_article_generation_tasks($limit)
{
    $tasks = list_due_article_generation_tasks((int)$limit);
    $results = array();
    foreach ($tasks as $task) {
        try {
            $results[] = array(
                'task_id' => isset($task['id']) ? (int)$task['id'] : 0,
                'success' => true,
                'data' => run_article_generation_task_once(isset($task['id']) ? (int)$task['id'] : 0)
            );
        } catch (Exception $e) {
            $results[] = array(
                'task_id' => isset($task['id']) ? (int)$task['id'] : 0,
                'success' => false,
                'message' => $e->getMessage()
            );
        }
    }
    return $results;
}

function site_build_step_definitions($publishNow, $taskType)
{
    if ($taskType === 'article') {
        return array(
            1 => array('key' => 'validate', 'label' => '校验文章生成参数', 'percent' => 10),
            2 => array('key' => 'prepare', 'label' => '读取域名 / 模板 / AI模型', 'percent' => 22),
            3 => array('key' => 'generate_title', 'label' => '生成标题与文章内容', 'percent' => 58),
            4 => array('key' => 'image', 'label' => '生成文章封面图', 'percent' => 74),
            5 => array('key' => 'persist', 'label' => '写入文章内容记录', 'percent' => $publishNow ? 88 : 100),
            6 => array('key' => 'publish', 'label' => '静态发布文章', 'percent' => 100)
        );
    }

    return array(
        1 => array('key' => 'validate', 'label' => '校验建站参数', 'percent' => 12),
        2 => array('key' => 'prepare', 'label' => '读取域名 / 模板 / AI模型', 'percent' => 24),
        3 => array('key' => 'generate', 'label' => '生成结构化站点 JSON', 'percent' => 56),
        4 => array('key' => 'persist', 'label' => '写入内容记录与模板变量', 'percent' => 76),
        5 => array('key' => 'preview', 'label' => '渲染模板预览', 'percent' => $publishNow ? 88 : 100),
        6 => array('key' => 'publish', 'label' => '静态发布首页', 'percent' => 100)
    );
}

function create_site_build_task($data)
{
    require_license_guard('创建建站任务', false, 'site_build');
    ensure_site_build_task_storage_ready();
    $pdo = db_connection();
    $token = bin2hex(random_bytes(16));
    $now = date('Y-m-d H:i:s');
    $taskType = isset($data['task_type']) ? trim((string)$data['task_type']) : 'homepage';
    if ($taskType !== 'article') {
        $taskType = 'homepage';
    }
    $payloadJson = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    $stmt = $pdo->prepare('INSERT INTO site_build_tasks (task_token, task_type, domain_id, template_id, model_id, keyword, site_name, site_description, payload_json, remark, publish_now, status, current_step, progress_percent, progress_message, error_message, result_json, created_at, started_at, finished_at, updated_at) VALUES (:task_token, :task_type, :domain_id, :template_id, :model_id, :keyword, :site_name, :site_description, :payload_json, :remark, :publish_now, :status, :current_step, :progress_percent, :progress_message, :error_message, :result_json, :created_at, :started_at, :finished_at, :updated_at)');
    $stmt->execute(array(
        ':task_token' => $token,
        ':task_type' => $taskType,
        ':domain_id' => isset($data['domain_id']) ? (int)$data['domain_id'] : 0,
        ':template_id' => isset($data['template_id']) ? (int)$data['template_id'] : 0,
        ':model_id' => isset($data['model_id']) ? (int)$data['model_id'] : 0,
        ':keyword' => isset($data['keyword']) ? trim((string)$data['keyword']) : '',
        ':site_name' => isset($data['site_name']) ? trim((string)$data['site_name']) : '',
        ':site_description' => isset($data['site_description']) ? trim((string)$data['site_description']) : '',
        ':payload_json' => $payloadJson === false ? null : $payloadJson,
        ':remark' => isset($data['remark']) ? trim((string)$data['remark']) : '',
        ':publish_now' => !empty($data['publish_now']) ? 1 : 0,
        ':status' => 'pending',
        ':current_step' => 1,
        ':progress_percent' => 0,
        ':progress_message' => '任务已创建，等待执行',
        ':error_message' => '',
        ':result_json' => null,
        ':created_at' => $now,
        ':started_at' => null,
        ':finished_at' => null,
        ':updated_at' => $now
    ));

    return find_site_build_task_by_id((int)$pdo->lastInsertId());
}

function find_site_build_task_by_id($id)
{
    ensure_site_build_task_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT * FROM site_build_tasks WHERE id = :id LIMIT 1');
    $stmt->execute(array(':id' => (int)$id));
    return $stmt->fetch(PDO::FETCH_ASSOC);
}

function find_site_build_task_by_token($taskToken)
{
    ensure_site_build_task_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT * FROM site_build_tasks WHERE task_token = :task_token LIMIT 1');
    $stmt->execute(array(':task_token' => trim((string)$taskToken)));
    return $stmt->fetch(PDO::FETCH_ASSOC);
}

function update_site_build_task_progress($taskId, $status, $currentStep, $progressPercent, $progressMessage, $extra)
{
    ensure_site_build_task_storage_ready();
    $pdo = db_connection();
    $task = find_site_build_task_by_id((int)$taskId);
    if (!$task) {
        throw new Exception('未找到建站任务。');
    }

    $now = date('Y-m-d H:i:s');
    $startedAt = isset($task['started_at']) ? (string)$task['started_at'] : '';
    $finishedAt = isset($task['finished_at']) ? (string)$task['finished_at'] : '';
    if ($status === 'running' && $startedAt === '') {
        $startedAt = $now;
    }
    if (($status === 'success' || $status === 'failed') && $finishedAt === '') {
        $finishedAt = $now;
    }

    $errorMessage = isset($extra['error_message']) ? trim((string)$extra['error_message']) : (isset($task['error_message']) ? (string)$task['error_message'] : '');
    $resultData = array();
    if (isset($task['result_json']) && trim((string)$task['result_json']) !== '') {
        $decodedResult = json_decode((string)$task['result_json'], true);
        if (is_array($decodedResult)) {
            $resultData = $decodedResult;
        }
    }
    if (isset($extra['result_data']) && is_array($extra['result_data'])) {
        $resultData = array_replace_recursive($resultData, $extra['result_data']);
    }
    $resultJson = array_key_exists('result_json', $extra) ? (string)$extra['result_json'] : '';
    if ($resultJson === '' && !empty($resultData)) {
        $encodedResult = json_encode($resultData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        $resultJson = $encodedResult === false ? '' : $encodedResult;
    }

    $stmt = $pdo->prepare('UPDATE site_build_tasks SET status = :status, current_step = :current_step, progress_percent = :progress_percent, progress_message = :progress_message, error_message = :error_message, result_json = :result_json, started_at = :started_at, finished_at = :finished_at, updated_at = :updated_at WHERE id = :id LIMIT 1');
    $stmt->execute(array(
        ':status' => (string)$status,
        ':current_step' => (int)$currentStep,
        ':progress_percent' => (int)$progressPercent,
        ':progress_message' => trim((string)$progressMessage),
        ':error_message' => $errorMessage,
        ':result_json' => $resultJson !== '' ? $resultJson : null,
        ':started_at' => $startedAt !== '' ? $startedAt : null,
        ':finished_at' => $finishedAt !== '' ? $finishedAt : null,
        ':updated_at' => $now,
        ':id' => (int)$taskId
    ));
}

function site_build_task_payload($task)
{
    if (isset($task['payload_json']) && trim((string)$task['payload_json']) !== '') {
        $decodedPayload = json_decode((string)$task['payload_json'], true);
        if (is_array($decodedPayload)) {
            return $decodedPayload;
        }
    }

    return array(
        'task_type' => isset($task['task_type']) ? (string)$task['task_type'] : 'homepage',
        'domain_id' => isset($task['domain_id']) ? (int)$task['domain_id'] : 0,
        'template_id' => isset($task['template_id']) ? (int)$task['template_id'] : 0,
        'model_id' => isset($task['model_id']) ? (int)$task['model_id'] : 0,
        'keyword' => isset($task['keyword']) ? (string)$task['keyword'] : '',
        'site_name' => isset($task['site_name']) ? (string)$task['site_name'] : '',
        'site_description' => isset($task['site_description']) ? (string)$task['site_description'] : '',
        'site_language' => '',
        'remark' => isset($task['remark']) ? (string)$task['remark'] : '',
        'publish_now' => !empty($task['publish_now']) ? 1 : 0
    );
}

function execute_site_build_task($taskId)
{
    require_license_guard('执行建站任务', false, 'site_build_execute');
    $task = find_site_build_task_by_id((int)$taskId);
    if (!$task) {
        throw new Exception('未找到建站任务。');
    }
    if (isset($task['status']) && ($task['status'] === 'success' || $task['status'] === 'running')) {
        return $task;
    }

    $payload = site_build_task_payload($task);
    $publishNow = !empty($payload['publish_now']);
    $taskType = isset($payload['task_type']) ? trim((string)$payload['task_type']) : 'homepage';
    if ($taskType !== 'article') {
        $taskType = 'homepage';
    }
    $steps = site_build_step_definitions($publishNow, $taskType);

    $progressCallback = function ($stepNumber, $message, $meta) use ($taskId, $steps) {
        $stepNumber = isset($steps[(int)$stepNumber]) ? (int)$stepNumber : 1;
        $stepMeta = $steps[$stepNumber];
        $extra = array();
        if (is_array($meta) && !empty($meta)) {
            $extra['result_data'] = array('stage_data' => $meta);
        }
        update_site_build_task_progress($taskId, 'running', $stepNumber, isset($stepMeta['percent']) ? (int)$stepMeta['percent'] : 0, $message !== '' ? $message : (isset($stepMeta['label']) ? (string)$stepMeta['label'] : ''), $extra);
    };

    try {
        update_site_build_task_progress($taskId, 'running', 1, 8, $taskType === 'article' ? '正在校验文章生成参数' : '正在校验建站参数', array());
        $result = $taskType === 'article'
            ? create_article_build_from_ai($payload, $progressCallback)
            : create_site_build_from_ai($payload, $progressCallback);
        if (isset($result['debug']) && $result['debug']) {
            update_site_build_task_progress($taskId, 'running', 3, 35, 'DEBUG: 首页生成指令已准备', array(
                'debug_result' => $result
            ));
            return find_site_build_task_by_id((int)$taskId);
        }
        $snapshot = $taskType === 'article' ? build_article_build_result_snapshot($result) : build_site_build_result_snapshot($result);
        $resultJson = json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        update_site_build_task_progress($taskId, 'success', $publishNow ? 6 : 5, 100, '建站任务已完成', array(
            'error_message' => '',
            'result_json' => $resultJson === false ? '' : $resultJson
        ));
    } catch (Exception $e) {
        $failedStep = 3;
        $message = $e->getMessage();
        if (strpos($message, '请选择') !== false || strpos($message, '请输入') !== false || strpos($message, '请填写') !== false) {
            $failedStep = 1;
        } elseif (strpos($message, '未找到对应') !== false || strpos($message, '未启用') !== false) {
            $failedStep = 2;
        } elseif (strpos($message, '访问路径') !== false || strpos($message, 'slug') !== false || strpos($message, '内容') !== false) {
            $failedStep = 4;
        } elseif ($publishNow && (strpos($message, '静态') !== false || strpos($message, '发布') !== false || strpos($message, '输出目录') !== false)) {
            $failedStep = 6;
        }
        $failedPercent = isset($steps[$failedStep]['percent']) ? (int)$steps[$failedStep]['percent'] : 0;
        update_site_build_task_progress($taskId, 'failed', $failedStep, $failedPercent, $message, array(
            'error_message' => $message
        ));
    }

    return find_site_build_task_by_id((int)$taskId);
}

function build_site_build_result_snapshot($result)
{
    $contentData = isset($result['content_data']) && is_array($result['content_data']) ? $result['content_data'] : array();
    $domain = isset($result['domain']) && is_array($result['domain']) ? $result['domain'] : array();
    $template = isset($result['template']) && is_array($result['template']) ? $result['template'] : array();
    $generatedData = isset($result['generated_data']) && is_array($result['generated_data']) ? $result['generated_data'] : array();
    $publishSummary = isset($result['publish_summary']) && is_array($result['publish_summary']) ? $result['publish_summary'] : null;
    $pathValue = isset($contentData['path']) ? content_public_url_path((string)$contentData['path']) : '/';
    if (template_looks_like_homepage($template)) {
        $pathValue = '/';
    }

    return array(
        'content_id' => isset($result['content_id']) ? (int)$result['content_id'] : 0,
        'content_data' => $contentData,
        'domain' => array(
            'id' => isset($domain['id']) ? (int)$domain['id'] : 0,
            'domain' => isset($domain['domain']) ? (string)$domain['domain'] : '',
            'site_name' => isset($domain['site_name']) ? (string)$domain['site_name'] : ''
        ),
        'template' => array(
            'id' => isset($template['id']) ? (int)$template['id'] : 0,
            'name' => isset($template['name']) ? (string)$template['name'] : '',
            'code' => isset($template['code']) ? (string)$template['code'] : '',
            'type' => template_looks_like_homepage($template) ? 'homepage' : (isset($template['type']) ? (string)$template['type'] : '')
        ),
        'generated_data' => $generatedData,
        'ai_result' => array(
            'http_code' => isset($result['ai_result']['http_code']) ? (int)$result['ai_result']['http_code'] : 0,
            'assistant_text' => isset($result['ai_result']['assistant_text']) ? (string)$result['ai_result']['assistant_text'] : '',
            'response_body' => isset($result['ai_result']['response_body']) ? (string)$result['ai_result']['response_body'] : ''
        ),
        'preview_html' => isset($result['preview_html']) ? (string)$result['preview_html'] : '',
        'summary' => array(
            'title' => isset($contentData['title']) ? (string)$contentData['title'] : '',
            'site_name' => isset($generatedData['site_name']) && trim((string)$generatedData['site_name']) !== '' ? (string)$generatedData['site_name'] : (isset($contentData['template_vars']['{{site_name}}']) ? (string)$contentData['template_vars']['{{site_name}}'] : ''),
            'path' => $pathValue,
            'status' => isset($contentData['status']) ? (string)$contentData['status'] : '',
            'template_var_count' => isset($generatedData['template_vars']) && is_array($generatedData['template_vars']) ? count($generatedData['template_vars']) : 0,
            'preview_length' => isset($result['preview_html']) ? strlen((string)$result['preview_html']) : 0,
            'published_count' => $publishSummary && isset($publishSummary['published_count']) ? (int)$publishSummary['published_count'] : 0
        ),
        'publish_summary' => $publishSummary
    );
}

function site_build_task_response_data($task)
{
    $result = array();
    if (isset($task['result_json']) && trim((string)$task['result_json']) !== '') {
        $decoded = json_decode((string)$task['result_json'], true);
        if (is_array($decoded)) {
            $result = $decoded;
        }
    }
    $taskType = isset($task['task_type']) ? (string)$task['task_type'] : 'homepage';
    $steps = site_build_step_definitions(!empty($task['publish_now']), $taskType === 'article' ? 'article' : 'homepage');

    return array(
        'id' => isset($task['id']) ? (int)$task['id'] : 0,
        'task_token' => isset($task['task_token']) ? (string)$task['task_token'] : '',
        'task_type' => $taskType,
        'status' => isset($task['status']) ? (string)$task['status'] : 'pending',
        'current_step' => isset($task['current_step']) ? (int)$task['current_step'] : 1,
        'total_steps' => count($steps),
        'progress_percent' => isset($task['progress_percent']) ? (int)$task['progress_percent'] : 0,
        'progress_message' => isset($task['progress_message']) ? (string)$task['progress_message'] : '',
        'error_message' => isset($task['error_message']) ? (string)$task['error_message'] : '',
        'publish_now' => !empty($task['publish_now']),
        'result' => $result,
        'created_at' => isset($task['created_at']) ? (string)$task['created_at'] : '',
        'started_at' => isset($task['started_at']) ? (string)$task['started_at'] : '',
        'finished_at' => isset($task['finished_at']) ? (string)$task['finished_at'] : ''
    );
}

function write_publish_log($data)
{
    ensure_publish_log_storage_ready();
    $pdo = db_connection();
    $metaJson = isset($data['meta_json']) ? trim((string)$data['meta_json']) : '';
    $stmt = $pdo->prepare('INSERT INTO publish_logs (domain_id, content_id, publish_scope, publish_action, status, message, meta_json, created_at) VALUES (:domain_id, :content_id, :publish_scope, :publish_action, :status, :message, :meta_json, :created_at)');
    $stmt->execute(array(
        ':domain_id' => isset($data['domain_id']) ? (int)$data['domain_id'] : 0,
        ':content_id' => isset($data['content_id']) ? (int)$data['content_id'] : 0,
        ':publish_scope' => isset($data['publish_scope']) ? (string)$data['publish_scope'] : 'content',
        ':publish_action' => isset($data['publish_action']) ? (string)$data['publish_action'] : '',
        ':status' => isset($data['status']) ? (string)$data['status'] : 'success',
        ':message' => isset($data['message']) ? (string)$data['message'] : '',
        ':meta_json' => $metaJson !== '' ? $metaJson : null,
        ':created_at' => date('Y-m-d H:i:s')
    ));
}

function static_file_relative_path_for_content($domainName, $path)
{
    $path = normalize_content_path($path);
    if (is_homepage_content_path($path)) {
        return 'index.html';
    }
    return str_replace('/', '/', $path) . '.html';
}

function static_file_relative_path_for_content_record($content)
{
    $domainName = isset($content['domain_name']) ? (string)$content['domain_name'] : '';
    $path = content_effective_public_path($content);
    return static_file_relative_path_for_content($domainName, $path);
}

function list_published_contents_by_domain($domainId)
{
    ensure_content_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare("SELECT c.*, d.domain AS domain_name, d.site_name AS domain_site_name, t.name AS template_name, t.type AS template_type, t.html_content, t.css_content FROM contents c LEFT JOIN domains d ON d.id = c.domain_id LEFT JOIN templates t ON t.id = c.template_id WHERE c.domain_id = :domain_id AND c.status = 'published' ORDER BY CASE WHEN t.type = 'homepage' OR c.path IN ('index', 'home') THEN 0 ELSE 1 END, c.id DESC");
    $stmt->execute(array(':domain_id' => (int)$domainId));
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function publish_domain_static($domainId)
{
    require_license_guard('域名静态发布', false, 'domain_publish');
    ensure_content_storage_ready();
    $domain = find_domain_by_id((int)$domainId);
    if (!$domain) {
        throw new Exception('未找到需要发布的域名。');
    }
    if (!isset($domain['status']) || $domain['status'] !== 'enabled') {
        throw new Exception('仅启用状态的域名支持全量静态发布。');
    }

    $contents = list_published_contents_by_domain((int)$domainId);
    if (empty($contents)) {
        throw new Exception('当前域名下暂无已发布内容可静态化。');
    }

    $results = array();
    foreach ($contents as $content) {
        $results[] = publish_content_static(isset($content['id']) ? (int)$content['id'] : 0);
    }

    // 同时生成栏目/标签/聚合页静态文件，使「全量静态发布」名副其实
    $archiveResults = array();
    try {
        $archiveResults = publish_domain_archive_pages($domain);
    } catch (Exception $_archiveEx) {
        // 列表页失败不影响整体（文章已写盘）；记录警告即可
        write_publish_log(array(
            'domain_id' => (int)$domainId,
            'content_id' => 0,
            'publish_scope' => 'domain',
            'publish_action' => 'archive_static_publish_warn',
            'status' => 'warn',
            'message' => '列表页静态化部分失败：' . $_archiveEx->getMessage(),
            'meta_json' => ''
        ));
    }

    write_publish_log(array(
        'domain_id' => (int)$domainId,
        'content_id' => 0,
        'publish_scope' => 'domain',
        'publish_action' => 'bulk_static_publish',
        'status' => 'success',
        'message' => '域名全量静态发布完成。',
        'meta_json' => json_encode(array(
            'published_count' => count($results),
            'archive_pages' => count($archiveResults)
        ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
    ));

    return array(
        'domain' => $domain,
        'published_count' => count($results),
        'results' => $results,
        'archive_pages' => $archiveResults
    );
}

/**
 * 生成域名下所有列表页（栏目/标签/文章聚合）的静态文件。
 * 每个列表页含分页 → 写入 site/{域}/{slug}/index.html、site/{域}/{slug}/page/N/index.html。
 * 返回写入的相对路径数组（用于日志）。
 */
function publish_domain_archive_pages($domain)
{
    if (!is_array($domain) || empty($domain['id'])) {
        return array();
    }
    $domainId = (int)$domain['id'];
    $siteRoot = domain_static_site_root($domain);
    if ($siteRoot === '' || !is_dir($siteRoot)) {
        @mkdir($siteRoot, 0755, true);
    }
    $rCfg = app_runtime_config();
    $pageSize = isset($rCfg['archive_page_size']) && (int)$rCfg['archive_page_size'] > 0 ? (int)$rCfg['archive_page_size'] : 10;
    $written = array();

    // 1. 文章聚合页 /articles/index.html
    try {
        $html = build_default_article_list_page_html($domain);
        if ($html !== '' && publish_archive_static_write($siteRoot, 'articles', 1, $html)) {
            $written[] = 'articles/';
        }
    } catch (Exception $e) { /* skip */ }

    // 2. 栏目页（非首页菜单）
    $menus = list_nav_menus_by_domain($domainId);
    foreach ($menus as $menu) {
        if (!empty($menu['is_home'])) { continue; }
        if (isset($menu['custom_url']) && trim((string)$menu['custom_url']) !== '') { continue; }
        $slug = isset($menu['menu_slug']) ? trim((string)$menu['menu_slug']) : '';
        if ($slug === '') { continue; }
        $totalItems = count_published_contents_by_menu(isset($menu['id']) ? (int)$menu['id'] : 0);
        if ($totalItems <= 0) { continue; }
        $totalPages = max(1, (int)ceil($totalItems / $pageSize));
        for ($p = 1; $p <= $totalPages; $p++) {
            try {
                $html = build_default_menu_archive_page_html($domain, $menu, $p);
                if ($html !== '' && publish_archive_static_write($siteRoot, $slug, $p, $html)) {
                    $written[] = $slug . ($p === 1 ? '/' : '/page/' . $p . '/');
                }
            } catch (Exception $e) { /* skip */ }
        }
    }

    // 标签页不参与静态化：标签数据量通常较大，且关联文章变动频繁；
    // 改为始终走伪静态 URL（/tag/{slug}/）→ PHP 实时渲染，避免大量 stale 静态文件。

    return $written;
}

/**
 * 把一个列表页 HTML 写到 site/{slugDir}/[page/N/]index.html
 */
function publish_archive_static_write($siteRoot, $slugDir, $page, $html)
{
    $page = max(1, (int)$page);
    $slugDir = trim((string)$slugDir, '/');
    if ($slugDir === '') { return false; }
    $relative = $slugDir;
    if ($page > 1) {
        $relative .= '/page/' . $page;
    }
    $relative .= '/index.html';
    $targetFile = $siteRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative);
    $dir = dirname($targetFile);
    if (!ensure_directory_exists($dir)) {
        return false;
    }
    return file_put_contents($targetFile, $html, LOCK_EX) !== false;
}

function publish_content_static($contentId)
{
    require_license_guard('内容静态发布', false, 'content_publish');
    ensure_content_storage_ready();
    $content = find_content_by_id((int)$contentId);
    if (!$content) {
        throw new Exception('未找到需要静态发布的内容。');
    }
    if (!isset($content['status']) || $content['status'] !== 'published') {
        throw new Exception('只有已发布内容才可静态化。');
    }

    $domain = find_domain_by_id(isset($content['domain_id']) ? (int)$content['domain_id'] : 0);
    if (!$domain) {
        throw new Exception('静态发布失败：绑定域名不存在。');
    }

    $content['domain_name'] = isset($domain['domain']) ? (string)$domain['domain'] : '';
    $content['domain_site_name'] = isset($domain['site_name']) ? (string)$domain['site_name'] : '';
    $content['domain_site_root'] = domain_static_site_root($domain);

    ensure_domains_table_has_template_pack();
    $packCode = domain_template_pack($domain);
    ob_start();
    $variables = build_content_render_variables($content);
    $renderWarnings = ob_get_clean();
    $html = '';

    // 如果内容绑定了页面模板，优先使用页面模板渲染（不注入站点地图）
    $pageTemplatePack = isset($content['page_template_pack']) ? trim((string)$content['page_template_pack']) : '';
    if ($pageTemplatePack !== '' && load_template_pack_manifest($pageTemplatePack)) {
        $GLOBALS['beeai_skip_sitemap_injection'] = true;
        ob_start();
        $html = render_template_pack_page($pageTemplatePack, 'page', $variables);
        $packWarnings = ob_get_clean();
        unset($GLOBALS['beeai_skip_sitemap_injection']);
        if ($packWarnings !== '' && $packWarnings !== false) {
            $renderWarnings .= $packWarnings;
        }
    }

    if ($html === '' && $packCode !== '' && load_template_pack_manifest($packCode)) {
        $contentPath = isset($content['path']) ? (string)$content['path'] : '';
        $isHomepage = in_array($contentPath, array('index', 'home', ''), true);
        $pageType = $isHomepage ? 'homepage' : 'article';
        ob_start();
        $html = render_template_pack_page($packCode, $pageType, $variables);
        $packWarnings = ob_get_clean();
        if ($packWarnings !== '' && $packWarnings !== false) {
            $renderWarnings .= $packWarnings;
        }
    }

    if ($html === '') {
        $templateId = isset($content['template_id']) ? (int)$content['template_id'] : 0;
        $template = $templateId > 0 ? find_template_by_id($templateId) : null;
        if (!$template) {
            $errParts = array('静态发布失败');
            if ($packCode === '') {
                $errParts[] = '域名未绑定模板套件';
            } else {
                $errParts[] = '模板套件 ' . $packCode . ' 未生成有效页面';
            }
            if ($templateId <= 0) {
                $errParts[] = '内容未绑定独立模板(template_id=0)';
            } else {
                $errParts[] = '模板ID ' . $templateId . ' 不存在';
            }
            if ($renderWarnings !== '' && $renderWarnings !== false) {
                $errParts[] = 'PHP: ' . strip_tags(substr($renderWarnings, 0, 200));
            }
            throw new Exception(implode('；', $errParts) . '。');
        }
        $content['html_content'] = isset($template['html_content']) ? (string)$template['html_content'] : '';
        $content['css_content'] = isset($template['css_content']) ? (string)$template['css_content'] : '';
        $content['template_type'] = isset($template['type']) ? (string)$template['type'] : '';
        $html = render_template_html($content['html_content'], $content['css_content'], $variables);
    }
    $relativePath = static_file_relative_path_for_content_record($content);
    $targetRoot = domain_static_site_root($domain);
    $targetFile = $targetRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath);
    $targetDirectory = dirname($targetFile);

    if (!ensure_directory_exists($targetDirectory)) {
        throw new Exception('静态发布失败：无法创建输出目录。');
    }
    // 覆盖发布：若已存在旧文件，先尝试删除，避免权限/缓存导致未更新
    if (is_file($targetFile)) {
        @chmod($targetFile, 0666);
        @unlink($targetFile);
        // 如启用 OPcache，主动使其失效
        if (function_exists('opcache_invalidate')) { @opcache_invalidate($targetFile, true); }
    }
    if (file_put_contents($targetFile, $html, LOCK_EX) === false) {
        throw new Exception('静态发布失败：无法写入静态文件。');
    }
    // 再次触达修改时间并失效缓存
    @touch($targetFile, time());
    if (function_exists('opcache_invalidate')) { @opcache_invalidate($targetFile, true); }

    $now = date('Y-m-d H:i:s');
    $pdo = db_connection();
    $stmt = $pdo->prepare('UPDATE contents SET static_file_path = :static_file_path, static_published_at = :static_published_at, updated_at = :updated_at WHERE id = :id LIMIT 1');
    $stmt->execute(array(
        ':static_file_path' => $relativePath,
        ':static_published_at' => $now,
        ':updated_at' => $now,
        ':id' => (int)$contentId
    ));

    write_publish_log(array(
        'domain_id' => isset($content['domain_id']) ? (int)$content['domain_id'] : 0,
        'content_id' => (int)$contentId,
        'publish_scope' => 'content',
        'publish_action' => 'static_publish',
        'status' => 'success',
        'message' => '页面静态发布完成。',
        'meta_json' => json_encode(array('relative_path' => $relativePath, 'site_root' => $targetRoot), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
    ));

    return array(
        'relative_path' => $relativePath,
        'absolute_path' => $targetFile,
        'published_at' => $now
    );
}

function remove_content_static($content)
{
    ensure_content_storage_ready();
    $relativePath = isset($content['static_file_path']) ? trim((string)$content['static_file_path']) : '';
    if ($relativePath === '') {
        return false;
    }

    $domain = find_domain_by_id(isset($content['domain_id']) ? (int)$content['domain_id'] : 0);
    $absolutePath = domain_static_site_root($domain ? $domain : array()) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath);
    if (file_exists($absolutePath) && !unlink($absolutePath)) {
        throw new Exception('静态文件删除失败。');
    }

    $contentPath = isset($content['path']) ? normalize_content_path((string)$content['path']) : '';
    $domainName = $domain && isset($domain['domain']) ? normalize_request_host((string)$domain['domain']) : '';
    $template = find_template_by_id(isset($content['template_id']) ? (int)$content['template_id'] : 0);
    $templateType = $template && isset($template['type']) ? (string)$template['type'] : '';
    if ($contentPath !== '' && $domainName !== '' && !is_homepage_content_path($contentPath) && !is_homepage_template_type($templateType)) {
        $directory = domain_static_site_root($domain ? $domain : array()) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $contentPath);
        if (is_dir($directory)) {
            remove_directory_recursively($directory);
        }
    }

    write_publish_log(array(
        'domain_id' => isset($content['domain_id']) ? (int)$content['domain_id'] : 0,
        'content_id' => isset($content['id']) ? (int)$content['id'] : 0,
        'publish_scope' => 'content',
        'publish_action' => 'static_remove',
        'status' => 'success',
        'message' => '页面静态文件已移除。',
        'meta_json' => json_encode(array('relative_path' => $relativePath), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
    ));

    $pdo = db_connection();
    $stmt = $pdo->prepare('UPDATE contents SET static_file_path = :static_file_path, static_published_at = :static_published_at, updated_at = :updated_at WHERE id = :id LIMIT 1');
    $stmt->execute(array(
        ':static_file_path' => '',
        ':static_published_at' => null,
        ':updated_at' => date('Y-m-d H:i:s'),
        ':id' => isset($content['id']) ? (int)$content['id'] : 0
    ));

    return true;
}

function content_status_options()
{
    return array(
        'draft' => '草稿',
        'published' => '已发布',
        'scheduled' => '定时发布'
    );
}

function content_badge_class($status)
{
    if ($status === 'published') {
        return 'success';
    }
    if ($status === 'scheduled') {
        return 'warning';
    }
    return 'info';
}

function list_enabled_domain_options()
{
    ensure_domains_table_has_site_root();
    ensure_domains_table_has_site_language();
    $pdo = db_connection();
    $uid = current_user_id();
    $stmt = $pdo->prepare("SELECT id, domain, site_name, site_root, site_language FROM domains WHERE status = 'enabled' AND user_id = :uid ORDER BY id DESC");
    $stmt->execute(array(':uid' => $uid));
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function count_published_contents_by_domain_ids()
{
    ensure_content_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->query("SELECT domain_id, COUNT(*) AS cnt FROM contents WHERE status = 'published' GROUP BY domain_id");
    $map = array();
    foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
        $map[(int)$row['domain_id']] = (int)$row['cnt'];
    }
    return $map;
}

function list_enabled_template_options()
{
    $pdo = db_connection();
    $uid = current_user_id();
    $stmt = $pdo->prepare("SELECT id, name, code, type FROM templates WHERE status = 'enabled' AND user_id = :uid ORDER BY id DESC");
    $stmt->execute(array(':uid' => $uid));
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function table_exists($tableName)
{
    static $cache = array();
    $key = (string)$tableName;
    if (array_key_exists($key, $cache)) {
        return $cache[$key];
    }
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = :table_name');
    $stmt->execute(array(':table_name' => $key));
    $result = (int)$stmt->fetchColumn() > 0;
    $cache[$key] = $result;
    return $result;
}

function column_exists($tableName, $columnName)
{
    static $cache = array();
    $key = (string)$tableName . '.' . (string)$columnName;
    if (array_key_exists($key, $cache)) {
        return $cache[$key];
    }
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = :table_name AND column_name = :column_name');
    $stmt->execute(array(
        ':table_name' => (string)$tableName,
        ':column_name' => (string)$columnName
    ));
    $result = (int)$stmt->fetchColumn() > 0;
    $cache[$key] = $result;
    return $result;
}

function index_exists($tableName, $indexName)
{
    static $cache = array();
    $key = (string)$tableName . '.' . (string)$indexName;
    if (array_key_exists($key, $cache)) {
        return $cache[$key];
    }
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = :table_name AND index_name = :index_name');
    $stmt->execute(array(
        ':table_name' => (string)$tableName,
        ':index_name' => (string)$indexName
    ));
    $result = (int)$stmt->fetchColumn() > 0;
    $cache[$key] = $result;
    return $result;
}

function create_contents_table_if_not_exists()
{
    $pdo = db_connection();
    $sql = "CREATE TABLE IF NOT EXISTS `contents` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `title` VARCHAR(255) NOT NULL,
        `slug` VARCHAR(150) NOT NULL,
        `path` VARCHAR(255) NOT NULL DEFAULT '',
        `domain_id` BIGINT UNSIGNED NOT NULL,
        `template_id` BIGINT UNSIGNED NOT NULL,
        `seo_title` VARCHAR(255) NOT NULL DEFAULT '',
        `seo_keywords` VARCHAR(255) NOT NULL DEFAULT '',
        `seo_description` TEXT NULL,
        `content_body` LONGTEXT NULL,
        `template_vars_json` LONGTEXT NULL,
        `status` VARCHAR(20) NOT NULL DEFAULT 'draft',
        `remark` VARCHAR(255) NOT NULL DEFAULT '',
        `static_file_path` VARCHAR(500) NOT NULL DEFAULT '',
        `static_published_at` DATETIME NULL,
        `created_at` DATETIME NOT NULL,
        `updated_at` DATETIME NOT NULL,
        PRIMARY KEY (`id`),
        UNIQUE KEY `uk_content_domain_path` (`domain_id`, `path`),
        KEY `idx_content_domain_id` (`domain_id`),
        KEY `idx_content_slug` (`slug`),
        KEY `idx_content_template_id` (`template_id`),
        KEY `idx_content_user_id` (`user_id`),
        KEY `idx_content_status` (`status`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    $pdo->exec($sql);
}

function migrate_contents_table_if_needed()
{
    $pdo = db_connection();

    if (!column_exists('contents', 'path')) {
        $pdo->exec("ALTER TABLE `contents` ADD COLUMN `path` VARCHAR(255) NOT NULL DEFAULT '' AFTER `slug`");
    }
    if (!column_exists('contents', 'template_vars_json')) {
        $pdo->exec("ALTER TABLE `contents` ADD COLUMN `template_vars_json` LONGTEXT NULL AFTER `content_body`");
    }
    if (!column_exists('contents', 'static_file_path')) {
        $pdo->exec("ALTER TABLE `contents` ADD COLUMN `static_file_path` VARCHAR(500) NOT NULL DEFAULT '' AFTER `remark`");
    }
    if (!column_exists('contents', 'static_published_at')) {
        $pdo->exec("ALTER TABLE `contents` ADD COLUMN `static_published_at` DATETIME NULL AFTER `static_file_path`");
    }
    if (!column_exists('contents', 'source_type')) {
        $pdo->exec("ALTER TABLE `contents` ADD COLUMN `source_type` VARCHAR(30) NOT NULL DEFAULT 'manual' AFTER `template_vars_json`");
    }
    if (!column_exists('contents', 'source_keyword')) {
        $pdo->exec("ALTER TABLE `contents` ADD COLUMN `source_keyword` VARCHAR(255) NOT NULL DEFAULT '' AFTER `source_type`");
    }
    if (!column_exists('contents', 'cover_image')) {
        $pdo->exec("ALTER TABLE `contents` ADD COLUMN `cover_image` VARCHAR(500) NOT NULL DEFAULT '' AFTER `source_keyword`");
    }
    if (!column_exists('contents', 'site_language')) {
        $pdo->exec("ALTER TABLE `contents` ADD COLUMN `site_language` VARCHAR(20) NOT NULL DEFAULT 'zh-CN' AFTER `cover_image`");
    }

    try {
        $colInfo = $pdo->query("SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'contents' AND COLUMN_NAME = 'seo_description'")->fetch(PDO::FETCH_ASSOC);
        if ($colInfo && strtolower($colInfo['DATA_TYPE']) !== 'text') {
            $pdo->exec("ALTER TABLE `contents` MODIFY COLUMN `seo_description` TEXT NULL");
        }
    } catch (Exception $e) {}

    if (php_sapi_name() === 'cli' && getenv('ALLOW_PATH_FIX') === '1') {
        $pdo->exec("UPDATE `contents` SET `path` = `slug` WHERE (`path` = '' OR `path` IS NULL) AND `slug` <> ''");
    }

    if (index_exists('contents', 'uk_content_slug')) {
        $pdo->exec("ALTER TABLE `contents` DROP INDEX `uk_content_slug`");
    }
    if (!index_exists('contents', 'idx_content_slug')) {
        $pdo->exec("ALTER TABLE `contents` ADD INDEX `idx_content_slug` (`slug`)");
    }
    if (!index_exists('contents', 'uk_content_domain_path')) {
        $pdo->exec("ALTER TABLE `contents` ADD UNIQUE INDEX `uk_content_domain_path` (`domain_id`, `path`)");
    }
    if (!index_exists('contents', 'idx_content_domain_status_id')) {
        $pdo->exec("ALTER TABLE `contents` ADD INDEX `idx_content_domain_status_id` (`domain_id`, `status`, `id`)");
    }
    if (!index_exists('contents', 'idx_content_domain_status_path')) {
        $pdo->exec("ALTER TABLE `contents` ADD INDEX `idx_content_domain_status_path` (`domain_id`, `status`, `path`)");
    }
    if (!index_exists('contents', 'idx_content_status_id')) {
        $pdo->exec("ALTER TABLE `contents` ADD INDEX `idx_content_status_id` (`status`, `id`)");
    }
    if (!index_exists('contents', 'idx_content_domain_source_status_id')) {
        $pdo->exec("ALTER TABLE `contents` ADD INDEX `idx_content_domain_source_status_id` (`domain_id`, `source_type`, `status`, `id`)");
    }

    if (!column_exists('contents', 'page_template_pack')) {
        $pdo->exec("ALTER TABLE `contents` ADD COLUMN `page_template_pack` VARCHAR(200) NOT NULL DEFAULT '' AFTER `cover_image`");
    }

    // 修复 user_id=0 的内容：根据 domain_id 归属到域名所有者
    $orphanCheck = $pdo->query("SELECT COUNT(*) FROM contents WHERE user_id = 0 AND domain_id > 0");
    if ($orphanCheck && (int)$orphanCheck->fetchColumn() > 0) {
        $pdo->exec("UPDATE contents c INNER JOIN domains d ON d.id = c.domain_id SET c.user_id = d.user_id WHERE c.user_id = 0 AND d.user_id > 0");
    }
}

function ensure_content_storage_ready()
{
    static $checked = false;
    if ($checked) return;
    $checked = true;
    if (!table_exists('contents')) {
        create_contents_table_if_not_exists();
    }
    migrate_contents_table_if_needed();
}

function content_storage_status_message()
{
    try {
        ensure_content_storage_ready();
        return '';
    } catch (Exception $e) {
        return $e->getMessage();
    }
}

function list_contents($keyword, $status, $domainId, $templateId)
{
    ensure_content_storage_ready();
    $pdo = db_connection();
    $uid = current_user_id();
    $sql = 'SELECT c.id, c.title, c.slug, c.path, c.status, c.seo_title, c.domain_id, c.template_id, c.updated_at, c.static_file_path, c.static_published_at, c.source_type, c.source_keyword, c.cover_image, d.domain AS domain_name, t.name AS template_name, t.type AS template_type FROM contents c LEFT JOIN domains d ON d.id = c.domain_id LEFT JOIN templates t ON t.id = c.template_id WHERE 1=1';
    $params = array();
    if (!is_super_admin()) {
        $sql .= ' AND c.user_id = :uid';
        $params[':uid'] = $uid;
    }

    if ($keyword !== '') {
        $sql .= ' AND (c.title LIKE :keyword_title OR c.slug LIKE :keyword_slug OR c.path LIKE :keyword_path OR c.seo_title LIKE :keyword_seo)';
        $params[':keyword_title'] = '%' . $keyword . '%';
        $params[':keyword_slug'] = '%' . $keyword . '%';
        $params[':keyword_path'] = '%' . $keyword . '%';
        $params[':keyword_seo'] = '%' . $keyword . '%';
    }
    if ($status !== '' && $status !== 'all') {
        $sql .= ' AND c.status = :status';
        $params[':status'] = $status;
    }
    if ((int)$domainId > 0) {
        $sql .= ' AND c.domain_id = :domain_id';
        $params[':domain_id'] = (int)$domainId;
    }
    if ((int)$templateId > 0) {
        $sql .= ' AND c.template_id = :template_id';
        $params[':template_id'] = (int)$templateId;
    }

    $sql .= ' ORDER BY c.id DESC';
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function count_content_stats()
{
    ensure_content_storage_ready();
    $pdo = db_connection();
    $today = date('Y-m-d');
    $sql = "SELECT
        COUNT(*) AS total,
        SUM(CASE WHEN t.type = 'homepage' OR (t.type IS NULL AND c.source_type NOT LIKE 'article%') THEN 1 ELSE 0 END) AS site_total,
        SUM(CASE WHEN t.type = 'article' OR c.source_type LIKE 'article%' THEN 1 ELSE 0 END) AS article_total,
        SUM(CASE WHEN (t.type = 'homepage' OR (t.type IS NULL AND c.source_type NOT LIKE 'article%')) AND DATE(c.created_at) = :today1 THEN 1 ELSE 0 END) AS site_today,
        SUM(CASE WHEN (t.type = 'article' OR c.source_type LIKE 'article%') AND DATE(c.created_at) = :today2 THEN 1 ELSE 0 END) AS article_today
        FROM contents c
        LEFT JOIN templates t ON t.id = c.template_id";
    $params = array(':today1' => $today, ':today2' => $today);
    if (!is_super_admin()) {
        $sql .= ' WHERE c.user_id = :uid';
        $params[':uid'] = current_user_id();
    }
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    return array(
        'site_total' => $row ? (int)$row['site_total'] : 0,
        'site_today' => $row ? (int)$row['site_today'] : 0,
        'article_total' => $row ? (int)$row['article_total'] : 0,
        'article_today' => $row ? (int)$row['article_today'] : 0,
    );
}

function find_content_by_id($id)
{
    ensure_content_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT * FROM contents WHERE id = :id LIMIT 1');
    $stmt->execute(array(':id' => (int)$id));
    return $stmt->fetch(PDO::FETCH_ASSOC);
}

function resolve_first_non_home_menu_slug($menuIds, $domainId)
{
    if (!empty($menuIds)) {
        $pdo = db_connection();
        $placeholders = implode(',', array_fill(0, count($menuIds), '?'));
        $stmt = $pdo->prepare("SELECT menu_slug FROM menus WHERE id IN ($placeholders) AND is_home = 0 AND menu_slug != '' LIMIT 1");
        $stmt->execute(array_values($menuIds));
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        if ($row && isset($row['menu_slug']) && $row['menu_slug'] !== '') {
            return (string)$row['menu_slug'];
        }
    }
    if ((int)$domainId > 0) {
        $menus = list_enabled_menu_options((int)$domainId);
        foreach ($menus as $m) {
            if (empty($m['is_home']) && isset($m['menu_slug']) && $m['menu_slug'] !== '') {
                return (string)$m['menu_slug'];
            }
        }
    }
    return 'page';
}

function create_content($data)
{
    ensure_content_storage_ready();
    ensure_menu_storage_ready();
    ensure_tag_storage_ready();
    $title = isset($data['title']) ? trim((string)$data['title']) : '';
    $path = isset($data['path']) ? trim((string)$data['path']) : '';
    $slug = isset($data['slug']) ? trim((string)$data['slug']) : '';
    $domainId = isset($data['domain_id']) ? (int)$data['domain_id'] : 0;
    $templateId = isset($data['template_id']) ? (int)$data['template_id'] : 0;
    $seoTitle = isset($data['seo_title']) ? trim((string)$data['seo_title']) : '';
    $seoKeywords = isset($data['seo_keywords']) ? trim((string)$data['seo_keywords']) : '';
    $seoDescription = isset($data['seo_description']) ? trim((string)$data['seo_description']) : '';
    $contentBody = isset($data['content_body']) ? (string)$data['content_body'] : '';
    $templateVarsJson = encode_template_vars_json(isset($data['template_vars']) ? $data['template_vars'] : array());
    $sourceType = isset($data['source_type']) ? trim((string)$data['source_type']) : 'manual';
    $sourceKeyword = isset($data['source_keyword']) ? trim((string)$data['source_keyword']) : '';
    $coverImage = isset($data['cover_image']) ? trim((string)$data['cover_image']) : '';
    $pageTemplatePack = isset($data['page_template_pack']) ? trim((string)$data['page_template_pack']) : '';
    $menuIds = isset($data['menu_ids']) && is_array($data['menu_ids']) ? array_values(array_unique(array_map('intval', $data['menu_ids']))) : array();
    $status = isset($data['status']) ? trim((string)$data['status']) : 'draft';
    $remark = isset($data['remark']) ? trim((string)$data['remark']) : '';

    if ($title === '') {
        throw new Exception('请填写内容标题。');
    }
    if ($domainId <= 0) {
        throw new Exception('请选择绑定域名。');
    }
    $domainForPack = find_domain_by_id($domainId);
    $domainPackCode = $domainForPack ? domain_template_pack($domainForPack) : '';
    if ($templateId <= 0 && $domainPackCode === '') {
        throw new Exception('请选择模板或为域名绑定模板套件。');
    }
    $sourceType = isset($data['source_type']) ? trim((string)$data['source_type']) : 'manual';
    $isHomepageTemplate = $templateId > 0 ? is_homepage_template_id($templateId) : !is_article_source_type($sourceType);

    $autoGeneratePath = false;
    if ($path === '' && $slug === '') {
        if ($isHomepageTemplate) {
            $path = 'index';
            $slug = 'index';
        } else {
            $autoGeneratePath = true;
            $tempToken = '_auto_' . time() . '_' . mt_rand(10000, 99999);
            $path = $tempToken;
            $slug = $tempToken;
        }
    } else {
        if ($path === '') {
            $path = $slug;
        }
        $path = normalize_content_path($path);
        if ($path === '') {
            throw new Exception('请填写访问路径。');
        }
        $slug = content_slug_from_path($path);
        if ($slug === '') {
            throw new Exception('访问路径格式无效。');
        }
        if ($isHomepageTemplate && $path === 'index') {
            $path = 'index';
        }
    }

    if (!$isHomepageTemplate && $contentBody === '') {
        throw new Exception('请填写正文内容。');
    }

    $statusOptions = content_status_options();
    if (!isset($statusOptions[$status])) {
        throw new Exception('内容状态值无效。');
    }

    $pdo = db_connection();

    if (!$autoGeneratePath) {
        $check = $pdo->prepare('SELECT id FROM contents WHERE domain_id = :domain_id AND path = :path LIMIT 1');
        $check->execute(array(':domain_id' => $domainId, ':path' => $path));
        if ($check->fetch()) {
            throw new Exception('访问路径 /' . $path . ' 已存在。');
        }
    }

    $siteLanguage = isset($data['site_language']) ? normalize_domain_language($data['site_language']) : domain_site_language(find_domain_by_id($domainId));
    $now = date('Y-m-d H:i:s');
    $stmt = $pdo->prepare('INSERT INTO contents (user_id, title, slug, path, domain_id, template_id, seo_title, seo_keywords, seo_description, content_body, template_vars_json, source_type, source_keyword, cover_image, page_template_pack, site_language, status, remark, static_file_path, static_published_at, created_at, updated_at) VALUES (:uid, :title, :slug, :path, :domain_id, :template_id, :seo_title, :seo_keywords, :seo_description, :content_body, :template_vars_json, :source_type, :source_keyword, :cover_image, :page_template_pack, :site_language, :status, :remark, :static_file_path, :static_published_at, :created_at, :updated_at)');
    $stmt->execute(array(
        ':uid' => (isset($data['user_id']) && (int)$data['user_id'] > 0) ? (int)$data['user_id'] : current_user_id(),
        ':title' => $title,
        ':slug' => $slug,
        ':path' => $path,
        ':domain_id' => $domainId,
        ':template_id' => $templateId,
        ':seo_title' => $seoTitle,
        ':seo_keywords' => $seoKeywords,
        ':seo_description' => $seoDescription,
        ':content_body' => $contentBody,
        ':template_vars_json' => $templateVarsJson !== '' ? $templateVarsJson : null,
        ':source_type' => $sourceType !== '' ? $sourceType : 'manual',
        ':source_keyword' => $sourceKeyword,
        ':cover_image' => $coverImage,
        ':page_template_pack' => $pageTemplatePack,
        ':site_language' => $siteLanguage,
        ':status' => $status,
        ':remark' => $remark,
        ':static_file_path' => '',
        ':static_published_at' => null,
        ':created_at' => $now,
        ':updated_at' => $now
    ));
    $contentId = (int)$pdo->lastInsertId();

    if ($autoGeneratePath) {
        $menuSlug = resolve_first_non_home_menu_slug($menuIds, $domainId);
        $finalPath = $menuSlug . '/' . time() . '/' . $contentId;
        $finalSlug = (string)$contentId;
        $upd = $pdo->prepare('UPDATE contents SET path = :path, slug = :slug WHERE id = :id');
        $upd->execute(array(':path' => $finalPath, ':slug' => $finalSlug, ':id' => $contentId));
    }

    if (empty($menuIds)) {
        $homeMenuId = ensure_default_home_menu_exists($domainId);
        if ($homeMenuId > 0 && $isHomepageTemplate) {
            $menuIds = array($homeMenuId);
        }
    }
    replace_content_menu_relations($contentId, $menuIds);
    sync_content_tags($contentId, $domainId, $seoKeywords);
    return $contentId;
}

function list_enabled_article_template_options()
{
    return array_values(array_filter(list_enabled_template_options(), function ($item) {
        return isset($item['type']) && (string)$item['type'] === 'article';
    }));
}

function parse_article_keywords_text($text)
{
    $text = str_replace(array("\r\n", "\r"), "\n", (string)$text);
    $rows = explode("\n", $text);
    $keywords = array();
    foreach ($rows as $row) {
        $parts = preg_split('/[，,；;｜|]+/u', (string)$row);
        if (!is_array($parts)) {
            $parts = array($row);
        }
        foreach ($parts as $part) {
            $keyword = trim((string)$part);
            if ($keyword === '') {
                continue;
            }
            if (!in_array($keyword, $keywords, true)) {
                $keywords[] = $keyword;
            }
        }
    }
    return $keywords;
}

function normalize_article_public_path($slug, $pathPrefix)
{
    $slug = normalize_site_build_slug($slug);
    if ($slug === '') {
        $slug = 'article-' . date('YmdHis');
    }
    $prefix = trim((string)$pathPrefix, " /\\");
    if ($prefix === '') {
        return $slug;
    }
    return $prefix . '/' . $slug;
}

function generate_unique_content_path($domainId, $path)
{
    $domainId = (int)$domainId;
    $path = normalize_content_path($path);
    if ($path === '') {
        throw new Exception('文章访问路径为空。');
    }
    $candidate = $path;
    $suffix = 2;
    while (find_published_content_by_domain_and_path($domainId, $candidate) || find_content_by_domain_and_path_any_status($domainId, $candidate)) {
        $candidate = $path . '-' . $suffix;
        $suffix++;
    }
    return $candidate;
}

function find_content_by_domain_and_path_any_status($domainId, $path)
{
    ensure_content_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT * FROM contents WHERE domain_id = :domain_id AND path = :path LIMIT 1');
    $stmt->execute(array(
        ':domain_id' => (int)$domainId,
        ':path' => normalize_content_path($path)
    ));
    return $stmt->fetch(PDO::FETCH_ASSOC);
}

function build_article_generation_messages($domain, $template, $keyword, $customPrompt, $articleLength)
{
    $domainName = isset($domain['domain']) ? (string)$domain['domain'] : '';
    $siteName = isset($domain['site_name']) ? trim((string)$domain['site_name']) : '';
    $siteLanguage = $forcedSiteLanguage !== '' ? normalize_domain_language($forcedSiteLanguage) : domain_site_language($domain);
    $isEnglish = ($siteLanguage === 'en-US' || $siteLanguage === 'en-GB');
    if ($siteLanguage === 'zh-CN' && isset($domain['site_language']) && trim((string)$domain['site_language']) !== '') {
        $siteLanguage = normalize_domain_language($domain['site_language']);
        $isEnglish = ($siteLanguage === 'en-US' || $siteLanguage === 'en-GB');
    }
    if ($siteLanguage === 'zh-CN' && isset($data['site_language']) && trim((string)$data['site_language']) !== '') {
        $candidateLang = normalize_domain_language($data['site_language']);
        if ($candidateLang !== 'zh-CN') {
            $siteLanguage = $candidateLang;
            $isEnglish = ($siteLanguage === 'en-US' || $siteLanguage === 'en-GB');
        }
    }
    $lengthText = $articleLength !== '' ? $articleLength : '1200-1800字';
    $systemPrompt = $isEnglish
        ? 'You are a professional SEO content editor. Write an article that fits an enterprise website and is based on the given keyword. Return JSON only, without explanations. The JSON structure must be: {"title":"","seo_title":"","seo_keywords":"","seo_description":"","excerpt":"","content_html":""}. content_html must be complete HTML suitable for injection into {{content}}, include at least 3 <h2> sections, an introduction, and a conclusion. Keep the title natural and avoid exaggerated marketing terms. seo_description must be 120-160 English words and must not exceed 160 words.'
        : '你是一名中文 SEO 内容编辑，请围绕给定关键词输出一篇适合企业站文章模板发布的内容。'
        . '请严格返回 JSON 对象，不要附加解释。'
        . 'JSON 结构必须为：{"title":"","seo_title":"","seo_keywords":"","seo_description":"","excerpt":"","content_html":""}。'
        . 'content_html 必须是可直接注入模板 {{content}} 的完整 HTML 内容，至少包含 3 个 <h2> 小节，并包含开头导语和结尾总结。'
        . '标题要自然，避免夸张营销词。seo_description 必须严格控制在 120-160 个中文字符以内，绝对不能超过 160 个字符，这是硬性限制。';
    $userPrompt = '站点：' . ($siteName !== '' ? $siteName : $domainName)
        . "\n站点语言：" . $siteLanguage
        . "\n域名：" . $domainName
        . "\n关键词：" . $keyword
        . "\n目标篇幅：" . $lengthText
        . "\n文章模板：" . (isset($template['name']) ? (string)$template['name'] : '')
        . "\n附加要求：" . ($customPrompt !== '' ? $customPrompt : ($isEnglish ? 'Write professional, readable content suitable for search engines, avoid filler text.' : '文章专业、可读、适合搜索引擎收录，避免空洞套话。'));
    return array(
        array('role' => 'system', 'content' => $systemPrompt),
        array('role' => 'user', 'content' => $userPrompt)
    );
}

function parse_article_generation_result($assistantText)
{
    $jsonText = extract_json_block_from_text($assistantText);
    if ($jsonText === '') {
        throw new Exception('AI 返回结果中未找到文章 JSON。');
    }
    $data = json_decode($jsonText, true);
    if (!is_array($data)) {
        throw new Exception('文章生成 JSON 解析失败。');
    }
    return $data;
}

function ensure_article_cover_upload_dir()
{
    $relativeDir = 'uploads/article-covers/' . date('Ym');
    $absoluteDir = __DIR__ . '/../' . $relativeDir;
    if (!is_dir($absoluteDir)) {
        @mkdir($absoluteDir, 0777, true);
    }
    if (!is_dir($absoluteDir)) {
        throw new Exception('无法创建文章封面目录。');
    }
    return array($relativeDir, $absoluteDir);
}

function article_cover_random_palette()
{
    $palettes = array(
        array(array(59, 130, 246), array(29, 78, 216), array(239, 246, 255)),
        array(array(16, 185, 129), array(5, 150, 105), array(236, 253, 245)),
        array(array(249, 115, 22), array(194, 65, 12), array(255, 247, 237)),
        array(array(139, 92, 246), array(109, 40, 217), array(245, 243, 255))
    );
    return $palettes[array_rand($palettes)];
}

function article_cover_random_shapes($width, $height, $palette)
{
    $count = random_int(3, 5);
    $shapes = array();
    $edges = array('top', 'right', 'bottom', 'left');
    for ($i = 0; $i < $count; $i++) {
        $edge = $edges[array_rand($edges)];
        $diameter = random_int((int)($width * 0.10), (int)($width * 0.24));
        $alpha = random_int(72, 102);
        $color = array(
            min(255, max(0, $palette[2][0] + random_int(-18, 18))),
            min(255, max(0, $palette[2][1] + random_int(-18, 18))),
            min(255, max(0, $palette[2][2] + random_int(-18, 18))),
            $alpha
        );
        if ($edge === 'top') {
            $cx = random_int((int)($diameter * 0.35), max((int)($diameter * 0.35), $width - (int)($diameter * 0.35)));
            $cy = random_int(-(int)($diameter * 0.20), (int)($height * 0.18));
        } elseif ($edge === 'right') {
            $cx = random_int((int)($width * 0.82), $width + (int)($diameter * 0.20));
            $cy = random_int((int)($diameter * 0.35), max((int)($diameter * 0.35), $height - (int)($diameter * 0.35)));
        } elseif ($edge === 'bottom') {
            $cx = random_int((int)($diameter * 0.35), max((int)($diameter * 0.35), $width - (int)($diameter * 0.35)));
            $cy = random_int((int)($height * 0.82), $height + (int)($diameter * 0.20));
        } else {
            $cx = random_int(-(int)($diameter * 0.20), (int)($width * 0.18));
            $cy = random_int((int)($diameter * 0.35), max((int)($diameter * 0.35), $height - (int)($diameter * 0.35)));
        }
        $shapes[] = array(
            'x' => $cx,
            'y' => $cy,
            'diameter' => $diameter,
            'color' => $color
        );
    }
    return $shapes;
}

function wrap_article_cover_text($title, $maxCharsPerLine)
{
    $title = trim((string)$title);
    if ($title === '') {
        return array('AI文章');
    }
    $maxCharsPerLine = max(6, (int)$maxCharsPerLine);
    if (function_exists('mb_strlen') && function_exists('mb_substr')) {
        $length = mb_strlen($title, 'UTF-8');
        $lines = array();
        for ($i = 0; $i < $length; $i += $maxCharsPerLine) {
            $lines[] = mb_substr($title, $i, $maxCharsPerLine, 'UTF-8');
        }
        return $lines;
    }
    return str_split($title, $maxCharsPerLine);
}

function article_cover_font_candidates()
{
    return array(
        // 项目内置字体
        __DIR__ . '/../assets/fonts/msyh.ttf',
        __DIR__ . '/../assets/fonts/msyh.ttc',
        __DIR__ . '/../assets/fonts/simhei.ttf',
        __DIR__ . '/../assets/fonts/simsun.ttc',
        // Windows 系统字体（支持中文、日文、韩文）
        'C:/Windows/Fonts/msyh.ttc',
        'C:/Windows/Fonts/msyh.ttf',
        'C:/Windows/Fonts/meiryo.ttc',
        'C:/Windows/Fonts/meiryo.ttf',
        'C:/Windows/Fonts/malgun.ttf',
        'C:/Windows/Fonts/simhei.ttf',
        'C:/Windows/Fonts/simsun.ttc',
        'C:/Windows/Fonts/simsun.ttc,0',
        // Linux 系统字体（支持多语言）
        '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
        '/usr/share/fonts/opentype/noto/NotoSans-Regular.ttf',
        '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
        // macOS 系统字体
        '/Library/Fonts/Microsoft YaHei.ttf',
        '/System/Library/Fonts/Hiragino Sans GB.ttc'
    );
}

function detect_text_language($text)
{
    // 检测文本中的语言特征
    $text = (string)$text;
    
    // 检测韩文（Hangul）- 使用 Unicode 范围
    if (preg_match('/[\uAC00-\uD7AF]/u', $text)) {
        return 'korean';
    }
    
    // 检测日文（Hiragana, Katakana, Kanji）
    if (preg_match('/[\u3040-\u309F\u30A0-\u30FF]/u', $text)) {
        return 'japanese';
    }
    
    // 检测中文（CJK）- 包括日文汉字
    if (preg_match('/[\u4E00-\u9FFF\u3400-\u4DBF]/u', $text)) {
        return 'chinese';
    }
    
    return 'default';
}

function resolve_article_cover_font_path($text = '')
{
    $language = detect_text_language($text);
    $candidates = article_cover_font_candidates();
    
    // 优先级排序：根据检测到的语言调整字体顺序
    $prioritized = array();
    
    if ($language === 'korean') {
        // 韩文优先使用 Malgun Gothic（Windows）或 Noto Sans CJK
        $prioritized = array(
            'C:/Windows/Fonts/malgun.ttf',
            'C:/Windows/Fonts/msyh.ttc',
            'C:/Windows/Fonts/msyh.ttf',
            '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
            __DIR__ . '/../assets/fonts/msyh.ttc',
            __DIR__ . '/../assets/fonts/msyh.ttf',
        );
    } elseif ($language === 'japanese') {
        // 日文优先使用 Meiryo（Windows）或 Noto Sans CJK
        $prioritized = array(
            'C:/Windows/Fonts/meiryo.ttc',
            'C:/Windows/Fonts/meiryo.ttf',
            'C:/Windows/Fonts/msyh.ttc',
            '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
            __DIR__ . '/../assets/fonts/msyh.ttc',
        );
    } else {
        // 中文或默认，使用原有顺序
        $prioritized = $candidates;
    }
    
    // 先尝试优先级列表
    foreach ($prioritized as $candidate) {
        if (strpos($candidate, ',') !== false) {
            $parts = explode(',', $candidate, 2);
            if (isset($parts[0]) && is_file($parts[0])) {
                return $candidate;
            }
            continue;
        }
        if (is_file($candidate)) {
            return $candidate;
        }
    }
    
    // 如果优先级列表中没有找到，再尝试其他候选
    foreach ($candidates as $candidate) {
        if (in_array($candidate, $prioritized, true)) {
            continue; // 已经尝试过了
        }
        if (strpos($candidate, ',') !== false) {
            $parts = explode(',', $candidate, 2);
            if (isset($parts[0]) && is_file($parts[0])) {
                return $candidate;
            }
            continue;
        }
        if (is_file($candidate)) {
            return $candidate;
        }
    }
    
    return '';
}

function article_cover_contains_non_ascii($text)
{
    return preg_match('/[^\x20-\x7E]/', (string)$text) === 1;
}

function generate_article_cover_image($title, $options)
{
    if (!function_exists('imagecreatetruecolor')) {
        return '';
    }
    list($relativeDir, $absoluteDir) = ensure_article_cover_upload_dir();
    $width = isset($options['width']) ? max(600, (int)$options['width']) : 1200;
    $height = isset($options['height']) ? max(320, (int)$options['height']) : 630;
    $filename = 'cover-' . date('YmdHis') . '-' . bin2hex(random_bytes(4)) . '.png';
    $absolutePath = $absoluteDir . '/' . $filename;
    $palette = article_cover_random_palette();

    $image = imagecreatetruecolor($width, $height);
    imagesavealpha($image, true);
    $bg1 = imagecolorallocate($image, $palette[0][0], $palette[0][1], $palette[0][2]);
    $bg2 = imagecolorallocate($image, $palette[1][0], $palette[1][1], $palette[1][2]);
    $textColor = imagecolorallocate($image, 255, 255, 255);
    $subTextColor = imagecolorallocatealpha($image, 255, 255, 255, 28);

    for ($y = 0; $y < $height; $y++) {
        $ratio = $height > 1 ? $y / ($height - 1) : 0;
        $r = (int)round($palette[0][0] + (($palette[1][0] - $palette[0][0]) * $ratio));
        $g = (int)round($palette[0][1] + (($palette[1][1] - $palette[0][1]) * $ratio));
        $b = (int)round($palette[0][2] + (($palette[1][2] - $palette[0][2]) * $ratio));
        $lineColor = imagecolorallocate($image, $r, $g, $b);
        imageline($image, 0, $y, $width, $y, $lineColor);
    }

    $shapes = article_cover_random_shapes($width, $height, $palette);
    foreach ($shapes as $shape) {
        $shapeColor = imagecolorallocatealpha($image, $shape['color'][0], $shape['color'][1], $shape['color'][2], $shape['color'][3]);
        imagefilledellipse($image, (int)$shape['x'], (int)$shape['y'], (int)$shape['diameter'], (int)$shape['diameter'], $shapeColor);
    }

    $font = resolve_article_cover_font_path($title);
    $textLanguage = detect_text_language($title);
    $lines = wrap_article_cover_text($title, isset($options['max_chars_per_line']) ? (int)$options['max_chars_per_line'] : 12);
    $lineHeight = 54;
    $startY = (int)(($height - (count($lines) * $lineHeight)) / 2);
    
    // 对于韩文、日文等复杂文字系统，GD 库支持不佳，使用 fallback
    // 只有在字体存在且文本为简单 ASCII 或中文时才使用 TTF 字体
    $useTTF = function_exists('imagettftext') && $font !== '' && ($textLanguage === 'chinese' || $textLanguage === 'default');
    
    if ($useTTF) {
        foreach ($lines as $index => $line) {
            $fontSize = 28;
            $box = imagettfbbox($fontSize, 0, $font, $line);
            $textWidth = abs((int)$box[4] - (int)$box[0]);
            $x = (int)(($width - $textWidth) / 2);
            $y = $startY + ($index * $lineHeight) + 30;
            imagettftext($image, $fontSize, 0, $x, $y, $textColor, $font, $line);
        }
        $watermarkEnabled = isset($options['watermark']) ? (bool)$options['watermark'] : true;
        if ($watermarkEnabled) {
            $watermarkText = isset($options['watermark_text']) && trim((string)$options['watermark_text']) !== '' ? trim((string)$options['watermark_text']) : 'BeeAI 自动生成封面';
            imagettftext($image, 14, 0, 42, $height - 42, $subTextColor, $font, $watermarkText);
        }
    } else {
        // Fallback：使用系统内置字体
        // 对于韩文、日文等，显示英文提示 + 日期
        $fallbackLines = ($textLanguage === 'korean' || $textLanguage === 'japanese')
            ? array('AI ARTICLE', date('Y-m-d'))
            : (article_cover_contains_non_ascii($title) ? array('AI ARTICLE', date('Y-m-d')) : $lines);
        
        foreach ($fallbackLines as $index => $line) {
            $x = 36;
            if (function_exists('imagefontwidth')) {
                $textWidth = imagefontwidth(5) * strlen($line);
                $x = max(36, (int)(($width - $textWidth) / 2));
            }
            imagestring($image, 5, $x, $startY + ($index * 26), $line, $textColor);
        }
    }

    imagepng($image, $absolutePath);
    imagedestroy($image);
    return $relativeDir . '/' . $filename;
}

function create_article_build_from_ai($data, $progressCallback = null)
{
    require_license_guard('AI文章生成', false, 'ai_generate');
    ensure_content_storage_ready();
    $domainIds = isset($data['domain_ids']) && is_array($data['domain_ids']) ? array_values(array_unique(array_map('intval', $data['domain_ids']))) : array();
    $templateId = isset($data['template_id']) ? (int)$data['template_id'] : 0;
    $modelId = isset($data['model_id']) ? (int)$data['model_id'] : 0;
    $customPrompt = isset($data['custom_prompt']) ? trim((string)$data['custom_prompt']) : '';
    $keywords = parse_article_keywords_text(isset($data['keywords_text']) ? (string)$data['keywords_text'] : '');
    $articleLength = isset($data['article_length']) ? trim((string)$data['article_length']) : '1200-1800字';
    $pathPrefix = isset($data['path_prefix']) ? trim((string)$data['path_prefix']) : 'article';
    $publishNow = !empty($data['publish_now']);
    $generateImage = !empty($data['generate_image']);
    $intervalSeconds = isset($data['interval_seconds']) ? max(0, min(30, (int)$data['interval_seconds'])) : 0;
    $remark = isset($data['remark']) ? trim((string)$data['remark']) : '';

    if (is_callable($progressCallback)) {
        call_user_func($progressCallback, 1, '正在校验文章生成参数', array());
    }
    if (empty($domainIds)) {
        throw new Exception('请至少选择一个域名。');
    }
    if ($templateId <= 0) {
        ensure_domains_table_has_template_pack();
        $allHavePack = true;
        foreach ($domainIds as $did) {
            $d = find_domain_by_id((int)$did);
            if (!$d || domain_template_pack($d) === '') {
                $allHavePack = false;
                break;
            }
        }
        if (!$allHavePack) {
            throw new Exception('请选择文章模板，或为所有执行域名绑定模板套件。');
        }
    }
    if ($modelId <= 0) {
        throw new Exception('请选择 AI 模型。');
    }
    if (empty($keywords)) {
        throw new Exception('请至少输入一个关键词。');
    }

    $template = null;
    if ($templateId > 0) {
        $template = find_template_by_id($templateId);
        if (!$template || !isset($template['type']) || (string)$template['type'] !== 'article') {
            throw new Exception('请选择可用的文章模板。');
        }
    }
    $model = find_ai_model_by_id($modelId);
    if (!$model || !isset($model['status']) || $model['status'] !== 'enabled') {
        throw new Exception('当前 AI 模型未启用，无法生成文章。');
    }

    $domains = array();
    foreach ($domainIds as $domainId) {
        if ($domainId <= 0) {
            continue;
        }
        $domain = find_domain_by_id($domainId);
        if (!$domain || !isset($domain['status']) || $domain['status'] !== 'enabled') {
            continue;
        }
        $domains[] = $domain;
    }
    if (empty($domains)) {
        throw new Exception('未找到可用域名，请检查域名状态。');
    }

    if (is_callable($progressCallback)) {
        call_user_func($progressCallback, 2, '已完成文章模板、域名与 AI 模型检查', array(
            'domain_count' => count($domains),
            'keyword_count' => count($keywords)
        ));
    }

    $createdItems = array();
    $previewHtml = '';
    $lastAiResult = null;
    $lastGenerated = null;
    $total = count($domains) * count($keywords);
    $current = 0;

    foreach ($domains as $domain) {
        foreach ($keywords as $keyword) {
            $current++;
            if (is_callable($progressCallback)) {
                call_user_func($progressCallback, 3, '正在生成文章：' . $keyword . '（' . $current . '/' . $total . '）', array(
                    'domain' => isset($domain['domain']) ? $domain['domain'] : '',
                    'keyword' => $keyword,
                    'current' => $current,
                    'total' => $total
                ));
            }

            $messages = build_article_generation_messages($domain, $template, $keyword, $customPrompt, $articleLength);
            $aiResult = execute_ai_generation_request($model, $messages);
            $generated = parse_article_generation_result($aiResult['assistant_text']);
            $title = isset($generated['title']) ? trim((string)$generated['title']) : '';
            $seoTitle = isset($generated['seo_title']) ? trim((string)$generated['seo_title']) : '';
            $seoKeywords = normalize_ai_keywords(isset($generated['seo_keywords']) ? $generated['seo_keywords'] : '', $keyword);
            $seoDescription = normalize_ai_description(isset($generated['seo_description']) ? $generated['seo_description'] : '', array(
                isset($generated['excerpt']) ? $generated['excerpt'] : '',
                isset($generated['content_html']) ? strip_tags((string)$generated['content_html']) : '',
                $keyword
            ), 120);
            $excerpt = isset($generated['excerpt']) ? trim((string)$generated['excerpt']) : '';
            $contentHtml = isset($generated['content_html']) ? trim((string)$generated['content_html']) : '';
            if ($title === '') {
                $title = $keyword . '：实用指南';
            }
            if ($seoTitle === '') {
                $seoTitle = $title;
            }
            if ($contentHtml === '') {
                throw new Exception('AI 未生成正文内容。');
            }

            $slugBase = normalize_site_build_slug($title);
            $pathValue = generate_unique_content_path((int)$domain['id'], normalize_article_public_path($slugBase, $pathPrefix));
            $coverImage = '';
            if ($generateImage) {
                if (is_callable($progressCallback)) {
                    call_user_func($progressCallback, 4, '正在生成文章封面图：' . $title, array(
                        'title' => $title,
                        'current' => $current,
                        'total' => $total
                    ));
                }
                $manualCoverOpts = array('width' => 1200, 'height' => 630);
                if (isset($data['cover_watermark'])) {
                    $manualCoverOpts['watermark'] = !empty($data['cover_watermark']);
                }
                if (isset($data['cover_watermark_text']) && trim((string)$data['cover_watermark_text']) !== '') {
                    $manualCoverOpts['watermark_text'] = trim((string)$data['cover_watermark_text']);
                } elseif (!empty($manualCoverOpts['watermark']) && isset($domain['site_name']) && trim((string)$domain['site_name']) !== '') {
                    $manualCoverOpts['watermark_text'] = trim((string)$domain['site_name']);
                }
                $coverImage = generate_article_cover_image($title, $manualCoverOpts);
            }

            $coverImageUrl = normalize_public_asset_url($coverImage);
            $renderContent = $coverImageUrl !== ''
                ? '<p><img src="' . e($coverImageUrl) . '" alt="' . e($title) . '" style="max-width:100%; height:auto; border-radius:12px;"></p>' . "\n" . $contentHtml
                : $contentHtml;

            if (is_callable($progressCallback)) {
                call_user_func($progressCallback, 5, '正在写入文章内容：' . $title, array(
                    'title' => $title,
                    'path' => content_public_url_path($pathValue),
                    'current' => $current,
                    'total' => $total
                ));
            }

            $contentId = create_content(array(
                'title' => $title,
                'slug' => content_slug_from_path($pathValue),
                'path' => $pathValue,
                'domain_id' => (int)$domain['id'],
                'template_id' => $templateId,
                'seo_title' => $seoTitle,
                'seo_keywords' => $seoKeywords,
                'seo_description' => $seoDescription,
                'content_body' => $renderContent,
                'template_vars' => array(
                    '{{title}}' => $title,
                    '{{content}}' => $renderContent,
                    '{{cover_image}}' => $coverImageUrl,
                    '{{excerpt}}' => $excerpt
                ),
                'source_type' => 'article_ai',
                'source_keyword' => $keyword,
                'cover_image' => $coverImageUrl,
                'menu_ids' => isset($data['target_menu_ids']) ? parse_target_menu_ids($data['target_menu_ids']) : (isset($data['target_menu_id']) && (int)$data['target_menu_id'] > 0 ? array((int)$data['target_menu_id']) : array()),
                'status' => $publishNow ? 'published' : 'draft',
                'remark' => $remark !== '' ? $remark : 'AI文章生成'
            ));

            $renderVariables = array_merge(default_template_vars_for_type('article', array(
                'site_name' => isset($domain['site_name']) ? (string)$domain['site_name'] : '',
                'domain' => isset($domain['domain']) ? (string)$domain['domain'] : '',
                'path' => content_public_url_path($pathValue)
            )), array(
                '{{site_name}}' => isset($domain['site_name']) ? (string)$domain['site_name'] : '',
                '{{site_logo}}' => build_site_logo_html($domain),
                '{{site_logo_url}}' => isset($domain['logo_url']) ? trim((string)$domain['logo_url']) : '',
                '{{domain}}' => isset($domain['domain']) ? (string)$domain['domain'] : '',
                '{{title}}' => $title,
                '{{seo_title}}' => $seoTitle,
                '{{seo_keywords}}' => $seoKeywords,
                '{{seo_description}}' => $seoDescription,
                '{{content}}' => $renderContent,
                '{{path}}' => content_public_url_path($pathValue),
                '{{year}}' => date('Y'),
                '{{cover_image}}' => $coverImageUrl,
                '{{excerpt}}' => $excerpt
            ));
            $previewHtml = render_template_html(isset($template['html_content']) ? $template['html_content'] : '', isset($template['css_content']) ? $template['css_content'] : '', $renderVariables);

            $createdItems[] = array(
                'content_id' => $contentId,
                'domain_id' => (int)$domain['id'],
                'domain' => isset($domain['domain']) ? (string)$domain['domain'] : '',
                'keyword' => $keyword,
                'title' => $title,
                'path' => content_public_url_path($pathValue),
                'status' => $publishNow ? 'published' : 'draft',
                'cover_image' => $coverImageUrl,
                'seo_title' => $seoTitle,
                'seo_description' => $seoDescription
            );
            $lastAiResult = $aiResult;
            $lastGenerated = $generated;

            if ($intervalSeconds > 0 && $current < $total) {
                sleep($intervalSeconds);
            }
        }
    }

    if ($publishNow) {
        foreach ($domains as $domain) {
            publish_domain_static((int)$domain['id']);
        }
    }

    return array(
        'items' => $createdItems,
        'content_id' => !empty($createdItems) ? (int)$createdItems[count($createdItems) - 1]['content_id'] : 0,
        'content_data' => !empty($createdItems) ? $createdItems[count($createdItems) - 1] : array(),
        'domain' => !empty($domains) ? $domains[count($domains) - 1] : array(),
        'template' => $template,
        'model' => $model,
        'generated_data' => is_array($lastGenerated) ? $lastGenerated : array(),
        'ai_result' => is_array($lastAiResult) ? $lastAiResult : array(),
        'preview_html' => $previewHtml,
        'publish_summary' => null,
        'result_summary' => array(
            'created_count' => count($createdItems),
            'domain_count' => count($domains),
            'keyword_count' => count($keywords)
        )
    );
}

function build_article_build_result_snapshot($result)
{
    $items = isset($result['items']) && is_array($result['items']) ? $result['items'] : array();
    $lastItem = !empty($items) ? $items[count($items) - 1] : array();
    return array(
        'content_id' => isset($lastItem['content_id']) ? (int)$lastItem['content_id'] : 0,
        'content_data' => $lastItem,
        'items' => $items,
        'domain' => isset($result['domain']) && is_array($result['domain']) ? array(
            'id' => isset($result['domain']['id']) ? (int)$result['domain']['id'] : 0,
            'domain' => isset($result['domain']['domain']) ? (string)$result['domain']['domain'] : '',
            'site_name' => isset($result['domain']['site_name']) ? (string)$result['domain']['site_name'] : ''
        ) : array(),
        'template' => isset($result['template']) && is_array($result['template']) ? array(
            'id' => isset($result['template']['id']) ? (int)$result['template']['id'] : 0,
            'name' => isset($result['template']['name']) ? (string)$result['template']['name'] : '',
            'code' => isset($result['template']['code']) ? (string)$result['template']['code'] : '',
            'type' => 'article'
        ) : array(),
        'ai_result' => array(
            'http_code' => isset($result['ai_result']['http_code']) ? (int)$result['ai_result']['http_code'] : 0,
            'assistant_text' => isset($result['ai_result']['assistant_text']) ? (string)$result['ai_result']['assistant_text'] : '',
            'response_body' => isset($result['ai_result']['response_body']) ? (string)$result['ai_result']['response_body'] : ''
        ),
        'generated_data' => isset($result['generated_data']) && is_array($result['generated_data']) ? $result['generated_data'] : array(),
        'preview_html' => isset($result['preview_html']) ? (string)$result['preview_html'] : '',
        'summary' => array(
            'created_count' => count($items),
            'last_title' => isset($lastItem['title']) ? (string)$lastItem['title'] : '',
            'last_path' => isset($lastItem['path']) ? (string)$lastItem['path'] : ''
        )
    );
}

function update_content($id, $data)
{
    ensure_content_storage_ready();
    ensure_menu_storage_ready();
    ensure_tag_storage_ready();
    $existing = find_content_by_id((int)$id);
    if (!$existing) {
        throw new Exception('未找到对应的内容。');
    }

    $title = isset($data['title']) ? trim((string)$data['title']) : '';
    $path = isset($data['path']) ? trim((string)$data['path']) : '';
    $slug = isset($data['slug']) ? trim((string)$data['slug']) : '';
    $domainId = isset($data['domain_id']) ? (int)$data['domain_id'] : 0;
    $templateId = isset($data['template_id']) ? (int)$data['template_id'] : 0;
    if ($templateId <= 0 && isset($existing['template_id']) && (int)$existing['template_id'] > 0) {
        $templateId = (int)$existing['template_id'];
    }
    $seoTitle = isset($data['seo_title']) ? trim((string)$data['seo_title']) : '';
    $seoKeywords = isset($data['seo_keywords']) ? trim((string)$data['seo_keywords']) : '';
    $seoDescription = isset($data['seo_description']) ? trim((string)$data['seo_description']) : '';
    $contentBody = isset($data['content_body']) ? (string)$data['content_body'] : '';
    $templateVarsJson = array_key_exists('template_vars', $data) ? encode_template_vars_json($data['template_vars']) : (isset($existing['template_vars_json']) ? (string)$existing['template_vars_json'] : '');
    $menuIds = isset($data['menu_ids']) && is_array($data['menu_ids']) ? array_values(array_unique(array_map('intval', $data['menu_ids']))) : null;
    $pageTemplatePack = array_key_exists('page_template_pack', $data) ? trim((string)$data['page_template_pack']) : (isset($existing['page_template_pack']) ? (string)$existing['page_template_pack'] : '');
    $siteLanguage = isset($data['site_language']) ? normalize_domain_language($data['site_language']) : (isset($existing['site_language']) ? (string)$existing['site_language'] : 'zh-CN');
    $status = isset($data['status']) ? trim((string)$data['status']) : 'draft';
    $remark = isset($data['remark']) ? trim((string)$data['remark']) : '';

    if ($title === '') {
        throw new Exception('请填写内容标题。');
    }
    if ($path === '') {
        $path = $slug;
    }
    $path = normalize_content_path($path);
    if ($path === '') {
        throw new Exception('请填写访问路径。');
    }
    $slug = content_slug_from_path($path);
    if ($slug === '') {
        throw new Exception('访问路径格式无效。');
    }
    if ($domainId <= 0) {
        throw new Exception('请选择绑定域名。');
    }
    $isHomepagePath = in_array($path, array('index', 'home', '/'), true);
    if ($templateId <= 0 && !$isHomepagePath) {
        // 允许使用域名已绑定的模板套件，无需为单篇内容选择模板
        $domainForPack = find_domain_by_id($domainId);
        $domainPackCode = $domainForPack ? domain_template_pack($domainForPack) : '';
        if ($domainPackCode === '') {
            throw new Exception('请选择模板。');
        }
    }
    $isHomepageTemplate = $templateId > 0 ? is_homepage_template_id($templateId) : $isHomepagePath;
    if ($isHomepageTemplate && ($path === '' || $path === 'index')) {
        $path = 'index';
        $slug = 'index';
    }
    if (!$isHomepageTemplate && $contentBody === '') {
        throw new Exception('请填写正文内容。');
    }

    $statusOptions = content_status_options();
    if (!isset($statusOptions[$status])) {
        throw new Exception('内容状态值无效。');
    }

    $pdo = db_connection();
    $check = $pdo->prepare('SELECT id FROM contents WHERE domain_id = :domain_id AND path = :path AND id <> :id LIMIT 1');
    $check->execute(array(':domain_id' => $domainId, ':path' => $path, ':id' => (int)$id));
    if ($check->fetch()) {
        throw new Exception('访问路径 /' . $path . ' 已被其他内容使用。');
    }

    // 自动同步封面图，优先使用人工输入的 cover_image（可清空），否则根据正文首图提取。
    $postedCover = array_key_exists('cover_image', $data) ? trim((string)$data['cover_image']) : null; // null 表示未提交该字段
    $finalCover = '';
    if ($postedCover !== null) {
        // 人工优先：空字符串表示显式清空
        if ($postedCover !== '') {
            $norm = normalize_public_asset_url($postedCover);
            $finalCover = $norm !== '' ? $norm : $postedCover;
        } else {
            $finalCover = '';
        }
    } else {
        // 未人工提交时，自动从正文首图提取
        if ($contentBody !== '' && preg_match('/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $contentBody, $m)) {
            $auto = isset($m[1]) ? trim((string)$m[1]) : '';
            if ($auto !== '') {
                $norm = normalize_public_asset_url($auto);
                $finalCover = $norm !== '' ? $norm : $auto;
            }
        }
    }
    // 同步模板变量中的 {{cover_image}}
    $tplVars = decode_template_vars_json($templateVarsJson);
    if (!is_array($tplVars)) { $tplVars = array(); }
    if ($finalCover !== '') {
        $tplVars['{{cover_image}}'] = $finalCover;
    } else {
        if (isset($tplVars['{{cover_image}}'])) { unset($tplVars['{{cover_image}}']); }
    }
    $templateVarsJson = encode_template_vars_json($tplVars);

    $stmt = $pdo->prepare('UPDATE contents SET title=:title, slug=:slug, path=:path, domain_id=:domain_id, template_id=:template_id, seo_title=:seo_title, seo_keywords=:seo_keywords, seo_description=:seo_description, content_body=:content_body, template_vars_json=:template_vars_json, cover_image=:cover_image, page_template_pack=:page_template_pack, site_language=:site_language, status=:status, remark=:remark, static_file_path=:static_file_path, static_published_at=:static_published_at, updated_at=:updated_at WHERE id=:id LIMIT 1');
    $stmt->execute(array(
        ':title' => $title,
        ':slug' => $slug,
        ':path' => $path,
        ':domain_id' => $domainId,
        ':template_id' => $templateId,
        ':seo_title' => $seoTitle,
        ':seo_keywords' => $seoKeywords,
        ':seo_description' => $seoDescription,
        ':content_body' => $contentBody,
        ':template_vars_json' => $templateVarsJson !== '' ? $templateVarsJson : null,
        ':cover_image' => $finalCover,
        ':page_template_pack' => $pageTemplatePack,
        ':site_language' => $siteLanguage,
        ':status' => $status,
        ':remark' => $remark,
        ':static_file_path' => '',
        ':static_published_at' => null,
        ':updated_at' => date('Y-m-d H:i:s'),
        ':id' => (int)$id
    ));
    if ($menuIds !== null) {
        replace_content_menu_relations((int)$id, $menuIds);
    }
    sync_content_tags((int)$id, $domainId, $seoKeywords);
    return true;
}

function delete_content($id)
{
    ensure_content_storage_ready();
    ensure_menu_storage_ready();
    ensure_tag_storage_ready();
    $existing = find_content_by_id((int)$id);
    if ($existing) {
        remove_content_static($existing);
    }
    $pdo = db_connection();
    $tagCleanup = $pdo->prepare('DELETE FROM content_tags WHERE content_id = :content_id');
    $tagCleanup->execute(array(':content_id' => (int)$id));
    $cleanup = $pdo->prepare('DELETE FROM content_menu_relations WHERE content_id = :content_id');
    $cleanup->execute(array(':content_id' => (int)$id));
    $stmt = $pdo->prepare('DELETE FROM contents WHERE id = :id LIMIT 1');
    $stmt->execute(array(':id' => (int)$id));
    return $stmt->rowCount() > 0;
}

function update_content_status($id, $status)
{
    ensure_content_storage_ready();
    $status = trim((string)$status);
    $statusOptions = content_status_options();
    if (!isset($statusOptions[$status])) {
        throw new Exception('内容状态值无效。');
    }

    $pdo = db_connection();
    $stmt = $pdo->prepare('UPDATE contents SET status = :status, updated_at = :updated_at WHERE id = :id LIMIT 1');
    $stmt->execute(array(
        ':status' => $status,
        ':updated_at' => date('Y-m-d H:i:s'),
        ':id' => (int)$id
    ));

    $content = find_content_by_id((int)$id);
    // 发布状态只更新 DB，不再自动触发静态化；由显式“静态发布”按钮/任务负责
    if ($content && $status !== 'published') {
        remove_content_static($content);
    }

    return $stmt->rowCount() > 0;
}

function extract_json_block_from_text($text)
{
    $text = trim((string)$text);
    if ($text === '') {
        return '';
    }

    // 去除模型可能输出的思考标签（Qwen3 等模型）
    $text = preg_replace('/<think>[\s\S]*?<\/think>/i', '', $text);
    $text = trim($text);

    $direct = json_decode($text, true);
    if (is_array($direct)) {
        return $text;
    }

    // 提取 markdown 代码块中的 JSON（对象或数组）
    if (preg_match('/```(?:json)?\s*([\{\[][\s\S]*?[\}\]])\s*```/', $text, $matches)) {
        return trim((string)$matches[1]);
    }

    // 提取 JSON 对象
    if (preg_match('/(\{(?:.|\r|\n)*\})/s', $text, $matches)) {
        return trim((string)$matches[1]);
    }

    // 提取 JSON 数组
    if (preg_match('/(\[(?:.|\r|\n)*\])/s', $text, $matches)) {
        return trim((string)$matches[1]);
    }

    return '';
}

function build_site_generation_messages($domain, $template, $keyword, $siteName, $templateVariables)
{
    $variableLines = array();
    foreach ($templateVariables as $key => $label) {
        $variableLines[] = $key . '：' . $label;
    }
    $siteLanguage = domain_site_language($domain);
    $langNote = $siteLanguage === 'en-US' || $siteLanguage === 'en-GB'
        ? '请全部使用自然、地道的英文输出，避免中文语序。'
        : '请全部使用简体中文输出。';

    $systemPrompt = '你是蜜蜂AI建站平台的批量建站生成助手。请基于关键词输出可直接用于模板渲染的结构化站点内容。必须严格返回 JSON，不要输出任何多余解释。';
    $userPrompt = "请为一个网站首页生成内容。\n"
        . '站点语言：' . $siteLanguage . "\n"
        . '域名：' . (isset($domain['domain']) ? (string)$domain['domain'] : '') . "\n"
        . '站点名称：' . $siteName . "\n"
        . '关键词：' . $keyword . "\n"
        . '模板名称：' . (isset($template['name']) ? (string)$template['name'] : '') . "\n"
        . '模板编码：' . (isset($template['code']) ? (string)$template['code'] : '') . "\n"
        . '模板可用变量：' . implode('；', $variableLines) . "\n"
        . $langNote . "\n"
        . '请返回如下 JSON 结构：{"title":"","seo_title":"","seo_keywords":"","seo_description":"","content":"","site_name":"","template_vars":{}}'
        . "\n其中 template_vars 用于补充模板变量，key 必须使用双大括号格式，例如 {{hero_title}}。content 字段请输出可直接渲染的 HTML。seo_keywords 请输出逗号分隔关键词。";

    return array(
        array('role' => 'system', 'content' => $systemPrompt),
        array('role' => 'user', 'content' => $userPrompt)
    );
}

function build_homepage_generation_messages($domain, $template, $keyword, $siteName, $siteDescription, $path, $templateSchema, $forcedSiteLanguage = '')
{
    $fieldLines = array();
    foreach ($templateSchema as $key => $meta) {
        $fieldLines[] = $key . '：' . (isset($meta['label']) ? (string)$meta['label'] : $key);
    }
    $siteLanguage = $forcedSiteLanguage !== '' ? normalize_domain_language($forcedSiteLanguage) : domain_site_language($domain);
    $isEnglish = ($siteLanguage === 'en-US' || $siteLanguage === 'en-GB');
    $isChinese = ($siteLanguage === 'zh-CN' || $siteLanguage === 'zh-TW');
    if ($isEnglish) {
        $langNote = 'All generated content must be written in natural, fluent English only. Do not output any Chinese text. If the keyword is in Chinese, translate it to English and use the English version.';
    } elseif ($isChinese) {
        $langNote = '所有生成内容必须使用简体中文，不要输出英文正文。';
    } else {
        $langNote = 'All generated content must be written in ' . $siteLanguage . ' only. Do not output any Chinese or English text unless it is a proper noun or brand name. If the keyword is in Chinese, translate it to the target language (' . $siteLanguage . ') and use the translated version.';
    }

    $langPrompts = array(
        'en-US' => array(
            'system' => 'You are the homepage field generator for the BeeAI website builder. Return JSON only for the homepage template fields. Do not output HTML page structure, CSS, JS, Markdown fences, or any explanation. You must write in English only.',
            'user_intro' => "Generate structured homepage field content for a website.\n",
            'requirements' => 'Requirements: keep template_vars limited to the fields above; fill all fields as completely as possible; do not return a content field; seo_keywords and keywords mean the same thing, seo_description and description mean the same thing; fill at least one pair and preferably make both consistent; keywords must be 3-5 items separated by English commas; description / seo_description must be 120-160 English words/characters?',
            'richness' => array(
                "- hero_stat_*_no: use persuasive metrics like '500+', '99.9%', '24h', '100k+' instead of 01/02/03;",
                "- hero_stat_*_title: 8-15 words/characters, use complete phrases;",
                "- hero_stat_*_desc: 20-40 words/characters, explain the value concretely;",
                "- trust_*_title / trust_*_desc: make the trust points concrete and specific;",
                "- service_*_title / service_*_desc: clearly describe the service;",
                "- feature_*_no / title / desc: make them specific and compelling;",
                "- panel_title / panel_desc / panel_item_*_title / panel_item_*_desc: avoid vague filler;",
                "- Footer text should not include ©, year, or site name because the template already adds them."
            ),
            'json_note' => 'Return exactly this JSON structure: {"site_name":"","title":"","seo_title":"","seo_keywords":"","seo_description":"","keywords":"","description":"","testimonials":[{"quote":"","name":"","role":""}],"template_vars":{}}'
        ),
        'ja-JP' => array(
            'system' => 'あなたは BeeAI 建站プラットフォームのホームページ項目生成アシスタントです。JSON のみを返し、HTML 構造、CSS、JS、Markdown、説明文を出力しないでください。すべての出力は日本語のみで行い、中国語や他の言語を混ぜないでください。',
            'user_intro' => "Webサイトのホームページ項目を構造化して生成してください。\n",
            'requirements' => '要件: template_vars には上記の項目だけを含めること。すべての項目を可能な限り埋めること。content フィールドは返さないこと。seo_keywords と keywords は同義、seo_description と description も同義です。少なくとも片方は必ず埋め、両方を揃えるのが望ましいです。keywords は 3〜5 件を英語カンマで区切ること。description / seo_description は 120〜160 文字程度の日本語にすること。すべてのテキストは日本語のみで作成し、中国語を混ぜないこと。',
            'richness' => array(
                "- hero_stat_*_no: '500+'、'99.9%'、'24h'、'100k+' のような説得力のある指標を使い、01/02/03 は使わないこと。",
                "- hero_stat_*_title: 8〜15 文字程度、完全なフレーズで書くこと。",
                "- hero_stat_*_desc: 20〜40 文字程度、価値を具体的に説明すること。",
                "- trust_*_title / trust_*_desc: 信頼ポイントを具体的かつ明確にすること。",
                "- service_*_title / service_*_desc: サービス内容を明確に書くこと。",
                "- feature_*_no / title / desc: 具体的で魅力的に書くこと。",
                "- panel_title / panel_desc / panel_item_*_title / panel_item_*_desc: 曖昧な表現を避けること。",
                "- footer_text: ©、年号、サイト名を含めないこと。"
            ),
            'json_note' => '次の JSON 形式を厳密に返してください: {"site_name":"","title":"","seo_title":"","seo_keywords":"","seo_description":"","keywords":"","description":"","testimonials":[{"quote":"","name":"","role":""}],"template_vars":{}}'
        ),
        'ko-KR' => array(
            'system' => '당신은 BeeAI 웹사이트 빌더의 홈페이지 필드 생성 도우미입니다. JSON만 반환하고 HTML 구조, CSS, JS, Markdown, 설명은 출력하지 마세요. 모든 출력은 한국어로만 작성하세요. 중국어나 다른 언어를 섞지 마세요.',
            'user_intro' => "웹사이트 홈페이지 필드를 구조화하여 생성하세요.\n",
            'requirements' => '요구사항: template_vars 에는 위 필드만 포함하세요. 모든 필드를 최대한 채우세요. content 필드는 반환하지 마세요. seo_keywords 와 keywords 는 같은 의미이며, seo_description 와 description 도 같은 의미입니다. 최소 하나는 반드시 채우고 둘 다 일치시키는 것이 좋습니다. keywords 는 3~5개를 영어 쉼표로 구분하세요. description / seo_description 는 120~160자 내외의 한국어로 작성하세요. 모든 텍스트는 한국어로만 작성하고 중국어를 섞지 마세요.',
            'richness' => array(
                "- hero_stat_*_no: '500+'、'99.9%'、'24h'、'100k+' 같은 설득력 있는 지표를 사용하고 01/02/03은 쓰지 마세요.",
                "- hero_stat_*_title: 8~15자 정도의 완전한 문구로 작성하세요.",
                "- hero_stat_*_desc: 20~40자 정도로 가치를 구체적으로 설명하세요.",
                "- trust_*_title / trust_*_desc: 신뢰 포인트를 구체적이고 명확하게 작성하세요.",
                "- service_*_title / service_*_desc: 서비스 내용을 명확히 작성하세요.",
                "- feature_*_no / title / desc: 구체적이고 매력적으로 작성하세요.",
                "- panel_title / panel_desc / panel_item_*_title / panel_item_*_desc: 모호한 표현을 피하세요.",
                "- footer_text: ©, 연도, 사이트명을 포함하지 마세요."
            ),
            'json_note' => '다음 JSON 형식을 정확히 반환하세요: {"site_name":"","title":"","seo_title":"","seo_keywords":"","seo_description":"","keywords":"","description":"","testimonials":[{"quote":"","name":"","role":""}],"template_vars":{}}'
        ),
    );
    if (!isset($langPrompts[$siteLanguage])) {
        $langPrompts[$siteLanguage] = array(
            'system' => 'You are the homepage field generator for the BeeAI website builder. Return JSON only for the homepage template fields. Do not output HTML page structure, CSS, JS, Markdown fences, or any explanation. Follow the target site language exactly.',
            'user_intro' => "Generate structured homepage field content for a website.\n",
            'requirements' => 'Requirements: keep template_vars limited to the fields above; fill all fields as completely as possible; do not return a content field; keep all generated text in the target site language and do not mix in Chinese unless the target language is Chinese.',
            'richness' => array(
                "- Use persuasive metrics and concrete descriptions; avoid generic filler.",
                "- Keep headings complete and specific.",
                "- Keep trust/service/feature/panel copy aligned with the target language.",
                "- Do not include ©, year, or site name in footer_text."
            ),
            'json_note' => 'Return exactly this JSON structure: {"site_name":"","title":"","seo_title":"","seo_keywords":"","seo_description":"","keywords":"","description":"","testimonials":[{"quote":"","name":"","role":""}],"template_vars":{}}'
        );
    }
    $promptCfg = $langPrompts[$siteLanguage];
    $systemPrompt = $promptCfg['system'];
    $userPrompt = $promptCfg['user_intro']
        . 'Site language: ' . $siteLanguage . "\n"
        . 'Domain: ' . (isset($domain['domain']) ? (string)$domain['domain'] : '') . "\n"
        . 'Site name: ' . $siteName . "\n"
        . 'Core keyword: ' . $keyword . "\n"
        . 'Site description: ' . $siteDescription . "\n"
        . 'Page path: ' . $path . "\n"
        . 'Template name: ' . (isset($template['name']) ? (string)$template['name'] : '') . "\n"
        . 'Template code: ' . (isset($template['code']) ? (string)$template['code'] : '') . "\n"
        . 'Required homepage fields: ' . implode('；', $fieldLines) . "\n"
        . $langNote . "\n"
        . $promptCfg['json_note']
        . "\n" . $promptCfg['requirements']
        . "\nImportant richness rules:\n" . implode("\n", $promptCfg['richness']);

    return array(
        array('role' => 'system', 'content' => $systemPrompt),
        array('role' => 'user', 'content' => $userPrompt)
    );
}

function execute_ai_generation_request($model, $messages, $useJsonResponseFormat = true)
{
    if (!function_exists('curl_init')) {
        throw new Exception('当前 PHP 环境未启用 cURL 扩展，无法执行 AI 内容生成。');
    }

    $apiUrl = isset($model['api_url']) ? trim((string)$model['api_url']) : '';
    $apiKey = isset($model['api_key']) ? trim((string)$model['api_key']) : '';

    if ($apiUrl === '' || !filter_var($apiUrl, FILTER_VALIDATE_URL)) {
        throw new Exception('当前模型配置的 API 地址无效。');
    }
    if ($apiKey === '') {
        throw new Exception('当前模型未配置 API Key。');
    }

    $payload = array(
        'model' => isset($model['model_name']) ? (string)$model['model_name'] : '',
        'messages' => $messages,
        'stream' => false
    );
    if ($useJsonResponseFormat) {
        $payload['response_format'] = array('type' => 'json_object');
    }

    $modelKey = isset($model['request_headers']) ? (string)$model['request_headers'] : '';
    if ($modelKey === 'deepseek-reasoner' || (isset($model['model_name']) && $model['model_name'] === 'deepseek-reasoner')) {
        $payload['thinking'] = array('type' => 'enabled');
    }

    $payloadJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    if ($payloadJson === false) {
        throw new Exception('AI 生成请求体构建失败。');
    }

    $headers = array(
        'Content-Type: application/json',
        'Accept: application/json',
        'Authorization: Bearer ' . $apiKey
    );

    $ch = curl_init($apiUrl);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $payloadJson);
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_TIMEOUT, 120);
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);

    $responseBody = curl_exec($ch);
    $curlError = curl_error($ch);
    $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($responseBody === false) {
        throw new Exception('调用 AI 接口失败：' . $curlError);
    }

    $decoded = json_decode($responseBody, true);
    $assistantText = '';
    $errorMessage = '';

    if (is_array($decoded)) {
        if (isset($decoded['choices'][0]['message']['content'])) {
            $assistantText = (string)$decoded['choices'][0]['message']['content'];
        }
        if (isset($decoded['error']['message'])) {
            $errorMessage = (string)$decoded['error']['message'];
        }
    }

    if ($httpCode < 200 || $httpCode >= 300) {
        throw new Exception($errorMessage !== '' ? $errorMessage : 'AI 接口调用失败，HTTP 状态码：' . $httpCode);
    }
    if ($assistantText === '') {
        throw new Exception('AI 已返回响应，但未提取到可用内容。');
    }

    // 去除模型可能输出的思考标签（Qwen3 等模型）
    $assistantText = preg_replace('/<think>[\s\S]*?<\/think>/i', '', $assistantText);
    $assistantText = trim($assistantText);

    return array(
        'request_payload_json' => $payloadJson,
        'response_body' => $responseBody,
        'response_data' => $decoded,
        'assistant_text' => $assistantText,
        'http_code' => $httpCode
    );
}

function parse_site_generation_result($assistantText)
{
    $jsonText = extract_json_block_from_text($assistantText);
    if ($jsonText === '') {
        throw new Exception('AI 返回结果中未找到合法 JSON。');
    }

    $data = json_decode($jsonText, true);
    if (!is_array($data)) {
        throw new Exception('AI 返回的 JSON 解析失败。');
    }

    return $data;
}

function create_site_build_from_ai($data, $progressCallback = null)
{
    ensure_content_storage_ready();
    $debugMode = !empty($data['debug_mode']);
    $domainId = isset($data['domain_id']) ? (int)$data['domain_id'] : 0;
    $templateId = isset($data['template_id']) ? (int)$data['template_id'] : 0;
    $modelId = isset($data['model_id']) ? (int)$data['model_id'] : 0;
    $keyword = isset($data['keyword']) ? trim((string)$data['keyword']) : '';
    $siteName = isset($data['site_name']) ? trim((string)$data['site_name']) : '';
    $siteDescription = isset($data['site_description']) ? trim((string)$data['site_description']) : '';
    $remark = isset($data['remark']) ? trim((string)$data['remark']) : '';
    $publishNow = !empty($data['publish_now']);

    if (is_callable($progressCallback)) {
        call_user_func($progressCallback, 1, '正在校验建站参数', array());
    }

    if ($domainId <= 0) {
        throw new Exception('请选择绑定域名。');
    }
    if ($modelId <= 0) {
        throw new Exception('请选择 AI 模型。');
    }
    if ($keyword === '') {
        throw new Exception('请输入核心关键词。');
    }

    $domain = find_domain_by_id($domainId);
    if (!$domain) {
        throw new Exception('未找到对应域名。');
    }
    if (!isset($domain['status']) || $domain['status'] !== 'enabled') {
        throw new Exception('当前域名未启用，无法建站。');
    }

    ensure_domains_table_has_template_pack();
    $packCode = domain_template_pack($domain);

    $template = null;
    $useTemplatePack = false;

    if ($packCode !== '' && load_template_pack_manifest($packCode)) {
        $useTemplatePack = true;
        if ($templateId > 0) {
            $template = find_template_by_id($templateId);
        }
        if (!$template) {
            $homepageTemplates = list_enabled_template_options();
            $homepageTemplates = array_values(array_filter($homepageTemplates, function ($item) {
                return is_homepage_template_type(isset($item['type']) ? $item['type'] : '');
            }));
            if (!empty($homepageTemplates)) {
                $templateId = (int)$homepageTemplates[0]['id'];
                $template = find_template_by_id($templateId);
            }
        }
        if (!$template) {
            $template = array(
                'id' => 0,
                'name' => $packCode,
                'code' => $packCode,
                'type' => 'homepage',
                'html_content' => load_template_pack_file($packCode, 'homepage'),
                'css_content' => load_template_pack_file($packCode, 'css'),
                'status' => 'enabled'
            );
            $templateId = 0;
        }
    } else {
        if ($templateId <= 0) {
            throw new Exception('请为域名绑定模板套件后再建站。');
        }
        $template = find_template_by_id($templateId);
        if (!$template) {
            throw new Exception('未找到对应模板。');
        }
        if (!isset($template['status']) || $template['status'] !== 'enabled') {
            throw new Exception('当前模板未启用，无法建站。');
        }
    }

    $model = find_ai_model_by_id($modelId);
    if (!$model) {
        throw new Exception('未找到对应 AI 模型。');
    }
    if (!isset($model['status']) || $model['status'] !== 'enabled') {
        throw new Exception('当前 AI 模型未启用，无法生成内容。');
    }

    if ($siteName === '') {
        $siteName = isset($domain['site_name']) ? trim((string)$domain['site_name']) : '';
    }
    if ($siteName === '') {
        $siteName = $keyword;
    }

    if (is_callable($progressCallback)) {
        call_user_func($progressCallback, 2, '已完成域名、模板套件与 AI 模型检查', array());
    }

    $templateType = isset($template['type']) ? (string)$template['type'] : 'homepage';
    $isHomepageTemplate = $useTemplatePack ? true : template_looks_like_homepage($template);
    $pathValue = $isHomepageTemplate ? '/' : normalize_site_build_slug($keyword . '-' . (isset($template['code']) ? $template['code'] : 'page'));
    $siteLanguage = domain_site_language($domain);
    if (isset($data['site_language']) && trim((string)$data['site_language']) !== '') {
        $siteLanguage = normalize_domain_language($data['site_language']);
    }
    $schemaContext = array(
        'site_name' => $siteName,
        'domain' => isset($domain['domain']) ? (string)$domain['domain'] : '',
        'keyword' => $keyword,
        'path' => $pathValue,
        'site_description' => $siteDescription,
        'site_language' => $siteLanguage,
        'site_locale' => $siteLanguage,
        'html_lang' => strtolower(substr(str_replace('_', '-', $siteLanguage), 0, 2))
    );

    if ($isHomepageTemplate) {
        $messages = build_homepage_generation_messages($domain, $template, $keyword, $siteName, $siteDescription, $pathValue, template_variable_schema_for_type($templateType), $siteLanguage);
    } else {
        $messages = build_site_generation_messages($domain, $template, $keyword, $siteName, template_variables_list());
    }

    if ($debugMode && is_callable($progressCallback)) {
        call_user_func($progressCallback, 3, 'DEBUG: 首页生成指令已准备', array(
            'debug_result' => array(
                'generation_type' => $isHomepageTemplate ? 'homepage' : 'site',
                'domain_id' => $domainId,
                'site_language' => $siteLanguage,
                'keyword' => $keyword,
                'site_name' => $siteName,
                'site_description' => $siteDescription,
                'path' => $pathValue,
                'messages' => $messages,
                'instruction_summary' => array(
                    'description_prompt' => $isHomepageTemplate ? '首页完整内容生成（含首页结构字段与SEO信息）' : '结构化站点内容生成',
                    'content_prompt' => $isHomepageTemplate ? '首页内容与模板字段必须严格按站点语言生成' : '站点完整内容必须严格按站点语言生成'
                )
            )
        ));
    }

    if (is_callable($progressCallback)) {
        call_user_func($progressCallback, 3, '正在请求 AI 生成结构化站点内容', array());
    }
    $aiResult = execute_ai_generation_request($model, $messages);
    $generated = parse_site_generation_result($aiResult['assistant_text']);

    $title = isset($generated['title']) ? trim((string)$generated['title']) : '';
    $seoTitle = isset($generated['seo_title']) ? trim((string)$generated['seo_title']) : '';
    $seoKeywords = normalize_ai_keywords(
        isset($generated['seo_keywords']) && clean_plain_text($generated['seo_keywords']) !== '' ? $generated['seo_keywords'] : (isset($generated['keywords']) ? $generated['keywords'] : ''),
        $keyword
    );
    $seoDescription = normalize_ai_description(
        isset($generated['seo_description']) && clean_plain_text($generated['seo_description']) !== '' ? $generated['seo_description'] : (isset($generated['description']) ? $generated['description'] : ''),
        array(
            isset($generated['description']) ? $generated['description'] : '',
            isset($generated['seo_description']) ? $generated['seo_description'] : '',
            isset($generated['title']) ? $generated['title'] : '',
            isset($generated['seo_title']) ? $generated['seo_title'] : '',
            isset($generated['site_name']) ? $generated['site_name'] : '',
            isset($data['site_description']) ? $data['site_description'] : '',
            $keyword
        ),
        120
    );
    $contentBody = isset($generated['content']) ? (string)$generated['content'] : '';
    $generatedSiteName = isset($generated['site_name']) ? trim((string)$generated['site_name']) : '';
    $templateVars = isset($generated['template_vars']) && is_array($generated['template_vars']) ? $generated['template_vars'] : array();

    if ($generatedSiteName !== '') {
        $siteName = $generatedSiteName;
    }
    if ($title === '') {
        $title = $keyword . ' - ' . $siteName;
    }
    if ($seoTitle === '') {
        $seoTitle = $title;
    }
    if ($seoDescription === '') {
        $seoDescription = $keyword . '相关内容由 AI 自动生成。';
    }
    if ($seoKeywords === '') {
        $seoKeywords = $keyword;
    }
    if (is_callable($progressCallback)) {
        call_user_func($progressCallback, 3, '结构化 JSON 已生成，正在整理模板字段', array(
            'generated_summary' => array(
                'title' => $title,
                'site_name' => $siteName,
                'seo_title' => $seoTitle,
                'template_var_count' => count($templateVars),
                'has_content' => trim($contentBody) !== ''
            )
        ));
    }
    if (!$isHomepageTemplate && trim($contentBody) === '') {
        throw new Exception('AI 未生成正文内容，无法创建页面。');
    }

    $slugBase = normalize_site_build_slug($keyword . '-' . (isset($template['code']) ? $template['code'] : 'page'));
    $pathValue = $isHomepageTemplate ? 'index' : $slugBase;
    if ($isHomepageTemplate) {
        $templateVars = enrich_homepage_template_vars($templateVars, $generated, $schemaContext);
    }
    $templateVars = sanitize_template_vars_by_schema($templateVars, $isHomepageTemplate ? 'homepage' : $templateType, $schemaContext);
    if ($isHomepageTemplate) {
        $contentBody = '';
    }
    $templateVars['{{site_name}}'] = $siteName;
    $templateVars['{{site_logo}}'] = build_site_logo_html($domain);
    $templateVars['{{site_logo_url}}'] = normalize_logo_url_to_relative(isset($domain['logo_url']) ? (string)$domain['logo_url'] : '');
    $templateVars['{{domain}}'] = isset($domain['domain']) ? (string)$domain['domain'] : '';
    $templateVars['{{path}}'] = $isHomepageTemplate ? '/' : content_public_url_path($pathValue);
    $contentData = array(
        'title' => $title,
        'slug' => generate_unique_content_slug($slugBase),
        'path' => $pathValue,
        'domain_id' => $domainId,
        'template_id' => $templateId,
        'seo_title' => $seoTitle,
        'seo_keywords' => $seoKeywords,
        'seo_description' => $seoDescription,
        'content_body' => $contentBody,
        'template_vars' => $templateVars,
        'site_language' => $siteLanguage,
        'status' => $publishNow ? 'published' : 'draft',
        'remark' => $remark !== '' ? $remark : 'AI一键建站生成'
    );

    if (is_callable($progressCallback)) {
        call_user_func($progressCallback, 4, '正在写入内容记录与模板变量', array());
    }
    $existingContentId = 0;
    if ($isHomepageTemplate) {
        $existCheck = db_connection()->prepare('SELECT id FROM contents WHERE domain_id = :did AND path = :path LIMIT 1');
        $existCheck->execute(array(':did' => $domainId, ':path' => $pathValue));
        $existRow = $existCheck->fetch(PDO::FETCH_ASSOC);
        if ($existRow) {
            $existingContentId = (int)$existRow['id'];
        }
    }
    if ($existingContentId > 0) {
        update_content($existingContentId, $contentData);
        $contentId = $existingContentId;
    } else {
        $contentId = create_content($contentData);
    }
    if (is_callable($progressCallback)) {
        call_user_func($progressCallback, 4, '内容记录写入完成，正在准备模板预览', array(
            'content_summary' => array(
                'content_id' => $contentId,
                'title' => $title,
                'path' => $isHomepageTemplate ? '/' : content_public_url_path($pathValue),
                'status' => $contentData['status']
            )
        ));
    }

    $publishSummary = null;
    if (is_callable($progressCallback)) {
        call_user_func($progressCallback, 5, '正在渲染模板预览', array());
    }

    $previewMenuNavHtml = '';
    if ($domainId > 0) {
        $previewHomeMenu = find_home_menu_by_domain($domainId);
        $previewActiveMenuId = ($previewHomeMenu && isset($previewHomeMenu['id'])) ? (int)$previewHomeMenu['id'] : 0;
        $previewMenuNavHtml = render_frontend_menu_nav_html(list_nav_menus_by_domain($domainId), $isHomepageTemplate ? '/' : content_public_url_path($pathValue), $previewActiveMenuId);
    }
    $renderVariables = array_merge(default_template_vars_for_type($isHomepageTemplate ? 'homepage' : $templateType, $schemaContext), array(
        '{{site_name}}' => $siteName,
        '{{site_logo}}' => build_site_logo_html($domain),
        '{{site_logo_url}}' => normalize_logo_url_to_relative(isset($domain['logo_url']) ? (string)$domain['logo_url'] : ''),
        '{{domain}}' => isset($domain['domain']) ? (string)$domain['domain'] : '',
        '{{title}}' => $title,
        '{{seo_title}}' => $seoTitle,
        '{{seo_keywords}}' => $seoKeywords,
        '{{seo_description}}' => $seoDescription,
        '{{content}}' => $contentBody,
        '{{path}}' => $isHomepageTemplate ? '/' : content_public_url_path($pathValue),
        '{{year}}' => date('Y'),
        '{{menu_nav}}' => $previewMenuNavHtml
    ), $templateVars);

    $previewHtml = render_template_html(isset($template['html_content']) ? $template['html_content'] : '', isset($template['css_content']) ? $template['css_content'] : '', $renderVariables);
    if (is_callable($progressCallback)) {
        call_user_func($progressCallback, 5, '模板预览已生成', array(
            'preview_summary' => array(
                'html_length' => strlen($previewHtml),
                'path' => $isHomepageTemplate ? '/' : content_public_url_path($pathValue)
            )
        ));
    }

    if ($publishNow) {
        if (is_callable($progressCallback)) {
            call_user_func($progressCallback, 6, '正在执行静态发布', array());
        }
        $publishSummary = publish_domain_static($domainId);
        if (is_callable($progressCallback)) {
            call_user_func($progressCallback, 6, '静态发布完成，正在整理访问结果', array(
                'publish_summary' => array(
                    'published_count' => isset($publishSummary['published_count']) ? (int)$publishSummary['published_count'] : 0,
                    'domain' => isset($publishSummary['domain']['domain']) ? (string)$publishSummary['domain']['domain'] : '',
                    'result_count' => isset($publishSummary['results']) && is_array($publishSummary['results']) ? count($publishSummary['results']) : 0
                )
            ));
        }
    }

    return array(
        'content_id' => $contentId,
        'content_data' => $contentData,
        'domain' => $domain,
        'template' => $template,
        'model' => $model,
        'generated_data' => $generated,
        'ai_result' => $aiResult,
        'preview_html' => $previewHtml,
        'render_variables' => $renderVariables,
        'publish_summary' => $publishSummary,
        'result_summary' => array(
            'content_id' => $contentId,
            'path' => $isHomepageTemplate ? '/' : content_public_url_path($pathValue),
            'status' => $contentData['status'],
            'preview_length' => strlen($previewHtml),
            'template_var_count' => count($templateVars)
        )
    );
}

function build_latest_articles_section_html($domainName, $limit = 8, $heading = '最新资讯')
{
    if ($domainName === '') { return ''; }
    $lim = max(2, min(12, (int)$limit));
    static $cache = array();
    $cacheKey = (string)$domainName . '|' . $lim . '|' . (string)$heading;
    if (array_key_exists($cacheKey, $cache)) {
        return $cache[$cacheKey];
    }
    try {
        ensure_content_storage_ready();
        $pdo = db_connection();
        // 优先取当前域名下的文章
        $stmt = $pdo->prepare("SELECT c.title, c.path, c.seo_description, c.cover_image, c.created_at, c.static_file_path, d.domain
            FROM contents c
            JOIN domains d ON d.id = c.domain_id
            WHERE d.domain = :domain AND c.status = 'published'
                AND c.path != 'index' AND c.path != '' AND c.path != '/'
            ORDER BY c.created_at DESC LIMIT :lim");
        $stmt->bindValue(':domain', $domainName, PDO::PARAM_STR);
        $stmt->bindValue(':lim', $lim, PDO::PARAM_INT);
        $stmt->execute();
        $articles = $stmt->fetchAll(PDO::FETCH_ASSOC);
        // 当前域名无文章时，取全站最新文章
        if (empty($articles)) {
            $stmt2 = $pdo->prepare("SELECT c.title, c.path, c.seo_description, c.cover_image, c.created_at, c.static_file_path, d.domain
                FROM contents c
                JOIN domains d ON d.id = c.domain_id
                WHERE c.status = 'published'
                    AND c.path != 'index' AND c.path != '' AND c.path != '/'
                ORDER BY c.created_at DESC LIMIT :lim");
            $stmt2->bindValue(':lim', $lim, PDO::PARAM_INT);
            $stmt2->execute();
            $articles = $stmt2->fetchAll(PDO::FETCH_ASSOC);
        }
    } catch (Exception $e) {
        $cache[$cacheKey] = '';
        return '';
    }
    if (empty($articles)) {
        $cache[$cacheKey] = '';
        return '';
    }

    $items = '';
    foreach ($articles as $row) {
        $title = htmlspecialchars((string)$row['title'], ENT_QUOTES, 'UTF-8');
        $desc = htmlspecialchars(mb_substr(strip_tags((string)$row['seo_description']), 0, 80, 'UTF-8'), ENT_QUOTES, 'UTF-8');
        $path = trim((string)$row['path'], '/');
        $articleDomain = isset($row['domain']) ? trim((string)$row['domain']) : '';
        $url = content_public_url_path($path, content_has_static_file($row));
        if ($articleDomain !== '' && strtolower($articleDomain) !== strtolower($domainName)) {
            $url = 'https://' . $articleDomain . $url;
        }
        $date = date('Y-m-d', strtotime((string)$row['created_at']));
        $coverUrl = normalize_public_asset_url(isset($row['cover_image']) ? (string)$row['cover_image'] : '');
        $coverHtml = $coverUrl !== ''
            ? '<div class="hp-article-cover"><img src="' . htmlspecialchars($coverUrl, ENT_QUOTES, 'UTF-8') . '" alt="' . $title . '" loading="lazy"></div>'
            : '';
        $items .= '<a class="hp-article-item" href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '">'
            . $coverHtml
            . '<div class="hp-article-body"><h3>' . $title . '</h3>'
            . ($desc !== '' ? '<p>' . $desc . '</p>' : '')
            . '<span>' . $date . '</span></div></a>';
    }
    $css = '<style>'
        . '.hp-latest-articles{padding:48px 24px}'
        . '.hp-articles-head{text-align:center;margin-bottom:28px}'
        . '.hp-articles-head h2{font-size:clamp(22px,5vw,32px);color:var(--v-dark-bg,var(--text-primary,#1e293b))}'
        . '.hp-articles-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--v-hp-article-min-w,260px),1fr));gap:var(--v-space-lg,20px);max-width:1200px;margin:0 auto}'
        . '.hp-article-item{display:flex;flex-direction:column;background:var(--v-list-bg,rgba(255,255,255,0.85));border:var(--v-list-border,1px solid rgba(148,163,184,0.15));border-radius:var(--v-list-radius,var(--radius-xl,14px));overflow:hidden;text-decoration:none;color:inherit;transition:box-shadow .2s,transform .15s}'
        . '.hp-article-item:hover{box-shadow:var(--v-list-shadow-hover,0 6px 24px rgba(0,0,0,0.08));transform:translateY(-2px)}'
        . '.hp-article-cover{width:100%;aspect-ratio:16/9;overflow:hidden;background:var(--bg-subtle,#f1f5f9)}'
        . '.hp-article-cover img{width:100%;height:100%;object-fit:cover}'
        . '.hp-article-body{padding:16px 18px 18px}'
        . '.hp-article-body h3{font-size:15px;font-weight:600;color:var(--v-list-title-color,var(--text-primary,#1e293b));margin:0 0 6px;line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}'
        . '.hp-article-item:hover .hp-article-body h3{color:var(--v-list-title-hover,var(--primary-color,#3b82f6))}'
        . '.hp-article-body p{font-size:13px;color:var(--v-list-desc-color,var(--text-secondary,#64748b));margin:0 0 8px;line-height:1.6;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}'
        . '.hp-article-body span{font-size:12px;color:var(--text-secondary,#94a3b8)}'
        . '</style>';
    $headingHtml = '';
    if (trim((string)$heading) !== '') {
        $safeHeading = htmlspecialchars((string)$heading, ENT_QUOTES, 'UTF-8');
        $headingHtml = '<div class="hp-articles-head"><h2>' . $safeHeading . '</h2></div>';
    }
    $result = $css . '<section class="hp-latest-articles" id="articles">' . $headingHtml . '<div class="hp-articles-grid">' . $items . '</div></section>';
    $cache[$cacheKey] = $result;
    return $result;
}

function build_menu_articles_sections_html($domainId, $articlesPerMenu = 8)
{
    $domainId = (int)$domainId;
    if ($domainId <= 0) { return ''; }
    $articlesPerMenu = max(1, min(100, (int)$articlesPerMenu));
    static $cache = array();
    $cacheKey = $domainId . '|' . $articlesPerMenu;
    if (array_key_exists($cacheKey, $cache)) {
        return $cache[$cacheKey];
    }
    try {
        ensure_menu_storage_ready();
        ensure_content_storage_ready();
        $menus = list_enabled_menu_options($domainId);
    } catch (Exception $e) {
        $cache[$cacheKey] = '';
        return '';
    }
    if (empty($menus)) {
        $cache[$cacheKey] = '';
        return '';
    }

    $sections = '';
    $hasContent = false;
    foreach ($menus as $menu) {
        $menuId = isset($menu['id']) ? (int)$menu['id'] : 0;
        $menuName = isset($menu['menu_name']) ? (string)$menu['menu_name'] : '';
        $menuSlug = isset($menu['menu_slug']) ? (string)$menu['menu_slug'] : '';
        $isHome = !empty($menu['is_home']);
        if ($menuId <= 0 || $menuName === '') { continue; }
        if ($isHome) { continue; }

        $articles = list_published_contents_by_menu($menuId, $articlesPerMenu);
        if (empty($articles)) { continue; }

        $hasContent = true;
        $archiveUrl = menu_public_url_path($menuSlug);
        $cards = '';
        foreach ($articles as $item) {
            $title = isset($item['title']) ? (string)$item['title'] : '';
            $path = isset($item['path']) ? content_public_url_path((string)$item['path'], content_has_static_file($item)) : '#';
            $desc = isset($item['seo_description']) ? mb_substr(strip_tags((string)$item['seo_description']), 0, 80, 'UTF-8') : '';
            $coverImage = isset($item['cover_image']) ? trim((string)$item['cover_image']) : '';
            $coverUrl = normalize_public_asset_url($coverImage);
            $date = isset($item['updated_at']) ? date('Y-m-d', strtotime((string)$item['updated_at'])) : '';
            $coverHtml = $coverUrl !== ''
                ? '<div class="mas-card-cover"><img src="' . e($coverUrl) . '" alt="' . e($title) . '" loading="lazy"></div>'
                : '';
            $cards .= '<a class="mas-card" href="' . e($path) . '" title="' . e($title) . '">'
                . $coverHtml
                . '<div class="mas-card-body"><h3>' . e($title) . '</h3>'
                . ($desc !== '' ? '<p>' . e($desc) . '</p>' : '')
                . ($date !== '' ? '<span class="mas-card-date">' . e($date) . '</span>' : '')
                . '</div></a>';
        }
        $sections .= '<section class="mas-section"><div class="mas-section-inner">'
            . '<div class="mas-section-head"><h2>' . e($menuName) . '</h2><a class="mas-more" href="' . e($archiveUrl) . '">查看更多 &rarr;</a></div>'
            . '<div class="mas-grid">' . $cards . '</div>'
            . '</div></section>';
    }
    if (!$hasContent) {
        $cache[$cacheKey] = '';
        return '';
    }

    $css = '<style>'
        . '.mas-section{padding:36px 24px}'
        . '.mas-section:nth-child(even){background:rgba(248,250,252,0.6)}'
        . '.mas-section-inner{max-width:1200px;margin:0 auto}'
        . '.mas-section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;padding-bottom:12px;border-bottom:2px solid #e2e8f0}'
        . '.mas-section-head h2{font-size:20px;font-weight:700;color:#1e293b;margin:0}'
        . '.mas-more{font-size:13px;color:#6366f1;text-decoration:none;font-weight:500;white-space:nowrap}'
        . '.mas-more:hover{color:#4f46e5;text-decoration:underline}'
        . '.mas-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}'
        . '.mas-card{display:flex;flex-direction:column;background:#fff;border:1px solid rgba(148,163,184,0.12);border-radius:12px;overflow:hidden;text-decoration:none;color:inherit;transition:box-shadow .2s,transform .15s}'
        . '.mas-card:hover{box-shadow:0 6px 24px rgba(0,0,0,0.07);transform:translateY(-2px)}'
        . '.mas-card-cover{width:100%;aspect-ratio:16/9;overflow:hidden;background:#f1f5f9}'
        . '.mas-card-cover img{width:100%;height:100%;object-fit:cover}'
        . '.mas-card-body{padding:14px 16px 16px;flex:1;display:flex;flex-direction:column}'
        . '.mas-card-body h3{font-size:15px;font-weight:600;color:#1e293b;margin:0 0 6px;line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}'
        . '.mas-card-body p{font-size:13px;color:#64748b;margin:0 0 8px;line-height:1.6;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}'
        . '.mas-card-date{font-size:12px;color:#94a3b8;margin-top:auto}'
        . '@media(max-width:1024px){.mas-grid{grid-template-columns:repeat(3,1fr)}}'
        . '@media(max-width:768px){.mas-grid{grid-template-columns:repeat(2,1fr)}}'
        . '@media(max-width:480px){.mas-grid{grid-template-columns:1fr}}'
        . '</style>';
    $result = $css . $sections;
    $cache[$cacheKey] = $result;
    return $result;
}

function render_template_html($htmlContent, $cssContent, $variables, $jsContent = '')
{
    $html = (string)$htmlContent;
    $isArticle = isset($variables['{{__is_article}}']) ? (bool)$variables['{{__is_article}}'] : false;
    // ── 加固：模板 CSS/JS 使用占位符 token 注入，避免内容中的 {{...}} 或 </style>
    //         字符被后续通用变量替换误伤。所有替换完成后再还原真实内容。
    $protectedBlocks = array();
    $cssTrim = trim((string)$cssContent);
    if ($cssTrim !== '') {
        $cssToken = '__TVAR_CSS_' . md5('css_' . uniqid('', true)) . '__';
        $protectedBlocks[$cssToken] = $cssTrim;
        $styleTag = '<style>' . $cssToken . '</style>';
        if (stripos($html, '</head>') !== false) {
            $html = str_ireplace('</head>', $styleTag . "\n</head>", $html);
        } else {
            $html = $styleTag . "\n" . $html;
        }
    }

    // 自动注入 SEO head：如模板无 {{seo_head}} 占位符则插入到 </title> 之后
    if (isset($variables['{{seo_head}}']) && trim((string)$variables['{{seo_head}}']) !== '') {
        if (strpos($html, '{{seo_head}}') === false) {
            if (stripos($html, '</title>') !== false) {
                $indentedSeoHead = preg_replace('/^(<)/m', '    $1', trim($variables['{{seo_head}}']));
                $html = preg_replace('/<\/title>/i', '</title>' . "\n" . $indentedSeoHead, $html, 1);
            } elseif (stripos($html, '</head>') !== false) {
                $html = str_ireplace('</head>', $variables['{{seo_head}}'] . "\n</head>", $html);
            }
        }
    }

    $jsTrim = trim((string)$jsContent);
    if ($jsTrim !== '') {
        $jsToken = '__TVAR_JS_' . md5('js_' . uniqid('', true)) . '__';
        $protectedBlocks[$jsToken] = $jsTrim;
        $scriptTag = '<script>' . $jsToken . '</script>';
        if (stripos($html, '</body>') !== false) {
            $html = str_ireplace('</body>', $scriptTag . "\n</body>", $html);
        } else {
            $html .= "\n" . $scriptTag;
        }
    }

    // 模块变量注入（{{mod_xxx}}）
    $domainForMod = isset($variables['{{domain}}']) ? (string)$variables['{{domain}}'] : '';
    $domainIdForMod = 0;
    if ($domainForMod !== '' && function_exists('find_domain_by_host')) {
        $domainRec = find_domain_by_host($domainForMod);
        if ($domainRec && isset($domainRec['id'])) { $domainIdForMod = (int)$domainRec['id']; }
    }
    if (function_exists('build_module_template_vars')) {
        $modVars = build_module_template_vars($domainIdForMod);
        $variables = array_merge($modVars, $variables);
    }

    // 保护由专用注入逻辑处理的占位符，避免被通用替换覆盖为空
    unset($variables['{{friend_links}}'], $variables['{{cross_site_articles}}'], $variables['{{latest_articles}}'], $variables['{{menu_articles_sections}}']);

    // 处理管道语法：{{var | slice:start:length}} 等
    $html = preg_replace_callback('/\{\{(\s*[\w]+(?:_[\w]+)*\s*)\|\s*slice\s*:\s*(\d+)\s*:\s*(\d+)\s*\}\}/', function ($m) use ($variables) {
        $varName = '{{' . trim($m[1]) . '}}';
        $val = isset($variables[$varName]) ? (string)$variables[$varName] : '';
        return mb_substr($val, (int)$m[2], (int)$m[3], 'UTF-8');
    }, $html);

    // 自动处理 HTML lang 属性：若模板中有硬编码的 lang 属性但没有 {{html_lang}} 占位符，则替换为动态值
    if (isset($variables['{{html_lang}}']) && trim((string)$variables['{{html_lang}}']) !== '' && strpos($html, '{{html_lang}}') === false) {
        $htmlLang = trim((string)$variables['{{html_lang}}']);
        $html = preg_replace('/<html\b([^>]*)lang=("|")[^"\']*(\2)/i', '<html$1lang="' . e($htmlLang) . '"', $html, 1);
        if (strpos($html, '<html') !== false && stripos($html, ' lang=') === false) {
            $html = preg_replace('/<html\b/i', '<html lang="' . e($htmlLang) . '"', $html, 1);
        }
    }

    // 自动注入 上/下一篇 导航：当模板未包含 {{article_navigation}} 且当前为文章页时
    if ($isArticle && strpos($html, '{{article_navigation}}') === false) {
        $injected = false;
        // 优先在中部广告前插入
        if (strpos($html, '{{article_ad_middle}}') !== false) {
            $html = str_replace('{{article_ad_middle}}', "{{article_navigation}}\n{{article_ad_middle}}", $html);
            $injected = true;
        }
        // 其次在关键词内链前插入
        if (!$injected && strpos($html, '{{keyword_links}}') !== false) {
            $html = str_replace('{{keyword_links}}', "{{article_navigation}}\n{{keyword_links}}", $html);
            $injected = true;
        }
        // 或在底部广告前插入
        if (!$injected && strpos($html, '{{article_ad_bottom}}') !== false) {
            $html = str_replace('{{article_ad_bottom}}', "{{article_navigation}}\n{{article_ad_bottom}}", $html);
            $injected = true;
        }
        // 最后退化为在正文内容之后插入
        if (!$injected && strpos($html, '{{content}}') !== false) {
            $html = str_replace('{{content}}', "{{content}}\n{{article_navigation}}", $html);
            $injected = true;
        }
    }

    foreach ($variables as $key => $value) {
        $html = str_replace($key, (string)$value, $html);
    }

    if (isset($variables['{{menu_nav}}']) && trim((string)$variables['{{menu_nav}}']) !== '' && strpos($html, (string)$variables['{{menu_nav}}']) === false) {
        $menuNavHtml = (string)$variables['{{menu_nav}}'];
        $navClasses = array('orange-nav', 'blue-nav', 'green-nav', 'hero-nav', 'article-domain', 'archive-nav', 'eb-nav', 'eb-footer-nav', 'eg-nav', 'eg-footer-nav', 'sb-nav', 'sb-footer-nav');
        foreach ($navClasses as $navClass) {
            $pattern = '#(<nav\b[^>]*class=("|\")[^"\']*\b' . preg_quote($navClass, '#') . '\b[^"\']*\2[^>]*>)(.*?)(</nav>)#is';
            $html = preg_replace_callback($pattern, function ($matches) use ($menuNavHtml) {
                return $matches[1] . $menuNavHtml . $matches[4];
            }, $html, 1);
        }
    }

    // 搜索框自动注入：如果模板没有 {{search_box}} 占位符，尝试注入到 </nav> 或 </header> 前
    $searchBoxHtml = build_search_box_html();
    if (strpos($html, 'site-search-form') === false && $searchBoxHtml !== '') {
        if (preg_match('#</nav>#i', $html)) {
            $html = preg_replace('#</nav>#i', '</nav>' . $searchBoxHtml, $html, 1);
        } elseif (preg_match('#</header>#i', $html)) {
            $html = preg_replace('#</header>#i', $searchBoxHtml . '</header>', $html, 1);
        }
    }

    // 站点互链注入（SEO 蜘蛛池效应）：占位符优先，其次 </footer> 前，最后 </body> 前
    $currentDomain = isset($variables['{{domain}}']) ? (string)$variables['{{domain}}'] : '';
    $currentPath = isset($variables['{{path}}']) ? (string)$variables['{{path}}'] : '';
    $injectionCfg = app_runtime_config();

    // 文章互链（随便看看）—— 占位符优先，否则自动注入到 </aside> 前（受后台开关控制）
    $crossSiteArticlesHtml = build_random_articles_html($currentDomain, $currentPath);
    if ($crossSiteArticlesHtml !== '') {
        if (strpos($html, '{{cross_site_articles}}') !== false) {
            $html = str_replace('{{cross_site_articles}}', $crossSiteArticlesHtml, $html);
        } elseif (preg_match('#</aside>#i', $html)) {
            $raCfg = isset($injectionCfg['random_articles']) && is_array($injectionCfg['random_articles']) ? $injectionCfg['random_articles'] : array();
            $raTitle = isset($raCfg['title']) && trim((string)$raCfg['title']) !== '' ? (string)$raCfg['title'] : '随便看看';
            $raWrapper = isset($raCfg['wrapper_html']) && trim((string)$raCfg['wrapper_html']) !== '' ? (string)$raCfg['wrapper_html'] : '<div class="side-card side-card-random-cross"><h3>{{title}}</h3><div class="random-article-list">{{links}}</div></div>';
            $wrapped = str_replace(array('{{title}}', '{{links}}'), array($raTitle, $crossSiteArticlesHtml), $raWrapper);
            $html = preg_replace('#</aside>#i', $wrapped . '</aside>', $html, 1);
        }
    } else {
        $html = str_replace('{{cross_site_articles}}', '', $html);
    }

    // 首页最新文章列表 —— 仅当模板包含占位符时替换
    if (strpos($html, '{{latest_articles}}') !== false) {
        // 检测 {{latest_articles}} 前 200 字符内是否已有 <h2 或 <h3 标题
        $laPos = strpos($html, '{{latest_articles}}');
        $laBefore = $laPos > 200 ? substr($html, $laPos - 200, 200) : substr($html, 0, $laPos);
        $laHeading = (preg_match('/<h[23][^>]*>/i', $laBefore)) ? '' : '最新资讯';
        $latestArticlesHtml = build_latest_articles_section_html_by_domain_id($domainIdForMod, 8, $laHeading);
        $html = str_replace('{{latest_articles}}', $latestArticlesHtml, $html);
    }

    // 按菜单分栏文章列表（资讯门户型首页）—— 仅当模板包含占位符时替换
    if (strpos($html, '{{menu_articles_sections}}') !== false) {
        $menuArticlesHtml = build_menu_articles_sections_html($domainIdForMod, 8);
        $html = str_replace('{{menu_articles_sections}}', $menuArticlesHtml, $html);
    }

    // 友情链接 —— 占位符优先，否则自动注入到 </footer> 前（受后台开关控制）
    $friendLinksHtml = build_friend_links_html($currentDomain, $currentPath);
    if ($friendLinksHtml !== '') {
        if (strpos($html, '{{friend_links}}') !== false) {
            $html = str_replace('{{friend_links}}', $friendLinksHtml, $html);
        } else {
            $lastFooterPos = strripos($html, '</footer>');
            if ($lastFooterPos !== false) {
                $html = substr($html, 0, $lastFooterPos) . $friendLinksHtml . substr($html, $lastFooterPos);
            } elseif (stripos($html, '</body>') !== false) {
                $html = str_ireplace('</body>', $friendLinksHtml . '</body>', $html);
            }
        }
    } else {
        $html = str_replace('{{friend_links}}', '', $html);
    }

    // 注入站点地图页脚链接（开启时在最后一个 </footer> 或 </body> 前）
    // 注意：必须用最后一个 </footer>，避免注入到分享栏等次级 <footer> 上
    // 页面模板（page template）不注入站点地图链接
    $sitemapFooter = empty($GLOBALS['beeai_skip_sitemap_injection']) ? build_sitemap_footer_html() : '';
    if ($sitemapFooter !== '') {
        $lastFooterPos = strripos($html, '</footer>');
        if ($lastFooterPos !== false) {
            $html = substr($html, 0, $lastFooterPos) . $sitemapFooter . substr($html, $lastFooterPos);
        } elseif (stripos($html, '</body>') !== false) {
            $html = str_ireplace('</body>', $sitemapFooter . '</body>', $html);
        }
    }

    // 注入流量统计代码（在 </head> 前，满足百度统计/Google Analytics 等要求）
    $analyticsCode = isset($injectionCfg['analytics_code']) ? trim((string)$injectionCfg['analytics_code']) : '';
    if ($analyticsCode !== '') {
        if (stripos($html, '</head>') !== false) {
            $html = str_ireplace('</head>', $analyticsCode . "\n</head>", $html);
        } elseif (stripos($html, '</body>') !== false) {
            $html = str_ireplace('</body>', $analyticsCode . "\n</body>", $html);
        } else {
            $html .= "\n" . $analyticsCode;
        }
    }

    // ── 模板变异引擎：注入域名级 CSS 变量 + 模块重排 + 语义变体 ──
    // 仅对变体(variation)模板套件启用，常规(standard)套件跳过
    $_varIsVariationPack = false;
    if ($domainIdForMod > 0 && function_exists('domain_template_pack') && function_exists('load_template_pack_manifest')) {
        $_varDomRec = function_exists('find_domain_by_id') ? find_domain_by_id($domainIdForMod) : null;
        $_varPCode  = $_varDomRec ? domain_template_pack($_varDomRec) : '';
        $_varMf     = $_varPCode !== '' ? load_template_pack_manifest($_varPCode) : null;
        $_varIsVariationPack = $_varMf && (!isset($_varMf['pack_type']) || $_varMf['pack_type'] === 'variation');
    }
    if ($_varIsVariationPack && function_exists('get_domain_variation')) {
        $variation = get_domain_variation($domainIdForMod);
        if (is_array($variation) && !empty($variation)) {
            // 1. 判断当前页面是否首页（用于模块顺序重排）
            $currentPagePath = isset($variables['{{path}}']) ? trim((string)$variables['{{path}}']) : '';
            $isHomepagePage = in_array($currentPagePath, array('', '/', '/index.html'), true);

            // 2. 首页模块顺序重排
            if ($isHomepagePage && isset($variation['module_order']) && function_exists('apply_module_reorder')) {
                $html = apply_module_reorder($html, $variation['module_order']);
            }

            // 3. 语义标签变体
            if (function_exists('apply_semantic_variation')) {
                $html = apply_semantic_variation($html, $domainIdForMod);
            }

            // 4. 构建并注入变异 CSS 变量（色彩/字体/间距/圆角）
            if (function_exists('build_variation_css')) {
                $variationCss = build_variation_css($variation);
                if ($variationCss !== '') {
                    $variationStyle = '<style id="tv-variation">' . $variationCss . '</style>';
                    if (stripos($html, '</head>') !== false) {
                        $html = str_ireplace('</head>', $variationStyle . "\n</head>", $html);
                    }
                }
            }

            // 5. 页面排版变异：注入 body class
            if ($isHomepagePage) {
                // 首页布局变异 class
                $bodyClasses = array();
                $hpPanel = isset($variation['hp_panel_layout']) ? (string)$variation['hp_panel_layout'] : 'default';
                if ($hpPanel !== 'default') {
                    $bodyClasses[] = 'tv-hp-panel-' . $hpPanel;
                }
                $hpFeatures = isset($variation['hp_features_layout']) ? (string)$variation['hp_features_layout'] : 'grid3';
                if ($hpFeatures !== 'grid3') {
                    $bodyClasses[] = 'tv-hp-features-' . $hpFeatures;
                }
                $hpLatest = isset($variation['hp_latest_layout']) ? (string)$variation['hp_latest_layout'] : 'grid4';
                if ($hpLatest !== 'grid4') {
                    $bodyClasses[] = 'tv-hp-latest-' . $hpLatest;
                }
                $hpCta = isset($variation['hp_cta_layout']) ? (string)$variation['hp_cta_layout'] : 'default';
                if ($hpCta !== 'default') {
                    $bodyClasses[] = 'tv-hp-cta-' . $hpCta;
                }
                $hpTestimonial = isset($variation['hp_testimonial_layout']) ? (string)$variation['hp_testimonial_layout'] : 'carousel';
                if ($hpTestimonial !== 'carousel') {
                    $bodyClasses[] = 'tv-hp-testimonial-' . $hpTestimonial;
                }
            } else {
                $bodyClasses = array('tv-article-body', 'tv-article-title');
                // 栏目页文章列表布局变异 class
                $archLayout = isset($variation['archive_layout']) ? (string)$variation['archive_layout'] : 'horizontal';
                if ($archLayout !== 'horizontal') {
                    $bodyClasses[] = 'tv-archive-' . $archLayout;
                }
                // 相关文章侧边栏布局变异 class
                $relatedLayout = isset($variation['related_layout']) ? (string)$variation['related_layout'] : '2col';
                if ($relatedLayout !== '2col') {
                    $bodyClasses[] = 'tv-related-' . $relatedLayout;
                }
            }
            // 导航菜单样式变异 class（所有页面通用）
            $navStyle = isset($variation['nav_style']) ? (string)$variation['nav_style'] : 'underline';
            if ($navStyle !== 'underline') {
                $bodyClasses[] = 'tv-nav-' . $navStyle;
            }
            // 注入 body class（首页+非首页通用）
            if (!empty($bodyClasses)) {
                $bodyClassStr = implode(' ', $bodyClasses);
                if (preg_match('/<body(\s[^>]*)?\s*>/i', $html, $bm)) {
                    $fullTag = $bm[0];
                    if (preg_match('/class\s*=\s*"([^"]*)"/i', $fullTag, $cm)) {
                        $newTag = str_replace($cm[0], 'class="' . $cm[1] . ' ' . $bodyClassStr . '"', $fullTag);
                    } elseif (preg_match("/class\s*=\s*'([^']*)'/i", $fullTag, $cm)) {
                        $newTag = str_replace($cm[0], "class='" . $cm[1] . ' ' . $bodyClassStr . "'", $fullTag);
                    } else {
                        $newTag = str_replace('<body', '<body class="' . $bodyClassStr . '"', $fullTag);
                    }
                    $html = str_replace($fullTag, $newTag, $html);
                }
            }
        }
    }

    // ── 加固：还原受保护的 CSS/JS 内容（必须在所有替换与下游处理之后） ──
    if (!empty($protectedBlocks)) {
        $html = str_replace(array_keys($protectedBlocks), array_values($protectedBlocks), $html);
    }

    // ── 插件 Hook：允许插件注入前端 <head> 内容 ──
    if (function_exists('hook_has') && hook_has('frontend_head')) {
        ob_start();
        hook_do('frontend_head');
        $pluginHeadHtml = ob_get_clean();
        if ($pluginHeadHtml !== '' && stripos($html, '</head>') !== false) {
            $html = str_ireplace('</head>', $pluginHeadHtml . "\n</head>", $html);
        }
    }

    // ── 插件 Hook：允许插件注入前端 </body> 前内容 ──
    if (function_exists('hook_has') && hook_has('frontend_footer')) {
        ob_start();
        hook_do('frontend_footer');
        $pluginFooterHtml = ob_get_clean();
        if ($pluginFooterHtml !== '' && stripos($html, '</body>') !== false) {
            $html = str_ireplace('</body>', $pluginFooterHtml . "\n</body>", $html);
        }
    }

    // ── 插件 Hook：允许插件对最终 HTML 做 Filter ──
    if (function_exists('hook_filter')) {
        $html = hook_filter('render_html_output', $html);
    }

    return $html;
}
function build_latest_articles_section_html_by_domain_id($domainId, $limit = 8, $heading = '最新资讯')
{
    $domainId = (int)$domainId;
    if ($domainId <= 0) { return ''; }
    $lim = max(2, min(12, (int)$limit));
    static $cache = array();
    $cacheKey = (string)$domainId . '|' . $lim . '|' . (string)$heading;
    if (array_key_exists($cacheKey, $cache)) {
        return $cache[$cacheKey];
    }
    try {
        ensure_content_storage_ready();
        $pdo = db_connection();
        // 优先取当前域名下的文章（使用 domain_id 过滤，比 domain 字符串更可靠）
        $stmt = $pdo->prepare("SELECT c.title, c.path, c.seo_description, c.cover_image, c.created_at, c.static_file_path, d.domain
            FROM contents c
            JOIN domains d ON d.id = c.domain_id
            WHERE c.domain_id = :domain_id AND c.status = 'published'
                AND c.path != 'index' AND c.path != '' AND c.path != '/'
            ORDER BY c.created_at DESC LIMIT :lim");
        $stmt->bindValue(':domain_id', $domainId, PDO::PARAM_INT);
        $stmt->bindValue(':lim', $lim, PDO::PARAM_INT);
        $stmt->execute();
        $articles = $stmt->fetchAll(PDO::FETCH_ASSOC);
        // 当前域名无文章时，取全站最新文章
        if (empty($articles)) {
            $stmt2 = $pdo->prepare("SELECT c.title, c.path, c.seo_description, c.cover_image, c.created_at, c.static_file_path, d.domain
                FROM contents c
                JOIN domains d ON d.id = c.domain_id
                WHERE c.status = 'published'
                    AND c.path != 'index' AND c.path != '' AND c.path != '/'
                ORDER BY c.created_at DESC LIMIT :lim");
            $stmt2->bindValue(':lim', $lim, PDO::PARAM_INT);
            $stmt2->execute();
            $articles = $stmt2->fetchAll(PDO::FETCH_ASSOC);
        }
    } catch (Exception $e) {
        $cache[$cacheKey] = '';
        return '';
    }
    if (empty($articles)) {
        $cache[$cacheKey] = '';
        return '';
    }

    $items = '';
    foreach ($articles as $row) {
        $title = htmlspecialchars((string)$row['title'], ENT_QUOTES, 'UTF-8');
        $desc = htmlspecialchars(mb_substr(strip_tags((string)$row['seo_description']), 0, 80, 'UTF-8'), ENT_QUOTES, 'UTF-8');
        $path = trim((string)$row['path'], '/');
        $articleDomain = isset($row['domain']) ? trim((string)$row['domain']) : '';
        $url = content_public_url_path($path, content_has_static_file($row));
        if ($articleDomain !== '' && strtolower($articleDomain) !== strtolower($domainId)) {
            $url = 'https://' . $articleDomain . $url;
        }
        $date = date('Y-m-d', strtotime((string)$row['created_at']));
        $coverUrl = normalize_public_asset_url(isset($row['cover_image']) ? (string)$row['cover_image'] : '');
        $coverHtml = $coverUrl !== ''
            ? '<div class="hp-article-cover"><img src="' . htmlspecialchars($coverUrl, ENT_QUOTES, 'UTF-8') . '" alt="' . $title . '" loading="lazy"></div>'
            : '';
        $items .= '<a class="hp-article-item" href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '">'
            . $coverHtml
            . '<div class="hp-article-body"><h3>' . $title . '</h3>'
            . ($desc !== '' ? '<p>' . $desc . '</p>' : '')
            . '<span>' . $date . '</span></div></a>';
    }
    $css = '<style>'
        . '.hp-latest-articles{padding:48px 24px}'
        . '.hp-articles-head{text-align:center;margin-bottom:28px}'
        . '.hp-articles-head h2{font-size:clamp(22px,5vw,32px);color:var(--v-dark-bg,var(--text-primary,#1e293b))}'
        . '.hp-articles-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--v-hp-article-min-w,260px),1fr));gap:var(--v-space-lg,20px);max-width:1200px;margin:0 auto}'
        . '.hp-article-item{display:flex;flex-direction:column;background:var(--v-list-bg,rgba(255,255,255,0.85));border:var(--v-list-border,1px solid rgba(148,163,184,0.15));border-radius:var(--v-list-radius,var(--radius-xl,14px));overflow:hidden;text-decoration:none;color:inherit;transition:box-shadow .2s,transform .15s}'
        . '.hp-article-item:hover{box-shadow:var(--v-list-shadow-hover,0 6px 24px rgba(0,0,0,0.08));transform:translateY(-2px)}'
        . '.hp-article-cover{width:100%;aspect-ratio:16/9;overflow:hidden;background:var(--bg-subtle,#f1f5f9)}'
        . '.hp-article-cover img{width:100%;height:100%;object-fit:cover}'
        . '.hp-article-body{padding:16px 18px 18px}'
        . '.hp-article-body h3{font-size:15px;font-weight:600;color:var(--v-list-title-color,var(--text-primary,#1e293b));margin:0 0 6px;line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}'
        . '.hp-article-item:hover .hp-article-body h3{color:var(--v-list-title-hover,var(--primary-color,#3b82f6))}'
        . '.hp-article-body p{font-size:13px;color:var(--v-list-desc-color,var(--text-secondary,#64748b));margin:0 0 8px;line-height:1.6;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}'
        . '.hp-article-body span{font-size:12px;color:var(--text-secondary,#94a3b8)}'
        . '</style>';
    $headingHtml = '';
    if (trim((string)$heading) !== '') {
        $safeHeading = htmlspecialchars((string)$heading, ENT_QUOTES, 'UTF-8');
        $headingHtml = '<div class="hp-articles-head"><h2>' . $safeHeading . '</h2></div>';
    }
    $result = $css . '<section class="hp-latest-articles" id="articles">' . $headingHtml . '<div class="hp-articles-grid">' . $items . '</div></section>';
    $cache[$cacheKey] = $result;
    return $result;
}


/**
 * 构建站点互链（友情链接）HTML
 * - 从已启用域名中挑选（排除当前域名自身）
 * - 基于 REQUEST_URI 的 crc32 作为洗牌 seed：同页稳定、不同页差异化
 * - 追加自定义外链；不加 nofollow 以保证权重传导
 */
function build_friend_links_html($currentHost, $currentPath = '')
{
    static $cache = array();
    $cacheKey = (string)$currentHost . '|' . (string)$currentPath;
    if (array_key_exists($cacheKey, $cache)) {
        return $cache[$cacheKey];
    }
    $config = app_runtime_config();
    $cfg = isset($config['friend_links']) && is_array($config['friend_links']) ? $config['friend_links'] : array();
    if (empty($cfg['enabled'])) {
        $cache[$cacheKey] = '';
        return '';
    }

    $scope = isset($cfg['scope']) ? (string)$cfg['scope'] : 'all';
    if ($scope === 'homepage') {
        $pathNorm = trim((string)$currentPath);
        $isHomepage = ($pathNorm === '' || $pathNorm === '/' || $pathNorm === '/index.html' || $pathNorm === 'index' || $pathNorm === 'home');
        if (!$isHomepage) {
            $cache[$cacheKey] = '';
            return '';
        }
    }

    $limit = isset($cfg['limit']) ? max(0, (int)$cfg['limit']) : 8;
    $strategy = isset($cfg['strategy']) ? (string)$cfg['strategy'] : 'random';
    $includeIds = isset($cfg['include_domain_ids']) && is_array($cfg['include_domain_ids']) ? array_map('intval', $cfg['include_domain_ids']) : array();
    $excludeIds = isset($cfg['exclude_domain_ids']) && is_array($cfg['exclude_domain_ids']) ? array_map('intval', $cfg['exclude_domain_ids']) : array();
    $customLinks = isset($cfg['custom_links']) && is_array($cfg['custom_links']) ? $cfg['custom_links'] : array();

    $currentHostNorm = strtolower(normalize_request_host($currentHost));
    $pdo = db_connection();

    $allDomains = array();
    try {
        $stmt = $pdo->query("SELECT id, domain, site_name FROM domains WHERE status = 'enabled' ORDER BY id DESC");
        $allDomains = $stmt->fetchAll(PDO::FETCH_ASSOC);
    } catch (Exception $e) {}

    $publishedDomainIds = array();
    try {
        $pubStmt = $pdo->query("SELECT DISTINCT domain_id FROM contents WHERE status = 'published'");
        foreach ($pubStmt->fetchAll(PDO::FETCH_COLUMN) as $did) {
            $publishedDomainIds[(int)$did] = true;
        }
    } catch (Exception $e) {}

    $candidates = array();
    foreach ($allDomains as $d) {
        if (!isset($publishedDomainIds[(int)$d['id']])) { continue; }
        $rawDomain = isset($d['domain']) ? trim((string)$d['domain']) : '';
        if ($rawDomain === '') { continue; }
        $hasScheme = (bool)preg_match('#^https?://#i', $rawDomain);
        $host = $rawDomain;
        if ($hasScheme) {
            $parsedHost = parse_url($rawDomain, PHP_URL_HOST);
            if ($parsedHost) { $host = $parsedHost; }
        }
        $host = strtolower($host);
        if ($currentHostNorm !== '' && $host === $currentHostNorm) { continue; }
        if ($strategy === 'specified' && !in_array((int)$d['id'], $includeIds, true)) { continue; }
        if ($strategy === 'blacklist' && in_array((int)$d['id'], $excludeIds, true)) { continue; }

        $url = ($hasScheme ? $rawDomain : 'https://' . $host) . '/';
        $name = isset($d['site_name']) && trim((string)$d['site_name']) !== '' ? trim((string)$d['site_name']) : $host;
        $candidates[] = array('name' => $name, 'url' => $url);
    }

    if (count($candidates) > 1) {
        $seedSource = isset($_SERVER['REQUEST_URI']) ? (string)$_SERVER['REQUEST_URI'] : $currentHostNorm;
        $seed = crc32($seedSource);
        mt_srand((int)$seed);
        usort($candidates, function ($a, $b) { return mt_rand(-1, 1); });
        mt_srand();
    }

    if ($limit > 0 && count($candidates) > $limit) {
        $candidates = array_slice($candidates, 0, $limit);
    }

    $externals = array();
    foreach ($customLinks as $link) {
        if (!is_array($link)) { continue; }
        $url = isset($link['url']) ? trim((string)$link['url']) : '';
        $name = isset($link['name']) ? trim((string)$link['name']) : '';
        if ($url === '' || $name === '') { continue; }
        if (!preg_match('#^https?://#i', $url)) { $url = 'https://' . ltrim($url, '/'); }
        $externals[] = array('name' => $name, 'url' => $url);
    }

    $all = array_merge($candidates, $externals);
    if (empty($all)) {
        $cache[$cacheKey] = '';
        return '';
    }

    $linksHtml = '';
    foreach ($all as $link) {
        $linksHtml .= '<a href="' . htmlspecialchars($link['url'], ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="external" style="margin-right:14px;color:#475569;text-decoration:none;">' . htmlspecialchars($link['name'], ENT_QUOTES, 'UTF-8') . '</a>';
    }

    $title = isset($cfg['title']) && trim((string)$cfg['title']) !== '' ? (string)$cfg['title'] : '友情链接';
    $wrapperHtml = isset($cfg['wrapper_html']) && trim((string)$cfg['wrapper_html']) !== '' ? (string)$cfg['wrapper_html'] : '<div class="seo-friend-links" style="margin:32px auto 0;padding:18px 24px;max-width:1200px;font-size:13px;line-height:2;color:#64748b;"><h4 style="margin:0 0 8px;font-size:14px;color:#475569;">{{title}}</h4>{{links}}</div>';
    $out = str_replace(array('{{title}}', '{{links}}'), array($title, $linksHtml), $wrapperHtml);
    $cache[$cacheKey] = $out;
    return $out;
}

/**
 * 构建「随便看看」跨站点随机文章卡片
 * - 从最近 pool_size 篇已发布文章中按 URL hash 确定性随机
 * - 按 cross_site_ratio 控制跨站/本站比例
 * - per_domain_cap 控制同一站出现次数上限（反站群指纹）
 * - 严格排除当前页自身
 */
function build_random_articles_html($currentHost, $currentPath)
{
    static $cache = array();
    $cacheKey = (string)$currentHost . '|' . (string)$currentPath;
    if (array_key_exists($cacheKey, $cache)) {
        return $cache[$cacheKey];
    }
    $config = app_runtime_config();
    $cfg = isset($config['random_articles']) && is_array($config['random_articles']) ? $config['random_articles'] : array();
    if (empty($cfg['enabled'])) {
        $cache[$cacheKey] = '';
        return '';
    }

    $scope = isset($cfg['scope']) ? (string)$cfg['scope'] : 'article_only';
    $pathNorm = trim((string)$currentPath);
    $isHomepage = ($pathNorm === '' || $pathNorm === '/' || $pathNorm === '/index.html');
    if ($scope === 'article_only' && $isHomepage) {
        $cache[$cacheKey] = '';
        return '';
    }

    $limit = isset($cfg['limit']) ? max(1, min(30, (int)$cfg['limit'])) : 10;
    $poolSize = isset($cfg['pool_size']) ? max(20, min(300, (int)$cfg['pool_size'])) : 120;
    $perDomainCap = isset($cfg['per_domain_cap']) ? max(0, (int)$cfg['per_domain_cap']) : 2;
    $crossRatio = isset($cfg['cross_site_ratio']) ? max(0, min(100, (int)$cfg['cross_site_ratio'])) : 70;

    $currentHostNorm = strtolower(normalize_request_host($currentHost));
    $currentPathNorm = preg_replace('#\.html$#i', '', trim($pathNorm, '/'));

    try {
        $pool = list_random_articles_pool($poolSize);
    } catch (Exception $e) {
        $cache[$cacheKey] = '';
        return '';
    }
    if (empty($pool)) {
        $cache[$cacheKey] = '';
        return '';
    }

    // 确定性洗牌
    $seedSource = isset($_SERVER['REQUEST_URI']) ? (string)$_SERVER['REQUEST_URI'] : ($currentHostNorm . '/' . $currentPathNorm);
    mt_srand((int)crc32($seedSource));
    usort($pool, function ($a, $b) { return mt_rand(-1, 1); });
    mt_srand();

    // 归一化每条
    $crossSite = array();
    $sameSite = array();
    foreach ($pool as $item) {
        $rawDomain = isset($item['domain']) ? trim((string)$item['domain']) : '';
        if ($rawDomain === '') { continue; }
        $hasScheme = (bool)preg_match('#^https?://#i', $rawDomain);
        $host = $rawDomain;
        if ($hasScheme) {
            $p = parse_url($rawDomain, PHP_URL_HOST);
            if ($p) { $host = $p; }
        }
        $host = strtolower($host);

        $itemPath = isset($item['path']) ? trim((string)$item['path'], '/') : '';
        $itemPathCompare = preg_replace('#\.html$#i', '', $itemPath);
        if ($currentHostNorm !== '' && $host === $currentHostNorm && $itemPathCompare === $currentPathNorm) {
            continue; // 排除自身
        }

        $url = ($hasScheme ? $rawDomain : 'https://' . $host) . ($itemPath !== '' ? content_public_url_path($itemPath, content_has_static_file($item)) : '/');
        $itemTitle = isset($item['title']) ? trim((string)$item['title']) : '';
        if ($itemTitle === '') { continue; }

        $entry = array('host' => $host, 'url' => $url, 'title' => $itemTitle);
        if ($host === $currentHostNorm) {
            $sameSite[] = $entry;
        } else {
            $crossSite[] = $entry;
        }
    }

    // 按比例+每站配额抽样
    $wantedCross = (int)floor($limit * $crossRatio / 100);
    if ($crossRatio > 0 && $wantedCross < 1) { $wantedCross = 1; }
    $selected = array();
    $domainCount = array();
    foreach ($crossSite as $e) {
        if (count($selected) >= $wantedCross) { break; }
        $c = isset($domainCount[$e['host']]) ? $domainCount[$e['host']] : 0;
        if ($perDomainCap > 0 && $c >= $perDomainCap) { continue; }
        $selected[] = $e;
        $domainCount[$e['host']] = $c + 1;
    }
    foreach ($sameSite as $e) {
        if (count($selected) >= $limit) { break; }
        $selected[] = $e;
    }
    // 若数量不足，再从跨站补位（忽略 per_domain_cap）
    if (count($selected) < $limit) {
        $picked = array();
        foreach ($selected as $s) { $picked[$s['url']] = true; }
        foreach ($crossSite as $e) {
            if (count($selected) >= $limit) { break; }
            if (isset($picked[$e['url']])) { continue; }
            $selected[] = $e;
            $picked[$e['url']] = true;
        }
    }

    if (empty($selected)) {
        $cache[$cacheKey] = '';
        return '';
    }

    $out = '';
    foreach ($selected as $i => $e) {
        $number = str_pad((string)($i + 1), 2, '0', STR_PAD_LEFT);
        $titleEsc = htmlspecialchars($e['title'], ENT_QUOTES, 'UTF-8');
        $out .= '<a class="random-article-link" href="' . htmlspecialchars($e['url'], ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="external" title="' . $titleEsc . '">'
            . '<span class="random-article-index">' . $number . '</span>'
            . '<span class="random-article-text">' . $titleEsc . '</span>'
            . '</a>';
    }
    $cache[$cacheKey] = $out;
    return $out;
}

function list_random_articles_pool($poolSize = 200)
{
    ensure_content_storage_ready();
    $pdo = db_connection();
    $poolSize = max(10, min(300, (int)$poolSize));
    static $cache = array();
    if (array_key_exists($poolSize, $cache)) {
        return $cache[$poolSize];
    }
    $sql = "SELECT c.id, c.title, c.path, c.domain_id, c.static_file_path, d.domain, d.site_name
        FROM contents c
        LEFT JOIN templates t ON t.id = c.template_id
        LEFT JOIN domains d ON d.id = c.domain_id
        WHERE c.status = 'published'
          AND d.status = 'enabled'
          AND (t.type = 'article' OR c.source_type = 'article_ai')
        ORDER BY c.id DESC LIMIT " . (int)$poolSize;
    $stmt = $pdo->query($sql);
    $result = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : array();
    $cache[$poolSize] = $result;
    return $result;
}

function normalize_request_host($host)
{
    $host = strtolower(trim((string)$host));
    if ($host === '') {
        return '';
    }
    if (strpos($host, ':') !== false) {
        $parts = explode(':', $host);
        $host = (string)$parts[0];
    }
    return $host;
}

function find_domain_by_host($host)
{
    ensure_domains_table_has_site_root();
    $host = normalize_request_host($host);
    if ($host === '') {
        return null;
    }
    static $cache = array();
    if (array_key_exists($host, $cache)) {
        return $cache[$host];
    }

    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT * FROM domains WHERE domain = :domain LIMIT 1');
    $stmt->execute(array(':domain' => $host));
    $result = $stmt->fetch(PDO::FETCH_ASSOC);
    $cache[$host] = $result;
    return $result;
}

function find_published_content_by_domain_and_slug($domainId, $slug)
{
    return find_published_content_by_domain_and_path($domainId, $slug);
}

function find_published_content_by_domain_and_path($domainId, $path)
{
    ensure_content_storage_ready();
    $path = normalize_content_path($path);
    if ($path === '') {
        return null;
    }

    $pdo = db_connection();
    $stmt = $pdo->prepare("SELECT c.*, d.domain AS domain_name, d.site_name AS domain_site_name, t.name AS template_name, t.type AS template_type, t.html_content, t.css_content FROM contents c LEFT JOIN domains d ON d.id = c.domain_id LEFT JOIN templates t ON t.id = c.template_id WHERE c.domain_id = :domain_id AND c.path = :path AND c.status = 'published' LIMIT 1");
    $stmt->execute(array(
        ':domain_id' => (int)$domainId,
        ':path' => $path
    ));
    return $stmt->fetch(PDO::FETCH_ASSOC);
}

function find_latest_published_content_by_domain($domainId)
{
    ensure_content_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare("SELECT c.*, d.domain AS domain_name, d.site_name AS domain_site_name, t.name AS template_name, t.type AS template_type, t.html_content, t.css_content FROM contents c LEFT JOIN domains d ON d.id = c.domain_id LEFT JOIN templates t ON t.id = c.template_id WHERE c.domain_id = :domain_id AND c.status = 'published' ORDER BY c.id DESC LIMIT 1");
    $stmt->execute(array(':domain_id' => (int)$domainId));
    return $stmt->fetch(PDO::FETCH_ASSOC);
}

function list_related_article_contents($domainId, $contentId, $keyword, $limit)
{
    ensure_content_storage_ready();
    $pdo = db_connection();
    $limit = max(1, min(12, (int)$limit));
    $sql = "SELECT c.id, c.title, c.path, c.seo_title, c.seo_keywords, c.cover_image, c.static_file_path
        FROM contents c
        LEFT JOIN templates t ON t.id = c.template_id
        WHERE c.domain_id = :domain_id AND c.status = 'published' AND c.id <> :content_id AND (t.type = 'article' OR c.source_type = 'article_ai')";
    $params = array(
        ':domain_id' => (int)$domainId,
        ':content_id' => (int)$contentId
    );
    $keyword = trim((string)$keyword);
    if ($keyword !== '') {
        $sql .= ' AND (c.title LIKE :kw_title OR c.seo_keywords LIKE :kw_keywords OR c.source_keyword LIKE :kw_source)';
        $params[':kw_title'] = '%' . $keyword . '%';
        $params[':kw_keywords'] = '%' . $keyword . '%';
        $params[':kw_source'] = '%' . $keyword . '%';
    }
    $sql .= ' ORDER BY c.id DESC LIMIT ' . $limit;
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function list_random_article_contents($domainId, $contentId, $limit)
{
    ensure_content_storage_ready();
    $pdo = db_connection();
    $domainId = (int)$domainId;
    $contentId = (int)$contentId;
    $limit = max(1, min(20, (int)$limit));
    static $cache = array();
    $cacheKey = $domainId . '|' . $contentId . '|' . $limit;
    if (array_key_exists($cacheKey, $cache)) {
        return $cache[$cacheKey];
    }
    $poolLimit = max($limit * 5, 50);
    $sql = "SELECT c.id, c.title, c.path, c.cover_image, c.static_file_path
        FROM contents c
        LEFT JOIN templates t ON t.id = c.template_id
        WHERE c.domain_id = :domain_id AND c.status = 'published' AND c.id <> :content_id AND (t.type = 'article' OR c.source_type = 'article_ai')
        ORDER BY c.id DESC LIMIT " . $poolLimit;
    $stmt = $pdo->prepare($sql);
    $stmt->execute(array(
        ':domain_id' => $domainId,
        ':content_id' => $contentId
    ));
    $items = $stmt->fetchAll(PDO::FETCH_ASSOC);
    if (count($items) > $limit) {
        shuffle($items);
        $items = array_slice($items, 0, $limit);
    }
    $cache[$cacheKey] = $items;
    return $items;
}

function article_keyword_list_from_string($keywords)
{
    return parse_article_keywords_text((string)$keywords);
}

function normalize_article_content_images($html, $title)
{
    $html = trim((string)$html);
    if ($html === '' || stripos($html, '<img') === false) {
        return $html;
    }
    return preg_replace_callback('/<img\b[^>]*>/i', function ($matches) use ($title) {
        $tag = isset($matches[0]) ? (string)$matches[0] : '';
        if ($tag === '') {
            return $tag;
        }
        if (preg_match('/\salt\s*=\s*(["\']).*?\1/i', $tag)) {
            return $tag;
        }
        return rtrim(substr($tag, 0, -1)) . ' alt="' . e((string)$title) . '">';
    }, $html);
}

function build_seo_head_html($content, $isArticle, $options = array())
{
    $domainName = isset($content['domain_name']) ? (string)$content['domain_name'] : '';
    $path = isset($options['public_path']) ? (string)$options['public_path'] : '/';
    $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
    $siteUrl = $scheme . '://' . $domainName;
    $canonicalUrl = $siteUrl . $path;
    $siteName = isset($options['site_name']) ? (string)$options['site_name'] : '';
    $title = isset($content['title']) ? (string)$content['title'] : '';
    $seoTitle = isset($content['seo_title']) ? (string)$content['seo_title'] : '';
    $seoDesc = isset($content['seo_description']) ? trim((string)$content['seo_description']) : '';
    $coverImage = isset($content['cover_image']) ? trim((string)$content['cover_image']) : '';
    if ($coverImage !== '' && strpos($coverImage, '://') === false) {
        $coverImage = $siteUrl . '/' . ltrim($coverImage, '/');
    }

    $html = '';

    // Canonical
    $html .= '<link rel="canonical" href="' . e($canonicalUrl) . '">' . "\n";

    // Robots
    $robots = 'index, follow';
    if (isset($options['noindex']) && $options['noindex']) {
        $robots = 'noindex, follow';
    }
    $html .= '<meta name="robots" content="' . $robots . '">' . "\n";

    // Open Graph
    $ogType = $isArticle ? 'article' : 'website';
    $html .= '<meta property="og:type" content="' . $ogType . '">' . "\n";
    $html .= '<meta property="og:title" content="' . e($seoTitle !== '' ? $seoTitle : $title) . '">' . "\n";
    $html .= '<meta property="og:description" content="' . e($seoDesc) . '">' . "\n";
    $html .= '<meta property="og:url" content="' . e($canonicalUrl) . '">' . "\n";
    $html .= '<meta property="og:site_name" content="' . e($siteName) . '">' . "\n";
    if ($coverImage !== '') {
        $html .= '<meta property="og:image" content="' . e($coverImage) . '">' . "\n";
    }

    // JSON-LD: BreadcrumbList (article pages)
    if ($isArticle) {
        $primaryMenu = isset($options['primary_menu']) ? $options['primary_menu'] : null;
        $menuTitle = $primaryMenu && isset($primaryMenu['menu_name']) ? trim((string)$primaryMenu['menu_name']) : '';
        $menuSlug = $primaryMenu && isset($primaryMenu['menu_slug']) ? (string)$primaryMenu['menu_slug'] : '';
        $menuUrl = $menuSlug !== '' ? $siteUrl . menu_public_url_path($menuSlug) : '';

        $breadcrumbItems = array();
        $breadcrumbItems[] = '{"@type":"ListItem","position":1,"name":"首页","item":"' . e($siteUrl) . '/"}';
        if ($menuTitle !== '' && $menuUrl !== '') {
            $breadcrumbItems[] = '{"@type":"ListItem","position":2,"name":"' . e($menuTitle) . '","item":"' . e($menuUrl) . '"}';
            $breadcrumbItems[] = '{"@type":"ListItem","position":3,"name":"' . e($title) . '"}';
        } else {
            $breadcrumbItems[] = '{"@type":"ListItem","position":2,"name":"' . e($title) . '"}';
        }

        $html .= '<script type="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[' . implode(',', $breadcrumbItems) . ']}</script>' . "\n";

        // JSON-LD: Article
        $dateIso = '';
        $modifiedIso = '';
        if (isset($content['updated_at']) && trim((string)$content['updated_at']) !== '') {
            $ts = strtotime(trim((string)$content['updated_at']));
            if ($ts !== false) {
                $modifiedIso = date('c', $ts);
                $dateIso = $modifiedIso;
            }
        }
        if (isset($content['created_at']) && trim((string)$content['created_at']) !== '') {
            $ts = strtotime(trim((string)$content['created_at']));
            if ($ts !== false) {
                $dateIso = date('c', $ts);
            }
        }
        if ($dateIso === '') {
            $dateIso = date('c');
        }
        if ($modifiedIso === '') {
            $modifiedIso = $dateIso;
        }

        $articleLd = '{';
        $articleLd .= '"@context":"https://schema.org",';
        $articleLd .= '"@type":"Article",';
        $articleLd .= '"headline":"' . e($title) . '",';
        $articleLd .= '"description":"' . e($seoDesc) . '",';
        if ($coverImage !== '') {
            $articleLd .= '"image":"' . e($coverImage) . '",';
        }
        $articleLd .= '"datePublished":"' . $dateIso . '",';
        $articleLd .= '"dateModified":"' . $modifiedIso . '",';
        $articleLd .= '"author":{"@type":"Organization","name":"' . e($siteName) . '"},';
        $articleLd .= '"publisher":{"@type":"Organization","name":"' . e($siteName) . '"},';
        $articleLd .= '"mainEntityOfPage":{"@type":"WebPage","@id":"' . e($canonicalUrl) . '"}';
        $articleLd .= '}';
        $html .= '<script type="application/ld+json">' . $articleLd . '</script>' . "\n";
    } else {
        // JSON-LD: WebSite (homepage)
        $isHome = is_homepage_content_path(isset($content['path']) ? (string)$content['path'] : '');
        if ($isHome) {
            $websiteLd = '{';
            $websiteLd .= '"@context":"https://schema.org",';
            $websiteLd .= '"@type":"WebSite",';
            $websiteLd .= '"name":"' . e($siteName) . '",';
            $websiteLd .= '"url":"' . e($siteUrl) . '/"';
            $websiteLd .= '}';
            $html .= '<script type="application/ld+json">' . $websiteLd . '</script>' . "\n";
        }
    }

    return $html;
}

function render_article_keywords_links($keywords, $path)
{
    $items = is_array($keywords) ? $keywords : article_keyword_list_from_string($keywords);
    if (empty($items)) {
        return '';
    }
    $parts = array();
    foreach ($items as $item) {
        if (is_array($item)) {
            $tagName = isset($item['name']) ? (string)$item['name'] : '';
            $tagSlug = tag_effective_slug($item);
        } else {
            $tagName = (string)$item;
            $tagSlug = normalize_tag_slug($item);
        }
        if ($tagName === '') {
            continue;
        }
        $parts[] = '<a href="' . e(tag_public_url_path($tagSlug)) . '" title="' . e($tagName) . '">' . e($tagName) . '</a>';
    }
    return implode('', $parts);
}

function render_article_cards_html($items)
{
    if (!is_array($items) || empty($items)) {
        return '';
    }
    $html = '';
    foreach ($items as $item) {
        $title = isset($item['title']) ? (string)$item['title'] : '';
        $path = isset($item['path']) ? content_public_url_path((string)$item['path'], content_has_static_file($item)) : '#';
        $coverImage = isset($item['cover_image']) ? trim((string)$item['cover_image']) : '';
        $imageHtml = $coverImage !== ''
            ? '<img src="' . e($coverImage) . '" alt="' . e($title) . '" loading="lazy">'
            : '<span class="article-card-thumb-placeholder">' . e($title !== '' ? $title : '文章') . '</span>';
        $html .= '<a class="article-card" href="' . e($path) . '" title="' . e($title) . '"><span class="article-card-thumb">' . $imageHtml . '</span><span class="article-card-title">' . e($title) . '</span></a>';
    }
    return $html;
}

function render_random_article_links_html($items)
{
    if (!is_array($items) || empty($items)) {
        return '';
    }
    $html = '';
    foreach ($items as $index => $item) {
        $title = isset($item['title']) ? (string)$item['title'] : '';
        $path = isset($item['path']) ? content_public_url_path((string)$item['path'], content_has_static_file($item)) : '#';
        $number = str_pad((string)($index + 1), 2, '0', STR_PAD_LEFT);
        $html .= '<a class="random-article-link" href="' . e($path) . '" title="' . e($title) . '"><span class="random-article-index">' . e($number) . '</span><span class="random-article-text">' . e($title) . '</span></a>';
    }
    return $html;
}

function inject_ad_into_content_middle($contentHtml, $adHtml)
{
    if (trim($adHtml) === '' || trim($contentHtml) === '') {
        return $contentHtml;
    }
    $adWrapped = '<div class="article-ad-inline-middle">' . $adHtml . '</div>';
    // 按块级结束标签拆分：</p>, </div>, </h2>, </h3>, </h4>, </ul>, </ol>, </blockquote>, </table>, </figure>
    $pattern = '#(</(?:p|div|h[1-6]|ul|ol|blockquote|table|figure|section)>)#i';
    $parts = preg_split($pattern, $contentHtml, -1, PREG_SPLIT_DELIM_CAPTURE);
    if (count($parts) < 3) {
        // 段落太少，追加到末尾
        return $contentHtml . $adWrapped;
    }
    // 每个逻辑块 = content + closing_tag（两两一组），计算注入点
    $blockCount = (int)floor(count($parts) / 2);
    $insertAfterBlock = max(1, (int)floor($blockCount / 2));
    // 在第 insertAfterBlock 个块级标签后插入广告
    $insertPos = $insertAfterBlock * 2; // parts 索引：0=text, 1=tag, 2=text, 3=tag ...
    $before = implode('', array_slice($parts, 0, $insertPos));
    $after = implode('', array_slice($parts, $insertPos));
    return $before . $adWrapped . $after;
}

function render_article_ad_html($ad)
{
    if (!is_array($ad) || empty($ad)) {
        return '';
    }
    $type = isset($ad['ad_type']) ? (string)$ad['ad_type'] : '';
    $name = isset($ad['name']) ? (string)$ad['name'] : '广告';
    if ($type === 'image') {
        $imageUrl = isset($ad['image_url']) ? trim((string)$ad['image_url']) : '';
        if ($imageUrl === '') {
            return '';
        }
        $image = '<img src="' . e($imageUrl) . '" alt="' . e($name) . '" loading="lazy">';
        $linkUrl = isset($ad['link_url']) ? trim((string)$ad['link_url']) : '';
        return $linkUrl !== '' ? '<a href="' . e($linkUrl) . '" target="_blank" rel="nofollow noopener">' . $image . '</a>' : $image;
    }
    return isset($ad['content_html']) ? (string)$ad['content_html'] : '';
}

function render_article_ads_block_html($slot, $domainId = 0)
{
    $ads = list_enabled_article_ads_by_slot($slot, (int)$domainId);
    if (empty($ads)) {
        return '';
    }
    $html = '';
    foreach ($ads as $ad) {
        $inner = render_article_ad_html($ad);
        if ($inner === '') {
            continue;
        }
        $html .= '<div class="article-ad-item">' . $inner . '</div>';
    }
    return $html === '' ? '' : '<div class="article-ad-slot article-ad-slot-' . e($slot) . '">' . $html . '</div>';
}

function default_article_breadcrumb_html($title, $primaryMenu)
{
    $menuTitle = $primaryMenu && isset($primaryMenu['menu_name']) ? trim((string)$primaryMenu['menu_name']) : '文章资讯';
    $menuUrl = article_archive_public_url_path('articles');
    if ($primaryMenu) {
        $menuUrl = !empty($primaryMenu['is_home']) ? '/' : menu_public_url_path(isset($primaryMenu['menu_slug']) ? (string)$primaryMenu['menu_slug'] : '');
    }
    return '<nav class="article-breadcrumb" aria-label="breadcrumb"><a href="/">首页</a><span>/</span><a href="' . e($menuUrl) . '">' . e($menuTitle) . '</a><span>/</span><span>' . e($title) . '</span></nav>';
}

function get_prev_article($domainId, $currentArticleId)
{
    ensure_content_storage_ready();
    $pdo = db_connection();
    $sql = "SELECT c.id, c.title, c.path
        FROM contents c
        LEFT JOIN templates t ON t.id = c.template_id
        WHERE c.domain_id = :domain_id AND c.status = 'published' AND c.id < :current_id
        AND (t.type = 'article' OR c.source_type = 'article_ai')
        ORDER BY c.id DESC LIMIT 1";
    $stmt = $pdo->prepare($sql);
    $stmt->execute(array(':domain_id' => (int)$domainId, ':current_id' => (int)$currentArticleId));
    $result = $stmt->fetch(PDO::FETCH_ASSOC);
    return $result ? $result : null;
}

function get_next_article($domainId, $currentArticleId)
{
    ensure_content_storage_ready();
    $pdo = db_connection();
    $sql = "SELECT c.id, c.title, c.path
        FROM contents c
        LEFT JOIN templates t ON t.id = c.template_id
        WHERE c.domain_id = :domain_id AND c.status = 'published' AND c.id > :current_id
        AND (t.type = 'article' OR c.source_type = 'article_ai')
        ORDER BY c.id ASC LIMIT 1";
    $stmt = $pdo->prepare($sql);
    $stmt->execute(array(':domain_id' => (int)$domainId, ':current_id' => (int)$currentArticleId));
    $result = $stmt->fetch(PDO::FETCH_ASSOC);
    return $result ? $result : null;
}

function render_article_navigation_html($prevArticle, $nextArticle)
{
    // 统一使用上下垂直布局 + 语义 nav
    $html = '<nav class="article-navigation article-navigation-vertical" aria-label="文章上下篇导航">';

    // 上一篇
    if ($prevArticle) {
        $prevTitle = isset($prevArticle['title']) ? htmlspecialchars((string)$prevArticle['title'], ENT_QUOTES, 'UTF-8') : '';
        $prevPath = isset($prevArticle['path']) ? htmlspecialchars((string)$prevArticle['path'], ENT_QUOTES, 'UTF-8') : '';
        $prevUrl = '/' . $prevPath;
        $html .= '<div class="article-nav-item article-nav-prev">'
              . '<div class="article-nav-label">← 上一篇</div>'
              . '<a href="' . $prevUrl . '" class="article-nav-title" title="' . $prevTitle . '">' . $prevTitle . '</a>'
              . '</div>';
    } else {
        $html .= '<div class="article-nav-item article-nav-prev article-nav-disabled" aria-disabled="true">'
              . '<div class="article-nav-label">← 上一篇</div>'
              . '<div class="article-nav-title">暂无</div>'
              . '</div>';
    }

    // 下一篇
    if ($nextArticle) {
        $nextTitle = isset($nextArticle['title']) ? htmlspecialchars((string)$nextArticle['title'], ENT_QUOTES, 'UTF-8') : '';
        $nextPath = isset($nextArticle['path']) ? htmlspecialchars((string)$nextArticle['path'], ENT_QUOTES, 'UTF-8') : '';
        $nextUrl = '/' . $nextPath;
        $html .= '<div class="article-nav-item article-nav-next">'
              . '<div class="article-nav-label">下一篇 →</div>'
              . '<a href="' . $nextUrl . '" class="article-nav-title" title="' . $nextTitle . '">' . $nextTitle . '</a>'
              . '</div>';
    } else {
        $html .= '<div class="article-nav-item article-nav-next article-nav-disabled" aria-disabled="true">'
              . '<div class="article-nav-label">下一篇 →</div>'
              . '<div class="article-nav-title">暂无</div>'
              . '</div>';
    }

    $html .= '</nav>';
    
    // 清理未匹配模板变量，避免前端明文输出
    $html = preg_replace('/\{\{[\w]+(?:\s*\|\s*[\w:]+\s*)+\}\}|\{\{[\w]+\}\}/', '', $html);
return $html;
}

function build_default_article_template_context($content, $path, $primaryMenu, $homeMenu)
{
    $title = isset($content['title']) ? (string)$content['title'] : '';
    $menuSeoKeywords = '';
    $menuSeoDescription = '';
    $menuNavHtml = '';
    $keywordLinksHtml = '';
    $breadcrumbHtml = '';
    $relatedArticlesHtml = '';
    $randomArticlesHtml = '';
    $articleAdTopHtml = '';
    $articleAdMiddleHtml = '';
    $articleAdBottomHtml = '';
    $articleAdSidebarHtml = '';
    $currentDomainId = isset($content['domain_id']) ? (int)$content['domain_id'] : 0;
    $activeMenuId = $primaryMenu && isset($primaryMenu['id']) ? (int)$primaryMenu['id'] : 0;
    if ($activeMenuId <= 0 && is_homepage_content_path($path) && $homeMenu && isset($homeMenu['id'])) {
        $activeMenuId = (int)$homeMenu['id'];
    }
    if ($currentDomainId > 0) {
        $menuNavHtml = render_frontend_menu_nav_html(list_nav_menus_by_domain($currentDomainId), $path, $activeMenuId);
    }
    if ($primaryMenu) {
        $menuSeoKeywords = isset($primaryMenu['seo_keywords']) ? (string)$primaryMenu['seo_keywords'] : '';
        $menuSeoDescription = isset($primaryMenu['seo_description']) ? (string)$primaryMenu['seo_description'] : '';
    }

    $tagItems = array();
    if (isset($content['id']) && (int)$content['id'] > 0) {
        $tagItems = list_tags_by_content((int)$content['id']);
    }
    if (empty($tagItems)) {
        foreach (article_keyword_list_from_string(isset($content['seo_keywords']) ? (string)$content['seo_keywords'] : '') as $tagName) {
            $tagItems[] = array('name' => $tagName, 'slug' => normalize_tag_slug($tagName));
        }
    }
    $keywordLinksHtml = render_article_keywords_links($tagItems, $path);
    $breadcrumbHtml = default_article_breadcrumb_html($title, $primaryMenu);
    $sourceKeyword = isset($content['source_keyword']) ? trim((string)$content['source_keyword']) : '';
    $relatedItems = list_related_article_contents($currentDomainId, isset($content['id']) ? (int)$content['id'] : 0, $sourceKeyword !== '' ? $sourceKeyword : (isset($content['seo_keywords']) ? (string)$content['seo_keywords'] : ''), 4);
    if (count($relatedItems) < 4) {
        $randomFallback = list_random_article_contents($currentDomainId, isset($content['id']) ? (int)$content['id'] : 0, 8);
        foreach ($randomFallback as $fallbackItem) {
            $exists = false;
            foreach ($relatedItems as $relatedItem) {
                if ((int)$relatedItem['id'] === (int)$fallbackItem['id']) {
                    $exists = true;
                    break;
                }
            }
            if (!$exists) {
                $relatedItems[] = $fallbackItem;
            }
            if (count($relatedItems) >= 4) {
                break;
            }
        }
    }
    $relatedArticlesHtml = render_article_cards_html(array_slice($relatedItems, 0, 4));
    $randomArticlesHtml = render_random_article_links_html(list_random_article_contents($currentDomainId, isset($content['id']) ? (int)$content['id'] : 0, 8));
    $articleAdTopHtml = render_article_ads_block_html('article_top', $currentDomainId);
    $articleAdMiddleHtml = render_article_ads_block_html('article_middle', $currentDomainId);
    $articleAdBottomHtml = render_article_ads_block_html('article_bottom', $currentDomainId);
    $articleAdSidebarHtml = render_article_ads_block_html('sidebar', $currentDomainId);
    
    // 默认输出禁用态导航容器，避免完全空白
    $articleNavigationHtml = render_article_navigation_html(null, null);
    if ($currentDomainId > 0 && isset($content['id']) && (int)$content['id'] > 0) {
        $prevArticle = get_prev_article($currentDomainId, (int)$content['id']);
        $nextArticle = get_next_article($currentDomainId, (int)$content['id']);
        $articleNavigationHtml = render_article_navigation_html($prevArticle, $nextArticle);
    }

    return array(
        'keyword_links' => $keywordLinksHtml,
        'breadcrumb' => $breadcrumbHtml,
        'related_articles' => $relatedArticlesHtml,
        'random_articles' => $randomArticlesHtml,
        'article_ad_top' => $articleAdTopHtml,
        'article_ad_middle' => $articleAdMiddleHtml,
        'article_ad_bottom' => $articleAdBottomHtml,
        'article_ad_sidebar' => $articleAdSidebarHtml,
        'menu_nav' => $menuNavHtml,
        'menu_keywords' => $menuSeoKeywords,
        'menu_description' => $menuSeoDescription,
        'article_navigation' => $articleNavigationHtml
    );
}

function list_domain_ids_with_homepage_content()
{
    ensure_content_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->query("SELECT DISTINCT c.domain_id FROM contents c LEFT JOIN templates t ON t.id = c.template_id WHERE (t.type = 'homepage' OR c.path IN ('index', 'home'))");
    $ids = array();
    foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $did) {
        $ids[(int)$did] = true;
    }
    return $ids;
}

function list_domain_ids_with_published_homepage()
{
    ensure_content_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->query("SELECT DISTINCT c.domain_id FROM contents c LEFT JOIN templates t ON t.id = c.template_id WHERE c.status = 'published' AND (t.type = 'homepage' OR c.path IN ('index', 'home'))");
    $ids = array();
    foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $did) {
        $ids[(int)$did] = true;
    }
    return $ids;
}

function find_homepage_published_content_by_domain($domainId)
{
    static $cache = array();
    $domainId = (int)$domainId;
    if (array_key_exists($domainId, $cache)) {
        return $cache[$domainId];
    }
    ensure_content_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare("SELECT c.*, d.domain AS domain_name, d.site_name AS domain_site_name, t.name AS template_name, t.type AS template_type, t.html_content, t.css_content FROM contents c LEFT JOIN domains d ON d.id = c.domain_id LEFT JOIN templates t ON t.id = c.template_id WHERE c.domain_id = :domain_id AND c.status = 'published' AND (t.type = 'homepage' OR c.path IN ('index', 'home')) ORDER BY CASE WHEN t.type = 'homepage' THEN 0 ELSE 1 END, FIELD(c.path, 'index', 'home'), c.id DESC LIMIT 1");
    $stmt->execute(array(':domain_id' => $domainId));
    $result = $stmt->fetch(PDO::FETCH_ASSOC);
    $cache[$domainId] = $result;
    return $result;
}

function resolve_frontend_content_by_domain($domainId, $path, $page = 1)
{
    $path = normalize_content_path($path);
    $menuPage = resolve_frontend_menu_page((int)$domainId, $path, $page);
    if ($menuPage) {
        return $menuPage;
    }
    $tagPage = parse_tag_archive_path($path);
    if ($tagPage) {
        return array(
            '__page_type' => 'tag_archive',
            'domain_id' => (int)$domainId,
            'tag_slug' => isset($tagPage['slug']) ? (string)$tagPage['slug'] : '',
            'page' => isset($tagPage['page']) ? (int)$tagPage['page'] : 1
        );
    }
    if (is_article_list_path($path)) {
        return array(
            '__page_type' => 'article_archive',
            'domain_id' => (int)$domainId,
            'path' => 'articles'
        );
    }
    if ($path !== '') {
        $homepageContent = find_homepage_published_content_by_domain((int)$domainId);
        if ($homepageContent) {
            $legacyPath = content_legacy_redirect_path($homepageContent);
            if ($legacyPath !== '' && $legacyPath === $path) {
                return $homepageContent;
            }
        }
        return find_published_content_by_domain_and_path((int)$domainId, $path);
    }

    $content = find_homepage_published_content_by_domain((int)$domainId);
    if ($content) {
        return $content;
    }

    return find_latest_published_content_by_domain((int)$domainId);
}

function build_content_render_variables($content)
{
    $siteName = isset($content['domain_site_name']) ? trim((string)$content['domain_site_name']) : '';
    if ($siteName === '') {
        $siteName = isset($content['title']) ? trim((string)$content['title']) : '';
    }

    $path = content_effective_public_path($content);
    $publicPath = content_public_url_path($path);
    $title = isset($content['title']) ? (string)$content['title'] : '';
    $contentBody = isset($content['content_body']) ? (string)$content['content_body'] : '';
    $contentBody = normalize_article_content_images($contentBody, $title);
    $templateType = isset($content['template_type']) ? trim((string)$content['template_type']) : '';
    // 如果 template_type 缺失（静态发布路径），尝试从 template_id 查询
    if ($templateType === '' && isset($content['template_id']) && (int)$content['template_id'] > 0) {
        $tpl = find_template_by_id((int)$content['template_id']);
        if ($tpl && isset($tpl['type'])) { $templateType = trim((string)$tpl['type']); }
    }
    $isArticle = $templateType === 'article'
        || is_article_source_type(isset($content['source_type']) ? (string)$content['source_type'] : '')
        || (!is_homepage_content_path($path) && $path !== '');

    $keywordLinksHtml = '';
    $breadcrumbHtml = '';
    $relatedArticlesHtml = '';
    $randomArticlesHtml = '';
    $articleAdTopHtml = '';
    $articleAdMiddleHtml = '';
    $articleAdBottomHtml = '';
    $articleAdSidebarHtml = '';
    $menuNavHtml = '';
    $menuSeoKeywords = '';
    $menuSeoDescription = '';
    $primaryMenu = null;
    $homeMenu = null;

    if (isset($content['domain_id']) && (int)$content['domain_id'] > 0) {
        $currentDomainId = (int)$content['domain_id'];
        $homeMenu = find_home_menu_by_domain($currentDomainId);
        $primaryMenu = isset($content['id']) ? find_primary_menu_for_content((int)$content['id']) : null;
        if ($primaryMenu) {
            $menuSeoKeywords = isset($primaryMenu['seo_keywords']) ? (string)$primaryMenu['seo_keywords'] : '';
            $menuSeoDescription = isset($primaryMenu['seo_description']) ? (string)$primaryMenu['seo_description'] : '';
        }
        $activeMenuId = $primaryMenu && isset($primaryMenu['id']) ? (int)$primaryMenu['id'] : 0;
        if ($activeMenuId <= 0 && is_homepage_content_path($path) && $homeMenu && isset($homeMenu['id'])) {
            $activeMenuId = (int)$homeMenu['id'];
        }
        $menuNavHtml = render_frontend_menu_nav_html(list_nav_menus_by_domain($currentDomainId), $path, $activeMenuId);
    }

    if ($isArticle) {
        $articleContext = build_default_article_template_context($content, $path, $primaryMenu, $homeMenu);
        $keywordLinksHtml = isset($articleContext['keyword_links']) ? $articleContext['keyword_links'] : '';
        $breadcrumbHtml = isset($articleContext['breadcrumb']) ? $articleContext['breadcrumb'] : '';
        $relatedArticlesHtml = isset($articleContext['related_articles']) ? $articleContext['related_articles'] : '';
        $randomArticlesHtml = isset($articleContext['random_articles']) ? $articleContext['random_articles'] : '';
        $articleAdTopHtml = isset($articleContext['article_ad_top']) ? $articleContext['article_ad_top'] : '';
        $articleAdMiddleHtml = isset($articleContext['article_ad_middle']) ? $articleContext['article_ad_middle'] : '';
        $articleAdBottomHtml = isset($articleContext['article_ad_bottom']) ? $articleContext['article_ad_bottom'] : '';
        $articleAdSidebarHtml = isset($articleContext['article_ad_sidebar']) ? $articleContext['article_ad_sidebar'] : '';
        $menuNavHtml = isset($articleContext['menu_nav']) ? $articleContext['menu_nav'] : $menuNavHtml;
        $menuSeoKeywords = isset($articleContext['menu_keywords']) ? $articleContext['menu_keywords'] : $menuSeoKeywords;
        $menuSeoDescription = isset($articleContext['menu_description']) ? $articleContext['menu_description'] : $menuSeoDescription;
        $articleNavigationHtml = isset($articleContext['article_navigation']) ? $articleContext['article_navigation'] : '';
    }

    $articleDate = date('Y-n-j');
    $articleDateIso = date('Y-m-d');
    if ($isArticle) {
        $articleDateSource = isset($content['updated_at']) ? trim((string)$content['updated_at']) : '';
        if ($articleDateSource === '' && isset($content['created_at'])) {
            $articleDateSource = trim((string)$content['created_at']);
        }
        if ($articleDateSource !== '') {
            $articleTimestamp = strtotime($articleDateSource);
            if ($articleTimestamp !== false) {
                $articleDate = date('Y-n-j', $articleTimestamp);
                $articleDateIso = date('Y-m-d', $articleTimestamp);
            }
        }
    }
    // 使用 <time> 语义标签包裹日期
    $articleDateHtml = '<time datetime="' . $articleDateIso . '">' . $articleDate . '</time>';

    $domainName = isset($content['domain_name']) ? (string)$content['domain_name'] : '';
    $domainRecord = isset($content['domain_id']) && (int)$content['domain_id'] > 0 ? find_domain_by_id((int)$content['domain_id']) : null;
    $siteLanguage = $domainRecord ? domain_site_language($domainRecord) : 'zh-CN';
    $siteLocale = $siteLanguage;
    $siteLanguageShort = strtolower(substr(str_replace('_', '-', $siteLanguage), 0, 2));
    $siteLogoHtml = build_site_logo_html($domainRecord ? $domainRecord : array('site_name' => $siteName));
    $siteLogoUrl = normalize_logo_url_to_relative(($domainRecord && isset($domainRecord['logo_url'])) ? (string)$domainRecord['logo_url'] : '');
    $footerTextDefault = $siteName !== '' ? $siteName . ' 版权所有' : '';
    if (isset($content['domain_id']) && (int)$content['domain_id'] > 0) {
        $homepageContent = find_homepage_published_content_by_domain((int)$content['domain_id']);
        if ($homepageContent) {
            $homepageVars = decode_template_vars_json(isset($homepageContent['template_vars_json']) ? (string)$homepageContent['template_vars_json'] : '');
            if (isset($homepageVars['{{footer_text}}']) && trim((string)$homepageVars['{{footer_text}}']) !== '') {
                $footerTextDefault = trim((string)$homepageVars['{{footer_text}}']);
            }
        }
    }

    // 生成 SEO head HTML（canonical + OG + JSON-LD + robots）
    $seoHeadHtml = '';
    $runtimeCfg = app_runtime_config();
    $seoHeadEnabled = isset($runtimeCfg['seo_head']['enabled']) && $runtimeCfg['seo_head']['enabled'];
    if ($seoHeadEnabled) {
        $seoHeadHtml = build_seo_head_html($content, $isArticle, array(
            'public_path' => $publicPath,
            'site_name' => $siteName,
            'primary_menu' => $primaryMenu
        ));
    }

    // keyword_links 包裹语义 nav 标签
    $keywordLinksWrapped = $keywordLinksHtml !== '' ? '<nav class="keyword-links" aria-label="文章标签">' . $keywordLinksHtml . '</nav>' : '';

    $variables = array(
        '{{site_name}}' => $siteName,
        '{{site_logo}}' => $siteLogoHtml,
        '{{site_logo_url}}' => $siteLogoUrl,
        '{{site_language}}' => $siteLanguage,
        '{{site_locale}}' => $siteLocale,
        '{{html_lang}}' => $siteLanguageShort,
        '{{__is_article}}' => $isArticle,
        '{{domain}}' => $domainName,
        '{{title}}' => $title,
        '{{seo_title}}' => isset($content['seo_title']) ? (string)$content['seo_title'] : '',
        '{{seo_keywords}}' => isset($content['seo_keywords']) ? (string)$content['seo_keywords'] : '',
        '{{seo_description}}' => isset($content['seo_description']) ? trim((string)$content['seo_description']) : '',
        '{{seo_head}}' => $seoHeadHtml,
        '{{content}}' => $contentBody,
        '{{path}}' => $publicPath,
        '{{year}}' => date('Y'),
        '{{footer_text}}' => $footerTextDefault,
        '{{article_date}}' => $articleDateHtml,
        '{{article_date_iso}}' => $articleDateIso,
        '{{article_date_text}}' => $articleDate,
        '{{breadcrumb}}' => $breadcrumbHtml,
        '{{keyword_links}}' => $keywordLinksWrapped,
        '{{related_articles}}' => $relatedArticlesHtml,
        '{{random_articles}}' => $randomArticlesHtml,
        '{{article_navigation}}' => isset($articleNavigationHtml) ? $articleNavigationHtml : '',
        '{{article_ad_top}}' => $articleAdTopHtml,
        '{{article_ad_middle}}' => $articleAdMiddleHtml,
        '{{article_ad_bottom}}' => $articleAdBottomHtml,
        '{{article_ad_sidebar}}' => $articleAdSidebarHtml,
        '{{menu_nav}}' => $menuNavHtml,
        '{{menu_keywords}}' => $menuSeoKeywords,
        '{{menu_description}}' => $menuSeoDescription,
        '{{search_box}}' => build_search_box_html()
    );

    $merged = array_merge($variables, decode_template_vars_json(isset($content['template_vars_json']) ? (string)$content['template_vars_json'] : ''));
    // 防旧值覆盖：文章页不允许 template_vars_json 覆盖正文与标题
    if ($isArticle) {
        $merged['{{content}}'] = $contentBody;
        $merged['{{title}}'] = $title;
    }

    // 将中间广告注入到正文 HTML 的中间段落位置（约50%处）
    // 必须在 array_merge 之后执行，因为 template_vars_json 可能覆盖 {{content}}
    if ($articleAdMiddleHtml !== '' && isset($merged['{{content}}']) && trim((string)$merged['{{content}}']) !== '') {
        $merged['{{content}}'] = inject_ad_into_content_middle((string)$merged['{{content}}'], $articleAdMiddleHtml);
        $merged['{{article_ad_middle}}'] = '';
    }

    if (isset($merged['{{html_lang}}']) && trim((string)$merged['{{html_lang}}']) !== '') {
        $htmlLang = trim((string)$merged['{{html_lang}}']);
    }

    // 内链处理：对文章正文进行关键词链接替换
    if ($isArticle && isset($merged['{{content}}']) && trim((string)$merged['{{content}}']) !== '') {
        $currentDomainId = isset($content['domain_id']) ? (int)$content['domain_id'] : 0;
        $currentPath = isset($content['path']) ? (string)$content['path'] : '';
        $merged['{{content}}'] = process_internal_links((string)$merged['{{content}}'], $currentDomainId, $currentPath);
    }

    return $merged;
}

function domain_status_options()
{
    return array(
        'enabled' => '启用',
        'pending' => '待审核',
        'disabled' => '停用'
    );
}

function domain_badge_class($status)
{
    if ($status === 'enabled') {
        return 'success';
    }
    if ($status === 'pending') {
        return 'warning';
    }
    if ($status === 'disabled') {
        return 'danger';
    }
    return 'info';
}

function template_type_options()
{
    return array(
        'homepage' => '首页模板',
        'article' => '文章页模板',
        'archive' => '栏目页模板'
    );
}

function template_status_options()
{
    return array(
        'enabled' => '启用',
        'disabled' => '停用'
    );
}

function template_variables_list()
{
    return array_merge(array(
        '{{site_name}}' => '站点名称（来自域名配置）',
        '{{site_logo}}' => '站点Logo（有logo_url时为<img>标签，否则为站点名称文本）',
        '{{site_logo_url}}' => '站点Logo图片URL（来自域名配置，可能为空）',
        '{{site_language}}' => '站点语言（如 zh-CN / en-US）',
        '{{site_locale}}' => '站点 Locale（与站点语言一致）',
        '{{html_lang}}' => 'HTML lang 属性值（简化版语言码）',
        '{{domain}}' => '域名',
        '{{title}}' => '页面标题',
        '{{seo_title}}' => 'SEO标题（<title>标签）',
        '{{seo_keywords}}' => 'SEO关键词（meta keywords）',
        '{{seo_description}}' => 'SEO描述（meta description）',
        '{{content}}' => '正文内容（HTML）',
        '{{path}}' => '页面访问路径',
        '{{year}}' => '当前年份',
        '{{article_date}}' => '文章发布日期',
        '{{cover_image}}' => '封面图URL',
        '{{excerpt}}' => '文章摘要',
        '{{breadcrumb}}' => '面包屑导航（HTML）',
        '{{keyword_links}}' => '关键词内链（HTML）',
        '{{related_articles}}' => '相关文章列表（HTML）',
        '{{random_articles}}' => '随机推荐文章（HTML）',
        '{{menu_nav}}' => '导航菜单（HTML）',
        '{{menu_keywords}}' => '栏目SEO关键词',
        '{{menu_description}}' => '栏目SEO描述',
        '{{article_ad_top}}' => '文章顶部广告位（HTML）',
        '{{article_ad_middle}}' => '文章中部广告位（HTML）',
        '{{article_ad_bottom}}' => '文章底部广告位（HTML）',
        '{{article_ad_sidebar}}' => '文章侧边栏广告位（HTML）',
        '{{cross_site_articles}}' => '跨站文章互链（HTML，自动注入）',
        '{{latest_articles}}' => '首页最新文章列表（HTML，自动注入）',
        '{{menu_articles_sections}}' => '按菜单分栏文章列表（HTML，自动注入，资讯门户型首页专用）',
        '{{friend_links}}' => '友情链接（HTML，自动注入）',
        '{{archive_content}}' => '栏目页主内容区（含标题+卡片+分页，HTML）',
        '{{archive_sidebar}}' => '栏目页侧边栏（随机推荐+广告，HTML）',
        '{{archive_pagination}}' => '栏目页分页导航（HTML）',
        '{{archive_cards}}' => '栏目页文章卡片列表（HTML）',
        '{{search_box}}' => '搜索框（HTML，自动注入到导航栏）',
        '{{mod_*}}' => '模块变量（后台模块管理中自定义，* 为模块编码）'
    ), array_map(function ($item) {
        return isset($item['label']) ? (string)$item['label'] : '';
    }, homepage_template_variable_schema()));
}

function template_variables_for_file_key($fileKey)
{
    $modVars = array(
        '{{mod_*}}' => '模块变量（后台模块管理中自定义，* 为模块编码）'
    );
    $global = array(
        '{{site_name}}' => '站点名称',
        '{{site_logo}}' => '站点Logo',
        '{{site_logo_url}}' => '站点Logo图片URL',
        '{{domain}}' => '域名',
        '{{year}}' => '当前年份',
        '{{menu_nav}}' => '导航菜单（HTML）',
        '{{search_box}}' => '搜索框（HTML）',
        '{{friend_links}}' => '友情链接（HTML）',
        '{{footer_text}}' => '页脚文案'
    );
    $seo = array(
        '{{title}}' => '页面标题',
        '{{seo_title}}' => 'SEO标题（<title>标签）',
        '{{seo_keywords}}' => 'SEO关键词（meta keywords）',
        '{{seo_description}}' => 'SEO描述（meta description）',
        '{{path}}' => '页面访问路径'
    );

    switch ($fileKey) {
        case 'header':
            return array_merge($global, array(
                '{{title}}' => '页面标题',
                '{{seo_title}}' => 'SEO标题',
                '{{seo_keywords}}' => 'SEO关键词',
                '{{seo_description}}' => 'SEO描述'
            ));
        case 'footer':
            return array_merge($global, $modVars);
        case 'homepage':
            $homepageVars = array_merge($global, $seo, array(
                '{{content}}' => '正文内容（HTML）',
                '{{header}}' => '页头组件（自动注入）',
                '{{footer}}' => '页脚组件（自动注入）',
                '{{latest_articles}}' => '最新文章列表（HTML）',
                '{{menu_articles_sections}}' => '按菜单分栏文章列表（HTML，资讯门户型首页专用）',
                '{{cross_site_articles}}' => '跨站互链（HTML）'
            ), $modVars);
            $schema = homepage_template_variable_schema();
            foreach ($schema as $var => $meta) {
                $homepageVars[$var] = isset($meta['label']) ? (string)$meta['label'] : '';
            }
            return $homepageVars;
        case 'article':
            return array_merge($global, $seo, array(
                '{{content}}' => '正文内容（HTML）',
                '{{header}}' => '页头组件（自动注入）',
                '{{footer}}' => '页脚组件（自动注入）',
                '{{article_date}}' => '文章发布日期',
                '{{excerpt}}' => '文章摘要',
                '{{breadcrumb}}' => '面包屑导航（HTML）',
                '{{keyword_links}}' => '关键词内链（HTML）',
                '{{article_navigation}}' => '上一篇/下一篇导航（HTML）',
                '{{related_articles}}' => '相关文章列表（HTML）',
                '{{random_articles}}' => '随机推荐文章（HTML）',
                '{{article_ad_top}}' => '文章顶部广告位',
                '{{article_ad_middle}}' => '文章中部广告位',
                '{{article_ad_bottom}}' => '文章底部广告位',
                '{{article_ad_sidebar}}' => '侧边栏广告位',
                '{{cross_site_articles}}' => '跨站互链（HTML）'
            ), $modVars);
        case 'archive':
            return array_merge($global, $seo, array(
                '{{header}}' => '页头组件（自动注入）',
                '{{footer}}' => '页脚组件（自动注入）',
                '{{breadcrumb}}' => '面包屑导航（HTML）',
                '{{menu_keywords}}' => '栏目SEO关键词',
                '{{menu_description}}' => '栏目SEO描述',
                '{{archive_content}}' => '栏目页主内容区（HTML）',
                '{{archive_sidebar}}' => '栏目页侧边栏（HTML）',
                '{{archive_pagination}}' => '栏目页分页导航（HTML）',
                '{{archive_cards}}' => '栏目页文章卡片列表（HTML）',
                '{{cross_site_articles}}' => '跨站互链（HTML）'
            ), $modVars);
        case 'css':
        case 'js':
            return array();
        default:
            return template_variables_list();
    }
}

function find_first_enabled_ai_model()
{
    $pdo = db_connection();
    $stmt = $pdo->query("SELECT id, name, code, provider, model_name, api_url, api_method, api_key, request_headers, request_config, status FROM ai_models WHERE status = 'enabled' ORDER BY id ASC LIMIT 1");
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    if ($row && isset($row['api_key'])) {
        $row['api_key'] = decrypt_value($row['api_key']);
    }
    return $row;
}


function create_templates_table_if_not_exists()
{
    $pdo = db_connection();
    $sql = "CREATE TABLE IF NOT EXISTS `templates` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `name` VARCHAR(100) NOT NULL,
        `code` VARCHAR(100) NOT NULL,
        `type` VARCHAR(20) NOT NULL DEFAULT 'homepage',
        `html_content` LONGTEXT NULL,
        `css_content` LONGTEXT NULL,
        `description` VARCHAR(255) NOT NULL DEFAULT '',
        `status` VARCHAR(20) NOT NULL DEFAULT 'enabled',
        `created_at` DATETIME NOT NULL,
        `updated_at` DATETIME NOT NULL,
        PRIMARY KEY (`id`),
        UNIQUE KEY `uk_template_code` (`code`),
        KEY `idx_template_user_id` (`user_id`),
        KEY `idx_template_type` (`type`),
        KEY `idx_template_status` (`status`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    $pdo->exec($sql);
}

function ensure_template_storage_ready()
{
    static $checked = false;
    if ($checked) return;
    $checked = true;
    if (!table_exists('templates')) {
        create_templates_table_if_not_exists();
    }
}

function create_domains_table_if_not_exists()
{
    $pdo = db_connection();
    $sql = "CREATE TABLE IF NOT EXISTS `domains` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `domain` VARCHAR(255) NOT NULL,
        `site_name` VARCHAR(100) NOT NULL,
        `logo_url` VARCHAR(500) NOT NULL DEFAULT '',
        `site_language` VARCHAR(20) NOT NULL DEFAULT 'zh-CN',
        `site_root` VARCHAR(255) NOT NULL DEFAULT '',
        `group_name` VARCHAR(100) NOT NULL,
        `template_name` VARCHAR(100) NOT NULL,
        `template_pack` VARCHAR(100) NOT NULL DEFAULT '',
        `status` VARCHAR(20) NOT NULL DEFAULT 'enabled',
        `remark` VARCHAR(255) NOT NULL DEFAULT '',
        `created_at` DATETIME NOT NULL,
        `updated_at` DATETIME NOT NULL,
        PRIMARY KEY (`id`),
        UNIQUE KEY `uk_domain` (`domain`),
        KEY `idx_domain_user_id` (`user_id`),
        KEY `idx_group_name` (`group_name`),
        KEY `idx_status` (`status`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    $pdo->exec($sql);
}

function ensure_domain_storage_ready()
{
    static $checked = false;
    if ($checked) return;
    $checked = true;
    if (!table_exists('domains')) {
        create_domains_table_if_not_exists();
    }
}

function create_ai_models_table_if_not_exists()
{
    $pdo = db_connection();
    $sql = "CREATE TABLE IF NOT EXISTS `ai_models` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
        `name` VARCHAR(100) NOT NULL,
        `code` VARCHAR(100) NOT NULL,
        `provider` VARCHAR(100) NOT NULL DEFAULT '',
        `model_name` VARCHAR(100) NOT NULL DEFAULT '',
        `api_url` VARCHAR(500) NOT NULL DEFAULT '',
        `api_method` VARCHAR(20) NOT NULL DEFAULT 'POST',
        `api_key` VARCHAR(255) NOT NULL DEFAULT '',
        `request_headers` TEXT NULL,
        `request_config` TEXT NULL,
        `status` VARCHAR(20) NOT NULL DEFAULT 'enabled',
        `remark` VARCHAR(255) NOT NULL DEFAULT '',
        `created_at` DATETIME NOT NULL,
        `updated_at` DATETIME NOT NULL,
        PRIMARY KEY (`id`),
        UNIQUE KEY `uk_ai_model_code` (`code`),
        KEY `idx_ai_model_user_id` (`user_id`),
        KEY `idx_ai_model_status` (`status`),
        KEY `idx_ai_model_provider` (`provider`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    $pdo->exec($sql);
}

function ensure_ai_model_storage_ready()
{
    static $checked = false;
    if ($checked) return;
    $checked = true;
    if (!table_exists('ai_models')) {
        create_ai_models_table_if_not_exists();
    }
}


function list_templates($keyword, $type, $status)
{
    ensure_template_storage_ready();
    $pdo = db_connection();
    $sql = 'SELECT id, user_id, name, code, type, description, status, created_at, updated_at FROM templates WHERE user_id = :uid';
    $params = array(':uid' => current_user_id());

    if ($keyword !== '') {
        $sql .= ' AND (name LIKE :kw1 OR code LIKE :kw2 OR description LIKE :kw3)';
        $params[':kw1'] = '%' . $keyword . '%';
        $params[':kw2'] = '%' . $keyword . '%';
        $params[':kw3'] = '%' . $keyword . '%';
    }
    if ($type !== '' && $type !== 'all') {
        $sql .= ' AND type = :type';
        $params[':type'] = $type;
    }
    if ($status !== '' && $status !== 'all') {
        $sql .= ' AND status = :status';
        $params[':status'] = $status;
    }

    $sql .= ' ORDER BY id DESC';
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll();
}

function find_template_by_id($id)
{
    ensure_template_storage_ready();
    $pdo = db_connection();
    $uid = current_user_id();
    if ($uid > 0) {
        $stmt = $pdo->prepare('SELECT * FROM templates WHERE id = :id AND user_id = :uid LIMIT 1');
        $stmt->execute(array(':id' => (int)$id, ':uid' => $uid));
    } else {
        $stmt = $pdo->prepare('SELECT * FROM templates WHERE id = :id LIMIT 1');
        $stmt->execute(array(':id' => (int)$id));
    }
    return $stmt->fetch(PDO::FETCH_ASSOC);
}

function find_template_by_code($code)
{
    ensure_template_storage_ready();
    $code = trim((string)$code);
    if ($code === '') {
        return false;
    }

    $pdo = db_connection();
    $stmt = $pdo->prepare('SELECT * FROM templates WHERE code = :code LIMIT 1');
    $stmt->execute(array(':code' => $code));
    return $stmt->fetch(PDO::FETCH_ASSOC);
}

function find_enabled_archive_template()
{
    ensure_template_storage_ready();
    $pdo = db_connection();
    $stmt = $pdo->prepare("SELECT * FROM templates WHERE type = 'archive' AND status = 'enabled' ORDER BY id ASC LIMIT 1");
    $stmt->execute();
    return $stmt->fetch(PDO::FETCH_ASSOC);
}

// ==================== 搜索功能 ====================

function search_frontend_contents($domainId, $query, $page = 1, $pageSize = 20)
{
    ensure_content_storage_ready();
    $query = trim((string)$query);
    if ($query === '') {
        return array('items' => array(), 'total' => 0);
    }
    $pdo = db_connection();
    $likeTerm = '%' . str_replace(array('%', '_'), array('\\%', '\\_'), $query) . '%';
    $countStmt = $pdo->prepare("SELECT COUNT(*) FROM contents WHERE domain_id = :domain_id AND status = 'published' AND (title LIKE :q1 OR seo_keywords LIKE :q2 OR seo_description LIKE :q3)");
    $countStmt->execute(array(':domain_id' => (int)$domainId, ':q1' => $likeTerm, ':q2' => $likeTerm, ':q3' => $likeTerm));
    $total = (int)$countStmt->fetchColumn();

    $page = max(1, (int)$page);
    $offset = ($page - 1) * $pageSize;
    $stmt = $pdo->prepare("SELECT c.id, c.title, c.path, c.seo_description, c.cover_image, c.created_at, c.updated_at FROM contents c WHERE c.domain_id = :domain_id AND c.status = 'published' AND (c.title LIKE :q1 OR c.seo_keywords LIKE :q2 OR c.seo_description LIKE :q3) ORDER BY c.id DESC LIMIT :lim OFFSET :off");
    $stmt->bindValue(':domain_id', (int)$domainId, PDO::PARAM_INT);
    $stmt->bindValue(':q1', $likeTerm, PDO::PARAM_STR);
    $stmt->bindValue(':q2', $likeTerm, PDO::PARAM_STR);
    $stmt->bindValue(':q3', $likeTerm, PDO::PARAM_STR);
    $stmt->bindValue(':lim', (int)$pageSize, PDO::PARAM_INT);
    $stmt->bindValue(':off', (int)$offset, PDO::PARAM_INT);
    $stmt->execute();
    return array('items' => $stmt->fetchAll(PDO::FETCH_ASSOC), 'total' => $total);
}

function build_search_results_page_html($domain, $query, $currentPage = 1)
{
    $siteName = isset($domain['site_name']) ? trim((string)$domain['site_name']) : '';
    if ($siteName === '') {
        $siteName = isset($domain['domain']) ? trim((string)$domain['domain']) : '站点';
    }
    $domainName = isset($domain['domain']) ? trim((string)$domain['domain']) : '';
    $domainId = isset($domain['id']) ? (int)$domain['id'] : 0;
    $query = trim((string)$query);
    $currentPage = max(1, (int)$currentPage);
    $pageSize = 10;

    $result = array('items' => array(), 'total' => 0);
    if ($query !== '') {
        $result = search_frontend_contents($domainId, $query, $currentPage, $pageSize);
    }
    $totalItems = (int)$result['total'];
    $items = $result['items'];
    $totalPages = max(1, (int)ceil($totalItems / $pageSize));

    // 搜索结果卡片（横向：左图右文）
    $listHtml = '';
    if ($query === '') {
        $listHtml = '<div class="sr-empty">请输入关键词进行搜索</div>';
    } elseif (empty($items)) {
        $listHtml = '<div class="sr-empty">未找到与 <strong>"' . e($query) . '"</strong> 相关的内容</div>';
    } else {
        foreach ($items as $item) {
            $t = isset($item['title']) ? (string)$item['title'] : '';
            $p = isset($item['path']) ? content_public_url_path((string)$item['path']) : '#';
            $img = isset($item['cover_image']) ? trim((string)$item['cover_image']) : '';
            $desc = isset($item['seo_description']) ? trim((string)$item['seo_description']) : '';
            if ($desc === '' && isset($item['content_body'])) {
                $desc = mb_substr(strip_tags((string)$item['content_body']), 0, 120, 'UTF-8');
            }
            $date = '';
            $dateSource = isset($item['updated_at']) ? trim((string)$item['updated_at']) : '';
            if ($dateSource === '' && isset($item['created_at'])) { $dateSource = trim((string)$item['created_at']); }
            if ($dateSource !== '') {
                $ts = strtotime($dateSource);
                if ($ts) { $date = date('Y年m月d日', $ts); }
            }
            $thumbHtml = $img !== ''
                ? '<div class="sr-thumb"><img src="' . e($img) . '" alt="' . e($t) . '" loading="lazy"></div>'
                : '<div class="sr-thumb"><span class="sr-thumb-ph">' . e(mb_substr($t, 0, 6, 'UTF-8')) . '</span></div>';
            $listHtml .= '<article class="sr-card"><a href="' . e($p) . '" class="sr-card-link">'
                . $thumbHtml
                . '<div class="sr-info"><h2 class="sr-title">' . e($t) . '</h2>'
                . ($desc !== '' ? '<p class="sr-desc">' . e(mb_substr($desc, 0, 150, 'UTF-8')) . '</p>' : '')
                . ($date !== '' ? '<span class="sr-meta">' . e($date) . '</span>' : '')
                . '</div></a></article>';
        }
    }

    $baseUrl = '/search?q=' . urlencode($query);
    $paginationHtml = '';
    if ($totalPages > 1) {
        $paginationHtml = render_search_pagination_html($baseUrl, $currentPage, $totalPages);
    }

    // 右侧栏：随便看看
    $randomItems = list_random_article_contents($domainId, 0, 10);
    $sidebarHtml = '';
    if (!empty($randomItems)) {
        $sidebarHtml .= '<div class="sr-side-box"><h3 class="sr-side-title"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;color:#3b82f6;"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>随便看看</h3><ol class="sr-rank-list">';
        foreach ($randomItems as $idx => $ri) {
            $rt = isset($ri['title']) ? (string)$ri['title'] : '';
            $rp = isset($ri['path']) ? content_public_url_path((string)$ri['path']) : '#';
            $numClass = $idx < 3 ? ' sr-rank-hot' : '';
            $sidebarHtml .= '<li class="sr-rank-item"><span class="sr-rank-num' . $numClass . '">' . ($idx + 1) . '</span><a href="' . e($rp) . '" title="' . e($rt) . '">' . e($rt) . '</a></li>';
        }
        $sidebarHtml .= '</ol></div>';
    }
    $sidebarAds = render_article_ads_block_html('sidebar', $domainId);
    if ($sidebarAds !== '') {
        $sidebarHtml .= '<div class="sr-side-box">' . $sidebarAds . '</div>';
    }

    // 导航
    $navMenus = list_nav_menus_by_domain($domainId);
    $navHtml = render_frontend_menu_nav_html($navMenus, 'search', 0);
    $title = ($query !== '' ? e($query) . ' - 搜索结果' : '搜索') . ' - ' . $siteName;
    $searchBoxHtml = build_search_box_html();

    $runtimeCfg = app_runtime_config();
    $analyticsSnippet = isset($runtimeCfg['analytics_code']) ? trim((string)$runtimeCfg['analytics_code']) : '';

    $css = search_results_page_styles();

    $htmlLang = isset($domain['site_language']) ? normalize_domain_language($domain['site_language']) : 'zh-CN';
    $htmlLangAttr = strtolower(str_replace('_', '-', substr($htmlLang, 0, 2)));
    return '<!DOCTYPE html><html lang="' . e($htmlLangAttr) . '"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">'
        . '<title>' . e($title) . '</title>'
        . '<meta name="keywords" content="' . e($query) . '">'
        . '<meta name="description" content="' . ($query !== '' ? '搜索 ' . e($query) . ' 的结果 - ' . e($siteName) : e($siteName) . ' 站内搜索') . '">'
        . '<meta name="robots" content="noindex,follow">'
        . '<style>' . $css . '</style></head><body>'
        . '<header class="sr-header"><div class="sr-header-inner"><a class="sr-brand" href="/">' . e($siteName) . '</a><nav class="sr-nav">' . $navHtml . '</nav>' . $searchBoxHtml . '</div></header>'
        . '<main class="sr-wrap"><div class="sr-main"><div class="sr-heading">'
        . ($query !== '' ? '<h1>搜索：<em>' . e($query) . '</em></h1><span class="sr-count">共 ' . $totalItems . ' 条结果</span>' : '<h1>站内搜索</h1>')
        . '</div><div class="sr-list">' . $listHtml . '</div>' . $paginationHtml . '</div>'
        . '<aside class="sr-side">' . $sidebarHtml . '</aside>'
        . '</main>'
        . '<footer class="sr-footer"><p>&copy; ' . date('Y') . ' ' . e($siteName) . '</p>' . build_sitemap_footer_html() . '</footer>'
        . $analyticsSnippet . '</body></html>';
}

function search_results_page_styles()
{
    return '*{margin:0;padding:0;box-sizing:border-box}'
        . 'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#f0f2f5;color:#1e293b;line-height:1.6}'
        . 'a{text-decoration:none;color:inherit}'
        // header
        . '.sr-header{background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.08);position:sticky;top:0;z-index:100}'
        . '.sr-header-inner{max-width:1200px;margin:0 auto;padding:0 24px;height:60px;display:flex;align-items:center;gap:20px}'
        . '.sr-brand{font-size:20px;font-weight:700;color:#1e40af;white-space:nowrap}'
        . '.sr-nav{display:flex;align-items:center;gap:6px;flex:1;flex-wrap:wrap;justify-content:flex-end}'
        . '.sr-nav a{padding:6px 14px;border-radius:6px;font-size:14px;color:#475569;transition:all .2s}'
        . '.sr-nav a:hover,.sr-nav a.is-active{background:#eff6ff;color:#1d4ed8}'
        . '.site-search-form{border-color:rgba(148,163,184,.3) !important;background:rgba(241,245,249,.6) !important}'
        . '.site-search-form input{color:#334155 !important}'
        // wrap
        . '.sr-wrap{max-width:1200px;margin:24px auto;padding:0 24px;display:grid;grid-template-columns:1fr 320px;gap:24px;align-items:start}'
        // main
        . '.sr-main{min-width:0}'
        . '.sr-heading{margin-bottom:20px;display:flex;align-items:baseline;gap:12px}'
        . '.sr-heading h1{font-size:20px;font-weight:700;color:#0f172a}'
        . '.sr-heading em{font-style:normal;color:#1d4ed8}'
        . '.sr-count{font-size:13px;color:#94a3b8}'
        . '.sr-empty{text-align:center;padding:80px 20px;color:#94a3b8;font-size:15px;background:#fff;border-radius:12px}'
        // card
        . '.sr-card{background:#fff;border-radius:12px;overflow:hidden;transition:box-shadow .25s,transform .25s}'
        . '.sr-card:hover{box-shadow:0 8px 30px rgba(15,23,42,.1);transform:translateY(-2px)}'
        . '.sr-card+.sr-card{margin-top:16px}'
        . '.sr-card-link{display:flex;gap:20px;padding:20px}'
        . '.sr-thumb{flex:0 0 200px;height:134px;border-radius:8px;overflow:hidden;background:#e2e8f0}'
        . '.sr-thumb img{width:100%;height:100%;object-fit:cover;display:block}'
        . '.sr-thumb-ph{display:flex;align-items:center;justify-content:center;width:100%;height:100%;font-size:13px;color:#94a3b8;padding:12px;text-align:center;line-height:1.4}'
        . '.sr-info{flex:1;min-width:0;display:flex;flex-direction:column;justify-content:center;gap:8px}'
        . '.sr-title{font-size:18px;font-weight:700;color:#1e40af;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;transition:color .2s}'
        . '.sr-card:hover .sr-title{color:#2563eb}'
        . '.sr-desc{font-size:14px;color:#64748b;line-height:1.7;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}'
        . '.sr-meta{font-size:13px;color:#94a3b8}'
        // sidebar
        . '.sr-side{position:sticky;top:84px}'
        . '.sr-side-box{background:#fff;border-radius:12px;padding:20px;margin-bottom:20px}'
        . '.sr-side-title{font-size:16px;font-weight:700;color:#0f172a;margin-bottom:16px;padding-bottom:12px;border-bottom:2px solid #e2e8f0;display:flex;align-items:center}'
        . '.sr-rank-list{list-style:none;counter-reset:rank}'
        . '.sr-rank-item{display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid #f1f5f9;font-size:14px;line-height:1.5}'
        . '.sr-rank-item:last-child{border-bottom:none}'
        . '.sr-rank-item a{flex:1;min-width:0;color:#334155;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:color .2s}'
        . '.sr-rank-item a:hover{color:#1d4ed8}'
        . '.sr-rank-num{flex:0 0 22px;height:22px;border-radius:4px;background:#e2e8f0;color:#64748b;font-size:12px;font-weight:700;display:flex;align-items:center;justify-content:center}'
        . '.sr-rank-hot{background:#1d4ed8;color:#fff}'
        // footer
        . '.sr-footer{max-width:1200px;margin:32px auto 0;padding:20px 24px;text-align:center;font-size:13px;color:#94a3b8}'
        // pagination
        . '.archive-pagination{display:flex;gap:8px;justify-content:center;padding:24px 0;flex-wrap:wrap}'
        // responsive
        . '@media(max-width:900px){.sr-wrap{grid-template-columns:1fr}.sr-side{position:static}}'
        . '@media(max-width:640px){.sr-card-link{flex-direction:column;gap:12px}.sr-thumb{flex:none;height:180px;width:100%}.sr-header-inner{gap:12px;padding:0 16px}.sr-nav{display:none}.sr-wrap{padding:0 16px;gap:16px}}';
}

function render_search_pagination_html($baseUrl, $currentPage, $totalPages)
{
    if ($totalPages <= 1) { return ''; }
    $html = '<div class="archive-pagination" style="display:flex;gap:8px;justify-content:center;padding:24px 0;flex-wrap:wrap;">';
    if ($currentPage > 1) {
        $html .= '<a href="' . e($baseUrl . '&page=' . ($currentPage - 1)) . '" style="display:inline-flex;align-items:center;justify-content:center;min-width:38px;height:38px;padding:0 12px;border-radius:10px;background:#fff;border:1px solid rgba(148,163,184,.2);color:#334155;font-size:14px;text-decoration:none;">上一页</a>';
    }
    $start = max(1, $currentPage - 2);
    $end = min($totalPages, $currentPage + 2);
    for ($i = $start; $i <= $end; $i++) {
        if ($i === $currentPage) {
            $html .= '<span style="display:inline-flex;align-items:center;justify-content:center;min-width:38px;height:38px;padding:0 12px;border-radius:10px;background:#1d4ed8;color:#fff;font-size:14px;font-weight:700;">' . $i . '</span>';
        } else {
            $html .= '<a href="' . e($baseUrl . '&page=' . $i) . '" style="display:inline-flex;align-items:center;justify-content:center;min-width:38px;height:38px;padding:0 12px;border-radius:10px;background:#fff;border:1px solid rgba(148,163,184,.2);color:#334155;font-size:14px;text-decoration:none;">' . $i . '</a>';
        }
    }
    if ($currentPage < $totalPages) {
        $html .= '<a href="' . e($baseUrl . '&page=' . ($currentPage + 1)) . '" style="display:inline-flex;align-items:center;justify-content:center;min-width:38px;height:38px;padding:0 12px;border-radius:10px;background:#fff;border:1px solid rgba(148,163,184,.2);color:#334155;font-size:14px;text-decoration:none;">下一页</a>';
    }
    $html .= '</div>';
    return $html;
}

function build_search_box_html()
{
    return '<form class="site-search-form" action="/search" method="get" style="display:flex;align-items:center;gap:0;max-width:220px;height:36px;border-radius:18px;overflow:hidden;background:rgba(255,255,255,.12);border:1px solid rgba(148,163,184,.25);transition:border-color .2s;">'
        . '<input type="text" name="q" placeholder="搜索..." autocomplete="off" style="flex:1;min-width:0;height:100%;padding:0 14px;border:none;outline:none;background:transparent;font-size:14px;color:inherit;">'
        . '<button type="submit" style="flex:0 0 36px;height:100%;border:none;background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;color:inherit;opacity:.7;transition:opacity .2s;" aria-label="搜索">'
        . '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
        . '</button></form>';
}

// ============================================================
// 用户数据初始化
// ============================================================

function initialize_user_data($userId)
{
    $userId = (int)$userId;
    if ($userId <= 0) {
        throw new Exception('无效的用户ID。');
    }

    $pdo = db_connection();
    $now = date('Y-m-d H:i:s');

    $aiCheck = $pdo->prepare("SELECT COUNT(*) FROM ai_models WHERE user_id = :uid");
    $aiCheck->execute(array(':uid' => $userId));
    if ((int)$aiCheck->fetchColumn() === 0) {
        $providers = array(
            array('name' => 'DeepSeek', 'code' => 'deepseek-user-' . $userId, 'provider' => 'deepseek', 'model_name' => 'deepseek-chat', 'api_url' => 'https://api.deepseek.com/chat/completions'),
            array('name' => '火山引擎', 'code' => 'volcengine-user-' . $userId, 'provider' => 'volcengine', 'model_name' => '', 'api_url' => 'https://ark.cn-beijing.volces.com/api/v3/chat/completions')
        );
        $stmt = $pdo->prepare('INSERT INTO ai_models (user_id, name, code, provider, model_name, api_url, api_method, status, created_at, updated_at) VALUES (:uid, :name, :code, :provider, :model_name, :api_url, :method, :status, :created_at, :updated_at)');
        foreach ($providers as $p) {
            $stmt->execute(array(
                ':uid' => $userId,
                ':name' => $p['name'],
                ':code' => $p['code'],
                ':provider' => $p['provider'],
                ':model_name' => $p['model_name'],
                ':api_url' => $p['api_url'],
                ':method' => 'POST',
                ':status' => 'disabled',
                ':created_at' => $now,
                ':updated_at' => $now
            ));
        }
    }

    $tplCheck = $pdo->prepare("SELECT COUNT(*) FROM templates WHERE user_id = :uid");
    $tplCheck->execute(array(':uid' => $userId));
    if ((int)$tplCheck->fetchColumn() === 0) {
        $defaultHomepage = '<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{seo_title}}</title>
<meta name="keywords" content="{{seo_keywords}}">
<meta name="description" content="{{seo_description}}">
</head>
<body>
<h1>{{site_name}}</h1>
<div>{{content}}</div>
<footer>&copy; {{year}} {{site_name}}</footer>
</body>
</html>';

        $defaultArticle = '<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{seo_title}}</title>
<meta name="keywords" content="{{seo_keywords}}">
<meta name="description" content="{{seo_description}}">
</head>
<body>
<header><a href="/">{{site_name}}</a></header>
<article>
<h1>{{title}}</h1>
<div>{{content}}</div>
</article>
<footer>&copy; {{year}} {{site_name}}</footer>
</body>
</html>';

        $templates = array(
            array('name' => '默认首页', 'code' => 'default-home-' . $userId, 'type' => 'homepage', 'html' => $defaultHomepage, 'desc' => '系统初始化生成的基础首页模板'),
            array('name' => '默认文章页', 'code' => 'default-article-' . $userId, 'type' => 'article', 'html' => $defaultArticle, 'desc' => '系统初始化生成的基础文章页模板')
        );
        $stmt = $pdo->prepare('INSERT INTO templates (user_id, name, code, type, html_content, css_content, description, status, created_at, updated_at) VALUES (:uid, :name, :code, :type, :html, :css, :desc, :status, :created_at, :updated_at)');
        foreach ($templates as $t) {
            $stmt->execute(array(
                ':uid' => $userId,
                ':name' => $t['name'],
                ':code' => $t['code'],
                ':type' => $t['type'],
                ':html' => $t['html'],
                ':css' => '',
                ':desc' => $t['desc'],
                ':status' => 'enabled',
                ':created_at' => $now,
                ':updated_at' => $now
            ));
        }
    }
}

// ========== 内链管理 ==========

function ensure_internal_links_table()
{
    static $checked = false;
    if ($checked) return;
    $checked = true;
    $pdo = db_connection();
    if (!table_exists('internal_links')) {
        $pdo->exec("CREATE TABLE `internal_links` (
            `id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
            `user_id` INT UNSIGNED NOT NULL DEFAULT 0,
            `keyword` VARCHAR(200) NOT NULL DEFAULT '',
            `url` VARCHAR(500) NOT NULL DEFAULT '',
            `mode` VARCHAR(20) NOT NULL DEFAULT 'match' COMMENT 'match=匹配时出现, force=强制出现',
            `max_replace` INT NOT NULL DEFAULT 1 COMMENT '单篇最大替换次数, 0=不限',
            `priority` INT NOT NULL DEFAULT 0 COMMENT '优先级, 越大越先匹配',
            `status` VARCHAR(20) NOT NULL DEFAULT 'enabled',
            `created_at` DATETIME NULL,
            `updated_at` DATETIME NULL,
            INDEX `idx_il_user` (`user_id`),
            INDEX `idx_il_keyword` (`keyword`),
            INDEX `idx_il_status` (`status`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    }
}

function list_internal_links($userId = 0)
{
    ensure_internal_links_table();
    $pdo = db_connection();
    $uid = $userId > 0 ? (int)$userId : current_user_id();
    if (is_super_admin() && $userId <= 0) {
        $stmt = $pdo->query("SELECT * FROM internal_links ORDER BY priority DESC, id DESC");
    } else {
        $stmt = $pdo->prepare("SELECT * FROM internal_links WHERE user_id = :uid ORDER BY priority DESC, id DESC");
        $stmt->execute(array(':uid' => $uid));
    }
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function list_enabled_internal_links($userId = 0)
{
    ensure_internal_links_table();
    $pdo = db_connection();
    $uid = $userId > 0 ? (int)$userId : current_user_id();
    if (is_super_admin() && $userId <= 0) {
        $stmt = $pdo->query("SELECT * FROM internal_links WHERE status = 'enabled' ORDER BY priority DESC, id DESC");
    } else {
        $stmt = $pdo->prepare("SELECT * FROM internal_links WHERE user_id = :uid AND status = 'enabled' ORDER BY priority DESC, id DESC");
        $stmt->execute(array(':uid' => $uid));
    }
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function find_internal_link_by_id($id)
{
    ensure_internal_links_table();
    $pdo = db_connection();
    $stmt = $pdo->prepare("SELECT * FROM internal_links WHERE id = :id LIMIT 1");
    $stmt->execute(array(':id' => (int)$id));
    return $stmt->fetch(PDO::FETCH_ASSOC);
}

function create_internal_link($data)
{
    ensure_internal_links_table();
    $pdo = db_connection();
    $now = date('Y-m-d H:i:s');
    $stmt = $pdo->prepare("INSERT INTO internal_links (user_id, keyword, url, mode, max_replace, priority, status, created_at, updated_at) VALUES (:uid, :keyword, :url, :mode, :max_replace, :priority, :status, :created_at, :updated_at)");
    $stmt->execute(array(
        ':uid' => isset($data['user_id']) ? (int)$data['user_id'] : current_user_id(),
        ':keyword' => isset($data['keyword']) ? trim((string)$data['keyword']) : '',
        ':url' => isset($data['url']) ? trim((string)$data['url']) : '',
        ':mode' => (isset($data['mode']) && $data['mode'] === 'force') ? 'force' : 'match',
        ':max_replace' => isset($data['max_replace']) ? max(0, (int)$data['max_replace']) : 1,
        ':priority' => isset($data['priority']) ? (int)$data['priority'] : 0,
        ':status' => (isset($data['status']) && $data['status'] === 'disabled') ? 'disabled' : 'enabled',
        ':created_at' => $now,
        ':updated_at' => $now
    ));
    return (int)$pdo->lastInsertId();
}

function update_internal_link($id, $data)
{
    ensure_internal_links_table();
    $pdo = db_connection();
    $now = date('Y-m-d H:i:s');
    $stmt = $pdo->prepare("UPDATE internal_links SET keyword = :keyword, url = :url, mode = :mode, max_replace = :max_replace, priority = :priority, status = :status, updated_at = :updated_at WHERE id = :id");
    $stmt->execute(array(
        ':keyword' => isset($data['keyword']) ? trim((string)$data['keyword']) : '',
        ':url' => isset($data['url']) ? trim((string)$data['url']) : '',
        ':mode' => (isset($data['mode']) && $data['mode'] === 'force') ? 'force' : 'match',
        ':max_replace' => isset($data['max_replace']) ? max(0, (int)$data['max_replace']) : 1,
        ':priority' => isset($data['priority']) ? (int)$data['priority'] : 0,
        ':status' => (isset($data['status']) && $data['status'] === 'disabled') ? 'disabled' : 'enabled',
        ':updated_at' => $now,
        ':id' => (int)$id
    ));
}

function delete_internal_link($id)
{
    ensure_internal_links_table();
    $pdo = db_connection();
    $stmt = $pdo->prepare("DELETE FROM internal_links WHERE id = :id");
    $stmt->execute(array(':id' => (int)$id));
}

function internal_link_config()
{
    $config = app_runtime_config();
    $defaults = array(
        'enabled' => false,
        'link_priority' => 'internal',
        'global_max_replace' => 3,
        'nofollow_external' => true,
        'new_window' => true
    );
    $cfg = isset($config['internal_links']) && is_array($config['internal_links']) ? $config['internal_links'] : array();
    return array_merge($defaults, $cfg);
}

/**
 * 内链处理引擎：对文章 HTML 内容进行关键词链接替换和强制链接插入
 *
 * @param string $html           文章 HTML 内容
 * @param int    $domainId       当前域名 ID（用于站内链接查找）
 * @param string $currentPath    当前文章路径（避免链接到自身）
 * @return string 处理后的 HTML
 */
function process_internal_links($html, $domainId = 0, $currentPath = '')
{
    $cfg = internal_link_config();
    if (empty($cfg['enabled'])) {
        return $html;
    }

    $links = list_enabled_internal_links(0);
    if (empty($links)) {
        return $html;
    }

    $globalMax = isset($cfg['global_max_replace']) ? (int)$cfg['global_max_replace'] : 3;
    $nofollow = !empty($cfg['nofollow_external']);
    $newWindow = !empty($cfg['new_window']);
    $linkPriority = isset($cfg['link_priority']) ? (string)$cfg['link_priority'] : 'internal';

    // 分离 match 和 force 类型
    $matchLinks = array();
    $forceLinks = array();
    foreach ($links as $link) {
        $kw = trim((string)$link['keyword']);
        $url = trim((string)$link['url']);
        if ($kw === '' || $url === '') continue;
        // 避免链接到当前页面
        if ($currentPath !== '' && strpos($url, $currentPath) !== false) continue;
        if ($link['mode'] === 'force') {
            $forceLinks[] = $link;
        } else {
            $matchLinks[] = $link;
        }
    }

    // 如果开启了站内优先，尝试为 match 关键词寻找站内文章链接
    if ($linkPriority === 'internal' && $domainId > 0) {
        $matchLinks = internal_links_resolve_site_urls($matchLinks, $domainId, $currentPath);
    }

    // 1. 处理匹配替换
    if (!empty($matchLinks)) {
        $html = internal_links_replace_keywords($html, $matchLinks, $globalMax, $nofollow, $newWindow);
    }

    // 2. 处理强制插入
    if (!empty($forceLinks)) {
        $html = internal_links_force_insert($html, $forceLinks, $nofollow, $newWindow);
    }

    return $html;
}

/**
 * 尝试为关键词查找站内文章 URL（站内优先策略）
 */
function internal_links_resolve_site_urls($matchLinks, $domainId, $currentPath)
{
    if (empty($matchLinks) || $domainId <= 0) return $matchLinks;
    $pdo = db_connection();
    $stmt = $pdo->prepare("SELECT path, title, seo_keywords FROM contents WHERE domain_id = :did AND status = 'published' AND path <> :cur AND path <> '' ORDER BY id DESC LIMIT 300");
    $stmt->execute(array(
        ':did' => (int)$domainId,
        ':cur' => $currentPath
    ));
    $candidates = $stmt->fetchAll(PDO::FETCH_ASSOC);
    if (empty($candidates)) {
        return $matchLinks;
    }
    foreach ($matchLinks as &$link) {
        $kw = trim((string)$link['keyword']);
        // 检查 URL 是否已经是站内链接
        $url = trim((string)$link['url']);
        if (strpos($url, '/') === 0 && strpos($url, '//') !== 0) {
            continue; // 已经是站内相对路径
        }
        foreach ($candidates as $row) {
            $title = isset($row['title']) ? (string)$row['title'] : '';
            $keywords = isset($row['seo_keywords']) ? (string)$row['seo_keywords'] : '';
            if (stripos($title, $kw) === false && stripos($keywords, $kw) === false) {
                continue;
            }
            if (!empty($row['path'])) {
                $link['_resolved_url'] = '/' . ltrim($row['path'], '/');
                $link['_is_internal'] = true;
                break;
            }
        }
    }
    unset($link);
    return $matchLinks;
}

/**
 * 在 HTML 中进行关键词链接替换（保护标签属性和已有链接内容不被替换）
 */
function internal_links_replace_keywords($html, $matchLinks, $globalMax, $nofollow, $newWindow)
{
    // 将 HTML 拆分为：标签部分和文本部分
    // 标签内容、<a>...</a> 内容、<script>/<style> 内容不做替换
    $totalReplaced = 0;
    $maxTotal = $globalMax > 0 ? $globalMax * count($matchLinks) : 999;
    $replacedKeywords = array(); // 记录每个关键词已替换次数

    foreach ($matchLinks as $link) {
        $kw = trim((string)$link['keyword']);
        if ($kw === '') continue;

        $url = isset($link['_resolved_url']) ? $link['_resolved_url'] : trim((string)$link['url']);
        $isInternal = !empty($link['_is_internal']) || (strpos($url, '/') === 0 && strpos($url, '//') !== 0);
        $perMax = isset($link['max_replace']) && (int)$link['max_replace'] > 0 ? (int)$link['max_replace'] : ($globalMax > 0 ? $globalMax : 999);

        $attrs = '';
        if ($newWindow && !$isInternal) {
            $attrs .= ' target="_blank"';
        }
        if ($nofollow && !$isInternal) {
            $attrs .= ' rel="noopener nofollow"';
        }

        $replacement = '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" title="' . htmlspecialchars($kw, ENT_QUOTES, 'UTF-8') . '"' . $attrs . '>' . htmlspecialchars($kw, ENT_QUOTES, 'UTF-8') . '</a>';

        $kwCount = 0;
        $escapedKw = preg_quote($kw, '/');

        // 使用回调逐段处理，跳过 HTML 标签和已有链接
        $html = preg_replace_callback(
            '/(<a\b[^>]*>.*?<\/a>|<script\b[^>]*>.*?<\/script>|<style\b[^>]*>.*?<\/style>|<[^>]+>)|(' . $escapedKw . ')/isu',
            function ($m) use ($replacement, &$kwCount, $perMax, &$totalReplaced, $maxTotal) {
                // 如果匹配到的是标签或已有链接，原样返回
                if (!empty($m[1])) {
                    return $m[1];
                }
                // 文本中的关键词匹配
                if ($kwCount >= $perMax || $totalReplaced >= $maxTotal) {
                    return $m[2];
                }
                $kwCount++;
                $totalReplaced++;
                return $replacement;
            },
            $html
        );

        $replacedKeywords[$kw] = $kwCount;
    }

    return $html;
}

/**
 * 强制插入链接：在文章段落中随机位置插入关键词锚文本
 */
function internal_links_force_insert($html, $forceLinks, $nofollow, $newWindow)
{
    // 拆分段落
    $parts = preg_split('/(<\/p>|<\/div>|<\/li>|<\/h[2-6]>)/iu', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
    if (count($parts) < 3) {
        // 内容太少，直接在末尾追加
        foreach ($forceLinks as $link) {
            $html .= internal_links_build_anchor($link, $nofollow, $newWindow);
        }
        return $html;
    }

    // 收集可插入的位置索引（在段落闭合标签之前）
    $insertablePositions = array();
    for ($i = 0; $i < count($parts) - 1; $i++) {
        if (preg_match('/^<\/(p|div|li)>/i', $parts[$i])) {
            // 前一个 part 是文本内容
            if ($i > 0 && trim(strip_tags($parts[$i - 1])) !== '') {
                $insertablePositions[] = $i - 1;
            }
        }
    }

    if (empty($insertablePositions)) {
        foreach ($forceLinks as $link) {
            $html .= internal_links_build_anchor($link, $nofollow, $newWindow);
        }
        return $html;
    }

    // 均匀分布强制链接到不同段落位置
    $posCount = count($insertablePositions);
    $linkCount = count($forceLinks);
    foreach ($forceLinks as $idx => $link) {
        $anchor = internal_links_build_anchor($link, $nofollow, $newWindow);
        $posIdx = $insertablePositions[(int)floor($idx * $posCount / $linkCount) % $posCount];
        $parts[$posIdx] .= $anchor;
    }

    return implode('', $parts);
}

function internal_links_build_anchor($link, $nofollow, $newWindow)
{
    $kw = trim((string)$link['keyword']);
    $url = trim((string)$link['url']);
    $isInternal = strpos($url, '/') === 0 && strpos($url, '//') !== 0;

    $attrs = '';
    if ($newWindow && !$isInternal) {
        $attrs .= ' target="_blank"';
    }
    if ($nofollow && !$isInternal) {
        $attrs .= ' rel="noopener nofollow"';
    }

    return '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" title="' . htmlspecialchars($kw, ENT_QUOTES, 'UTF-8') . '"' . $attrs . '>' . htmlspecialchars($kw, ENT_QUOTES, 'UTF-8') . '</a>';
}

// ========== 蜘蛛访问统计 ==========

function ensure_spider_tables()
{
    static $checked = false;
    if ($checked) return;
    $checked = true;
    $pdo = db_connection();

    if (!table_exists('spider_logs')) {
        $pdo->exec("CREATE TABLE `spider_logs` (
            `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            `spider_name` VARCHAR(40) NOT NULL DEFAULT '' COMMENT '蜘蛛名称',
            `domain` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '被访问域名',
            `url` VARCHAR(800) NOT NULL DEFAULT '' COMMENT '被访问URL路径',
            `status_code` SMALLINT UNSIGNED NOT NULL DEFAULT 200 COMMENT 'HTTP状态码',
            `ip` VARCHAR(45) NOT NULL DEFAULT '' COMMENT '蜘蛛IP',
            `user_agent` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '完整UA',
            `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (`id`),
            KEY `idx_spider_name` (`spider_name`),
            KEY `idx_domain` (`domain`),
            KEY `idx_created_at` (`created_at`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='蜘蛛访问明细日志'");
    }

    if (!table_exists('spider_daily')) {
        $pdo->exec("CREATE TABLE `spider_daily` (
            `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            `day` DATE NOT NULL COMMENT '日期',
            `spider_name` VARCHAR(40) NOT NULL DEFAULT '',
            `domain` VARCHAR(200) NOT NULL DEFAULT '',
            `hits` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '访问次数',
            `status_2xx` INT UNSIGNED NOT NULL DEFAULT 0,
            `status_3xx` INT UNSIGNED NOT NULL DEFAULT 0,
            `status_4xx` INT UNSIGNED NOT NULL DEFAULT 0,
            `status_5xx` INT UNSIGNED NOT NULL DEFAULT 0,
            PRIMARY KEY (`id`),
            UNIQUE KEY `uk_day_spider_domain` (`day`, `spider_name`, `domain`),
            KEY `idx_day` (`day`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='蜘蛛访问每日汇总'");
    }
}

function spider_definitions()
{
    return array(
        'Baiduspider'  => array('pattern' => '/Baiduspider/i',          'label' => '百度蜘蛛',   'color' => '#2932E1', 'cidrs' => array('111.206.0.0/16', '123.125.0.0/16', '180.76.0.0/20', '220.181.0.0/16', '36.110.128.0/17', '180.97.0.0/18'), 'rdns' => array('.baidu.com')),
        'Googlebot'    => array('pattern' => '/Googlebot/i',            'label' => '谷歌蜘蛛',   'color' => '#4285F4', 'cidrs' => array('66.249.64.0/19', '66.249.71.0/24', '66.249.74.0/24'), 'rdns' => array('.googlebot.com', '.google.com')),
        'bingbot'      => array('pattern' => '/bingbot|msnbot/i',       'label' => '必应蜘蛛',   'color' => '#00809D', 'cidrs' => array('157.55.39.0/24', '207.46.13.0/24', '40.77.167.0/24'), 'rdns' => array('.search.msn.com')),
        'Sogou'        => array('pattern' => '/Sogou/i',                'label' => '搜狗蜘蛛',   'color' => '#FF6600', 'cidrs' => array('118.184.176.0/22', '123.180.0.0/14', '123.126.0.0/16', '61.135.0.0/16'), 'rdns' => array('.crawl.sogou.com')),
        '360Spider'    => array('pattern' => '/360Spider|HaoSou/i',     'label' => '360蜘蛛',    'color' => '#00B050', 'cidrs' => array('180.153.0.0/16', '180.163.128.0/17', '42.224.0.0/12'), 'rdns' => array('.so.com')),
        'Bytespider'   => array('pattern' => '/Bytespider|Bytedance|ToutiaoSpider/i', 'label' => '头条蜘蛛',   'color' => '#FE2C55', 'cidrs' => array('110.249.201.0/24', '111.225.148.0/24', '220.243.135.0/24'), 'rdns' => array('.crawl.bytedance.com')),
        'YisouSpider'  => array('pattern' => '/YisouSpider/i',          'label' => '神马蜘蛛',   'color' => '#FF9900', 'cidrs' => array('42.156.128.0/17', '42.120.128.0/17', '106.11.144.0/20', '106.11.153.0/24'), 'rdns' => array('.crawl.sm.cn')),
        'YandexBot'    => array('pattern' => '/YandexBot/i',            'label' => 'Yandex',     'color' => '#FF0000', 'cidrs' => array(), 'rdns' => array('.yandex.ru', '.yandex.net', '.yandex.com')),
        'Applebot'     => array('pattern' => '/Applebot/i',             'label' => 'Apple蜘蛛',  'color' => '#A2AAAD', 'cidrs' => array(), 'rdns' => array('.applebot.apple.com')),
        'SemrushBot'   => array('pattern' => '/SemrushBot/i',           'label' => 'Semrush',    'color' => '#FF642D', 'cidrs' => array(), 'rdns' => array('.semrush.com')),
        'AhrefsBot'    => array('pattern' => '/AhrefsBot/i',            'label' => 'Ahrefs',     'color' => '#174AFF', 'cidrs' => array(), 'rdns' => array('.ahrefs.com')),
        'DotBot'       => array('pattern' => '/DotBot/i',               'label' => 'DotBot',     'color' => '#6C757D', 'cidrs' => array(), 'rdns' => array('.dotbot.com')),
        'Other'        => array('pattern' => '/bot|crawl|spider|slurp|ia_archiver/i', 'label' => '其他蜘蛛', 'color' => '#94A3B8')
    );
}

function spider_ipv4_in_cidr($ip, $cidr)
{
    if (strpos($ip, ':') !== false || strpos($cidr, '/') === false) return false;
    list($subnet, $mask) = explode('/', $cidr, 2);
    $ipLong = ip2long($ip);
    $subnetLong = ip2long($subnet);
    $mask = (int)$mask;
    if ($ipLong === false || $subnetLong === false || $mask < 0 || $mask > 32) return false;
    $maskLong = $mask === 0 ? 0 : (-1 << (32 - $mask));
    return (($ipLong & $maskLong) === ($subnetLong & $maskLong));
}

function spider_ip_in_cidrs($ip, $cidrs)
{
    if (!is_array($cidrs) || empty($cidrs)) return false;
    foreach ($cidrs as $cidr) {
        if (spider_ipv4_in_cidr($ip, $cidr)) return true;
    }
    return false;
}

function spider_reverse_dns_matches($ip, $suffixes)
{
    if (!is_array($suffixes) || empty($suffixes)) return false;
    if (!filter_var($ip, FILTER_VALIDATE_IP)) return false;
    $host = @gethostbyaddr($ip);
    if (!$host || $host === $ip) return false;
    $host = strtolower(rtrim($host, '.'));
    foreach ($suffixes as $suffix) {
        $suffix = strtolower((string)$suffix);
        if ($suffix !== '' && substr($host, -strlen($suffix)) === $suffix) {
            $forwardIps = @gethostbynamel($host);
            return is_array($forwardIps) && in_array($ip, $forwardIps, true);
        }
    }
    return false;
}

function verify_spider_ip($spiderName, $ip)
{
    $defs = spider_definitions();
    if (!isset($defs[$spiderName]) || $spiderName === 'Other') return false;
    $def = $defs[$spiderName];
    if (spider_ip_in_cidrs($ip, isset($def['cidrs']) ? $def['cidrs'] : array())) return true;
    return spider_reverse_dns_matches($ip, isset($def['rdns']) ? $def['rdns'] : array());
}

function detect_spider($ua, $ip = '')
{
    if ($ua === '') return '';
    $defs = spider_definitions();
    foreach ($defs as $name => $def) {
        if ($name === 'Other') continue;
        if (preg_match($def['pattern'], $ua)) {
            return ($ip !== '' && verify_spider_ip($name, $ip)) ? $name : '';
        }
    }
    return '';
}

function log_spider_visit($spiderName, $domain, $url, $statusCode, $ip, $ua)
{
    try {
        ensure_spider_tables();
        $pdo = db_connection();
        $day = date('Y-m-d');

        $pdo->prepare("INSERT INTO spider_logs (spider_name, domain, url, status_code, ip, user_agent, created_at) VALUES (:sn, :domain, :url, :sc, :ip, :ua, NOW())")
            ->execute(array(':sn' => $spiderName, ':domain' => substr($domain, 0, 200), ':url' => substr($url, 0, 800), ':sc' => (int)$statusCode, ':ip' => substr($ip, 0, 45), ':ua' => substr($ua, 0, 500)));

        $statusBucket = 'status_2xx';
        $sc = (int)$statusCode;
        if ($sc >= 300 && $sc < 400) $statusBucket = 'status_3xx';
        elseif ($sc >= 400 && $sc < 500) $statusBucket = 'status_4xx';
        elseif ($sc >= 500) $statusBucket = 'status_5xx';

        $pdo->prepare("INSERT INTO spider_daily (day, spider_name, domain, hits, {$statusBucket}) VALUES (:day, :sn, :domain, 1, 1) ON DUPLICATE KEY UPDATE hits = hits + 1, {$statusBucket} = {$statusBucket} + 1")
            ->execute(array(':day' => $day, ':sn' => $spiderName, ':domain' => substr($domain, 0, 200)));
    } catch (Exception $e) {
        // 静默失败，不影响前端请求
    }
}

function track_spider_if_needed()
{
    if (!db_is_ready()) return;
    $ua = isset($_SERVER['HTTP_USER_AGENT']) ? (string)$_SERVER['HTTP_USER_AGENT'] : '';
    $ip = request_remote_addr();
    $spiderName = detect_spider($ua, $ip);
    if ($spiderName === '') return;
    $domain = isset($_SERVER['HTTP_HOST']) ? (string)$_SERVER['HTTP_HOST'] : '';
    $url = isset($_SERVER['REQUEST_URI']) ? (string)$_SERVER['REQUEST_URI'] : '/';
    log_spider_visit($spiderName, $domain, $url, http_response_code(), $ip, $ua);
}

function spider_build_domain_filter($domain, $domainWhitelist, &$params)
{
    $sql = '';
    if ($domain !== '') {
        $sql .= " AND domain = :domain";
        $params[':domain'] = $domain;
    } elseif (is_array($domainWhitelist)) {
        if (empty($domainWhitelist)) {
            $sql .= " AND 1=0";
        } else {
            $ph = array();
            foreach ($domainWhitelist as $i => $d) {
                $ph[] = ':dw' . $i;
                $params[':dw' . $i] = $d;
            }
            $sql .= " AND domain IN (" . implode(',', $ph) . ")";
        }
    }
    return $sql;
}

function spider_daily_stats($days = 30, $domain = '', $domainWhitelist = null)
{
    ensure_spider_tables();
    $pdo = db_connection();
    $startDate = date('Y-m-d', strtotime("-{$days} days"));
    $sql = "SELECT day, spider_name, SUM(hits) AS hits FROM spider_daily WHERE day >= :start";
    $params = array(':start' => $startDate);
    $sql .= spider_build_domain_filter($domain, $domainWhitelist, $params);
    $sql .= " GROUP BY day, spider_name ORDER BY day ASC";
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function spider_summary_stats($days = 30, $domain = '', $domainWhitelist = null)
{
    ensure_spider_tables();
    $pdo = db_connection();
    $startDate = date('Y-m-d', strtotime("-{$days} days"));
    $sql = "SELECT spider_name, SUM(hits) AS total_hits, SUM(status_2xx) AS s2, SUM(status_3xx) AS s3, SUM(status_4xx) AS s4, SUM(status_5xx) AS s5 FROM spider_daily WHERE day >= :start";
    $params = array(':start' => $startDate);
    $sql .= spider_build_domain_filter($domain, $domainWhitelist, $params);
    $sql .= " GROUP BY spider_name ORDER BY total_hits DESC";
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function spider_top_urls($days = 30, $domain = '', $limit = 20, $domainWhitelist = null)
{
    ensure_spider_tables();
    $pdo = db_connection();
    $startDate = date('Y-m-d H:i:s', strtotime("-{$days} days"));
    $sql = "SELECT url, spider_name, COUNT(*) AS hits FROM spider_logs WHERE created_at >= :start";
    $params = array(':start' => $startDate);
    $sql .= spider_build_domain_filter($domain, $domainWhitelist, $params);
    $sql .= " GROUP BY url, spider_name ORDER BY hits DESC LIMIT " . (int)$limit;
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function spider_recent_logs($domain = '', $limit = 50, $spiderName = '', $domainWhitelist = null)
{
    ensure_spider_tables();
    $pdo = db_connection();
    $sql = "SELECT id, spider_name, domain, url, status_code, ip, user_agent, created_at FROM spider_logs WHERE 1=1";
    $params = array();
    $sql .= spider_build_domain_filter($domain, $domainWhitelist, $params);
    if ($spiderName !== '') {
        $sql .= " AND spider_name = :sn";
        $params[':sn'] = $spiderName;
    }
    $sql .= " ORDER BY id DESC LIMIT " . (int)$limit;
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function user_domain_names($userId = 0)
{
    $uid = $userId > 0 ? (int)$userId : current_user_id();
    if ($uid <= 0) {
        return array();
    }
    $pdo = db_connection();
    $stmt = $pdo->prepare("SELECT domain FROM domains WHERE user_id = :uid AND domain <> '' ORDER BY id DESC");
    $stmt->execute(array(':uid' => $uid));
    return $stmt->fetchAll(PDO::FETCH_COLUMN);
}

function spider_domain_list()
{
    ensure_spider_tables();
    $pdo = db_connection();
    if (!is_super_admin()) {
        $userDomains = user_domain_names();
        if (empty($userDomains)) {
            return array();
        }
        $placeholders = array();
        $params = array();
        foreach ($userDomains as $i => $d) {
            $placeholders[] = ':ud' . $i;
            $params[':ud' . $i] = $d;
        }
        $sql = "SELECT DISTINCT dm.domain AS d FROM domains dm "
             . "INNER JOIN contents c ON c.domain_id = dm.id AND c.status = 'published' "
             . "LEFT JOIN templates t ON t.id = c.template_id "
             . "WHERE dm.status = 'enabled' AND dm.domain <> '' AND dm.domain IN (" . implode(',', $placeholders) . ") "
             . "AND (t.type = 'homepage' OR c.path IN ('index','home')) "
             . "ORDER BY d ASC";
        $stmt = $pdo->prepare($sql);
        $stmt->execute($params);
        return $stmt->fetchAll(PDO::FETCH_COLUMN);
    }
    $sql = "SELECT DISTINCT dm.domain AS d FROM domains dm "
         . "INNER JOIN contents c ON c.domain_id = dm.id AND c.status = 'published' "
         . "LEFT JOIN templates t ON t.id = c.template_id "
         . "WHERE dm.status = 'enabled' AND dm.domain <> '' AND (t.type = 'homepage' OR c.path IN ('index','home')) "
         . "ORDER BY d ASC";
    $stmt = $pdo->query($sql);
    return $stmt->fetchAll(PDO::FETCH_COLUMN);
}

function spider_cleanup_old_logs($keepDays = 90)
{
    ensure_spider_tables();
    $pdo = db_connection();
    $cutoff = date('Y-m-d H:i:s', strtotime("-{$keepDays} days"));
    $pdo->prepare("DELETE FROM spider_logs WHERE created_at < :cutoff")->execute(array(':cutoff' => $cutoff));
    $cutoffDay = date('Y-m-d', strtotime("-{$keepDays} days"));
    $pdo->prepare("DELETE FROM spider_daily WHERE day < :cutoff")->execute(array(':cutoff' => $cutoffDay));
}

function build_sitemap_xml($domainId, $domainName)
{
    $pdo = db_connection();
    $baseUrl = 'https://' . $domainName;

    $urls = array();
    $urls[] = array('loc' => $baseUrl . '/', 'priority' => '1.0', 'changefreq' => 'daily');

    try {
        $stmt = $pdo->prepare("SELECT path, updated_at, static_file_path FROM contents WHERE domain_id = :did AND status = 'published' ORDER BY updated_at DESC");
        $stmt->execute(array(':did' => (int)$domainId));
        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
        foreach ($rows as $row) {
            $path = isset($row['path']) ? trim((string)$row['path']) : '';
            if ($path === '' || $path === 'index' || $path === 'home') continue;
            $publicPath = content_public_url_path($path, content_has_static_file($row));
            $lastmod = isset($row['updated_at']) ? date('Y-m-d', strtotime($row['updated_at'])) : date('Y-m-d');
            $urls[] = array('loc' => $baseUrl . $publicPath, 'lastmod' => $lastmod, 'priority' => '0.8', 'changefreq' => 'weekly');
        }
    } catch (Exception $e) {}

    try {
        $stmt = $pdo->prepare("SELECT slug FROM tags WHERE domain_id = :did AND status = 'enabled' ORDER BY id DESC");
        $stmt->execute(array(':did' => (int)$domainId));
        $tags = $stmt->fetchAll(PDO::FETCH_COLUMN);
        foreach ($tags as $slug) {
            $slug = trim((string)$slug);
            if ($slug !== '') {
                $urls[] = array('loc' => $baseUrl . '/tag/' . $slug . '/', 'priority' => '0.5', 'changefreq' => 'weekly');
            }
        }
    } catch (Exception $e) {}

    $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
    $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
    foreach ($urls as $u) {
        $xml .= "  <url>\n";
        $xml .= '    <loc>' . htmlspecialchars($u['loc'], ENT_XML1, 'UTF-8') . "</loc>\n";
        if (isset($u['lastmod'])) {
            $xml .= '    <lastmod>' . $u['lastmod'] . "</lastmod>\n";
        }
        $xml .= '    <changefreq>' . $u['changefreq'] . "</changefreq>\n";
        $xml .= '    <priority>' . $u['priority'] . "</priority>\n";
        $xml .= "  </url>\n";
    }
    $xml .= '</urlset>' . "\n";
    return $xml;
}

function build_sitemap_html($domainId, $domainName, $siteName)
{
    $pdo = db_connection();
    $baseUrl = 'https://' . $domainName;
    $siteName = $siteName !== '' ? $siteName : $domainName;

    $articles = array();
    try {
        $stmt = $pdo->prepare("SELECT title, path, updated_at, static_file_path FROM contents WHERE domain_id = :did AND status = 'published' ORDER BY updated_at DESC");
        $stmt->execute(array(':did' => (int)$domainId));
        $articles = $stmt->fetchAll(PDO::FETCH_ASSOC);
    } catch (Exception $e) {}

    $tags = array();
    try {
        $stmt = $pdo->prepare("SELECT name, slug FROM tags WHERE domain_id = :did AND status = 'enabled' ORDER BY id DESC");
        $stmt->execute(array(':did' => (int)$domainId));
        $tags = $stmt->fetchAll(PDO::FETCH_ASSOC);
    } catch (Exception $e) {}

    $htmlLang = isset($domain['site_language']) ? normalize_domain_language($domain['site_language']) : 'zh-CN';
    $htmlLangAttr = strtolower(str_replace('_', '-', substr($htmlLang, 0, 2)));
    $html = '<!DOCTYPE html><html lang="' . e($htmlLangAttr) . '"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">';
    $html .= '<title>网站地图 - ' . e($siteName) . '</title>';
    $html .= '<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f8fafc;color:#1e293b;line-height:1.7}.wrap{max-width:960px;margin:0 auto;padding:32px 24px}h1{font-size:22px;margin-bottom:8px}h2{font-size:17px;margin:28px 0 12px;color:#334155;border-bottom:1px solid #e2e8f0;padding-bottom:8px}.desc{color:#64748b;font-size:14px;margin-bottom:24px}ul{list-style:none;padding:0}li{padding:6px 0;border-bottom:1px solid #f1f5f9}li a{color:#2563eb;text-decoration:none;font-size:14px}li a:hover{text-decoration:underline}li .date{float:right;color:#94a3b8;font-size:13px}.back{display:inline-block;margin-top:24px;color:#4f46e5;font-size:14px}</style>';
    $html .= '</head><body><div class="wrap">';
    $html .= '<h1>网站地图</h1><p class="desc">' . e($siteName) . ' 全站页面索引，共 ' . count($articles) . ' 个页面。</p>';
    $html .= '<h2>页面列表</h2><ul>';
    $html .= '<li><a href="/">首页</a></li>';
    foreach ($articles as $a) {
        $path = isset($a['path']) ? trim((string)$a['path']) : '';
        if ($path === '' || $path === 'index' || $path === 'home') continue;
        $title = isset($a['title']) ? (string)$a['title'] : $path;
        $date = isset($a['updated_at']) ? date('Y-m-d', strtotime($a['updated_at'])) : '';
        $publicPath = content_public_url_path($path, content_has_static_file($a));
        $html .= '<li><a href="' . e($publicPath) . '">' . e($title) . '</a>' . ($date !== '' ? '<span class="date">' . $date . '</span>' : '') . '</li>';
    }
    $html .= '</ul>';
    if (!empty($tags)) {
        $html .= '<h2>标签</h2><ul>';
        foreach ($tags as $t) {
            $name = isset($t['name']) ? (string)$t['name'] : '';
            $slug = isset($t['slug']) ? (string)$t['slug'] : '';
            if ($slug !== '') {
                $html .= '<li><a href="/tag/' . e($slug) . '/">' . e($name !== '' ? $name : $slug) . '</a></li>';
            }
        }
        $html .= '</ul>';
    }
    $html .= '<a class="back" href="/">&larr; 返回首页</a>';
    $html .= '</div></body></html>';
    return $html;
}

function build_sitemap_footer_html()
{
    static $cache = null;
    if ($cache !== null) {
        return $cache;
    }
    $config = app_runtime_config();
    $sitemapCfg = isset($config['sitemap']) && is_array($config['sitemap']) ? $config['sitemap'] : array();
    if (empty($sitemapCfg['enabled'])) {
        $cache = '';
        return '';
    }
    $cache = '<div class="sitemap-footer-links" style="text-align:center;padding:8px 0;font-size:12px;color:#94a3b8;"><a href="/sitemap.xml" style="color:#64748b;text-decoration:none;" target="_blank">XML地图</a> | <a href="/sitemap.html" style="color:#64748b;text-decoration:none;" target="_blank">网站地图</a></div>';
    return $cache;
}

function generate_default_robots_txt($currentDomain = '')
{
    $lines = array();
    $lines[] = 'User-agent: *';
    $lines[] = 'Allow: /';
    $lines[] = '';
    $lines[] = 'Disallow: /app/';
    $lines[] = 'Disallow: /assets/';
    $lines[] = 'Disallow: /runtime/';
    $lines[] = '';

    $currentDomain = trim((string)$currentDomain);
    if ($currentDomain !== '') {
        $lines[] = 'Sitemap: https://' . $currentDomain . '/sitemap.xml';
    } else {
        try {
            $pdo = db_connection();
            $homepageIds = list_domain_ids_with_published_homepage();
            if (!empty($homepageIds)) {
                $placeholders = implode(',', array_keys($homepageIds));
                $stmt = $pdo->query("SELECT domain FROM domains WHERE status = 'enabled' AND domain <> '' AND id IN ({$placeholders}) ORDER BY id ASC");
                $domains = $stmt->fetchAll(PDO::FETCH_COLUMN);
                foreach ($domains as $d) {
                    $d = trim((string)$d);
                    if ($d !== '') {
                        $lines[] = 'Sitemap: https://' . $d . '/sitemap.xml';
                    }
                }
            }
        } catch (Exception $e) {}
    }

    return implode("\n", $lines) . "\n";
}

function get_robots_txt_content($currentDomain = '')
{
    $config = app_runtime_config();
    $robotsCfg = isset($config['robots']) && is_array($config['robots']) ? $config['robots'] : array();
    $content = isset($robotsCfg['content']) ? trim((string)$robotsCfg['content']) : '';
    if ($content === '') {
        return generate_default_robots_txt($currentDomain);
    }
    $currentDomain = trim((string)$currentDomain);
    if ($currentDomain !== '') {
        $content = preg_replace('/^Sitemap:.*$/mi', '', $content);
        $content = preg_replace('/\n{3,}/', "\n\n", trim($content));
        $content .= "\n\nSitemap: https://" . $currentDomain . "/sitemap.xml\n";
    }
    return $content;
}
