<?php

namespace XZD\AI\V5\Core;

/**
 * 插件心跳系统 — 多路径保障定时任务百分百触发
 *
 * 四条触发路径：WP-Cron（主力）| 后台JS ping | shutdown钩子（兜底）| Action Scheduler（最后防线）
 */
class Heartbeat
{
    private const LOCK_KEY      = 'xzd_heartbeat_lock';
    public  const LAST_TICK_KEY = 'xzd_heartbeat_last_tick';
    private const INTERVAL      = 30;
    private const LOCK_TTL      = 300;
    private const MAX_TICK_TIME = 25;

    /**
     * 心跳主脉冲 — 检查并触发所有逾期的定时任务
     *
     * @return array{ok: bool, tasks_dispatched: int, queue_drained: int, scan_triggered: bool, learning_triggered: bool, elapsed_ms: float}
     */
    public static function tick(): array
    {
        $start = microtime(true);

        // 防并发锁，lock->acquire(60) 可在持锁超过60秒后强制释放
        $lock = Lock::for(self::LOCK_KEY, self::LOCK_TTL);
        if (!$lock->acquire(60)) {
            // 锁获取失败时仍更新 LAST_TICK_KEY，防止 shutdown selfPing 无限反馈循环
            set_transient(self::LAST_TICK_KEY, time(), 600);
            return [
                'ok'               => true,
                'tasks_dispatched' => 0,
                'queue_drained'    => 0,
                'scan_triggered'   => false,
                'learning_triggered' => false,
                'elapsed_ms'       => round((microtime(true) - $start) * 1000, 1),
                'skipped'          => 'locked',
            ];
        }

        // 锁获取成功后立即更新时间戳，防止 shutdown selfPing 误判为逾期
        set_transient(self::LAST_TICK_KEY, time(), 600);

        // FPM 启动保护移入 try 内，确保任何异常都不会泄漏锁
        try {
            // FPM 启动后 120 秒内只设时间戳不做同步处理
            $fpmStartFile = WP_CONTENT_DIR . '/.xzd_fpm_start';
            $startTime = @filemtime($fpmStartFile);
            if (!$startTime) {
                @touch($fpmStartFile);
                $startTime = time();
            }
            if ($startTime && (time() - $startTime) > 86400) {
                @touch($fpmStartFile);
                $startTime = time();
            }
            if ($startTime && (time() - $startTime) < 120) {
                return [
                    'ok'                => true,
                    'tasks_dispatched'  => 0,
                    'queue_drained'     => 0,
                    'scan_triggered'    => false,
                    'learning_triggered' => false,
                    'elapsed_ms'        => 0,
                    'skipped'           => 'boot_protection',
                ];
            }

            // AS Runner 保活和僵尸清理必须在负载检查之前，否则高负载时被拦截
            // 确保 AS Runner 存活（每次 tick 检查）
            if (function_exists('as_has_scheduled_action') && function_exists('as_schedule_single_action')) {
                if (!as_has_scheduled_action('action_scheduler_run_queue')) {
                    as_schedule_single_action(time() + 5, 'action_scheduler_run_queue');
                    Logger::info('heartbeat', 'AS Runner缺失，已补调');
                }
            }

            // 清理 AS 僵尸状态（10 分钟冷却在 cleanASStaleState 内部）
            self::cleanASStaleState();

            // 看门狗：检测过去 10 分钟是否有任何 pipeline 处理，无则强制自救
            $wdKey = 'xzd_watchdog_last_ok';
            $wdLast = (int) get_transient($wdKey);
            $wdNow = time();

            // 所有 AS COUNT 查询合并为一次，后续复用变量
            global $wpdb;
            $asPendingNoClaim = (int) $wpdb->get_var(
                "SELECT COUNT(*) FROM {$wpdb->prefix}actionscheduler_actions
                 WHERE hook = 'xzd_worker_process' AND status = 'pending' AND claim_id = 0"
            );
            $asPendingAll = (int) $wpdb->get_var(
                "SELECT COUNT(*) FROM {$wpdb->prefix}actionscheduler_actions
                 WHERE hook = 'xzd_worker_process' AND status = 'pending'"
            );
            $pendingCount = $asPendingNoClaim;
            if ($pendingCount > 0) {
                $staleClaimId = $wpdb->get_var($wpdb->prepare(
                    "SELECT c.claim_id FROM {$wpdb->prefix}actionscheduler_claims c
                     WHERE c.date_created_gmt < %s
                     AND EXISTS (SELECT 1 FROM {$wpdb->prefix}actionscheduler_actions a
                                 WHERE a.claim_id = c.claim_id AND a.status = 'pending')
                     LIMIT 1",
                    date('Y-m-d H:i:s', time() - 1800)
                ));
                if ($staleClaimId) {
                    Logger::info('heartbeat', "检测到僵尸claim#{$staleClaimId}阻塞{$pendingCount}个pending，强制清理");
                    $wpdb->delete($wpdb->prefix . 'actionscheduler_claims', ['claim_id' => $staleClaimId]);
                    $wpdb->query($wpdb->prepare(
                        "UPDATE {$wpdb->prefix}actionscheduler_actions SET claim_id = 0
                         WHERE claim_id = %d AND status = 'pending'",
                        $staleClaimId
                    ));
                }
            }

            if ($wdLast && ($wdNow - $wdLast) > 600) {
                // 超过 10 分钟无处理 → 强制清除状态，触发全量恢复
                // 不再 delete_transient 删除当前 tick 持有的心跳锁，交给 Lock::acquire(60) 的 forceAfter 机制处理
                Logger::info('heartbeat', "看门狗: 超过10分钟无处理，触发强制自救");
                delete_transient('xzd_as_circuit');
                if (class_exists(\XZD\AI\V5\Tools\PipelineEngine::class)) {
                    \XZD\AI\V5\Tools\PipelineEngine::recoverAllStaleRunning();
                }
            }
            // AS Runner 不再同步调 run()（会阻塞进程 30+ 秒），改为确保调度 + 异步并行 Worker
            if ($asPendingNoClaim > 0) {
                if (function_exists('as_has_scheduled_action') && !as_has_scheduled_action('action_scheduler_run_queue')) {
                    as_schedule_single_action(time() + 5, 'action_scheduler_run_queue');
                }
            }

            // 弹性负载保护 — 核心理念：永远不停工，负载高时减速，负载低时全速
            $loadAvg = function_exists('sys_getloadavg') ? sys_getloadavg() : [0];
            $load1m  = $loadAvg[0];
            $maxLoad = (float) Config::instance()->get('pipeline.max_load', '2.0');

            $drainCap = 1; // 保底 1 条，绝不停工

            if ($load1m <= $maxLoad) {
                $drainCap = 10;
            } elseif ($load1m <= $maxLoad * 2.5) {
                $drainCap = 5;
            } elseif ($load1m <= $maxLoad * 5) {
                $drainCap = 2;
            }
            // 极端负载保底 1 条（默认值）

            // 内存不足时也保底 1 条
            $memLimit = Config::instance()->int('pipeline.memory_limit_mb', 128);
            if (memory_get_usage(true) > $memLimit * 1024 * 1024 * 0.8) {
                $drainCap = 1;
            }

            $dispatched  = 0;
            $drained     = 0;
            $scanTriggered = false;
            $learningTriggered = false;

            // 调度 QueueManager（动态批次，队列深时加速清空）
            if (class_exists(\XZD\AI\V5\Tools\QueueManager::class)) {
                $dispatched += \XZD\AI\V5\Tools\QueueManager::dispatch(0);
            }

            // 恢复卡死的 running 文章（每 5 次 tick 执行一次）
            $tickCount = (int) get_transient('xzd_heartbeat_tick_count');
            if ($tickCount % 5 === 0 && class_exists(\XZD\AI\V5\Tools\PipelineEngine::class)) {
                \XZD\AI\V5\Tools\PipelineEngine::recoverAllStaleRunning();
            }
            set_transient('xzd_heartbeat_tick_count', $tickCount + 1, HOUR_IN_SECONDS);

            $asPending = 0;
            $circuitKey = 'xzd_as_circuit';
            $circuit = ['last_pending' => 0, 'stuck_since' => 0, 'min_pending' => 9999];
            $circuitOpen = false;

            // 水位控制 drain：新文章优先，旧文章按 AS 剩余容量节流
            if (class_exists(PendingQueue::class) && class_exists(\XZD\AI\V5\Tools\QueueManager::class)) {
                // 复用 tick 开头查询的 asPendingAll 变量
                $asPending = $asPendingAll;

                // AS 进度断路器：积压持续不降则停止 drain 旧文章
                $circuit = Config::getArrayTransient($circuitKey, ['last_pending' => 0, 'stuck_since' => 0, 'min_pending' => 9999]);
                if ($asPending > 0 && $asPending >= $circuit['min_pending'] - 3) {
                    // 积压未显著下降（波动 ≤3 条），保持计时
                    if ($circuit['stuck_since'] === 0) {
                        $circuit['stuck_since'] = time();
                    }
                    $circuit['min_pending'] = min($circuit['min_pending'], $asPending);
                } else if ($asPending <= \XZD\AI\V5\Tools\QueueManager::RESUME_AT) {
                    // 降到安全水位才复位断路器
                    $circuit['stuck_since'] = 0;
                    $circuit['min_pending'] = 9999;
                }
                $circuit['last_pending'] = $asPending;
                $circuitOpen = ($circuit['stuck_since'] > 0 && (time() - $circuit['stuck_since']) > 300);
                set_transient($circuitKey, $circuit, 600);

                // AS 剩余容量 = 上限 - 当前积压，保底 1 条；断路器打开时设为 0
                $maxOld = $circuitOpen ? 0 : max(1, \XZD\AI\V5\Tools\QueueManager::MAX_BACKLOG - $asPending);
                $maxOld = min($maxOld, $drainCap);

                if ($circuitOpen) {
                    Logger::info('heartbeat', "AS断路器打开: {$asPending}条积压" . floor((time() - $circuit['stuck_since']) / 60) . "分钟未降，暂停drain旧文章等待AS恢复");
                }

                $drainResult = PendingQueue::drainNewFirst($maxOld);
                $priorityBatch = $drainResult['priority'] ?? [];
                $normalBatch   = $drainResult['normal'] ?? [];
                $drained = count($priorityBatch) + count($normalBatch);

                $allBatch = array_merge($priorityBatch, $normalBatch);
                foreach ($allBatch as $item) {
                    $isPipeline = ($item['type'] ?? '') === 'pipeline';
                    $isNewArticle = ($item['event'] ?? '') === 'publish_post';

                    if ($isPipeline) {
                        if (function_exists('as_schedule_single_action')) {
                            $pid = (int)($item['post_id'] ?? 0);
                            // AS 去重：检查同一文章是否已有 pending/in-progress 的 action
                            $existingAction = $wpdb->get_var($wpdb->prepare(
                                "SELECT action_id FROM {$wpdb->prefix}actionscheduler_actions
                                 WHERE hook = 'xzd_worker_process'
                                 AND status IN ('pending', 'in-progress')
                                 AND args LIKE %s
                                 LIMIT 1",
                                '%"post_id";i:' . $pid . ';%'
                            ));
                            if ($existingAction) {
                                continue;
                            }
                            $scheduleTime = $isNewArticle ? time() + 1 : time() + 5;
                            as_schedule_single_action($scheduleTime, 'xzd_worker_process', [[
                                'post_id' => $pid,
                                'event'   => $item['event'] ?? 'publish_post',
                            ]], 'xzd_worker');
                        }
                    } else {
                        self::processQueueItem($item);
                    }
                }

                if ($drained > 0) {
                    $totalBacklog = \XZD\AI\V5\Tools\QueueManager::getTotalBacklog();
                    Logger::info('heartbeat', "drain: 新" . count($priorityBatch) . "篇+旧" . count($normalBatch) . "篇, AS{$asPending}/" . \XZD\AI\V5\Tools\QueueManager::MAX_BACKLOG . ", 总{$totalBacklog}, load{$load1m}, cap{$drainCap}");
                }
            }

            // AS 自救：积压 >0 时清理僵尸状态后触发 Runner
            $concurrency = 1;
            $circuitOpenAs = false;
            if ($asPendingNoClaim > 0) {
                $userMax = \XZD\AI\V5\Core\Config::instance()->int('worker.max_concurrent', 0);
                $concurrency = $userMax > 0 ? $userMax : \XZD\AI\V5\Core\Plugin::calcDynamicConcurrency();

                // 清理 AS 僵尸状态，防止 stale claims 阻塞 Runner
                self::cleanASStaleState();

                // 断路器打开时绕过 AS 直接处理，确保积压持续消耗
                $circuitAs = Config::getArrayTransient($circuitKey, ['stuck_since' => 0]);
                $circuitOpenAs = ($circuitAs['stuck_since'] > 0 && (time() - $circuitAs['stuck_since']) > 300);

                if ($circuitOpenAs) {
                    $directDone = self::processPendingDirectly(2);
                    if ($directDone > 0) {
                        Logger::info('heartbeat', "AS绕过: 直接处理{$directDone}篇 (AS断路器已打开" . floor((time() - $circuitAs['stuck_since']) / 60) . "分钟)");
                    }
                }

                // 确保 AS Runner 已调度（不阻塞）+ 异步投放并行 Worker
                if (function_exists('as_has_scheduled_action') && !as_has_scheduled_action('action_scheduler_run_queue')) {
                    as_schedule_single_action(time() + 5, 'action_scheduler_run_queue');
                }

                // 合并批量投放异步 Runner，单次 HTTP 请求携带所有 token
                $spawnCount = $concurrency;
                self::spawnAsyncRunners($spawnCount);

                Logger::info('heartbeat', "AS自救: 积压{$asPendingNoClaim}条, 并发{$concurrency}(异步批量x{$spawnCount})");
            } else {
                set_transient('xzd_as_progress', time(), 120);
            }

            // 快速扫描（轻量 WP_Query）
            if (microtime(true) - $start < self::MAX_TICK_TIME) {
                self::tickQuickScanRecent();
            }

            // 重任务加时间保护，超时跳过留给下次 tick
            if (microtime(true) - $start < self::MAX_TICK_TIME) {
                $scanTriggered = self::tickScheduleScan();
            }
            if (microtime(true) - $start < self::MAX_TICK_TIME) {
                $learningTriggered = self::tickNightlyLearn();
            }
            if (microtime(true) - $start < self::MAX_TICK_TIME && class_exists(\XZD\AI\V5\Tools\RuleEngine::class)) {
                $dispatched += self::tickScheduledRules();
            }

            // 更新心跳时间
            $elapsed = round((microtime(true) - $start) * 1000, 1);
            set_transient(self::LAST_TICK_KEY, time(), 600);

            // 看门狗无条件更新 — tick 成功即标记系统正常
            set_transient('xzd_watchdog_last_ok', time(), 900);

            if ($dispatched > 0 || $drained > 0 || $scanTriggered || $learningTriggered) {
                Logger::info('heartbeat', "心跳完成: 分发{$dispatched}个Worker, 清空{$drained}个队列项, 扫描{$scanTriggered}, 学习{$learningTriggered}, 耗时{$elapsed}ms");
            }

            return [
                'ok'                => true,
                'tasks_dispatched'  => $dispatched,
                'queue_drained'     => $drained,
                'scan_triggered'    => $scanTriggered,
                'learning_triggered' => $learningTriggered,
                'elapsed_ms'        => $elapsed,
            ];
        } catch (\Throwable $e) {
            Logger::error('heartbeat', "心跳异常: {$e->getMessage()}");
            return [
                'ok'                => false,
                'error'             => $e->getMessage(),
                'tasks_dispatched'  => 0,
                'queue_drained'     => 0,
                'scan_triggered'    => false,
                'learning_triggered' => false,
                'elapsed_ms'        => round((microtime(true) - $start) * 1000, 1),
            ];
        } finally {
            // 确保锁一定释放（即使异常）
            try {
                $lock->release();
            } catch (\Throwable $e) {
                error_log("[XZD-AI] 心跳锁释放异常: {$e->getMessage()}");
            }
        }
    }

