import { Injectable } from '@nestjs/common';
// clean subpath(运行时 exports map 放行);类型见 src/types/mcp-sdk.d.ts ambient 声明
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import {
  Permission,
  TEMPERATURE_TOOL_DESC,
  TEMPERATURE_TOOL_VALUES,
} from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import { PatientService } from '../patient/patient.service';
import { PersonaService } from '../persona/persona.service';
import { PlanService } from '../plan/plan.service';
import { AgentRosterService } from '../plan/agent-roster.service';
import { PlanAssignmentService } from '../plan/plan-assignment.service';
import { AssignmentProposalService } from '../plan/assignment-proposal.service';
import { CohortAttributesService, COHORT_DIM_CATALOG } from '../plan/cohort-attributes.service';
import type { ListPlansQueryDto } from '../plan/dto/plan.dto';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { resolveClinicId } from '../../common/decorators/resolve-clinic-id';
import type { McpAuthContext } from './mcp-auth.service';
import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from './persona-tags.desc';
// ⭐ 面向模型的翻译层 —— 取值码换中文、桶名换中文键。见 model-facing.ts 顶部注释：
//   给模型现成的中文（① 级），替代提示词里那两页「⛔ 不许说出取值码」（③ 级）。
import {
  criteriaZh,
  zhCohortDim,
  zhProgressBuckets,
  zhScenario,
} from './model-facing';
// ⭐ 解读规范随返回值下发，⛔ 不再常驻在工具描述里。见 guides.ts 顶部注释。
import { BATCH_LIST_GUIDE, TRACKING_GUIDE } from './guides';

function jsonResult(data: unknown) {
  return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}

/**
 * 诊所 id 的解析与越权闸 —— **复用 REST 那一份**(`common/decorators/resolve-clinic-id`)。
 *
 * ⚠️ 这里原来抄了一份一模一样的实现。2026-08-10 发现 `/agents`、`/workload`、
 *   `/plans/matrix` 三个 REST 读接口**根本没拦**(朝阳公园的主管带别家 id
 *   拿到了那家 26 人的姓名与负载)—— 抄一份的直接后果就是:补的时候只补了一边。
 * ⛔ 别再复制:两份必然漂,而漂了不报错。
 *
 * ⚠️ 抛的是 `ForbiddenException`,MCP 这边照样能把 message 带给模型(工具错误即文本),
 *   所以「⛔ 不要自己编诊所 id」那半句提示挪进了下面 CLINIC_ID_SCHEMA 的 describe。
 */

/**
 * 诊所 id 入参 —— **可选**,且明说"不知道就别传"。
 *
 * ⚠️ 原来是 `z.string()` 必填:模型没有 id 又必须填,只好编一个(实测编出了 `"CL001"`)。
 *   把它改成可选不是"放宽",而是**把猜的动机拿掉** —— 服务端本来就知道该用哪个诊所。
 *
 * 🔴 2026-08-14:**产品内的助手已经完全看不到这一格了** ——
 *   `AssistantService` 在包 MCP 工具时把它从 schema 里摘掉,并注入主管正看着那家
 *   (见那边的 `stripClinicId`)。理由是"没有一种填法是对的":下面这句
 *   「用 get_current_user 返回的 clinicIds 里的值」给的是**一串没有名字的 uuid**,
 *   模型只能在几家里挑一个。
 * ⇒ 这里保留这一格**是给外部 agent 的**(它们从自己的配置里知道诊所 id),
 *   ⛔ 别因为"产品内没人用了"就删:删了外部客户端就再也指定不了诊所。
 */
const CLINIC_ID_SCHEMA = z
  .string()
  .optional()
  .describe(
    '诊所 id(可选)。⛔ **不知道就不要传**,更不要自己编 —— 不传默认取当前登录人的第一个诊所。' +
      '要显式指定时,只能用 get_current_user 返回的 clinicIds 里的值。',
  );

function maskPhone(phone: string | null): string | null {
  if (!phone) return null;
  const d = phone.replace(/\D/g, '');
  if (d.length < 7) return '***';
  return `${d.slice(0, 3)}****${d.slice(-4)}`;
}

