// src/smart-agent/api-adapters/__tests__/anthropic-adapter.test.ts

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type {
  LlmStreamChunk,
  OrchestratorError,
  Result,
  SmartAgentResponse,
} from '@mcp-abap-adt/llm-agent';
import { buildExternalResults } from '../../external-results.js';
import type {
  ApiRequestContext,
  ApiSseEvent,
} from '../../interfaces/api-adapter.js';
import { AdapterValidationError } from '../../interfaces/api-adapter.js';
import { AnthropicApiAdapter } from '../anthropic-adapter.js';

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

function makeContext(
  overrides?: Partial<ApiRequestContext>,
): ApiRequestContext {
  return {
    adapterName: 'anthropic',
    protocol: {
      id: 'msg_test-id',
      model: 'test-model',
    },
    ...overrides,
  };
}

type ChunkResult = Result<LlmStreamChunk, OrchestratorError>;

async function collectEvents(
  adapter: AnthropicApiAdapter,
  chunks: ChunkResult[],
  ctx: ApiRequestContext,
): Promise<ApiSseEvent[]> {
  async function* source(): AsyncIterable<ChunkResult> {
    for (const c of chunks) yield c;
  }
  const events: ApiSseEvent[] = [];
  for await (const e of adapter.transformStream(source(), ctx)) {
    events.push(e);
  }
  return events;
}

function ok(value: LlmStreamChunk): ChunkResult {
  return { ok: true, value };
}

function err(message: string, code = 'ORCHESTRATOR_ERROR'): ChunkResult {
  const error = new Error(message) as OrchestratorError;
  error.code = code;
  error.name = 'OrchestratorError';
  return { ok: false, error };
}

function parseData(event: ApiSseEvent): unknown {
  return JSON.parse(event.data);
}

// ---------------------------------------------------------------------------
// normalizeRequest
// ---------------------------------------------------------------------------

