import { describe, expect, it } from 'vitest';
import type { AssignmentProposal } from '@pac/types';
import {
  applyIncomingSheet,
  findActiveSheet,
  intentToPrompt,
  intentToSheetOps,
  isDiscardedDraft,
  stripToolEcho,
  toApiMessages,
  type Block,
  type ChatMessage,
  type DraftState,
  type ToolStep,
} from './chat-blocks';

/**
 * 草稿状态机的回归 —— 这段逻辑的失败形态**全都是静默的**：
 * 改错了卡片主管看不到反应、两张 active 同时能点确认就是分错一批人。
 * 端到端点一遍代价高且不稳定，所以抽成纯函数在这里锁住。
 */

const sheet = (n: number) => ({ placed: n } as unknown as AssignmentProposal);

function msg(id: string, ...sheets: Array<[string, DraftState]>): ChatMessage {
  return {
    id,
    role: 'assistant',
    blocks: sheets.map(([requestId, state]): Block => ({
      kind: 'assignment_sheet',
      requestId,
      sheet: sheet(1),
      state,
    })),
  };
}

/** 摊平成 [requestId, state] 便于断言 */
function states(ms: ChatMessage[]): Array<[string, DraftState]> {
  return ms.flatMap((m) =>
    m.blocks
      .filter((b): b is Extract<Block, { kind: 'assignment_sheet' }> => b.kind === 'assignment_sheet')
      .map((b) => [b.requestId, b.state] as [string, DraftState]),
  );
}

describe('applyIncomingSheet', () => {
  it('第一张草稿挂到宿主消息下，状态是 active', () => {
    const out = applyIncomingSheet([msg('m1')], {
      requestId: 'r1',
      sheet: sheet(10),
      hostMessageId: 'm1',
    });
    expect(states(out)).toEqual([['r1', 'active']]);
  });

  it('🔴 新草稿到达时，把**上一轮消息里**的 active 作废（跨消息扫描）', () => {
    const prev = [msg('m1', ['r1', 'active']), msg('m2')];
    const out = applyIncomingSheet(prev, {
      requestId: 'r2',
      sheet: sheet(20),
      hostMessageId: 'm2',
    });
    expect(states(out)).toEqual([
      ['r1', 'superseded'],
      ['r2', 'active'],
    ]);
  });

  it('🔴 终态不许被回退：confirmed / cancelled 不受作废扫描影响', () => {
    const prev = [msg('m1', ['r1', 'confirmed'], ['r2', 'cancelled']), msg('m2')];
    const out = applyIncomingSheet(prev, {
      requestId: 'r3',
      sheet: sheet(30),
      hostMessageId: 'm2',
    });
    expect(states(out)).toEqual([
      ['r1', 'confirmed'],
      ['r2', 'cancelled'],
      ['r3', 'active'],
    ]);
  });

  it('同一 requestId 重推：原地换内容、回到 active、⛔ 不堆第二张卡', () => {
    const withEdits: ChatMessage = {
      id: 'm1',
      role: 'assistant',
      blocks: [
        {
          kind: 'assignment_sheet',
          requestId: 'r1',
          sheet: sheet(1),
          state: 'active',
          edits: [{ seq: 1, ops: [] }],
        },
      ],
    };
    const out = applyIncomingSheet([withEdits], {
      requestId: 'r1',
      sheet: sheet(99),
      hostMessageId: 'm1',
    });
    expect(states(out)).toEqual([['r1', 'active']]);
    const b = out[0]!.blocks[0] as Extract<Block, { kind: 'assignment_sheet' }>;
    expect(b.sheet).toEqual(sheet(99));
    // ⚠️ 旧编辑指令必须清空 —— 对新名单没有意义
    expect(b.edits).toEqual([]);
  });

  it('任一时刻最多一张 active（连出三版）', () => {
    let ms: ChatMessage[] = [msg('m1')];
    for (const r of ['r1', 'r2', 'r3']) {
      ms = applyIncomingSheet(ms, { requestId: r, sheet: sheet(1), hostMessageId: 'm1' });
    }
    expect(states(ms).filter(([, s]) => s === 'active')).toHaveLength(1);
    expect(states(ms)).toEqual([
      ['r1', 'superseded'],
      ['r2', 'superseded'],
      ['r3', 'active'],
    ]);
  });
});