/** 患者基础卡(掩码手机号,去掉真号 PII) */
function patientCard(p: {
  id: string;
  externalId: string;
  name: string | null;
  phone: string | null;
  gender: string | null;
  birthDate: string | null;
  status: string;
}) {
  return {
    id: p.id,
    externalId: p.externalId,
    name: p.name,
    phoneMasked: maskPhone(p.phone),
    gender: p.gender,
    birthDate: p.birthDate,
    status: p.status,
  };
}

/**
 * McpServerFactory — 每个(已鉴权的)请求构建一个 scoped McpServer。
 *
 * 6 个**只读**工具,全部薄封装现有 service。租户隔离两条路径(均等价安全):
 *   - 调不带 scope 的读(persona.getCurrent)的工具 → 先过 assertPatientInScope 堵越权;
 *   - get_facts → getTimeline 内部 findFirst{host,tenant}+NotFound 自守,无需额外 assert。
 */
@Injectable()
export class McpServerFactory {
  constructor(
    private readonly prisma: PrismaService,
    private readonly patient: PatientService,
    private readonly persona: PersonaService,
    private readonly plans: PlanService,
    private readonly roster: AgentRosterService,
    private readonly assignments: PlanAssignmentService,
    private readonly proposals: AssignmentProposalService,
    private readonly cohorts: CohortAttributesService,
  ) {}