describe('AnthropicApiAdapter.normalizeRequest', () => {
  const adapter = new AnthropicApiAdapter();

  it('parses a basic request', () => {
    const result = adapter.normalizeRequest({
      messages: [{ role: 'user', content: 'hello' }],
    });

    assert.equal(result.messages.length, 1);
    assert.equal(result.messages[0].role, 'user');
    assert.equal(result.messages[0].content, 'hello');
    assert.equal(result.stream, false);
    assert.ok(result.context.protocol.id);
    assert.match(result.context.protocol.id as string, /^msg_/);
    assert.equal(result.context.adapterName, 'anthropic');
    assert.equal(result.context.protocol.model, 'smart-agent');
  });

  it('extracts system string as system message', () => {
    const result = adapter.normalizeRequest({
      messages: [{ role: 'user', content: 'hi' }],
      system: 'You are a helpful assistant.',
    });

    assert.equal(result.messages.length, 2);
    assert.equal(result.messages[0].role, 'system');
    assert.equal(result.messages[0].content, 'You are a helpful assistant.');
    assert.equal(result.messages[1].role, 'user');
  });

  it('extracts system content blocks as system message', () => {
    const result = adapter.normalizeRequest({
      messages: [{ role: 'user', content: 'hi' }],
      system: [
        { type: 'text', text: 'First instruction.' },
        { type: 'text', text: 'Second instruction.' },
      ],
    });

    assert.equal(result.messages.length, 2);
    assert.equal(result.messages[0].role, 'system');
    assert.equal(
      result.messages[0].content,
      'First instruction.\nSecond instruction.',
    );
  });

  it('handles user content blocks with text', () => {
    const result = adapter.normalizeRequest({
      messages: [
        {
          role: 'user',
          content: [
            { type: 'text', text: 'Part one.' },
            { type: 'text', text: 'Part two.' },
          ],
        },
      ],
    });

    assert.equal(result.messages.length, 1);
    assert.equal(result.messages[0].role, 'user');
    assert.equal(result.messages[0].content, 'Part one.\nPart two.');
  });

  it('converts tool_result blocks to tool messages', () => {
    const result = adapter.normalizeRequest({
      messages: [
        {
          role: 'user',
          content: [
            {
              type: 'tool_result',
              tool_use_id: 'toolu_123',
              content: 'Result data',
            },
            { type: 'text', text: 'Continue please.' },
          ],
        },
      ],
    });

    assert.equal(result.messages.length, 2);
    // tool_result becomes a tool message
    assert.equal(result.messages[0].role, 'tool');
    assert.equal(result.messages[0].tool_call_id, 'toolu_123');
    assert.equal(result.messages[0].content, 'Result data');
    // text becomes a user message
    assert.equal(result.messages[1].role, 'user');
    assert.equal(result.messages[1].content, 'Continue please.');
  });

  it('converts assistant tool_use blocks', () => {
    const result = adapter.normalizeRequest({
      messages: [
        {
          role: 'assistant',
          content: [
            { type: 'text', text: 'Let me check.' },
            {
              type: 'tool_use',
              id: 'toolu_456',
              name: 'get_weather',
              input: { city: 'NYC' },
            },
          ],
        },
      ],
    });

    assert.equal(result.messages.length, 1);
    const msg = result.messages[0];
    assert.equal(msg.role, 'assistant');
    assert.equal(msg.content, 'Let me check.');
    assert.ok(msg.tool_calls);
    assert.equal(msg.tool_calls?.length, 1);
    assert.equal(msg.tool_calls?.[0].id, 'toolu_456');
    assert.equal(msg.tool_calls?.[0].type, 'function');
    assert.equal(msg.tool_calls?.[0].function.name, 'get_weather');
    assert.equal(
      msg.tool_calls?.[0].function.arguments,
      JSON.stringify({ city: 'NYC' }),
    );
  });

  it('extracts tools into options.externalTools', () => {
    const tools = [
      {
        name: 'get_weather',
        description: 'Get weather',
        input_schema: { type: 'object' },
      },
    ];
    const result = adapter.normalizeRequest({
      messages: [{ role: 'user', content: 'hi' }],
      tools,
    });

    assert.deepEqual(result.options?.externalTools, tools);
  });

  it('extracts model and options', () => {
    const result = adapter.normalizeRequest({
      messages: [{ role: 'user', content: 'hi' }],
      model: 'claude-sonnet-4-20250514',
      temperature: 0.5,
      max_tokens: 2048,
      top_p: 0.9,
      stream: true,
    });

    assert.equal(result.context.protocol.model, 'claude-sonnet-4-20250514');
    assert.equal(result.options?.model, 'claude-sonnet-4-20250514');
    assert.equal(result.options?.temperature, 0.5);
    assert.equal(result.options?.maxTokens, 2048);
    assert.equal(result.options?.topP, 0.9);
    assert.equal(result.stream, true);
  });

  it('throws AdapterValidationError for non-object body', () => {
    assert.throws(
      () => adapter.normalizeRequest('not an object'),
      AdapterValidationError,
    );
  });

  it('throws AdapterValidationError for null body', () => {
    assert.throws(() => adapter.normalizeRequest(null), AdapterValidationError);
  });

  it('throws AdapterValidationError when messages is missing', () => {
    assert.throws(
      () => adapter.normalizeRequest({ model: 'claude-sonnet-4-20250514' }),
      AdapterValidationError,
    );
  });

  it('throws AdapterValidationError when messages is empty', () => {
    assert.throws(
      () => adapter.normalizeRequest({ messages: [] }),
      AdapterValidationError,
    );
  });
});

// ---------------------------------------------------------------------------
// formatResult
// ---------------------------------------------------------------------------

