import type { AssistantMessage, Message, ToolResultMessage } from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import {
	COMPACTION_IMAGE_PLACEHOLDER,
	COMPACTION_TOOL_RESULT_PLACEHOLDER,
	prepareMessageGroupsForSummary,
	prepareMessagesForSummary,
	serializeConversation,
	shouldIncludeThinkingInSummary,
} from "../src/core/compaction/utils.js";

type UselessToolResultMessage = ToolResultMessage & { useless?: boolean };

function assistantWithTools(ids: string[]): AssistantMessage {
	return {
		role: "assistant",
		content: [
			{ type: "thinking", thinking: "private chain", thinkingSignature: "opaque" },
			{ type: "text", text: "running tools" },
			...ids.map((id) => ({
				type: "toolCall" as const,
				id,
				name: "ipython",
				arguments: { id, nested: { preserved: true } },
				thoughtSignature: "tool-signature",
			})),
		],
		api: "anthropic",
		provider: "anthropic",
		model: "test",
		usage: {
			input: 0,
			output: 0,
			cacheRead: 0,
			cacheWrite: 0,
			totalTokens: 0,
			cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
		},
		stopReason: "toolUse",
		timestamp: 1,
	};
}

function toolResult(id: string, text: string, isError = false): ToolResultMessage {
	return {
		role: "toolResult",
		toolCallId: id,
		toolName: "ipython",
		content: [{ type: "text", text }],
		isError,
		timestamp: 1,
	};
}

