import { Injectable, Logger } from '@nestjs/common';
import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
import { Permission } from '@pac/types';
import { AiProviderService } from '../ai/core/ai-provider.service';
import { McpClientService } from './mcp-client.service';

const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所的客服人员提供帮助。
你可以回答任何问题——日常闲聊、常识、写作、计算、以及与牙科或患者完全无关的话题,都请正常、友好地回答。
重要:不要因为一个问题"和患者业务无关"就拒绝或声明自己只能处理患者数据;像一个真正的通用助手那样尽力帮忙即可。

你额外接入了 PAC(疗效保障 / 患者分析中心)的一组患者工具,在用户需要查患者时按需调用:
- 用户给患者姓名/手机/患者号时,先用 find_patient 拿到 patientId,再继续。
- 要患者全景就用 get_patient_overview(一次给画像+事实+召回计划);要细节再用 get_persona / get_facts / get_recall_plan。
- 问"现在该联系谁 / 召回池"用 list_recall_queue。

当用工具回答患者相关问题时(数据正确性,务必遵守):只依据工具返回的真实数据,绝不编造;工具没返回的就如实说没有该信息;手机号只显示掩码。

展示方式(重要):
- 简短问答、闲聊、解释 → 直接用文字(markdown)回答,不要产卡片。
- 当内容适合可视化时——召回池/患者列表、患者画像卡、分析报表(可含图表)——先用工具取真实数据,然后调用 render_artifact 渲染一个 HTML 卡片来展示。
- render_artifact 的 html 是「<body> 内部片段」:用 Tailwind 工具类排版,强调色用 PAC 主题 teal #0D9488,白底圆角卡片、留白舒适;需要图表时用 Chart.js,且**必须**把 <canvas> 放进固定高度容器(如 <div style="position:relative;height:300px"><canvas></canvas></div>)并在 options 里设 maintainAspectRatio:false;**切勿**给图表用百分比/满屏高度(h-full / h-screen / height:100%),否则会无限拉高。
- 运行环境已注入 Tailwind 与 Chart.js,不要再写 <html>/<head>/<!DOCTYPE> 或自行引入它们;严禁 fetch / 访问任何外部网络——所有数据内联写进 HTML,手机号掩码,只用工具返回的真实值。
- HTML 力求紧凑高效:聚焦关键字段,避免重复堆砌的大段装饰性 class 和冗余结构(同类条目用最精简的标记),以便更快生成与呈现。
- 产出卡片后,再用一两句话点出要点即可(数据细节在卡片里)。

