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

import { NetaClawSessionService } from '../src/modules/netaclaw/gateway/session.js';
import { NetaClawChatOrchestratorService } from '../src/modules/netaclaw/service/chat_orchestrator.js';

describe('NetaClawChatOrchestratorService', () => {
  it('anchors an assistant placeholder before running the agent and updates it with the final content', async () => {
    const sessionService = {
      getOrCreateSession: jest.fn().mockResolvedValue('session-1'),
      loadHistoryWithId: jest.fn().mockResolvedValue([{ id: 101, role: 'user', content: 'hello' }]),
      saveMessage: jest
        .fn()
        .mockResolvedValueOnce({ id: 101, sessionId: 'session-1', role: 'user', content: 'hello' })
        .mockResolvedValueOnce({
          id: 202,
          sessionId: 'session-1',
          role: 'assistant',
          content: '',
          metadata: { inFlight: true },
        }),
      updateMessage: jest.fn().mockResolvedValue(undefined),
    } as unknown as NetaClawSessionService;

    const runner = jest.fn(async params => {
      expect(params.assistantMessageId).toBe(202);
      expect(params.userMessageId).toBe(101);
      expect(params.history).toEqual([]);
      expect(sessionService.saveMessage).toHaveBeenCalledTimes(2);
      expect(sessionService.updateMessage).not.toHaveBeenCalled();

      return {
        finalContent: 'final answer',
        usage: { inputTokens: 11, outputTokens: 7 },
        toolCallCount: 0,
      };
    });

    const service = new NetaClawChatOrchestratorService();
    service.sessionService = sessionService;

    const result = await service.executeChat({
      sessionId: 'session-1',
      requestedAgentName: 'default',
      message: 'hello',
      agentId: 7,
      userId: 'user-1',
      runner,
    });

    expect(sessionService.getOrCreateSession).toHaveBeenCalledWith('session-1', 'default', 7, 'user-1');
    expect(sessionService.saveMessage).toHaveBeenNthCalledWith(1, 'session-1', {
      role: 'user',
      content: 'hello',
    });
    expect(sessionService.saveMessage).toHaveBeenNthCalledWith(2, 'session-1', {
      role: 'assistant',
      content: '',
      metadata: { inFlight: true },
    });
    expect(runner).toHaveBeenCalledWith(
      expect.objectContaining({
        sessionId: 'session-1',
        assistantMessageId: 202,
        userMessageId: 101,
        userMessage: 'hello',
      })
    );
    expect(sessionService.updateMessage).toHaveBeenCalledWith(202, {
      content: 'final answer',
      metadata: { inFlight: false },
    });
    expect(result).toEqual({
      sessionId: 'session-1',
      finalContent: 'final answer',
      content: 'final answer',
      usage: { inputTokens: 11, outputTokens: 7 },
      toolCallCount: 0,
      assistantMessageId: 202,
      subagentBatches: [],
    });
  });

  it('excludes the just-saved user message by exact id even when later history entries exist', async () => {
    const sessionService = {
      getOrCreateSession: jest.fn().mockResolvedValue('session-1'),
      loadHistoryWithId: jest.fn().mockResolvedValue([
        { id: 11, role: 'user', content: 'older user' },
        { id: 101, role: 'user', content: 'current user' },
        { id: 150, role: 'assistant', content: 'later assistant' },
      ]),
      saveMessage: jest
        .fn()
        .mockResolvedValueOnce({ id: 101, sessionId: 'session-1', role: 'user', content: 'current user' })
        .mockResolvedValueOnce({
          id: 202,
          sessionId: 'session-1',
          role: 'assistant',
          content: '',
          metadata: { inFlight: true },
        }),
      updateMessage: jest.fn().mockResolvedValue(undefined),
    } as unknown as NetaClawSessionService;

    const runner = jest.fn().mockResolvedValue({
      finalContent: 'final answer',
      toolCallCount: 0,
    });

    const service = new NetaClawChatOrchestratorService();
    service.sessionService = sessionService;

    await service.executeChat({
      sessionId: 'session-1',
      requestedAgentName: 'default',
      message: 'current user',
      runner,
    });

    expect(runner).toHaveBeenCalledWith(
      expect.objectContaining({
        history: [
          { role: 'user', content: 'older user', toolCalls: undefined, toolCallId: undefined },
          { role: 'assistant', content: 'later assistant', toolCalls: undefined, toolCallId: undefined },
        ],
      })
    );
  });

  it('passes the saved user message id into history preparation callbacks', async () => {
    const sessionService = {
      getOrCreateSession: jest.fn().mockResolvedValue('session-1'),
      loadHistoryWithId: jest.fn().mockResolvedValue([
        { id: 7, role: 'assistant', content: 'older answer' },
        { id: 101, role: 'user', content: 'current user' },
      ]),
      saveMessage: jest
        .fn()
        .mockResolvedValueOnce({ id: 101, sessionId: 'session-1', role: 'user', content: 'current user' })
        .mockResolvedValueOnce({ id: 202, sessionId: 'session-1', role: 'assistant', content: '' }),
      updateMessage: jest.fn().mockResolvedValue(undefined),
    } as unknown as NetaClawSessionService;

    const prepareHistory = jest.fn(async ({ userMessageId, history }) => {
      expect(userMessageId).toBe(101);
      return history;
    });
    const runner = jest.fn().mockResolvedValue({
      finalContent: 'final answer',
      toolCallCount: 0,
    });

    const service = new NetaClawChatOrchestratorService();
    service.sessionService = sessionService;

    await service.executeChat({
      sessionId: 'session-1',
      requestedAgentName: 'default',
      message: 'current user',
      prepareHistory,
      runner,
    });

    expect(prepareHistory).toHaveBeenCalledWith({
      sessionId: 'session-1',
      userMessageId: 101,
      history: [{ role: 'assistant', content: 'older answer', toolCalls: undefined, toolCallId: undefined }],
    });
  });

  it('collects subagent batches for the assistant placeholder and persists them on the same assistant message', async () => {
    const sessionService = {
      getOrCreateSession: jest.fn().mockResolvedValue('session-1'),
      loadHistoryWithId: jest.fn().mockResolvedValue([{ id: 101, role: 'user', content: 'delegate this' }]),
      saveMessage: jest
        .fn()
        .mockResolvedValueOnce({ id: 101, sessionId: 'session-1', role: 'user', content: 'delegate this' })
        .mockResolvedValueOnce({
          id: 202,
          sessionId: 'session-1',
          role: 'assistant',
          content: '',
          metadata: { inFlight: true },
        }),
      updateMessage: jest.fn().mockResolvedValue(undefined),
    } as unknown as NetaClawSessionService;

    const service = new NetaClawChatOrchestratorService();
    service.sessionService = sessionService;

    const result = await service.executeChat({
      sessionId: 'session-1',
      requestedAgentName: 'default',
      message: 'delegate this',
      runner: async params => {
        expect(params.assistantMessageId).toBe(202);
        expect(params.subagents).toEqual(
          expect.objectContaining({
            sessionId: 'session-1',
            parentMessageId: 202,
            batches: [],
          })
        );

        params.subagents.onBatchStart({
          batchId: 'batch-1',
          sessionId: 'session-1',
          parentMessageId: 202,
          mode: 'parallel',
          tasks: [
            { goal: 'task-1', agentName: 'worker-a' },
            { goal: 'task-2', agentName: 'worker-b' },
          ],
        });
        params.subagents.onUpdate({
          batchId: 'batch-1',
          sessionId: 'session-1',
          parentMessageId: 202,
          sequence: 1,
          timestamp: '2026-04-19T00:00:01.000Z',
          event: {
            kind: 'result',
            status: 'completed',
            subagent: {
              id: 501,
              sortOrder: 0,
              name: 'worker-a',
              goal: 'task-1',
              status: 'completed',
              summary: 'done',
              error: null,
            },
          },
        });
        params.subagents.onDone({
          batchId: 'batch-1',
          sessionId: 'session-1',
          parentMessageId: 202,
          results: [
            {
              id: 501,
              sortOrder: 0,
              name: 'worker-a',
              goal: 'task-1',
              status: 'completed',
              summary: 'done',
              error: null,
            },
            {
              id: 502,
              sortOrder: 1,
              name: 'worker-b',
              goal: 'task-2',
              status: 'failed',
              summary: '',
              error: 'boom',
            },
          ],
        });

        return {
          finalContent: 'final answer',
          toolCallCount: 1,
          metadata: {
            existing: true,
          },
        };
      },
    });

    expect(sessionService.updateMessage).toHaveBeenCalledWith(202, {
      content: 'final answer',
      thinking: undefined,
      metadata: expect.objectContaining({
        existing: true,
        inFlight: false,
        subagentBatches: [
          expect.objectContaining({
            batchId: 'batch-1',
            sessionId: 'session-1',
            parentMessageId: 202,
            mode: 'parallel',
            tasks: [
              { goal: 'task-1', agentName: 'worker-a' },
              { goal: 'task-2', agentName: 'worker-b' },
            ],
            status: 'completed',
            results: [
              {
                id: 501,
                sortOrder: 0,
                name: 'worker-a',
                goal: 'task-1',
                status: 'completed',
                summary: 'done',
                error: null,
              },
              {
                id: 502,
                sortOrder: 1,
                name: 'worker-b',
                goal: 'task-2',
                status: 'failed',
                summary: '',
                error: 'boom',
              },
            ],
            metadata: expect.objectContaining({
              events: expect.arrayContaining([
                expect.objectContaining({
                  sequence: 1,
                  timestamp: '2026-04-19T00:00:01.000Z',
                  event: expect.objectContaining({
                    kind: 'result',
                    status: 'completed',
                    subagent: expect.objectContaining({
                      id: 501,
                      summary: 'done',
                    }),
                  }),
                }),
                expect.objectContaining({
                  event: expect.objectContaining({
                    kind: 'status',
                    status: 'completed',
                  }),
                }),
              ]),
              latestEvent: expect.objectContaining({
                event: expect.objectContaining({
                  kind: 'status',
                  status: 'completed',
                }),
              }),
              finalResults: [
                expect.objectContaining({
                  id: 501,
                  summary: 'done',
                }),
                expect.objectContaining({
                  id: 502,
                  error: 'boom',
                }),
              ],
            }),
          }),
        ],
      }),
    });
    expect(result).toEqual({
      sessionId: 'session-1',
      finalContent: 'final answer',
      content: 'final answer',
      usage: undefined,
      toolCallCount: 1,
      assistantMessageId: 202,
      subagentBatches: [
        expect.objectContaining({
          batchId: 'batch-1',
          sessionId: 'session-1',
          parentMessageId: 202,
          mode: 'parallel',
          tasks: [
            { goal: 'task-1', agentName: 'worker-a' },
            { goal: 'task-2', agentName: 'worker-b' },
          ],
          status: 'completed',
          results: [
            {
              id: 501,
              sortOrder: 0,
              name: 'worker-a',
              goal: 'task-1',
              status: 'completed',
              summary: 'done',
              error: null,
            },
            {
              id: 502,
              sortOrder: 1,
              name: 'worker-b',
              goal: 'task-2',
              status: 'failed',
              summary: '',
              error: 'boom',
            },
          ],
          metadata: expect.objectContaining({
            events: expect.any(Array),
            latestEvent: expect.any(Object),
            finalResults: expect.any(Array),
          }),
        }),
      ],
    });
  });

  it('marks the assistant placeholder as failed before rethrowing the runner error', async () => {
    const runnerError = new Error('runner failed');
    const sessionService = {
      getOrCreateSession: jest.fn().mockResolvedValue('session-1'),
      loadHistoryWithId: jest.fn().mockResolvedValue([{ id: 101, role: 'user', content: 'hello' }]),
      saveMessage: jest
        .fn()
        .mockResolvedValueOnce({ id: 101, sessionId: 'session-1', role: 'user', content: 'hello' })
        .mockResolvedValueOnce({
          id: 202,
          sessionId: 'session-1',
          role: 'assistant',
          content: '',
          metadata: { inFlight: true },
        }),
      updateMessage: jest.fn().mockResolvedValue(undefined),
    } as unknown as NetaClawSessionService;

    const service = new NetaClawChatOrchestratorService();
    service.sessionService = sessionService;

    await expect(
      service.executeChat({
        sessionId: 'session-1',
        requestedAgentName: 'default',
        message: 'hello',
        runner: jest.fn().mockRejectedValue(runnerError),
      })
    ).rejects.toThrow('runner failed');

    expect(sessionService.updateMessage).toHaveBeenCalledWith(202, {
      content: 'runner failed',
      metadata: {
        inFlight: false,
        error: {
          message: 'runner failed',
          name: 'Error',
        },
      },
    });
  });

  it('rethrows the original runner error when placeholder cleanup also fails', async () => {
    const runnerError = new Error('runner failed');
    const cleanupError = new Error('cleanup failed');
    const sessionService = {
      getOrCreateSession: jest.fn().mockResolvedValue('session-1'),
      loadHistoryWithId: jest.fn().mockResolvedValue([{ id: 101, role: 'user', content: 'hello' }]),
      saveMessage: jest
        .fn()
        .mockResolvedValueOnce({ id: 101, sessionId: 'session-1', role: 'user', content: 'hello' })
        .mockResolvedValueOnce({
          id: 202,
          sessionId: 'session-1',
          role: 'assistant',
          content: '',
          metadata: { inFlight: true },
        }),
      updateMessage: jest.fn().mockRejectedValue(cleanupError),
    } as unknown as NetaClawSessionService;

    const logger = {
      error: jest.fn(),
    } as any;

    const service = new NetaClawChatOrchestratorService() as NetaClawChatOrchestratorService & { logger?: { error: jest.Mock } };
    service.sessionService = sessionService;
    service.logger = logger;

    await expect(
      service.executeChat({
        sessionId: 'session-1',
        requestedAgentName: 'default',
        message: 'hello',
        runner: jest.fn().mockRejectedValue(runnerError),
      })
    ).rejects.toThrow('runner failed');

    expect(logger.error).toHaveBeenCalledWith(
      '[NetaClaw] failed to persist assistant placeholder cleanup: %s',
      'cleanup failed'
    );
  });
});

