"""统一 Agent 内核。

这个模块负责一次 Agent 调用的完整编排：

1. 根据 ``persona`` 选择客户顾问或运营助手。
2. 组合该人格的 Prompt、Skills 和工具白名单。
3. 调用大模型，让模型按需选择工具。
4. 收集工具产生的结构化卡片和页面操作。
5. 返回文本、卡片、操作按钮以及 Trace 信息。

它不直接查询数据库，也不直接计算保费；真实业务事实必须通过受控工具获取。
"""

from dataclasses import dataclass
from typing import Any, cast
from uuid import UUID, uuid4

from langchain.agents import create_agent
from langchain_core.messages import AIMessage
from langchain_core.runnables import RunnableConfig
from langsmith import Client, tracing_context

from zbt.core.config import Settings
from zbt.domains.identity.models import AdminUser, H5User
from zbt.harness.model_gateway import ModelGateway
from zbt.harness.policy import HarnessPolicyEngine
from zbt.harness.registries import PersonaRegistry, PromptRegistry, SkillRegistry
from zbt.harness.schemas import (
    AgentAction,
    AgentReply,
    AgentStructuredOutput,
    ProductRecommendationsBlock,
    ProductView,
)
from zbt.harness.tooling import ToolExecutionContext, ToolRegistry


@dataclass(frozen=True)
class ConversationTurn:
    """一轮历史对话；对象创建后不可修改。"""

    role: str
    content: str


@dataclass(frozen=True)
class AgentInvocation:
    """调用 Agent Kernel 所需的全部输入。

    客户端调用时传 ``h5_user``，运营端调用时传 ``admin_user``。
    当前登录身份会继续传给工具层，用于权限和数据范围检查。
    """

    persona: str
    message: str
    h5_user: H5User | None = None
    admin_user: AdminUser | None = None
    history: tuple[ConversationTurn, ...] = ()