  build(ctx: McpAuthContext): McpServer {
    const { scope, permissions } = ctx;
    const server = new McpServer({ name: 'pac-patient-tools', version: '0.1.0' });

    // ⚠️ 身份工具放**最前**:工具清单的顺序会影响模型的默认注意力,
    //   而"我在跟主管说话还是跟客服说话"决定了后面走哪条工作流,必须第一个被看到。
    server.registerTool(
      'get_current_user',
      {
        /**
         * 🔴 2026-08-14 删掉「**每轮对话开始时先调它**」：
         *   ① 它要解决的事**结构上已经解决了** —— 权限决定的是**工具清单**（条件注册），
         *      没有 `plan:dispatch` 的人根本看不到分配那几个工具，"走哪条工作流"不用问；
         *   ② 每轮一步，而这信息在一次会话里是静态的（实测：第一轮调、第二轮就不调了，
         *      规则本身已被部分无视）；
         *   ③ 「据此决定走哪条工作流」是**流程**描述，属于系统提示词的角色层，写在工具描述里越界。
         */
        description:
          '当前登录人是谁、能做什么(可发起批次分配 / 只处理自己名下的任务)。' +
          '\n⚠️ 要用到 clinicIds、或要如实回答"我能不能做某件事"时才调 —— ' +
          '能不能做**不用问它**:做得了的事,对应的工具就在你手上;做不了的,那个工具压根不会出现。',
        inputSchema: {},
      },
      async () =>
        jsonResult({
          userId: scope.userId,
          displayName: ctx.userName ?? null,
          // ⭐ 返回**能力**不返回 role(T19):真正控制权限的是 permission,role 只是给人看的标签。
          //   模型该问的是"我能不能分配",不是"我是不是 leader" —— 后者会诱导它自己发明规则
          //   (比如"leader 应该也能看全部执行记录"),而那不是权限模型说了算的。
          capabilities: {
            canDispatch: permissions.includes(Permission.PLAN_DISPATCH),
            canViewAllPlans: permissions.includes(Permission.PLAN_VIEW_ALL),
            canExecute: permissions.includes(Permission.PLAN_EXECUTE),
          },
          clinicIds: scope.clinicIds,
          tenantId: scope.tenantId,
          // ⚠️ canDispatch=false 时助手要说"登录态可能已过期,请重新登录",**不要**说"你没有权限":
          //   权限是按 role 现算的,但 token 里的 role 是签发时固化的 ——
          //   真正的高频原因是 token 旧了,不是这个人真没权限。
          hint: permissions.includes(Permission.PLAN_DISPATCH)
            ? '主管工作流:可发起批次分配'
            : '客服工作流:只处理自己名下的任务;若你确信自己是主管,可能是登录态已过期,请重新登录',
        }),
    );

    server.registerTool(
      'find_patient',
      {
        description:
          '按姓名/手机号/患者号模糊检索患者,返回极简候选卡片(手机号中间打码)用于认人。先用它拿到 patientId,再调其它工具。',
        inputSchema: {
          query: z.string().describe('姓名 / 手机号 / 患者号(模糊匹配)'),
          limit: z.number().int().min(1).max(50).optional(),
        },
      },
      async ({ query, limit }) => jsonResult(await this.patient.search(scope, query, limit ?? 10)),
    );

    server.registerTool(
      'get_patient_overview',
      {
        description:
          '一次把一个患者拉全:画像要点 + 近期事实 + 当前召回计划。召回前的首选工具,省去多次往返。',
        inputSchema: { patientId: z.string() },
      },
      async ({ patientId }) => {
        await this.assertPatientInScope(scope, patientId);
        const [persona, recallPlan, timeline] = await Promise.all([
          this.persona.getCurrent(scope, patientId),
          this.activeRecallPlan(scope, patientId),
          this.patient.getTimeline(scope, patientId, 20),
        ]);
        return jsonResult({
          patient: patientCard(timeline.patient),
          persona,
          recallPlan,
          recentFacts: timeline.facts,
        });
      },
    );

    server.registerTool(
      'get_persona',
      {
        description:
          // ⚠️ 「RFM」「persona」是**内部词**:同一类词漏给用户已经栽过(cold_3y 那次)。
          //   工具描述里出现,模型就会顺手当成"这个东西的名字"说出去。
          '患者的全量画像:价值与阶段 / 治疗史 / 治疗敏感 / 禁忌 / 潜在治疗 等全部画像特征。' +
          '\n⚠️ 这一层是 PAC **推断**出来的,⛔ 不是原始记录 —— 原始记录见 get_facts。',
        inputSchema: { patientId: z.string() },
      },
      async ({ patientId }) => {
        await this.assertPatientInScope(scope, patientId);
        return jsonResult(await this.persona.getCurrent(scope, patientId));
      },
    );

    server.registerTool(
      'get_facts',
      {
        description:
          '患者真实发生过的事(诊断 / 治疗 / 预约 / 收费等,按时间排)。' +
          '\n⚠️ 只回**当前有效的那一版**:改过的记录不会连着旧版一起出现,⛔ 别当成"改过两次"。' +
          '\n只给发生过的事,⛔ 不含任何推断(推断见 get_persona)。',
        inputSchema: {
          patientId: z.string(),
          limit: z.number().int().min(1).max(200).optional(),
        },
      },
      async ({ patientId, limit }) => {
        // 无需单独 assertPatientInScope:getTimeline 内部 findFirst{hostId,tenantId} 且
        // 越租户即抛 NotFound —— 已自带租户隔离(与其它 4 个工具的显式 assert 等价安全)。
        const t = await this.patient.getTimeline(scope, patientId, limit ?? 50);
        return jsonResult({ patient: patientCard(t.patient), facts: t.facts });
      },
    );

    server.registerTool(
      'get_recall_plan',
      {
        description:
          '患者当前的召回计划:为什么召回(场景/原因)、优先级、目标诊所、状态。无活跃计划则返回 null。',
        inputSchema: { patientId: z.string() },
      },
      async ({ patientId }) => {
        await this.assertPatientInScope(scope, patientId);
        return jsonResult(await this.activeRecallPlan(scope, patientId));
      },
    );

    server.registerTool(
      'list_recall_queue',
      {
        description:
          '召回工作台:列出待跟进的召回计划,按优先级排序。回答"现在该联系谁 / 今日推荐"。' +
          '\n支持画像圈人(personaTags)+ 病种(scenario)+ 关键字(keyword:姓名/手机/患者号)+ 真实号(phoneVerified)筛选。' +
          // 🔴 服务端会**静默降级**:没有看全池权限的人,`pool` 一律按 `mine` 执行
          //   (见下面 handler 的 canSeePool —— 刻意不报错,客服问"今天该联系谁"是正当需求)。
          //   ⚠️ 但模型不知道这件事就会把自己名下那几条**说成整个池子**,而数字是真的、
          //   口径是错的 —— 这正是 T14「口径对数」最难查的形状。
          '\n⚠️ 没有看全池权限的人,即使按池子查,回的也**只有他自己名下**的 —— ' +
          '⛔ 拿不准就别说"池子里一共…",说"你手上还有…"。' +
          '\n' +
          PERSONA_TAGS_DESC,
        inputSchema: {
          view: z
            .enum(['pool', 'mine'])
            .optional()
            .describe('pool=还没分下去的召回池(默认) / mine=他自己名下的'),
          personaTags: z.string().optional().describe(PERSONA_TAGS_DESC),
          scenario: z
            .string()
            .optional()
            .describe('召回场景过滤,如 treatment_initiation_recall(启治召回)'),
          status: z.enum(['active', 'assigned', 'completed', 'abandoned']).optional(),
          keyword: z.string().optional().describe('姓名 / 手机 / 患者号 模糊匹配'),
          phoneVerified: z.boolean().optional().describe('只看真实号码(已核实)'),
          sort: z
            .enum(['priority_desc', 'priority_asc', 'created_desc'])
            .optional()
            .describe('排序,默认 priority_desc'),
          // ⚠️ **不能**换成 CLINIC_ID_SCHEMA:那份说的是「不传 = 取第一个诊所」,
          //   而这里不传 = **不按诊所过滤**。说反了会让人以为看的是全部,其实只有一家。
          clinicId: z
            .string()
            .optional()
            .describe(
              '只看某一家诊所。不传 = **不按诊所过滤**(他权限范围内全部)。' +
                '⛔ 不知道 id 就不要传,更不要自己编 —— 编出来的 id 会被当场拒掉。',
            ),
          limit: z.number().int().min(1).max(100).optional(),
        },
      },
      async ({ view, personaTags, scenario, status, keyword, phoneVerified, sort, clinicId, limit }) => {
        // ⭐ view 的权限分流(2026-08 补):`plan.service.list` **只对 view='all' 校验 PLAN_VIEW_ALL**,
        //   `view='pool'` 一路无校验 —— 于是客服在前端看不到召回池(T16 已撤入口),
        //   却可以直接问助手「召回池里还有谁」把整池捞出来,绕过 T16。
        //   这里按能力降级:没有看全池的权限就只给 'mine'。
        //   ⚠️ 是**静默降级**不是报错:客服问"今天该联系谁"是正当需求,
        //   给他自己的单就好,不该甩一个权限错误让他以为系统坏了。
        const canSeePool = permissions.includes(Permission.PLAN_VIEW_ALL);
        const effectiveView = view === 'mine' ? 'mine' : canSeePool ? (view ?? 'pool') : 'mine';
        // 注:schema 的诊所过滤字段是 targetClinicId(不是 clinicId);以前 cast 成 any 传 clinicId
        // 被静默忽略。这里类型化构造,过滤真正生效。
        const query: ListPlansQueryDto = {
          view: effectiveView,
          sort: sort ?? 'priority_desc',
          page: 1,
          pageSize: limit ?? 20,
          ...(clinicId ? { targetClinicId: resolveClinicId(scope, clinicId) } : {}),
          ...(personaTags ? { personaTags } : {}),
          ...(scenario ? { scenario } : {}),
          ...(status ? { status } : {}),
          ...(keyword ? { keyword } : {}),
          ...(phoneVerified !== undefined ? { phoneVerified } : {}),
        };
        return jsonResult(await this.plans.list(scope, query, permissions));
      },
    );

    server.registerTool(
      'recall_queue_stats',
      {
        description:
          '召回池数字概览:总量 + 优先级高/中/低各多少(高≥70 / 中 40-69 / 低<40)+ 病种分布。' +
          '\n他问"今天有多少人要跟"时先用它给摘要,再用 list_recall_queue 取明细。' +
          // 同 list_recall_queue：没有看全池权限的人只统计自己的 —— 否则**总数本身**就泄漏了池子规模
          '\n⚠️ 没有看全池权限的人,回的总数**只是他自己名下**的 —— ⛔ 别说成"池子里一共…"。' +
          '\n支持同样的 view / personaTags / scenario / clinicId 圈定范围。',
        inputSchema: {
          view: z
            .enum(['pool', 'mine'])
            .optional()
            .describe('pool=召回池(默认) / mine=他自己名下的'),
          personaTags: z.string().optional().describe(PERSONA_TAGS_DESC),
          scenario: z.string().optional(),
          clinicId: z
            .string()
            .optional()
            .describe(
              '只看某一家诊所。不传 = 不按诊所过滤（他权限范围内全部）。' +
                '这个 id 只有他明确指定某家诊所时才有；范围外或不存在的 id 会被当场拒掉。',
            ),
        },
      },
      async ({ view, personaTags, scenario, clinicId }) => {
        // 同 list_recall_queue:没有看全池的权限就只统计自己的(否则总数本身就泄漏了池子规模)
        const canSeePool = permissions.includes(Permission.PLAN_VIEW_ALL);
        const query: ListPlansQueryDto = {
          view: view === 'mine' ? 'mine' : canSeePool ? (view ?? 'pool') : 'mine',
          sort: 'priority_desc',
          page: 1,
          pageSize: 1, // stats 不取明细行
          ...(clinicId ? { targetClinicId: resolveClinicId(scope, clinicId) } : {}),
          ...(personaTags ? { personaTags } : {}),
          ...(scenario ? { scenario } : {}),
        };
        return jsonResult(await this.plans.queueStats(scope, query, permissions));
      },
    );

    // ═══════════════════════════════════════════════════════════
    // 主管专属工具 —— **条件注册**:没有 plan:dispatch 的人连工具清单里都看不到
    // ═══════════════════════════════════════════════════════════
    //
    // ⚠️ 为什么是"不注册"而不是"注册了再在 handler 里拒":
    //   模型看得见的工具就会去试,试了被拒它会自己编解释("可能是权限问题,要不你换个账号"),
    //   对客服来说这是纯噪声。不注册则它压根不知道有这回事,行为自然收敛。
    // ⚠️ 这条依赖 `McpClientService.listTools` 的**能力分桶缓存**(mcp-client.service.ts)——
    //   缓存不分桶的话,进程重启后第一个进来的人的清单会被全公司复用,当场串号。
    if (permissions.includes(Permission.PLAN_DISPATCH)) {
      server.registerTool(
        'get_agents',
        {
          description:
            // 🔴 2026-08-14 改：原文是「**分配前用它**看…」，而 `propose_assignment` 内部
            //   自己就会取名册 —— 那句话在**主动制造一次白花的调用**（实测抓到一轮：
            //   写字之前先调了它，返回的数一个都没用上）。
            '某诊所的在岗客服名册 + 各自手上压着多少。他问名册、问谁手上空、问某人手上有多少时用它。' +
            '\n⚠️ 出方案**不需要**先调它 —— 方案自己会取名册。' +
            '\n⚠️ 在岗按「近 N 月有回访记录」近似判定,**不代表系统确认在职**;' +
            '名册外的客服也可以指定(用 include 传 userId)。' +
            // 🔴 2026-08-14 走查,原文两处都过期了:
            //   ①「容量是主管的基数(**沿用他上一次的值**)」—— 沿用 2026-08-12 就断开了;
            //   ②「基数」本身就列在提示词的禁说清单里,而这里是**直接递给模型**的词。
            '\n⚠️ 这里只回**在手**(客观量)。⛔ **不要替他做减法说"还能吃 N 个"** ——' +
            '落人只看谁手上少,没有"容量上限"这回事;而谁在休假只有他知道,你不知道。' +
            '要看本批每人分多少,那是 propose_assignment 出的确认单的事。',
          inputSchema: {
            clinicId: CLINIC_ID_SCHEMA,
            months: z
              .number()
              .int()
              .min(1)
              .max(36)
              .optional()
              .describe('按最近几个月有没有回访记录判定在岗，默认 12 个月。'),
            include: z
              .array(z.string())
              .optional()
              .describe('额外带上这几位（userId），即使他们不在名册里 —— 他点名要某人时用。'),
          },
        },
        async ({ clinicId, months, include }) => {
          const r = await this.roster.list(scope, resolveClinicId(scope, clinicId), {
            months,
            extraUserIds: include,
          });
          /**
           * ⭐ **名册自己标出哪一行是他** —— 2026-08-14 加。
           *
           * 主管常常**自己也在名册里**（实测：3 位在岗里有一位就是当前登录人）。
           *   在此之前提示词末尾挂着一句「当前登录人：某某」，让模型拿名字去比对 ——
           *   那是把一件结构能表达的事交给字符串匹配，而且那句话还给了它称呼的抓手。
           * ⇒ 名字放回它唯一有用的地方：**这一行是不是他**由数据说，⛔ 不由模型认。
           * ⚠️ 只在**模型这一侧**加（REST 那份不动）：它是给模型读的投影，
           *   ⛔ 不是 `AgentInfo` 的新字段（那会让每个消费方都得处理它）。
           */
          return jsonResult({
            ...r,
            agents: r.agents.map((a) =>
              a.userId === scope.userId ? { ...a, 就是你本人: true } : a,
            ),
          });
        },
      );

      server.registerTool(
        'get_cohort_attributes',
        {
          description:
            /**
             * 🔴 2026-08-14 删掉开头的场景举例（原文：「主管在调整阶段说
             *   『只要商保直付的』『排掉怕疼的』时…」）。
             *
             * ⚠️ **那两个举例被模型逐字念给主管听了** —— 实测两轮正文里出现
             *   「也可以只圈其中一类（**比如只要商保直付的**、排掉某些情况的）」，
             *   而那一版的数据里根本没有商保直付这一类。
             * ⇒ 工具描述里的**正例**（"他会这么说"）会被当成给用户的例子复述，
             *   而它过期或不适用时不会报错。⛔ 正例不留，反例可留
             *   （下面 `noTag` 那条里的「32 人有商保标签」是反例，⛔ 别一起删）。
             */
            '看**当前这批人**的画像分布:能按哪几类切、各多少人。' +
            '\n他要按画像收窄、但没说清切哪一刀时先用它,拿到分布再据实回话,' +
            '然后用同样的条件重出一版。' +
            '\n⚠️ **`noTag` 是「没有这条画像证据」的人数,不是反面** —— ' +
            '「32 人有商保标签」剩下的**不是自费**,是没证据。⛔ 绝不能说成「其余 68 人自费」。' +
            '\n⚠️ 标了 `multi` 的那几类,一个人可能同时算进好几项,合计会大于 cohortSize,**别拿它算百分比**。' +
            '\n⚠️ 专属客服只报有/无,**不判在岗** —— 谁休假谁离职以他说的为准,别替他挡人。' +
            `\n可点名的类别(keys,不传给一组常用的):${COHORT_DIM_CATALOG}`,
          inputSchema: {
            clinicId: CLINIC_ID_SCHEMA,
            potentialTreatment: z
              .string()
              .optional()
              .describe(POTENTIAL_TREATMENT_DESC),
            temperature: z
              .enum(TEMPERATURE_TOOL_VALUES as unknown as [string, ...string[]])
              .optional()
              .describe(TEMPERATURE_TOOL_DESC),
            personaTags: z.string().optional().describe(PERSONA_TAGS_DESC),
            keys: z
              .array(z.string())
              .optional()
              .describe('只看这几类，可填哪些见本工具说明末尾那份清单。不传给一组常用的。'),
          },
        },
        async ({ clinicId, potentialTreatment, temperature, personaTags, keys }) => {
          const r = await this.cohorts.describe(
            scope,
            {
              clinicId: resolveClinicId(scope, clinicId),
              ...(potentialTreatment ? { potentialTreatment } : {}),
              ...(temperature ? { temperature } : {}),
              ...(personaTags ? { personaTags } : {}),
            },
            { ...(keys ? { keys } : {}) },
          );
          return jsonResult({
            ...r,
            // ⭐ 条件回显 —— 模型用码调工具，但要用中文回话。不给回显它就只能自己翻译。
            criteriaZh: criteriaZh({ potentialTreatment, temperature }),
            dimensions: r.dimensions.map(zhCohortDim),
          });
        },
      );

      // ⛔ **`propose_assignment` 刻意不在这里注册** —— 它是 assistant 的**本地工具**。
      //   原因:确认单载荷含**几百个 planId**,MCP 工具的返回值会原样回灌进模型上下文
      //   (实测一批 850 人 ≈ 12k token),而模型对它唯一要做的事是"转述一句话"。
      //   本地工具可以走**侧信道**把肥载荷直接推给前端渲染,只回给模型一句短话。
      //   见 assistant.service 的 propose_assignment。
      server.registerTool(
        'list_assignment_batches',
        {
          description:
            // ⚠️ 原文第一句是「**我**分过的批次」——「我」指谁在这句里是含糊的
            //   (说话人是工具描述,读的人是模型),而下面 `mine` 恰恰就是"只看自己发起的"。
            //   一含糊就会连带 `mine` 的默认值一起被猜错。
            '已经分下去的批次列表 + 每批汇总(分了多少 / 已处理 / 还在手 / 已退回 / 涉及几个客服)。' +
            '\n他问哪批出了问题、哪批还没动静时用它。要看某批细节再用 get_assignment_detail。' +
            '\n⚠️ 返回里带 `_guide`,读数前先看它。',
          inputSchema: {
            mine: z
              .boolean()
              .optional()
              .describe(
                '只看他自己发起的。不传 = 他权限范围内的**全部**批次 —— ' +
                  '⚠️ 那里面会有别人发的批,说成"你分的"就错了。',
              ),
          },
        },
        async ({ mine }) =>
          jsonResult({
            batches: await this.assignments.list(scope, mine ? scope.userId : undefined),
            _guide: BATCH_LIST_GUIDE,
          }),
      );

      server.registerTool(
        'revoke_assignment',
        {
          description:
            '撤销整批分配(限时)—— 他说「刚才那批分错了 / 撤回」时用。' +
            '\n⚠️ 这是**唯一一个会改数据的工具**,而且**撤了就收回了**:' +
            '客服手上那批活当场消失,他们看不到任何解释。' +
            '⇒ 只在他**明确要求撤销**时调 —— 试探性调一次没有"预览",那一次就是真撤。' +
            '\n⚠️ 客服**已经打开过**的单不会被收回(他可能已经联系了患者)——' +
            '返回里的 note 是成品句子,**原话转述**,别把 skippedTouched 说成"失败"。' +
            '\n⚠️ 撤销 ≠ 退回:这是收回整批,不是客服退单条。超窗口就照实说,让他走逐条退回。' +
            '\n⚠️ assignmentId 用**完整 id**(确认那条消息里给了);' +
            '只有 8 位短号也可以传,系统会解析,撞到多个会明确报错。' +
            '\n⛔ **失败时原样转述服务端那句话,不要自己编原因** —— ' +
            '实测编过「可能是超了撤销窗口/状态已变更」,而服务端说的只是 id 没匹配上,' +
            '他照着这个"原因"去排查是白跑一趟。查不到就直接 list_assignment_batches 拿真实 id 重试。',
          inputSchema: {
            assignmentId: z
              .string()
              .describe(
                '要撤销的批次。用确认那条消息里给的**完整 id**；' +
                  '只有 8 位短号也能传，系统会解析，撞到多个会明确报错。',
              ),
          },
        },
        async ({ assignmentId }) =>
          jsonResult(
            await this.assignments.revoke(
              scope,
              { userId: scope.userId, permissions },
              assignmentId,
            ),
          ),
      );

      /**
       * 🔴 **「为什么这个人给了张悦」** —— F5 的兑现。
       *
       * 在此之前模型没有这个工具，被问到只能**推理**，而推理出来的理由听起来
       * 完全合理，主管照着去调策略就是白跑一趟。⇒ 唯一诚实的回答方式是查。
       */
      server.registerTool(
        'explain_assignment',
        {
          description:
            '回答「这个患者**为什么**分给了他」—— 从分配当时记下来的依据查,⛔ 不要自己推理。' +
            // ⚠️ 只说"那一版还没分下去,不在这里查",⛔ 不说它在界面的哪个部件里:
            //   这个工具不是确认单的生产者,让它知道确认单长什么样就是**反向耦合**
            //   (呈现方式一改，这句话就过期,而过期不报错)。
            '\n⚠️ 只回**已经确认分下去**的分配。还没确认的那一版不在这里 —— ' +
            '它的明细他手上就有,⛔ 别为了回答这个问题去重出一版。' +
            '\n⚠️ 回的是**分配当时**的情况(专属客服会被摄入覆盖、优先级分会被引擎就地改分,' +
            '两者都不留痕)—— ⛔ 别拿"现在"的值去解释"当时"的决定。' +
            '\n先用 find_patient 拿到 patientId。',
          inputSchema: {
            patientId: z.string().describe('要问的那位患者，先用 find_patient 拿到。'),
          },
        },
        async ({ patientId }) => {
          await this.assertPatientInScope(scope, patientId);
          return jsonResult(await this.assignments.explainAssignment(scope, patientId));
        },
      );

      server.registerTool(
        'get_assignment_detail',
        {
          // ⚠️ 这里只写「**怎么用这个工具**」—— 那是模型在决定调不调时要看的。
          //   「**怎么解读这批数**」全部搬进返回值的 `_guide`（见 guides.ts 顶部注释）:
          //   原来那 35 行是常驻成本，而只有约三成会话用得到。
          description:
            '单个批次的全貌:处理进度 + 按客服拆 + 退回原因分布 + 通话成效 + 逐条明细与电话纪要。' +
            '\n回答「我分的那批怎么样了 / 哪个患者为什么退回 / 客服怎么说的」。' +
            '\n⚠️ 返回里带 `_guide` —— **读数之前先看它**,里面是这批数容易被误读的地方。',
          inputSchema: {
            assignmentId: z
              .string()
              .describe('要看哪一批。id 从 list_assignment_batches 取，或用确认那条消息里给的那个。'),
          },
        },
        async ({ assignmentId }) => {
          const d = await this.assignments.detail(scope, assignmentId);
          // ⭐ 五个桶换成中文键。⚠️ 「已出池·引擎判定需求已了」刻意写全 —— resolved
          //   不是「谈成了」，键名本身就该挡住这个误读（T14）。
          const p = d.progress as Record<string, unknown> | undefined;
          return jsonResult({
            ...(p ? { ...d, progress: { ...zhProgressBuckets(p), note: p.note } } : d),
            _guide: TRACKING_GUIDE,
          });
        },
      );
    }

    return server;
  }