    /**
     * 检查是否心跳逾期（超过 INTERVAL 秒未 tick）
     */
    public static function isOverdue(): bool
    {
        // FPM 刚启动 120 秒保护期内不判定逾期
        $fpmStartFile = WP_CONTENT_DIR . '/.xzd_fpm_start';
        $startTime = @filemtime($fpmStartFile);
        if ($startTime && (time() - $startTime) < 120) {
            return false;
        }

        $lastTick = get_transient(self::LAST_TICK_KEY);
        return !$lastTick || (time() - (int)$lastTick) > self::INTERVAL;
    }

    /**
     * 获取心跳状态（供 JS ping 和仪表盘使用）
     */
    public static function getStatus(): array
    {
        $lastTick   = get_transient(self::LAST_TICK_KEY);
        $lastTickTs = $lastTick ? (int)$lastTick : 0;
        $now        = time();
        $nextIn     = $lastTickTs ? max(0, self::INTERVAL - ($now - $lastTickTs)) : 0;
        $overdue    = !$lastTickTs || ($now - $lastTickTs) > self::INTERVAL;

        $queueDepth = 0;
        if (class_exists(PendingQueue::class)) {
            $queueDepth += PendingQueue::size();
        }
        if (class_exists(\XZD\AI\V5\Tools\QueueManager::class)) {
            $qs = \XZD\AI\V5\Tools\QueueManager::getQueueStatus();
            $queueDepth += ($qs['queue_pending'] ?? 0) + ($qs['as_pending'] ?? 0);
        }

        return [
            'last_tick_at'    => $lastTickTs ? date('Y-m-d H:i:s', $lastTickTs) : null,
            'last_tick_ago_s' => $lastTickTs ? $now - $lastTickTs : null,
            'next_tick_in_s'  => $nextIn,
            'overdue'         => $overdue,
            'interval_s'      => self::INTERVAL,
            'queue_depth'     => $queueDepth,
            'healthy'         => !$overdue && $queueDepth < 50,
            'server_time'     => date('Y-m-d H:i:s'),
            'server_ts'       => $now,
        ];
    }

