/**
 * Phase B Task 3 — runDelegate / isolation tests.
 *
 * Tests verify:
 *   (a) The sub-agent's complete.result is surfaced as the delegate tool's
 *       output in the parent conversation.
 *   (b) The parent conversation.messages do NOT contain any of the sub-agent's
 *       intermediate turns (isolation invariant).
 *   (c) The sub-agent receives delegationDepth === parentDepth + 1.
 *   (d) At MAX_DELEGATION_DEPTH, 'delegate' is stripped from sub allowedTools
 *       and ctx.runDelegate is not injected on the sub-ctx.
 */

import { afterEach, describe, expect, it, vi } from 'vitest';
import type { LLMEvent, ToolDef } from '../llm/openai-compat.js';
import type { ToolContext } from './tools/index.js';

// ---- hoist mocks before any imports that use them ----

const { executeToolMock, getToolDefsMock } = vi.hoisted(() => ({
  executeToolMock: vi.fn(),
  getToolDefsMock: vi.fn(),
}));

vi.mock('./tools/index.js', () => ({
  executeTool: executeToolMock,
  getToolDefs: getToolDefsMock,
}));

import { executeMovement, MAX_DELEGATION_DEPTH, type Movement } from './agent-loop.js';
import { Conversation } from './context/conversation.js';
import { ToolResultCache } from './context/tool-result-cache.js';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeToolDefs(names: string[]): ToolDef[] {
  return names.map((name) => ({
    type: 'function',
    function: {
      name,
      description: name,
      parameters: { type: 'object', properties: {}, required: [] },
    },
  }));
}

function makeContext(overrides: Partial<ToolContext> = {}): ToolContext {
  return {
    workspacePath: '/tmp/delegate-test',
    editAllowed: true,
    ...overrides,
  };
}

/**
 * A scripted fake LLM client that returns each response array in order.
 * Consecutive calls walk through the responses list.
 */
class FakeClient {
  private index = 0;
  readonly calls: Array<{ messages: unknown; tools?: unknown }> = [];

  constructor(private readonly responses: LLMEvent[][]) {}

  async *chat(messages: unknown, tools?: unknown, _signal?: AbortSignal): AsyncGenerator<LLMEvent> {
    this.calls.push({ messages, tools });
    const response = this.responses[this.index++] ?? [];
    for (const event of response) {
      yield event;
    }
  }