class AgentKernel:
    """把模型、人格、提示词、技能、工具和权限策略装配成一次 Agent 运行。"""

    def __init__(
        self,
        *,
        settings: Settings,
        model_gateway: ModelGateway,
        tools: ToolRegistry,
        prompts: PromptRegistry | None = None,
        skills: SkillRegistry | None = None,
        personas: PersonaRegistry | None = None,
        policy: HarnessPolicyEngine | None = None,
    ) -> None:
        """保存可替换组件，并按配置决定是否启用 LangSmith Trace。"""

        self._settings = settings
        self._model_gateway = model_gateway
        self._tools = tools
        self._prompts = prompts or PromptRegistry()
        self._skills = skills or SkillRegistry()
        self._personas = personas or PersonaRegistry()
        self._policy = policy or HarnessPolicyEngine()
        self._langsmith_client = (
            Client(
                api_url=settings.langsmith_endpoint,
                api_key=settings.langsmith_api_key,
            )
            if settings.langsmith_tracing and settings.langsmith_api_key
            else None
        )

    def reply(self, invocation: AgentInvocation) -> AgentReply:
        """执行一次完整的 Agent 对话并返回前端可直接使用的结果。"""

        # 人格决定使用哪份 Prompt、加载哪些 Skills，以及允许调用哪些工具。
        persona = self._personas.get(invocation.persona)
        system_prompt = self._compose_prompt(persona.prompt_id, persona.skill_ids)

        # 上下文保存当前登录身份和本次已经发生的工具调用。
        # 每个工具执行前都会读取这里的身份，再经过 Policy 校验。
        context = ToolExecutionContext(
            persona=persona.id,
            h5_user=invocation.h5_user,
            admin_user=invocation.admin_user,
        )
        tools = self._tools.build(
            names=persona.tool_names,
            context=context,
            policy_engine=self._policy,
        )

        # ``create_agent`` 创建“模型思考 -> 调用工具 -> 继续思考”的执行循环。
        # 工具参数由模型生成，但会先经过 Pydantic 和权限策略校验。
        agent = create_agent(
            model=self._model_gateway.chat_model(persona.id),
            tools=tools,
            system_prompt=system_prompt,
            name=f"zhibaotong-{persona.id}",
        )
        trace_id = uuid4()

        # 只携带最近 12 轮，防止会话越长、模型输入无限增长。
        messages = [
            {"role": turn.role, "content": turn.content} for turn in invocation.history[-12:]
        ]
        messages.append({"role": "user", "content": invocation.message})
        with tracing_context(
            project_name=self._settings.langsmith_project,
            enabled=self._langsmith_client is not None,
            client=self._langsmith_client,
            tags=["zhibaotong", "s1", persona.id],
            metadata={
                "persona": persona.id,
                "channel": ("customer_h5" if persona.id == "customer" else "operation_admin"),
                "principal_id": context.principal_id,
            },
        ):
            # run_id 同时作为本地 Trace ID，便于本地运行记录与 LangSmith 对照。
            run_config: RunnableConfig = {"run_id": trace_id}
            result = agent.invoke(
                cast(Any, {"messages": messages}),
                config=run_config,
            )
        # 模型文本用于阅读；卡片和 Action 来自受控工具。
        # 前端不需要依赖正则表达式解析模型的自然语言。
        structured = self._structured_output(
            cast(dict[str, Any], result),
            context,
        )
        blocks = [block for execution in context.executions for block in execution.result.blocks]
        recommendation = self._recommendation_block(
            context,
            structured.selected_product_codes,
        )
        if recommendation is not None:
            blocks.insert(0, recommendation)
        actions = [
            action for execution in context.executions for action in execution.result.actions
        ]
        if structured.suggested_action is not None:
            actions.append(structured.suggested_action)
        unique_actions = _deduplicate_actions(actions)
        return AgentReply(
            text=structured.message,
            cards=[block.model_dump(mode="json") for block in blocks],
            trace_id=str(trace_id),
            trace_url=self._trace_url(trace_id),
            actions=[action.model_dump(mode="json") for action in unique_actions],
            invoked_tools=tuple(execution.name for execution in context.executions),
        )

    def _compose_prompt(
        self,
        prompt_id: str,
        skill_ids: tuple[str, ...],
    ) -> str:
        """把基础 Prompt 与多个文件化 Skill 合并为最终系统提示词。"""

        prompt = self._prompts.get(prompt_id)
        skill_sections = []
        for skill_id in skill_ids:
            skill = self._skills.get(skill_id)
            skill_sections.append(f"## Skill：{skill.name}\n\n{skill.instructions}")
        return f"{prompt}\n\n# 已加载技能\n\n" + "\n\n".join(skill_sections)

    @staticmethod
    def _structured_output(
        result: dict[str, Any],
        context: ToolExecutionContext,
    ) -> AgentStructuredOutput:
        """提取最终回答，并识别回答中实际选中的产品编码。

        这里只整理模型输出，不允许模型自由构造产品详情。
        """

        messages = result.get("messages", [])
        final = messages[-1] if messages else None
        text = (
            str(final.content)
            if isinstance(final, AIMessage)
            else "暂时无法生成有效回答，请稍后重试。"
        )
        selected_codes: list[str] = []
        for execution in context.executions:
            if execution.name != "list_available_products":
                continue
            raw_items = execution.result.data.get("items", [])
            if not isinstance(raw_items, list):
                continue
            for item in raw_items:
                if not isinstance(item, dict):
                    continue
                code = str(item.get("product_code", ""))
                name = str(item.get("name", ""))
                if code and (code in text or name in text):
                    selected_codes.append(code)
        return AgentStructuredOutput(
            message=text,
            selected_product_codes=list(dict.fromkeys(selected_codes))[:2],
        )

    @staticmethod
    def _recommendation_block(
        context: ToolExecutionContext,
        selected_codes: list[str],
    ) -> ProductRecommendationsBlock | None:
        """根据模型选中的编码，从工具数据中生成可信的推荐卡片。"""

        if not selected_codes:
            return None
        product_items: list[dict[str, Any]] = []
        for execution in context.executions:
            if execution.name != "list_available_products":
                continue
            raw_items = execution.result.data.get("items", [])
            if isinstance(raw_items, list):
                product_items.extend(item for item in raw_items if isinstance(item, dict))
        selected = [
            item
            for code in selected_codes
            for item in product_items
            if item.get("product_code") == code
        ][:2]
        if not selected:
            return None
        views = [
            ProductView.model_validate(
                {
                    "product_id": item["product_id"],
                    "product_code": item["product_code"],
                    "name": item["name"],
                    "category": item["category"],
                    "summary": item["summary"],
                    "product_version_id": item["product_version_id"],
                    "plans": item["plans"],
                }
            )
            for item in selected
        ]
        return ProductRecommendationsBlock(items=views)

    def _trace_url(self, trace_id: UUID) -> str | None:
        """尝试获取 LangSmith 页面地址；Trace 尚未同步时返回 ``None``。"""

        if self._langsmith_client is None:
            return None
        try:
            run = self._langsmith_client.read_run(trace_id)
            return self._langsmith_client.get_run_url(
                run=run,
                project_name=self._settings.langsmith_project,
            )
        except Exception:
            # Trace 上传可能有短暂延迟，运行详情接口会再次尝试解析。
            return None

    def trace_url(self, trace_id: str) -> str | None:
        """供会话服务在稍后重新查询 Trace 地址。"""

        try:
            return self._trace_url(UUID(trace_id))
        except ValueError:
            return None


def _deduplicate_actions(actions: list[AgentAction]) -> list[AgentAction]:
    """按 Action 内容去重，避免多个工具返回相同按钮。"""
    unique: list[AgentAction] = []
    seen: set[tuple[str, str]] = set()
    for action in actions:
        key = (action.type, str(sorted(action.payload.items())))
        if key not in seen:
            seen.add(key)
            unique.append(action)
    return unique
