import type { SystemModelMessage } from 'ai';

import { isLlmMessage } from '../../sdk/message';
import type {
	AgentDbMessage,
	AgentMessage,
	ContentToolCall,
	Message,
} from '../../types/sdk/message';
import { AgentMessageList, buildSystemMessages } from '../model/message-list';

function flattenSystemContent(system: SystemModelMessage | SystemModelMessage[]): string {
	if (Array.isArray(system)) {
		return system.map((entry) => entry.content).join('');
	}
	return system.content;
}

function makeUserMsg(text: string): AgentMessage {
	return { role: 'user', content: [{ type: 'text', text }] };
}

function makeDbMsg(text: string, createdAt: Date): AgentDbMessage {
	return {
		id: crypto.randomUUID(),
		createdAt,
		role: 'user',
		content: [{ type: 'text', text }],
	};
}

// ---------------------------------------------------------------------------
// Monotonic timestamp assignment
// ---------------------------------------------------------------------------

describe('AgentMessageList — monotonic timestamps', () => {
	it('assigns a Date createdAt to every message added via addInput', () => {
		const list = new AgentMessageList();
		list.addInput([makeUserMsg('hello')]);

		const [msg] = list.turnDelta();
		expect(msg.createdAt).toBeInstanceOf(Date);
	});

	it('assigns strictly increasing createdAt to a batch of input messages', () => {
		const list = new AgentMessageList();
		// Three messages added in the same synchronous call — all would share the
		// same Date.now() tick without monotonic enforcement.
		list.addInput([makeUserMsg('a'), makeUserMsg('b'), makeUserMsg('c')]);

		const [a, b, c] = list.turnDelta();

		expect(a.createdAt).toBeInstanceOf(Date);
		expect(b.createdAt).toBeInstanceOf(Date);
		expect(c.createdAt).toBeInstanceOf(Date);

		expect(b.createdAt.getTime()).toBeGreaterThan(a.createdAt.getTime());
		expect(c.createdAt.getTime()).toBeGreaterThan(b.createdAt.getTime());
	});

	it('assigns strictly increasing createdAt to response messages', () => {
		const list = new AgentMessageList();
		list.addResponse([makeUserMsg('r1'), makeUserMsg('r2'), makeUserMsg('r3')]);

		const [r1, r2, r3] = list.responseDelta();

		expect(r2.createdAt.getTime()).toBeGreaterThan(r1.createdAt.getTime());
		expect(r3.createdAt.getTime()).toBeGreaterThan(r2.createdAt.getTime());
	});

	it('assigns createdAt that is strictly greater than history timestamps', () => {
		const list = new AgentMessageList();

		// Simulate a DB-loaded message with a timestamp in the future relative to
		// wall clock — the new input message must still be later.
		const futureTs = new Date(Date.now() + 60_000);
		list.addHistory([makeDbMsg('old', futureTs)]);

		list.addInput([makeUserMsg('new')]);

		const [inputMsg] = list.turnDelta();
		expect(inputMsg.createdAt).toBeInstanceOf(Date);
		expect(inputMsg.createdAt.getTime()).toBeGreaterThan(futureTs.getTime());
	});
});

// ---------------------------------------------------------------------------
// inputDelta — input-only messages for eager persistence
// ---------------------------------------------------------------------------

describe('AgentMessageList — inputDelta', () => {
	it('returns only input messages, excluding history and responses', () => {
		const list = new AgentMessageList();
		list.addHistory([makeDbMsg('old', new Date('2024-01-01T00:00:00.000Z'))]);
		list.addInput([makeUserMsg('the user prompt')]);
		list.addResponse([makeUserMsg('assistant reply')]);

		const delta = list.inputDelta();

		expect(delta).toHaveLength(1);
		const [text] = (delta[0] as { content: Array<{ type: string; text: string }> }).content;
		expect(text.text).toBe('the user prompt');
	});

	it('returns an empty array when no input was added', () => {
		const list = new AgentMessageList();
		list.addHistory([makeDbMsg('old', new Date('2024-01-01T00:00:00.000Z'))]);

		expect(list.inputDelta()).toEqual([]);
	});
});

// ---------------------------------------------------------------------------
// History messages keep their DB-sourced createdAt
// ---------------------------------------------------------------------------

describe('AgentMessageList — chronological order', () => {
	it('reorders addHistory when the batch is not in createdAt order', () => {
		const list = new AgentMessageList();
		const t1 = new Date('2024-01-01T00:00:01.000Z');
		const t2 = new Date('2024-01-01T00:00:02.000Z');
		list.addHistory([makeDbMsg('second', t2), makeDbMsg('first', t1)]);

		const msgs = list.serialize().messages.filter(isLlmMessage) as Message[];
		expect(msgs).toHaveLength(2);
		expect(msgs[0].content[0]).toMatchObject({ type: 'text', text: 'first' });
		expect(msgs[1].content[0]).toMatchObject({ type: 'text', text: 'second' });
	});
});

// ---------------------------------------------------------------------------
// History messages keep their DB-sourced createdAt
// ---------------------------------------------------------------------------

