import { AnchorMode, type AssignmentProposal } from '@pac/types';
import {
  ASSIGNMENT_INTENTS,
  computeHighlights,
  computeSignals,
} from '../src/modules/plan/assignment-signals';
import { modelFacts } from '../src/modules/plan/assignment-facts';

/**
 * 引导节点的判定 —— 规格见 docs/design/assignment-agent-flow.md §五。
 *
 * 这里锁的是**判定本身**（什么情况该出、severity 怎么排、默认是不是 no-op），
 * ⛔ 不锁措辞 —— 措辞会随产品走查改，锁住它只会让每次改文案都红一片。
 */

/** 最小提案骨架:只填判定要用到的字段,其余给零值。 */
function proposal(over: Partial<AssignmentProposal> = {}): AssignmentProposal {
  return {
    clinicId: 'c1',
    potentialTreatment: 'implant',
    temperature: 'hot',
    anchorMode: AnchorMode.DIAGNOSIS,
    candidateTotal: 1000,
    target: 405,
    batchSize: 405,
    expiresInDays: 3,
    basis: 'explicit',
    basisFrom: null,
    agentOverrides: {},
    placed: 405,
    unplaced: 0,
    items: [],
    pending: [],
    canRefill: false,
    poolOwnership: null,
    byAgent: [],
    skippedAgents: [],
    rosterNote: '',
    basisNote: '',
    selectionNote: '',
    pendingNote: '',
    refillNote: '',
    opsNote: '',
    signals: [],
    ...over,
  } as AssignmentProposal;
}

/** @param count 本批给他几条 @param inHandBefore 分配前他手上还有几条 */
const agent = (name: string, count: number, inHandBefore = 0) =>
  ({
    userId: name,
    name,
    inHandBefore,
    count,
    dedicated: 0,
    spread: count,
    loadAfter: inHandBefore + count,
    expiresInDays: 3,
    overridden: false,
  }) as AssignmentProposal['byAgent'][number];

const keys = (p: AssignmentProposal) => computeSignals(p).map((s) => s.key);