describe('findActiveSheet', () => {
  it('🔴 找的是 active，不是「最后一张」—— 这正是 2026-08-12 改掉的坑', () => {
    // 顺序上 r2 在后，但它已作废；助手的修改必须落到 r1 上
    const ms = [msg('m1', ['r1', 'active']), msg('m2', ['r2', 'superseded'])];
    const at = findActiveSheet(ms);
    expect(at).toEqual({ messageIndex: 0, blockIndex: 0 });
  });

  /**
   * 🔴🔴 **2026-08-14 改判**：这条原来断言的是「一张 active 都没有 → null」，
   *   而那正好把**确认之后补挂福利**整条路堵死了。
   *
   * 实测：确认完说「这批带上『老客户复查免挂号费』」——
   *   助手调了 `edit_assignment_sheet`，回「福利已挂上，会进这批人此后生成的话术里」，
   *   而 `plan_assignments.attributes` 是空的，接口一次都没被调过。
   *   指令在这里被**静默丢弃**（连"没改成"都没有），模型只能按"我发出去了"往下说。
   * ⇒ `confirmed` 必须能被选中；不能改的那些由卡片**如实拒绝**（它有 `if (assignmentId)` 那段），
   *   ⛔ 而不是在路由这一层假装没听见。
   */
  it('🔴🔴 没有 active 时落到 confirmed —— 确认之后还能补挂福利', () => {
    const ms = [msg('m1', ['r1', 'confirmed']), msg('m2', ['r2', 'superseded'])];
    expect(findActiveSheet(ms)).toEqual({ messageIndex: 0, blockIndex: 0 });
  });

  it('⭐ active 永远压过 confirmed（⛔ 别把还能改的那张让给已经落库的）', () => {
    const ms = [msg('m1', ['r1', 'confirmed']), msg('m2', ['r2', 'active'])];
    expect(findActiveSheet(ms)).toEqual({ messageIndex: 1, blockIndex: 0 });
  });

  it('⛔ 作废/已撤销的一律不选 —— 改了什么都不会发生', () => {
    const ms = [msg('m1', ['r1', 'superseded']), msg('m2', ['r2', 'cancelled'])];
    expect(findActiveSheet(ms)).toBeNull();
  });

  it('没有确认单时返回 null', () => {
    expect(findActiveSheet([msg('m1')])).toBeNull();
  });
});

/**
 * intent 路由 —— 🔴 **按钮和助手必须走同一条路**。
 *
 * 这里锁的是"哪些 intent 走局部改单、哪些走重跑"，以及 owner/balance 不能弄反
 * （实测栽过：主管说「各自分给各自的专属客服」，助手用了铺平，18 人被散给 17 位别人）。
 */
