import type { ModelMessage } from 'ai';
import {
  slimInputSnapshot,
  briefToolArgs,
  truncateOutputText,
  joinStepTexts,
  stepShapes,
  assistantCallKey,
  assistantInputHash,
} from '../src/modules/assistant/assistant-invocation';
import { estimateCostYuan, FALLBACK_PRICE } from '../src/modules/ai/core/cost';

/**
 * 助手落 `agent_invocations` 的**纯函数层**。
 *
 * 这组测试守的是同一件事：**落库量不能随对话轮数涨**。
 * 助手的 messages 里带着确认单那类肥载荷，一旦把上下文原样落进去，
 * 第 N 轮就要写前 N-1 轮的全部内容 —— 按平方涨（实测口径 ~100 KB/行 vs ~2.5 KB/行）。
 */

/** 造一段带工具往返的多轮对话 —— 工具返回值刻意做得很肥。 */
function conversation(turns: number, fatBytes = 50_000): ModelMessage[] {
  const out: ModelMessage[] = [];
  for (let i = 1; i <= turns; i++) {
    out.push({ role: 'user', content: `第 ${i} 轮：给我出一版` } as ModelMessage);
    out.push({
      role: 'assistant',
      content: [{ type: 'text', text: 'x'.repeat(fatBytes) }],
    } as unknown as ModelMessage);
  }
  return out;
}

describe('slimInputSnapshot —— 只存当轮', () => {
  it('只取最后一条用户消息，⛔ 不带历史', () => {
    const s = slimInputSnapshot(conversation(3));
    expect(s.userText).toBe('第 3 轮：给我出一版');
    expect(s.turnNo).toBe(3);
    expect(s.priorTurns).toBe(2);
  });

  it('🔴 快照大小不随轮数增长 —— 这是这层存在的全部理由', () => {
    const small = JSON.stringify(slimInputSnapshot(conversation(1))).length;
    const large = JSON.stringify(slimInputSnapshot(conversation(20))).length;
    // 只有 turnNo/priorTurns 的位数会变，差几个字符而已
    expect(large - small).toBeLessThan(10);
    // 而原始上下文本身是几十万字节
    expect(JSON.stringify(conversation(20)).length).toBeGreaterThan(1_000_000);
  });

  it('⛔ 不含任何工具返回值 / 助手历史发言', () => {
    const raw = JSON.stringify(slimInputSnapshot(conversation(5)));
    expect(raw).not.toContain('xxxx');
  });

  it('用户原话截断到 2000 字', () => {
    const s = slimInputSnapshot([
      { role: 'user', content: '甲'.repeat(5000) } as ModelMessage,
    ]);
    expect(s.userText).toHaveLength(2000);
  });

  it('content 为 parts 数组时也能取到文本', () => {
    const s = slimInputSnapshot([
      { role: 'user', content: [{ type: 'text', text: '换成正畸' }] } as unknown as ModelMessage,
    ]);
    expect(s.userText).toBe('换成正畸');
  });

  it('没有用户消息时不炸', () => {
    expect(slimInputSnapshot([])).toMatchObject({ turnNo: 0, userText: '', priorTurns: 0 });
  });

  it('现场上下文有才带，⛔ 不塞 undefined 键', () => {
    const bare = slimInputSnapshot([{ role: 'user', content: 'a' } as ModelMessage]);
    expect(Object.keys(bare)).not.toContain('activeClinicId');
    const withCtx = slimInputSnapshot([{ role: 'user', content: 'a' } as ModelMessage], {
      activeClinicId: 'c1',
      activePatientId: 'p1',
    });
    expect(withCtx).toMatchObject({ activeClinicId: 'c1', activePatientId: 'p1' });
  });
});

describe('briefToolArgs —— 参数截断', () => {
  it('肥载荷截到 200 字', () => {
    expect(briefToolArgs({ patients: 'p'.repeat(9999) })).toHaveLength(200);
  });

  it('不可序列化的对象不抛', () => {
    const cyclic: Record<string, unknown> = {};
    cyclic.self = cyclic;
    expect(briefToolArgs(cyclic)).toBe('[unserializable]');
  });

  it('undefined 归一成 {}', () => {
    expect(briefToolArgs(undefined)).toBe('{}');
  });
});

describe('truncateOutputText', () => {
  it('截到 8000', () => {
    expect(truncateOutputText('乙'.repeat(20_000))).toHaveLength(8000);
  });
  it('空值原样返回 undefined', () => {
    expect(truncateOutputText(undefined)).toBeUndefined();
    expect(truncateOutputText('')).toBeUndefined();
  });
});