describe('NetaClawSessionService message persistence', () => {
  let service: NetaClawSessionService;

  beforeEach(() => {
    service = new NetaClawSessionService();
  });

  it('returns the saved message entity and updates it by id', async () => {
    const savedEntity = {
      id: 88,
      sessionId: 'session-1',
      role: 'assistant',
      content: '',
      metadata: { inFlight: true },
    };

    service.messageRepo = {
      save: jest.fn().mockResolvedValue(savedEntity),
      update: jest.fn().mockResolvedValue(undefined),
    } as any;

    const result = await service.saveMessage('session-1', {
      role: 'assistant',
      content: '',
      metadata: { inFlight: true },
    });

    expect(service.messageRepo.save).toHaveBeenCalledWith({
      sessionId: 'session-1',
      role: 'assistant',
      content: '',
      thinking: undefined,
      toolCalls: undefined,
      toolCallId: undefined,
      skillName: undefined,
      metadata: { inFlight: true },
    });
    expect(result).toBe(savedEntity);

    await service.updateMessage(88, {
      content: 'final answer',
      metadata: { inFlight: false, source: 'agent' },
    });

    expect(service.messageRepo.update).toHaveBeenCalledWith(
      { id: 88 },
      {
        content: 'final answer',
        metadata: { inFlight: false, source: 'agent' },
      }
    );
  });
});