describe('引导节点 intent 路由', () => {
  it('待分配三个动作 → 局部改单，且 owner / balance 不弄反', () => {
    expect(intentToSheetOps('pending.to_owner')).toEqual([
      { select: { group: 'pending' }, action: 'assign', to: { mode: 'owner' } },
    ]);
    expect(intentToSheetOps('pending.spread')).toEqual([
      { select: { group: 'pending' }, action: 'assign', to: { mode: 'balance' } },
    ]);
    expect(intentToSheetOps('pending.remove')).toEqual([
      { select: { group: 'pending' }, action: 'remove' },
    ]);
  });

  it('改时效 → 局部改单；缺 days → null（⛔ 不许瞎猜一个天数）', () => {
    expect(intentToSheetOps('expiry.set', { days: 5 })).toEqual([
      { select: { group: 'batch' }, action: 'set_expiry', days: 5 },
    ]);
    expect(intentToSheetOps('expiry.set')).toBeNull();
  });

  it('🔴 换人群 / 换基数 → ⛔ 不能局部改单，必须走重跑', () => {
    for (const i of ['cohort.narrow', 'basis.set']) {
      expect(intentToSheetOps(i)).toBeNull();
    }
    /**
     * 🔴 `cohort.widen`（往后放一档 / 换个治疗项）连同它的节点 `short_supply`
     *   删于 2026-08-14：换格子是主管在矩阵上一点的事，矩阵每格都写着人数，
     *   而按钮不带数 ⇒ 严格弱于矩阵。⛔ 别加回来（这条断言就是拦它回来的）。
     */
    expect(intentToPrompt('cohort.widen')).toBeNull();
    // 🔴 替主管说的那句话里**不许出现内部码**（这句会原样进对话框）
    const narrow = intentToPrompt('cohort.narrow', {
      personaTags: 'rfm:vip',
      labelZh: '重要价值',
      count: 62,
    })!;
    expect(narrow).toContain('重要价值');
    expect(narrow).toContain('62 人');
    expect(narrow).not.toContain('rfm:vip');
    // ⚠️ 没有中文名就宁可不发 —— ⛔ 不许退化成把码念出来
    expect(intentToPrompt('cohort.narrow', { personaTags: 'rfm:vip' })).toBeNull();
  });

  it('🔴 消费那一刀要把**门槛数字**说出来（模型据此填 minSpendYuan，单位元）', () => {
    const t = intentToPrompt('cohort.narrow', {
      minSpendYuan: 720,
      labelZh: '消费高于这批平均 ¥720',
      count: 312,
    })!;
    expect(t).toContain('720 元');
    expect(t).toContain('312 人');
    expect(t).toContain('重新排一版');
    // ⛔ 千分位会被读成别的数 —— 这句是给模型读回一个整数用的
    expect(t).not.toContain('8,');
  });

  it('⭐「按别的条件选」只问不排 —— ⛔ 不许顺手出一版新确认单', () => {
    const t = intentToPrompt('cohort.attrs_ask')!;
    expect(t).toContain('先别重新排');
    expect(intentToSheetOps('cohort.attrs_ask')).toBeNull();
  });

  it('⛔ 两张表不能都认领同一个 intent（否则按钮行为取决于判断顺序）', () => {
    const all = [
      'pending.to_owner', 'pending.spread', 'pending.remove', 'pending.refill',
      'expiry.set', 'basis.set',
      'cohort.narrow', 'cohort.attrs_ask',
    ];
    for (const i of all) {
      const ops = intentToSheetOps(i, { days: 5, personaTags: 'rfm:vip' });
      const text = intentToPrompt(i, { days: 5, personaTags: 'rfm:vip' });
      expect(ops === null || text === null).toBe(true);
    }
  });

  it('未知 intent → 两边都 null（卡片静默不动，⛔ 不许乱执行）', () => {
    expect(intentToSheetOps('nope.nope')).toBeNull();
    expect(intentToPrompt('nope.nope')).toBeNull();
  });
});

/**
 * 历史回传 —— 🔴 P3 的核心：**带全脉络，但分层带**。
 *
 * 两个失败方向都很贵：全量带 → 确认单几百个 uuid 灌进上下文且诱导复述；
 * 一点不带 → 模型每轮从零开始，只能拿自己上一轮说过的话当依据（背句子的温床）。
 */
/**
 * 🔴 模型把**工具记录**当成输出格式抄进正文 —— 实测两次，换形状没堵住：
 *   ① `〔调用 propose_assignment …〕`（中文书名号，混在中文里毫无违和感）;
 *   ② 换成 `<tool …>` 之后 **qwen3.8-max 照抄不误**，连工具返回里的
 *      「⛔ 不要自己宣布已完成」这句内部叮嘱都一起贴给了主管。
 * ⇒ 只能结构上剥：渲染前剥（主管看不到）+ 回传前剥（⛔ 别进历史反过来强化）。
 */
describe('stripToolEcho · 抄出来的工具记录', () => {
  it('🔴🔴 成对标签连同里面的工具返回一起剥掉（内部叮嘱不许漏给主管）', () => {
    const leaked =
      '<tool name="edit_assignment_sheet" args="{}">已把 1 条修改交给界面执行。' +
      '请等那句结果,⛔ 不要自己宣布已完成。</tool> 确认单已更新:已移除「闫保玉」。';
    const out = stripToolEcho(leaked);
    expect(out).not.toContain('<tool');
    expect(out).not.toContain('不要自己宣布已完成');
    // ⚠️ 真正要给主管看的那半句必须留着
    expect(out).toContain('已移除「闫保玉」');
  });

  it('⭐ 自闭合的 sheet / artifact 标记也剥', () => {
    expect(stripToolEcho('<sheet state="当前有效" />好的')).toBe('好的');
    expect(stripToolEcho('<artifact title="x" />好的')).toBe('好的');
  });

  it('⭐ 流式到一半的半截标签也剥 —— 否则主管看着尖括号一个字一个字长出来', () => {
    expect(stripToolEcho('这批 495 人。<tool name="propo')).toBe('这批 495 人。');
  });

  it('⛔ 不误伤正文:没有这些标记时原样返回', () => {
    const t = '这批 495 人是从 1119 人里挑的，没被分过的排前面。';
    expect(stripToolEcho(t)).toBe(t);
  });
});