    /**
     * 绕过 AS 直接处理积压的 pending actions（断路器打开时使用）
     *
     * 异步投递而非同步调用 PostWorker，避免 AI 调用阻塞心跳 tick。
     */
    private static function processPendingDirectly(int $limit = 2): int
    {
        global $wpdb;
        $tableActions = $wpdb->prefix . 'actionscheduler_actions';

        try {
            $rows = $wpdb->get_results($wpdb->prepare(
                "SELECT action_id, args FROM {$tableActions}
                 WHERE hook = 'xzd_worker_process'
                 AND status = 'pending'
                 AND claim_id = 0
                 ORDER BY scheduled_date_gmt ASC
                 LIMIT %d",
                $limit
            ));

            $recovered = 0;
            foreach ($rows as $row) {
                $args = json_decode($row->args, true);
                if (empty($args[0]['post_id'])) continue;

                $postId = (int) $args[0]['post_id'];

                // 强制恢复超过 2 分钟的 running 文章
                $pipelineMeta = get_post_meta($postId, '_xzd_ai_pipeline_status', true);
                if (is_array($pipelineMeta) && ($pipelineMeta['status'] ?? '') === 'running') {
                    $startTs = \XZD\AI\V5\Tools\PipelineEngine::parseLocalTime(
                        $pipelineMeta['started_at'] ?? $pipelineMeta['created_at'] ?? ''
                    );
                    if ($startTs && (time() - $startTs) > 120) {
                        delete_post_meta($postId, '_xzd_ai_pipeline_status');
                        delete_post_meta($postId, '_xzd_ai_pipeline_retries');
                        delete_transient('xzd_lock_' . md5("xzd_worker_post_{$postId}"));
                        $recovered++;
                    }
                }
            }

            if ($recovered > 0) {
                Logger::info('heartbeat', "旁路恢复{$recovered}篇卡死文章，已释放给异步Runner处理");
                self::spawnAsyncRunner();
            }

            return $recovered;
        } catch (\Throwable $e) {
            Logger::error('heartbeat', "旁路恢复异常: {$e->getMessage()}");
            return 0;
        }
    }