describe('AnthropicApiAdapter.formatResult', () => {
  const adapter = new AnthropicApiAdapter();
  const ctx = makeContext();

  it('formats a text response', () => {
    const response: SmartAgentResponse = {
      content: 'Hello!',
      iterations: 1,
      toolCallCount: 0,
      stopReason: 'stop',
      usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
    };

    const result = adapter.formatResult(response, ctx) as Record<
      string,
      unknown
    >;

    assert.equal(result.id, 'msg_test-id');
    assert.equal(result.type, 'message');
    assert.equal(result.role, 'assistant');
    assert.equal(result.model, 'test-model');
    assert.equal(result.stop_reason, 'end_turn');

    const content = result.content as Array<Record<string, unknown>>;
    assert.equal(content.length, 1);
    assert.equal(content[0].type, 'text');
    assert.equal(content[0].text, 'Hello!');

    const usage = result.usage as Record<string, number>;
    assert.equal(usage.input_tokens, 10);
    assert.equal(usage.output_tokens, 5);
  });

  it('formats a response with tool_use', () => {
    const response: SmartAgentResponse = {
      content: 'Let me check.',
      iterations: 1,
      toolCallCount: 1,
      stopReason: 'tool_calls',
      toolCalls: [
        {
          id: 'toolu_1',
          type: 'function',
          function: { name: 'get_weather', arguments: '{"city":"NYC"}' },
        },
      ],
    };

    const result = adapter.formatResult(response, ctx) as Record<
      string,
      unknown
    >;
    assert.equal(result.stop_reason, 'tool_use');

    const content = result.content as Array<Record<string, unknown>>;
    assert.equal(content.length, 2);
    assert.equal(content[0].type, 'text');
    assert.equal(content[0].text, 'Let me check.');
    assert.equal(content[1].type, 'tool_use');
    assert.equal(content[1].id, 'toolu_1');
    assert.equal(content[1].name, 'get_weather');
    assert.deepEqual(content[1].input, { city: 'NYC' });
  });

  it('maps iteration_limit to max_tokens', () => {
    const response: SmartAgentResponse = {
      content: 'partial',
      iterations: 10,
      toolCallCount: 5,
      stopReason: 'iteration_limit',
    };

    const result = adapter.formatResult(response, ctx) as Record<
      string,
      unknown
    >;
    assert.equal(result.stop_reason, 'max_tokens');
  });

  it('defaults usage to zeros when not provided', () => {
    const response: SmartAgentResponse = {
      content: 'ok',
      iterations: 1,
      toolCallCount: 0,
      stopReason: 'stop',
    };

    const result = adapter.formatResult(response, ctx) as Record<
      string,
      unknown
    >;
    const usage = result.usage as Record<string, number>;
    assert.equal(usage.input_tokens, 0);
    assert.equal(usage.output_tokens, 0);
  });
});

// ---------------------------------------------------------------------------
// formatError
// ---------------------------------------------------------------------------

describe('AnthropicApiAdapter.formatError', () => {
  const adapter = new AnthropicApiAdapter();
  const ctx = makeContext();

  it('formats an error in Anthropic format', () => {
    const error = new Error('Something failed') as OrchestratorError;
    error.code = 'ORCHESTRATOR_ERROR';
    error.name = 'OrchestratorError';

    const result = adapter.formatError(error, ctx) as Record<string, unknown>;

    assert.equal(result.type, 'error');
    const inner = result.error as Record<string, unknown>;
    assert.equal(inner.type, 'api_error');
    assert.equal(inner.message, 'Something failed');
  });
});

// ---------------------------------------------------------------------------
// transformStream
// ---------------------------------------------------------------------------

