import { Injectable, Logger } from '@nestjs/common';
import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
import { randomUUID } from 'node:crypto';
import { Permission } from '@pac/types';
import { AiProviderService } from '../ai/core/ai-provider.service';
import { McpClientService } from './mcp-client.service';
import { AssignmentProposalService } from '../plan/assignment-proposal.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';

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[];
  /**
   * 侧信道 —— 把**肥载荷**直接推给前端,不经过模型上下文。
   *
   * ⭐ 确认单是这条通道存在的理由:它含几百个 planId(一批 850 人 ≈ 12k token),
   * 而模型对它唯一要做的事是"转述一句话"。走工具返回值等于让模型
   * **逐字复述几百个 uuid** —— 幻觉率接近 100%,而且贵。
   * ⛔ 也不能让模型自己在 tool_call 参数里写这些 id:那些参数是**模型生成的**,
   * 同样是它在编。真数据只能由服务端产、由服务端推。
   */
  onSideEvent?: (event: Record<string, unknown>) => void;
  /** 当前登录人(本地工具要用 scope 取数);缺省则本地写工具不注册 */
  scope?: TenantScopeContext;
  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,
    private readonly proposals: AssignmentProposalService,
  ) {}

  // 显式收窄返回类型为控制器实际消费的最小形状,避开 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 工具**:MCP 的返回值会原样回灌进模型上下文,
    //   而这个载荷有几百个 planId。这里走侧信道推前端,只回给模型一句短话。
    if (input.scope && input.onSideEvent && input.permissions?.includes(Permission.PLAN_DISPATCH)) {
      const scope = input.scope;
      const push = input.onSideEvent;
      tools.propose_assignment = tool({
        description:
          '出「全景确认单」:从召回池按排序键选人,按「专属优先 / 溢出铺平」落到客服头上,' +
          '并把卡片直接呈现给主管。' +
          '\n⚠️ 卡片由界面渲染,**你看不到明细也不需要看** —— 你的任务是转述返回的那句摘要。' +
          '\n⚠️ **这只是提案,一个字都没写库**。绝不要说「已经分配好了」。' +
          '\n⚠️ 批次规模你自己定(不传 targetCount 即用默认),**不要问主管**;他要改会自己说。',
        inputSchema: jsonSchema({
          type: 'object',
          properties: {
            clinicId: { type: 'string', description: '诊所 id;不传用当前登录人的第一个诊所' },
            potentialTreatment: {
              type: 'string',
              description:
                '画像「潜在治疗」8 类之一:implant/ortho/early_ortho/endo/perio/filling/restoration/extraction',
            },
            targetCount: { type: 'number', description: '拟分人数(不传用默认批次规模)' },
            exploreRatio: { type: 'number', description: '探索配额占比 0-0.2' },
          },
        }),
        execute: async (args: unknown) => {
          const a = (args ?? {}) as {
            clinicId?: string;
            potentialTreatment?: string;
            targetCount?: number;
            exploreRatio?: number;
          };
          const clinicId = a.clinicId ?? scope.clinicIds[0];
          if (!clinicId) {
            return '当前登录人没有绑定诊所,无法出确认单 —— 请主管确认数据范围。';
          }
          const sheet = await this.proposals.propose(scope, { ...a, clinicId });
          // ⭐ 肥载荷走侧信道;requestId 由**服务端**在这里铸造(D-2)——
          //   前端确认时原样回传,这样重连/重试都命中同一个幂等键。
          const requestId = `pa_${scope.userId}_${randomUUID()}`;
          push({ type: 'assignment_sheet', requestId, sheet });
          if (sheet.placed === 0) {
            return `没有可分配的候选(${sheet.selectionNote})。请把这句如实转述给主管。`;
          }
          // ⭐ 回给模型的**只有摘要**,不含任何 planId
          return [
            `确认单已呈现给主管(卡片在对话里,你看不到明细,也不要复述明细)。`,
            `摘要:候选 ${sheet.candidateTotal} 人,本批 ${sheet.placed} 人,涉及 ${sheet.byAgent.length} 位客服` +
              (sheet.unplaced > 0 ? `,另有 ${sheet.unplaced} 人按当前容量分不下去` : '') + '。',
            `请转述以下三句(**原话**,不要改写、不要省略"默认值"字样):`,
            `· ${sheet.selectionNote}`,
            `· ${sheet.capacityNote}`,
            `· ${sheet.rosterNote}`,
            `然后提示主管:确认无误请在卡片上点「确认分配」。⛔ 不要说已经分配好了。`,
          ].join('\n');
        },
      });
    }

    // 本地"渲染"工具(不走 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,
    });
  }
}