describe('AgentMessageList — preserving DB timestamps', () => {
	it('preserves the exact createdAt of history messages loaded from the database', () => {
		const list = new AgentMessageList();
		const dbTimestamp = new Date('2020-01-01T00:00:00.000Z');

		list.addHistory([makeDbMsg('from db', dbTimestamp)]);

		const [hist] = list.serialize().messages;
		expect(hist.createdAt).toBeInstanceOf(Date);
		expect(hist.createdAt.getTime()).toBe(dbTimestamp.getTime());
	});
});

// ---------------------------------------------------------------------------
// LLM context assembly
// ---------------------------------------------------------------------------

describe('AgentMessageList — forLlm observation memory', () => {
	it('does not inject a memory section when no observation log has been rendered', () => {
		const list = new AgentMessageList();
		list.addInput([makeUserMsg('hello')]);

		const { system, messages } = list.forLlm('Base instructions');

		expect(flattenSystemContent(system)).toContain('Base instructions');
		expect(flattenSystemContent(system)).not.toContain('## Memory');
		expect(Array.isArray(system)).toBe(false);
		expect(messages).toEqual(
			expect.arrayContaining([
				expect.objectContaining({
					role: 'user',
					content: [expect.objectContaining({ type: 'text', text: 'hello' })],
				}),
			]),
		);
	});

	it('injects the rendered observation log into the system prompt', () => {
		const list = new AgentMessageList();
		list.observationLogMemory = [
			'<observations>',
			'The following is your memory of this conversation.',
			'',
			'* CRITICAL (14:30) User wants the SDK to stay unopinionated.',
			'</observations>',
		].join('\n');

		const { system } = list.forLlm('Base instructions');
		const prompt = flattenSystemContent(system);

		expect(Array.isArray(system)).toBe(true);
		expect(prompt).toContain('Base instructions');
		expect(prompt).toContain('<observations>');
		expect(prompt).toContain('* CRITICAL (14:30) User wants the SDK to stay unopinionated.');
	});

	it('places observation memory in a separate system message with its own cache breakpoint', () => {
		const observationLog = [
			'<observations>',
			'* CRITICAL (14:30) User wants the SDK to stay unopinionated.',
			'</observations>',
		].join('\n');
		const cacheOptions = {
			anthropic: { cacheControl: { type: 'ephemeral' as const } },
		};

		const system = buildSystemMessages('Base instructions', observationLog, cacheOptions);

		expect(Array.isArray(system)).toBe(true);
		if (!Array.isArray(system)) throw new Error('Expected split system messages');
		expect(system).toHaveLength(2);
		expect(system[0]).toEqual({
			role: 'system',
			content: 'Base instructions',
			providerOptions: cacheOptions,
		});
		expect(system[1]).toMatchObject({
			role: 'system',
			providerOptions: cacheOptions,
		});
		expect(system[1]?.content).toContain('<observations>');
		expect(system[1]?.content).not.toContain('Base instructions');
	});

	it('keeps recent history messages in LLM context when observation memory is empty', () => {
		const list = new AgentMessageList();
		list.addHistory([makeDbMsg('recent history', new Date('2024-01-01T00:00:00.000Z'))]);
		list.addInput([makeUserMsg('new request')]);

		const { messages } = list.forLlm('Base instructions');

		expect(messages).toEqual(
			expect.arrayContaining([
				expect.objectContaining({
					role: 'user',
					content: [expect.objectContaining({ type: 'text', text: 'recent history' })],
				}),
				expect.objectContaining({
					role: 'user',
					content: [expect.objectContaining({ type: 'text', text: 'new request' })],
				}),
			]),
		);
	});
});

// ---------------------------------------------------------------------------
// Input / response messages use existing createdAt as a hint
// ---------------------------------------------------------------------------

describe('AgentMessageList — hint-based monotonicity for input/response', () => {
	it('keeps an input message createdAt when it is already later than lastCreatedAt', () => {
		const list = new AgentMessageList();
		const histTs = new Date('2024-01-01T00:00:01.000Z');
		list.addHistory([makeDbMsg('hist', histTs)]);

		// freshTs is well after histTs so no bump is needed
		const freshTs = new Date('2024-01-01T00:00:10.000Z');
		const msg = { ...makeUserMsg('new'), createdAt: freshTs };
		list.addInput([msg]);

		const [inputMsg] = list.turnDelta();
		expect(inputMsg.createdAt.getTime()).toBe(freshTs.getTime());
	});

	it('bumps an input message createdAt when it collides with or precedes the last timestamp', () => {
		const list = new AgentMessageList();
		const histTs = new Date('2024-01-01T00:00:05.000Z');
		list.addHistory([makeDbMsg('hist', histTs)]);

		// staleTs is before histTs — must be bumped to at least histTs + 1 ms
		const staleTs = new Date('2024-01-01T00:00:04.000Z');
		const msg = { ...makeUserMsg('stale'), createdAt: staleTs };
		list.addInput([msg]);

		const [inputMsg] = list.turnDelta();
		expect(inputMsg.createdAt.getTime()).toBeGreaterThan(histTs.getTime());
	});

	it('bumps an input message createdAt when it equals the last timestamp', () => {
		const list = new AgentMessageList();
		const ts = new Date('2024-06-01T12:00:00.000Z');
		list.addHistory([makeDbMsg('hist', ts)]);

		// sameTs equals histTs — must be bumped by at least 1 ms
		const msg = { ...makeUserMsg('same'), createdAt: new Date(ts) };
		list.addInput([msg]);

		const [inputMsg] = list.turnDelta();
		expect(inputMsg.createdAt.getTime()).toBeGreaterThan(ts.getTime());
	});
});