用中文,简洁专业、友好。`;

/** 桌宠"小牙"的人设(pet-say 专用,无工具、极短输出)。 */
const PET_SYSTEM_PROMPT = `你是牙科客服工作台 PAC 的桌面宠物"小牙"——一颗 Q 版小磨牙。
根据给你的环境观察,用第一人称说一句话:中文,不超过 30 个字,口语化、俏皮但不油腻,最多一个 emoji。
只评论观察里出现的事,不编造患者信息,不给医疗建议,不自称 AI 或模型;
不假设用户的性别/称呼(不要叫哥哥姐姐,直接用"你"或不称呼)。
直接输出台词本身,不要引号、不要解释。`;

export interface AssistantChatInput {
  userToken: string;
  modelId?: string;
  messages: ModelMessage[];
  /** 追加到 system 的渠道特定指令(如企微:纯文字、链接格式、多轮指代)。 */
  systemExtra?: string;
  /**
   * 调用人的权限清单 —— 只用来算 MCP 工具清单的**缓存分桶键**,不在本层做权限判定
   * (真正的判定在 MCP server 的条件注册与各 handler 里)。
   * 不传 = 按无特权处理:宁可少给工具,也不能把主管的工具漏给客服。
   */
  permissions?: readonly string[];
  abortSignal?: AbortSignal;
}

/**
 * 能力指纹 —— 决定 MCP 工具清单缓存分到哪个桶。
 *
 * ⚠️ 凡是**会改变工具清单**的权限,都必须列进来,否则两类人会共用一份缓存(串号且不报错)。
 * 现在只有 `plan:dispatch` 影响清单(主管多出分配/跟踪工具);将来再有条件注册的工具,
 * 加权限的同时**必须**在这里加一位,并在 mcp-server.factory 的条件注册处对齐。
 */
export function mcpCapabilityKey(permissions: readonly string[] | undefined): string {
  return permissions?.includes(Permission.PLAN_DISPATCH) ? 'dispatch' : 'basic';
}

/**
 * AssistantService — 独立的"外部 agent"模拟器(不复用 AiCall 单发框架)。
 *
 * 模型自主决定调哪些工具(model-driven tool-calling,非强制工作流):
 *   动态从 MCP 拉工具 → 包成 AI SDK tool(execute = 回调 MCP)→ streamText 多步循环
 *   (模型出 tool-call → 执行 → 喂回 → 直到产出最终文本)→ 流式返回。
 * provider 可切换(deepseek / gemini / qwen),复用 AiProviderService 配置。
 */
@Injectable()
export class AssistantService {
  private readonly logger = new Logger(AssistantService.name);

  constructor(
    private readonly provider: AiProviderService,
    private readonly mcp: McpClientService,
  ) {}

  // 显式收窄返回类型为控制器实际消费的最小形状,避开 streamText 返回类型引用 ai 内部
  // 未导出的 Output 类型导致的 .d.ts 命名失败(TS4053)。
  async chat(input: AssistantChatInput): Promise<{ fullStream: AsyncIterable<unknown> }> {
    // 1. 动态拉 MCP 工具 → 转成 AI SDK tools(execute 回调真 MCP 调用,带用户 token)
    const mcpTools = await this.mcp.listTools(
      input.userToken,
      mcpCapabilityKey(input.permissions),
    );
    const tools: ToolSet = {};
    for (const t of mcpTools) {
      tools[t.name] = tool({
        description: t.description ?? t.name,
        inputSchema: jsonSchema(t.inputSchema),
        execute: async (args: unknown) => this.mcp.callTool(input.userToken, t.name, args),
      });
    }

    // 本地"渲染"工具(不走 MCP):模型把自包含 HTML 片段交给前端,在沙箱 iframe 里渲染成卡片/报表。
    // html 通过 tool-call 入参流式到前端;execute 仅回执,模型据此继续给一句话总结。
    tools.render_artifact = tool({
      description:
        '把一段自包含 HTML(<body> 内部片段)渲染成可视化卡片/报表展示给用户。适合召回池列表、患者画像卡、分析报表(可含图表)。用 Tailwind 工具类排版,可含 <canvas>+<script> 画 Chart.js 图表;运行环境已注入 Tailwind 与 Chart.js,勿自行引入,勿访问外部网络,数据内联、手机号掩码、只用真实数据。',
      inputSchema: jsonSchema({
        type: 'object',
        properties: {
          title: { type: 'string', description: '卡片标题(可选)' },
          html: {
            type: 'string',
            description: '<body> 内部 HTML 片段(Tailwind 类;可含 <canvas>+<script> 图表)',
          },
        },
        required: ['html'],
      }),
      execute: async () => '已在界面渲染该卡片。',
    });

    this.logger.log(
      `assistant chat: model=${input.modelId ?? 'deepseek'} tools=${Object.keys(tools).join(',')}`,
    );

    // 2. resolve provider(可切换)+ streamText 跑 model-driven tool-calling 循环
    const { model } = this.provider.resolve(input.modelId ?? 'deepseek');
    return streamText({
      model,
      system: input.systemExtra ? `${SYSTEM_PROMPT}\n\n${input.systemExtra}` : SYSTEM_PROMPT,
      messages: input.messages,
      tools,
      stopWhen: stepCountIs(8), // 防失控:最多 8 步工具循环
      abortSignal: input.abortSignal,
    });
  }

  /** 桌宠环境观察发言 —— 无工具、限长、流式;失败由前端静默降级(宠物只是不说话)。 */
  petSay(input: { observation: string; abortSignal?: AbortSignal }): { textStream: AsyncIterable<string> } {
    const { model } = this.provider.resolve('deepseek');
    return streamText({
      model,
      system: PET_SYSTEM_PROMPT,
      prompt: `环境观察:${input.observation}`,
      // 注:思考型模型 reasoning 也计入 output token,上限给宽;台词长度靠系统提示词约束(≤30 字)
      maxOutputTokens: 500,
      temperature: 1.0,
      abortSignal: input.abortSignal,
    });
  }
}