/**
 * 🔴 位置标记不进历史 —— 2026-08-13 实测的回归。
 *
 * 主管点「只选…」重排，第二轮只跑了 1 步：`propose_assignment` 调了，
 * `show_sheet` / `show_guidance` 一个都没调 —— 卡片掉到末尾、按钮全堆在卡上。
 * 原因之一是上一轮的记录还在上下文里，「已插入选项按钮」被读成"已经摆过了"。
 * ⚠️ 这条锁的是**别让位置标记回传**；⛔ 真做了事的工具必须留（否则模型会重复调）。
 *
 * 🔴 **2026-08-14 试过"留一个无参数无返回值的空壳"（`<按钮位置 />`），当场翻车。**
 *   模型把标记当成一种**输出格式**，直接写给主管看，而一个工具都没调。
 *   ⇒ 任何进了历史的"形状"都可能被照写。⛔ 别再尝试任何文本形态的位置标记 ——
 *   下面那条 `not.toContain` 就是拦这个的，⛔ 别把它改成 `toContain`。
 */
/**
 * 🔬 裸模式的历史构造 —— 这几条锁的是**会让 provider 直接 400 的那些形状**。
 *
 * tool-call 没有配对的 tool-result 时，DeepSeek 会 400（官方 issue 有记）。
 * ⇒ 配对、顺序、以及"半截调用整对丢掉"这三件必须有回归，⛔ 别等到实测那一轮才发现。
 */
describe('历史回传 · 结构化（唯一路径）', () => {
  const step = (t: string, over: Partial<ToolStep> = {}): Block => ({
    kind: 'tool',
    step: { id: t, callId: `call_${t}`, tool: t, args: { 编号: 1 }, result: 'ok', status: 'done', ...over },
  });

  it('🔴 每个 tool-call 都有配对的 tool-result（缺一个就 400）', () => {
    const m: ChatMessage = {
      id: 'a',
      role: 'assistant',
      blocks: [{ kind: 'text', text: '这批人怎么来的' }, step('show_guidance'), step('show_sheet')],
    };
    const out = toApiMessages(m);
    expect(out.map((x) => x.role)).toEqual(['assistant', 'tool']);
    const calls = (out[0] as { content: Array<{ type: string; toolCallId?: string }> }).content
      .filter((p) => p.type === 'tool-call')
      .map((p) => p.toolCallId);
    const results = (out[1] as { content: Array<{ toolCallId: string }> }).content.map(
      (p) => p.toolCallId,
    );
    expect(calls).toEqual(['call_show_guidance', 'call_show_sheet']);
    expect(results).toEqual(calls);
  });

  it('🔴 顺序照块的原始顺序 —— 「先说了什么、再调了什么」本身就是脉络', () => {
    const m: ChatMessage = {
      id: 'a',
      role: 'assistant',
      blocks: [
        { kind: 'text', text: '第一段' },
        step('show_guidance'),
        { kind: 'text', text: '第二段' },
      ],
    };
    const parts = (toApiMessages(m)[0] as { content: Array<{ type: string }> }).content;
    expect(parts.map((p) => p.type)).toEqual(['text', 'tool-call', 'text']);
  });

  it('⛔ 半截调用（running / 无 callId）整对丢掉，⛔ 不许只留一半', () => {
    const m: ChatMessage = {
      id: 'a',
      role: 'assistant',
      blocks: [
        { kind: 'text', text: '写了一半被打断' },
        step('propose_assignment', { status: 'running' }),
        step('show_sheet', { callId: undefined }),
      ],
    };
    const out = toApiMessages(m);
    // 只剩文本 ⇒ 没有 tool 消息
    expect(out).toHaveLength(1);
    expect((out[0] as { content: Array<{ type: string }> }).content.map((p) => p.type)).toEqual([
      'text',
    ]);
  });

  it('⚠️ 卡片 / artifact 块不回传 —— 模型当初就没从 API 收到过它们', () => {
    const m: ChatMessage = {
      id: 'a',
      role: 'assistant',
      blocks: [
        { kind: 'text', text: '正文' },
        { kind: 'assignment_sheet', requestId: 'r1', sheet: sheet(3), state: 'active' },
      ],
    };
    const parts = (toApiMessages(m)[0] as { content: Array<{ type: string }> }).content;
    expect(parts).toHaveLength(1);
  });
});

