"""AI 事件调度与调用链编排器。

根据业务事件（消费、备注、任务分配、DWS 完成）编排 AI 应用调用链，
确保执行顺序和数据依赖正确。

关键设计：
- 所有入口均为 async def，无 asyncio.run() / new_event_loop()
- 后台任务通过 asyncio.create_task() 发起
- 超时通过 asyncio.wait_for() 控制
- 每步调用前依次检查：熔断→限流→预算
- 调用链某步失败不中断后续步骤
- prompt 拼装委托给 app.ai.prompts 模块（含真实业务数据）

调用链：
- 消费事件（无助教）：App3 → App8 → App7
- 消费事件（有助教）：App3 → App8 → App7 + App4 → App5
- 备注事件：App6 → App8
- 任务分配事件：App4 → App5
- DWS 完成事件：App2 预生成（8 个时间维度）
"""

from __future__ import annotations

import asyncio
import json
import logging
import time
from dataclasses import dataclass, field
from datetime import date
from typing import Any, Callable

from app.ai.budget_tracker import BudgetTracker
from app.ai.cache_service import AICacheService
from app.ai.circuit_breaker import CircuitBreaker, CircuitState
from app.ai.config import AIConfig
from app.ai.conversation_service import ConversationService
from app.ai.dashscope_client import DashScopeClient
from app.ai.prompts import (
    build_app2_prompt,
    build_app2a_area_prompt,
    build_app3_prompt,
    build_app4_prompt,
    build_app5_prompt,
    build_app6_prompt,
    build_app7_prompt,
    build_app8_prompt,
)
from app.ai.event_bus import AIEvent, get_event_bus
from app.ai.rate_limiter import RateLimiter
from app.ai.references import attach_references
from app.ai.run_log_service import AIRunLogService
from app.ai.schemas import CacheTypeEnum

logger = logging.getLogger(__name__)

# 单步调用默认超时（秒）
# 2026-04-21：App2 prompt 中文 key 膨胀后 AI 响应延迟常突破 120s，上调至 180s
_STEP_TIMEOUT = 180

# App2 预热时间维度（与 prompts/app2_finance_prompt.DIMENSION_MAP 对齐）
APP2_TIME_DIMENSIONS = (
    "this_month", "last_month", "this_week", "last_week",
    "this_quarter", "last_quarter", "last_3_months", "last_6_months",
)

# App2 预热区域维度（与 prompts/app2_finance_prompt.AREA_OPTIONS 对齐）
APP2_AREA_OPTIONS = (
    "all", "hall", "hallA", "hallB", "hallC",
    "vip", "snooker", "mahjong", "ktv",
)

# 2026-04-23 · 72 组合拆分为 2 个 APP：
# - app2_finance 处理 area='all' 的 8 组合
# - app2a_finance_area 处理 area != 'all' 的 64 组合
APP2A_AREA_OPTIONS = tuple(a for a in APP2_AREA_OPTIONS if a != "all")


def _app2_target_id(time_dimension: str, area: str) -> str:
    """App2 缓存 target_id：time_dimension__area（双下划线分隔，避免歧义）。

    例：this_month__all / last_month__hallA。
    前端 board-finance.ts _loadAIInsights 需用相同拼装规则读取缓存。
    """
    return f"{time_dimension}__{area}"


def _publish_alert(
    site_id: int,
    app_name: str,
    alert_type: str,
    message: str,
    log_id: int | None = None,
) -> None:
    """Phase 3.3：推送 alert_created 事件给 WS 订阅者（/ws/ai-alerts/{site_id}）。

    alert_type 取值：circuit_open / rate_limited / budget_exceeded / timeout / failed
    异常仅记日志，不影响主流程。
    """
    try:
        get_event_bus().publish(AIEvent(
            type="alert_created",
            site_id=site_id,
            payload={
                "app_type": app_name,
                "alert_type": alert_type,
                "message": message,
                "log_id": log_id,
            },
        ))
    except Exception:
        logger.debug("alert_created 事件广播失败", exc_info=True)


def _update_trigger_job_status(
    job_id: int,
    status: str,
    error_message: str | None = None,
    set_started: bool = False,
    set_finished: bool = False,
) -> None:
    """更新 ai_trigger_jobs 表的状态（仅当 job_id 存在于 DB 时生效）。"""
    from app.database import get_connection

    parts = ["status = %s"]
    params: list[Any] = [status]
    if set_started:
        parts.append("started_at = NOW()")
    if set_finished:
        parts.append("finished_at = NOW()")
    if error_message is not None:
        parts.append("error_message = %s")
        params.append(error_message)
    params.append(job_id)

    conn = get_connection()
    try:
        with conn.cursor() as cur:
            cur.execute(
                f"UPDATE biz.ai_trigger_jobs SET {', '.join(parts)} WHERE id = %s",
                params,
            )
        conn.commit()
    except Exception:
        conn.rollback()
        logger.debug("更新 trigger_job %d 状态失败（可能为内存 job）", job_id, exc_info=True)
    finally:
        conn.close()


@dataclass
class TriggerEvent:
    """统一事件触发载体。"""

    event_type: str                          # consumption / note_created / task_assigned / dws_completed
    site_id: int
    member_id: int | None = None
    connector_type: str = "feiqiu"
    payload: dict = field(default_factory=dict)
    is_forced: bool = False


# 事件类型常量
EVENT_CONSUMPTION = "consumption"
EVENT_NOTE_CREATED = "note_created"
EVENT_TASK_ASSIGNED = "task_assigned"
EVENT_DWS_COMPLETED = "dws_completed"