    /**
     * 清理 AS 僵尸状态 — 防止 stale claims 和 stuck actions 导致 Runner 死锁
     *
     * 每 10 分钟执行一次，避免频繁 DB 写入。
     */
    private static function cleanASStaleState(): void
    {
        $lastClean = get_transient('xzd_as_clean_last');
        if ($lastClean && (time() - (int)$lastClean) < 600) {
            return;
        }
        set_transient('xzd_as_clean_last', time(), 900);

        global $wpdb;
        $tableActions = $wpdb->prefix . 'actionscheduler_actions';
        $tableClaims  = $wpdb->prefix . 'actionscheduler_claims';

        try {
            // 清理 30 分钟前的僵尸 claims
            $cutoff = date('Y-m-d H:i:s', time() - 1800);
            $deletedClaims = $wpdb->query($wpdb->prepare(
                "DELETE FROM {$tableClaims} WHERE date_created_gmt < %s",
                $cutoff
            ));

            // 重置卡死超过 30 分钟的 in-progress worker actions
            $resetActions = $wpdb->query($wpdb->prepare(
                "UPDATE {$tableActions}
                 SET status = 'failed', claim_id = 0
                 WHERE hook = 'xzd_worker_process'
                 AND status = 'in-progress'
                 AND last_attempt_gmt < %s",
                $cutoff
            ));

            // 清理 pending 超过 10 分钟的 action_scheduler_run_queue
            $runnerCutoff = date('Y-m-d H:i:s', time() - 600);
            $deletedRunners = $wpdb->query($wpdb->prepare(
                "DELETE FROM {$tableActions}
                 WHERE hook = 'action_scheduler_run_queue'
                 AND status = 'pending'
                 AND scheduled_date_gmt < %s",
                $runnerCutoff
            ));

            // 清理僵尸 claim_id：claims 已删除但 actions 上 claim_id 残留
            $resetClaims = $wpdb->query(
                "UPDATE {$tableActions} a
                 SET a.claim_id = 0
                 WHERE a.hook = 'xzd_worker_process'
                 AND a.status = 'pending'
                 AND a.claim_id > 0
                 AND NOT EXISTS (
                     SELECT 1 FROM {$tableClaims} c WHERE c.claim_id = a.claim_id
                 )"
            );

            // 清理 7 天前的 failed worker actions，防止堆积
            $failedCutoff = date('Y-m-d H:i:s', time() - 7 * DAY_IN_SECONDS);
            $deletedFailed = $wpdb->query($wpdb->prepare(
                "DELETE FROM {$tableActions}
                 WHERE hook = 'xzd_worker_process'
                 AND status = 'failed'
                 AND last_attempt_gmt < %s",
                $failedCutoff
            ));

            if (($deletedClaims + $resetActions + $deletedRunners + $resetClaims + $deletedFailed) > 0) {
                Logger::info('heartbeat', "AS清理: claims{$deletedClaims} actions{$resetActions} runners{$deletedRunners} orphanClaims{$resetClaims} oldFailed{$deletedFailed}");
            }
        } catch (\Throwable $e) {
            Logger::warning('heartbeat', 'AS清理异常: ' . $e->getMessage());
        }
    }

