import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { RefillProposalRequestSchema, SheetSnapshotSchema } from '@pac/types';

/**
 * 分配这条线的**跨端契约** —— 谁传什么、谁压过谁、少传一格会怎样。
 *
 * ═══ 为什么要单独一份 ═══════════════════════════════════════════
 * 2026-08-15 那天抓到的两个真 bug **都不在单测能覆盖的地方**：
 *   ① 「换无专属客服的患者补上」不回传 `narrowedBy` → 服务端退回整格取人
 *      （实测候选 2178 vs 296，差 1882 人）；
 *   ② 确认单快照没带人群条件 → 模型重算时按全池算，圈出完全另一批人。
 * 共同点：**两端各自都是对的**，schema 有这个字段、服务端也会用，
 * 只是**发的那一头没填**。单测测不到"没填"，因为没有哪一侧是错的。
 *
 * ═══ 为什么用"把源码当字符串读" ═══════════════════════════════
 * 与 `mcp-clinic-scope.spec.ts` 同一套办法，理由也一样：
 * 它让**纪律在源码上可数**（"凡是能触发重算的载体都要带全人群条件"）。
 * ⚠️ 代价是脆：改个变量名就红。⛔ 所以断言只锁**字段名与去向**，
 *   ⛔ 不锁格式、不锁行号、不锁措辞。红了先看是不是真漏了，再改断言。
 */

const web = (p: string) => readFileSync(join(__dirname, '../../pac-web/src', p), 'utf8');
const svc = (p: string) => readFileSync(join(__dirname, '../src', p), 'utf8');

const CHAT_HOOK = web('components/assistant/use-assistant-chat.ts');
const SHEET = web('components/assistant/assignment-confirm-sheet.tsx');
const CONTROLLER = svc('modules/assistant/assistant.controller.ts');
const ASSISTANT = svc('modules/assistant/assistant.service.ts');
const ASSIGN_CTRL = svc('modules/plan/assignment.controller.ts');

describe('重算类请求 —— 人群条件必须原样带回', () => {
  /**
   * 🔴 三处载体都能触发"重出一版"，三处都必须带全人群条件。
   *   ⛔ 少一处就是一条静默换人的路径 —— 而它不报错，界面只显示"重新排了一版"。
   */
  test('🔴 refill 的 schema 收得下主管自己加的那一刀', () => {
    const shape = RefillProposalRequestSchema.shape;
    expect(Object.keys(shape)).toEqual(
      expect.arrayContaining(['potentialTreatment', 'temperature', 'personaTags', 'minSpendYuan']),
    );
  });

  test('🔴 前端点「补上」时真的把 narrowedBy 发出去了（⛔ schema 有 ≠ 传了）', () => {
    // 取 refill({...}) 那一段
    // ⚠️ 取到 `.then(` 为止 —— 非贪婪匹配到第一个 `}` 会在内联的三元 spread 处截断
    const call = /\.refill\(\{[\s\S]*?\n          \}\)/.exec(CHAT_HOOK)?.[0] ?? '';
    expect(call).toContain('narrowedBy?.personaTags');
    expect(call).toContain('narrowedBy?.minSpendYuan');
    // ⚠️ 真源是 narrowedBy，⛔ 不许改成去 criteria 里翻（那儿混着矩阵两轴，分不出哪些是他加的）
    expect(call).not.toMatch(/criteria\.(personaTags|minSpendYuan)/);
  });

  test('🔴 服务端 refill 把这几项全透传（漏一个就是悄悄换了一批人 / 丢了他的精调）', () => {
    const handler = /async refill\([\s\S]*?\n  \}/.exec(ASSIGN_CTRL)?.[0] ?? '';
    for (const k of [
      'personaTags',
      'minSpendYuan',
      'potentialTreatment',
      'temperature',
      // 🔴 他在这一版上设的按客服精调 —— 第三个"重算丢东西"，2026-08-15 补
      'agentOverrides',
      // 🔴 换算尺 —— 第四个。它**不影响人数**（targetCount 显式带回），只改「约几天」那一列，
      //   正因为不影响人数，漏了更难被发现（当天就是做那个功能时才撞见的）。
      'dailyCalls',
    ]) {
      expect({ 参数: k, 透传: handler.includes(`body.${k}`) }).toEqual({ 参数: k, 透传: true });
    }
  });

  /**
   * 🔴 **「重算丢东西」到今天是第三个**，三个的形状完全一样：
   *   工具/接口调对了、序列也对，只是**发的那一头少填一格**，而两边都不报错。
   *   ① 人群条件（治疗项 / 时间档）→ 按全池重算，圈出另一批人
   *   ② 主管自己那一刀（`narrowedBy`）→ 退回整格，实测差 1,882 人
   *   ③ 按客服精调（`agentOverrides`）→「李莉这批只给 5 条」悄悄失效
   * ⇒ 这条断言是**通例**：⛔ 加第四个能触发重算的载体时，先回来补一行。
   */
  test('🔴 「他在这一版上做过的事」三样一样不能少（重算丢东西的通例）', () => {
    const call = /\.refill\(\{[\s\S]*?\n          \}\)/.exec(CHAT_HOOK)?.[0] ?? '';
    for (const k of [
      'narrowedBy?.personaTags',
      'narrowedBy?.minSpendYuan',
      's.agentOverrides',
      's.dailyCalls',
    ]) {
      expect({ 带回: k, 在: call.includes(k) }).toEqual({ 带回: k, 在: true });
    }
    // 人群那两轴走 criteria 快照（模型重算的依据），单独锁在上面那条
  });

  test('🔴 确认单快照带着「这批人是谁」（模型重算的唯一依据）', () => {
    const shape = SheetSnapshotSchema.shape.criteria.shape;
    expect(Object.keys(shape)).toEqual(
      expect.arrayContaining([
        'potentialTreatment',
        'temperature',
        'narrowedBy',
        'candidateTotal',
        'batchSize',
      ]),
    );
    // 卡片得真的填 —— schema 要求了但填 null 一样白搭
    const snap = /const snapshot = useMemo<SheetSnapshot>\([\s\S]*?\n  \);/.exec(SHEET)?.[0] ?? '';
    expect(snap).toContain('potentialTreatment: sheet.potentialTreatment');
    expect(snap).toContain('narrowedBy: sheet.narrowedBy');
  });
});

