/**
 * Round-trip conversion tests: toAiMessages ↔ fromAiMessages
 *
 * These tests exercise the message split/merge logic without making real LLM
 * calls. They lock down the structural invariants that the agent runtime relies
 * on, including the key interim-message ordering guarantee described in the
 * plan:
 *
 *   input:  [assistant{tool-call resolved}, user{x}, assistant{y}]
 *   output: [assistant{tool-call}, tool{tool-result}, user{x}, assistant{y}]
 *
 * The tool-result is inserted right after its tool-call, regardless of what
 * messages follow it in the n8n list.
 */
import type { ModelMessage } from 'ai';
import { describe, it, expect } from 'vitest';

import type { Message } from '../../types/sdk/message';
import { toAiMessages, fromAiMessages } from '../model/messages';

describe('toAiMessages + fromAiMessages — round-trip', () => {
	it('splits a resolved tool-call into assistant + tool ModelMessages', () => {
		const input: Message[] = [
			{
				role: 'assistant',
				content: [
					{
						type: 'tool-call',
						toolCallId: 'tc-1',
						toolName: 'add',
						input: { a: 1, b: 2 },
						state: 'resolved',
						output: { result: 3 },
					},
				],
			},
		];

		const aiMessages = toAiMessages(input);

		expect(aiMessages).toHaveLength(2);
		expect(aiMessages[0].role).toBe('assistant');
		expect(aiMessages[1].role).toBe('tool');

		const toolCallPart = (
			aiMessages[0] as { role: string; content: Array<{ type: string; toolCallId: string }> }
		).content[0];
		expect(toolCallPart.type).toBe('tool-call');
		expect(toolCallPart.toolCallId).toBe('tc-1');

		const toolResultPart = (
			aiMessages[1] as {
				role: string;
				content: Array<{
					type: string;
					toolCallId: string;
					output: { type: string; value: unknown };
				}>;
			}
		).content[0];
		expect(toolResultPart.type).toBe('tool-result');
		expect(toolResultPart.toolCallId).toBe('tc-1');
		expect(toolResultPart.output.type).toBe('json');
		expect(toolResultPart.output.value).toEqual({ result: 3 });
	});

	it('preserves provider metadata on replayed assistant tool-call parts', () => {
		const providerMetadata = { google: { thoughtSignature: 'gemini-signature' } };
		const input: Message[] = [
			{
				role: 'assistant',
				content: [
					{
						type: 'tool-call',
						toolCallId: 'tc-1',
						toolName: 'load_skill',
						input: { skillId: 'agent-builder-tools' },
						providerMetadata,
						state: 'resolved',
						output: { ok: true },
					},
				],
			},
		];

		const aiMessages = toAiMessages(input);
		const toolCallPart = (
			aiMessages[0] as {
				role: string;
				content: Array<{ type: string; providerMetadata?: unknown }>;
			}
		).content[0];

		expect(toolCallPart.providerMetadata).toEqual(providerMetadata);
	});

	it('copies Anthropic reasoning replay metadata into providerOptions on replay', () => {
		const providerMetadata = { anthropic: { signature: 'anthropic-thinking-signature' } };
		const input: Message[] = [
			{
				role: 'assistant',
				content: [
					{
						type: 'reasoning',
						text: 'Let me think about this...',
						providerMetadata,
					},
				],
			},
		];

		const aiMessages = toAiMessages(input);
		const reasoningPart = (
			aiMessages[0] as {
				role: string;
				content: Array<{ type: string; providerOptions?: unknown; providerMetadata?: unknown }>;
			}
		).content[0];

		expect(reasoningPart.type).toBe('reasoning');
		expect(reasoningPart.providerMetadata).toEqual(providerMetadata);
		expect(reasoningPart.providerOptions).toEqual({
			anthropic: { signature: 'anthropic-thinking-signature' },
		});
	});

	it('keeps only the last Anthropic signature when assistant responses are adjacent', () => {
		const input: Message[] = [
			{
				role: 'assistant',
				content: [
					{
						type: 'reasoning',
						text: 'First response reasoning',
						providerMetadata: { anthropic: { signature: 'first-signature' } },
					},
					{ type: 'text', text: 'First response' },
				],
			},
			{
				role: 'assistant',
				content: [
					{
						type: 'reasoning',
						text: 'Second response reasoning',
						providerMetadata: { anthropic: { signature: 'second-signature' } },
					},
					{ type: 'text', text: 'Second response' },
				],
			},
		];

		const aiMessages = toAiMessages(input);
		const content = aiMessages.map(
			(message) =>
				(
					message as {
						content: Array<{
							type: string;
							text?: string;
							providerOptions?: { anthropic?: { signature?: string } };
						}>;
					}
				).content,
		);

		expect(content.flatMap((parts) => parts).filter((part) => part.type === 'text')).toEqual([
			{ type: 'text', text: 'First response' },
			{ type: 'text', text: 'Second response' },
		]);
		expect(
			content
				.flatMap((parts) => parts)
				.map((part) => part.providerOptions?.anthropic?.signature)
				.filter((signature) => signature !== undefined),
		).toEqual(['second-signature']);
	});

	it('copies OpenAI reasoning replay metadata into providerOptions on replay', () => {
		const providerMetadata = {
			openai: { itemId: 'rs_123', reasoningEncryptedContent: 'encrypted' },
		};
		const input: Message[] = [
			{
				role: 'assistant',
				content: [
					{
						type: 'reasoning',
						text: 'Let me think about this...',
						providerMetadata,
					},
				],
			},
		];

		const aiMessages = toAiMessages(input);
		const reasoningPart = (
			aiMessages[0] as {
				role: string;
				content: Array<{ type: string; providerOptions?: unknown; providerMetadata?: unknown }>;
			}
		).content[0];

		expect(reasoningPart.type).toBe('reasoning');
		expect(reasoningPart.providerOptions).toEqual({
			openai: { itemId: 'rs_123', reasoningEncryptedContent: 'encrypted' },
		});
	});

	it('round-trips SDK 7 reasoning files with provider replay state', () => {
		const aiMessage: ModelMessage = {
			role: 'assistant',
			content: [
				{
					type: 'reasoning-file',
					data: 'base64-reasoning-data',
					mediaType: 'application/octet-stream',
					providerOptions: { google: { thoughtSignature: 'gemini-signature' } },
				},
			],
		};

		const agentMessages = fromAiMessages([aiMessage]);

		expect(agentMessages).toEqual([
			{
				role: 'assistant',
				content: [
					{
						type: 'reasoning-file',
						data: 'base64-reasoning-data',
						mediaType: 'application/octet-stream',
						providerOptions: { google: { thoughtSignature: 'gemini-signature' } },
					},
				],
			},
		]);
		expect(toAiMessages(agentMessages as Message[])).toEqual([aiMessage]);
	});

	it('round-trips SDK 7 custom provider state', () => {
		const aiMessage: ModelMessage = {
			role: 'assistant',
			content: [
				{
					type: 'custom',
					kind: 'openai.compaction',
					providerOptions: {
						openai: { itemId: 'cmp_123', encryptedContent: 'encrypted' },
					},
				},
			],
		};

		const agentMessages = fromAiMessages([aiMessage]);

		expect(agentMessages).toEqual([
			{
				role: 'assistant',
				content: [
					{
						type: 'custom',
						kind: 'openai.compaction',
						providerOptions: {
							openai: { itemId: 'cmp_123', encryptedContent: 'encrypted' },
						},
					},
				],
			},
		]);
		expect(toAiMessages(agentMessages as Message[])).toEqual([aiMessage]);
	});

	it.each([
		{ name: 'no provider metadata', content: {} },
		{
			name: 'unsupported Anthropic metadata',
			content: { providerMetadata: { anthropic: { unsupported: true } } },
		},
		{
			name: 'unsupported OpenAI metadata',
			content: { providerMetadata: { openai: { unsupported: true } } },
		},
	])('drops reasoning parts with $name', ({ content }) => {
		const input: Message[] = [
			{
				role: 'assistant',
				content: [
					{
						type: 'reasoning',
						text: 'Reasoning text without replay metadata',
						...content,
					},
				],
			},
		];

		expect(toAiMessages(input)).toEqual([]);
	});

	it('sanitizes replayed non-object tool-call inputs for provider requests', () => {
		const input: Message[] = [
			{
				role: 'assistant',
				content: [
					{
						type: 'tool-call',
						toolCallId: 'tc-json',
						toolName: 'search',
						input: '{"query":"n8n"}',
						state: 'resolved',
						output: {},
					},
					{
						type: 'tool-call',
						toolCallId: 'tc-array',
						toolName: 'batch',
						input: '["a","b"]',
						state: 'resolved',
						output: {},
					},
					{
						type: 'tool-call',
						toolCallId: 'tc-null',
						toolName: 'noop',
						input: null,
						state: 'resolved',
						output: {},
					},
					{
						type: 'tool-call',
						toolCallId: 'tc-string',
						toolName: 'legacy',
						input: 'plain-text',
						state: 'resolved',
						output: {},
					},
				],
			},
		];

		const aiMessages = toAiMessages(input);
		const assistantParts = (
			aiMessages[0] as {
				role: string;
				content: Array<{ type: string; input: unknown }>;
			}
		).content;

		expect(assistantParts.map((part) => part.input)).toEqual([{ query: 'n8n' }, {}, {}, {}]);
	});

	it('preserves content tool outputs when building tool ModelMessages', () => {
		const contentOutput = {
			type: 'content' as const,
			value: [
				{ type: 'text' as const, text: 'current browser screenshot' },
				{ type: 'image-data' as const, data: 'base64-screenshot', mediaType: 'image/png' },
			],
		};
		const input: Message[] = [
			{
				role: 'assistant',
				content: [
					{
						type: 'tool-call',
						toolCallId: 'tc-1',
						toolName: 'screen_screenshot',
						input: {},
						state: 'resolved',
						output: contentOutput,
					},
				],
			},
		];

		const aiMessages = toAiMessages(input);
		const toolResultPart = (
			aiMessages[1] as {
				role: string;
				content: Array<{ output: unknown }>;
			}
		).content[0];

		expect(toolResultPart.output).toEqual(contentOutput);
	});

	it('keeps type/value-shaped tool JSON wrapped when building tool ModelMessages', () => {
		const textLikeOutput = { type: 'text', value: 'file written' };
		const jsonLikeOutput = {
			type: 'json',
			value: { status: 'ok' },
			keep: 'tool metadata',
		};
		const input: Message[] = [
			{
				role: 'assistant',
				content: [
					{
						type: 'tool-call',
						toolCallId: 'tc-1',
						toolName: 'write_file',
						input: {},
						state: 'resolved',
						output: textLikeOutput,
					},
					{
						type: 'tool-call',
						toolCallId: 'tc-2',
						toolName: 'tool',
						input: {},
						state: 'resolved',
						output: jsonLikeOutput,
					},
				],
			},
		];

		const aiMessages = toAiMessages(input);
		const firstToolResultPart = (
			aiMessages[1] as {
				role: string;
				content: Array<{ output: unknown }>;
			}
		).content[0];
		const secondToolResultPart = (
			aiMessages[2] as {
				role: string;
				content: Array<{ output: unknown }>;
			}
		).content[0];

		expect(firstToolResultPart.output).toEqual({ type: 'json', value: textLikeOutput });
		expect(secondToolResultPart.output).toEqual({ type: 'json', value: jsonLikeOutput });
	});

	it('round-trips type/value-shaped tool JSON without unwrapping it', () => {
		const textLikeOutput = { type: 'text', value: 'file written' };
		const jsonLikeOutput = {
			type: 'json',
			value: { status: 'ok' },
			keep: 'tool metadata',
		};
		const input: Message[] = [
			{
				role: 'assistant',
				content: [
					{
						type: 'tool-call',
						toolCallId: 'tc-1',
						toolName: 'write_file',
						input: {},
						state: 'resolved',
						output: textLikeOutput,
					},
					{
						type: 'tool-call',
						toolCallId: 'tc-2',
						toolName: 'tool',
						input: {},
						state: 'resolved',
						output: jsonLikeOutput,
					},
				],
			},
		];

		const roundTripped = fromAiMessages(toAiMessages(input));
		const blocks = (roundTripped[0] as Message).content;

		expect(blocks[0]).toMatchObject({ state: 'resolved', output: textLikeOutput });
		expect(blocks[1]).toMatchObject({ state: 'resolved', output: jsonLikeOutput });
	});

	it('round-trips content tool outputs from AI SDK tool messages', () => {
		const contentOutput = {
			type: 'content' as const,
			value: [
				{ type: 'text' as const, text: 'current browser screenshot' },
				{ type: 'image-data' as const, data: 'base64-screenshot', mediaType: 'image/png' },
			],
		};

		const messages = fromAiMessages([
			{
				role: 'assistant',
				content: [
					{
						type: 'tool-call',
						toolCallId: 'tc-1',
						toolName: 'screen_screenshot',
						input: {},
					},
				],
			},
			{
				role: 'tool',
				content: [
					{
						type: 'tool-result',
						toolCallId: 'tc-1',
						toolName: 'screen_screenshot',
						output: contentOutput,
					},
				],
			},
		]);

		expect(messages).toEqual([
			{
				role: 'assistant',
				content: [
					{
						type: 'tool-call',
						toolCallId: 'tc-1',
						toolName: 'screen_screenshot',
						input: {},
						state: 'resolved',
						output: contentOutput,
					},
				],
			},
		]);
	});

	it('encodes rejected tool-call as error-text in the tool ModelMessage', () => {
		const input: Message[] = [
			{
				role: 'assistant',
				content: [
					{
						type: 'tool-call',
						toolCallId: 'tc-1',
						toolName: 'do_it',
						input: {},
						state: 'rejected',
						error: 'Error: something went wrong',
					},
				],
			},
		];

		const aiMessages = toAiMessages(input);
		expect(aiMessages).toHaveLength(2);

		const toolResultPart = (
			aiMessages[1] as { role: string; content: Array<{ output: { type: string; value: string } }> }
		).content[0];
		expect(toolResultPart.output.type).toBe('error-text');
		expect(toolResultPart.output.value).toBe('Error: something went wrong');
	});

	it('drops pending tool-call blocks from both assistant and tool ModelMessages', () => {
		const input: Message[] = [
			{
				role: 'assistant',
				content: [
					{ type: 'text', text: 'Thinking...' },
					{
						type: 'tool-call',
						toolCallId: 'tc-1',
						toolName: 'do_it',
						input: {},
						state: 'pending',
					},
				],
			},
		];

		const aiMessages = toAiMessages(input);

		// Only the assistant text part remains; no tool-result emitted for pending
		expect(aiMessages).toHaveLength(1);
		expect(aiMessages[0].role).toBe('assistant');
		const content = (aiMessages[0] as { role: string; content: Array<{ type: string }> }).content;
		expect(content).toHaveLength(1);
		expect(content[0].type).toBe('text');
	});

	it('emits nothing for an assistant message whose only blocks are all pending', () => {
		const input: Message[] = [
			{
				role: 'assistant',
				content: [
					{
						type: 'tool-call',
						toolCallId: 'tc-1',
						toolName: 'do_it',
						input: {},
						state: 'pending',
					},
					{
						type: 'tool-call',
						toolCallId: 'tc-2',
						toolName: 'do_more',
						input: {},
						state: 'pending',
					},
				],
			},
		];

		const aiMessages = toAiMessages(input);

		// No empty-content assistant message — the whole message is suppressed
		expect(aiMessages).toHaveLength(0);
	});

	it('skips legacy tool-call blocks that have no state field and emits nothing when they are the only content', () => {
		const input: Message[] = [
			{
				role: 'assistant',
				content: [
					// Simulate a DB row written before the state field was introduced
					{
						type: 'tool-call',
						toolCallId: 'tc-legacy',
						toolName: 'old_tool',
						input: {},
					} as unknown as Message['content'][number],
				],
			},
		];

		const aiMessages = toAiMessages(input);

		// No empty-content assistant message and no spurious error-json tool message
		expect(aiMessages).toHaveLength(0);
	});

	it('emits one tool ModelMessage per settled block in the same assistant turn', () => {
		const input: Message[] = [
			{
				role: 'assistant',
				content: [
					{
						type: 'tool-call',
						toolCallId: 'tc-1',
						toolName: 'add',
						input: { a: 1, b: 2 },
						state: 'resolved',
						output: { result: 3 },
					},
					{
						type: 'tool-call',
						toolCallId: 'tc-2',
						toolName: 'mul',
						input: { a: 4, b: 5 },
						state: 'resolved',
						output: { result: 20 },
					},
				],
			},
		];

		const aiMessages = toAiMessages(input);

		// assistant{tc-1, tc-2} + tool{tc-1} + tool{tc-2}
		expect(aiMessages).toHaveLength(3);
		expect(aiMessages[0].role).toBe('assistant');
		const assistantContent = (
			aiMessages[0] as { content: Array<{ type: string; toolCallId: string }> }
		).content;
		expect(assistantContent).toHaveLength(2);
		expect(assistantContent[0].toolCallId).toBe('tc-1');
		expect(assistantContent[1].toolCallId).toBe('tc-2');

		expect(aiMessages[1].role).toBe('tool');
		expect(aiMessages[2].role).toBe('tool');
	});

	it('merges role:tool ModelMessages into the preceding assistant tool-call block', () => {
		// Simulate AI SDK output: [assistant{tool-call}, tool{tool-result}]
		const aiMessages = [
			{
				role: 'assistant' as const,
				content: [
					{
						type: 'tool-call' as const,
						toolCallId: 'tc-1',
						toolName: 'add',
						input: { a: 1, b: 2 },
						providerExecuted: undefined,
					},
				],
			},
			{
				role: 'tool' as const,
				content: [
					{
						type: 'tool-result' as const,
						toolCallId: 'tc-1',
						toolName: 'add',
						output: { type: 'json' as const, value: { result: 3 } },
					},
				],
			},
		];

		const n8nMessages = fromAiMessages(aiMessages);

		// Should produce a single assistant message with the resolved block
		expect(n8nMessages).toHaveLength(1);
		expect((n8nMessages[0] as Message).role).toBe('assistant');
		const block = (n8nMessages[0] as Message).content[0];
		expect(block.type).toBe('tool-call');
		expect((block as { state: string }).state).toBe('resolved');
		expect((block as { output: unknown }).output).toEqual({ result: 3 });
	});

	it('round-trips provider-executed tool results inside the assistant message', () => {
		// Native web search: the AI SDK places the result in the assistant message,
		// never in a role:tool message.
		const results = [{ url: 'https://n8n.io', title: 'n8n', type: 'web_search_result' }];
		const searchError = { type: 'web_search_tool_result_error', errorCode: 'max_uses_exceeded' };
		const error = JSON.stringify(searchError);
		const aiMessages: ModelMessage[] = [
			{
				role: 'assistant',
				content: [
					{
						type: 'tool-call',
						toolCallId: 'srvtoolu_1',
						toolName: 'web_search',
						input: { query: 'n8n' },
						providerExecuted: true,
					},
					{
						type: 'tool-result',
						toolCallId: 'srvtoolu_1',
						toolName: 'web_search',
						output: { type: 'json', value: results },
					},
					{
						type: 'tool-call',
						toolCallId: 'srvtoolu_2',
						toolName: 'web_search',
						input: { query: 'more' },
						providerExecuted: true,
					},
					{
						type: 'tool-result',
						toolCallId: 'srvtoolu_2',
						toolName: 'web_search',
						output: { type: 'error-json', value: searchError },
					},
					{ type: 'text', text: 'Here is what I found.' },
				],
			},
		];

		const persisted = fromAiMessages(aiMessages) as Message[];
		expect(persisted[0].content).toMatchObject([
			{ type: 'tool-call', state: 'resolved', output: results },
			{ type: 'tool-call', state: 'rejected', error },
			{ type: 'text' },
		]);

		const replayed = toAiMessages(persisted);
		expect(replayed).toHaveLength(1);
		expect(replayed[0].content).toMatchObject([
			{ type: 'tool-call', toolCallId: 'srvtoolu_1', providerExecuted: true },
			{ type: 'tool-result', toolCallId: 'srvtoolu_1', output: { type: 'json', value: results } },
			{ type: 'tool-call', toolCallId: 'srvtoolu_2', providerExecuted: true },
			{
				type: 'tool-result',
				toolCallId: 'srvtoolu_2',
				output: { type: 'error-json', value: error },
			},
			{ type: 'text', text: 'Here is what I found.' },
		]);
	});

	it('round-trip is structurally equivalent for a resolved tool-call', () => {
		const original: Message[] = [
			{
				role: 'assistant',
				content: [
					{
						type: 'tool-call',
						toolCallId: 'tc-1',
						toolName: 'echo',
						input: { text: 'hello' },
						state: 'resolved',
						output: { echoed: 'hello' },
					},
				],
			},
		];

		const aiMessages = toAiMessages(original);
		const roundTripped = fromAiMessages(aiMessages);

		expect(roundTripped).toHaveLength(1);
		expect((roundTripped[0] as Message).role).toBe('assistant');
		const block = (roundTripped[0] as Message).content[0];
		expect(block.type).toBe('tool-call');
		expect((block as { state: string }).state).toBe('resolved');
		expect((block as { output: unknown }).output).toEqual({ echoed: 'hello' });
		expect((block as { toolCallId: string }).toolCallId).toBe('tc-1');
	});

	it('interim-message ordering: tool-result is inserted right after its tool-call', () => {
		// This is the key regression test for the interim-message scenario.
		// Input n8n list: [assistant{tool-call resolved}, user{x}, assistant{y}]
		// Expected AI SDK output: [assistant{tc}, tool{tr}, user{x}, assistant{y}]
		const input: Message[] = [
			{
				role: 'assistant',
				content: [
					{
						type: 'tool-call',
						toolCallId: 'tc-1',
						toolName: 'delete_file',
						input: { path: 'foo.txt' },
						state: 'resolved',
						output: { deleted: true },
					},
				],
			},
			{
				role: 'user',
				content: [{ type: 'text', text: 'Actually, what is 2+2?' }],
			},
			{
				role: 'assistant',
				content: [{ type: 'text', text: 'It is 4.' }],
			},
		];

		const aiMessages = toAiMessages(input);

		// 4 messages: assistant{tool-call}, tool{tool-result}, user, assistant
		expect(aiMessages).toHaveLength(4);
		expect(aiMessages[0].role).toBe('assistant');
		expect(aiMessages[1].role).toBe('tool');
		expect(aiMessages[2].role).toBe('user');
		expect(aiMessages[3].role).toBe('assistant');

		// tool-result is immediately after the assistant tool-call message
		const toolResultContent = (aiMessages[1] as { content: Array<{ toolCallId: string }> })
			.content[0];
		expect(toolResultContent.toolCallId).toBe('tc-1');

		// user interim message is after the tool-result
		const userContent = (aiMessages[2] as { content: Array<{ type: string; text: string }> })
			.content[0];
		expect(userContent.text).toBe('Actually, what is 2+2?');
	});
});