# 事件类型 → 调用链描述（用于 ai_trigger_jobs.app_chain）
_EVENT_CHAIN_MAP: dict[str, str] = {
    EVENT_CONSUMPTION: "app3→app8→app7",
    EVENT_NOTE_CREATED: "app6→app8",
    EVENT_TASK_ASSIGNED: "app4→app5",
    EVENT_DWS_COMPLETED: "app2",
}


class AIDispatcher:
    """AI 事件调度与调用链编排器。

    集成熔断器、限流器、Token 预算控制，
    每步调用前依次检查：熔断→限流→预算→调用→记录日志。
    """

    def __init__(
        self,
        client: DashScopeClient,
        cache_svc: AICacheService,
        conv_svc: ConversationService,
        circuit_breaker: CircuitBreaker,
        rate_limiter: RateLimiter,
        budget_tracker: BudgetTracker,
        run_log_svc: AIRunLogService,
        config: AIConfig,
    ) -> None:
        self.client = client
        self.cache_svc = cache_svc
        self.conv_svc = conv_svc
        self.circuit_breaker = circuit_breaker
        self.rate_limiter = rate_limiter
        self.budget_tracker = budget_tracker
        self.run_log_svc = run_log_svc
        self.config = config

        # 内存去重集合：(event_type, member_id, site_id, date_str)
        # DB 迁移完成后改为查询 ai_trigger_jobs 表
        self._dedup_set: set[tuple[str, int | None, int, str]] = set()

        # 内存 trigger_job 计数器（DB 迁移完成后改为 INSERT RETURNING id）
        self._next_job_id = 1
        self._running_tasks: dict[int, asyncio.Task] = {}
        self._running_task_sites: dict[int, int] = {}

    def _forget_running_task(self, job_id: int) -> None:
        self._running_tasks.pop(job_id, None)
        self._running_task_sites.pop(job_id, None)

    def cancel_running(self, site_id: int) -> int:
        """取消当前进程内指定门店未完成的 AI 调用链。"""
        cancelled = 0
        for job_id, task in list(self._running_tasks.items()):
            if self._running_task_sites.get(job_id) != site_id:
                continue
            if task.done():
                self._forget_running_task(job_id)
                continue
            task.cancel()
            cancelled += 1
        return cancelled

    # ── 统一事件入口 ─────────────────────────────────────

    async def handle_trigger(self, event: TriggerEvent) -> int:
        """统一事件入口。记录 trigger_job 后异步执行调用链。

        返回 trigger_job_id（当前为内存自增，DB 迁移后改为数据库 ID）。
        """
        job_id = self._next_job_id
        self._next_job_id += 1

        app_chain = _EVENT_CHAIN_MAP.get(event.event_type, "unknown")
        logger.info(
            "收到触发事件: job_id=%d event_type=%s site_id=%d chain=%s",
            job_id, event.event_type, event.site_id, app_chain,
        )

        # 去重检查（is_forced 跳过）
        if not event.is_forced and await self._check_dedup(event):
            logger.info(
                "事件去重跳过: job_id=%d event_type=%s site_id=%d member_id=%s",
                job_id, event.event_type, event.site_id, event.member_id,
            )
            return job_id

        # 记录去重键
        dedup_key = (
            event.event_type,
            event.member_id,
            event.site_id,
            date.today().isoformat(),
        )
        self._dedup_set.add(dedup_key)

        # 后台异步执行调用链，不阻塞返回
        task = asyncio.create_task(self._execute_chain(job_id, event))
        self._running_tasks[job_id] = task
        self._running_task_sites[job_id] = event.site_id
        task.add_done_callback(lambda _task, _job_id=job_id: self._forget_running_task(_job_id))
        return job_id

    # ── 调用链分发 ───────────────────────────────────────

    async def _execute_chain(self, job_id: int, event: TriggerEvent) -> None:
        """执行调用链，根据 event_type 分发到对应处理器。"""
        handler_map: dict[str, Callable] = {
            EVENT_CONSUMPTION: self._handle_consumption,
            EVENT_NOTE_CREATED: self._handle_note,
            EVENT_TASK_ASSIGNED: self._handle_task_assigned,
            EVENT_DWS_COMPLETED: self._handle_dws_completed,
        }

        handler = handler_map.get(event.event_type)
        if handler is None:
            logger.error("未知事件类型: %s (job_id=%d)", event.event_type, job_id)
            _update_trigger_job_status(job_id, "failed", error_message=f"未知事件类型: {event.event_type}")
            return

        # 标记开始执行
        _update_trigger_job_status(job_id, "running", set_started=True)

        # 外层超时按事件类型给预算：
        # - 消费/备注/任务分配：最多 4-5 个 App 串行，10 min 足够
        # - DWS 完成：72 组合（8 时间 × 9 区域），每组合 ~30-120s，需 ~2.5h 预算
        chain_timeout = (
            _STEP_TIMEOUT * len(APP2_TIME_DIMENSIONS) * len(APP2_AREA_OPTIONS) + 600
            if event.event_type == EVENT_DWS_COMPLETED
            else _STEP_TIMEOUT * 5
        )

        try:
            await asyncio.wait_for(handler(event), timeout=chain_timeout)
            logger.info("调用链完成: job_id=%d event_type=%s", job_id, event.event_type)
            _update_trigger_job_status(job_id, "completed", set_finished=True)
        except asyncio.CancelledError:
            logger.warning("调用链已取消: job_id=%d event_type=%s", job_id, event.event_type)
            _update_trigger_job_status(job_id, "cancelled", error_message="业务运行上下文切换取消", set_finished=True)
            raise
        except asyncio.TimeoutError:
            logger.error("调用链超时: job_id=%d event_type=%s", job_id, event.event_type)
            _update_trigger_job_status(job_id, "failed", error_message="调用链超时", set_finished=True)
        except Exception as exc:
            logger.exception("调用链异常: job_id=%d event_type=%s", job_id, event.event_type)
            _update_trigger_job_status(
                job_id, "failed",
                error_message=str(exc)[:500],
                set_finished=True,
            )

    # ── 去重检查 ─────────────────────────────────────────

    async def _check_dedup(self, event: TriggerEvent) -> bool:
        """去重检查：(event_type, member_id, site_id, date) 是否已存在。

        返回 True 表示重复（应跳过），False 表示不重复。
        当前为内存实现，DB 迁移后改为查询 ai_trigger_jobs 表。
        """
        dedup_key = (
            event.event_type,
            event.member_id,
            event.site_id,
            date.today().isoformat(),
        )
        return dedup_key in self._dedup_set

    # ── 单步执行 ─────────────────────────────────────────

    async def _run_step(
        self,
        app_name: str,
        app_id: str,
        prompt: str,
        context: dict,
    ) -> dict | None:
        """执行单步：熔断检查→限流检查→预算检查→调用→记录日志。

        失败返回 None，调用链继续执行后续步骤。

        Args:
            app_name: 应用名称（如 "app3_clue"），用于日志
            app_id: 百炼应用 ID
            prompt: 发送给应用的 prompt 字符串
            context: 上下文信息（含 site_id、member_id 等）

        Returns:
            解析后的 JSON dict，失败时返回 None
        """
        site_id = context.get("site_id", 0)
        member_id = context.get("member_id")
        start_time = time.monotonic()

        # ── 1. 熔断检查 ──
        state = self.circuit_breaker.check(app_id)
        if state == CircuitState.OPEN:
            logger.warning("熔断器 OPEN，跳过: app=%s app_id=%s", app_name, app_id)
            try:
                log_id = self.run_log_svc.create_log(
                    site_id=site_id,
                    app_type=app_name,
                    trigger_type="event",
                    member_id=member_id,
                    request_prompt=prompt,
                )
                elapsed_ms = int((time.monotonic() - start_time) * 1000)
                self.run_log_svc.update_failed(log_id, "circuit_open", elapsed_ms)
            except Exception:
                logger.exception("记录 circuit_open 日志失败: app=%s", app_name)
            _publish_alert(site_id, app_name, "circuit_open", "熔断器已打开")
            return None

        # ── 2. 限流检查 ──
        if not self.rate_limiter.check_store_rate(site_id):
            logger.warning("限流超限，跳过: app=%s site_id=%d", app_name, site_id)
            try:
                log_id = self.run_log_svc.create_log(
                    site_id=site_id,
                    app_type=app_name,
                    trigger_type="event",
                    member_id=member_id,
                    request_prompt=prompt,
                )
                elapsed_ms = int((time.monotonic() - start_time) * 1000)
                self.run_log_svc.update_failed(log_id, "rate_limited", elapsed_ms)
            except Exception:
                logger.exception("记录 rate_limited 日志失败: app=%s", app_name)
            _publish_alert(site_id, app_name, "rate_limited", "门店限流超限")
            return None

        # ── 3. 预算检查 ──
        budget_status = self.budget_tracker.check_budget()
        if not budget_status.allowed:
            logger.warning(
                "预算超限，跳过: app=%s reason=%s", app_name, budget_status.reason,
            )
            try:
                log_id = self.run_log_svc.create_log(
                    site_id=site_id,
                    app_type=app_name,
                    trigger_type="event",
                    member_id=member_id,
                    request_prompt=prompt,
                )
                elapsed_ms = int((time.monotonic() - start_time) * 1000)
                self.run_log_svc.update_failed(
                    log_id, f"budget_exceeded:{budget_status.reason}", elapsed_ms,
                )
            except Exception:
                logger.exception("记录 budget_exceeded 日志失败: app=%s", app_name)
            _publish_alert(
                site_id, app_name, "budget_exceeded",
                f"Token 预算超限: {budget_status.reason}",
            )
            return None

        # ── 4. 创建日志记录（pending → running） ──
        log_id: int | None = None
        try:
            log_id = self.run_log_svc.create_log(
                site_id=site_id,
                app_type=app_name,
                trigger_type="event",
                member_id=member_id,
                request_prompt=prompt,
            )
            self.run_log_svc.update_running(log_id)
        except Exception:
            logger.exception("创建/更新运行日志失败: app=%s", app_name)

        # ── 5. 调用 DashScope ──
        try:
            result, tokens_used, _session_id = await asyncio.wait_for(
                self.client.call_app(app_id, prompt),
                timeout=_STEP_TIMEOUT,
            )

            # 成功：更新日志 + 熔断器
            elapsed_ms = int((time.monotonic() - start_time) * 1000)
            if log_id is not None:
                try:
                    self.run_log_svc.update_success(
                        log_id,
                        response_text=json.dumps(result, ensure_ascii=False),
                        tokens_used=tokens_used,
                        latency_ms=elapsed_ms,
                    )
                except Exception:
                    logger.exception("更新 success 日志失败: app=%s log_id=%d", app_name, log_id)

            self.circuit_breaker.record_success(app_id)
            logger.info("调用成功: app=%s tokens=%d latency=%dms", app_name, tokens_used, elapsed_ms)

            # Phase 1.3：按 app_name + context 注入 _references 元数据（非破坏性）
            result = attach_references(app_name, result, context) or result
            return result

        except asyncio.TimeoutError:
            # 超时
            elapsed_ms = int((time.monotonic() - start_time) * 1000)
            logger.error("调用超时: app=%s latency=%dms", app_name, elapsed_ms)
            if log_id is not None:
                try:
                    self.run_log_svc.update_timeout(log_id, elapsed_ms)
                except Exception:
                    logger.exception("更新 timeout 日志失败: app=%s", app_name)
            _publish_alert(site_id, app_name, "timeout", f"调用超时 ({elapsed_ms}ms)", log_id)
            return None

        except Exception as exc:
            # 其他失败
            elapsed_ms = int((time.monotonic() - start_time) * 1000)
            error_msg = f"{type(exc).__name__}: {exc}"
            logger.exception("调用失败: app=%s error=%s", app_name, error_msg)
            if log_id is not None:
                try:
                    self.run_log_svc.update_failed(log_id, error_msg, elapsed_ms)
                except Exception:
                    logger.exception("更新 failed 日志失败: app=%s", app_name)

            self.circuit_breaker.record_failure(app_id)
            _publish_alert(site_id, app_name, "failed", error_msg[:200], log_id)
            return None

    # ── 缓存写入辅助 ─────────────────────────────────────

    def _write_cache(
        self,
        cache_type: str,
        site_id: int,
        target_id: str,
        result: dict | None,
        triggered_by: str,
        score: int | None = None,
    ) -> None:
        """统一缓存写入入口，结果为 None 时跳过。

        Phase 1.3：result['_references'] 已由 _run_step 成功返回前注入。
        """
        if result is None:
            return
        try:
            self.cache_svc.write_cache(
                cache_type=cache_type,
                site_id=site_id,
                target_id=target_id,
                result_json=result,
                triggered_by=triggered_by,
                score=score,
            )
        except Exception:
            logger.exception(
                "缓存写入失败: cache_type=%s site_id=%s target_id=%s",
                cache_type, site_id, target_id,
            )
            return

        # Phase 1.4：广播 cache_updated 事件，admin-web / 小程序 WS 订阅可实时刷新
        try:
            from app.ai.event_bus import AIEvent, get_event_bus
            get_event_bus().publish(AIEvent(
                type="cache_updated",
                site_id=site_id,
                payload={
                    "cache_type": cache_type,
                    "target_id": target_id,
                    "triggered_by": triggered_by,
                },
            ))
        except Exception:
            logger.debug("cache_updated 事件广播失败（不影响主流程）", exc_info=True)

    # ── App8 幂等写入 member_retention_clue ─────────────

    def _write_retention_clue(
        self,
        member_id: int,
        site_id: int,
        consolidate_result: dict,
        source: str,
    ) -> None:
        """全量替换 member_retention_clue：DELETE 同源旧记录 + INSERT 新记录（事务）。

        同一 member 同一 site 同一 source 当天只保留最新一批。
        人工线索（source='manual'）不受影响。
        事务失败自动回滚。

        字段映射：
        - category → category
        - emoji + " " + summary → summary（如 "📅 偏好周末下午时段消费"）
        - detail → detail
        - providers → recorded_by_name
        """
        from app.database import get_connection

        clues = consolidate_result.get("clues", [])
        if not clues:
            logger.info(
                "App8 无线索数据，跳过写入: member_id=%d site_id=%d", member_id, site_id,
            )
            return

        conn = get_connection()
        try:
            with conn.cursor() as cur:
                cur.execute(
                    """
                    DELETE FROM member_retention_clue
                    WHERE member_id = %s AND site_id = %s AND source = %s
                    """,
                    (member_id, site_id, source),
                )
                deleted = cur.rowcount

                for clue in clues:
                    emoji = clue.get("emoji", "")
                    raw_summary = clue.get("summary", "")
                    summary = f"{emoji} {raw_summary}" if emoji else raw_summary

                    cur.execute(
                        """
                        INSERT INTO member_retention_clue
                            (member_id, category, summary, detail, site_id,
                             source, recorded_by_name, recorded_by_assistant_id)
                        VALUES (%s, %s, %s, %s, %s, %s, %s, NULL)
                        """,
                        (
                            member_id,
                            clue.get("category", "客户基础"),
                            summary,
                            clue.get("detail", ""),
                            site_id,
                            source,
                            clue.get("providers", ""),
                        ),
                    )

                conn.commit()
                logger.info(
                    "维客线索全量替换完成: member_id=%d site_id=%d source=%s "
                    "deleted=%d inserted=%d",
                    member_id, site_id, source, deleted, len(clues),
                )
        except Exception:
            conn.rollback()
            raise
        finally:
            conn.close()

    # ── App8 输入辅助：提取最近 App3/App6 线索 ─────────

    def _fetch_recent_clues(
        self,
        cache_type: str,
        site_id: int,
        member_id: int,
    ) -> tuple[list[dict], str | None]:
        """从 ai_cache 提取最近一次结果的 clues 数组与生成时间。"""
        try:
            latest = self.cache_svc.get_latest(
                cache_type, site_id, str(member_id),
            )
        except Exception:
            logger.exception(
                "查询缓存失败: cache_type=%s site_id=%s member_id=%s",
                cache_type, site_id, member_id,
            )
            return [], None

        if not latest:
            return [], None

        result_json = latest.get("result_json") or {}
        if isinstance(result_json, str):
            try:
                result_json = json.loads(result_json)
            except Exception:
                result_json = {}
        clues = result_json.get("clues", []) if isinstance(result_json, dict) else []
        return clues, latest.get("created_at")

    # ── 事件处理器 ───────────────────────────────────────

    async def _handle_consumption(self, event: TriggerEvent) -> None:
        """消费事件链：App3 → App8 → App7（无助教）/ + App4 → App5（有助教）。"""
        site_id = event.site_id
        member_id = event.member_id
        if member_id is None:
            logger.error("消费事件缺少 member_id: site_id=%d", site_id)
            return

        has_assistant = event.payload.get("has_assistant", False)
        assistant_id = event.payload.get("assistant_id") if has_assistant else None
        context = {"site_id": site_id, "member_id": member_id}

        # ── Step 1: App3 客户线索分析 ──
        try:
            app3_prompt = await build_app3_prompt(
                {"site_id": site_id, "member_id": member_id},
                self.cache_svc,
            )
        except Exception:
            logger.exception("App3 prompt 拼装失败: member_id=%s", member_id)
            app3_prompt = ""

        app3_result = None
        if app3_prompt:
            app3_result = await self._run_step(
                "app3_clue", self.config.app_id_3_clue, app3_prompt, context,
            )
            self._write_cache(
                CacheTypeEnum.APP3_CLUE.value, site_id, str(member_id),
                app3_result, "consumption",
            )

        # ── Step 2: App8 线索整合 ──
        app3_clues = (app3_result or {}).get("clues", []) if isinstance(app3_result, dict) else []
        app6_clues, app6_generated_at = self._fetch_recent_clues(
            CacheTypeEnum.APP6_NOTE_ANALYSIS.value, site_id, member_id,
        )

        try:
            app8_prompt = await build_app8_prompt({
                "site_id": site_id,
                "member_id": member_id,
                "app3_clues": app3_clues,
                "app6_clues": app6_clues,
                "app3_generated_at": None,  # App3 刚生成，无 created_at
                "app6_generated_at": app6_generated_at,
            })
        except Exception:
            logger.exception("App8 prompt 拼装失败: member_id=%s", member_id)
            app8_prompt = ""

        app8_result = None
        if app8_prompt:
            app8_result = await self._run_step(
                "app8_consolidate", self.config.app_id_8_consolidate,
                app8_prompt, context,
            )
            self._write_cache(
                CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, str(member_id),
                app8_result, "consumption",
            )
            if app8_result is not None:
                try:
                    self._write_retention_clue(
                        member_id=member_id,
                        site_id=site_id,
                        consolidate_result=app8_result,
                        source="ai_consumption",
                    )
                except Exception:
                    logger.exception(
                        "App8 维客线索写入失败: site_id=%d member_id=%d",
                        site_id, member_id,
                    )

        # ── Step 3: App7 客户分析 ──
        try:
            app7_prompt = await build_app7_prompt(
                {"site_id": site_id, "member_id": member_id},
                self.cache_svc,
            )
        except Exception:
            logger.exception("App7 prompt 拼装失败: member_id=%s", member_id)
            app7_prompt = ""

        if app7_prompt:
            app7_result = await self._run_step(
                "app7_customer", self.config.app_id_7_customer,
                app7_prompt, context,
            )
            self._write_cache(
                CacheTypeEnum.APP7_CUSTOMER_ANALYSIS.value, site_id, str(member_id),
                app7_result, "consumption",
            )

        # ── 有助教时：App4 → App5 ──
        if has_assistant and assistant_id is not None:
            await self._run_app4_app5(
                site_id=site_id,
                assistant_id=int(assistant_id),
                member_id=member_id,
                triggered_by="consumption",
            )

    async def _handle_note(self, event: TriggerEvent) -> None:
        """备注事件链：App6 → App8。"""
        site_id = event.site_id
        member_id = event.member_id
        if member_id is None:
            logger.error("备注事件缺少 member_id: site_id=%d", site_id)
            return

        context = {"site_id": site_id, "member_id": member_id}

        # ── Step 1: App6 备注分析 ──
        try:
            app6_prompt = await build_app6_prompt({
                "site_id": site_id,
                "member_id": member_id,
                "note_content": event.payload.get("note_content", ""),
                "noted_by_name": event.payload.get("noted_by_name", ""),
                "noted_by_created_at": event.payload.get("noted_by_created_at", ""),
            }, self.cache_svc)
        except Exception:
            logger.exception("App6 prompt 拼装失败: member_id=%s", member_id)
            app6_prompt = ""

        app6_result = None
        if app6_prompt:
            app6_result = await self._run_step(
                "app6_note", self.config.app_id_6_note, app6_prompt, context,
            )
            score = (app6_result or {}).get("score") if isinstance(app6_result, dict) else None
            self._write_cache(
                CacheTypeEnum.APP6_NOTE_ANALYSIS.value, site_id, str(member_id),
                app6_result, "note_created", score=score,
            )

        # ── Step 2: App8 线索整合 ──
        app6_clues = (app6_result or {}).get("clues", []) if isinstance(app6_result, dict) else []
        app3_clues, app3_generated_at = self._fetch_recent_clues(
            CacheTypeEnum.APP3_CLUE.value, site_id, member_id,
        )

        try:
            app8_prompt = await build_app8_prompt({
                "site_id": site_id,
                "member_id": member_id,
                "app3_clues": app3_clues,
                "app6_clues": app6_clues,
                "app3_generated_at": app3_generated_at,
                "app6_generated_at": None,  # App6 刚生成
            })
        except Exception:
            logger.exception("App8 prompt 拼装失败: member_id=%s", member_id)
            return

        app8_result = await self._run_step(
            "app8_consolidate", self.config.app_id_8_consolidate,
            app8_prompt, context,
        )
        self._write_cache(
            CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, str(member_id),
            app8_result, "note_created",
        )
        if app8_result is not None:
            try:
                self._write_retention_clue(
                    member_id=member_id,
                    site_id=site_id,
                    consolidate_result=app8_result,
                    source="ai_note",
                )
            except Exception:
                logger.exception(
                    "App8 维客线索写入失败: site_id=%d member_id=%d",
                    site_id, member_id,
                )

    async def _handle_task_assigned(self, event: TriggerEvent) -> None:
        """任务分配事件链：App4 → App5。"""
        site_id = event.site_id
        member_id = event.member_id
        assistant_id = event.payload.get("assistant_id")
        if member_id is None or assistant_id is None:
            logger.error(
                "任务分配事件缺少 member_id 或 assistant_id: site_id=%d", site_id,
            )
            return

        await self._run_app4_app5(
            site_id=site_id,
            assistant_id=int(assistant_id),
            member_id=member_id,
            triggered_by="task_assigned",
        )

    async def _run_app4_app5(
        self,
        *,
        site_id: int,
        assistant_id: int,
        member_id: int,
        triggered_by: str,
    ) -> None:
        """App4 → App5 串行执行（消费链含助教 + 任务分配链复用）。"""
        context = {
            "site_id": site_id,
            "member_id": member_id,
            "assistant_id": assistant_id,
        }
        target_id = f"{assistant_id}_{member_id}"

        # ── Step A: App4 关系分析 ──
        try:
            app4_prompt = await build_app4_prompt({
                "site_id": site_id,
                "assistant_id": assistant_id,
                "member_id": member_id,
            }, self.cache_svc)
        except Exception:
            logger.exception(
                "App4 prompt 拼装失败: assistant=%s member=%s",
                assistant_id, member_id,
            )
            app4_prompt = ""

        app4_result = None
        if app4_prompt:
            app4_result = await self._run_step(
                "app4_analysis", self.config.app_id_4_analysis,
                app4_prompt, context,
            )
            self._write_cache(
                CacheTypeEnum.APP4_ANALYSIS.value, site_id, target_id,
                app4_result, triggered_by,
            )

        # ── Step B: App5 话术参考（使用 App4 结果） ──
        try:
            app5_prompt = await build_app5_prompt({
                "site_id": site_id,
                "assistant_id": assistant_id,
                "member_id": member_id,
                "app4_result": app4_result,
            }, self.cache_svc)
        except Exception:
            logger.exception(
                "App5 prompt 拼装失败: assistant=%s member=%s",
                assistant_id, member_id,
            )
            return

        app5_result = await self._run_step(
            "app5_tactics", self.config.app_id_5_tactics,
            app5_prompt, context,
        )
        self._write_cache(
            CacheTypeEnum.APP5_TACTICS.value, site_id, target_id,
            app5_result, triggered_by,
        )

    async def _handle_dws_completed(self, event: TriggerEvent) -> None:
        """DWS 完成事件：App2 + App2a 联合预生成（8 时间维度 × 9 区域 = 72 组合）。

        2026-04-23 · 72 组合拆分为两个百炼 APP：
        - app2_finance（全域版）处理 area='all' 的 8 组合
        - app2a_finance_area（区域派生版）处理 area != 'all' 的 64 组合

        每个组合独立调用百炼，结果写入 ai_cache，target_id=time_dimension__area。
        单步失败不影响后续组合。熔断/限流/预算检查在 _run_step 内部生效，
        达到阈值后会自动跳过剩余调用。
        """
        site_id = event.site_id
        context = {"site_id": site_id, "member_id": None}

        total = len(APP2_TIME_DIMENSIONS) * len(APP2_AREA_OPTIONS)
        ok = 0
        for dimension in APP2_TIME_DIMENSIONS:
            # 1. 全域组合 · app2_finance
            try:
                prompt = await build_app2_prompt({
                    "site_id": site_id,
                    "time_dimension": dimension,
                    "area": "all",
                })
            except Exception:
                logger.exception(
                    "App2 prompt 拼装失败: site_id=%d dimension=%s area=all",
                    site_id, dimension,
                )
            else:
                result = await self._run_step(
                    "app2_finance", self.config.app_id_2_finance, prompt, context,
                )
                self._write_cache(
                    CacheTypeEnum.APP2_FINANCE.value,
                    site_id,
                    _app2_target_id(dimension, "all"),
                    result,
                    "dws_completed",
                )
                if result is not None:
                    ok += 1

            # 2. 区域组合 · app2a_finance_area（64 组合 = 8 时间 × 8 区域）
            for area in APP2A_AREA_OPTIONS:
                try:
                    prompt = await build_app2a_area_prompt({
                        "site_id": site_id,
                        "time_dimension": dimension,
                        "area": area,
                    })
                except Exception:
                    logger.exception(
                        "app2a prompt 拼装失败: site_id=%d dimension=%s area=%s",
                        site_id, dimension, area,
                    )
                    continue

                result = await self._run_step(
                    "app2a_finance_area", self.config.app_id_2a_finance_area, prompt, context,
                )
                self._write_cache(
                    CacheTypeEnum.APP2A_FINANCE_AREA.value,
                    site_id,
                    _app2_target_id(dimension, area),
                    result,
                    "dws_completed",
                )
                if result is not None:
                    ok += 1

        logger.info(
            "App2 + App2a 预热完成: site_id=%d 成功=%d/%d",
            site_id, ok, total,
        )

    # ── 按需单 App 执行（admin-web 重新生成按钮用） ──────────────

    async def run_single_app(
        self,
        app_type: str,
        context: dict,
        triggered_by: str = "admin_manual",
    ) -> dict | None:
        """按需执行单个 App，跳过链路编排。

        用于 admin-web "重新生成" 按钮等场景：只跑一个 App 不触发下游链。
        结果自动写入 ai_cache。

        Args:
            app_type: 应用名称，支持 app2_finance ~ app8_consolidation
            context: 上下文参数。各 App 所需字段：
                - app2_finance: site_id + time_dimension
                - app3_clue: site_id + member_id
                - app4_analysis: site_id + member_id + assistant_id
                - app5_tactics: site_id + member_id + assistant_id + app4_result?
                - app6_note: site_id + member_id + note_content + noted_by_name + noted_by_created_at?
                - app7_customer: site_id + member_id
                - app8_consolidation: site_id + member_id（从 cache 自动拼 app3/app6 clues）
            triggered_by: 触发来源（日志用），默认 admin_manual

        Returns:
            百炼返回的结构化 JSON；失败返回 None
        """
        site_id = context.get("site_id")
        if site_id is None:
            raise ValueError("context 缺少 site_id")

        # 分派：app_type → (prompt builder, app_id, cache_type, target_id)
        if app_type == "app2_finance":
            dimension = context.get("time_dimension")
            if not dimension:
                raise ValueError("app2_finance 需要 time_dimension")
            area = context.get("area", "all")
            context = {**context, "area": area}
            prompt = await build_app2_prompt(context)
            app_id = self.config.app_id_2_finance
            cache_type = CacheTypeEnum.APP2_FINANCE.value
            target_id = _app2_target_id(str(dimension), str(area))

        elif app_type == "app2a_finance_area":
            dimension = context.get("time_dimension")
            area = context.get("area")
            if not dimension:
                raise ValueError("app2a_finance_area 需要 time_dimension")
            if not area or area == "all":
                raise ValueError("app2a_finance_area 需要 area != 'all'（area='all' 请走 app2_finance）")
            prompt = await build_app2a_area_prompt(context)
            app_id = self.config.app_id_2a_finance_area
            cache_type = CacheTypeEnum.APP2A_FINANCE_AREA.value
            target_id = _app2_target_id(str(dimension), str(area))

        elif app_type == "app3_clue":
            if context.get("member_id") is None:
                raise ValueError("app3_clue 需要 member_id")
            prompt = await build_app3_prompt(context, self.cache_svc)
            app_id = self.config.app_id_3_clue
            cache_type = CacheTypeEnum.APP3_CLUE.value
            target_id = str(context["member_id"])

        elif app_type == "app4_analysis":
            if context.get("member_id") is None or context.get("assistant_id") is None:
                raise ValueError("app4_analysis 需要 member_id + assistant_id")
            prompt = await build_app4_prompt(context, self.cache_svc)
            app_id = self.config.app_id_4_analysis
            cache_type = CacheTypeEnum.APP4_ANALYSIS.value
            target_id = f"{context['assistant_id']}_{context['member_id']}"

        elif app_type == "app5_tactics":
            if context.get("member_id") is None or context.get("assistant_id") is None:
                raise ValueError("app5_tactics 需要 member_id + assistant_id")
            prompt = await build_app5_prompt(context, self.cache_svc)
            app_id = self.config.app_id_5_tactics
            cache_type = CacheTypeEnum.APP5_TACTICS.value
            target_id = f"{context['assistant_id']}_{context['member_id']}"

        elif app_type == "app6_note":
            if context.get("member_id") is None:
                raise ValueError("app6_note 需要 member_id")
            prompt = await build_app6_prompt(context, self.cache_svc)
            app_id = self.config.app_id_6_note
            cache_type = CacheTypeEnum.APP6_NOTE_ANALYSIS.value
            target_id = str(context["member_id"])

        elif app_type == "app7_customer":
            if context.get("member_id") is None:
                raise ValueError("app7_customer 需要 member_id")
            prompt = await build_app7_prompt(context, self.cache_svc)
            app_id = self.config.app_id_7_customer
            cache_type = CacheTypeEnum.APP7_CUSTOMER_ANALYSIS.value
            target_id = str(context["member_id"])

        elif app_type == "app8_consolidation":
            if context.get("member_id") is None:
                raise ValueError("app8_consolidation 需要 member_id")
            # 自动从 cache 拼 app3/app6 clues
            member_id = context["member_id"]
            app3_clues, app3_at = self._fetch_recent_clues(
                CacheTypeEnum.APP3_CLUE.value, site_id, member_id,
            )
            app6_clues, app6_at = self._fetch_recent_clues(
                CacheTypeEnum.APP6_NOTE_ANALYSIS.value, site_id, member_id,
            )
            prompt = await build_app8_prompt({
                "site_id": site_id,
                "member_id": member_id,
                "app3_clues": app3_clues,
                "app6_clues": app6_clues,
                "app3_generated_at": app3_at,
                "app6_generated_at": app6_at,
            })
            app_id = self.config.app_id_8_consolidate
            cache_type = CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value
            target_id = str(member_id)
        else:
            raise ValueError(f"不支持的 app_type: {app_type}")

        # 执行 + 写缓存（复用 _run_step 熔断/限流/预算/日志逻辑）
        result = await self._run_step(app_type, app_id, prompt, context)
        score = None
        if app_type == "app6_note" and isinstance(result, dict):
            score = result.get("score")
        self._write_cache(cache_type, site_id, target_id, result, triggered_by, score=score)

        # App8 额外写入 member_retention_clue（幂等替换）
        if app_type == "app8_consolidation" and result is not None:
            try:
                self._write_retention_clue(
                    member_id=context["member_id"],
                    site_id=site_id,
                    consolidate_result=result,
                    source="ai_consumption" if context.get("source") != "ai_note" else "ai_note",
                )
            except Exception:
                logger.exception(
                    "App8 manual: 维客线索写入失败 site_id=%d member_id=%s",
                    site_id, context.get("member_id"),
                )

        return result