// ---------------------------------------------------------------------------
// Deserialization restores lastCreatedAt
// ---------------------------------------------------------------------------

describe('AgentMessageList — deserialize', () => {
	it('messages added after deserialize have createdAt later than any restored message', () => {
		const list = new AgentMessageList();

		// History message with a future timestamp (edge case: e.g. clock skew or
		// the previous turn's monotonic assignment ran ahead of wall clock).
		const futureTs = new Date(Date.now() + 60_000);
		list.addHistory([makeDbMsg('prev', futureTs)]);

		// Round-trip through serialization (simulates suspend / resume)
		const list2 = AgentMessageList.deserialize(list.serialize());
		list2.addInput([makeUserMsg('after resume')]);

		const [newMsg] = list2.turnDelta();
		expect(newMsg.createdAt).toBeInstanceOf(Date);
		expect(newMsg.createdAt.getTime()).toBeGreaterThan(futureTs.getTime());
	});
});

// ---------------------------------------------------------------------------
// setToolCallResult / setToolCallError
// ---------------------------------------------------------------------------

function makePendingToolCallMsg(toolCallId: string): AgentMessage {
	return {
		role: 'assistant',
		content: [
			{
				type: 'tool-call',
				toolCallId,
				toolName: 'my_tool',
				input: { x: 1 },
				state: 'pending',
			},
		],
	};
}

describe('AgentMessageList — setToolCallResult', () => {
	it('sets state and output on the matching tool-call block', () => {
		const list = new AgentMessageList();
		list.addResponse([makePendingToolCallMsg('id-1')]);

		const host = list.setToolCallResult('id-1', { ok: true });
		expect(host).toBeDefined();

		const block = (host as Message).content.find((c) => c.type === 'tool-call') as ContentToolCall;
		expect(block.state).toBe('resolved');
		expect((block as ContentToolCall & { state: 'resolved' }).output).toEqual({ ok: true });
	});

	it('promotes a history-only message into responseDelta after setToolCallResult', () => {
		const list = new AgentMessageList();
		const histMsg: AgentDbMessage = {
			id: 'hist-1',
			createdAt: new Date('2024-01-01T00:00:01.000Z'),
			role: 'assistant',
			content: [
				{
					type: 'tool-call',
					toolCallId: 'tc-hist',
					toolName: 'my_tool',
					input: {},
					state: 'pending',
				},
			],
		};
		list.addHistory([histMsg]);

		// Before: not in responseDelta (history only)
		expect(list.responseDelta()).toHaveLength(0);

		list.setToolCallResult('tc-hist', { done: true });

		// After: promoted to responseDelta
		const delta = list.responseDelta();
		expect(delta).toHaveLength(1);
		const block = (delta[0] as Message).content.find(
			(c) => c.type === 'tool-call',
		) as ContentToolCall;
		expect(block.state).toBe('resolved');
	});

	it('is a no-op when toolCallId is unknown', () => {
		const list = new AgentMessageList();
		list.addResponse([makePendingToolCallMsg('id-1')]);

		const result = list.setToolCallResult('unknown-id', { x: 1 });
		expect(result).toBeUndefined();
		// List unchanged
		expect(list.responseDelta()).toHaveLength(1);
	});

	it('Set semantics make repeated calls idempotent (no duplicate messages)', () => {
		const list = new AgentMessageList();
		list.addResponse([makePendingToolCallMsg('id-1')]);

		list.setToolCallResult('id-1', { ok: true });
		list.setToolCallResult('id-1', { ok: true });

		expect(list.responseDelta()).toHaveLength(1);
	});
});

describe('AgentMessageList — setToolCallError', () => {
	it('stringifies errors and clears any prior output', () => {
		const list = new AgentMessageList();
		list.addResponse([
			{
				role: 'assistant',
				content: [
					{
						type: 'tool-call',
						toolCallId: 'id-1',
						toolName: 'my_tool',
						input: {},
						state: 'resolved',
						output: { prev: true },
					},
				],
			},
		]);

		const host = list.setToolCallError('id-1', new Error('boom'));
		expect(host).toBeDefined();

		const block = (host as Message).content.find((c) => c.type === 'tool-call') as ContentToolCall;
		expect(block.state).toBe('rejected');
		expect((block as ContentToolCall & { state: 'rejected' }).error).toBe('Error: boom');
		// output should be gone
		expect((block as unknown as { output?: unknown }).output).toBeUndefined();
	});
});