describe('确认单草稿态 —— 前端产、请求带、工具读', () => {
  /**
   * ⚠️ 这条链上任何一段断了，症状都一样：模型拿旧数说话，而**两边都不报错**。
   *   （确认之前那份状态只在浏览器里，服务端没有可查的东西。）
   */
  test('🔴 三段都在：卡片挂 store → 请求体带 → 工具读', () => {
    expect(SHEET).toContain('setSheetSnapshot(snapshot)');
    expect(CHAT_HOOK).toMatch(/sheetState:\s*useAssistantStore\.getState\(\)\.sheetSnapshot/);
    expect(CONTROLLER).toMatch(/SheetSnapshotSchema\.safeParse\(body\.sheetState\)/);
    expect(ASSISTANT).toMatch(/tools\.get_current_sheet\s*=\s*tool\(/);
    expect(ASSISTANT).toContain('sheetSnapshotFacts(input.sheetState)');
  });

  test('⭐ 解不出来就当没有 —— ⛔ 不许把半份快照喂给模型（缺的字段会被读成 0）', () => {
    expect(CONTROLLER).toMatch(/safeParse\(body\.sheetState\)\.success/);
  });

  test('🔴 卡片卸载时只清自己那份（重出一版后旧卡片会把新的清掉）', () => {
    expect(SHEET).toContain('sheetSnapshot?.requestId === snapshot.requestId');
  });
});

describe('改单回声 —— 每一条改动路径都要发', () => {
  /**
   * 🔴 2026-08-15 之前**只有一条**路径发回声，另外几条全静默：
   *   拖动改派、点 ×（明细行 / 待分配组）、右上角整批时限、每行单独时限、
   *   连引导按钮里的「改时限」都不发 —— 而那颗按钮正是模型自己开出来的。
   * ⇒ 这条测的是**纪律在源码上可数**：改状态的地方后面必须紧跟一个 echo。
   * ⚠️ 窗口给 12 行：批量改单那条会在 `setX(...)` 与 `notes.push(...)` 之间夹一段注释，
   *   6 行会把它误判成静默。⛔ 别再放宽 —— 真静默的点周围**几十行内**一个都没有，
   *   窗口越大越容易借到隔壁按钮的 echo，那时这条测试就成了摆设。
   */
  const MUTATORS = ['setMoveByPlan(', 'setDropped(', 'setExpiresInDays(', 'setDayByPlan('];

  test('🔴 每个改状态的地方近旁都有 echo（⛔ 静默改动 = 模型拿旧数说话）', () => {
    const lines = SHEET.split('\n');
    const silent: string[] = [];
    lines.forEach((ln, i) => {
      if (!MUTATORS.some((m) => ln.includes(m))) return;
      // ⚠️ 跳过声明本身（useState）与 effect 里的批量落盘（那处自己带 echo，在窗口内）
      if (ln.includes('useState') || ln.includes('const [')) return;
      const window = lines.slice(i, i + 12).join('\n');
      /**
       * ⚠️ `notes.push(` 也算数：批量改单（`edits` effect）把每条结果攒进 notes，
       *   循环跑完统一 `echo(...notes)` —— 那是**一次操作多条指令**的正确形状，
       *   ⛔ 别为了让断言好写就逼它每条各发一次回声（主管会看到一串碎片）。
       */
      if (!window.includes('echo(') && !window.includes('notes.push('))
        silent.push(`第 ${i + 1} 行: ${ln.trim().slice(0, 60)}`);
    });
    expect(silent).toEqual([]);
  });

  test('⭐ 当前值只进 modelText —— ⛔ 界面不显示（卡片上就在他眼前，写两遍是读两遍）', () => {
    expect(SHEET).toMatch(/onEditApplied\?\.\(text, `\$\{text\}\\n\$\{sheetSnapshotLine/);
  });
});

describe('模型选择 —— 谁压过谁', () => {
  /**
   * ⚠️ 2026-08-15：我照着 call 上的 `defaultModelId` 报了一张"实际在跑什么模型"的表，
   *   **报错了** —— UI 每次都传 `model`，`modelIdOverride` 压过它，那几个 default 是死值。
   * ⇒ 锁住这条优先级，并让"话术那条路真正跑哪个模型"在源码上一眼可见。
   */
  test('🔴 override 压过 defaultModelId（⛔ 别调换，那会让 UI 的选择静默失效）', () => {
    const runner = svc('modules/ai/ai-call-runner.service.ts');
    expect(runner).toMatch(/ctx\.modelIdOverride \?\? call\.defaultModelId/);
  });

  test('⭐ 四个摘要类走同一个模型（裸键 qwen → QWEN_DEFAULT_MODEL）', () => {
    for (const f of [
      'modules/ai/calls/draft-recall-brief/call.ts',
      'modules/ai/calls/draft-persona-summary/call.ts',
      'modules/ai/calls/draft-recall-summary/call.ts',
      'modules/ai/calls/draft-plan-summary/call.ts',
    ]) {
      expect({ 文件: f, 模型: /defaultModelId = '([^']+)'/.exec(svc(f))?.[1] }).toEqual({
        文件: f,
        模型: 'qwen',
      });
    }
  });

  test('🔴 摘要 schema ⛔ 不许有硬长度下界（qwen 写得短 → too_small 整次报废）', () => {
    // ⚠️ 先剥注释：那份 schema 的注释里就写着「⛔ 别把 .min(50) / .max(600) 加回来」，
    //   连注释一起扫会把**禁令本身**判成违规（2026-08-15 第一版就这么红的）。
    const schema = svc('modules/ai/calls/draft-plan-summary/schema.ts')
      .replace(/\/\*[\s\S]*?\*\//g, '')
      .replace(/\/\/.*/g, '');
    // .min(2) 是"不许空串"的守卫,不是长度要求 —— 大于它的下界才是问题
    for (const m of schema.matchAll(/\.min\((\d+)\)/g)) {
      expect({ 下界: Number(m[1]), 允许: Number(m[1]) <= 2 }).toEqual({
        下界: Number(m[1]),
        允许: true,
      });
    }
    expect(schema).not.toMatch(/\.max\(\d+\)/);
  });
});

/**
 * 🔴 名册的**归属判据** —— 2026-08-16 测试服上海世纪公园显示「193 位在岗」引出的。
 *
 * 拆开看:19 人做了 95.8% 的回访,98 人只有 1 条;**160 人的主场在别家诊所**,
 * 他们在这儿总共 330 条(产品指认的康慧捧:主场 3,799 条在另一家,这儿 3 条)。
 * 而 `rosterCount` 直接进默认批次估算(在岗人数 × 每天几通 × 时限)——
 * 名册虚高一个数量级,默认批次就跟着虚高一个数量级。
 *
 * ⚠️ 这里锁的是**判据在不在**,⛔ 不锁阈值数字(20% / 20 条是产品可调的)。
 */
describe('在岗名册 —— 「在这儿干活的人」不是「碰过这儿一次的人」', () => {
  const ROSTER = svc('modules/plan/agent-roster.service.ts');

  test('🔴 两条判据都在,且是**或**(只留一条都会误伤)', () => {
    // 只用占比 → 挡掉在某诊所做了 50+ 条、但个人总量更大的真客服(实测 13 人);
    // 只用绝对量 → 对小诊所和新人不公平,那正是「名册不是白名单」要护的人。
    expect(ROSTER).toContain('ROSTER_MIN_SHARE');
    expect(ROSTER).toContain('ROSTER_MIN_VISITS');
    expect(/>=\s*\$\{ROSTER_MIN_VISITS\}[\s\S]{0,80}OR[\s\S]{0,120}ROSTER_MIN_SHARE/.test(ROSTER)).toBe(
      true,
    );
  });

  test('🔴 占比的分母是**跨诊所**总量 —— 按本诊所算的话它恒等于 100%,整道闸失效', () => {
    const everywhere = /everywhere AS \(([\s\S]*?)\n      \)/.exec(ROSTER)?.[1] ?? '';
    expect(everywhere).not.toBe('');
    expect(everywhere).toContain('task_director_id IN');
    // ⛔ 这个 CTE 里不许出现 clinic_id:一加上,占比就成了「本诊所 ÷ 本诊所」
    expect(everywhere).not.toContain('clinic_id');
    // 时间窗必须与名册那半边同一个,否则分子分母量的不是同一段时间
    expect(everywhere).toContain('source_created_at >=');
  });

  test('⚠️ 名册仍不是白名单 —— 被挡掉的人主管照样点得到', () => {
    // 闸只改"建议给谁",⛔ 没改"能分给谁"。extraUserIds 那条路必须还在。
    expect(ROSTER).toContain('extraUserIds');
    expect(ROSTER).toContain('namesAnywhere');
    expect(ROSTER).toContain('inRoster');
  });
});
