// Integration tests for the agentic tool loop (tools.js runToolLoop)
// Tests the full flow: user message → model tool calls → tool execution → model response
// Uses mocked Ollama and tool implementations to test the orchestration layer.

const fs = require('fs');
const path = require('path');
const os = require('os');

// ── Module mocks (must be before requires) ──

// Constants — use temp directory for sessions
const mockTestDir = path.join(os.tmpdir(), `pre-test-toolloop-${Date.now()}`);
jest.mock('../src/constants', () => {
  const p = require('path');
  return {
    MODEL: 'test-model',
    MODEL_CTX: 8192,
    OLLAMA_PORT: 11434,
    PRE_DIR: mockTestDir,
    MAX_TOOL_TURNS: 35,
    SESSIONS_DIR: p.join(mockTestDir, 'sessions'),
    MEMORY_DIR: p.join(mockTestDir, 'memory'),
    ARTIFACTS_DIR: p.join(mockTestDir, 'artifacts'),
    CONNECTIONS_FILE: p.join(mockTestDir, 'connections.json'),
    COMFYUI_FILE: p.join(mockTestDir, 'comfyui.json'),
    CRON_FILE: p.join(mockTestDir, 'cron.json'),
    QUICK_ACTIONS_FILE: p.join(mockTestDir, 'quick-actions.json'),
    ONBOARDING_FILE: p.join(mockTestDir, 'onboarding.json'),
    RESEARCH_AGENT_MAX_TURNS: 30,
    RESEARCH_MAX_SECTIONS: 8,
    RESEARCH_MAX_SOURCES: 5,
  };
});

// Ollama — controlled by test
jest.mock('../src/ollama');

// Context — return simple system prompt
jest.mock('../src/context', () => ({
  buildSystemPrompt: jest.fn().mockReturnValue('You are PRE, a helpful assistant.'),
  buildSystemPromptAsync: jest.fn().mockResolvedValue('You are PRE, a helpful assistant.'),
  getActiveConnections: jest.fn().mockReturnValue([]),
  isComfyUIInstalled: jest.fn().mockReturnValue(false),
}));

// Tools-defs — return minimal tool set (integration test tests the loop, not defs)
jest.mock('../src/tools-defs', () => ({
  buildToolDefs: jest.fn().mockReturnValue([
    { type: 'function', function: { name: 'bash', description: 'Run a shell command', parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] } } },
    { type: 'function', function: { name: 'read_file', description: 'Read a file', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } } },
    { type: 'function', function: { name: 'system_info', description: 'Get system info', parameters: { type: 'object', properties: {} } } },
    { type: 'function', function: { name: 'disk_usage', description: 'Get disk usage', parameters: { type: 'object', properties: {} } } },
    { type: 'function', function: { name: 'hardware_info', description: 'Get hardware info', parameters: { type: 'object', properties: {} } } },
    { type: 'function', function: { name: 'memory_list', description: 'List memories', parameters: { type: 'object', properties: {} } } },
  ]),
}));

// MCP — no external tools
jest.mock('../src/mcp', () => ({
  getConnectedTools: jest.fn().mockReturnValue([]),
  getAllTools: jest.fn().mockReturnValue([]),
  loadConfig: jest.fn().mockReturnValue({ servers: {} }),
}));

// Custom tools — none
jest.mock('../src/custom-tools', () => ({
  loadCustomTools: jest.fn().mockReturnValue([]),
  getCustomToolFn: jest.fn().mockReturnValue(null),
}));

// Background intelligence — no-ops
jest.mock('../src/memory', () => ({
  autoExtract: jest.fn().mockResolvedValue([]),
}));
jest.mock('../src/experience', () => ({
  reflect: jest.fn().mockResolvedValue([]),
  getRecentLessons: jest.fn().mockReturnValue([]),
}));
jest.mock('../src/skills', () => ({
  analyzeForSkills: jest.fn().mockResolvedValue([]),
  getSkillContext: jest.fn().mockReturnValue(''),
}));
jest.mock('../src/knowledge-graph', () => ({
  autoExtractGraph: jest.fn().mockResolvedValue(undefined),
  buildGraphContext: jest.fn().mockResolvedValue(''),
}));
jest.mock('../src/calibration', () => ({
  detectFeedback: jest.fn(),
  getContextBlock: jest.fn().mockReturnValue(''),
}));