describe("summary message preparation", () => {
	it("prepares a loss-bounded clone without changing the raw messages", () => {
		const newestResult = toolResult("t4", "newest output", true);
		newestResult.details = { source: "raw-session", nested: { preserved: true } };
		const uselessResult = toolResult("useless", "no matches") as UselessToolResultMessage;
		uselessResult.useless = true;
		const messages: Message[] = [
			{
				role: "user",
				content: [
					{ type: "text", text: "inspect this" },
					{ type: "image", data: "base64-secret", mimeType: "image/png" },
				],
				timestamp: 1,
			},
			assistantWithTools(["t1", "t2", "t3", "t4", "empty", "useless"]),
			toolResult("t1", "oldest exact output"),
			toolResult("t2", "second output"),
			toolResult("t3", "third output"),
			newestResult,
			toolResult("empty", ""),
			uselessResult,
			toolResult("orphan", "must disappear"),
		];
		const original = JSON.stringify(messages);

		const prepared = prepareMessagesForSummary(messages);
		const serialized = serializeConversation(messages);

		expect(JSON.stringify(messages)).toBe(original);
		expect(prepared).not.toBe(messages);
		const preparedAssistant = prepared.find((message): message is AssistantMessage => message.role === "assistant")!;
		const preparedCall = preparedAssistant.content.find((block) => block.type === "toolCall" && block.id === "t4");
		if (!preparedCall || preparedCall.type !== "toolCall") throw new Error("prepared tool call is missing");
		expect(preparedCall).toMatchObject({
			name: "ipython",
			arguments: { id: "t4", nested: { preserved: true } },
			thoughtSignature: "tool-signature",
		});
		const preparedNewestResult = prepared.find(
			(message): message is ToolResultMessage => message.role === "toolResult" && message.toolCallId === "t4",
		)!;
		expect(preparedNewestResult.details).toEqual({ source: "raw-session", nested: { preserved: true } });
		(preparedCall.arguments.nested as { preserved: boolean }).preserved = false;
		(preparedNewestResult.details as { nested: { preserved: boolean } }).nested.preserved = false;
		expect(JSON.stringify(messages)).toBe(original);
		expect(serialized).toContain(COMPACTION_IMAGE_PLACEHOLDER);
		expect(serialized).not.toContain("base64-secret");
		expect(serialized).toContain("[Assistant reasoning]: private chain");
		expect(serialized).not.toContain("must disappear");
		expect(serialized).not.toContain("no matches");
		expect(serialized).not.toContain("ipython#empty");
		expect(serialized).not.toContain("ipython#useless");
		expect(serialized).not.toContain("oldest exact output");
		expect(serialized).toContain(COMPACTION_TOOL_RESULT_PLACEHOLDER);
		expect(serialized).toContain("second output");
		expect(serialized).toContain("third output");
		expect(serialized).toContain("newest output");
		expect(serialized).toContain("ipython#t4 error=true");
	});

	it("omits uncloneable tool metadata instead of failing compaction", () => {
		const result = toolResult("t1", "usable output");
		result.details = { callback: () => "not serializable" };

		expect(() => serializeConversation([assistantWithTools(["t1"]), result])).not.toThrow();
		const prepared = prepareMessagesForSummary([assistantWithTools(["t1"]), result]);
		const preparedResult = prepared.find((message) => message.role === "toolResult");
		expect(preparedResult).not.toHaveProperty("details");
	});

	it("excludes readable reasoning for Claude-family summary targets", () => {
		const messages = [assistantWithTools([])];
		const serialized = serializeConversation(messages, { includeThinking: false });
		const thinkingOnly = { ...assistantWithTools([]), content: [{ type: "thinking" as const, thinking: "private" }] };
		const uselessOnly = {
			...assistantWithTools([]),
			content: [{ type: "toolCall" as const, id: "useless", name: "ipython", arguments: {} }],
		};
		const useless = toolResult("useless", "no matches") as UselessToolResultMessage;
		useless.useless = true;
		const emptied = prepareMessagesForSummary([thinkingOnly, uselessOnly, useless], { includeThinking: false });

		expect(serialized).not.toContain("private chain");
		expect(emptied).toEqual([]);
		expect(
			shouldIncludeThinkingInSummary({
				api: "anthropic-messages",
				id: "claude-sonnet-4-5",
				provider: "anthropic",
			}),
		).toBe(false);
		expect(
			shouldIncludeThinkingInSummary({
				api: "openai-responses",
				id: "gpt-5.6-sol",
				provider: "openai",
			}),
		).toBe(true);
	});

	it("preserves split-turn groups while applying retention across their boundary", () => {
		const groups: Message[][] = [
			[assistantWithTools(["t1", "t2"]), toolResult("t1", "old result")],
			[
				toolResult("t2", "boundary result"),
				{ role: "user", content: "next round", timestamp: 1 },
				assistantWithTools(["t3", "t4"]),
				toolResult("t3", "third result"),
				toolResult("t4", "fourth result"),
			],
		];
		const original = JSON.stringify(groups);

		const prepared = prepareMessageGroupsForSummary(groups);
		const resultText = prepared
			.flat()
			.filter((message): message is ToolResultMessage => message.role === "toolResult")
			.map((message) => message.content.map((block) => (block.type === "text" ? block.text : "")).join(""));

		expect(JSON.stringify(groups)).toBe(original);
		expect(prepared).toHaveLength(2);
		expect(resultText).toEqual([
			COMPACTION_TOOL_RESULT_PLACEHOLDER,
			"boundary result",
			"third result",
			"fourth result",
		]);
	});

	it("drops empty successes and useless pairs while retaining an empty error", () => {
		const useless = toolResult("useless", "no matches") as UselessToolResultMessage;
		useless.useless = true;
		const messages: Message[] = [
			assistantWithTools(["empty", "useless", "empty-error"]),
			toolResult("empty", ""),
			useless,
			toolResult("empty-error", "", true),
		];

		const serialized = serializeConversation(messages);

		expect(serialized).not.toContain("ipython#empty(");
		expect(serialized).not.toContain("ipython#useless");
		expect(serialized).not.toContain("no matches");
		expect(serialized).toContain("ipython#empty-error");
		expect(serialized).toContain("error=true");
	});

	it("can mask every tool result for the final overflow attempt", () => {
		const messages = [assistantWithTools(["t1"]), toolResult("t1", "huge output")];
		const serialized = serializeConversation(messages, { keepRecentToolResults: 0 });

		expect(serialized).toContain(COMPACTION_TOOL_RESULT_PLACEHOLDER);
		expect(serialized).not.toContain("huge output");
	});

	it("replaces tool-result image bytes without losing result identity", () => {
		const imageResult: Message = {
			role: "toolResult",
			toolCallId: "t1",
			toolName: "ipython",
			content: [{ type: "image", data: "tool-image-bytes", mimeType: "image/png" }],
			isError: false,
			timestamp: 1,
		};
		const serialized = serializeConversation([assistantWithTools(["t1"]), imageResult]);

		expect(serialized).toContain(`ipython#t1 error=false]: ${COMPACTION_IMAGE_PLACEHOLDER}`);
		expect(serialized).not.toContain("tool-image-bytes");
	});

	it("drops a tool result without an immediately preceding matching call", () => {
		const messages: Message[] = [
			assistantWithTools(["t1"]),
			{ role: "user", content: "new turn", timestamp: 1 },
			toolResult("t1", "stale result"),
		];

		expect(serializeConversation(messages)).not.toContain("stale result");
	});

	it("keeps long user and assistant text intact", () => {
		const longText = "y".repeat(5000);
		const messages: Message[] = [
			{ role: "user", content: longText, timestamp: 1 },
			{
				...assistantWithTools([]),
				content: [{ type: "text", text: longText }],
				stopReason: "stop",
			},
		];

		const serialized = serializeConversation(messages);
		expect(serialized).toContain(longText);
		expect(serialized).not.toContain("truncated");
	});
});
