# =============================================================================
# 企微IT智能服务台 — 会话状态管理服务
# =============================================================================
# 说明：管理会话的完整生命周期，包括：
#   1. 创建会话（新员工发消息时自动创建）
#   2. 更新会话状态（queued → serving → resolved）
#   3. 分配坐席
#   4. 结单
#   5. 获取会话列表（支持排序：紧急→举手→需介入→活跃→已结单）
#   6. 获取坐席当前服务的会话列表
# =============================================================================

import logging
from datetime import datetime
from typing import List, Optional
from uuid import UUID

from sqlalchemy import and_, case, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.models.agent import Agent
from app.models.conversation import Conversation
from app.services.wecom_service import WecomService
from app.utils.response import (
    AppException,
    ERR_AGENT_BUSY,
    ERR_AGENT_NOT_FOUND,
    ERR_CONVERSATION_NOT_FOUND,
    ERR_CONVERSATION_RESOLVED,
    ERR_DUPLICATE_ASSIGN,
)

logger = logging.getLogger(__name__)


class SessionService:
    """会话状态管理服务。

    管理会话的完整生命周期，实现会话状态流转和坐席分配逻辑。
    """

    def __init__(
        self,
        db: AsyncSession,
        wecom_service: Optional[WecomService] = None,
    ):
        """初始化会话状态管理服务。

        Args:
            db: 异步数据库会话
            wecom_service: 企微 API 服务（用于坐席接入时发送通知，可选）
        """
        self.db = db
        self.wecom_service = wecom_service

    # --------------------------------------------------------------------------
    # 创建会话
    # --------------------------------------------------------------------------
    async def create_conversation(
        self,
        employee_id: str,
        employee_name: str = "",
        department: str = "",
        position: str = "",
        level: str = "",
    ) -> Conversation:
        """创建新会话。

        当员工首次发消息或摇人时自动创建。
        新会话默认状态为 queued（排队等坐席）。

        Args:
            employee_id: 企微员工 UserID
            employee_name: 员工姓名
            department: 部门
            position: 岗位
            level: 等级

        Returns:
            Conversation: 新创建的会话对象
        """
        conversation = Conversation(
            employee_id=employee_id,
            employee_name=employee_name,
            department=department,
            position=position,
            level=level,
            status="queued",
            is_vip=False,
            is_pinned=False,
            is_todo=False,
            urgency_score=1,
            tags={},
        )
        self.db.add(conversation)
        await self.db.flush()

        logger.info(f"创建会话: conv_id={conversation.id}, employee={employee_id}")
        return conversation

    # --------------------------------------------------------------------------
    # 更新会话状态
    # --------------------------------------------------------------------------
    async def update_status(
        self, conversation_id: UUID, new_status: str
    ) -> Conversation:
        """更新会话状态。

        状态流转规则：
        - queued → serving: 坐席接单
        - serving → resolved: 结单
        - queued → resolved: 直接结单（排队中员工问题已自行解决）

        Args:
            conversation_id: 会话ID
            new_status: 新状态（queued/serving/resolved/ai_handling）

        Returns:
            Conversation: 更新后的会话对象

        Raises:
            AppException: 会话不存在或状态流转不合法
        """
        conversation = await self._get_conversation(conversation_id)

        # 校验状态流转合法性
        valid_transitions = {
            "queued": ["serving", "resolved"],
            "serving": ["resolved"],
            "ai_handling": ["queued", "serving", "resolved"],
            "resolved": [],  # 已结单不能再改状态
        }

        allowed = valid_transitions.get(conversation.status, [])
        if new_status not in allowed and new_status != conversation.status:
            raise AppException(
                3010,
                f"会话状态流转不合法: {conversation.status} → {new_status}",
            )

        # 如果是已结单，不能再改状态
        if conversation.status == "resolved":
            raise ERR_CONVERSATION_RESOLVED

        conversation.status = new_status
        conversation.updated_at = datetime.now()
        self.db.add(conversation)
        await self.db.flush()

        logger.info(
            f"会话状态更新: conv_id={conversation_id}, "
            f"{conversation.status} → {new_status}"
        )
        return conversation

    # --------------------------------------------------------------------------
    # 分配坐席（接单）
    # --------------------------------------------------------------------------
    async def assign_agent(
        self, conversation_id: UUID, agent_id: str
    ) -> Conversation:
        """分配坐席接入会话。

        流程：
        1. 校验会话存在且未结单
        2. 校验坐席存在且在线
        3. 校验坐席未满负荷
        4. 更新会话状态为 serving
        5. 更新坐席当前服务数 +1
        6. 通过企微 API 向员工发送接入通知

        Args:
            conversation_id: 会话ID
            agent_id: 坐席ID

        Returns:
            Conversation: 更新后的会话对象

        Raises:
            AppException: 校验不通过
        """
        # 1. 校验会话
        conversation = await self._get_conversation(conversation_id)
        if conversation.status == "resolved":
            raise ERR_CONVERSATION_RESOLVED
        if conversation.assigned_agent_id and conversation.status == "serving":
            raise ERR_DUPLICATE_ASSIGN

        # 2. 校验坐席（本地开发模式：自动创建不存在的坐席）
        stmt = select(Agent).where(Agent.user_id == agent_id)
        result = await self.db.execute(stmt)
        agent = result.scalars().first()

        if not agent:
            # DEV MODE: 本地开发时自动创建坐席，避免必须先登录才能测试接单
            logger.warning(f"坐席不存在，自动创建: user_id={agent_id}")
            agent = Agent(
                user_id=agent_id,
                name="未知坐席",
                status="online",
                current_load=0,
                max_load=5,
            )
            self.db.add(agent)
            await self.db.flush()

        if agent.status == "offline":
            raise AppException(3007, "坐席不在线，无法接单")
        if agent.current_load >= agent.max_load:
            raise ERR_AGENT_BUSY

        # 3. 更新会话
        conversation.status = "serving"
        conversation.assigned_agent_id = agent_id
        conversation.updated_at = datetime.now()
        self.db.add(conversation)

        # 4. 更新坐席服务数
        agent.current_load += 1
        self.db.add(agent)

        await self.db.flush()

        # 5. 发送接入通知给员工
        if self.wecom_service:
            try:
                await self.wecom_service.send_text_message(
                    conversation.employee_id,
                    "人摇来了！IT坐席为您服务",
                )
            except Exception as e:
                logger.warning(f"发送接入通知失败（不阻塞流程）: {e}")

        logger.info(
            f"坐席接单: conv_id={conversation_id}, agent={agent_id}"
        )

        # ----------------------------------------------------------------------
        # 广播 WebSocket 事件：会话状态变更
        # ----------------------------------------------------------------------
        # 做什么：通知所有在线坐席，有会话被接单了
        # 为什么：其他坐席需要实时看到该会话从"排队"变为"服务中"
        # 用 try/except 包裹：广播失败不能阻塞接单主流程
        # ----------------------------------------------------------------------
        from app.services.ws_manager import manager as ws_manager
        try:
            await ws_manager.broadcast({
                "type": "conversation_updated",
                "data": {
                    "conversation_id": str(conversation.id),
                    "status": conversation.status,
                    "assigned_agent_id": conversation.assigned_agent_id,
                }
            })
        except Exception as e:
            logger.warning(f"WebSocket广播失败: {e}")

        return conversation

    # --------------------------------------------------------------------------
    # 结单
    # --------------------------------------------------------------------------
    async def resolve_conversation(
        self, conversation_id: UUID, agent_id: Optional[str] = None
    ) -> Conversation:
        """结单。

        流程：
        1. 校验会话存在
        2. 更新会话状态为 resolved
        3. 如果有坐席，更新坐席当前服务数 -1

        Args:
            conversation_id: 会话ID
            agent_id: 坐席ID（可选，用于更新坐席服务数）

        Returns:
            Conversation: 更新后的会话对象
        """
        conversation = await self._get_conversation(conversation_id)

        if conversation.status == "resolved":
            raise ERR_CONVERSATION_RESOLVED

        # 更新会话状态
        conversation.status = "resolved"
        conversation.updated_at = datetime.now()
        self.db.add(conversation)

        # 更新坐席服务数
        assigned_agent_id = agent_id or conversation.assigned_agent_id
        if assigned_agent_id:
            stmt = select(Agent).where(Agent.user_id == assigned_agent_id)
            result = await self.db.execute(stmt)
            agent = result.scalars().first()
            if agent and agent.current_load > 0:
                agent.current_load -= 1
                self.db.add(agent)

        await self.db.flush()

        logger.info(f"会话结单: conv_id={conversation_id}")

        # ----------------------------------------------------------------------
        # 广播 WebSocket 事件：会话已结单
        # ----------------------------------------------------------------------
        # 做什么：通知所有在线坐席，有会话已结单
        # 为什么：坐席需要实时看到该会话从"服务中"变为"已结单"
        # 用 try/except 包裹：广播失败不能阻塞结单主流程
        # ----------------------------------------------------------------------
        from app.services.ws_manager import manager as ws_manager
        try:
            await ws_manager.broadcast({
                "type": "conversation_updated",
                "data": {
                    "conversation_id": str(conversation.id),
                    "status": conversation.status,
                    "assigned_agent_id": conversation.assigned_agent_id,
                }
            })
        except Exception as e:
            logger.warning(f"WebSocket广播失败: {e}")

        return conversation

    # --------------------------------------------------------------------------
    # 置顶/取消置顶
    # --------------------------------------------------------------------------
    async def toggle_pin(self, conversation_id: UUID) -> Conversation:
        """切换会话置顶状态。

        Args:
            conversation_id: 会话ID

        Returns:
            Conversation: 更新后的会话对象
        """
        conversation = await self._get_conversation(conversation_id)
        conversation.is_pinned = not conversation.is_pinned
        conversation.updated_at = datetime.now()
        self.db.add(conversation)
        await self.db.flush()

        logger.info(
            f"会话置顶{'开启' if conversation.is_pinned else '取消'}: "
            f"conv_id={conversation_id}"
        )
        return conversation

    # --------------------------------------------------------------------------
    # 代办/取消代办
    # --------------------------------------------------------------------------
    async def toggle_todo(self, conversation_id: UUID) -> Conversation:
        """切换会话代办状态。

        Args:
            conversation_id: 会话ID

        Returns:
            Conversation: 更新后的会话对象
        """
        conversation = await self._get_conversation(conversation_id)
        conversation.is_todo = not conversation.is_todo
        conversation.updated_at = datetime.now()
        self.db.add(conversation)
        await self.db.flush()

        logger.info(
            f"会话代办{'开启' if conversation.is_todo else '取消'}: "
            f"conv_id={conversation_id}"
        )
        return conversation

    # --------------------------------------------------------------------------
    # 摇人 — 邀请坐席加入协作
    # --------------------------------------------------------------------------
    async def invite_collaborator(
        self,
        conversation_id: UUID,
        inviter_agent_id: str,
        invitee_agent_id: str,
    ) -> Conversation:
        """邀请坐席加入协作。

        做什么：坐席A在处理会话时发现需要坐席B的专业知识，点击「摇人」
        将坐席B加入 collaborating_agent_ids 列表，坐席B可查看和回复但不能结单。

        校验规则：
        1. 会话存在且为 serving（已结单的不能摇人）
        2. 邀请人在主责坐席或协作坐席列表中
        3. 被邀请人不能是主责坐席，也不能已在协作列表中（防重复）
        4. 被邀请坐席存在且在线

        Args:
            conversation_id: 会话ID
            inviter_agent_id: 邀请人坐席ID
            invitee_agent_id: 被邀请坐席ID

        Returns:
            Conversation: 更新后的会话对象

        Raises:
            AppException: 校验不通过
        """
        # 1. 校验会话
        conversation = await self._get_conversation(conversation_id)
        if conversation.status == "resolved":
            raise ERR_CONVERSATION_RESOLVED
        if conversation.status != "serving":
            raise AppException(3020, "只能邀请协作服务中的会话")

        # 2. 校验邀请人权限：必须是主责坐席或已在协作列表中
        is_owner = conversation.assigned_agent_id == inviter_agent_id
        is_collaborator = inviter_agent_id in (conversation.collaborating_agent_ids or [])
        if not is_owner and not is_collaborator:
            raise AppException(3021, "只有主责坐席或协作坐席才能摇人")

        # 3. 校验被邀请人：不能是主责坐席
        if conversation.assigned_agent_id == invitee_agent_id:
            raise AppException(3022, "不能邀请主责坐席协作（他已经在处理了）")

        # 4. 校验被邀请人：不能已在协作列表中
        if invitee_agent_id in (conversation.collaborating_agent_ids or []):
            raise AppException(3023, "该坐席已在协作中，无需重复邀请")

        # 5. 校验被邀请坐席存在且在线
        stmt = select(Agent).where(Agent.user_id == invitee_agent_id)
        result = await self.db.execute(stmt)
        invitee = result.scalars().first()
        if not invitee:
            raise ERR_AGENT_NOT_FOUND
        if invitee.status == "offline":
            raise AppException(3024, "被邀请坐席不在线")

        # 6. 将被邀请人加入协作列表
        collab_ids = list(conversation.collaborating_agent_ids or [])
        collab_ids.append(invitee_agent_id)
        conversation.collaborating_agent_ids = collab_ids
        conversation.updated_at = datetime.now()
        self.db.add(conversation)
        await self.db.flush()

        logger.info(
            f"摇人成功: conv_id={conversation_id}, "
            f"inviter={inviter_agent_id}, invitee={invitee_agent_id}"
        )

        # ----------------------------------------------------------------------
        # 广播 + 定向推送 WebSocket 事件
        # ----------------------------------------------------------------------
        # 做什么：
        #   1. broadcast — 所有坐席看到协作关系变化（刷新会话列表）
        #   2. send_to_agent — 被邀请人收到定向通知（弹窗提示）
        # 用 try/except 包裹：推送失败不能阻塞主流程
        # ----------------------------------------------------------------------
        from app.services.ws_manager import manager as ws_manager
        inviter_name = inviter_agent_id  # 前端会从 agent_name_map 获取真实姓名

        try:
            # 广播：会话协作关系变更（所有坐席刷新列表）
            await ws_manager.broadcast({
                "type": "collaborator_joined",
                "data": {
                    "conversation_id": str(conversation.id),
                    "agent_id": invitee_agent_id,
                    "inviter_agent_id": inviter_agent_id,
                }
            })
            # 定向推送：通知被邀请人
            await ws_manager.send_to_agent(invitee_agent_id, {
                "type": "collaborator_invited",
                "data": {
                    "conversation_id": str(conversation.id),
                    "inviter_agent_id": inviter_agent_id,
                    "invitee_agent_id": invitee_agent_id,
                    "employee_name": conversation.employee_name,
                    "last_message_summary": conversation.last_message_summary or "",
                }
            })
        except Exception as e:
            logger.warning(f"WebSocket推送失败（不阻塞流程）: {e}")

        return conversation

    # --------------------------------------------------------------------------
    # 摇人 — 退出协作
    # --------------------------------------------------------------------------
    async def leave_collaboration(
        self,
        conversation_id: UUID,
        agent_id: str,
    ) -> Conversation:
        """坐席退出协作。

        做什么：将坐席从 collaborating_agent_ids 中移除。

        校验规则：
        1. 坐席必须在协作列表中
        2. 坐席不能是主责坐席（主责坐席不能"退出"，只能转接或结单）

        Args:
            conversation_id: 会话ID
            agent_id: 退出协作的坐席ID

        Returns:
            Conversation: 更新后的会话对象

        Raises:
            AppException: 校验不通过
        """
        # 1. 校验会话
        conversation = await self._get_conversation(conversation_id)

        # 2. 校验：不能是主责坐席
        if conversation.assigned_agent_id == agent_id:
            raise AppException(3025, "主责坐席不能退出协作，请使用转接或结单")

        # 3. 校验：必须在协作列表中
        collab_ids = list(conversation.collaborating_agent_ids or [])
        if agent_id not in collab_ids:
            raise AppException(3026, "您不在该会话的协作列表中")

        # 4. 移除
        collab_ids.remove(agent_id)
        conversation.collaborating_agent_ids = collab_ids
        conversation.updated_at = datetime.now()
        self.db.add(conversation)
        await self.db.flush()

        logger.info(f"退出协作: conv_id={conversation_id}, agent={agent_id}")

        # ----------------------------------------------------------------------
        # 广播 WebSocket 事件：协作坐席退出
        # ----------------------------------------------------------------------
        from app.services.ws_manager import manager as ws_manager
        try:
            await ws_manager.broadcast({
                "type": "collaborator_left",
                "data": {
                    "conversation_id": str(conversation.id),
                    "agent_id": agent_id,
                }
            })
        except Exception as e:
            logger.warning(f"WebSocket推送失败（不阻塞流程）: {e}")

        return conversation

    # --------------------------------------------------------------------------
    # 转接会话
    # --------------------------------------------------------------------------
    async def transfer_conversation(
        self, conversation_id: UUID, target_agent_id: str
    ) -> Conversation:
        """转接会话到另一个坐席。

        第一步简化版：只更换坐席，不做转接通知。

        Args:
            conversation_id: 会话ID
            target_agent_id: 目标坐席ID

        Returns:
            Conversation: 更新后的会话对象
        """
        conversation = await self._get_conversation(conversation_id)

        # 校验目标坐席
        stmt = select(Agent).where(Agent.user_id == target_agent_id)
        result = await self.db.execute(stmt)
        target_agent = result.scalars().first()

        if not target_agent:
            raise ERR_AGENT_NOT_FOUND
        if target_agent.current_load >= target_agent.max_load:
            raise ERR_AGENT_BUSY

        # 旧坐席服务数 -1
        old_agent_id = conversation.assigned_agent_id
        if old_agent_id:
            stmt = select(Agent).where(Agent.user_id == old_agent_id)
            result = await self.db.execute(stmt)
            old_agent = result.scalars().first()
            if old_agent and old_agent.current_load > 0:
                old_agent.current_load -= 1
                self.db.add(old_agent)

        # 新坐席服务数 +1
        target_agent.current_load += 1
        self.db.add(target_agent)

        # 更新会话
        conversation.assigned_agent_id = target_agent_id
        conversation.updated_at = datetime.now()
        self.db.add(conversation)

        await self.db.flush()

        logger.info(
            f"会话转接: conv_id={conversation_id}, "
            f"from={old_agent_id} to={target_agent_id}"
        )

        # ----------------------------------------------------------------------
        # 广播 WebSocket 事件：会话转接
        # ----------------------------------------------------------------------
        # 做什么：通知所有在线坐席，有会话被转接了
        # 为什么：原坐席和目标坐席都需要实时看到会话归属变化
        # 用 try/except 包裹：广播失败不能阻塞转接主流程
        # ----------------------------------------------------------------------
        from app.services.ws_manager import manager as ws_manager
        try:
            await ws_manager.broadcast({
                "type": "conversation_updated",
                "data": {
                    "conversation_id": str(conversation.id),
                    "status": conversation.status,
                    "assigned_agent_id": conversation.assigned_agent_id,
                }
            })
        except Exception as e:
            logger.warning(f"WebSocket广播失败: {e}")

        return conversation

    # --------------------------------------------------------------------------
    # 获取会话列表（坐席端）
    # --------------------------------------------------------------------------
    async def get_conversations(
        self,
        status: Optional[str] = None,
        agent_id: Optional[str] = None,
        page: int = 1,
        page_size: int = 50,
    ) -> tuple[List[Conversation], int]:
        """获取会话列表，支持过滤和排序。

        排序规则（PRD 定义）：
        紧急 → 举手 → 需介入 → 活跃 → 已结单
        同级别按 last_message_at 倒序

        实现方式：先按数据库基础排序（状态+置顶+紧急度），
        再在 Python 侧按完整规则精细排序（含 JSON tags 字段）。

        Args:
            status: 按状态过滤（可选）
            agent_id: 按坐席ID过滤（可选，查看某坐席的会话）
            page: 页码（从1开始）
            page_size: 每页数量

        Returns:
            tuple[List[Conversation], int]: (会话列表, 总数)
        """
        # 构建查询条件
        conditions = []
        if status:
            conditions.append(Conversation.status == status)
        if agent_id:
            conditions.append(Conversation.assigned_agent_id == agent_id)

        # 查询总数
        count_stmt = select(func.count(Conversation.id))
        if conditions:
            count_stmt = count_stmt.where(and_(*conditions))
        total_result = await self.db.execute(count_stmt)
        total = total_result.scalar() or 0

        # 数据库侧基础排序（快速过滤）：
        #   置顶 > 紧急度5 > 紧急度4 > 紧急度3 > 状态排序 > 最后消息时间
        # JSON tags 字段的排序在 Python 侧完成（SQLite 不支持 JSON 操作符）
        db_order_weight = case(
            (Conversation.is_pinned == True, 1000),
            (Conversation.urgency_score >= 5, 900),
            (Conversation.urgency_score >= 4, 600),
            (Conversation.urgency_score >= 3, 300),
            (Conversation.status == "queued", 200),
            (Conversation.status == "ai_handling", 150),
            (Conversation.status == "serving", 100),
            else_=0,
        )

        stmt = select(Conversation)
        if conditions:
            stmt = stmt.where(and_(*conditions))
        # 数据库侧先按基础权重 + 最后消息时间排序
        stmt = stmt.order_by(desc(db_order_weight), desc(Conversation.last_message_at))

        # 查询所有符合条件的会话（数据量不大时可行；生产环境建议改用 PostgreSQL + JSONB 操作符）
        result = await self.db.execute(stmt)
        all_conversations = list(result.scalars().all())

        # ===== Python 侧精细排序（支持 JSON tags 字段）=====
        def _sort_key(conv: Conversation):
            """计算完整排序权重（数值越大越靠前）"""
            weight = 0
            tags = conv.tags or {}

            # 置顶（最高优先级）
            if conv.is_pinned:
                weight += 10000

            # 紧急度评分（越高越靠前）
            urgency = conv.urgency_score or 0
            if urgency >= 5:
                weight += 9000
            elif urgency >= 4:
                weight += 6000
            elif urgency >= 3:
                weight += 3000

            # 举手标记
            if tags.get("hand_raise"):
                weight += 8000

            # 需介入标记
            if tags.get("need_intervene"):
                weight += 7000

            # 情绪标记（非 neutral）
            emotion = tags.get("emotion", "neutral")
            if emotion and emotion != "neutral":
                weight += 5000

            # 状态排序
            status_order = {
                "queued": 2000,
                "ai_handling": 1500,
                "serving": 1000,
                "resolved": 0,
            }
            weight += status_order.get(conv.status, 0)

            # 最后消息时间（时间戳越大越靠前，除以 1e6 归一化到合理范围）
            import time
            if conv.last_message_at:
                ts = conv.last_message_at.timestamp()
            else:
                ts = 0
            # 用 (weight, ts) 元组排序：先按 weight 降序，再按 ts 降序
            return (weight, ts)

        # 按完整规则排序（降序）
        all_conversations.sort(key=_sort_key, reverse=True)

        # 分页
        offset = (page - 1) * page_size
        conversations = all_conversations[offset:offset + page_size]

        return conversations, total

    # --------------------------------------------------------------------------
    # 获取坐席当前服务的会话列表
    # --------------------------------------------------------------------------
    async def get_agent_conversations(
        self, agent_id: str
    ) -> List[Conversation]:
        """获取坐席当前正在服务的会话列表。

        只返回状态为 serving 且分配给该坐席的会话。

        Args:
            agent_id: 坐席ID

        Returns:
            List[Conversation]: 会话列表
        """
        stmt = (
            select(Conversation)
            .where(
                Conversation.assigned_agent_id == agent_id,
                Conversation.status == "serving",
            )
            .order_by(desc(Conversation.last_message_at))
        )
        result = await self.db.execute(stmt)
        return list(result.scalars().all())

    # --------------------------------------------------------------------------
    # 获取会话详情
    # --------------------------------------------------------------------------
    async def _get_conversation(self, conversation_id: UUID) -> Conversation:
        """获取会话对象，不存在则抛异常。

        Args:
            conversation_id: 会话ID

        Returns:
            Conversation: 会话对象

        Raises:
            AppException: 会话不存在
        """
        # 将 UUID 转为字符串，确保与 String(36) 列类型匹配
        conv_id_str = str(conversation_id)
        stmt = select(Conversation).where(Conversation.id == conv_id_str)
        result = await self.db.execute(stmt)
        conversation = result.scalars().first()

        if not conversation:
            raise ERR_CONVERSATION_NOT_FOUND

        return conversation

    async def get_conversation(self, conversation_id: UUID) -> Conversation:
        """获取会话详情（公开方法）。

        Args:
            conversation_id: 会话ID

        Returns:
            Conversation: 会话对象

        Raises:
            AppException: 会话不存在
        """
        return await self._get_conversation(conversation_id)

    # ======================================================================
    # 邀请功能（P0-09~P0-11）：坐席邀请员工/部门加入会话
    # ======================================================================

    async def _get_employee_avatar(self, employee_id: str) -> str:
        """获取员工头像URL。

        做什么：从 employees 表或企微通讯录API获取头像
        为什么：邀请参与者时需要展示头像，前端无法单独获取被邀请人头像
        优先级：employees 表（本地缓存） > 企微API（实时获取）

        Args:
            employee_id: 企微员工UserID

        Returns:
            str: 头像URL，获取不到返回空字符串
        """
        # 1. 优先从 employees 表查（本地缓存，速度快）
        from app.models.employee import Employee
        result = await self.db.execute(
            select(Employee.avatar).where(Employee.employee_id == employee_id)
        )
        row = result.first()
        if row and row[0]:
            return row[0]

        # 2. employees 表没有，尝试从企微通讯录API获取
        if self.wecom_service:
            try:
                user_info = await self.wecom_service.get_user_info(employee_id)
                avatar = user_info.get("avatar", "")
                return avatar
            except Exception as e:
                logger.warning(f"从企微API获取头像失败: employee_id={employee_id}, error={e}")

        return ""

    async def invite_participants(
        self,
        conversation_id: UUID,
        inviter_agent_id: str,
        participants: list[dict],
        history_mode: str = "recent10",
    ) -> Conversation:
        """邀请员工/部门加入会话（P0-09）。

        做什么：主责坐席邀请非坐席人员（员工/部门）参与会话
        为什么：复杂IT问题可能需要业务方人员补充信息
        权限：只有主责坐席可以发起邀请

        Args:
            conversation_id: 会话ID
            inviter_agent_id: 邀请人坐席ID
            participants: 被邀请人列表 [{"id": "xxx", "name": "xxx", "department": "xxx", "type": "employee"}]
            history_mode: 历史共享模式 — recent10/all/none

        Returns:
            Conversation: 更新后的会话对象

        Raises:
            AppException: 校验不通过
        """
        # 1. 校验会话
        conversation = await self._get_conversation(conversation_id)

        # 2. 权限：只有主责坐席可以邀请
        if conversation.assigned_agent_id != inviter_agent_id:
            raise AppException(3030, "只有主责坐席才能邀请人员加入会话")

        # 3. 校验：会话必须是服务中状态
        if conversation.status != "serving":
            raise AppException(3031, f"只有服务中的会话才能邀请，当前状态: {conversation.status}")

        # 4. 合并参与者（去重），同时补充头像
        existing_participants = list(conversation.participants or [])
        existing_ids = {p.get("id") for p in existing_participants}
        new_added = []

        for p in participants:
            if p.get("id") not in existing_ids:
                # 补充头像：员工类型的参与者，从 employees 表或企微API获取头像
                if p.get("type", "employee") == "employee" and not p.get("avatar"):
                    try:
                        avatar_url = await self._get_employee_avatar(p["id"])
                        if avatar_url:
                            p["avatar"] = avatar_url
                    except Exception as e:
                        logger.warning(f"获取参与者头像失败（不阻塞流程）: employee_id={p['id']}, error={e}")

                existing_participants.append(p)
                existing_ids.add(p.get("id"))
                new_added.append(p)

        if not new_added:
            raise AppException(3032, "所有被邀请人已在该会话中")

        # 5. 更新 participants 字段
        conversation.participants = existing_participants
        conversation.updated_at = datetime.now()
        self.db.add(conversation)
        await self.db.flush()

        logger.info(
            f"邀请参与者: conv_id={conversation_id}, "
            f"inviter={inviter_agent_id}, new_participants={len(new_added)}"
        )

        # 6. 发送企微卡片通知给被邀请人
        if self.wecom_service:
            for p in new_added:
                if p.get("type") == "employee":
                    try:
                        # 生成邀请链接：H5 端加入会话的 URL
                        # 格式：https://itsupport.servyou.com.cn/itdesk/?invite={conv_id}&eid={employee_id}
                        invite_url = (
                            f"{getattr(self, '_h5_base_url', '')}/itdesk/"
                            f"?invite={conversation.id}&eid={p['id']}"
                        )
                        await self.wecom_service.send_card_message(
                            user_id=p["id"],
                            title="IT服务台邀请您加入会话",
                            description=(
                                f"坐席邀请您加入一个IT服务会话，"
                                f"请点击「加入会话」查看详情并参与讨论。"
                            ),
                            url=invite_url,
                            btntxt="加入会话",
                        )
                    except Exception as e:
                        logger.warning(f"发送邀请通知失败（不阻塞流程）: user_id={p['id']}, error={e}")

        # 7. 创建系统消息广播
        await self._create_system_message(
            conversation_id=conversation.id,
            content=f"坐席邀请 {', '.join(p['name'] for p in new_added)} 加入会话",
            extra_data={"action": "participant_invited", "participants": new_added, "history_mode": history_mode},
        )

        # 8. WebSocket 广播：参与者变更通知
        await self._broadcast_participant_change(conversation, "participant_invited", new_added)

        return conversation

    async def join_conversation(
        self,
        conversation_id: UUID,
        employee_id: str,
    ) -> Conversation:
        """被邀请人通过链接加入会话（P0-10）。

        做什么：被邀请人点击企微卡片链接后加入会话
        为什么：实现邀请-加入闭环
        校验：该员工必须在 participants 列表中

        Args:
            conversation_id: 会话ID
            employee_id: 加入的员工企微UserID

        Returns:
            Conversation: 会话对象

        Raises:
            AppException: 校验不通过
        """
        # 1. 校验会话
        conversation = await self._get_conversation(conversation_id)

        # 2. 校验：会话必须是服务中状态
        if conversation.status != "serving":
            raise AppException(3033, "该会话已结束，无法加入")

        # 3. 校验：该员工必须在 participants 列表中（被邀请过才能加入）
        participants = list(conversation.participants or [])
        is_invited = any(p.get("id") == employee_id for p in participants)
        if not is_invited:
            raise AppException(3034, "您未被邀请加入该会话")

        # 4. 更新参与者的 joined 状态
        for p in participants:
            if p.get("id") == employee_id:
                p["joined"] = True
                p["joined_at"] = datetime.now().isoformat()
                break

        conversation.participants = participants
        conversation.updated_at = datetime.now()
        self.db.add(conversation)
        await self.db.flush()

        # 5. 获取参与者姓名
        participant_name = next(
            (p["name"] for p in participants if p.get("id") == employee_id),
            "未知"
        )

        # 6. 系统消息
        await self._create_system_message(
            conversation_id=conversation.id,
            content=f"{participant_name} 已加入会话",
            extra_data={"action": "participant_joined", "employee_id": employee_id},
        )

        # 7. WebSocket 广播
        await self._broadcast_participant_change(
            conversation, "participant_joined", [{"id": employee_id, "name": participant_name}]
        )

        return conversation

    async def remove_participant(
        self,
        conversation_id: UUID,
        remover_agent_id: str,
        target_user_id: str,
    ) -> Conversation:
        """移除参与者（P0-11）。

        做什么：主责坐席将参与者移出会话
        为什么：参与者不再需要参与时，主责坐席可以移除
        权限：只有主责坐席可以移除参与者

        Args:
            conversation_id: 会话ID
            remover_agent_id: 操作坐席ID
            target_user_id: 被移除的员工UserID

        Returns:
            Conversation: 更新后的会话对象

        Raises:
            AppException: 校验不通过
        """
        # 1. 校验会话
        conversation = await self._get_conversation(conversation_id)

        # 2. 权限：只有主责坐席可以移除
        if conversation.assigned_agent_id != remover_agent_id:
            raise AppException(3035, "只有主责坐席才能移除参与者")

        # 3. 查找并移除
        participants = list(conversation.participants or [])
        removed_name = None
        new_participants = []
        for p in participants:
            if p.get("id") == target_user_id:
                removed_name = p.get("name", "未知")
            else:
                new_participants.append(p)

        if removed_name is None:
            raise AppException(3036, "该人员不在会话参与者列表中")

        conversation.participants = new_participants
        conversation.updated_at = datetime.now()
        self.db.add(conversation)
        await self.db.flush()

        # 4. 系统消息
        await self._create_system_message(
            conversation_id=conversation.id,
            content=f"{removed_name} 已被移出会话",
            extra_data={"action": "participant_removed", "user_id": target_user_id},
        )

        # 5. WebSocket 广播
        await self._broadcast_participant_change(
            conversation, "participant_removed", [{"id": target_user_id, "name": removed_name}]
        )

        return conversation

    async def leave_as_participant(
        self,
        conversation_id: UUID,
        employee_id: str,
    ) -> Conversation:
        """参与者主动退出会话。

        做什么：被邀请人主动退出会话
        为什么：参与者不再需要参与时，可以自行退出

        Args:
            conversation_id: 会话ID
            employee_id: 退出的员工企微UserID

        Returns:
            Conversation: 更新后的会话对象

        Raises:
            AppException: 校验不通过
        """
        # 1. 校验会话
        conversation = await self._get_conversation(conversation_id)

        # 2. 查找并移除
        participants = list(conversation.participants or [])
        leaving_name = None
        new_participants = []
        for p in participants:
            if p.get("id") == employee_id:
                leaving_name = p.get("name", "未知")
            else:
                new_participants.append(p)

        if leaving_name is None:
            raise AppException(3037, "您不在该会话的参与者列表中")

        conversation.participants = new_participants
        conversation.updated_at = datetime.now()
        self.db.add(conversation)
        await self.db.flush()

        # 3. 系统消息
        await self._create_system_message(
            conversation_id=conversation.id,
            content=f"{leaving_name} 已退出会话",
            extra_data={"action": "participant_left", "employee_id": employee_id},
        )

        # 4. WebSocket 广播
        await self._broadcast_participant_change(
            conversation, "participant_left", [{"id": employee_id, "name": leaving_name}]
        )

        return conversation

    # ======================================================================
    # 邀请功能 — 内部辅助方法
    # ======================================================================

    async def _create_system_message(
        self,
        conversation_id: str,
        content: str,
        extra_data: Optional[dict] = None,
    ) -> None:
        """创建系统消息并保存到数据库。

        做什么：在会话中插入一条系统类型的消息
        为什么：邀请/加入/退出等事件需要在消息流中可见

        Args:
            conversation_id: 会话ID（字符串）
            content: 消息内容
            extra_data: 扩展元数据（JSON）
        """
        from app.models.message import Message

        system_msg = Message(
            conversation_id=conversation_id,
            sender_type="system",
            sender_id="system",
            sender_name="系统",
            content=content,
            msg_type="system",
            extra_data=extra_data,
        )
        self.db.add(system_msg)
        await self.db.flush()

        logger.info(f"系统消息已创建: conv_id={conversation_id}, content={content[:50]}")

    async def _broadcast_participant_change(
        self,
        conversation: Conversation,
        action: str,
        changed_participants: list[dict],
    ) -> None:
        """通过 WebSocket 广播参与者变更事件。

        做什么：
        1. 通知所有在线坐席参与者列表发生变化
        2. 通知相关H5员工（会话的原始员工 + 被邀请参与者）参与者变更
        为什么：
        - 坐席端需要实时更新参与者展示
        - H5员工端需要实时更新参与者面板（如新参与者加入、有人退出）
        降级策略：广播失败不阻塞主流程

        Args:
            conversation: 会话对象
            action: 事件类型（participant_invited/joined/removed/left）
            changed_participants: 变更的参与者列表
        """
        from app.services.ws_manager import manager as ws_manager
        event_data = {
            "type": action,
            "data": {
                "conversation_id": str(conversation.id),
                "participants": conversation.participants or [],
                "changed": changed_participants,
            }
        }
        try:
            # 1. 广播给所有在线坐席
            await ws_manager.broadcast(event_data)
        except Exception as e:
            logger.warning(f"WebSocket广播参与者变更失败（坐席端，不阻塞流程）: {e}")

        try:
            # 2. 推送给相关H5员工（原始员工 + 所有参与者中已加入的员工）
            # 做什么：收集会话相关员工的ID，通过 WS 推送
            # 为什么：H5端需要实时刷新参与者面板，不用等3秒轮询
            employee_ids = set()

            # 原始员工（会话发起人）
            if conversation.employee_id:
                employee_ids.add(conversation.employee_id)

            # 所有参与者中的员工类型
            for p in (conversation.participants or []):
                if p.get("type", "employee") == "employee" and p.get("id"):
                    employee_ids.add(p["id"])

            if employee_ids:
                await ws_manager.broadcast_to_employees(list(employee_ids), event_data)
        except Exception as e:
            logger.warning(f"WebSocket推送参与者变更失败（H5员工端，不阻塞流程）: {e}")