// Hooks — no-ops
jest.mock('../src/hooks', () => ({
  runHooks: jest.fn().mockReturnValue({ blocked: false }),
  loadHooks: jest.fn().mockReturnValue({}),
}));

// Argus — no-ops
jest.mock('../src/argus', () => ({
  observeEvent: jest.fn(),
  setToolLoopActive: jest.fn(),
  getReaction: jest.fn().mockResolvedValue(null),
}));

// Telemetry — no-ops
jest.mock('../src/telemetry', () => ({
  record: jest.fn(),
}));

// Chronos — no-ops
jest.mock('../src/chronos', () => ({
  getTimeContext: jest.fn().mockReturnValue(''),
}));

// Compression — passthrough
jest.mock('../src/compression', () => ({
  compressIfNeeded: jest.fn().mockImplementation((_sid, history) => Promise.resolve(history)),
  calibrate: jest.fn(),
}));

// Router — always standard tier
jest.mock('../src/router', () => ({
  classify: jest.fn().mockReturnValue({ tier: 'standard', reason: 'test', strippedMessage: null }),
  getTier: jest.fn().mockReturnValue({ tools: true }),
  getModelForTier: jest.fn().mockReturnValue(null),
}));

// Connections — local ollama provider
jest.mock('../src/connections', () => ({
  getProvider: jest.fn().mockReturnValue({ type: 'ollama' }),
}));

// Tool tiers — all tools active
jest.mock('../src/tool-tiers', () => ({
  getActiveDomains: jest.fn().mockReturnValue(new Set(['core', 'intelligence'])),
  resolveKeywords: jest.fn(),
}));

// Inference queue — immediate slot acquisition
jest.mock('../src/inference-queue', () => ({
  slotManager: {
    acquireSlot: jest.fn().mockResolvedValue({ release: jest.fn() }),
  },
}));

// ── Requires (after mocks) ──

const ollama = require('../src/ollama');
const { runToolLoop } = require('../src/tools');
const sessions = require('../src/sessions');

// ── Test setup ──

beforeAll(() => {
  const sessionsDir = path.join(mockTestDir, 'sessions');
  fs.mkdirSync(sessionsDir, { recursive: true });
});

afterAll(() => {
  fs.rmSync(mockTestDir, { recursive: true, force: true });
});

// ── Helpers ──

let sessionCounter = 0;
function freshSessionId() {
  return `test-loop-${Date.now()}-${++sessionCounter}`;
}

/** Configure mock to return a simple text response (no tool calls). */
function mockTextResponse(text) {
  ollama.streamChat.mockResolvedValueOnce({
    response: text,
    thinking: '',
    toolCalls: null,
    stats: { eval_count: 10, eval_duration: 1e8, prompt_eval_count: 50, prompt_eval_duration: 5e8 },
  });
}

/** Configure mock to return a tool call on first invocation, text on second. */
function mockToolThenText(toolCalls, finalText) {
  ollama.streamChat
    .mockResolvedValueOnce({
      response: '',
      thinking: '',
      toolCalls,
      stats: { eval_count: 5, eval_duration: 5e7, prompt_eval_count: 40, prompt_eval_duration: 4e8 },
    })
    .mockResolvedValueOnce({
      response: finalText,
      thinking: '',
      toolCalls: null,
      stats: { eval_count: 20, eval_duration: 2e8, prompt_eval_count: 80, prompt_eval_duration: 8e8 },
    });
}

/** Configure mock to return multiple tool-call turns then text. */
function mockMultiToolThenText(toolCallRounds, finalText) {
  for (const calls of toolCallRounds) {
    ollama.streamChat.mockResolvedValueOnce({
      response: '',
      thinking: '',
      toolCalls: calls,
      stats: { eval_count: 5, eval_duration: 5e7, prompt_eval_count: 40, prompt_eval_duration: 4e8 },
    });
  }
  ollama.streamChat.mockResolvedValueOnce({
    response: finalText,
    thinking: '',
    toolCalls: null,
    stats: { eval_count: 20, eval_duration: 2e8, prompt_eval_count: 80, prompt_eval_duration: 8e8 },
  });
}