describe('引导节点 · 判定', () => {
  test('⭐ 一切正常 → 一个节点都不出', () => {
    expect(keys(proposal())).toEqual([]);
  });

  test('🔴 有待分配 → 出 pending，且 severity 最高（有人会被静默漏掉）', () => {
    const p = proposal({
      pending: [{}] as AssignmentProposal['pending'],
      byAgent: [agent('张悦', 10)],
      basis: 'default',
    });
    const s = computeSignals(p);
    expect(s[0]!.key).toBe('pending');
    expect(s[0]!.tier).toBe('action');
    // ⚠️ 排在纯信息类之前
    expect(s.map((x) => x.severity)).toEqual([...s.map((x) => x.severity)].sort((a, b) => a - b));
  });

  test('⭐ canRefill=false 时，「换无主患者补上」这个选项不出现（⛔ 不给做不到的选项）', () => {
    const no = computeSignals(proposal({ pending: [{}] as AssignmentProposal['pending'] }))[0]!;
    expect(no.options.map((o) => o.intent)).not.toContain(ASSIGNMENT_INTENTS.PENDING_REFILL);
    const yes = computeSignals(
      proposal({ pending: [{}] as AssignmentProposal['pending'], canRefill: true }),
    )[0]!;
    expect(yes.options.map((o) => o.intent)).toContain(ASSIGNMENT_INTENTS.PENDING_REFILL);
  });

  test('🔴 手上的量超出时效 → 出 daily_overload，给出要几天 + 三个可选动作', () => {
    // 每天 15 通 × 3 天 = 45 条是时效内打得完的量；60 条要 4 天
    const p = proposal({ expiresInDays: 3, byAgent: [agent('李莉', 30), agent('张悦', 60)] });
    const s = computeSignals(p).find((x) => x.key === 'daily_overload')!;
    expect(s).toBeDefined();
    expect(s.title).toContain('张悦');
    expect(s.why).toContain('4 天');
    expect(s.options.map((o) => o.intent)).toEqual([
      ASSIGNMENT_INTENTS.EXPIRY_SET,
      ASSIGNMENT_INTENTS.DAILY_RATE_SET,
      ASSIGNMENT_INTENTS.BATCH_SIZE_SET,
    ]);
    expect(s.options.some((o) => o.args?.days === 4)).toBe(true);
  });

  /**
   * 🔴 这个节点**每批基本都会亮**（N 只算新增、不扣在手），产品定这是对的 ——
   * 它是工作量预估，不是异常。⇒ 措辞不许像报错，否则常亮就变成"系统天天出故障"。
   */
  test('🔴 措辞不许像报错，且必须把「本批 / 原本在手」拆开说', () => {
    // 本批 45 + 原本在手 15 = 60 —— 拆开才看得出这批只压了 45
    const p = proposal({ expiresInDays: 3, byAgent: [agent('张悦', 45, 15)] });
    const s = computeSignals(p).find((x) => x.key === 'daily_overload')!;
    for (const w of ['打不完', '超了', '过载', '超出', '警告', '异常']) {
      expect(`${s.title}${s.why}`).not.toContain(w);
    }
    // ⚠️ 增量与总量必须分开 —— 不拆开主管会以为这批一下压了 60 条给他
    expect(s.why).toContain('本批 45 条');
    expect(s.why).toContain('原本在手 15 条');
    // ⚠️ 默认路径要说清后果是可接受的（落回池子、下批还能分），⛔ 不制造紧迫感
    expect(s.defaultLabel).toContain('落回池子');
  });

  test('⭐ 刚好打得完（45 条 / 3 天）→ ⛔ 不出 daily_overload', () => {
    expect(keys(proposal({ expiresInDays: 3, byAgent: [agent('张悦', 45)] }))).not.toContain(
      'daily_overload',
    );
  });

  test('⭐ 候选不够 → 出 short_supply', () => {
    expect(keys(proposal({ candidateTotal: 44, target: 44, batchSize: 405 }))).toContain(
      'short_supply',
    );
  });

  test('⭐ 用的是系统默认基数 → 出 expiry_default，且 tier=info（可折叠）', () => {
    const s = computeSignals(proposal({ basis: 'default', byAgent: [agent('a', 1)] })).find(
      (x) => x.key === 'expiry_default',
    )!;
    expect(s.tier).toBe('info');
  });

  test('⭐ 按末诊口径 → 出 anchor_nondefault；按诊断（默认）→ 不出', () => {
    expect(keys(proposal({ anchorMode: AnchorMode.LAST_VISIT }))).toContain('anchor_nondefault');
    expect(keys(proposal({ anchorMode: AnchorMode.DIAGNOSIS }))).not.toContain('anchor_nondefault');
  });

  test('🔴 每个节点的默认都是 no-op —— 「一条不点直接确认」必须永远安全', () => {
    const p = proposal({
      pending: [{}] as AssignmentProposal['pending'],
      candidateTotal: 44,
      target: 44,
      basis: 'default',
      anchorMode: AnchorMode.LAST_VISIT,
      expiresInDays: 3,
      byAgent: [agent('张悦', 90)],
    });
    const s = computeSignals(p);
    expect(s.length).toBeGreaterThanOrEqual(5);
    // ⚠️ 判据:每个 defaultLabel 都在描述"不处理会怎样",⛔ 没有一个是要主管动手的
    for (const x of s) expect(x.defaultLabel).toMatch(/^不处理 = |^不/);
  });

  test('🔴 ⛔ 不截断 —— 五个条件同时成立就出五个，防噪音靠 tier 分层', () => {
    const p = proposal({
      pending: [{}] as AssignmentProposal['pending'],
      candidateTotal: 44,
      target: 44,
      basis: 'default',
      anchorMode: AnchorMode.LAST_VISIT,
      byAgent: [agent('张悦', 90)],
    });
    const s = computeSignals(p);
    expect(s.length).toBe(5);
    expect(s.filter((x) => x.tier === 'action').map((x) => x.key)).toEqual([
      'pending',
      'daily_overload',
      'short_supply',
    ]);
    expect(s.filter((x) => x.tier === 'info').map((x) => x.key)).toEqual([
      'expiry_default',
      'anchor_nondefault',
    ]);
  });

  test('⛔ 模型拿不到 options 的技术细节 —— why 里不许出现 intent id', () => {
    const s = computeSignals(
      proposal({ pending: [{}] as AssignmentProposal['pending'], canRefill: true }),
    );
    for (const x of s) {
      expect(x.why).not.toMatch(/pending\.|expiry\.|cohort\./);
      expect(x.title).not.toMatch(/pending\.|expiry\.|cohort\./);
    }
  });
});

/**
 * 画像亮点 —— 「这一格和同类治疗整体比，哪里不一样」。
 *
 * 🔴 判据是**相对基线的偏离**，⛔ 不是绝对占比：占比高不等于有信息
 *   （「这批 80% 自费」全池也是 80%，说了等于没说）。
 */
