import { Injectable, Logger } from '@nestjs/common';
import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
import { randomUUID } from 'node:crypto';
import { Permission, type TemperatureValue } 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⚠️ 本批人数与时效**会自动沿用主管上一次的值**,⛔ 别问他 —— 那正是这个设计要省掉的输入。' +
          '他明确说「这批 200 人」「给 5 天」时才传 targetCount / expiresInDays(都会被记住)。' +
          '\n⚠️ **没有"容量上限"** —— 落人走「专属(封顶在目标水位)→ 无主补空 → 有主改派」三趟,' +
          '结果又满又平;池子里还有人就随时能再分一批。',
        inputSchema: jsonSchema({
          type: 'object',
          properties: {
            clinicId: { type: 'string', description: '诊所 id;不传用当前登录人的第一个诊所' },
            potentialTreatment: {
              type: 'string',
              description:
                '画像「潜在治疗」8 类之一:implant/ortho/early_ortho/endo/perio/filling/restoration/extraction',
            },
            temperature: {
              type: 'string',
              enum: ['hot', 'warm', 'cold'],
              description: '窗口温度(矩阵 Y 轴)。⚠️ 必须与 potentialTreatment 同时给',
            },
            personaTags: {
              type: 'string',
              description:
                '主管在调整阶段追加的画像条件("key:value" 逗号串,同维 OR、跨维 AND)。' +
                '先用 get_cohort_attributes 看清各口子多少人,再带着它重出确认单。',
            },
            expiresInDays: {
              type: 'number',
              description: '批次时效天数(基数②)。主管说「给 5 天」时传;不传自动沿用上一次的值。',
            },
            targetCount: {
              type: 'number',
              description:
                '本批人数 N(基数①)。主管说「这批 200 人」时传;不传自动沿用他上一次的值(首次 100)。',
            },
            agentOverrides: {
              type: 'object',
              additionalProperties: {
                type: 'object',
                properties: {
                  maxThisBatch: { type: 'number' },
                  expiresInDays: { type: 'number' },
                },
              },
              description:
                '按客服精调(userId → {maxThisBatch, expiresInDays})。' +
                'maxThisBatch = 本批最多给他几条(0 = 这轮不给他),⛔ 不是"容量上限"。' +
                '主管说「李莉这周最多 5 条」「王强那批给 7 天」时传。' +
                '⚠️ 先用 list_agents 拿到 userId,⛔ 不要用姓名当 key。' +
                '⚠️ 精调**也会被记住**,所以主管说「李莉恢复正常」时要把她从这个表里去掉' +
                '(传一个不含她的完整表,⛔ 别只传变化的那一条)。',
            },
            exploreRatio: { type: 'number', description: '探索配额占比 0-0.2' },
          },
        }),
        execute: async (args: unknown) => {
          const a = (args ?? {}) as {
            clinicId?: string;
            potentialTreatment?: string;
            temperature?: TemperatureValue;
            personaTags?: string;
            expiresInDays?: number;
            agentOverrides?: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
            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。
          //
          // ── 为什么是台词稿而不是数据(方法论)──────────────────
          // T14/T20 那两类要求(标注默认值、样本不足不出百分比)全是除法和阈值判断,
          // 恰恰是 LLM 最不可靠的地方。把话写好交给它抄,任务就从"让模型算对"
          // 降级成"让模型照抄"。
          //
          // ⚠️ 2026-08-03 走查后改了三处:
          //   ① **先说话再看卡** —— 主管要先知道"怎么选的、怎么分的",再去核对明细;
          //      (卡片本身由界面钉在这段话下面,见 assistant-chat 的 blocks 排序)
          //   ② **说人话** —— 原来那三句里全是"排序键/铺平/水位/基数",那是给程序员看的;
          //   ③ **只讲怎么选 + 怎么分**,别的都别说;最后给一句"要调怎么说"。
          return [
            `确认单已呈现给主管(卡片会显示在你这段话**下面**,你看不到明细,也不要复述明细)。`,
            ``,
            `请**先用两句话开口**,顺序不要变:`,
            `1)「怎么选的」:${sheet.selectionNote}`,
            `2)「怎么分的」:${sheet.basisNote}`,
            ``,
            `最后一句引导他怎么调 —— **四种都要提到**(⚠️ 主管不知道能这么用,不说他就只会点确认):`,
            `· 改这一批:「把某某移出这批」「某某的单给 5 天」「这批 200 人」—— 我直接改;`,
            `· **再挑一挑人**:「只要商保直付的」「排掉最近来过的」—— 我先看看这批人里各类有多少,再重新圈;`,
            `· **带个福利**:「这批带上『老客户复查免挂号费』」—— 我写进批次,生成话术时会用上;`,
            `· 确认无误就点卡片上的『确认分配』。`,
            ``,
            `⚠️ 上面两句是**成品句子,原话抄**:里面的数字、"没被分过的排在前面"、"第一次的估算值"` +
              `这类措辞都不要改写、不要省。`,
            `⛔ 不要说"已经分配好了"(一条都还没落库)。`,
            `⛔ 不要出现「排序键 / 收敛 / 铺平 / 水位 / 基数 / 探索配额」这类词 —— 主管看不懂。`,
            `⛔ 不要复述患者名单或客服分配明细 —— 卡片上有,而且你手里根本没有。`,
            `⚠️ 引导那几句要用**主管的话**说,别报工具名(⛔ 不要出现 get_cohort_attributes 这种词)。`,
          ].join('\n');
        },
      });

      /**
       * ⭐ 助手**直接改已经呈现的那张确认单**。
       *
       * 主管说「把杨丽华移出这批」「王强的单给 5 天」「李莉这些转给张悦」时用。
       * 在此之前助手只能回一句"我做不到,你先确认再逐条退回" —— 那是把界面能做的事
       * 推回给主管手工做,而这张卡就在他眼前(2026-08-03 走查)。
       *
       * ⚠️ 指令用**姓名**表达,⛔ 不用 planId:那些 id 根本没进过模型上下文(故意的)。
       *   匹配由界面用自己手里那份确认单完成,结果它会回一句话进对话。
       * ⚠️ 能做的只有卡片上能做的那几件(删/改派/改时效)。
       *   换人群、改批次人数要重跑算法 → 只能重新调 propose_assignment 出一版。
       */
      tools.edit_assignment_sheet = tool({
        description:
          '直接修改**已经呈现**的那张确认单(删患者 / 移除某个客服 / 把患者转给别人 / 改时效 / 设批次福利)。' +
          '\n⚠️ 主管说「把某某移出这批」「某某的单给 5 天」时**就用这个**,' +
          '⛔ 不要回答"做不到"、也不要让他先确认再逐条退回 —— 卡片就在他眼前。' +
          '\n⚠️ 只能做这几件局部修改。**换人群 / 改批次人数**要重出确认单,那走 propose_assignment。' +
          '\n⚠️ 患者用**姓名或病历号**指代,客服用姓名;⛔ 你没有 planId,也不需要。' +
          '\n⚠️ 改完**不要说"已完成"**:界面执行后会自己回一句结果(可能没找到人),等那句再说。',
        inputSchema: jsonSchema({
          type: 'object',
          properties: {
            ops: {
              type: 'array',
              description: '一组操作,按顺序执行',
              items: {
                type: 'object',
                properties: {
                  action: {
                    type: 'string',
                    enum: [
                      'remove_patient',
                      'remove_agent',
                      'move_patient',
                      'set_expiry',
                      'set_benefit',
                    ],
                  },
                  patient: { type: 'string', description: '患者姓名或病历号' },
                  agent: { type: 'string', description: '客服姓名' },
                  toAgent: { type: 'string', description: 'move_patient 的目标客服' },
                  days: { type: 'number', description: 'set_expiry 的天数(1-90)' },
                  text: {
                    type: 'string',
                    description:
                      'set_benefit 的福利原文(挂在整批上,会进这批人的话术)。空串 = 撤掉福利。' +
                      '⛔ 原话写,不要替主管加条件/期限/承诺。',
                  },
                },
                required: ['action'],
              },
            },
          },
          required: ['ops'],
        }),
        execute: async (args: unknown) => {
          const ops = (args as { ops?: unknown[] })?.ops;
          if (!Array.isArray(ops) || ops.length === 0) return '没有给出任何操作,确认单未改动。';
          push({ type: 'assignment_sheet_edit', ops });
          // ⚠️ 只回"已交给界面",⛔ 不回"已完成" —— 匹配得到匹配不到是界面才知道的事,
          //   它会把结果作为一条消息回到对话里(前端 appendAssistantNote)。
          return `已把 ${ops.length} 条修改交给界面执行,结果会由界面回报(可能有人没找到)。请等那句结果,⛔ 不要自己宣布已完成。`;
        },
      });
    }

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