/**
 * @license
 * Copyright 2026 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
  AgentTerminateMode,
  SubagentActivityErrorType,
  SUBAGENT_REJECTED_ERROR_PREFIX,
  SUBAGENT_CANCELLED_ERROR_MESSAGE,
  type SubagentProgress,
  type LocalAgentDefinition,
  type AgentInputs,
  type SubagentActivityEvent,
} from './types.js';
import { LocalSessionInvocation } from './local-session-invocation.js';
import { LocalSubagentSession } from './local-subagent-protocol.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';

vi.mock('./local-subagent-protocol.js');

const MockLocalSubagentSession = vi.mocked(LocalSubagentSession);

let capturedActivityCallback:
  | ((activity: SubagentActivityEvent) => void)
  | undefined;

const testDefinition: LocalAgentDefinition = {
  kind: 'local',
  name: 'MockAgent',
  description: 'A mock agent for testing.',
  inputConfig: {
    inputSchema: {
      type: 'object',
      properties: { task: { type: 'string' } },
    },
  },
  modelConfig: { model: 'test-model', generateContentConfig: {} },
  runConfig: { maxTimeMinutes: 1 },
  promptConfig: { systemPrompt: 'test' },
};

function setupMockSession(config: {
  output?: { result: string; terminate_reason: AgentTerminateMode };
  error?: Error;
}) {
  const mockSession = {
    send: vi.fn().mockResolvedValue({ streamId: 'stream-1' }),
    getResult: config.error
      ? vi.fn().mockRejectedValue(config.error)
      : vi.fn().mockResolvedValue(
          config.output ?? {
            result: 'done',
            terminate_reason: AgentTerminateMode.GOAL,
          },
        ),
    abort: vi.fn(),
    subscribe: vi.fn().mockReturnValue(vi.fn()),
  };
  MockLocalSubagentSession.mockImplementation(
    (
      _def: LocalAgentDefinition,
      _ctx: AgentLoopContext,
      _bus: MessageBus,
      rawCallback?: (activity: SubagentActivityEvent) => void,
    ) => {
      capturedActivityCallback = rawCallback;
      return mockSession as unknown as LocalSubagentSession;
    },
  );
  return mockSession;
}

describe('LocalSessionInvocation', () => {
  let mockContext: AgentLoopContext;
  let mockMessageBus: MessageBus;

  beforeEach(() => {
    vi.clearAllMocks();
    capturedActivityCallback = undefined;
    mockContext = makeFakeConfig() as unknown as AgentLoopContext;
    mockMessageBus = createMockMessageBus();
  });

  it('should pass the messageBus to the parent constructor', () => {
    setupMockSession({});
    const params = { task: 'Analyze data' };
    const invocation = new LocalSessionInvocation(
      testDefinition,
      mockContext,
      params,
      mockMessageBus,
    );
    expect(
      (invocation as unknown as { messageBus: MessageBus }).messageBus,
    ).toBe(mockMessageBus);
  });

  describe('getDescription', () => {
    it('should format the description with inputs', () => {
      setupMockSession({});
      const params = { task: 'Analyze data', priority: 5 };
      const invocation = new LocalSessionInvocation(
        testDefinition,
        mockContext,
        params,
        mockMessageBus,
      );
      const description = invocation.getDescription();
      expect(description).toBe(
        "Running subagent 'MockAgent' with inputs: { task: Analyze data, priority: 5 }",
      );
    });

    it('should not truncate long input values', () => {
      setupMockSession({});
      const longTask = 'A'.repeat(100);
      const params = { task: longTask };
      const invocation = new LocalSessionInvocation(
        testDefinition,
        mockContext,
        params,
        mockMessageBus,
      );
      const description = invocation.getDescription();
      expect(description).toBe(
        `Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(100)} }`,
      );
    });

    it('should not truncate the overall description', () => {
      setupMockSession({});
      const longNameDef: LocalAgentDefinition = {
        ...testDefinition,
        name: 'VeryLongAgentNameThatTakesUpSpace',
      };
      const params: AgentInputs = {};
      for (let i = 0; i < 20; i++) {
        params[`input${i}`] = `value${i}`;
      }
      const invocation = new LocalSessionInvocation(
        longNameDef,
        mockContext,
        params,
        mockMessageBus,
      );
      const description = invocation.getDescription();
      expect(description.length).toBeGreaterThan(300);
    });
  });

  describe('execute', () => {
    it('should create session and run successfully', async () => {
      const mockOutput = {
        result: 'Analysis complete.',
        terminate_reason: AgentTerminateMode.GOAL,
      };
      const mockSession = setupMockSession({ output: mockOutput });
      const params = { query: 'Execute task' };
      const signal = new AbortController().signal;
      const updateOutput = vi.fn();
      const invocation = new LocalSessionInvocation(
        testDefinition,
        mockContext,
        params,
        mockMessageBus,
      );

      const result = await invocation.execute({
        abortSignal: signal,
        updateOutput,
      });

      expect(MockLocalSubagentSession).toHaveBeenCalledWith(
        testDefinition,
        mockContext,
        mockMessageBus,
        expect.any(Function),
      );
      expect(mockSession.send).toHaveBeenCalledWith({
        message: { content: [{ type: 'text', text: 'Execute task' }] },
      });
      expect(result.llmContent).toEqual([
        {
          text: expect.stringContaining(
            "Subagent 'MockAgent' finished.\nTermination Reason: GOAL\nResult:\nAnalysis complete.",
          ),
        },
      ]);
      const display = result.returnDisplay as SubagentProgress;
      expect(display.isSubagentProgress).toBe(true);
      expect(display.state).toBe('completed');
      expect(display.result).toBe('Analysis complete.');
      expect(display.terminateReason).toBe(AgentTerminateMode.GOAL);
    });

    it('should stream THOUGHT_CHUNK activity', async () => {
      const mockSession = setupMockSession({});
      const params = { query: 'think' };
      const signal = new AbortController().signal;
      const updateOutput = vi.fn();
      const invocation = new LocalSessionInvocation(
        testDefinition,
        mockContext,
        params,
        mockMessageBus,
      );

      const executePromise = invocation.execute({
        abortSignal: signal,
        updateOutput,
      });

      // Wait for send to be called so the activity callback is wired
      await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());

      // Emit a thought chunk via captured callback
      capturedActivityCallback!({
        isSubagentActivityEvent: true,
        agentName: 'MockAgent',
        type: 'THOUGHT_CHUNK',
        data: { text: 'Analyzing...' },
      });

      await executePromise;

      // Find an updateOutput call containing the thought
      const progressCalls = updateOutput.mock.calls.map(
        (c) => c[0] as SubagentProgress,
      );
      const hasThought = progressCalls.some(
        (p) =>
          p.recentActivity &&
          p.recentActivity.some(
            (a) => a.type === 'thought' && a.content === 'Analyzing...',
          ),
      );
      expect(hasThought).toBe(true);
    });

    it('should stream TOOL_CALL_START and TOOL_CALL_END', async () => {
      const mockSession = setupMockSession({});
      const params = { query: 'run tool' };
      const signal = new AbortController().signal;
      const updateOutput = vi.fn();
      const invocation = new LocalSessionInvocation(
        testDefinition,
        mockContext,
        params,
        mockMessageBus,
      );

      const executePromise = invocation.execute({
        abortSignal: signal,
        updateOutput,
      });

      await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());

      capturedActivityCallback!({
        isSubagentActivityEvent: true,
        agentName: 'MockAgent',
        type: 'TOOL_CALL_START',
        data: { name: 'ls', args: {}, callId: 'call-123' },
      });
      capturedActivityCallback!({
        isSubagentActivityEvent: true,
        agentName: 'MockAgent',
        type: 'TOOL_CALL_END',
        data: { name: 'ls', data: {}, id: 'call-123' },
      });

      await executePromise;

      const progressCalls = updateOutput.mock.calls.map(
        (c) => c[0] as SubagentProgress,
      );

      // After TOOL_CALL_START, the immediate updateOutput call should show running
      const runningCalls = progressCalls.filter((p) => p.state === 'running');
      // The first running call with a tool_call should show 'running'
      const firstToolCall = runningCalls.find((p) =>
        p.recentActivity?.some(
          (a) => a.type === 'tool_call' && a.content === 'ls',
        ),
      );
      expect(firstToolCall).toBeDefined();

      // After TOOL_CALL_END, the tool should be completed
      const hasCompleted = progressCalls.some((p) =>
        p.recentActivity?.some(
          (a) =>
            a.type === 'tool_call' &&
            a.content === 'ls' &&
            a.status === 'completed',
        ),
      );
      expect(hasCompleted).toBe(true);
    });

    it('should handle ERROR activity', async () => {
      const mockSession = setupMockSession({});
      const params = { query: 'fail' };
      const signal = new AbortController().signal;
      const updateOutput = vi.fn();
      const invocation = new LocalSessionInvocation(
        testDefinition,
        mockContext,
        params,
        mockMessageBus,
      );

      const executePromise = invocation.execute({
        abortSignal: signal,
        updateOutput,
      });

      await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());

      capturedActivityCallback!({
        isSubagentActivityEvent: true,
        agentName: 'MockAgent',
        type: 'ERROR',
        data: { error: 'Something broke' },
      });

      await executePromise;

      const progressCalls = updateOutput.mock.calls.map(
        (c) => c[0] as SubagentProgress,
      );
      const hasError = progressCalls.some((p) =>
        p.recentActivity?.some(
          (a) =>
            a.type === 'thought' &&
            a.content === 'Error: Something broke' &&
            a.status === 'error',
        ),
      );
      expect(hasError).toBe(true);
    });

    it('should handle cancelled errors', async () => {
      const mockSession = setupMockSession({});
      const params = { query: 'cancel' };
      const signal = new AbortController().signal;
      const updateOutput = vi.fn();
      const invocation = new LocalSessionInvocation(
        testDefinition,
        mockContext,
        params,
        mockMessageBus,
      );

      const executePromise = invocation.execute({
        abortSignal: signal,
        updateOutput,
      });

      await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());

      capturedActivityCallback!({
        isSubagentActivityEvent: true,
        agentName: 'MockAgent',
        type: 'ERROR',
        data: {
          error: SUBAGENT_CANCELLED_ERROR_MESSAGE,
          errorType: SubagentActivityErrorType.CANCELLED,
        },
      });

      await executePromise;

      const progressCalls = updateOutput.mock.calls.map(
        (c) => c[0] as SubagentProgress,
      );
      const hasCancelled = progressCalls.some((p) =>
        p.recentActivity?.some(
          (a) => a.type === 'thought' && a.status === 'cancelled',
        ),
      );
      expect(hasCancelled).toBe(true);
    });

    it('should handle rejected errors', async () => {
      const mockSession = setupMockSession({});
      const params = { query: 'reject' };
      const signal = new AbortController().signal;
      const updateOutput = vi.fn();
      const invocation = new LocalSessionInvocation(
        testDefinition,
        mockContext,
        params,
        mockMessageBus,
      );

      const executePromise = invocation.execute({
        abortSignal: signal,
        updateOutput,
      });

      await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());

      capturedActivityCallback!({
        isSubagentActivityEvent: true,
        agentName: 'MockAgent',
        type: 'TOOL_CALL_START',
        data: { name: 'dangerous_tool', args: {}, callId: 'call-rej' },
      });
      capturedActivityCallback!({
        isSubagentActivityEvent: true,
        agentName: 'MockAgent',
        type: 'ERROR',
        data: {
          name: 'dangerous_tool',
          error: `${SUBAGENT_REJECTED_ERROR_PREFIX} Rethink approach.`,
          errorType: SubagentActivityErrorType.REJECTED,
          callId: 'call-rej',
        },
      });

      await executePromise;

      const progressCalls = updateOutput.mock.calls.map(
        (c) => c[0] as SubagentProgress,
      );
      // Tool call should be marked cancelled
      const hasToolCancelled = progressCalls.some((p) =>
        p.recentActivity?.some(
          (a) =>
            a.type === 'tool_call' &&
            a.content === 'dangerous_tool' &&
            a.status === 'cancelled',
        ),
      );
      expect(hasToolCancelled).toBe(true);
    });

    it('should trim recentActivity to MAX_RECENT_ACTIVITY', async () => {
      const mockSession = setupMockSession({});
      const params = { query: 'trim' };
      const signal = new AbortController().signal;
      const updateOutput = vi.fn();
      const invocation = new LocalSessionInvocation(
        testDefinition,
        mockContext,
        params,
        mockMessageBus,
      );

      const executePromise = invocation.execute({
        abortSignal: signal,
        updateOutput,
      });

      await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());

      // Emit 4+ activities to exceed MAX_RECENT_ACTIVITY (3)
      capturedActivityCallback!({
        isSubagentActivityEvent: true,
        agentName: 'MockAgent',
        type: 'TOOL_CALL_START',
        data: { name: 'tool1', args: {} },
      });
      capturedActivityCallback!({
        isSubagentActivityEvent: true,
        agentName: 'MockAgent',
        type: 'TOOL_CALL_START',
        data: { name: 'tool2', args: {} },
      });
      capturedActivityCallback!({
        isSubagentActivityEvent: true,
        agentName: 'MockAgent',
        type: 'TOOL_CALL_START',
        data: { name: 'tool3', args: {} },
      });
      capturedActivityCallback!({
        isSubagentActivityEvent: true,
        agentName: 'MockAgent',
        type: 'TOOL_CALL_START',
        data: { name: 'tool4', args: {} },
      });

      await executePromise;

      // After the 4th activity, the last updateOutput call before completion
      // should have only 3 items in recentActivity
      const progressCalls = updateOutput.mock.calls.map(
        (c) => c[0] as SubagentProgress,
      );
      // Find the call right after the 4th activity (before completion)
      const afterFourthActivity = progressCalls.filter(
        (p) => p.state === 'running' && p.recentActivity.length > 0,
      );
      const lastRunning = afterFourthActivity[afterFourthActivity.length - 1];
      expect(lastRunning.recentActivity.length).toBeLessThanOrEqual(3);
      // Should contain tool4 (the latest)
      expect(
        lastRunning.recentActivity.some((a) => a.content === 'tool4'),
      ).toBe(true);
      // Should NOT contain tool1 (trimmed away)
      expect(
        lastRunning.recentActivity.some((a) => a.content === 'tool1'),
      ).toBe(false);
    });

    it('should handle executor errors', async () => {
      const error = new Error('Model failed during execution.');
      setupMockSession({ error });
      const params = { query: 'fail hard' };
      const signal = new AbortController().signal;
      const updateOutput = vi.fn();
      const invocation = new LocalSessionInvocation(
        testDefinition,
        mockContext,
        params,
        mockMessageBus,
      );

      const result = await invocation.execute({
        abortSignal: signal,
        updateOutput,
      });

      expect(result.llmContent).toBe(
        `Subagent 'MockAgent' failed. Error: ${error.message}`,
      );
      const display = result.returnDisplay as SubagentProgress;
      expect(display.isSubagentProgress).toBe(true);
      expect(display.state).toBe('error');
      expect(display.recentActivity).toContainEqual(
        expect.objectContaining({
          type: 'thought',
          content: `Error: ${error.message}`,
          status: 'error',
        }),
      );
    });

    it('should handle abort', async () => {
      const mockOutput = {
        result: '',
        terminate_reason: AgentTerminateMode.ABORTED,
      };
      setupMockSession({ output: mockOutput });
      const params = { query: 'abort me' };
      const signal = new AbortController().signal;
      const updateOutput = vi.fn();
      const invocation = new LocalSessionInvocation(
        testDefinition,
        mockContext,
        params,
        mockMessageBus,
      );

      await expect(
        invocation.execute({ abortSignal: signal, updateOutput }),
      ).rejects.toThrow('Operation cancelled by user');

      // Verify cancelled state was published
      const progressCalls = updateOutput.mock.calls.map(
        (c) => c[0] as SubagentProgress,
      );
      const hasCancelledState = progressCalls.some(
        (p) => p.state === 'cancelled',
      );
      expect(hasCancelledState).toBe(true);
    });

    it('should wire abort signal to session.abort', async () => {
      const mockSession = setupMockSession({});
      const params = { query: 'abort wire' };
      const controller = new AbortController();
      const updateOutput = vi.fn();
      const invocation = new LocalSessionInvocation(
        testDefinition,
        mockContext,
        params,
        mockMessageBus,
      );

      const executePromise = invocation.execute({
        abortSignal: controller.signal,
        updateOutput,
      });

      // Trigger abort
      controller.abort();

      // The execute should complete (getResult returned GOAL by default)
      await executePromise.catch(() => {
        /* abort may throw */
      });

      expect(mockSession.abort).toHaveBeenCalled();
    });

    it('should send non-query params as config update before query', async () => {
      const mockSession = setupMockSession({});
      const params = { query: 'Do something', extra_config: 'value123' };
      const signal = new AbortController().signal;
      const updateOutput = vi.fn();
      const invocation = new LocalSessionInvocation(
        testDefinition,
        mockContext,
        params,
        mockMessageBus,
      );

      await invocation.execute({ abortSignal: signal, updateOutput });

      // First send: config update with non-query params
      expect(mockSession.send).toHaveBeenCalledWith({
        update: { config: { extra_config: 'value123' } },
      });
      // Second send: message with query
      expect(mockSession.send).toHaveBeenCalledWith({
        message: { content: [{ type: 'text', text: 'Do something' }] },
      });
      // Config update should come before message
      const sendCalls = mockSession.send.mock.calls;
      const configIdx = sendCalls.findIndex((c) => c[0]?.update?.config);
      const messageIdx = sendCalls.findIndex((c) => c[0]?.message);
      expect(configIdx).toBeLessThan(messageIdx);
    });

    it('should publish SUBAGENT_ACTIVITY on messageBus', async () => {
      const mockSession = setupMockSession({});
      const params = { query: 'publish test' };
      const signal = new AbortController().signal;
      const updateOutput = vi.fn();
      const invocation = new LocalSessionInvocation(
        testDefinition,
        mockContext,
        params,
        mockMessageBus,
      );

      const executePromise = invocation.execute({
        abortSignal: signal,
        updateOutput,
      });

      await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());

      capturedActivityCallback!({
        isSubagentActivityEvent: true,
        agentName: 'MockAgent',
        type: 'THOUGHT_CHUNK',
        data: { text: 'Thinking...' },
      });

      await executePromise;

      expect(mockMessageBus.publish).toHaveBeenCalledWith(
        expect.objectContaining({
          type: MessageBusType.SUBAGENT_ACTIVITY,
          subagentName: 'MockAgent',
          activity: expect.objectContaining({
            type: 'thought',
            content: 'Thinking...',
          }),
        }),
      );
    });

    it('should clean up abort listener in finally', async () => {
      setupMockSession({});
      const params = { query: 'cleanup' };
      const controller = new AbortController();
      const removeEventListenerSpy = vi.spyOn(
        controller.signal,
        'removeEventListener',
      );
      const updateOutput = vi.fn();
      const invocation = new LocalSessionInvocation(
        testDefinition,
        mockContext,
        params,
        mockMessageBus,
      );

      await invocation.execute({
        abortSignal: controller.signal,
        updateOutput,
      });

      expect(removeEventListenerSpy).toHaveBeenCalledWith(
        'abort',
        expect.any(Function),
      );
    });

    it('should unsubscribe parent observer in finally', async () => {
      const unsubscribeFn = vi.fn();
      const mockSession = setupMockSession({});
      mockSession.subscribe.mockReturnValue(unsubscribeFn);

      const params = { query: 'unsub test' };
      const signal = new AbortController().signal;
      const updateOutput = vi.fn();
      const onAgentEvent = vi.fn();
      const invocation = new LocalSessionInvocation(
        testDefinition,
        mockContext,
        params,
        mockMessageBus,
        { onAgentEvent },
      );

      await invocation.execute({ abortSignal: signal, updateOutput });

      expect(mockSession.subscribe).toHaveBeenCalledWith(onAgentEvent);
      expect(unsubscribeFn).toHaveBeenCalled();
    });

    it('should handle TOOL_CALL_END with error data', async () => {
      const mockSession = setupMockSession({});
      const params = { query: 'tool error' };
      const signal = new AbortController().signal;
      const updateOutput = vi.fn();
      const invocation = new LocalSessionInvocation(
        testDefinition,
        mockContext,
        params,
        mockMessageBus,
      );

      const executePromise = invocation.execute({
        abortSignal: signal,
        updateOutput,
      });

      await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());

      capturedActivityCallback!({
        isSubagentActivityEvent: true,
        agentName: 'MockAgent',
        type: 'TOOL_CALL_START',
        data: { name: 'failing_tool', args: {}, callId: 'call-err' },
      });
      capturedActivityCallback!({
        isSubagentActivityEvent: true,
        agentName: 'MockAgent',
        type: 'TOOL_CALL_END',
        data: { name: 'failing_tool', data: { isError: true }, id: 'call-err' },
      });

      await executePromise;

      const progressCalls = updateOutput.mock.calls.map(
        (c) => c[0] as SubagentProgress,
      );
      const hasToolError = progressCalls.some((p) =>
        p.recentActivity?.some(
          (a) =>
            a.type === 'tool_call' &&
            a.content === 'failing_tool' &&
            a.status === 'error',
        ),
      );
      expect(hasToolError).toBe(true);
    });

    it('should mark running items as cancelled on abort', async () => {
      const abortError = new Error('Aborted');
      abortError.name = 'AbortError';
      const mockSession = setupMockSession({ error: abortError });
      const params = { query: 'mark cancelled' };
      const signal = new AbortController().signal;
      const updateOutput = vi.fn();
      const invocation = new LocalSessionInvocation(
        testDefinition,
        mockContext,
        params,
        mockMessageBus,
      );

      const executePromise = invocation.execute({
        abortSignal: signal,
        updateOutput,
      });

      await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());

      // Emit a running tool call before the abort
      capturedActivityCallback!({
        isSubagentActivityEvent: true,
        agentName: 'MockAgent',
        type: 'TOOL_CALL_START',
        data: { name: 'running_tool', args: {} },
      });

      await expect(executePromise).rejects.toThrow('Aborted');

      const progressCalls = updateOutput.mock.calls.map(
        (c) => c[0] as SubagentProgress,
      );
      // The final progress should show the tool as cancelled
      const lastProgress = progressCalls[progressCalls.length - 1];
      expect(lastProgress.state).toBe('cancelled');
      expect(lastProgress.recentActivity).toContainEqual(
        expect.objectContaining({
          type: 'tool_call',
          content: 'running_tool',
          status: 'cancelled',
        }),
      );
    });
  });
});