// ── Tests ──

describe('runToolLoop — agentic integration', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  // ── Basic chat (no tools) ──

  describe('basic chat (no tool calls)', () => {
    test('streams response and emits done event', async () => {
      const sessionId = freshSessionId();
      sessions.appendMessage(sessionId, { role: 'user', content: 'What is 2+2?' });

      mockTextResponse('The answer is 4.');

      const events = [];
      await runToolLoop({
        sessionId,
        cwd: '/tmp',
        send: (e) => events.push(e),
        signal: null,
        onConfirmRequest: null,
        userMessage: 'What is 2+2?',
        needsTitle: false,
      });

      // Should have a done event with stats
      const doneEvent = events.find(e => e.type === 'done');
      expect(doneEvent).toBeDefined();
      expect(doneEvent.stats).toBeDefined();
      expect(doneEvent.context).toBeDefined();
      expect(doneEvent.context.max).toBe(8192);

      // Should have called streamChat exactly once
      expect(ollama.streamChat).toHaveBeenCalledTimes(1);

      // Session should have the assistant message persisted
      const history = sessions.getSessionMessages(sessionId);
      const assistantMsg = history.find(m => m.role === 'assistant');
      expect(assistantMsg).toBeDefined();
      expect(assistantMsg.content).toBe('The answer is 4.');
    });

    test('handles thinking tokens in response', async () => {
      const sessionId = freshSessionId();
      sessions.appendMessage(sessionId, { role: 'user', content: 'complex question' });

      ollama.streamChat.mockResolvedValueOnce({
        response: 'After analysis, the answer is yes.',
        thinking: 'Let me reason through this step by step...',
        toolCalls: null,
        stats: { eval_count: 30, eval_duration: 3e8, prompt_eval_count: 50, prompt_eval_duration: 5e8 },
      });

      const events = [];
      await runToolLoop({
        sessionId,
        cwd: '/tmp',
        send: (e) => events.push(e),
        signal: null,
        onConfirmRequest: null,
        userMessage: 'complex question',
        needsTitle: false,
      });

      expect(events.find(e => e.type === 'done')).toBeDefined();
      expect(ollama.streamChat).toHaveBeenCalledTimes(1);
    });
  });

  // ── Single tool call ──

  describe('single tool call', () => {
    test('executes bash tool and feeds result back to model', async () => {
      const sessionId = freshSessionId();
      sessions.appendMessage(sessionId, { role: 'user', content: 'list files in /tmp' });

      mockToolThenText(
        [{ function: { name: 'bash', arguments: { command: 'ls /tmp' } } }],
        'The /tmp directory contains several files.'
      );

      const events = [];
      await runToolLoop({
        sessionId,
        cwd: '/tmp',
        send: (e) => events.push(e),
        signal: null,
        onConfirmRequest: null,
        userMessage: 'list files in /tmp',
        needsTitle: false,
      });

      // Model called twice: once producing tool call, once producing final response
      expect(ollama.streamChat).toHaveBeenCalledTimes(2);

      // Second call should include the tool result in messages
      const secondCall = ollama.streamChat.mock.calls[1][0];
      const toolResultMsg = secondCall.messages.find(m => m.role === 'tool');
      expect(toolResultMsg).toBeDefined();
      expect(toolResultMsg.content).toBeDefined(); // bash output

      // Session should have tool result message persisted
      const history = sessions.getSessionMessages(sessionId);
      const toolMsg = history.find(m => m.role === 'tool');
      expect(toolMsg).toBeDefined();

      // Should have both done_partial (after tool call) and done (final)
      expect(events.filter(e => e.type === 'done_partial').length).toBeGreaterThanOrEqual(1);
      expect(events.find(e => e.type === 'done')).toBeDefined();
    });

    test('tool call result is included in next inference call messages', async () => {
      const sessionId = freshSessionId();
      sessions.appendMessage(sessionId, { role: 'user', content: 'read a file' });

      mockToolThenText(
        [{ function: { name: 'read_file', arguments: { path: '/tmp/nonexistent-test-file.txt' } } }],
        'The file does not exist.'
      );

      await runToolLoop({
        sessionId,
        cwd: '/tmp',
        send: () => {},
        signal: null,
        onConfirmRequest: null,
        userMessage: 'read a file',
        needsTitle: false,
      });

      // Verify second streamChat call has the tool result in messages
      const secondCallMessages = ollama.streamChat.mock.calls[1][0].messages;
      // Should have: system, user, assistant (with tool_calls), tool (result)
      const roles = secondCallMessages.map(m => m.role);
      expect(roles).toContain('system');
      expect(roles).toContain('user');
      expect(roles).toContain('assistant');
      expect(roles).toContain('tool');
    });

    test('tool error is captured and fed back to model', async () => {
      const sessionId = freshSessionId();
      sessions.appendMessage(sessionId, { role: 'user', content: 'read nonexistent' });

      mockToolThenText(
        [{ function: { name: 'read_file', arguments: { path: '/nonexistent/path/xyz.txt' } } }],
        'Sorry, that file does not exist.'
      );

      const events = [];
      await runToolLoop({
        sessionId,
        cwd: '/tmp',
        send: (e) => events.push(e),
        signal: null,
        onConfirmRequest: null,
        userMessage: 'read nonexistent',
        needsTitle: false,
      });

      // Model should still get called twice and produce final response
      expect(ollama.streamChat).toHaveBeenCalledTimes(2);
      expect(events.find(e => e.type === 'done')).toBeDefined();

      // Tool result message should contain error text
      const secondCallMessages = ollama.streamChat.mock.calls[1][0].messages;
      const toolResult = secondCallMessages.find(m => m.role === 'tool');
      expect(toolResult).toBeDefined();
      // The tool result should exist (may contain error text from readFile)
      expect(typeof toolResult.content).toBe('string');
    });
  });

  // ── Multi-turn tool loop ──

  describe('multi-turn tool loop', () => {
    test('handles two sequential tool calls', async () => {
      const sessionId = freshSessionId();
      sessions.appendMessage(sessionId, { role: 'user', content: 'check system' });

      mockMultiToolThenText(
        [
          [{ function: { name: 'system_info', arguments: {} } }],
          [{ function: { name: 'disk_usage', arguments: {} } }],
        ],
        'System looks healthy with plenty of disk space.'
      );

      const events = [];
      await runToolLoop({
        sessionId,
        cwd: '/tmp',
        send: (e) => events.push(e),
        signal: null,
        onConfirmRequest: null,
        userMessage: 'check system',
        needsTitle: false,
      });

      // Three streamChat calls: tool1, tool2, final response
      expect(ollama.streamChat).toHaveBeenCalledTimes(3);

      // Session should have both tool results
      const history = sessions.getSessionMessages(sessionId);
      const toolMessages = history.filter(m => m.role === 'tool');
      expect(toolMessages.length).toBe(2);

      // Final done event
      expect(events.find(e => e.type === 'done')).toBeDefined();
    });

    test('parallel tool calls in single turn', async () => {
      const sessionId = freshSessionId();
      sessions.appendMessage(sessionId, { role: 'user', content: 'get system and disk info' });

      // Model returns two tool calls in a single response
      mockToolThenText(
        [
          { function: { name: 'system_info', arguments: {} } },
          { function: { name: 'hardware_info', arguments: {} } },
        ],
        'Here is your system and hardware information.'
      );

      await runToolLoop({
        sessionId,
        cwd: '/tmp',
        send: () => {},
        signal: null,
        onConfirmRequest: null,
        userMessage: 'get system and disk info',
        needsTitle: false,
      });

      // Two streamChat calls: one for parallel tool calls, one for final
      expect(ollama.streamChat).toHaveBeenCalledTimes(2);

      // Both tool results are combined into a single tool message (tools.js combines parallel results)
      const history = sessions.getSessionMessages(sessionId);
      const toolMessages = history.filter(m => m.role === 'tool');
      expect(toolMessages.length).toBe(1);
      // The combined message should contain both tool responses
      expect(toolMessages[0].content).toContain('system_info');
      expect(toolMessages[0].content).toContain('hardware_info');
    });
  });

  // ── Session persistence ──

  describe('session persistence', () => {
    test('full conversation is persisted to session JSONL', async () => {
      const sessionId = freshSessionId();
      sessions.appendMessage(sessionId, { role: 'user', content: 'hello' });

      mockTextResponse('Hi there! How can I help?');

      await runToolLoop({
        sessionId,
        cwd: '/tmp',
        send: () => {},
        signal: null,
        onConfirmRequest: null,
        userMessage: 'hello',
        needsTitle: false,
      });

      const history = sessions.getSessionMessages(sessionId);
      // Should have user message + assistant response
      expect(history.length).toBeGreaterThanOrEqual(2);
      expect(history[0].role).toBe('user');
      expect(history[0].content).toBe('hello');
      expect(history[history.length - 1].role).toBe('assistant');
      expect(history[history.length - 1].content).toBe('Hi there! How can I help?');
    });

    test('tool call round trip is fully persisted', async () => {
      const sessionId = freshSessionId();
      sessions.appendMessage(sessionId, { role: 'user', content: 'what time is it' });

      mockToolThenText(
        [{ function: { name: 'bash', arguments: { command: 'date' } } }],
        'The current time is shown above.'
      );

      await runToolLoop({
        sessionId,
        cwd: '/tmp',
        send: () => {},
        signal: null,
        onConfirmRequest: null,
        userMessage: 'what time is it',
        needsTitle: false,
      });

      const history = sessions.getSessionMessages(sessionId);
      const roles = history.map(m => m.role);

      // Expected sequence: user → assistant (with tool_calls) → tool (result) → assistant (final)
      expect(roles[0]).toBe('user');
      expect(roles[1]).toBe('assistant');
      expect(history[1].tool_calls).toBeDefined();
      expect(roles[2]).toBe('tool');
      expect(roles[3]).toBe('assistant');
      expect(history[3].content).toBe('The current time is shown above.');
    });
  });

  // ── Abort signal ──

  describe('abort signal', () => {
    test('aborted signal stops the loop before tool execution', async () => {
      const sessionId = freshSessionId();
      sessions.appendMessage(sessionId, { role: 'user', content: 'do something' });

      // Pre-aborted signal
      const controller = new AbortController();
      controller.abort();

      await runToolLoop({
        sessionId,
        cwd: '/tmp',
        send: () => {},
        signal: controller.signal,
        onConfirmRequest: null,
        userMessage: 'do something',
        needsTitle: false,
      });

      // Should not have called streamChat at all (aborted before first turn)
      expect(ollama.streamChat).not.toHaveBeenCalled();
    });
  });

  // ── Tool alias resolution ──

  describe('tool alias resolution', () => {
    test('resolves tool name aliases (e.g., shell → bash)', async () => {
      const sessionId = freshSessionId();
      sessions.appendMessage(sessionId, { role: 'user', content: 'run a command' });

      // Model calls "shell" which should be aliased to "bash"
      mockToolThenText(
        [{ function: { name: 'shell', arguments: { command: 'echo test' } } }],
        'Command executed successfully.'
      );

      const events = [];
      await runToolLoop({
        sessionId,
        cwd: '/tmp',
        send: (e) => events.push(e),
        signal: null,
        onConfirmRequest: null,
        userMessage: 'run a command',
        needsTitle: false,
      });

      // Should complete successfully (alias resolved, tool executed)
      expect(ollama.streamChat).toHaveBeenCalledTimes(2);
      expect(events.find(e => e.type === 'done')).toBeDefined();
    });

    test('strips domain prefix from tool names', async () => {
      const sessionId = freshSessionId();
      sessions.appendMessage(sessionId, { role: 'user', content: 'check memory' });

      // Model calls "intelligence:memory_list" with domain prefix
      mockToolThenText(
        [{ function: { name: 'intelligence:memory_list', arguments: {} } }],
        'Here are your memories.'
      );

      await runToolLoop({
        sessionId,
        cwd: '/tmp',
        send: () => {},
        signal: null,
        onConfirmRequest: null,
        userMessage: 'check memory',
        needsTitle: false,
      });

      // Should resolve to memory_list and execute
      expect(ollama.streamChat).toHaveBeenCalledTimes(2);
    });
  });

  // ── Deduplication ──

  describe('tool call deduplication', () => {
    test('deduplicates identical tool calls in same turn', async () => {
      const sessionId = freshSessionId();
      sessions.appendMessage(sessionId, { role: 'user', content: 'get info' });

      // Model emits the same tool call twice
      mockToolThenText(
        [
          { function: { name: 'system_info', arguments: {} } },
          { function: { name: 'system_info', arguments: {} } },
        ],
        'System information retrieved.'
      );

      await runToolLoop({
        sessionId,
        cwd: '/tmp',
        send: () => {},
        signal: null,
        onConfirmRequest: null,
        userMessage: 'get info',
        needsTitle: false,
      });

      // Only one tool result should be in session (duplicate filtered)
      const history = sessions.getSessionMessages(sessionId);
      const toolMessages = history.filter(m => m.role === 'tool');
      expect(toolMessages.length).toBe(1);
    });
  });

  // ── onToken callback (streaming) ──

  describe('streaming events', () => {
    test('onToken events are forwarded via send callback', async () => {
      const sessionId = freshSessionId();
      sessions.appendMessage(sessionId, { role: 'user', content: 'hello' });

      // Mock streamChat to invoke onToken callback
      ollama.streamChat.mockImplementationOnce(async (opts) => {
        if (opts.onToken) {
          opts.onToken({ type: 'token', content: 'Hello' });
          opts.onToken({ type: 'token', content: ' world' });
        }
        return {
          response: 'Hello world',
          thinking: '',
          toolCalls: null,
          stats: { eval_count: 2, eval_duration: 1e8, prompt_eval_count: 50, prompt_eval_duration: 5e8 },
        };
      });

      const events = [];
      await runToolLoop({
        sessionId,
        cwd: '/tmp',
        send: (e) => events.push(e),
        signal: null,
        onConfirmRequest: null,
        userMessage: 'hello',
        needsTitle: false,
      });

      // Token events should have been forwarded
      const tokenEvents = events.filter(e => e.type === 'token');
      expect(tokenEvents.length).toBe(2);
      expect(tokenEvents[0].content).toBe('Hello');
      expect(tokenEvents[1].content).toBe(' world');

      // Done event still emitted
      expect(events.find(e => e.type === 'done')).toBeDefined();
    });
  });

  // ── Empty response nudge ──

  describe('empty response nudge', () => {
    test('nudges model when thinking consumed entire budget', async () => {
      const sessionId = freshSessionId();
      sessions.appendMessage(sessionId, { role: 'user', content: 'analyze something complex' });

      // First call: lots of thinking, empty response
      ollama.streamChat.mockResolvedValueOnce({
        response: '',
        thinking: 'A'.repeat(600), // >500 chars triggers nudge
        toolCalls: null,
        stats: { eval_count: 100, eval_duration: 1e9, prompt_eval_count: 50, prompt_eval_duration: 5e8 },
      });

      // Second call: actual response after nudge
      mockTextResponse('Here is my thorough analysis.');

      const events = [];
      await runToolLoop({
        sessionId,
        cwd: '/tmp',
        send: (e) => events.push(e),
        signal: null,
        onConfirmRequest: null,
        userMessage: 'analyze something complex',
        needsTitle: false,
      });

      // Should have called streamChat twice (nudge triggered a second call)
      expect(ollama.streamChat).toHaveBeenCalledTimes(2);

      // Final response should be the nudged response
      const history = sessions.getSessionMessages(sessionId);
      const lastAssistant = [...history].reverse().find(m => m.role === 'assistant');
      expect(lastAssistant.content).toBe('Here is my thorough analysis.');
    });
  });

  // ── Context window stats ──

  describe('context tracking', () => {
    test('done event includes context usage stats', async () => {
      const sessionId = freshSessionId();
      sessions.appendMessage(sessionId, { role: 'user', content: 'test' });

      mockTextResponse('Response.');

      const events = [];
      await runToolLoop({
        sessionId,
        cwd: '/tmp',
        send: (e) => events.push(e),
        signal: null,
        onConfirmRequest: null,
        userMessage: 'test',
        needsTitle: false,
      });

      const done = events.find(e => e.type === 'done');
      expect(done.context).toBeDefined();
      expect(done.context.max).toBe(8192);
      expect(typeof done.context.used).toBe('number');
      expect(typeof done.context.pct).toBe('number');
    });
  });
});
