import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { TEMPERATURE_TOOL_DESC } from '@pac/types';
import {
  PERSONA_TAGS_DESC,
  POTENTIAL_TREATMENT_DESC,
} from '../src/modules/mcp/persona-tags.desc';

/**
 * 「模型自己编了个诊所 id,于是整批人凭空消失」的回归(2026-08-06 实测)。
 *
 * ── 事故经过 ──────────────────────────────────────────────────────
 * 主管点了矩阵的「充填 · 2–3 年」(138 人),接着说「我只需要男性患者」。
 * 助手回:「这个格子当前是空的(0 人)」,还给了两条像模像样的原因
 * (「这档人群本身就少」「画像里没有性别标记」),然后建议换档位。
 *
 * 真因:`get_cohort_attributes` 的 `clinicId` 是 `z.string()` **必填、无兜底**。
 * 模型手里没有真 id 又必须填,于是编了 `"CL001"`。SQL 完全正确,
 * 只是 `target_clinic_id = 'CL001'` 一条也匹配不上 → cohortSize=0。
 *
 * 🔴 **最坏的地方不是查不到,是"查不到"看起来像个结论**:
 *   0 是一个合法返回值,模型拿到 0 就去解释"为什么这批人是空的",越解释越可信。
 *   全程零报错,而主管刚在矩阵上看过 138。这正是 T14「口径对数」要防的形状。
 *
 * 这里锁三件事(都做错了不会报错):
 *   ① 不传 clinicId → 用登录人的第一个诊所,⛔ 不许要求模型必填
 *   ② 传了范围外的 id → **抛错**,⛔ 绝不能当成"这个诊所没人"返回 0
 *   ③ 收 personaTags 的工具必须挂全维度字典 —— 猜错的维度会被静默丢掉
 */

const FACTORY = readFileSync(
  join(__dirname, '../src/modules/mcp/mcp-server.factory.ts'),
  'utf8',
);