describe('AnthropicApiAdapter.transformStream', () => {
  const adapter = new AnthropicApiAdapter();
  const ctx = makeContext();

  it('emits correct event sequence for text content', async () => {
    const chunks: ChunkResult[] = [
      ok({ content: 'Hello' }),
      ok({ content: ' world' }),
      ok({ content: '', finishReason: 'stop' }),
    ];

    const events = await collectEvents(adapter, chunks, ctx);

    // message_start
    assert.equal(events[0].event, 'message_start');
    const msgStart = parseData(events[0]) as Record<string, unknown>;
    assert.equal(msgStart.type, 'message_start');
    const message = msgStart.message as Record<string, unknown>;
    assert.equal(message.id, 'msg_test-id');
    assert.equal(message.role, 'assistant');

    // content_block_start (text)
    assert.equal(events[1].event, 'content_block_start');
    const blockStart = parseData(events[1]) as Record<string, unknown>;
    assert.equal(blockStart.index, 0);
    assert.equal(
      (blockStart.content_block as Record<string, unknown>).type,
      'text',
    );

    // content_block_delta "Hello"
    assert.equal(events[2].event, 'content_block_delta');
    const delta1 = parseData(events[2]) as Record<string, unknown>;
    assert.equal((delta1.delta as Record<string, unknown>).type, 'text_delta');
    assert.equal((delta1.delta as Record<string, unknown>).text, 'Hello');

    // content_block_delta " world"
    assert.equal(events[3].event, 'content_block_delta');
    const delta2 = parseData(events[3]) as Record<string, unknown>;
    assert.equal((delta2.delta as Record<string, unknown>).text, ' world');

    // content_block_stop
    assert.equal(events[4].event, 'content_block_stop');

    // message_delta with stop_reason
    assert.equal(events[5].event, 'message_delta');
    const msgDelta = parseData(events[5]) as Record<string, unknown>;
    assert.equal(
      (msgDelta.delta as Record<string, unknown>).stop_reason,
      'end_turn',
    );

    // message_stop
    assert.equal(events[6].event, 'message_stop');
    assert.equal(events.length, 7);
  });

  it('skips heartbeat-only chunks', async () => {
    const chunks: ChunkResult[] = [
      ok({ content: 'Hi' }),
      ok({ content: '', heartbeat: { tool: 'search', elapsed: 100 } }),
      ok({ content: '', finishReason: 'stop' }),
    ];

    const events = await collectEvents(adapter, chunks, ctx);

    // Should have: message_start, block_start, text_delta, block_stop, message_delta, message_stop
    assert.equal(events.length, 6);
    // No heartbeat events
    for (const e of events) {
      const data = parseData(e) as Record<string, unknown>;
      assert.notEqual(data.type, 'heartbeat');
    }
  });

  it('skips timing-only chunks', async () => {
    const chunks: ChunkResult[] = [
      ok({ content: 'Hi' }),
      ok({ content: '', timing: [{ phase: 'llm', duration: 500 }] }),
      ok({ content: '', finishReason: 'stop' }),
    ];

    const events = await collectEvents(adapter, chunks, ctx);
    assert.equal(events.length, 6);
  });

  it('emits tool_use blocks correctly', async () => {
    const chunks: ChunkResult[] = [
      ok({ content: 'Let me search.' }),
      ok({
        content: '',
        toolCalls: [
          { id: 'toolu_1', name: 'search', arguments: { q: 'test' } },
        ],
      }),
      ok({ content: '', finishReason: 'tool_calls' }),
    ];

    const events = await collectEvents(adapter, chunks, ctx);

    // message_start
    assert.equal(events[0].event, 'message_start');

    // text block: start, delta, stop
    assert.equal(events[1].event, 'content_block_start');
    const textBlock = parseData(events[1]) as Record<string, unknown>;
    assert.equal(
      (textBlock.content_block as Record<string, unknown>).type,
      'text',
    );
    assert.equal(events[2].event, 'content_block_delta');
    assert.equal(events[3].event, 'content_block_stop');

    // tool_use block: start, delta (input_json), stop
    assert.equal(events[4].event, 'content_block_start');
    const toolBlock = parseData(events[4]) as Record<string, unknown>;
    assert.equal(
      (toolBlock.content_block as Record<string, unknown>).type,
      'tool_use',
    );
    assert.equal(
      (toolBlock.content_block as Record<string, unknown>).id,
      'toolu_1',
    );
    assert.equal(
      (toolBlock.content_block as Record<string, unknown>).name,
      'search',
    );

    assert.equal(events[5].event, 'content_block_delta');
    const inputDelta = parseData(events[5]) as Record<string, unknown>;
    assert.equal(
      (inputDelta.delta as Record<string, unknown>).type,
      'input_json_delta',
    );
    assert.equal(
      (inputDelta.delta as Record<string, unknown>).partial_json,
      JSON.stringify({ q: 'test' }),
    );

    assert.equal(events[6].event, 'content_block_stop');

    // message_delta + message_stop
    assert.equal(events[7].event, 'message_delta');
    const msgDelta = parseData(events[7]) as Record<string, unknown>;
    assert.equal(
      (msgDelta.delta as Record<string, unknown>).stop_reason,
      'tool_use',
    );
    assert.equal(events[8].event, 'message_stop');
  });

  it('handles error chunks with closing events', async () => {
    const chunks: ChunkResult[] = [ok({ content: 'Start' }), err('LLM failed')];

    const events = await collectEvents(adapter, chunks, ctx);

    // message_start, block_start, text_delta "Start", block_stop, message_delta, message_stop
    assert.equal(events[0].event, 'message_start');
    assert.equal(events[1].event, 'content_block_start');
    assert.equal(events[2].event, 'content_block_delta');
    assert.equal(events[3].event, 'content_block_stop');
    assert.equal(events[4].event, 'message_delta');
    assert.equal(events[5].event, 'message_stop');
    assert.equal(events.length, 6);
  });

  it('emits closing events when stream ends without finishReason', async () => {
    const chunks: ChunkResult[] = [ok({ content: 'partial' })];

    const events = await collectEvents(adapter, chunks, ctx);

    // message_start, block_start, text_delta, block_stop, message_delta, message_stop
    assert.equal(events.length, 6);
    assert.equal(events[4].event, 'message_delta');
    const msgDelta = parseData(events[4]) as Record<string, unknown>;
    assert.equal(
      (msgDelta.delta as Record<string, unknown>).stop_reason,
      'end_turn',
    );
    assert.equal(events[5].event, 'message_stop');
  });

  it('includes usage in message_delta', async () => {
    const chunks: ChunkResult[] = [
      ok({ content: 'Hi' }),
      ok({
        content: '',
        finishReason: 'stop',
        usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
      }),
    ];

    const events = await collectEvents(adapter, chunks, ctx);

    // Find message_delta event
    const msgDeltaEvent = events.find((e) => e.event === 'message_delta');
    assert.ok(msgDeltaEvent);
    const msgDelta = parseData(msgDeltaEvent) as Record<string, unknown>;
    const usage = msgDelta.usage as Record<string, number>;
    assert.equal(usage.input_tokens, 10);
    assert.equal(usage.output_tokens, 5);
  });

  it('all events have event field set (Anthropic protocol)', async () => {
    const chunks: ChunkResult[] = [ok({ content: 'x', finishReason: 'stop' })];

    const events = await collectEvents(adapter, chunks, ctx);
    for (const e of events) {
      assert.ok(e.event, `Expected event field to be set, got: ${e.event}`);
    }
  });
});