    /**
     * 投放单个异步 Runner — token 机制 + init 后期处理
     */
    private static function spawnAsyncRunner(): bool
    {
        $token = wp_generate_uuid4();
        set_transient("xzd_async_{$token}", 1, 30);
        $url = add_query_arg('xzd_async', $token, home_url('/'));
        $response = wp_remote_post($url, [
            'timeout'   => 0.5,
            'blocking'  => false,
            'sslverify' => false,
        ]);
        return !is_wp_error($response);
    }

    /**
     * 合并批量投放异步 Runner — 所有 token 打包到单个请求中
     */
    private static function spawnAsyncRunners(int $count): bool
    {
        if ($count <= 0) return false;
        if ($count === 1) return self::spawnAsyncRunner();

        $tokens = [];
        for ($i = 0; $i < $count; $i++) {
            $token = wp_generate_uuid4();
            set_transient("xzd_async_{$token}", 1, 30);
            $tokens[] = $token;
        }

        $url = add_query_arg('xzd_async_batch', implode(',', $tokens), home_url('/'));
        $response = wp_remote_post($url, [
            'timeout'   => 0.5,
            'blocking'  => false,
            'sslverify' => false,
        ]);
        return !is_wp_error($response);
    }

    /**
     * 自触发 — 发起异步 HTTP 请求到自身 REST 端点触发心跳
     *
     * 用于 shutdown 兜底，不阻塞当前请求。
     * 防风暴：30 秒内最多触发 1 次，发送前立即更新 LAST_TICK_KEY 防并发。
     */
    public static function selfPing(): void
    {
        // 30 秒内最多触发 1 次
        $dedupKey = 'xzd_self_ping_last';
        $lastPing = (int) get_transient($dedupKey);
        if ($lastPing && (time() - $lastPing) < 30) {
            return;
        }

        // 发送前立即更新时间戳，防止并发请求重复触发
        set_transient(self::LAST_TICK_KEY, time(), 600);
        set_transient($dedupKey, time(), 60);

        // shutdown 阶段 $wp_rewrite 可能已销毁，rest_url() 会报错
        global $wp_rewrite;
        if (!$wp_rewrite) {
            $restUrl = home_url('wp-json/xzdbk-ai/v1/heartbeat/tick');
        } else {
            $restUrl = rest_url('xzdbk-ai/v1/heartbeat/tick');
        }
        if (empty($restUrl)) return;

        wp_remote_post($restUrl, [
            'timeout'   => 0.3,
            'blocking'  => false,
            'sslverify' => false,
        ]);
    }

