import { AiService } from './ai.service';
import type { AiGatewayService } from './ai-gateway.service';
import { DECOMPOSE_SYSTEM_PROMPT, DECOMPOSE_TOOL_INPUT_SCHEMA } from './prompts/decompose';
import { RISK_INTERPRET_SYSTEM_PROMPT, RISK_INTERPRET_TOOL_INPUT_SCHEMA } from './prompts/risk-interpret';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { RiskInterpretDto } from './dto/risk-interpret.dto';
import type { AgentRiskInterpretRequest } from '@ftb/shared';

const missingToolUseMessage =
  'Anthropic 未通过 tool_use 返回结果；stop_reason=tool_use；content=text("⚠️ 上游模型未返回任何内容。可能原因：触发了安全策略、上游限流、或模型对当前输入直接结束。")';

function createRiskRequest(patch: Partial<AgentRiskInterpretRequest> = {}): AgentRiskInterpretRequest {
  return {
    versionId: 'ver-1',
    versionName: 'V1.0',
    riskScore: 70,
    riskLevel: 'at_risk',
    delayDays: 1,
    confidence: 80,
    signals: {
      unfinishedCount: 4,
      openBugCount: 3,
      criticalBugCount: 3,
      failedTestCount: 1,
      blockedCount: 0,
      silentRiskCount: 0,
      daysToExpectedRelease: 1,
    },
    trendSummary: 'risk rising',
    reasons: [{ key: 'critical_bug', label: 'Critical Bug', severity: 'critical', detail: '3 P1 bugs' }],
    silentRisks: [],
    dailyEvidence: {
      todayDeliveries: [],
      todayProgress: [],
      todayCreations: [],
      todayRisks: ['tests failed'],
      progressNotes: [],
      needsProgressItems: [],
      recentActivityCount: 1,
      totalActivityCount: 1,
      todayActualHours: 2,
    },
    ...patch,
  };
}

