jest.mock('uuid', () => ({
  v4: jest.fn(() => 'mock-uuid-v4'),
  v7: jest.fn(() => 'mock-uuid-v7'),
}));

jest.mock('../../../../src/modules/netaclaw/runtime/process_events.js', () => {
  const actual = jest.requireActual('../../../../src/modules/netaclaw/runtime/process_events.js');
  return {
    ...actual,
    appendRuntimeProcessLog: jest.fn().mockReturnValue({
      kind: 'runtime_process_log',
      sessionId: 'mock-session',
      operationId: 'mock-op',
    }),
  };
});

jest.mock('../../../../src/modules/netaclaw/runtime/model_selection.js', () => ({
  initDefaultProviders: jest.fn(),
}));

import { NetaClawAgentExecutorService } from '../../../../src/modules/netaclaw/service/agent_executor.js';
import type { AgentRunParams } from '../../../../src/modules/netaclaw/runtime/agent.js';
import { GroupInteractionDeclineError } from '../../../../src/modules/netaclaw/runtime/errors.js';

type RunnerCallbacks = AgentRunParams;

interface CapturedRunner {
  fn: (params: any) => Promise<any>;
  lastResult?: any;
}

function buildExecutor(opts: {
  agentRunnerImpl: (params: RunnerCallbacks) => Promise<any>;
  capturedRunner?: CapturedRunner;
  agentEntity?: any;
}): NetaClawAgentExecutorService {
  const service = new NetaClawAgentExecutorService();

  service.agentService = {
    info: jest.fn().mockResolvedValue(opts.agentEntity ?? null),
    agentRepo: {
      findOneBy: jest.fn().mockResolvedValue(opts.agentEntity ?? null),
    },
  } as any;

  service.skillLoader = {} as any;
  service.channelService = {
    resolveForAgent: jest.fn(),
  } as any;
  service.toolResolver = {
    resolve: jest.fn().mockResolvedValue({
      tools: [],
      toolNames: ['bash', 'memory_save'],
      toolRuntimeRoutes: [
        {
          name: 'bash',
          runtimeRoute: 'worker-local',
          workerRoutingHint: 'local',
          blockedReason: null,
          policySources: ['toolManifest'],
        },
        {
          name: 'memory_save',
          runtimeRoute: 'main-process-proxy',
          workerRoutingHint: 'main-process-proxy',
          blockedReason: null,
          policySources: ['agent', 'toolManifest'],
        },
      ],
    }),
  } as any;
  service.subagentService = {} as any;

  service.sessionService = {
    getRuntimeSnapshot: jest.fn().mockResolvedValue({
      session: { cwd: null },
      runtimeContext: { messages: [] },
    }),
  } as any;

  service.chatOrchestrator = {
    executeSessionTreeChat: jest.fn().mockImplementation(async (params: any) => {
      if (opts.capturedRunner) {
        opts.capturedRunner.fn = params.runner;
      }
      const runResult = await params.runner({
        sessionId: 'session-x',
        userEntryId: 'user-entry-x',
        assistantEntryId: 'assistant-entry-x',
        userMessage: params.message,
        history: [],
        subagents: {
          sessionId: 'session-x',
          parentMessageId: 0,
          batches: [],
          onBatchStart: jest.fn(),
          onUpdate: jest.fn(),
          onDone: jest.fn(),
        },
        onAssistantDelta: jest.fn(),
      });
      if (opts.capturedRunner) {
        opts.capturedRunner.lastResult = runResult;
      }
      return {
        sessionId: 'session-x',
        finalContent: runResult.finalContent,
        content: runResult.finalContent,
        usage: runResult.usage,
        toolCallCount: runResult.toolCallCount ?? 0,
        metadata: runResult.metadata,
        assistantEntryId: 'assistant-entry-x',
        subagentBatches: [],
      };
    }),
  } as any;

  service.memoryRepo = {} as any;
  service.memoryTypeRepo = {} as any;
  service.subagentDefaults = {} as any;

  (service as any).agentRunner = jest.fn().mockImplementation(opts.agentRunnerImpl);

  return service;
}