    /**
     * 快速扫描最近 24 小时未处理的文章，直接入 PendingQueue
     *
     * 每次心跳 tick 执行（2 分钟冷却），确保最新文章优先处理。
     */
    private static function tickQuickScanRecent(): void
    {
        // 总积压 ≥50 时跳过快速扫描
        if (class_exists(\XZD\AI\V5\Tools\QueueManager::class)) {
            $backlog = \XZD\AI\V5\Tools\QueueManager::getTotalBacklog();
            if ($backlog >= \XZD\AI\V5\Tools\QueueManager::MAX_BACKLOG) {
                return;
            }
        }

        $lastKey = 'xzd_quick_scan_last';
        $last = (int) get_transient($lastKey);
        if ($last && (time() - $last) < 120) {
            return;
        }
        set_transient($lastKey, time(), 180);

        try {
            $args = [
                'post_type'      => 'post',
                'post_status'    => 'publish',
                'posts_per_page' => 25,
                'orderby'        => 'date',
                'order'          => 'DESC',
                'fields'         => 'ids',
                'no_found_rows'  => true,
                'date_query'     => [
                    'after' => date('Y-m-d H:i:s', time() - DAY_IN_SECONDS),
                ],
            ];
            $postIds = get_posts($args);

            if (empty($postIds)) return;

            // 逐篇水位检查，'quick_scan' 事件受水位限制
            $enqueued = 0;
            foreach ($postIds as $pid) {
                \XZD\AI\V5\Tools\PipelineEngine::recoverStaleRunning((int)$pid);
                if (!\XZD\AI\V5\Tools\PipelineEngine::isFullyProcessed((int)$pid, 'publish_post')) {
                    \XZD\AI\V5\Core\PendingQueue::addPipeline((int)$pid, 'quick_scan');
                    $enqueued++;
                }
                // 每 5 篇检查一次水位
                if ($enqueued % 5 === 0 && \XZD\AI\V5\Tools\QueueManager::getTotalBacklog() >= \XZD\AI\V5\Tools\QueueManager::MAX_BACKLOG) {
                    break;
                }
            }

            if ($enqueued > 0) {
                Logger::info('heartbeat', "快速扫描: 最近24小时入队 {$enqueued} 篇");
            }
        } catch (\Throwable $e) {
            // 静默跳过，不影响其他心跳任务
        }
    }