describe('MCP 诊所 id —— 不许模型自己编', () => {
  test('⭐⭐ 所有收 clinicId 的工具都走 CLINIC_ID_SCHEMA(可选 + 明说别编)', () => {
    // ⛔ 必填的 z.string() 会逼模型编一个出来 —— 这正是事故的成因
    expect(FACTORY).not.toMatch(/clinicId:\s*z\.string\(\)(?!\s*\.optional)/);
    expect(FACTORY).toMatch(/const CLINIC_ID_SCHEMA[\s\S]*?\.optional\(\)/);
  });

  test('⭐⭐ 每一处 clinicId 落到查询前都过 resolveClinicId(范围外要抛错,不是返回 0)', () => {
    // 出现几次 clinicId 作为查询入参,就该有几次 resolveClinicId
    const guarded = FACTORY.match(/resolveClinicId\(scope,/g) ?? [];
    expect(guarded.length).toBeGreaterThanOrEqual(4);
  });

  /**
   * 🔴🔴 **闸只能有一份实现,且 REST 与 MCP 都要走它**(2026-08-10 测试机实测的越权)。
   *
   * 原来 MCP 里抄了一份 `resolveClinicId`。抄一份的直接后果是**补的时候只补了一边**:
   * 北京朝阳公园的主管带着杭州大厦的 id 请求 `/plans/assignments/workload`,
   * 拿到了 **200 + 那家 26 位客服的姓名与负载**;`/agents`、`/plans/matrix` 同样敞着。
   *
   * ⚠️ 触发它的不是攻击,是**前端第一帧**(session 没回来时回落到"全部诊所")——
   *   前端也修了,但 ⛔ 查询串是用户可改的,服务端这道闸不能省。
   */
  const GUARD = readFileSync(
    join(__dirname, '../src/common/decorators/resolve-clinic-id.ts'),
    'utf8',
  );

  test('🔴🔴 闸只有一份实现 —— MCP 不许再抄一份', () => {
    // 范围外必须**抛错**并列出真实 id —— 静默返回 0 是这个 bug 的全部危害
    expect(GUARD).toMatch(/不在当前登录人的数据范围内/);
    expect(GUARD).toMatch(/scope\.clinicIds\.join/);
    expect(GUARD).toMatch(/ForbiddenException/);
    // 集团级(clinicIds 为空)无从校验,必须放行 —— ⛔ 别把空数组当成"没权限"
    expect(GUARD).toMatch(/scope\.clinicIds\.length &&/);
    // ⛔ MCP 只许 import,不许自己再定义一个
    expect(FACTORY).toMatch(/import \{ resolveClinicId \} from/);
    expect(FACTORY).not.toMatch(/function resolveClinicId/);
  });

  test.each([
    ['assignment.controller.ts', 'agents / workload'],
    ['plan.controller.ts', 'matrix'],
  ])('🔴🔴 REST 读接口 %s(%s)也必须过闸', (file) => {
    const src = readFileSync(join(__dirname, `../src/modules/plan/${file}`), 'utf8');
    // 每个收 clinicId 查询参数的读接口,都要有一次 resolveClinicId
    const takes = (src.match(/@Query\('clinicId'\)/g) ?? []).length;
    const guards = (src.match(/resolveClinicId\(scope,/g) ?? []).length;
    expect(takes).toBeGreaterThan(0);
    expect(guards).toBeGreaterThanOrEqual(takes);
    // ⛔ 不许再把裸 clinicId 直接丢进 service(那正是漏的写法)
    expect(src).not.toMatch(/\.list\(scope, clinicId,/);
    expect(src).not.toMatch(/\.workload\(scope, clinicId,/);
    expect(src).not.toMatch(/\.matrix\(scope, clinicId\)/);
  });

  test('CLINIC_ID_SCHEMA 的说明里必须写明「不知道就不要传」', () => {
    expect(FACTORY).toMatch(/不知道就不要传/);
    expect(FACTORY).toMatch(/get_current_user 返回的 clinicIds/);
  });
});

describe('personaTags 维度字典 —— 猜错会被静默丢掉,所以必须给全', () => {
  test('⭐ 字典里有性别维度(事故当天主管要的就是它)', () => {
    expect(PERSONA_TAGS_DESC).toMatch(/gender\(性别\)/);
    expect(PERSONA_TAGS_DESC).toContain('male=男');
    expect(PERSONA_TAGS_DESC).toContain('female=女');
  });

  test('⭐⭐ 必须警告「写别的会被静默忽略」—— 那是 personaTagsSql 的真实行为', () => {
    // cohort-filter.personaTagsSql:`if (!dim) continue` —— 认不出的维度直接跳过,
    // 筛选等于没加,人数一个不少地返回,不报错。
    expect(PERSONA_TAGS_DESC).toMatch(/静默忽略/);
  });

  test('⛔ 工具定义里不许再内联第二份简写字典', () => {
    // 只允许从 persona-tags.desc 引入;工厂里不该再有一份 PERSONA_TAG_FILTER_DIMS.map
    expect(FACTORY).not.toMatch(/PERSONA_TAG_FILTER_DIMS\.map/);
    expect(FACTORY).toMatch(/from '\.\/persona-tags\.desc'/);
  });
});

/**
 * 「助手对主管念代码」的回归(2026-08-06 产品走查)。
 *
 * 实测原话:
 *   ·「「2–3 年」对应的是 `cold_3y` 档,该档目前整体为空。」
 *   ·「放宽温度:改成「充填 · 窗口外全体」( `cold` )或「充填 · 1–2 年」( `cold_2y` )」
 *   ·「换病种:比如「种植 · 2–3 年」…」
 *
 * 主管是门诊经理:界面上**没有** `cold_3y`,**没有**「温度」这个词,那一行也不叫「病种」。
 * 他看到不认识的英文词,第一反应是系统坏了 —— 要么不信这个数,要么回头来问,
 * 两种都是白多出来的一轮。
 *
 * ⚠️ 修法是**两件事一起做**,少一件都不行:
 *   ① 参数说明里必须给全 code↔中文 的对照表 —— 不给,模型没得翻,只能原样念码;
 *   ② 必须有一条硬约束写明"码只用于调工具,不许说出口" —— 只给表不给约束,它照念不误。
 */
describe('助手措辞 —— 码给工具,中文给人', () => {
  const PROMPTS = readFileSync(
    join(__dirname, '../src/modules/assistant/assistant-prompts.ts'),
    'utf8',
  );

  test('⭐⭐ 温度说明:既有中文对照表,又明令不许把码说出口', () => {
    expect(TEMPERATURE_TOOL_DESC).toContain('2–3 年=cold_3y'); // ① 有得翻
    expect(TEMPERATURE_TOOL_DESC).toMatch(/不许说给主管/); // ② 有约束
    expect(TEMPERATURE_TOOL_DESC).toMatch(/不要说「温度」这个词/);
  });

  test('⭐⭐ 潜在治疗说明:同样两件事都要有', () => {
    expect(POTENTIAL_TREATMENT_DESC).toContain('充填=filling');
    expect(POTENTIAL_TREATMENT_DESC).toMatch(/不许说给主管/);
    // ⛔ 「病种」是模型自己发明的叫法,界面上叫「潜在治疗」
    expect(POTENTIAL_TREATMENT_DESC).toMatch(/病种/);
  });

  test('⭐⭐ 主管工作流里有一条独立的「说人话」硬约束,且点名了实际漏出来的那些词', () => {
    expect(PROMPTS).toMatch(/说人话/);
    for (const leaked of ['cold_3y', 'personaTags', 'get_cohort_attributes', '温度']) {
      expect(PROMPTS).toContain(leaked); // 都被明确列进了禁说清单
    }
    // 光禁止不够,得给出替代说法(否则模型只会绕着说,一样难懂)
    expect(PROMPTS).toMatch(/往后放一档/);
  });

  test('客服那一侧也要有同样的约束(取值码不分角色地漏)', () => {
    const staff = PROMPTS.slice(PROMPTS.indexOf('const STAFF_EXTRA'));
    expect(staff).toMatch(/说人话/);
  });
});

/**
 * 「同一个治疗项,助手叫它 A、界面叫它 B」的回归(2026-08-06 走查时顺带发现)。
 *
 * `POTENTIAL_TREATMENT_CARD_LABEL`(labels.ts)是**展示措辞的真理源**,
 * 业务 2026-07-29 定过:filling 是「充填」不是「补牙」。矩阵按它渲染。
 * 而 `PERSONA_TAG_FILTER_DIMS` 里另存了一份 zh,漏改成了「补牙」——
 * 于是助手嘴里是「补牙 · 2–3 年」,主管界面上是「充填 · 2–3 年」,像两件事。
 *
 * ⚠️ 两份中文并存是历史结构(筛选面板要 hint,卡片只要名字),短期内不合并;
 *   这条测试就是它们的**唯一**同步保证 —— 改任一处措辞,这里会红。
 */
describe('潜在治疗的中文 —— 两份表必须一致', () => {
  const { PERSONA_TAG_FILTER_DIMS, potentialTreatmentItemName } =
    jest.requireActual<typeof import('@pac/types')>('@pac/types');

  test('⭐⭐ 筛选维度的 zh == 矩阵/卡片实际渲染的中文', () => {
    const dim = PERSONA_TAG_FILTER_DIMS.find((d) => d.key === 'potential_treatment')!;
    for (const o of dim.options) {
      expect([o.value, o.zh]).toEqual([o.value, potentialTreatmentItemName(o.value)]);
    }
  });

  test('业务钉死的那一条:filling = 充填,⛔ 不是补牙', () => {
    expect(potentialTreatmentItemName('filling')).toBe('充填');
    expect(POTENTIAL_TREATMENT_DESC).toContain('充填=filling');
    expect(POTENTIAL_TREATMENT_DESC).not.toContain('补牙');
  });
});

/**
 * 「确认完没配福利 → 提醒补挂」的约束(2026-08-06 产品加)。
 *
 * ⭐ 为什么值得单独锁:这是一条**主动推销**性质的提示,而本系统
 *   **不统计成功**(硬约束第 4 条)。一旦让模型自由发挥,它极容易顺嘴说出
 *   「带福利成功率更高」「转化率能提升」—— 那个数据根本不存在,是凭空造事实(违 T14)。
 *   所以提示词里必须**同时**有:① 提醒的动作 ② 只提一次 ③ 禁止编效果。
 *
 * ⚠️ 界面那一半(assignment-confirm-sheet 在确认时拼进 modelSummary)与提示词这一半
 *   是「双保险」的两条腿,少一条这个功能就要么不触发、要么乱说。
 */
describe('确认后没配福利 → 提醒补挂,但不许编效果', () => {
  const PROMPTS = readFileSync(
    join(__dirname, '../src/modules/assistant/assistant-prompts.ts'),
    'utf8',
  );
  const SHEET = readFileSync(
    join(
      __dirname,
      '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx',
    ),
    'utf8',
  );

  test('⭐ 界面侧:只在 benefit 为空时才提醒', () => {
    expect(SHEET).toMatch(/benefit\.trim\(\)\s*\n?\s*\?\s*''/);
    expect(SHEET).toMatch(/这批还没带福利/);
  });

  test('⭐⭐ 提醒必须与摘要之间空一行 —— 单个 \\n 会被 Markdown 折叠成一行', () => {
    // 实测:折叠后渲染成「…3 天有效 这批还没带福利 ——」,主管只当是上一句的尾巴
    expect(SHEET).toMatch(/'\\n\\n这批还没带福利/);
  });

  test('⭐⭐ 确认这一句要自成一条助手消息,⛔ 不能贴在上一条尾巴上', () => {
    const CHAT = readFileSync(
      join(__dirname, '../../pac-web/src/components/assistant/assistant-chat.tsx'),
      'utf8',
    );
    expect(CHAT).toMatch(/appendAssistantNote\(summary, modelSummary, \{ asNewMessage: true \}\)/);
  });

  test('🔴🔴 提醒必须进**可见文案**,⛔ 不能只进模型那份', () => {
    // appendAssistantNote 只往消息流塞文字,**不会触发模型新一轮发言** ——
    // 只写进 modelSummary 的话,主管永远看不到这句(实测过:确认完对话里一个字都没多)。
    expect(SHEET).toMatch(/summary \+ benefitNudge/);
    expect(SHEET).toMatch(/不会触发模型新一轮发言/);
  });

  test('⭐ 界面已经说过的话,⛔ 不许让模型再复述一遍', () => {
    expect(SHEET).toMatch(/界面已经对主管说过了/);
  });

  test('⭐ 确认摘要里用中文项目名,⛔ 不许出现 endo/filling 这种码', () => {
    // 实测漏过:确认后界面显示「已确认分配:endo · 3 条」
    expect(SHEET).toMatch(/potentialTreatmentItemName\(sheet\.potentialTreatment\)/);
  });

  test('⭐⭐ 两侧都必须写明「⛔ 不许说成功率/转化率更高」', () => {
    for (const src of [PROMPTS, SHEET]) {
      expect(src).toMatch(/成功率/); // 出现在禁令里
      expect(src).toMatch(/不统计成功|不许说/);
    }
  });

  test('⭐ 只提一次 —— 每轮都念会变成骚扰', () => {
    // 界面侧天然只说一次(确认那一刻拼一次);模型侧要被明确拦住,否则它会跟着复述
    expect(PROMPTS).toMatch(/只提一次/);
    expect(SHEET).toMatch(/不要再重复一遍/);
    expect(SHEET).toMatch(/说不用就别再提/);
  });

  test('提示词里要说清"确认后仍可补挂、但人员时效改不了"', () => {
    expect(PROMPTS).toMatch(/确认之后福利仍然可以补挂/);
    expect(PROMPTS).toMatch(/撤销重分/);
  });
});

/**
 * 「三行破折号夹一行真话」的回归(2026-08-06 产品走查)。
 *
 * `plan_summaries.content` 里还有大量**老行是纯文本一句话**。服务端兼容时把整句落到
 * `problem`、其余三段填「—」(parseBriefContent)。实测该接口对老行返回:
 *   { who: "—", history: "—", problem: "重要价值客16;47龋齿…", hook: "—" }
 * 前端照直渲染四行,主管看到的就是三行「—」夹一行真话 —— 像加载失败。
 *
 * ⚠️ 修法是**前端不渲染占位符**,⛔ 不是"把老行丢掉重跑 LLM"(那会让每个存量 plan
 *   打开时白烧一次调用),也⛔ 不是"渲染成空行"(空行一样占位)。
 */
describe('四点简报 —— 占位符那几行不渲染', () => {
  const DETAIL = readFileSync(
    join(__dirname, '../../pac-web/src/components/plan-detail/plan-detail-app.tsx'),
    'utf8',
  );

  test('⭐⭐ 渲染前必须过滤占位符', () => {
    expect(DETAIL).toMatch(/BRIEF_PLACEHOLDERS\.has/);
    expect(DETAIL).toMatch(/\.filter\(\(t\) => !BRIEF_PLACEHOLDERS/);
  });

  test('⭐ 占位符集合要覆盖服务端兼容老行时填的那个值(「—」)', () => {
    const set = /const BRIEF_PLACEHOLDERS = new Set\(\[([^\]]*)\]\)/.exec(DETAIL)?.[1] ?? '';
    for (const p of ['—', '-', '']) expect(set).toContain(`'${p}'`);
  });
});

/**
 * 「点了这个会怎么样」那句提示的回归(2026-08-06 走查)。
 *
 * 原来只按 `drivesStatus` 查一张三项的表,于是「秒挂」显示
 * 「本次任务保留,等下次跟进」—— 而它刚被配上 30 天冷静期,**下次跟进是一个月以后**。
 * 客服看到"等下次跟进"会以为过两天还能再打,实际这单一个月不会再出现。
 *
 * ⚠️ 抑制期是**用户能感知的后果**,不说就是骗人(T14)。同理「再考虑」是
 *   abandoned + 7 天,说成"将关闭"会让客服以为这人废了,白白再手动跟一遍。
 */
describe('通话结果提示 —— 必须说出抑制期', () => {
  const FORM = readFileSync(
    join(__dirname, '../../pac-web/src/components/plan-detail/outcome-form.tsx'),
    'utf8',
  );

  test('⭐⭐ 提示函数要同时吃 drivesStatus 和 suppressDays', () => {
    expect(FORM).toMatch(/function stateHint\(drives: string, days: number \| null, scheduled: boolean\)/);
    // ⛔ 不许退回只查 drivesStatus 的老写法
    expect(FORM).not.toMatch(/const STATE_HINTS/);
  });

  test('⭐ keep + 有抑制期 → 明说多少天内不再出现', () => {
    expect(FORM).toMatch(/天内不再出现/);
  });

  test('⭐ abandoned + 有限抑制期 → 说明它会自己回来,⛔ 别说成彻底关闭', () => {
    expect(FORM).toMatch(/天后若仍有需要会重新出现/);
    expect(FORM).toMatch(/SUPPRESS_PERMANENT_DAYS/); // 永久档要单独分岔
  });

  test('⭐ 约定下次回访 → 抑制点是那个日期,不是天数', () => {
    expect(FORM).toMatch(/到您约定的日期再跟进/);
  });
});

/**
 * 「待分配」批量处置的约束(2026-08-06 产品走查)。
 *
 * ── 事故经过 ──────────────────────────────────────────────────────
 * 取消自动改派后,待分配一次能有 55 人。主管说「把待分配的人分给客服,尽量齐平」,
 * 助手回:「我没有把患者从专属客服手里挪给别人的权限 —— 这属于关系层面的决定,
 * 只有您能拍板」,然后建议他**逐条拖 55 次**。
 *
 * 🔴 那句话本身没错,但用错了地方:**决定已经是主管做的了**,助手拒绝的是「执行」。
 *   边界应该是「决定他做,执行你做」—— 拒绝执行不是保护,是甩锅。
 *
 * ⚠️ 另一半同样重要:主管嘴上说"随机",要的是**齐平**。⛔ 不能真随机 ——
 *   同一张确认单每次算出不同结果,会毁掉"同样的输入两次算出同样的分法"这条
 *   他敢按确认键的前提(assignment-proposal 里 pickEvenly / lowest 都为此不用随机数)。
 */
describe('待分配的批量处置 —— 决定他做,执行你做', () => {
  const SHEET = readFileSync(
    join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
    'utf8',
  );
  const PROMPTS = readFileSync(
    join(__dirname, '../src/modules/assistant/assistant-prompts.ts'),
    'utf8',
  );
  const SVC = readFileSync(
    join(__dirname, '../src/modules/assistant/assistant.service.ts'),
    'utf8',
  );

  test('⭐⭐ 契约表达得了「整组一次性处置」—— 否则助手只能逐条改派', () => {
    const { SheetEditOpSchema } = jest.requireActual<typeof import('@pac/types')>('@pac/types');
    const ok = (o: unknown) => SheetEditOpSchema.safeParse(o).success;
    expect(ok({ select: { group: 'pending' }, action: 'assign', to: { mode: 'balance' } })).toBe(true);
    expect(
      ok({
        select: { group: 'pending' },
        action: 'assign',
        to: { mode: 'balance', agents: ['张悦'] },
      }),
    ).toBe(true);
    expect(ok({ select: { group: 'pending' }, action: 'remove' })).toBe(true);
  });

  test('⭐ 工具的 enum 里要有三个轴,否则模型根本发不出来', () => {
    expect(SVC).toMatch(/enum: \['patients', 'agent', 'pending', 'batch'\]/);
    expect(SVC).toMatch(/enum: \['assign', 'remove', 'set_expiry', 'set_benefit'\]/);
    expect(SVC).toMatch(/enum: \['owner', 'balance'\]/);
  });

  test('⭐⭐ 界面按水位法铺,⛔ 不许用随机数', () => {
    expect(SHEET).toMatch(/谁手上少先给谁/);
    // ⛔ 确认单的任何一处都不许出现随机 —— 它会毁掉可重现性
    expect(SHEET).not.toMatch(/Math\.random/);
  });

  test('⭐⭐ 候选客服必须含「一条都没分到的」—— 他们恰恰最空', () => {
    expect(SHEET).toMatch(/sheet\.skippedAgents\.map/);
  });

  test('⭐⭐ 提示词要点名禁止「我不能替您做这个决定」这种甩锅', () => {
    expect(PROMPTS).toMatch(/决定他做,执行你做/);
    expect(PROMPTS).toMatch(/甩锅/);
    // 同时仍然保留"不许自作主张"那一半 —— 两半缺一不可
    expect(PROMPTS).toMatch(/不许自作主张/);
  });

  test('⭐ 不许把"铺平"说成"随机分了"', () => {
    expect(PROMPTS).toMatch(/不是真随机/);
    expect(SVC).toMatch(/别顺着"随机"两个字/);
  });
});

/**
 * 「卡片上看得见的人,指令必须能指到」的回归(2026-08-06 实测)。
 *
 * 主管对着卡片说「把常瀚越分配给刘艳阳」,助手回:
 *   「确认单里没有『常瀚越』,跳过」
 * —— 而常瀚越的名字**就印在卡片的「待分配」那一组里**。
 *
 * 真因:`findPatients` 只搜 `items`(已落人的),没搜 `pending`。
 * 🔴 取消自动改派之后,待分配可能占一批人的大半 —— 只搜一半等于**对话这条路对他们失效**,
 *   主管只能一个个手动拖。
 */
describe('待分配的人 —— 对话指令要能指到', () => {
  const SHEET = readFileSync(
    join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
    'utf8',
  );

  test('⭐⭐ findPatients 必须同时搜「已落人」和「待分配」', () => {
    // 两半都要搜:已落人的 + 还在待分配的(后者按草稿算,见"同批次多条指令"那组)
    expect(SHEET).toMatch(/items\.filter\(\(i\) => !dropDraft\.has\(i\.planId\)\)\.filter\(hit\)/);
    expect(SHEET).toMatch(/pendingRest\(\)\.filter\(hit\)/);
  });

  test('⭐ 给待分配的人改时效要说明"分给客服后才生效"(否则主管以为已进批次)', () => {
    expect(SHEET).toMatch(/分给客服后这个时效才生效/);
  });

  test('⭐⭐ findAgent 也要能指到「本批未分到」的客服 —— 他们恰恰手上最空', () => {
    // 实测:「把陈浡龙分配给刘艳阳」→「没找到客服『刘艳阳』」,
    // 而刘艳阳的名字就印在卡片下面那行「本批未分到」里。
    expect(SHEET).toMatch(/sheet\.skippedAgents\.find/);
  });

  test('⭐ 对没分到条目的客服执行「移除」→ 说清"本来就没有",⛔ 不报"已移除 0 条"', () => {
    expect(SHEET).toMatch(/本批一条都没分到,没什么可动的/);
  });

  test('🔴🔴 拖给「未分到」的客服后,组标题不许退化成 id + 在手 0', () => {
    // 实测显示成「#678 1 条(在手 0 → 1)」——而王锐实际在手 56。
    // 真因:nameOf / inHandOf 只查了 byAgent(提案时就有条目的人)。
    expect(SHEET).toMatch(/sheet\.skippedAgents\.map\(\(a\) => \[a\.userId, a\.name\]/);
    expect(SHEET).toMatch(/sheet\.skippedAgents\.map\(\(a\) => \[a\.userId, a\.inHand\]/);
  });

  test('⭐ 已经被分到条目的客服要从「本批未分到」里剔掉(否则一人两处)', () => {
    expect(SHEET).toMatch(/const idleAgents = useMemo/);
  });

  test('⭐ 「待分配」可收拢,但**默认展开**(默认藏起来就回到"悄悄没了")', () => {
    expect(SHEET).toMatch(/useState\(true\);?\s*$/m); // pendingOpen 初值 true
    expect(SHEET).toMatch(/setPendingOpen/);
    expect(SHEET).toMatch(/\{pendingOpen && \(/);
  });

  test('⛔ 客服姓名的复制按钮不能嵌在展开按钮里(button 套 button 是非法 HTML)', () => {
    // 展开的点击区被拆成"箭头"和"条数"两块,姓名夹在中间只管复制
    expect(SHEET).toMatch(/aria-label=\{isOpen \? '收起' : '展开'\}/);
  });

  test('⭐ 姓名可点击复制,且⛔ 不能吃掉整行的拖拽', () => {
    expect(SHEET).toMatch(/function CopyableName/);
    expect(SHEET).toMatch(/draggable=\{false\}/); // 不加会被当成拖文本
    expect(SHEET).toMatch(/e\.stopPropagation\(\)/);
    // 两处行都要用上:客服组里的 + 待分配里的
    // 三处都要用上:客服组标题 + 客服组里的患者 + 待分配里的患者
    expect((SHEET.match(/<CopyableName /g) ?? []).length).toBeGreaterThanOrEqual(3);
  });
});

/**
 * 「同一批指令里,后一条覆盖了前一条」的回归(2026-08-06 实测)。
 *
 * 主管一句话给了两条指令:
 *   「李汝明分配给李闻,**剩下的**都分配给高瑞珍」
 * `move_patient(李汝明→李闻)` 先执行,但紧接着的 `assign_pending` 读的还是**旧的**
 * `moveByPlan`(setState 在同一次 effect 里读不到刚写的值)——
 * 于是李汝明仍被当成"待分配",连同其余 31 人一起铺给了高瑞珍。
 *
 * 🔴 最坏的地方:**两条都回报了"已执行"**。主管看到「李汝明 → 李闻」的回执,
 *   而卡片上李闻名下根本没有他 —— 界面说做了、实际被后一条冲掉了(违 T14)。
 *
 * ⚠️ 这类 bug 只在**一句话里给多条指令**时出现,单条指令永远测不出来。
 */
describe('同批次多条指令 —— 后一条要看得见前一条', () => {
  const SHEET = readFileSync(
    join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
    'utf8',
  );

  test('⭐⭐ 循环内读写**草稿**,⛔ 不许直接读 state', () => {
    expect(SHEET).toMatch(/const moveDraft: Record<string, string> = \{ \.\.\.moveByPlan \}/);
    expect(SHEET).toMatch(/const dropDraft = new Set\(dropped\)/);
    // 待分配的"还剩谁"必须按草稿算,否则刚被改派的人还会被当成待分配
    expect(SHEET).toMatch(/const pendingRest = \(\) =>/);
    expect(SHEET).toMatch(/!dropDraft\.has\(p\.planId\) && moveDraft\[p\.planId\] == null/);
  });

  test('⭐⭐ 草稿在循环结束后**一次性**提交,且用函数式合并', () => {
    expect(SHEET).toMatch(/setMoveByPlan\(\(m\) => \(\{ \.\.\.m, \.\.\.moveDraft \}\)\)/);
    expect(SHEET).toMatch(/setDropped\(\(sset\) => new Set\(\[\.\.\.sset, \.\.\.dropDraft\]\)\)/);
  });

  test('⭐ 铺平时的水位要按草稿算经办人(同批次刚改派的人不能算在旧客服头上)', () => {
    expect(SHEET).toMatch(/const who = moveDraft\[it\.planId\] \?\? it\.assignee/);
  });
});

/**
 * 确认单话术的**可读性**约束(2026-08-06 走查)。
 *
 * 原来助手每出一版都要念三大段:选人口径 + 待分配解释 + 四条操作指引。
 * 主管的原话是「密密麻麻不一定看得懂」。拆出来的具体毛病:
 *   ① **数字打架** —— 「这批挑了 50 人」紧跟着「10 人分给 6 位客服」,
 *      主管第一反应是"到底分了几个"(真相:50 = 已排 10 + 待定 40,但没人说)。
 *   ② **括号套括号** —— 「…(最多的是康慧捧)(他们原本还有 951 条在手)」。
 *   ③ **每轮重复四条操作指引** —— 第一次有用,一天看好几版就全是噪音。
 *   ④ **内部词** —— 「落库」「排队」。
 */
describe('确认单话术 —— 让主管一眼看懂', () => {
  const SVC_PROPOSAL = readFileSync(
    join(__dirname, '../src/modules/plan/assignment-proposal.service.ts'),
    'utf8',
  );
  const SVC_ASSIST = readFileSync(
    join(__dirname, '../src/modules/assistant/assistant.service.ts'),
    'utf8',
  );
  const SHEET = readFileSync(
    join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
    'utf8',
  );

  test('⭐⭐ 有待分配时必须给出**等式**,否则两个数字看着打架', () => {
    // 等式改成了"分段加粗起头"的形式:待分配自成一段,且标题里就带数字
    expect(SVC_PROPOSAL).toMatch(/\*\*⚠️ 其中 \$\{pending\.length\} 人要您定\*\*/);
    expect(SVC_PROPOSAL).toMatch(/\*\*已排好 \$\{x\.placed\} 人 · \$\{x\.loads\.length\} 位客服\*\*/);
  });

  test('⛔ 不许再出现括号套括号的负载描述', () => {
    expect(SVC_PROPOSAL).not.toMatch(/最多的是\$\{top\?\.name\}\)`\)/);
    expect(SVC_PROPOSAL).not.toMatch(/他们原本还有 \$\{x\.inHandTotal\} 条在手/);
  });

  test('⛔ 内部词清理:落库 / 排队', () => {
    expect(SVC_PROPOSAL).toMatch(/还没轮到/);
    expect(SVC_ASSIST).not.toMatch(/一条都还没落库/);
  });

  test('⭐⭐ 四条操作指引搬到卡片常驻,⛔ 不再要求助手每轮复述', () => {
    expect(SVC_ASSIST).toMatch(/不要再列"您可以这样调整"那几条/);
    expect(SHEET).toMatch(/可以直接跟我说:「把某某移出这批」/);
    // ⚠️ 只在还能改的时候显示 —— 已确认/已撤销时它是误导
    expect(SHEET).toMatch(/\{!readOnly && \(\s*<div className="border-t border-slate-100 bg-slate-50/);
  });

  test('⭐ 待分配的行要显示医生 —— 与已分配的行字段一致', () => {
    // 两组显示的字段不一样,主管会以为待分配是"另一种东西"
    expect((SHEET.match(/主治 \{/g) ?? []).length).toBeGreaterThanOrEqual(2);
  });
});

/**
 * 「重新排一版(尽量排满)」的约束(2026-08-06 产品定,改掉了当天早些时候的"顶替候选")。
 *
 * ── 第一版为什么不行 ────────────────────────────────────────────
 * 最初的做法是服务端预先算一份「可顶替名单」下发。实测当场露馅:
 * 池子里有 **17 位无主**患者,而取数窗口只有 `1.5×N + 20 = 95` 行,
 * 排在 95 名之外的 14 位**根本没被取回** —— 界面只敢说「用 3 位顶替」,
 * 而主管刚在同一段话里读到"17 人无主"。**候选来源被取数窗口限死了,不是池子。**
 *
 * ⇒ 改成:服务端只回答**能不能**(canRefill),主管点一下 → **重新出一版**:
 *   取数窗口放大到 `4×N + 100`,落人时遇到专属排满的**直接跳过**继续往后取。
 *
 * 🔴 底线没变:**不动任何人的专属关系**。⛔ 一条都不许从专属客服手里挪走。
 * ⚠️ 代价是这批人整体排名往后走 —— 产品判定可以接受:
 *   「排名不重要,重要的是这批人共享同一组特征」。
 *
 * ── 第二版又炸了一次(2026-08-06 当天晚些时候,实测)────────────────
 * 「尽量排满」下 `chosen` 变成**整个取回窗口**,而水位是拿 `chosen.length` 算的 ——
 * 水位于是被窗口撑大(实测 62 → 69),那批"专属排满"的人原地进了**同一位客服**手里。
 * 底线没破(进的是本人的客服),但主管看到的是「19 位无主怎么顶掉了 34 位」:**数对不上**。
 * ── 第三版(定稿):**只换"挑谁",落人一个字不改** ─────────────────
 * 产品原话:「为什么要纠结排名,50 个里 19 位无主 31 位有主重新定确认单不行吗」。
 * ⇒ 把池子里**无主的全换进来**,其余按优先级用有专属的补满 N,然后走**完全一样**的三趟。
 *   排不进的照样进待分配,卡片形状一个字不变 —— 实测 拟分 16·待分配 34 → 拟分 31·待分配 19。
 * ⛔ 别再说「顶替 / 凑满」:发生的只是"这批人换过了",没有任何人被顶掉。
 *
 * 🔴 两条不许回头的路:
 *   ⛔ 落人不许再有 `stopAt` / 跳过 / 超水位补发这类分支(那是第二版的病根);
 *   ⛔ `chosen` 不许再是取数窗口 —— 它必须恒等于"这一批要分的 N 个人"。
 */
/**
 * 🔴🔴 **确认单指令 = 选谁(select)× 干什么(action)× 给谁(to)。**
 *
 * ── 为什么推翻原来那串字面量(2026-08-06 产品判定)─────────────────
 * 原设计是 8 条并列的 action:`remove_patient` / `remove_agent` / `move_patient` /
 * `assign_pending` / `assign_pending_to_owner` / `drop_pending` / `set_expiry` / `set_benefit`。
 * 摊开看它们根本不是 8 件事,是**三个轴被压成了一维** —— 每加一种"选谁"或"给谁",
 * 条数就要乘一遍,而没被乘出来的格子,主管说到时模型只能掉进最像的那一格。
 *
 * 当天实测栽的那次:主管说「把待分配的患者**各自分给各自的专属客服**」,
 * 而「待分配 × 改派 × 各自的专属」这一格当时是空的 —— 只有铺平可用,
 * 18 个人被散给了 17 位**别人**。产品原话:「测出一条加一条?应该给助手通用的工具」。
 *
 * ⚠️ **正交化不消灭"枚举漏值"**,少写一个 mode 照样会栽。它买到的是:
 *   ① 加维度不再乘一遍 ② 缺的组合是**表格里的空格**(看得见) ③ 覆盖度能按矩阵测。
 * ⚠️ 有一条边界拿不掉:指令由**界面**拿自己那份确认单执行(模型没有 planId,故意的),
 *   所以做不到"给它通用能力让它自己写" —— 只能把词汇表做得足够表达。
 *
 * 🔴 下面这张矩阵就是"覆盖度"本身:**空格在这里是看得见的**。
 */
describe('确认单指令 —— 三个正交的轴', () => {
  const { SheetEditOpSchema } = jest.requireActual<typeof import('@pac/types')>('@pac/types');
  const ok = (o: unknown) => SheetEditOpSchema.safeParse(o).success;
  const SVC = readFileSync(
    join(__dirname, '../src/modules/assistant/assistant.service.ts'),
    'utf8',
  );
  const SHEET = readFileSync(
    join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
    'utf8',
  );

  /**
   * 主管**说得出口**的话 → 该拼成什么。
   * ⭐ 加一行比加一条指令便宜得多 —— 这正是正交化要换来的东西。
   */
  const SAYS: [string, unknown][] = [
    ['把王强移出这批', { select: { group: 'patients', patients: ['王强'] }, action: 'remove' }],
    [
      '把王强和李莉都给张悦',
      {
        select: { group: 'patients', patients: ['王强', '李莉'] },
        action: 'assign',
        to: { mode: 'balance', agents: ['张悦'] },
      },
    ],
    ['把张悦名下的都移出去', { select: { group: 'agent', agent: '张悦' }, action: 'remove' }],
    [
      '张悦名下的分给别人',
      { select: { group: 'agent', agent: '张悦' }, action: 'assign', to: { mode: 'balance' } },
    ],
    [
      '🔴 待分配的各自给回自己的专属客服(实测栽过的那句)',
      { select: { group: 'pending' }, action: 'assign', to: { mode: 'owner' } },
    ],
    [
      '待分配的平均分下去',
      { select: { group: 'pending' }, action: 'assign', to: { mode: 'balance' } },
    ],
    [
      '待分配的分给张悦和李莉',
      {
        select: { group: 'pending' },
        action: 'assign',
        to: { mode: 'balance', agents: ['张悦', '李莉'] },
      },
    ],
    ['待分配的先都不管', { select: { group: 'pending' }, action: 'remove' }],
    // ⭐ 下面三条在旧设计里**一条都表达不了** —— 正交化白捡的
    ['待分配的时效给 5 天', { select: { group: 'pending' }, action: 'set_expiry', days: 5 }],
    [
      '张悦名下的时效给 5 天',
      { select: { group: 'agent', agent: '张悦' }, action: 'set_expiry', days: 5 }
    ],
    ['王强的单给 5 天', { select: { group: 'patients', patients: ['王强'] }, action: 'set_expiry', days: 5 }],
    ['整批给 7 天', { select: { group: 'batch' }, action: 'set_expiry', days: 7 }],
    ['这批带上老客户复查免挂号费', { select: { group: 'batch' }, action: 'set_benefit', text: '老客户复查免挂号费' }],
    ['撤掉福利', { select: { group: 'batch' }, action: 'set_benefit', text: '' }],
  ];

  test.each(SAYS)('⭐ 主管说「%s」→ 契约里拼得出来', (_say, op) => {
    expect(ok(op)).toBe(true);
  });

  test('🔴🔴 owner 与 balance 是**两个不同的值**,不是一个默认行为', () => {
    // 这就是当天那次事故的正题:少了 owner 这一格,模型只能用 balance 干反面的事
    expect(ok({ select: { group: 'pending' }, action: 'assign', to: { mode: 'owner' } })).toBe(true);
    expect(ok({ select: { group: 'pending' }, action: 'assign', to: { mode: 'balance' } })).toBe(true);
    // ⛔ owner 不吃 agents:收谁由患者自己的专属关系决定,轮不到点名
    expect(SVC).toMatch(/mode=owner 不吃这个参数/);
  });

  /**
   * 🔴 **缺参数在 schema 就拦掉**,⛔ 别留给界面运行时兜 ——
   *   界面兜的话只能回一句"没执行",而模型不知道自己少填了什么,下一轮还会再错一次。
   */
  test.each([
    ['assign 没给 to', { select: { group: 'pending' }, action: 'assign' }],
    ['set_expiry 没给 days', { select: { group: 'batch' }, action: 'set_expiry' }],
    ['set_benefit 没给 text', { select: { group: 'batch' }, action: 'set_benefit' }],
    ['福利挂到了个人头上', { select: { group: 'patients', patients: ['王强'] }, action: 'set_benefit', text: 'x' }],
    ['整批"移出自己"', { select: { group: 'batch' }, action: 'remove' }],
    ['整批当改派对象', { select: { group: 'batch' }, action: 'assign', to: { mode: 'balance' } }],
    ['patients 一个都没给', { select: { group: 'patients', patients: [] }, action: 'remove' }],
  ])('⛔ %s → schema 直接拒掉', (_why, op) => {
    expect(ok(op)).toBe(false);
  });

  test('🔴🔴 提示词教的是**三问怎么拼**,⛔ 不是一串要背的短语', () => {
    expect(SVC).toMatch(/选谁\(select\)· 干什么\(action\)· 给谁\(to\)/);
    expect(SVC).toMatch(/别去背短语,按这三问填/);
    // 举例可以有,但必须写明"别当成清单" —— 否则模型只会照抄例子,没列到的组合又不敢拼
    expect(SVC).toMatch(/⛔ 别当成清单/);
    // 把实测栽的那次写成反例:抽象规则模型容易绕过,具体反例不会
    expect(SVC).toMatch(/散给了 17 位\*\*别人\*\*/);
    expect(SVC).toMatch(/话里出现「各自 \/ 自己的 \/ 本来的 \/ 专属 \/ 谁的客户归谁」/);
  });

  test('🔴🔴 界面侧 owner:收的人必须是患者**自己的** ownerUserId', () => {
    expect(SHEET).toMatch(/moveDraft\[id\] = owner\.id/);
    // ⛔ 专属客服不在名册的分不下去,如实报数,别顺手改派给别人
    expect(SHEET).toMatch(/查不到在岗的专属客服,没动他们/);
  });

  test('⭐ 两种铺法的回话措辞必须能一眼分开', () => {
    expect(SHEET).toMatch(/各自还给自己的专属客服/);
    expect(SHEET).toMatch(/谁手上少先给谁\*\*铺给/);
  });

  test('🔴 铺平算水位时要**排除本次要改派的这批** —— 否则他们被算了两遍', () => {
    expect(SHEET).toMatch(/const moving = new Set\(sel\.planIds\)/);
    expect(SHEET).toMatch(/moving\.has\(it\.planId\)/);
  });
});

describe('重新排一版 —— 只换挑谁,不动落人', () => {
  const SVC = readFileSync(
    join(__dirname, '../src/modules/plan/assignment-proposal.service.ts'),
    'utf8',
  );
  const SHEET = readFileSync(
    join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
    'utf8',
  );

  test('🔴🔴 尽量排满时取数窗口必须放大 —— 这正是"17 位无主只顶了 3 位"的病根', () => {
    expect(SVC).toMatch(/input\.preferPlaceable\s*\n?\s*\?\s*Math\.ceil\(batchSize \* 4\) \+ 100/);
  });

  test('🔴🔴 落人**只有一套**:重排不许在这里开分支', () => {
    // 第二版的病根:`stopAt` 让落人跳过排满的,水位跟着窗口一起飘。
    // ⚠️ 只禁**代码**里的用法 —— 上面那段"为什么栽过"的注释要留着,它是这条约束的理由
    expect(SVC).not.toMatch(/stopAt(\?:|\s*!=|\s*=[^=]|\s*\?\?)/);
    expect(SVC).not.toMatch(/overWaterline/);
    // 底线:没有"改派"这条路 —— 源码里只剩注释提到那个已废弃的标记
    expect(SVC).not.toMatch(/AssignStrategy\.SPREAD_OVERFLOW/);
  });

  test('🔴🔴 重排只换"挑谁" —— 无主的全换进来,有专属的**按客服轮着取**', () => {
    expect(SVC).toMatch(/repickPlaceable\(ranked, dedicated,/);
    // ⛔ 直取前 N 名会把名额全给专属大户(111/165 属同一人),另两位客服的余量白白空着
    expect(SVC).toMatch(/for \(const c of free\.slice\(0, target\)\) picked\.add/);
  });

  test('⭐ 水位只由「本批 N」决定 —— chosen 恒等于这一批要分的人', () => {
    expect(SVC).toMatch(/Math\.ceil\(\(inHandSum \+ chosen\.length\) \/ agents\.length\)/);
    // 第二版把整个窗口丢了进来,62 当场变 69
    expect(SVC).not.toMatch(/ranked\.map\(\(c, i\) => \(\{/);
  });

  /**
   * 🔴 **运营结论钉在卡片上,⛔ 不是助手那段话里。**
   *
   * 与重排交代同一条理由:助手那段话会被后续对话冲走,而卡片一直在;
   * 重排换卡片时助手也不会重新说一遍。⇒ 「这活多大」必须跟着卡片走。
   * 由来:2026-08-04 产品评审「你在**这儿**应该给的是结论性的东西」—— 这儿 = 确认单。
   */
  test('🔴 运营结论渲染在卡片上,且助手不重复说一遍', () => {
    expect(SHEET).toMatch(/sheet\.opsNote/);
    // 助手只念 selectionNote / basisNote / pendingNote 三段,⛔ opsNote 不在其中
    const ASSISTANT = readFileSync(
      join(__dirname, '../src/modules/assistant/assistant.service.ts'),
      'utf8',
    );
    expect(ASSISTANT).not.toMatch(/sheet\.opsNote/);
  });

  test('🔴🔴 重排换了什么必须由**卡片自己**说 —— 助手不会重新说一遍', () => {
    // 重排走 HTTP 只换卡片,上面那段旧话还在("34 人要您定"),不交代就是两份打架的口径
    expect(SVC).toMatch(/refillNote:/);
    expect(SVC).toMatch(/已重挑这 \$\{target\} 人/);
    expect(SHEET).toMatch(/sheet\.refillNote/);
  });

  test('⛔ 不许再把重排说成「顶替 / 凑满」—— 实际只是这批人换过了', () => {
    expect(SVC).not.toMatch(/换一批\*\*专属没排满的\*\*人来凑/);
    expect(SVC).not.toMatch(/已凑满/);
    expect(SHEET).not.toMatch(/顶替/);
  });

  test('⭐⭐ 被换进来的标 swap,⛔ 不混进 rank', () => {
    expect(SVC).toMatch(/wouldRank\.has\(c\.patientId\) \? 'rank' : 'swap'/);
    const { AssignmentItemSchema } = jest.requireActual<typeof import('@pac/types')>('@pac/types');
    expect(
      AssignmentItemSchema.safeParse({
        planId: '00000000-0000-4000-8000-000000000000',
        assigneeUserId: 'u1',
        assignStrategy: 'spread_no_dedicated',
        selectionMode: 'swap',
      }).success,
    ).toBe(true);
  });

  test('⭐⭐ 服务端只回答"能不能",⛔ 不下发顶替名单(名单会被窗口限死)', () => {
    expect(SVC).toMatch(/const canRefill = raw\.pending\.length > 0 && ownership\.free > 0/);
    expect(SVC).not.toMatch(/swapCandidates/);
  });

  test('⭐⭐ 卡片上**只有一个按钮**,⛔ 不再复述助手已经说过的解释', () => {
    expect(SHEET).toMatch(/重新排一版,尽量排满/);
    expect(SHEET).toMatch(/sheet\.canRefill/);
    // ⛔ 名次不该出现在界面上(产品:排名不重要)
    expect(SHEET).not.toMatch(/排名靠后/);
    // ⛔ 分布说明已经在助手那段话里了,卡片不再重复
    expect(SHEET).not.toMatch(/本批候选 \{sheet\.poolOwnership/);
  });

  test('⭐ 重排要把条件原样带回 —— 少带一个就退回"沿用上一次",人数悄悄变了', () => {
    expect(SHEET).toMatch(/targetCount: sheet\.batchSize/);
    expect(SHEET).toMatch(/potentialTreatment: sheet\.potentialTreatment/);
    expect(SHEET).toMatch(/temperature: sheet\.temperature/);
  });

  test('⭐ 待分配那段排在「已排好」之后', () => {
    const ASSIST = readFileSync(
      join(__dirname, '../src/modules/assistant/assistant.service.ts'),
      'utf8',
    );
    const iBasis = ASSIST.indexOf('sheet.basisNote');
    const iPending = ASSIST.indexOf('sheet.pendingNote');
    expect(iBasis).toBeGreaterThan(0);
    expect(iPending).toBeGreaterThan(iBasis);
  });
});