describe('NetaClawAgentExecutorService runner pipeline', () => {
  it('aggregates skillExecutions and tokenUsage in metadata', async () => {
    const agentRunnerImpl = async (cb: RunnerCallbacks) => {
      // 调用第一轮工具：bash echo
      const beforeRes1 = await cb.beforeToolCall!({
        toolCallId: 'call_1',
        name: 'bash',
        label: 'Bash',
        args: { command: 'echo hi' },
        messages: [],
      });
      expect(beforeRes1).not.toMatchObject({ block: true });
      const runtimeArgs1 = (beforeRes1 as any).args;

      cb.onToolProcessEvent!({
        toolCallId: 'call_1',
        operationId: 'tool-call_1',
        toolName: 'bash',
        args: runtimeArgs1,
        event: {
          version: 1,
          operationId: 'tool-call_1',
          targetType: 'tool',
          source: 'runtime',
          status: 'started',
          level: 'info',
          message: 'starting bash',
          timestamp: new Date().toISOString(),
        } as any,
      });

      cb.onToolProcessEvent!({
        toolCallId: 'call_1',
        operationId: 'tool-call_1',
        toolName: 'bash',
        args: runtimeArgs1,
        event: {
          version: 1,
          operationId: 'tool-call_1',
          targetType: 'tool',
          source: 'runtime',
          status: 'completed',
          level: 'info',
          message: 'bash done',
          timestamp: new Date().toISOString(),
        } as any,
      });

      cb.onToolCall!('bash', runtimeArgs1, 'call_1');
      await cb.afterToolCall!({
        toolCallId: 'call_1',
        name: 'bash',
        label: 'Bash',
        args: runtimeArgs1,
        result: 'hi',
        rawResult: { type: 'text', text: 'hi' } as any,
        isError: false,
        messages: [],
      });
      cb.onToolResult!('bash', 'hi', 'call_1', { type: 'text', text: 'hi' } as any);

      // 第二轮：memory_save
      const beforeRes2 = await cb.beforeToolCall!({
        toolCallId: 'call_2',
        name: 'memory_save',
        label: '记忆保存',
        args: { content: 'note' },
        messages: [],
      });
      const runtimeArgs2 = (beforeRes2 as any).args;
      cb.onToolCall!('memory_save', runtimeArgs2, 'call_2');
      await cb.afterToolCall!({
        toolCallId: 'call_2',
        name: 'memory_save',
        label: '记忆保存',
        args: runtimeArgs2,
        result: 'saved',
        rawResult: 'saved',
        isError: false,
        messages: [],
      });

      cb.onThinkingDelta!('我在分析');

      return {
        messages: [],
        finalContent: '完成',
        usage: { inputTokens: 100, outputTokens: 30 },
        toolCallCount: 2,
      };
    };

    const captured: CapturedRunner = { fn: async () => undefined };
    const service = buildExecutor({ agentRunnerImpl, capturedRunner: captured });
    const onToolCall = jest.fn();
    const onToolResult = jest.fn();

    await service.execute({
      message: '测试',
      agentId: undefined,
      agentName: 'fallback',
      onToolCall,
      onToolResult,
    });

    const metadata = captured.lastResult?.metadata as any;
    expect(metadata).toBeDefined();
    expect(metadata.skillExecutions).toHaveLength(2);

    const exec1 = metadata.skillExecutions[0];
    expect(exec1.name).toBe('bash');
    expect(exec1.status).toBe('done');
    expect(exec1.toolCallId).toBe('call_1');
    // routeMeta 注入到 toolExecutions 但不会进入 metadata —— skillExecutions 字段集与 server.ts 对齐：
    expect(Object.keys(exec1)).toEqual(
      expect.arrayContaining([
        'name', 'label', 'status', 'args', 'result', 'rawResult', 'toolCallId',
        'latestProcessEvent', 'processEvents', 'processLogRef',
      ])
    );
    expect(exec1.processEvents).toHaveLength(2);
    expect(exec1.latestProcessEvent.message).toBe('bash done');
    expect(exec1.rawResult).toEqual({ type: 'text', text: 'hi' });

    const exec2 = metadata.skillExecutions[1];
    expect(exec2.name).toBe('memory_save');
    expect(exec2.status).toBe('done');
    expect(exec2.rawResult).toBeUndefined(); // string rawResult 不会被结构化

    expect(onToolCall).toHaveBeenCalledWith(
      'bash',
      expect.objectContaining({ command: 'echo hi' }),
      'call_1'
    );
    expect(onToolResult).toHaveBeenCalledWith(
      'bash',
      'hi',
      'call_1',
      { type: 'text', text: 'hi' }
    );

    expect(metadata.tokenUsage).toEqual({
      total: { input: 100, output: 30, total: 130 },
    });

    // thinking 兜底：runResult 没带 thinking 时取本地累积
    expect(captured.lastResult?.thinking).toBe('我在分析');
  });

  it('blocks risky bash when no onRiskConfirmRequest is provided', async () => {
    const agentRunnerImpl = async (cb: RunnerCallbacks) => {
      const before = await cb.beforeToolCall!({
        toolCallId: 'call_risk',
        name: 'bash',
        label: 'Bash',
        args: { command: 'rm -rf /tmp/foo' },
        messages: [],
      });
      expect((before as any).block).toBe(true);
      expect((before as any).reason).toMatch(/未配置/);
      return {
        messages: [],
        finalContent: 'blocked',
        usage: { inputTokens: 0, outputTokens: 0 },
        toolCallCount: 0,
      };
    };
    const service = buildExecutor({ agentRunnerImpl });
    await service.execute({ message: '危险', agentName: 'fallback' });
  });

  it('passes through risky tool when user confirms', async () => {
    const onRiskConfirmRequest = jest.fn().mockResolvedValue(true);
    const agentRunnerImpl = async (cb: RunnerCallbacks) => {
      const before = await cb.beforeToolCall!({
        toolCallId: 'call_risk',
        name: 'bash',
        label: 'Bash',
        args: { command: 'rm -rf /tmp/foo' },
        messages: [],
      });
      expect((before as any).block).toBeUndefined();
      expect((before as any).args).toEqual({ command: 'rm -rf /tmp/foo' });
      return {
        messages: [],
        finalContent: 'ok',
        usage: { inputTokens: 0, outputTokens: 0 },
        toolCallCount: 0,
      };
    };
    const service = buildExecutor({ agentRunnerImpl });
    await service.execute({
      message: '确认执行',
      agentName: 'fallback',
      onRiskConfirmRequest,
    });
    expect(onRiskConfirmRequest).toHaveBeenCalledTimes(1);
    const call = onRiskConfirmRequest.mock.calls[0][0];
    expect(call.toolName).toBe('bash');
    expect(call.riskReason).toMatch(/删除|强制|rm/);
  });

  it('blocks risky tool when user declines', async () => {
    const onRiskConfirmRequest = jest.fn().mockResolvedValue(false);
    const agentRunnerImpl = async (cb: RunnerCallbacks) => {
      const before = await cb.beforeToolCall!({
        toolCallId: 'call_risk',
        name: 'bash',
        label: 'Bash',
        args: { command: 'rm -rf /tmp/foo' },
        messages: [],
      });
      expect((before as any).block).toBe(true);
      expect((before as any).reason).toMatch(/拒绝/);
      return {
        messages: [],
        finalContent: 'blocked',
        usage: { inputTokens: 0, outputTokens: 0 },
        toolCallCount: 0,
      };
    };
    const service = buildExecutor({ agentRunnerImpl });
    await service.execute({
      message: '拒绝',
      agentName: 'fallback',
      onRiskConfirmRequest,
    });
    expect(onRiskConfirmRequest).toHaveBeenCalledTimes(1);
  });

  it('marks running tools as error and decorates runtimeMetadata when runner throws', async () => {
    const agentRunnerImpl = async (cb: RunnerCallbacks) => {
      await cb.beforeToolCall!({
        toolCallId: 'call_will_fail',
        name: 'bash',
        label: 'Bash',
        args: { command: 'echo hi' },
        messages: [],
      });
      const err: any = new Error('boom');
      throw err;
    };
    const service = buildExecutor({ agentRunnerImpl });
    await expect(service.execute({ message: '炸', agentName: 'fallback' })).rejects.toMatchObject({
      message: 'boom',
      runtimeMetadata: expect.objectContaining({
        skillExecutions: expect.arrayContaining([
          expect.objectContaining({ name: 'bash', status: 'error' }),
        ]),
      }),
    });
  });

  it('injects weixin group bizContext into prompt and tool runtime args', async () => {
    const agentRunnerImpl = async (cb: RunnerCallbacks) => {
      expect(cb.agentConfig.systemPrompt).toContain('当前消息来自微信群');
      expect(cb.agentConfig.systemPrompt).toContain('公司技术内部群');
      expect(cb.agentConfig.systemPrompt).toContain('channelId=6');

      const before = await cb.beforeToolCall!({
        toolCallId: 'call_wx',
        name: 'weixin_send_text',
        label: '微信发送文字',
        args: { roomName: '公司技术内部群', text: '测试' },
        messages: [],
      });
      expect((before as any).args._netaRuntime.bizContext).toMatchObject({
        channelId: 6,
        roomName: '公司技术内部群',
      });
      return {
        messages: [],
        finalContent: 'ok',
        usage: { inputTokens: 0, outputTokens: 0 },
        toolCallCount: 1,
      };
    };
    const service = buildExecutor({ agentRunnerImpl });
    await service.execute({
      message: '查看成都天气',
      agentName: 'fallback',
      chatScope: 'group',
      bizContext: {
        channelId: 6,
        roomName: '公司技术内部群',
        roomId: '6:room:47501718909@chatroom',
      },
    } as any);
  });

  it('returns toolExecutions at top level for channel send-action guards', async () => {
    const agentRunnerImpl = async (cb: RunnerCallbacks) => {
      const before = await cb.beforeToolCall!({
        toolCallId: 'call_wx',
        name: 'weixin_send_text',
        label: '微信发送文字',
        args: { roomName: '公司技术内部群', text: '测试' },
        messages: [],
      });
      cb.afterToolCall?.({
        toolCallId: 'call_wx',
        name: 'weixin_send_text',
        label: '微信发送文字',
        args: { roomName: '公司技术内部群', text: '测试' },
        result: 'ok',
        rawResult: 'ok',
        isError: false,
        messages: [],
      });
      expect((before as any).args._netaRuntime.bizContext.channelId).toBe(6);
      return {
        messages: [],
        finalContent: 'ok',
        usage: { inputTokens: 0, outputTokens: 0 },
        toolCallCount: 1,
      };
    };
    const service = buildExecutor({ agentRunnerImpl });
    const result = await service.execute({
      message: '随便回复点什么',
      agentName: 'fallback',
      chatScope: 'group',
      bizContext: {
        channelId: 6,
        roomName: '公司技术内部群',
        roomId: '6:room:47501718909@chatroom',
      },
    } as any);
    expect((result as any).toolExecutions).toEqual([
      expect.objectContaining({
        name: 'weixin_send_text',
        status: 'done',
      }),
    ]);
  });
});