    /**
     * 定时扫描未处理文章（替代 xzd_ai_schedule_scan WP-Cron）
     *
     * @return bool 是否触发了扫描
     */
    private static function tickScheduleScan(): bool
    {
        $triggers = Config::instance()->get('pipeline.triggers', []);
        if (!in_array('schedule', $triggers, true)) {
            return false;
        }

        $scanInterval = Config::instance()->int('pipeline.scan_interval', 10);
        $lastScanKey  = 'xzd_heartbeat_last_scan';
        $lastScan     = (int) get_transient($lastScanKey);

        // 首轮扫描用更短间隔（2 分钟）快速穿越已处理区
        $state = Config::getArrayOption('xzd_scan_state', []);
        $isFirstRound = !$state || ($state['round'] ?? 1) === 1;
        $effectiveInterval = $isFirstRound ? 2 : $scanInterval;

        if ($lastScan && (time() - $lastScan) < $effectiveInterval * 60) {
            return false;
        }

        // 先标记时间，防止并发重复触发
        set_transient($lastScanKey, time(), max($scanInterval * 120, 600));

        if (class_exists(\XZD\AI\V5\Tools\EventTrigger::class)) {
            try {
                \XZD\AI\V5\Tools\EventTrigger::scheduleScan();
                Logger::info('heartbeat', "心跳触发定时扫描 (间隔: {$scanInterval}分钟)");
                return true;
            } catch (\Throwable $e) {
                Logger::error('heartbeat', "定时扫描异常: {$e->getMessage()}");
            }
        }

        return false;
    }