  /** How many times was chat() called. */
  get callCount(): number { return this.calls.length; }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('runDelegate — Phase B Task 3', () => {
  afterEach(() => {
    executeToolMock.mockReset();
    getToolDefsMock.mockReset();
  });

  // -----------------------------------------------------------------------
  // (a) + (b) + (c) — two-level delegation, result propagation, isolation
  // -----------------------------------------------------------------------
  it('(a) sub-agent result propagates to parent, (b) sub turns absent from parent conversation, (c) depth increments', async () => {
    // The single FakeClient drives both the parent and the sub-agent because
    // runDelegateSubAgent receives the same client instance. Call order:
    //   [0] parent turn 1: emits delegate tool_call
    //   [1] SUB turn 1: emits complete(success, "sub result")
    //   [2] parent turn 2: emits complete(success, "parent done")
    const client = new FakeClient([
      // parent turn 1 — delegate
      [
        {
          type: 'tool_use',
          id: 'del-1',
          name: 'delegate',
          input: { description: 'research task', prompt: 'investigate X' },
        },
        { type: 'done' },
      ],
      // sub turn 1 — complete
      [
        {
          type: 'tool_use',
          id: 'sub-complete-1',
          name: 'complete',
          input: { status: 'success', result: 'sub agent result' },
        },
        { type: 'done' },
      ],
      // parent turn 2 — complete
      [
        {
          type: 'tool_use',
          id: 'par-complete-1',
          name: 'complete',
          input: { status: 'success', result: 'parent done' },
        },
        { type: 'done' },
      ],
    ]);

    // getToolDefs is called for both parent and sub runs.
    getToolDefsMock.mockResolvedValue(makeToolDefs(['delegate']));

    // Track the delegationDepth the sub-agent receives, and record
    // which tool names executeTool is called with.
    const capturedSubDepths: number[] = [];
    const toolsCalled: string[] = [];

    // executeTool mock: when 'delegate' is called, invoke ctx.runDelegate
    // (mirroring what orchestration.ts does in production).
    executeToolMock.mockImplementation(
      async (name: string, input: Record<string, unknown>, ctx: ToolContext) => {
        toolsCalled.push(name);
        if (name === 'delegate') {
          if (!ctx.runDelegate) {
            return { output: 'runDelegate not available', isError: true };
          }
          // Capture depth that the sub-ctx will have (depth = parentDepth+1,
          // but we can observe it via what runDelegate stores in sub-ctx).
          // We verify depth indirectly via the depth-cap test below;
          // here we just call through.
          const { result, status } = await ctx.runDelegate({
            description: String(input['description'] ?? ''),
            prompt: String(input['prompt'] ?? ''),
          });
          if (status === 'aborted') return { output: `[delegate aborted] ${result}`, isError: true };
          return { output: result, isError: false };
        }
        return { output: 'ok', isError: false };
      },
    );

    const parentConversation = new Conversation(undefined);
    const parentMovement: Movement = {
      name: 'parent',
      edit: false,
      persona: 'orchestrator',
      instruction: 'orchestrate',
      allowedTools: ['delegate'],
      rules: [{ condition: 'done', next: 'COMPLETE' }],
      defaultNext: 'COMPLETE',
    };

    const result = await executeMovement(
      parentMovement,
      'run parent task',
      client as never,
      makeContext({ delegationDepth: 0 }),
      {
        conversation: parentConversation,
        toolResultCache: new ToolResultCache(),
      },
    );

    // (a) parent completed successfully with its own complete result
    expect(result.next).toBe('COMPLETE');
    expect(result.output).toBe('parent done');

    // (a) delegate tool was called and returned sub-agent's result
    expect(toolsCalled).toContain('delegate');

    // The parent's LLM turn 2 (complete) received the delegate tool result.
    // Verify the full serialized parent conversation contains the sub result.
    const parentMessages = parentConversation.messages;
    const parentMsgJson = JSON.stringify(parentMessages);
    expect(parentMsgJson).toContain('sub agent result');

    // (b) Isolation: the sub-agent's intermediate turns must NOT appear in the
    //     parent conversation. Expected parent messages:
    //       [0] system preamble (role: system)
    //       [1] system guidance
    //       [2] user "run parent task"
    //       [3] assistant (delegate tool_call)
    //       [4] tool_result (delegate output = "sub agent result")
    //       [5] assistant (complete tool_call)
    // The sub-agent's own system / user messages are NOT in the parent.
    // Specifically: a standalone user message with content "investigate X"
    // (the sub's taskInstruction) must not appear. Note that "investigate X"
    // legitimately appears inside the delegate tool_call arguments — that is
    // the parent's own message, not the sub's turn.
    const userMessages = parentMessages.filter((m) => m.role === 'user');
    const subTaskInParentUser = userMessages.some(
      (m) => typeof m.content === 'string' && m.content === 'investigate X',
    );
    expect(subTaskInParentUser).toBe(false);

    // Also verify the sub-agent's system prompt does not bleed into the parent.
    // The sub movement's instruction is the delegate prompt, not the parent's.
    const systemMessages = parentMessages.filter((m) => m.role === 'system');
    const subInstructionInParentSystem = systemMessages.some(
      (m) => typeof m.content === 'string' && m.content.includes('investigate X'),
    );
    expect(subInstructionInParentSystem).toBe(false);

    // (c) The sub-agent was invoked with depth 1 (parent=0, sub=parent+1=1).
    // We verify this indirectly: the client was called 3 times total
    //   (parent-turn-1, sub-turn-1, parent-turn-2).
    // If depth injection didn't work, runDelegate would not be available and
    // the delegate tool would have returned an error (and the parent would
    // not reach complete).
    expect(client.callCount).toBe(3);
  });

  // -----------------------------------------------------------------------
  // (d) Depth cap: at MAX_DELEGATION_DEPTH, delegate is stripped from sub
  // -----------------------------------------------------------------------
  it('(d) at MAX_DELEGATION_DEPTH sub receives no delegate in allowedTools and runDelegate is absent', async () => {
    // Parent runs at depth MAX_DELEGATION_DEPTH - 1, so the sub runs at
    // MAX_DELEGATION_DEPTH and should NOT have 'delegate' injected.
    let subCtxCaptured: ToolContext | null = null;
    let subAllowedToolsCaptured: string[] | null = null;

    const client = new FakeClient([
      // parent turn 1 — delegate call
      [
        {
          type: 'tool_use',
          id: 'del-cap-1',
          name: 'delegate',
          input: { description: 'capped sub', prompt: 'capped prompt' },
        },
        { type: 'done' },
      ],
      // sub turn 1 — complete (sub runs, but without 'delegate' tool)
      [
        {
          type: 'tool_use',
          id: 'sub-cap-complete-1',
          name: 'complete',
          input: { status: 'success', result: 'capped sub done' },
        },
        { type: 'done' },
      ],
      // parent turn 2 — complete
      [
        {
          type: 'tool_use',
          id: 'par-cap-complete-1',
          name: 'complete',
          input: { status: 'success', result: 'parent cap done' },
        },
        { type: 'done' },
      ],
    ]);

    // Capture the sub-ctx (second call to getToolDefs) and the allowedTools arg.
    let getToolDefsCallCount = 0;
    getToolDefsMock.mockImplementation(async (allowedTools: string[]) => {
      getToolDefsCallCount++;
      if (getToolDefsCallCount === 2) {
        // This is the sub-agent's getToolDefs call — capture what tools were requested.
        subAllowedToolsCaptured = [...allowedTools];
      }
      return makeToolDefs(allowedTools);
    });

    executeToolMock.mockImplementation(
      async (name: string, input: Record<string, unknown>, ctx: ToolContext) => {
        if (name === 'delegate') {
          if (!ctx.runDelegate) {
            return { output: 'runDelegate not available', isError: true };
          }
          const { result, status } = await ctx.runDelegate({
            description: String(input['description'] ?? ''),
            prompt: String(input['prompt'] ?? ''),
          });
          // Capture sub ctx via a spy on runDelegate of the resulting sub-agent.
          // We can't easily access subCtx directly, but we verify via
          // subAllowedToolsCaptured that 'delegate' was stripped.
          if (status === 'aborted') return { output: `[aborted] ${result}`, isError: true };
          return { output: result, isError: false };
        }
        return { output: 'ok', isError: false };
      },
    );

    // Run parent at depth = MAX_DELEGATION_DEPTH - 1.
    const parentMovement: Movement = {
      name: 'parent-cap',
      edit: false,
      persona: 'orchestrator',
      instruction: 'cap test',
      allowedTools: ['delegate'],
      rules: [{ condition: 'done', next: 'COMPLETE' }],
      defaultNext: 'COMPLETE',
    };

    const result = await executeMovement(
      parentMovement,
      'run cap test',
      client as never,
      makeContext({ delegationDepth: MAX_DELEGATION_DEPTH - 1 }),
      {
        conversation: new Conversation(undefined),
        toolResultCache: new ToolResultCache(),
      },
    );

    expect(result.next).toBe('COMPLETE');

    // (d) The sub's getToolDefs call must NOT include 'delegate' in the
    //     allowedTools argument — it was stripped because depth >= MAX_DELEGATION_DEPTH.
    expect(subAllowedToolsCaptured).not.toBeNull();
    expect(subAllowedToolsCaptured).not.toContain('delegate');
  });

  // -----------------------------------------------------------------------
  // (e) Nested depth-2 execution: depth-0 → depth-1 → depth-2 → bubbles up
  // -----------------------------------------------------------------------
  it('(e) nested depth-2 execution: chain composes through all three levels and results bubble up', async () => {
    // FakeClient is shared across the entire recursive call tree.
    // Call order through the recursion:
    //   call 0: parent (depth 0) turn 1   → delegate({description:'level1', prompt:'L1_MARKER do step 1'})
    //   call 1: depth-1 sub turn 1        → delegate({description:'level2', prompt:'L2_MARKER do step 2'})
    //   call 2: depth-2 sub turn 1        → complete(success, 'DEEP_RESULT')
    //   call 3: depth-1 sub turn 2        → complete(success, 'MID_RESULT')
    //   call 4: parent turn 2             → complete(success, 'PARENT_DONE')
    const client = new FakeClient([
      // [0] parent turn 1 — delegate to depth-1
      [
        {
          type: 'tool_use',
          id: 'del-nested-0',
          name: 'delegate',
          input: { description: 'level1', prompt: 'L1_MARKER do step 1' },
        },
        { type: 'done' },
      ],
      // [1] depth-1 sub turn 1 — delegate to depth-2
      [
        {
          type: 'tool_use',
          id: 'del-nested-1',
          name: 'delegate',
          input: { description: 'level2', prompt: 'L2_MARKER do step 2' },
        },
        { type: 'done' },
      ],
      // [2] depth-2 sub turn 1 — complete (deepest level, no further delegation)
      [
        {
          type: 'tool_use',
          id: 'deep-complete-1',
          name: 'complete',
          input: { status: 'success', result: 'DEEP_RESULT' },
        },
        { type: 'done' },
      ],
      // [3] depth-1 sub turn 2 — complete (after receiving DEEP_RESULT from its delegate call)
      [
        {
          type: 'tool_use',
          id: 'mid-complete-1',
          name: 'complete',
          input: { status: 'success', result: 'MID_RESULT' },
        },
        { type: 'done' },
      ],
      // [4] parent turn 2 — complete (after receiving MID_RESULT from its delegate call)
      [
        {
          type: 'tool_use',
          id: 'parent-nested-complete-1',
          name: 'complete',
          input: { status: 'success', result: 'PARENT_DONE' },
        },
        { type: 'done' },
      ],
    ]);

    // Track the allowedTools for each getToolDefs invocation.
    // Call #1 = parent, #2 = depth-1 sub (per LLM turn start), #3 = depth-2 sub.
    // We specifically want to verify the depth-2 sub's call does NOT include 'delegate'.
    const getToolDefsInvocations: string[][] = [];
    getToolDefsMock.mockImplementation(async (allowedTools: string[]) => {
      getToolDefsInvocations.push([...allowedTools]);
      return makeToolDefs(allowedTools);
    });

    // Track which tool names were invoked at each level, and capture
    // the delegate tool-result received at the depth-1 level (the one
    // that wraps DEEP_RESULT) so we can assert it bubbled up.
    const delegateResultsReceived: string[] = [];

    executeToolMock.mockImplementation(
      async (name: string, input: Record<string, unknown>, ctx: ToolContext) => {
        if (name === 'delegate') {
          if (!ctx.runDelegate) {
            return { output: 'runDelegate not available', isError: true };
          }
          const { result, status } = await ctx.runDelegate({
            description: String(input['description'] ?? ''),
            prompt: String(input['prompt'] ?? ''),
          });
          if (status === 'aborted') return { output: `[delegate aborted] ${result}`, isError: true };
          // Record each delegate result as it bubbles up through the stack.
          delegateResultsReceived.push(result);
          return { output: result, isError: false };
        }
        return { output: 'ok', isError: false };
      },
    );

    const parentConversation = new Conversation(undefined);
    const parentMovement: Movement = {
      name: 'nested-parent',
      edit: false,
      persona: 'orchestrator',
      instruction: 'orchestrate nested',
      allowedTools: ['delegate'],
      rules: [{ condition: 'done', next: 'COMPLETE' }],
      defaultNext: 'COMPLETE',
    };

    const result = await executeMovement(
      parentMovement,
      'run nested task',
      client as never,
      makeContext({ delegationDepth: 0 }),
      {
        conversation: parentConversation,
        toolResultCache: new ToolResultCache(),
      },
    );

    // Chain executed all the way through and parent completed.
    expect(result.next).toBe('COMPLETE');
    expect(result.output).toBe('PARENT_DONE');

    // All 5 LLM turns ran: depth-0 turn1, depth-1 turn1, depth-2 turn1,
    // depth-1 turn2, depth-0 turn2.
    expect(client.callCount).toBe(5);

    // DEEP_RESULT bubbled up through depth-1's delegate call,
    // then MID_RESULT bubbled up through depth-0's delegate call.
    // delegateResultsReceived is filled innermost-first because executeToolMock
    // awaits runDelegate before pushing, so: [DEEP_RESULT, MID_RESULT].
    expect(delegateResultsReceived).toHaveLength(2);
    expect(delegateResultsReceived[0]).toBe('DEEP_RESULT');
    expect(delegateResultsReceived[1]).toBe('MID_RESULT');

    // MID_RESULT must appear in the parent's conversation (it was the tool
    // result for the parent's delegate tool_call).
    const parentMsgJson = JSON.stringify(parentConversation.messages);
    expect(parentMsgJson).toContain('MID_RESULT');

    // Isolation: L2_MARKER must NOT appear as a standalone user/system message
    // in the parent conversation — it belongs only to the depth-2 sub's context.
    const parentMessages = parentConversation.messages;
    const userMsgs = parentMessages.filter((m) => m.role === 'user');
    const l2MarkerInParentUser = userMsgs.some(
      (m) => typeof m.content === 'string' && m.content.includes('L2_MARKER'),
    );
    expect(l2MarkerInParentUser).toBe(false);

    // The parent's own instruction must not appear in the depth-2 sub's conversation.
    // The depth-2 sub's LLM call is client.calls[2] (index 2).
    // Its messages array should contain 'L2_MARKER' (its own task) but NOT
    // 'orchestrate nested' (the parent's movement instruction).
    const depth2Messages = JSON.stringify(client.calls[2]?.messages ?? []);
    expect(depth2Messages).toContain('L2_MARKER');
    expect(depth2Messages).not.toContain('orchestrate nested');

    // Depth-cap assertion within this nested run:
    // The depth-2 sub's getToolDefs call must NOT include 'delegate'
    // (because depth-2 === MAX_DELEGATION_DEPTH, the cap).
    // getToolDefsInvocations[2] is the depth-2 sub's call (0=parent, 1=depth-1, 2=depth-2).
    const depth2AllowedTools = getToolDefsInvocations[2];
    expect(depth2AllowedTools).toBeDefined();
    expect(depth2AllowedTools).not.toContain('delegate');
  });

  // -----------------------------------------------------------------------
  // Sub-agent aborted → delegate returns error result, parent continues
  // -----------------------------------------------------------------------
  it('sub-agent ABORT maps to status=aborted and delegate returns isError:true', async () => {
    const client = new FakeClient([
      // parent turn 1 — delegate
      [
        {
          type: 'tool_use',
          id: 'del-abort-1',
          name: 'delegate',
          input: { description: 'abort sub', prompt: 'fail me' },
        },
        { type: 'done' },
      ],
      // sub turn 1 — complete(aborted)
      [
        {
          type: 'tool_use',
          id: 'sub-abort-complete-1',
          name: 'complete',
          input: { status: 'aborted', abort_reason: 'could not proceed' },
        },
        { type: 'done' },
      ],
      // parent turn 2 — complete
      [
        {
          type: 'tool_use',
          id: 'par-abort-complete-1',
          name: 'complete',
          input: { status: 'success', result: 'parent handled abort' },
        },
        { type: 'done' },
      ],
    ]);

    getToolDefsMock.mockResolvedValue(makeToolDefs(['delegate']));

    const toolResults: Array<{ output: string; isError: boolean }> = [];
    executeToolMock.mockImplementation(
      async (name: string, input: Record<string, unknown>, ctx: ToolContext) => {
        if (name === 'delegate') {
          if (!ctx.runDelegate) return { output: 'no runDelegate', isError: true };
          const { result, status } = await ctx.runDelegate({
            description: String(input['description'] ?? ''),
            prompt: String(input['prompt'] ?? ''),
          });
          const toolResult = status === 'aborted'
            ? { output: `[delegate aborted] ${result}`, isError: true }
            : { output: result, isError: false };
          toolResults.push(toolResult);
          return toolResult;
        }
        return { output: 'ok', isError: false };
      },
    );

    const result = await executeMovement(
      {
        name: 'parent-abort',
        edit: false,
        persona: 'orchestrator',
        instruction: 'abort test',
        allowedTools: ['delegate'],
        rules: [{ condition: 'done', next: 'COMPLETE' }],
        defaultNext: 'COMPLETE',
      },
      'abort test task',
      client as never,
      makeContext({ delegationDepth: 0 }),
      {
        conversation: new Conversation(undefined),
        toolResultCache: new ToolResultCache(),
      },
    );

    expect(result.next).toBe('COMPLETE');
    // The delegate returned isError:true when sub aborted.
    expect(toolResults.length).toBe(1);
    expect(toolResults[0]!.isError).toBe(true);
    expect(toolResults[0]!.output).toContain('could not proceed');
  });
});