describe('chatScope=group risk short-circuit', () => {
  it('throws GroupInteractionDeclineError from beforeToolCall and runner returns decline as finalContent', async () => {
    const onRiskConfirmRequest = jest.fn();
    const agentRunnerImpl = async (cb: any) => {
      const before = await cb.beforeToolCall({
        toolCallId: 'call_risk',
        name: 'bash',
        label: 'Bash',
        args: { command: 'rm -rf /tmp/foo' },
        messages: [],
      });
      throw new Error('should have thrown, got ' + JSON.stringify(before));
    };
    const captured: CapturedRunner = { fn: async () => undefined };
    const service = buildExecutor({ agentRunnerImpl, capturedRunner: captured });
    await service.execute({
      message: '群内危险',
      agentName: 'fallback',
      chatScope: 'group',
      onRiskConfirmRequest,
    });
    const result = captured.lastResult;
    expect(result?.finalContent).toEqual(expect.stringContaining('群内禁止高风险操作'));
    expect(result?.finalContent).toEqual(expect.stringContaining('私聊 bot'));
    expect(onRiskConfirmRequest).not.toHaveBeenCalled();
    const metadata = result?.metadata as any;
    expect(metadata?.skillExecutions).toHaveLength(1);
    expect(metadata.skillExecutions[0].name).toBe('bash');
    expect(metadata.skillExecutions[0].status).toBe('error');
  });

  it('dm path still uses onRiskConfirmRequest (unchanged)', async () => {
    const onRiskConfirmRequest = jest.fn().mockResolvedValue(false);
    const agentRunnerImpl = async (cb: any) => {
      const before = await cb.beforeToolCall({
        toolCallId: 'call_risk',
        name: 'bash',
        label: 'Bash',
        args: { command: 'rm -rf /tmp/foo' },
        messages: [],
      });
      expect((before as any).block).toBe(true);
      return {
        messages: [],
        finalContent: 'blocked',
        usage: { inputTokens: 0, outputTokens: 0 },
        toolCallCount: 0,
      };
    };
    const service = buildExecutor({ agentRunnerImpl });
    await service.execute({
      message: '私聊危险',
      agentName: 'fallback',
      chatScope: 'dm',
      onRiskConfirmRequest,
    });
    expect(onRiskConfirmRequest).toHaveBeenCalledTimes(1);
  });

  it('propagates GroupInteractionDeclineError thrown from inside agentRunner (e.g. clarify timeout)', async () => {
    const agentRunnerImpl = async () => {
      throw new GroupInteractionDeclineError('⏱️ 等待 5 分钟未收到回复，已自动取消本次询问');
    };
    const captured: CapturedRunner = { fn: async () => undefined };
    const service = buildExecutor({ agentRunnerImpl, capturedRunner: captured });
    await service.execute({ message: 'x', agentName: 'fallback', chatScope: 'group' });
    expect(captured.lastResult?.finalContent).toEqual(expect.stringContaining('5 分钟'));
  });
});