    /**
     * 深夜 AI 学习检查（替代 xzd_ai_nightly_learn WP-Cron）
     *
     * @return bool 是否触发了学习
     */
    private static function tickNightlyLearn(): bool
    {
        // 仅旗舰版执行 AI 知识库学习
        if (\XZD\AI\V5\Core\LicenseService::instance()->getLevel() !== 'ultimate') {
            return false;
        }

        $today = date('Y-m-d');
        $triggeredKey = "xzd_learning_triggered_{$today}";

        if (get_transient($triggeredKey)) {
            return false;
        }

        $learnHour = Config::instance()->int('learning.schedule_hour', 3);
        $currentHour = (int) wp_date('H');

        if ($currentHour < $learnHour) {
            return false;
        }

        // 标记已触发，当天不再重复
        set_transient($triggeredKey, 1, DAY_IN_SECONDS * 2);

        if (class_exists(\XZD\AI\V5\Tools\LearningEngine::class)) {
            // V3.2: 设置待学习标记，下一个页面请求时自动执行
            // 不依赖 AS Runner，不依赖 HTTP 自调用，最可靠
            set_transient('xzd_learning_pending', 1, DAY_IN_SECONDS);
            Logger::info('heartbeat', "心跳标记AI学习待执行 (时间: {$currentHour}:00)");
            return true;
        }

        return false;
    }

    /**
     * 检查所有定时类规则的触发条件是否满足
     */
    private static function tickScheduledRules(): int
    {
        // 免费版不执行定时规则
        if (\XZD\AI\V5\Core\LicenseService::instance()->getLevel() === 'free') {
            return 0;
        }

        $dispatched   = 0;
        $ruleEngine   = \XZD\AI\V5\Tools\RuleEngine::instance();
        $now          = wp_date('H:i');
        $currentHour  = (int)wp_date('H');
        $currentMinute = (int)wp_date('i');
        $today        = date('Y-m-d');

        $context = [
            'trigger'             => 'heartbeat',
            'current_hour_minute' => $now,
            'current_hour'        => $currentHour,
            'current_minute'      => $currentMinute,
        ];

        $scheduleEvents = ['xzd_ai_schedule_write', 'daily_cron_3am'];

        foreach ($scheduleEvents as $event) {
            // daily_cron_3am 每天只评估一次
            if ($event === 'daily_cron_3am') {
                $dailyKey = "xzd_daily_cron_ticked_{$today}";
                if (get_transient($dailyKey)) {
                    continue;
                }
                set_transient($dailyKey, 1, DAY_IN_SECONDS * 2);
            }

            try {
                $results = $ruleEngine->evaluate($event, $context);
                if (!empty($results)) {
                    $dispatched += count($results);
                }
            } catch (\Throwable $e) {
                Logger::error('heartbeat', "评估事件 {$event} 异常: {$e->getMessage()}");
            }
        }

        return $dispatched;
    }

    /**
     * 处理 PendingQueue 中的单个队列项
     */
    private static function processQueueItem(array $item): void
    {
        $type    = $item['type'] ?? '';
        $postId  = (int)($item['post_id'] ?? 0);
        $event   = $item['event'] ?? '';

        try {
            if ($type === 'pipeline' && $postId) {
                (new \XZD\AI\V5\Tools\PipelineEngine())->run($postId, $event);
            } elseif ($type === 'rule_evaluate' && $event) {
                $contextKey = $item['context_key'] ?? '';
                $context = [];
                if ($contextKey) {
                    $context = Config::getArrayTransient($contextKey);
                    delete_transient($contextKey);
                }
                \XZD\AI\V5\Tools\RuleEngine::instance()->evaluate($event, $context);
            }
        } catch (\Throwable $e) {
            Logger::error('heartbeat', "处理队列项异常 ({$type}/{$event}): {$e->getMessage()}");
        }
    }
}
