import { describe, it, expect, vi } from 'vitest';
import { EarlyToolDispatcher } from '../early-dispatcher.js';
import type { Tool, ToolContext, EngineEvent } from '../types.js';
import type { PendingToolCall } from '../orchestration.js';
import { z } from 'zod';

function makeTool(overrides: Partial<Tool> = {}): Tool {
  return {
    name: 'read_tool',
    description: 'test',
    inputSchema: z.object({}).passthrough(),
    jsonSchema: { type: 'object', properties: {} },
    call: vi.fn().mockResolvedValue({ data: { result: 'ok' } }),
    isReadOnly: true,
    isConcurrencySafe: true,
    permissionLevel: 'auto',
    flags: {},
    ...overrides,
  };
}

function makeCall(id: string, name: string, input: unknown = {}): PendingToolCall {
  return { id, name, input };
}

const ctx: ToolContext = {};

async function collectAll(gen: AsyncGenerator<EngineEvent>): Promise<EngineEvent[]> {
  const events: EngineEvent[] = [];
  for await (const e of gen) events.push(e);
  return events;
}

describe('EarlyToolDispatcher', () => {
  it('dispatches read-only tools and returns true', () => {
    const tool = makeTool();
    const dispatcher = new EarlyToolDispatcher([tool], ctx);
    const dispatched = dispatcher.tryDispatch(makeCall('t1', 'read_tool'));
    expect(dispatched).toBe(true);
    expect(dispatcher.hasPending()).toBe(true);
    expect(tool.call).toHaveBeenCalledOnce();
  });

  it('rejects write tools and returns false', () => {
    const tool = makeTool({ name: 'write_tool', isReadOnly: false, isConcurrencySafe: false, permissionLevel: 'confirm' });
    const dispatcher = new EarlyToolDispatcher([tool], ctx);
    const dispatched = dispatcher.tryDispatch(makeCall('t1', 'write_tool'));
    expect(dispatched).toBe(false);
    expect(dispatcher.hasPending()).toBe(false);
  });

  it('rejects unknown tools and returns false', () => {
    const dispatcher = new EarlyToolDispatcher([], ctx);
    const dispatched = dispatcher.tryDispatch(makeCall('t1', 'unknown'));
    expect(dispatched).toBe(false);
  });

  it('exposes original tool input by tool_use_id (needed for guard state recording)', () => {
    const tool = makeTool();
    const dispatcher = new EarlyToolDispatcher([tool], ctx);
    dispatcher.tryDispatch(makeCall('t1', 'read_tool', { from: 'USDC', to: 'SUI', amount: 1 }));

    expect(dispatcher.getInputById('t1')).toEqual({ from: 'USDC', to: 'SUI', amount: 1 });
    expect(dispatcher.getInputById('missing')).toBeUndefined();
  });

  it('collects results in dispatch order regardless of completion order', async () => {
    let resolveFirst!: (v: { data: unknown }) => void;
    let resolveSecond!: (v: { data: unknown }) => void;

    const slowTool = makeTool({
      name: 'slow_tool',
      call: () => new Promise((r) => { resolveFirst = r; }),
    });
    const fastTool = makeTool({
      name: 'fast_tool',
      call: () => new Promise((r) => { resolveSecond = r; }),
    });

    const dispatcher = new EarlyToolDispatcher([slowTool, fastTool], ctx);
    dispatcher.tryDispatch(makeCall('t1', 'slow_tool'));
    dispatcher.tryDispatch(makeCall('t2', 'fast_tool'));

    // Fast tool completes first
    resolveSecond({ data: { fast: true } });
    // Then slow tool
    resolveFirst({ data: { slow: true } });

    const events = await collectAll(dispatcher.collectResults());
    expect(events).toHaveLength(2);
    expect(events[0]).toMatchObject({ type: 'tool_result', toolName: 'slow_tool', toolUseId: 't1' });
    expect(events[1]).toMatchObject({ type: 'tool_result', toolName: 'fast_tool', toolUseId: 't2' });
    // [SPEC 23A-Q-source, 2026-05-11] Early-dispatched reads are still
    // LLM-driven (the LLM emitted the tool_use; the engine just chose to
    // dispatch it before the stream finished). source must be 'llm', not
    // 'pwr' or 'user'.
    for (const e of events) {
      expect(e.type === 'tool_result' && e.source).toBe('llm');
    }
  });

  it('handles tool execution errors gracefully', async () => {
    const failTool = makeTool({
      name: 'fail_tool',
      call: vi.fn().mockRejectedValue(new Error('network timeout')),
    });
    const dispatcher = new EarlyToolDispatcher([failTool], ctx);
    dispatcher.tryDispatch(makeCall('t1', 'fail_tool'));

    const events = await collectAll(dispatcher.collectResults());
    expect(events).toHaveLength(1);
    expect(events[0]).toMatchObject({
      type: 'tool_result',
      toolName: 'fail_tool',
      isError: true,
    });
    if (events[0].type === 'tool_result') {
      expect(events[0].result).toMatchObject({ error: 'network timeout' });
    }
  });

  it('applies tool result budgeting to early-dispatched tools', async () => {
    const bigResult = { data: { payload: 'x'.repeat(200) } };
    const tool = makeTool({
      name: 'big_tool',
      maxResultSizeChars: 50,
      call: vi.fn().mockResolvedValue(bigResult),
    });
    const dispatcher = new EarlyToolDispatcher([tool], ctx);
    dispatcher.tryDispatch(makeCall('t1', 'big_tool'));

    const events = await collectAll(dispatcher.collectResults());
    expect(events).toHaveLength(1);
    if (events[0].type === 'tool_result') {
      // [v1.5.2] Object payloads are wrapped in a `_truncated` envelope so
      // downstream consumers (frontend cards, replay logic) can still
      // destructure the result. See `budgetToolResult` in orchestration.ts.
      const r = events[0].result as { _truncated: boolean; _note: string };
      expect(typeof r).toBe('object');
      expect(r._truncated).toBe(true);
      expect(r._note).toContain('Truncated');
    }
  });

  it('tracks dispatched IDs', () => {
    const tool = makeTool();
    const dispatcher = new EarlyToolDispatcher([tool], ctx);
    dispatcher.tryDispatch(makeCall('t1', 'read_tool'));
    dispatcher.tryDispatch(makeCall('t2', 'read_tool'));

    const ids = dispatcher.dispatchedIds();
    expect(ids.has('t1')).toBe(true);
    expect(ids.has('t2')).toBe(true);
    expect(ids.has('t3')).toBe(false);
  });

  it('abort cancels in-flight tools', async () => {
    let capturedSignal: AbortSignal | undefined;
    const tool = makeTool({
      name: 'abort_tool',
      call: vi.fn().mockImplementation((_input, toolCtx) => {
        capturedSignal = toolCtx.signal;
        return new Promise((_, reject) => {
          if (toolCtx.signal?.aborted) reject(new Error('Aborted'));
          toolCtx.signal?.addEventListener('abort', () => reject(new Error('Aborted')));
        });
      }),
    });

    const dispatcher = new EarlyToolDispatcher([tool], ctx);
    dispatcher.tryDispatch(makeCall('t1', 'abort_tool'));
    dispatcher.abort();

    expect(capturedSignal?.aborted).toBe(true);

    const events = await collectAll(dispatcher.collectResults());
    expect(events).toHaveLength(1);
    expect(events[0]).toMatchObject({ type: 'tool_result', isError: true });
  });

  it('handles mixed read + write calls correctly', () => {
    const readTool = makeTool({ name: 'read_tool' });
    const writeTool = makeTool({ name: 'write_tool', isReadOnly: false, isConcurrencySafe: false, permissionLevel: 'confirm' });

    const dispatcher = new EarlyToolDispatcher([readTool, writeTool], ctx);

    expect(dispatcher.tryDispatch(makeCall('t1', 'read_tool'))).toBe(true);
    expect(dispatcher.tryDispatch(makeCall('t2', 'write_tool'))).toBe(false);
    expect(dispatcher.dispatchedIds().size).toBe(1);
  });

  it('returns empty results when nothing dispatched', async () => {
    const dispatcher = new EarlyToolDispatcher([], ctx);
    const events = await collectAll(dispatcher.collectResults());
    expect(events).toHaveLength(0);
  });

  // [S.123 v1.24.7] Regression: a thrown promise that sits in the dispatcher's
  // entries[] without being awaited for ≥1 microtask MUST NOT trigger Node's
  // unhandled-rejection detector. Before the .catch fix, this scenario crashed
  // the Vercel serverless function (process.exit(128)), killing every in-flight
  // request. The test simulates the timing race: throw + multiple ticks of
  // delay before collectResults() iterates.
  it('does NOT trigger unhandledRejection when a thrown promise sits in entries before collectResults()', async () => {
    const failTool = makeTool({
      name: 'ssui_swap_quote',
      call: vi.fn().mockImplementation(() => Promise.reject(new Error('Unknown token: SSUI. Provide the full coin type.'))),
    });

    const unhandledRejections: unknown[] = [];
    const handler = (reason: unknown) => unhandledRejections.push(reason);
    process.on('unhandledRejection', handler);

    try {
      const dispatcher = new EarlyToolDispatcher([failTool], ctx);
      dispatcher.tryDispatch(makeCall('t1', 'ssui_swap_quote'));

      // Simulate the LLM stream still running for several microtasks before
      // collectResults() is called. This is the EXACT timing race that crashed
      // the process before the .catch fix.
      for (let i = 0; i < 10; i++) {
        await new Promise((resolve) => setTimeout(resolve, 0));
      }

      // Node's unhandled-rejection detector fires at the END of microtask
      // queue drain. By this point, before the .catch fix, the rejection
      // would already be flagged as unhandled.
      expect(unhandledRejections).toHaveLength(0);

      // Now consume the results — rejection should be cleanly converted to
      // a structured tool_result event with isError: true.
      const events = await collectAll(dispatcher.collectResults());
      expect(events).toHaveLength(1);
      expect(events[0]).toMatchObject({ type: 'tool_result', isError: true });
      if (events[0].type === 'tool_result') {
        expect(events[0].result).toMatchObject({ error: 'Unknown token: SSUI. Provide the full coin type.' });
      }

      // Final check: still no unhandled rejection after consumption.
      expect(unhandledRejections).toHaveLength(0);
    } finally {
      process.off('unhandledRejection', handler);
    }
  });
});