  /** 越权防护:确认该 patientId 属当前租户(persona.getCurrent 等不带 scope 的读之前必调)。 */
  private async assertPatientInScope(scope: TenantScopeContext, patientId: string): Promise<void> {
    const p = await this.prisma.patient.findFirst({
      // MCP 走 @Public 无 ALS 上下文 → 纵深防御不兜,品牌隔离全靠这里显式过滤
      where: {
        id: patientId,
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        ...(scope.sourceUnits.length ? { sourceUnit: { in: scope.sourceUnits } } : {}),
      },
      select: { id: true },
    });
    if (!p) throw new Error(`patient ${patientId} 不在当前租户范围内`);
  }

  private async activeRecallPlan(scope: TenantScopeContext, patientId: string) {
    const plan = await this.prisma.followupPlan.findFirst({
      where: {
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        patientId,
        ...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}),
        status: { in: ['active', 'assigned'] },
        supersededAt: null,
      },
      orderBy: { priorityScore: 'desc' },
      select: {
        id: true,
        status: true,
        priorityScore: true,
        targetClinicId: true,
        assigneeUserId: true,
        snoozedUntil: true,
      },
    });
    if (!plan) return null;
    const rawReasons = await this.prisma.planReason.findMany({
      where: { planId: plan.id },
      select: { scenario: true, priorityScore: true, reason: true },
    });
    // ⭐ 场景码带上中文（`treatment_initiation_recall` → 「潜在治疗」）。
    //   ⚠️ 保留原 `scenario` 字段：模型有时要拿它当别的工具的参数。
    const reasons = rawReasons.map((r) => ({ ...r, scenarioZh: zhScenario(r.scenario) }));
    return {
      planId: plan.id,
      status: plan.status,
      priorityScore: plan.priorityScore,
      targetClinicId: plan.targetClinicId,
      assigned: !!plan.assigneeUserId,
      snoozedUntil: plan.snoozedUntil ? plan.snoozedUntil.toISOString() : null,
      reasons,
    };
  }
}