describe('引导节点 · 画像亮点', () => {
  const dim = (id: string, nameZh: string, opts: Array<[string, string, number]>) => ({
    id,
    nameZh,
    options: opts.map(([value, zh, count]) => ({ value, zh, count })),
  });

  test('🔴 偏离达到 2 倍且人数够 → 亮；只是占比高但和整体一样 → ⛔ 不亮', () => {
    const s = computeHighlights({
      // 这一格 200 人：转介绍 62（31%）、自费 160（80%）
      batch: {
        size: 200,
        dims: [
          dim('referral_champion', '转介绍达人', [['yes', '转介绍达人', 62]]),
          dim('rfm', '价值分群', [['self_pay', '自费', 160]]),
        ],
      },
      // 同治疗整体 1000 人：转介绍 120（12%）、自费 800（80%）
      baseline: {
        size: 1000,
        dims: [
          dim('referral_champion', '转介绍达人', [['yes', '转介绍达人', 120]]),
          dim('rfm', '价值分群', [['self_pay', '自费', 800]]),
        ],
      },
    });
    expect(s.map((x) => x.key)).toEqual(['highlight:referral_champion:yes']);
    expect(s[0]!.title).toContain('62 人');
    expect(s[0]!.why).toContain('31%');
    expect(s[0]!.why).toContain('12%');
  });

  test('⛔ 倍数够但人数太少 → 不亮（6 个人不值得主管改打法）', () => {
    const s = computeHighlights({
      batch: { size: 200, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 6]])] },
      baseline: { size: 1000, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 10]])] },
    });
    expect(s).toEqual([]);
  });

  test('⛔ 基线人群太小 → 不比（谁占多数都不奇怪）', () => {
    const s = computeHighlights({
      batch: { size: 30, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 20]])] },
      baseline: { size: 40, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 2]])] },
    });
    expect(s).toEqual([]);
  });

  test('⛔ 基线里没有这个取值 → 不亮（⛔ 不许当成「这一格独有」凭空下结论）', () => {
    const s = computeHighlights({
      batch: { size: 200, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 50]])] },
      baseline: { size: 1000, dims: [dim('rfm', '价值分群', [['other', '其他', 900]])] },
    });
    expect(s).toEqual([]);
  });

  test('⭐ 多个命中时按倍数排序并最多取 3 个', () => {
    const many = Array.from({ length: 5 }, (_, i) => [`v${i}`, `取值${i}`, 20 + i * 10] as [string, string, number]);
    const s = computeHighlights({
      batch: { size: 200, dims: [dim('rfm', '价值分群', many)] },
      baseline: { size: 1000, dims: [dim('rfm', '价值分群', many.map(([v, z]) => [v, z, 10] as [string, string, number]))] },
    });
    expect(s).toHaveLength(3);
    // 倍数从大到小 —— 人数越多倍数越大
    expect(s.map((x) => x.key)).toEqual([
      'highlight:rfm:v4',
      'highlight:rfm:v3',
      'highlight:rfm:v2',
    ]);
  });

  test('🔴 亮点进 computeSignals 后按 severity 排在待分配之后、纯信息之前', () => {
    const hl = computeHighlights({
      batch: { size: 200, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 60]])] },
      baseline: { size: 1000, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 100]])] },
    });
    const s = computeSignals(
      proposal({
        pending: [{}] as AssignmentProposal['pending'],
        basis: 'default',
        byAgent: [agent('张悦', 10)],
      }),
      hl,
    );
    expect(s.map((x) => x.key)).toEqual([
      'pending',
      'highlight:rfm:vip',
      'expiry_default',
    ]);
  });
});

/**
 * 给模型的事实投影 —— P2 的核心不变式。
 *
 * 🔴 这里锁的是「**返回值里一个字的指令都不许有**」：
 * 成品句子有固定形状，模型学会形状就能凭空背出来（实测背出过「已撤销批次：收回 9 条」，
 * 而撤销工具一次都没调）。结构化事实编不出来 —— 但前提是我们别又把话写回去。
 */