// ---------------------------------------------------------------------------
// External-tool round-trip normalization (#171, review#8)
//
// An Anthropic tool round-trip (assistant tool_use -> user tool_result) must,
// after adapter normalization, look identical to the OpenAI-normalized shape
// that buildExternalResults consumes: assistant.tool_calls[{id}] followed by a
// role:'tool' message carrying tool_call_id. The deterministic `ext:` id then
// lets a stateless re-run correlate the client-executed result by id.
// ---------------------------------------------------------------------------

describe('AnthropicApiAdapter external-tool normalization', () => {
  const adapter = new AnthropicApiAdapter();

  it('normalizes a tool_use/tool_result round-trip into the OpenAI shape that buildExternalResults consumes', () => {
    const extId = 'ext:abc123';
    const request = {
      model: 'claude-test',
      messages: [
        { role: 'user', content: 'call the weather tool' },
        {
          role: 'assistant',
          content: [
            { type: 'text', text: 'Let me check.' },
            {
              type: 'tool_use',
              id: extId,
              name: 'get_weather',
              input: { city: 'Kyiv' },
            },
          ],
        },
        {
          role: 'user',
          content: [
            {
              type: 'tool_result',
              tool_use_id: extId,
              content: 'sunny, 21C',
            },
          ],
        },
      ],
    };

    const normalized = adapter.normalizeRequest(request);

    // Adapter produced the internal OpenAI shape.
    const assistant = normalized.messages.find((m) => m.role === 'assistant');
    assert.ok(assistant, 'expected an assistant message');
    assert.equal(assistant.tool_calls?.[0]?.id, extId);
    assert.equal(assistant.tool_calls?.[0]?.type, 'function');
    assert.equal(assistant.tool_calls?.[0]?.function.name, 'get_weather');

    const toolMsg = normalized.messages.find((m) => m.role === 'tool');
    assert.ok(toolMsg, 'expected a role:tool message');
    assert.equal(toolMsg.tool_call_id, extId);
    assert.equal(toolMsg.content, 'sunny, 21C');

    // buildExternalResults consumes those turns and keys the result by ext id.
    const { results, sanitizedMessages } = buildExternalResults(
      normalized.messages,
    );
    assert.equal(results.get(extId), 'sunny, 21C');
    // The consumed external assistant/tool turns are stripped from history.
    assert.equal(
      sanitizedMessages.some((m) => m.role === 'tool'),
      false,
    );
    assert.equal(
      sanitizedMessages.some(
        (m) => m.role === 'assistant' && (m.tool_calls?.length ?? 0) > 0,
      ),
      false,
    );
  });
});