# ── 事件处理器注册（向后兼容） ────────────────────────────


def _create_ai_event_handlers(dispatcher: AIDispatcher) -> dict[str, Callable]:
    """创建 AI 事件处理器，用于注册到 trigger_scheduler。

    每个处理器从 payload 提取参数，构造 TriggerEvent，
    通过 asyncio.create_task 后台执行，不阻塞调用方。

    Returns:
        {event_job_type: handler_func} 映射
    """

    async def handle_consumption_settled(payload: dict | None = None, **_kw: Any) -> None:
        """消费结算事件处理器（async 入口）。"""
        if not payload:
            logger.warning("consumption_settled 事件缺少 payload")
            return
        event = TriggerEvent(
            event_type=EVENT_CONSUMPTION,
            site_id=payload["site_id"],
            member_id=payload.get("member_id"),
            payload=payload,
        )
        await dispatcher.handle_trigger(event)

    async def handle_note_created(payload: dict | None = None, **_kw: Any) -> None:
        """备注创建事件处理器。"""
        if not payload:
            logger.warning("note_created 事件缺少 payload")
            return
        event = TriggerEvent(
            event_type=EVENT_NOTE_CREATED,
            site_id=payload["site_id"],
            member_id=payload.get("member_id"),
            payload=payload,
        )
        await dispatcher.handle_trigger(event)

    async def handle_task_assigned(payload: dict | None = None, **_kw: Any) -> None:
        """任务分配事件处理器。"""
        if not payload:
            logger.warning("task_assigned 事件缺少 payload")
            return
        event = TriggerEvent(
            event_type=EVENT_TASK_ASSIGNED,
            site_id=payload["site_id"],
            member_id=payload.get("member_id"),
            payload=payload,
        )
        await dispatcher.handle_trigger(event)

    async def handle_dws_completed(payload: dict | None = None, **_kw: Any) -> None:
        """DWS 完成事件处理器。"""
        if not payload:
            logger.warning("dws_completed 事件缺少 payload")
            return
        event = TriggerEvent(
            event_type=EVENT_DWS_COMPLETED,
            site_id=payload["site_id"],
            member_id=payload.get("member_id"),
            payload=payload,
        )
        await dispatcher.handle_trigger(event)

    async def handle_app2_prewarm(**_kw: Any) -> None:
        """App2 财务洞察 cron 预热处理器（每日 10:00 触发）。

        对所有 active 门店发起 ai_dws_completed 事件，触发 App2 预生成：
        8 时间维度 × 9 区域 = 72 组合。
        cron 签名 (conn, job_id)，这里不需要，用 **_kw 忽略。
        """
        from app.database import get_connection
        from app.services.trigger_scheduler import fire_event

        conn = get_connection()
        try:
            with conn.cursor() as cur:
                cur.execute(
                    "SELECT DISTINCT site_id FROM biz.sites WHERE site_id IS NOT NULL"
                )
                site_ids = [r[0] for r in cur.fetchall()]
            conn.commit()
        finally:
            conn.close()

        for sid in site_ids:
            try:
                fire_event("ai_dws_completed", {"site_id": sid})
            except Exception:
                logger.exception("cron 触发 ai_dws_completed 失败: site_id=%s", sid)
        logger.info("App2 cron 预热完成: 已触发 %d 个门店", len(site_ids))

    return {
        "ai_consumption_settled": handle_consumption_settled,
        "ai_note_created": handle_note_created,
        "ai_task_assigned": handle_task_assigned,
        "ai_dws_completed": handle_dws_completed,
        "ai_dws_prewarm": handle_app2_prewarm,
    }


# ── 模块级 AIDispatcher 单例 ─────────────────────────────

_dispatcher_instance: AIDispatcher | None = None


def get_dispatcher() -> AIDispatcher:
    """获取全局 AIDispatcher 实例。

    须在 main.py lifespan 调用 register_ai_handlers 后使用。
    """
    if _dispatcher_instance is None:
        raise RuntimeError(
            "AIDispatcher 未初始化。确认 main.py lifespan 已调用 register_ai_handlers。"
        )
    return _dispatcher_instance


def register_ai_handlers(dispatcher: AIDispatcher) -> None:
    """将 AI 事件处理器注册到 trigger_scheduler，并设置模块级实例。

    在 FastAPI lifespan 中调用，将 AI 事件处理器
    注册为 trigger_scheduler 的 job handler。
    """
    global _dispatcher_instance
    from app.services.trigger_scheduler import register_job

    _dispatcher_instance = dispatcher

    handlers = _create_ai_event_handlers(dispatcher)
    for job_type, handler in handlers.items():
        register_job(job_type, handler)
        logger.info("已注册 AI 事件处理器: %s", job_type)