describe('给模型的事实', () => {
  const base = () =>
    proposal({
      candidateTotal: 1000,
      target: 405,
      batchSize: 405,
      placed: 405,
      basis: 'default',
      expiresInDays: 3,
      byAgent: [agent('张悦', 45, 10), agent('李莉', 45)],
      skippedAgents: [{ userId: 'x', name: '王强', inHand: 80 }],
    });

  test('⭐ 三段齐全且顺序固定：怎么选的 → 怎么派的 → 要主管定的', () => {
    expect(Object.keys(modelFacts(base()))).toEqual(['怎么选的', '怎么派的', '要主管定的']);
  });

  test('🔴 ⛔ 里面一个字的指令都不许有', () => {
    const json = JSON.stringify(modelFacts(base()));
    for (const w of ['照抄', '原样', '不要说', '绝不', '⛔', '请把', '转述', '必须说']) {
      expect(json).not.toContain(w);
    }
  });

  test('🔴 ⛔ 不含 planId / 患者姓名（那些走侧信道给界面）', () => {
    const p = base();
    p.items = [{ planId: 'plan-uuid-1', patientName: '张三' }] as never;
    expect(JSON.stringify(modelFacts(p))).not.toContain('plan-uuid-1');
    expect(JSON.stringify(modelFacts(p))).not.toContain('张三');
  });

  test('⭐ 候选不够时，本批人数与基数**分别给** —— 否则主管以为系统偷偷改小了他的设置', () => {
    const f = modelFacts(proposal({ candidateTotal: 44, target: 44, batchSize: 405 })) as {
      怎么选的: Record<string, unknown>;
    };
    expect(f.怎么选的['本批人数']).toBe(44);
    expect(f.怎么选的['您设的基数']).toBe(405);
  });

  test('⭐ 候选够时不给「您设的基数」（没有歧义就别多一个数）', () => {
    const f = modelFacts(base()) as { 怎么选的: Record<string, unknown> };
    expect(f.怎么选的['您设的基数']).toBeUndefined();
  });

  test('⭐ 取值码换成中文，⛔ 模型手里不该有 implant / hot 这种词', () => {
    const f = modelFacts(base()) as { 怎么选的: Record<string, unknown> };
    expect(f.怎么选的['治疗项']).not.toBe('implant');
    expect(f.怎么选的['时间档']).not.toBe('hot');
  });

  test('⭐ 要主管定的只给 action 层，且不含 intent 技术细节', () => {
    const p = base();
    p.signals = computeSignals(p);
    const f = modelFacts(p) as { 要主管定的: Array<Record<string, unknown>> };
    expect(f.要主管定的.length).toBeGreaterThan(0);
    for (const x of f.要主管定的) {
      expect(Object.keys(x)).toEqual(['是什么', '为什么', '不处理会怎样']);
    }
    expect(JSON.stringify(f.要主管定的)).not.toMatch(/pending\.|expiry\.|cohort\./);
  });
});

/**
 * 🔴 大基数维度的偏离 —— 2026-08-12 本地实测补的判据。
 *
 * 只用「倍数 ≥2」时，这条判据**结构上打不着大类**：
 * 朝阳公园的获客渠道是 口碑 33% / 集团营销 23% / 走入 22%，
 * 口碑要够 2 倍得占到 66%（不可能），而能翻倍的小类在一格里凑不满 10 人。
 * ⇒ 整个维度永远不会命中。
 */
describe('引导节点 · 画像亮点（大基数）', () => {
  const dim = (id: string, nameZh: string, opts: Array<[string, string, number]>) => ({
    id,
    nameZh,
    options: opts.map(([value, zh, count]) => ({ value, zh, count })),
  });

  test('🔴 33% → 48%（1.45 倍但高出 15 个百分点）→ 必须命中', () => {
    const s = computeHighlights({
      batch: { size: 200, dims: [dim('acquisition_channel', '获客渠道', [['wom', '口碑客户', 96]])] },
      baseline: { size: 1000, dims: [dim('acquisition_channel', '获客渠道', [['wom', '口碑客户', 332]])] },
    });
    expect(s).toHaveLength(1);
    // ⚠️ 大基数说"高出 N 个百分点"比说"1.4 倍"直观
    expect(s[0]!.why).toContain('个百分点');
    expect(s[0]!.why).not.toContain('倍');
  });

  test('⭐ 33% → 38%（只高 5 个百分点、1.15 倍）→ ⛔ 不命中', () => {
    const s = computeHighlights({
      batch: { size: 200, dims: [dim('acquisition_channel', '获客渠道', [['wom', '口碑客户', 76]])] },
      baseline: { size: 1000, dims: [dim('acquisition_channel', '获客渠道', [['wom', '口碑客户', 332]])] },
    });
    expect(s).toEqual([]);
  });

  test('⭐ 小基数仍走倍数口径，措辞用「倍」', () => {
    const s = computeHighlights({
      batch: { size: 200, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 30]])] },
      baseline: { size: 1000, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 50]])] },
    });
    expect(s[0]!.why).toContain('倍');
  });

  test('🔴 两种口径混在一起时，按偏离强度排序（⛔ 不能只按倍数，否则百分点项永远垫底）', () => {
    const s = computeHighlights({
      batch: {
        size: 200,
        dims: [
          // 高出 25 个百分点（1.75 倍）—— 更值得看
          dim('acquisition_channel', '获客渠道', [['wom', '口碑客户', 116]]),
          // 2.1 倍但只高出 5.5 个百分点
          dim('rfm', '价值分群', [['vip', '重要价值', 21]]),
        ],
      },
      baseline: {
        size: 1000,
        dims: [
          dim('acquisition_channel', '获客渠道', [['wom', '口碑客户', 332]]),
          dim('rfm', '价值分群', [['vip', '重要价值', 50]]),
        ],
      },
    });
    expect(s.map((x) => x.key)).toEqual([
      'highlight:acquisition_channel:wom',
      'highlight:rfm:vip',
    ]);
  });
});