describe('joinStepTexts —— 多步时把每一步的话都留下', () => {
  it('🔴 拼所有步，⛔ 不只取最后一步', () => {
    const steps = [{ text: '我先查一下' }, { text: '' }, { text: '这批人比预想的少' }];
    expect(joinStepTexts(steps, '这批人比预想的少')).toBe('我先查一下\n这批人比预想的少');
  });

  it('末步文本不在 steps 里时补上', () => {
    expect(joinStepTexts([{ text: '第一步' }], '收尾这句')).toBe('第一步\n收尾这句');
  });

  it('⛔ 不把末步拼两遍', () => {
    expect(joinStepTexts([{ text: 'a' }, { text: 'b' }], 'b')).toBe('a\nb');
  });

  it('没有 steps 时退回 ev.text（单步调用）', () => {
    expect(joinStepTexts(undefined, '就一句')).toBe('就一句');
    expect(joinStepTexts([], '就一句')).toBe('就一句');
  });

  it('全空返回 undefined', () => {
    expect(joinStepTexts([{ text: '' }, {}], undefined)).toBeUndefined();
  });
});

describe('stepShapes —— 让「一类一调、紧挨着调」变得可验证', () => {
  const g = (name: string) => ({ toolName: name });

  it('🔴 守规矩：每类各占一步，且每步都先讲了话', () => {
    const shapes = stepShapes([
      { text: '这一版 405 人。', toolCalls: [g('propose_assignment')] },
      { text: '这一格还能再切一刀。', toolCalls: [g('show_guidance')] },
      { text: '本批人数是估的。', toolCalls: [g('show_guidance')] },
      { text: '明细在这儿。', toolCalls: [g('show_sheet')] },
    ]);
    const guid = shapes.filter((s) => s.tools.includes('show_guidance'));
    expect(guid).toHaveLength(2);
    expect(guid.every((s) => s.tools.length === 1 && s.textLen > 0)).toBe(true);
  });

  it('🔴 攒着一起调：同一步里两次 show_guidance —— 这正是 2026-08-15 那次事故的形状', () => {
    const shapes = stepShapes([
      { text: '选人这段讲完了。', toolCalls: [g('show_guidance'), g('show_guidance')] },
    ]);
    expect(shapes[0].tools.filter((t) => t === 'show_guidance')).toHaveLength(2);
  });

  it('这一步只调工具没说话 → textLen = 0（引导会落在没头没尾的位置）', () => {
    expect(stepShapes([{ text: '   ', toolCalls: [g('show_guidance')] }])[0]).toMatchObject({
      n: 1,
      textLen: 0,
    });
  });

  it('没有 steps / 没有工具时不炸', () => {
    expect(stepShapes(undefined)).toEqual([]);
    expect(stepShapes([{ text: '就一句' }])).toEqual([{ n: 1, textLen: 3, tools: [] }]);
  });
});

describe('callKey 按现场分', () => {
  it('主管走分配线，客服走打单线', () => {
    expect(assistantCallKey(true)).toBe('assistant_assignment');
    expect(assistantCallKey(false)).toBe('assistant_execute');
  });
});

describe('inputHash', () => {
  it('同输入同 hash，改一个字就变', () => {
    const a = slimInputSnapshot([{ role: 'user', content: '发 200 人' } as ModelMessage]);
    const b = slimInputSnapshot([{ role: 'user', content: '发 300 人' } as ModelMessage]);
    const k = 'assistant_assignment';
    const v = 'assistant@test';
    expect(assistantInputHash(k, v, a)).toBe(assistantInputHash(k, v, a));
    expect(assistantInputHash(k, v, a)).not.toBe(assistantInputHash(k, v, b));
  });

  it('提示词版本变了，hash 必须跟着变', () => {
    const s = slimInputSnapshot([{ role: 'user', content: 'x' } as ModelMessage]);
    expect(assistantInputHash('k', 'v1', s)).not.toBe(assistantInputHash('k', 'v2', s));
  });
});

describe('estimateCostYuan —— 与 AiCallRunner 共用同一份口径', () => {
  const table = { m1: { inHit: 1, inMiss: 10, out: 100 } };

  it('命中缓存的部分按 inHit 计价', () => {
    const { yuan } = estimateCostYuan(table, 'm1', 1_000_000, 0, 1_000_000);
    expect(yuan).toBeCloseTo(1, 6);
  });

  it('未命中的部分按 inMiss 计价', () => {
    const { yuan } = estimateCostYuan(table, 'm1', 1_000_000, 0, 0);
    expect(yuan).toBeCloseTo(10, 6);
  });

  it('输出按 out 计价', () => {
    const { yuan } = estimateCostYuan(table, 'm1', 0, 1_000_000, 0);
    expect(yuan).toBeCloseTo(100, 6);
  });

  it('🔴 价目表里没有就报 priceMissing —— ⛔ 不许静默低报', () => {
    const { priceMissing } = estimateCostYuan(table, '未知模型', 100, 100);
    expect(priceMissing).toBe(true);
  });

  it('cached > prompt 时 clamp，不出负数', () => {
    const { yuan } = estimateCostYuan(table, 'm1', 100, 0, 999_999);
    expect(yuan).toBeGreaterThanOrEqual(0);
    expect(yuan).toBeCloseTo((100 * 1) / 1_000_000, 9);
  });

  it('表全空时走兜底价', () => {
    const { yuan, priceMissing } = estimateCostYuan({}, 'x', 1_000_000, 0, 0);
    expect(priceMissing).toBe(true);
    expect(yuan).toBeCloseTo(FALLBACK_PRICE.inMiss, 6);
  });
});