describe('AiService', () => {
  it('interprets version risk through the active provider', async () => {
    const callTool = jest.fn().mockResolvedValue({
      toolName: 'submit_risk_interpretation',
      toolInput: {
        summary: '预计延期',
        why: ['P1 Bug 增加'],
        forecast: '预计 2026-07-04 可发',
        recommendedReleaseWindow: '2026-07-04 之后',
        suggestedActions: ['优先修复 P1 Bug'],
        ownerHints: ['测试负责人同步失败用例'],
        generatedAt: '2026-06-29T01:00:00.000Z',
      },
      rawModel: 'test-model',
      inputTokens: 10,
      outputTokens: 20,
    });
    const gateway = {
      getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
      getActiveModel: jest.fn().mockResolvedValue('test-model'),
    } as unknown as AiGatewayService;
    const service = new AiService(gateway);

    const result = await service.interpretRisk({
      versionId: 'ver-1',
      versionName: 'V1.0',
      riskScore: 70,
      riskLevel: 'at_risk',
      delayDays: 1,
      confidence: 80,
      signals: {
        unfinishedCount: 4,
        openBugCount: 3,
        criticalBugCount: 3,
        failedTestCount: 1,
        blockedCount: 0,
        silentRiskCount: 0,
        daysToExpectedRelease: 1,
      },
      trendSummary: '风险上升',
      reasons: [{ key: 'critical_bug', label: '关键 Bug', severity: 'critical', detail: '3 个 P1 Bug' }],
      silentRisks: [],
      dailyEvidence: {
        todayDeliveries: [],
        todayProgress: [],
        todayCreations: [],
        todayRisks: ['测试不通过'],
        progressNotes: [],
        needsProgressItems: [],
        recentActivityCount: 1,
        totalActivityCount: 1,
        todayActualHours: 2,
      },
    });

    expect(result.ok).toBe(true);
    if (result.ok) expect(result.result.summary).toBe('预计延期');
    expect(callTool).toHaveBeenCalledWith(expect.objectContaining({
      forceTool: true,
      maxTokens: 3000,
      tool: expect.objectContaining({ name: 'submit_risk_interpretation' }),
    }));
    expect(callTool.mock.calls[0][0].userPrompt).toContain('风险上升');
  });

  it('removes invalid page refresh suggestions from risk interpretation results', async () => {
    const callTool = jest.fn().mockResolvedValue({
      toolName: 'submit_risk_interpretation',
      toolInput: {
        summary: '预计延期',
        why: ['P1 Bug 增加'],
        forecast: '预计 2026-07-04 可发',
        suggestedActions: [
          '手动刷新小宝预警页面，等待风险自动更新',
          '优先修复 P1 Bug 并安排测试复测',
        ],
        ownerHints: ['测试负责人同步失败用例'],
        generatedAt: '2026-06-29T01:00:00.000Z',
      },
      rawModel: 'test-model',
      inputTokens: 10,
      outputTokens: 20,
    });
    const gateway = {
      getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
      getActiveModel: jest.fn().mockResolvedValue('test-model'),
    } as unknown as AiGatewayService;
    const service = new AiService(gateway);

    const result = await service.interpretRisk(createRiskRequest());

    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.result.suggestedActions).toEqual(['优先修复 P1 Bug 并安排测试复测']);
    }
  });

  it('returns PARSE_ERROR when risk provider returns malformed payload', async () => {
    const gateway = {
      getActiveProvider: jest.fn().mockResolvedValue({
        callTool: jest.fn().mockResolvedValue({
          toolName: 'submit_risk_interpretation',
          toolInput: null,
          rawModel: 'test-model',
          inputTokens: 10,
          outputTokens: 20,
        }),
      }),
      getActiveModel: jest.fn().mockResolvedValue('test-model'),
    } as unknown as AiGatewayService;
    const service = new AiService(gateway);

    const result = await service.interpretRisk(createRiskRequest());

    expect(result).toMatchObject({ ok: false, code: 'PARSE_ERROR' });
  });

  it('returns PARSE_ERROR when risk provider returns the wrong tool name', async () => {
    const gateway = {
      getActiveProvider: jest.fn().mockResolvedValue({
        callTool: jest.fn().mockResolvedValue({
          toolName: 'submit_decompose',
          toolInput: {
            summary: 'wrong tool',
            why: ['bad'],
            forecast: 'bad',
            suggestedActions: ['bad'],
            ownerHints: ['bad'],
            generatedAt: '2026-06-29T01:00:00.000Z',
          },
          rawModel: 'test-model',
          inputTokens: 10,
          outputTokens: 20,
        }),
      }),
      getActiveModel: jest.fn().mockResolvedValue('test-model'),
    } as unknown as AiGatewayService;
    const service = new AiService(gateway);

    const result = await service.interpretRisk(createRiskRequest());

    expect(result).toMatchObject({ ok: false, code: 'PARSE_ERROR' });
  });

  it('returns PARSE_ERROR when risk insight arrays contain non-string values', async () => {
    const gateway = {
      getActiveProvider: jest.fn().mockResolvedValue({
        callTool: jest.fn().mockResolvedValue({
          toolName: 'submit_risk_interpretation',
          toolInput: {
            summary: 'bad array',
            why: [123],
            forecast: 'bad',
            suggestedActions: ['bad'],
            ownerHints: ['bad'],
            generatedAt: '2026-06-29T01:00:00.000Z',
          },
          rawModel: 'test-model',
          inputTokens: 10,
          outputTokens: 20,
        }),
      }),
      getActiveModel: jest.fn().mockResolvedValue('test-model'),
    } as unknown as AiGatewayService;
    const service = new AiService(gateway);

    const result = await service.interpretRisk(createRiskRequest());

    expect(result).toMatchObject({ ok: false, code: 'PARSE_ERROR' });
  });

  it('validates risk daily evidence array items as strings', () => {
    const dto = plainToInstance(RiskInterpretDto, {
      ...createRiskRequest(),
      dailyEvidence: {
        todayDeliveries: [],
        todayProgress: [],
        todayCreations: [],
        todayRisks: [123],
        progressNotes: [],
        needsProgressItems: [],
        recentActivityCount: 1,
        totalActivityCount: 1,
        todayActualHours: 2,
      },
    });

    const errors = validateSync(dto);

    expect(JSON.stringify(errors)).toContain('todayRisks');
  });

  it('requires risk signals and daily evidence objects in DTO validation', () => {
    const { signals: _signals, dailyEvidence: _dailyEvidence, ...payload } = createRiskRequest();
    const dto = plainToInstance(RiskInterpretDto, payload);

    const errors = validateSync(dto);
    const serialized = JSON.stringify(errors);

    expect(serialized).toContain('signals');
    expect(serialized).toContain('dailyEvidence');
  });

  it('forbids extra fields in risk interpretation tool schema', () => {
    expect((RISK_INTERPRET_TOOL_INPUT_SCHEMA as any).additionalProperties).toBe(false);
  });

  it('tells the risk model not to suggest page refresh or manual update triggers', () => {
    expect(RISK_INTERPRET_SYSTEM_PROMPT).toContain('不要建议刷新页面');
    expect(RISK_INTERPRET_SYSTEM_PROMPT).toContain('系统会自动更新');
  });

  it('returns PARSE_ERROR when provider reports invalid tool arguments', async () => {
    const gateway = {
      getActiveProvider: jest.fn().mockResolvedValue({
        callTool: jest.fn().mockRejectedValue(new Error('OpenAI tool_call arguments is not valid JSON')),
      }),
      getActiveModel: jest.fn().mockResolvedValue('test-model'),
    } as unknown as AiGatewayService;
    const service = new AiService(gateway);

    const result = await service.interpretRisk(createRiskRequest());

    expect(result).toMatchObject({ ok: false, code: 'PARSE_ERROR' });
  });

  it('retries once with shorter prototype context when Anthropic returns no tool_use', async () => {
    const callTool = jest
      .fn()
      .mockRejectedValueOnce(new Error(missingToolUseMessage))
      .mockResolvedValueOnce({
        toolName: 'submit_decompose',
        toolInput: {
          report: { matched: [], reqOnly: ['req-1'], prototypeOnly: [], ambiguous: [] },
          devTaskDrafts: [],
          testCaseDrafts: [],
        },
        inputTokens: 100,
        outputTokens: 20,
        rawModel: 'claude-test',
      });

    const gateway = {
      getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
      getActiveModel: jest.fn().mockResolvedValue('claude-test'),
    } as unknown as AiGatewayService;

    const service = new AiService(gateway);
    (service as any).fetchPrototype = jest.fn().mockResolvedValue(
      `${'无关内容'.repeat(5000)} QY0001：手机号登录。${'噪声内容'.repeat(5000)}`,
    );

    const result = await service.decompose({
      prototypeUrl: 'https://example.com/prototype',
      requirements: [{ id: 'req-1', code: 'REQ001', title: '手机号登录' }],
      members: [{ name: '张三', role: 'frontend' }],
      versionId: 'version-1',
      planId: 'plan-1',
    });

    expect(result.ok).toBe(true);
    expect(callTool).toHaveBeenCalledTimes(2);
    const firstPrompt = callTool.mock.calls[0][0].userPrompt;
    const retryPrompt = callTool.mock.calls[1][0].userPrompt;
    expect(retryPrompt.length).toBeLessThan(firstPrompt.length);
    expect(retryPrompt).toContain('重试');
  });

  it('keeps shrinking prototype context when the first retry still returns no tool_use', async () => {
    const callTool = jest
      .fn()
      .mockRejectedValueOnce(new Error(missingToolUseMessage))
      .mockRejectedValueOnce(new Error(missingToolUseMessage))
      .mockResolvedValueOnce({
        toolName: 'submit_decompose',
        toolInput: {
          report: { matched: [], reqOnly: ['req-1'], prototypeOnly: [], ambiguous: [] },
          devTaskDrafts: [],
          testCaseDrafts: [],
        },
        inputTokens: 100,
        outputTokens: 20,
        rawModel: 'claude-test',
      });

    const gateway = {
      getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
      getActiveModel: jest.fn().mockResolvedValue('claude-test'),
    } as unknown as AiGatewayService;

    const service = new AiService(gateway);
    (service as any).fetchPrototype = jest.fn().mockResolvedValue(
      Array.from({ length: 30 }, (_, i) => `QY${String(i + 1).padStart(4, '0')}：${'内容'.repeat(300)}`).join('\n'),
    );

    const result = await service.decompose({
      prototypeUrl: 'https://example.com/prototype',
      requirements: [{ id: 'req-1', code: 'REQ001', title: '手机号登录' }],
      members: [{ name: '张三', role: 'frontend' }],
      versionId: 'version-1',
      planId: 'plan-1',
    });

    expect(result.ok).toBe(true);
    expect(callTool).toHaveBeenCalledTimes(3);
    const firstPrompt = callTool.mock.calls[0][0].userPrompt;
    const secondPrompt = callTool.mock.calls[1][0].userPrompt;
    const thirdPrompt = callTool.mock.calls[2][0].userPrompt;
    expect(secondPrompt.length).toBeLessThan(firstPrompt.length);
    expect(thirdPrompt.length).toBeLessThan(secondPrompt.length);
  });

  it('retries prototype fetch before returning fetch failure', async () => {
    const originalFetch = globalThis.fetch;
    const fetchMock = jest
      .fn()
      .mockRejectedValueOnce(new Error('fetch failed'))
      .mockResolvedValueOnce({
        ok: true,
        text: async () => '<html><body>QY0001：手机号登录</body></html>',
      });
    (globalThis as any).fetch = fetchMock;

    const callTool = jest.fn().mockResolvedValue({
      toolName: 'submit_decompose',
      toolInput: {
        report: { matched: [], reqOnly: ['req-1'], prototypeOnly: [], ambiguous: [] },
        devTaskDrafts: [],
        testCaseDrafts: [],
      },
      inputTokens: 100,
      outputTokens: 20,
      rawModel: 'claude-test',
    });

    const gateway = {
      getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
      getActiveModel: jest.fn().mockResolvedValue('claude-test'),
    } as unknown as AiGatewayService;

    const service = new AiService(gateway);

    try {
      const result = await service.decompose({
        prototypeUrl: 'https://example.com/prototype',
        requirements: [{ id: 'req-1', code: 'REQ001', title: '手机登录' }],
        members: [{ name: '张三', role: 'frontend' }],
        versionId: 'version-1',
        planId: 'plan-1',
      });

      expect(result.ok).toBe(true);
      expect(fetchMock).toHaveBeenCalledTimes(2);
      expect(callTool).toHaveBeenCalledTimes(1);
    } finally {
      (globalThis as any).fetch = originalFetch;
    }
  });

  it('requires free-form taskTypeName without requiring categoryCode for drafts', () => {
    const devRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items.required;
    const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required;

    expect(devRequired).toContain('taskTypeName');
    expect(testCaseRequired).toContain('taskTypeName');
    expect(devRequired).not.toContain('categoryCode');
    expect(testCaseRequired).not.toContain('categoryCode');
  });

  it('does not constrain AI decomposition to the manual task category dictionary', () => {
    const devCategoryCodeSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items.properties
      .categoryCode;
    const testCaseCategoryCodeSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.properties
      .categoryCode;

    expect(devCategoryCodeSchema).toEqual({ type: 'string' });
    expect(testCaseCategoryCodeSchema).toEqual({ type: 'string' });
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('taskTypeName');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('taskTypeName 是任务类型字典项，不是任务标题或任务概述');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('必须可复用');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('categoryCode 只作为可选映射');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('不得因为任务类型字典没有覆盖就不拆解');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('采纳时自动追加到任务类型字典');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('测试用例建议类型');
  });

  it('allows optional recommended assignee fields without requiring adoption', () => {
    const devSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items;
    const testCaseSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items;

    expect(devSchema.properties.recommendedAssigneeName).toEqual({ type: 'string' });
    expect(devSchema.properties.recommendedAssigneeReason).toEqual({ type: 'string' });
    expect(testCaseSchema.properties.recommendedAssigneeName).toEqual({ type: 'string' });
    expect(testCaseSchema.properties.recommendedAssigneeReason).toEqual({ type: 'string' });
    expect(devSchema.required).not.toContain('recommendedAssigneeName');
    expect(devSchema.required).not.toContain('recommendedAssigneeReason');
    expect(testCaseSchema.required).not.toContain('recommendedAssigneeName');
    expect(testCaseSchema.required).not.toContain('recommendedAssigneeReason');
  });

  it('tells the model to decompose clear prototype-only notes into no-requirement groups', () => {
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('prototypeOnly');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('无需求ID');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('requirementName');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('只有无法形成稳定任务/用例的含糊批注才进入 ambiguous');
    expect(DECOMPOSE_SYSTEM_PROMPT).not.toContain('仅 QY 命中、需求未提的批注，不拆任务');
    expect(DECOMPOSE_SYSTEM_PROMPT).not.toContain('noteOnly');
  });

  it('allows requirementName on drafts and reports prototypeOnly groups in the tool schema', () => {
    const reportSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.report as any);
    const devSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items;
    const testCaseSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items;

    expect(reportSchema.properties.prototypeOnly).toBeDefined();
    expect(reportSchema.required).toContain('prototypeOnly');
    expect(reportSchema.required).not.toContain('noteOnly');
    expect(devSchema.properties.requirementName).toEqual({ type: 'string' });
    expect(testCaseSchema.properties.requirementName).toEqual({ type: 'string' });
  });

  it('tells the model to recommend assignees only from version member names', () => {
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('recommendedAssigneeName');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('members[].name');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('omit recommendedAssigneeName');
  });

  it('requires aiEstimateHours for dev task and test case drafts', () => {
    const devRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items.required;
    const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required;

    expect(devRequired).toContain('aiEstimateHours');
    expect(devRequired).not.toContain('estimateHours');
    expect(testCaseRequired).toContain('aiEstimateHours');
    expect(testCaseRequired).not.toContain('estimateHours');
  });

  it('requires structured descriptions for dev task and test case drafts', () => {
    const devRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items.required;
    const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required;

    expect(devRequired).toContain('description');
    expect(testCaseRequired).toContain('description');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('实现范围');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('验收点');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('前置条件');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('操作步骤');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('预期结果');
  });

  it('tells the model to build deliverable slices before drafting tasks and tests', () => {
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('交付切片');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('UI/交互/接口/数据/异常/边界/状态流转/兼容/回归');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('每条业务规则');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('至少映射到 1 条开发任务或 1 条测试用例');
  });

  it('forbids classifying clear prototype notes as ambiguous', () => {
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('包含标题、详细说明、字段、异常或验收标准之一');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('不得进入 ambiguous');
    expect(DECOMPOSE_SYSTEM_PROMPT).toContain('QY0005/QY0019/QY0020');
  });

  it('passes decomposition target into the model prompt', async () => {
    const callTool = jest.fn().mockResolvedValue({
      toolName: 'submit_decompose',
      toolInput: {
        report: { matched: [], reqOnly: ['req-1'], prototypeOnly: [], ambiguous: [] },
        devTaskDrafts: [],
        testCaseDrafts: [],
      },
      inputTokens: 100,
      outputTokens: 20,
      rawModel: 'claude-test',
    });

    const gateway = {
      getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
      getActiveModel: jest.fn().mockResolvedValue('claude-test'),
    } as unknown as AiGatewayService;

    const service = new AiService(gateway);
    (service as any).fetchPrototype = jest.fn().mockResolvedValue('QY0001：手机号登录');

    await service.decompose({
      prototypeUrl: 'https://example.com/prototype',
      requirements: [{ id: 'req-1', code: 'REQ001', title: '手机登录' }],
      members: [{ name: '张三', role: 'frontend' }],
      versionId: 'version-1',
      planId: 'plan-1',
      target: 'dev_tasks',
    });

    const userPrompt = callTool.mock.calls[0][0].userPrompt;
    expect(userPrompt).toContain('本次只重新拆解开发任务');
    expect(userPrompt).toContain('testCaseDrafts 必须返回空数组');
  });

  it('repeats the no-requirement group rule in the decomposition user prompt', async () => {
    const callTool = jest.fn().mockResolvedValue({
      toolName: 'submit_decompose',
      toolInput: {
        report: { matched: [], reqOnly: [], prototypeOnly: [], ambiguous: [] },
        devTaskDrafts: [],
        testCaseDrafts: [],
      },
      inputTokens: 100,
      outputTokens: 20,
      rawModel: 'claude-test',
    });

    const gateway = {
      getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
      getActiveModel: jest.fn().mockResolvedValue('claude-test'),
    } as unknown as AiGatewayService;

    const service = new AiService(gateway);
    (service as any).fetchPrototype = jest.fn().mockResolvedValue('QY0020：预入职人员支持，可搜索、选择预入职人员并成功创建案例。');

    await service.decompose({
      prototypeUrl: 'https://example.com/prototype',
      requirements: [{ id: 'req-1', code: 'REQ001', title: '案例分享基础功能' }],
      members: [{ name: '张三', role: 'frontend' }],
      versionId: 'version-1',
      planId: 'plan-1',
    });

    const userPrompt = callTool.mock.calls[0][0].userPrompt;
    expect(userPrompt).toContain('无需求ID');
    expect(userPrompt).toContain('prototypeOnly');
    expect(userPrompt).not.toContain('才放入 noteOnly 或 ambiguous');
  });

  it('tells the model to match prototype notes by requirement code before summary semantics', async () => {
    const callTool = jest.fn().mockResolvedValue({
      toolName: 'submit_decompose',
      toolInput: {
        report: { matched: [], reqOnly: ['req-1'], prototypeOnly: [], ambiguous: [] },
        devTaskDrafts: [],
        testCaseDrafts: [],
      },
      inputTokens: 100,
      outputTokens: 20,
      rawModel: 'claude-test',
    });

    const gateway = {
      getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
      getActiveModel: jest.fn().mockResolvedValue('claude-test'),
    } as unknown as AiGatewayService;

    const service = new AiService(gateway);
    (service as any).fetchPrototype = jest.fn().mockResolvedValue('REA-20260629001：客户批量导入');

    await service.decompose({
      prototypeUrl: 'https://example.com/prototype',
      requirements: [
        {
          id: 'req-1',
          code: 'REA-20260629001',
          title: '客户批量导入',
          description: '上传 Excel 后生成客户档案',
        },
      ],
      members: [{ name: '张三', role: 'frontend' }],
      versionId: 'version-1',
      planId: 'plan-1',
      target: 'dev_tasks',
    });

    const userPrompt = callTool.mock.calls[0][0].userPrompt;
    expect(userPrompt).toContain('优先按需求编号');
    expect(userPrompt).toContain('需求概述');
  });
});
