/**
 * Shell — HTTP transport layer (effects injected at the edges).
 *
 * streamChat: POST /chat, returns an async iterable of AgentEvents.
 * fetchModels: GET /models, returns the ModelsResponse.
 *
 * The fetchImpl dependency is injected (outermost edge mock allowed).
 */

import type {
	AgentEvent,
	ChatRequest,
	CompactResponse,
	ConversationListResponse,
	LastMessageResponse,
	ModelsResponse,
	OpenConversationResponse,
	QueueResponse,
} from "@dispatch/transport-contract";
import { splitNdjsonLines } from "./ndjson.js";

interface FetchDeps {
	readonly fetchImpl: typeof fetch;
}

interface StreamChatOpts {
	readonly server: string;
	readonly request: ChatRequest;
}

export async function streamChat(
	deps: FetchDeps,
	opts: StreamChatOpts,
): Promise<{ conversationId: string | null; events: AsyncIterable<AgentEvent> }> {
	const url = `${opts.server}/chat`;
	const res = await deps.fetchImpl(url, {
		method: "POST",
		headers: { "Content-Type": "application/json" },
		body: JSON.stringify(opts.request),
	});

	if (!res.ok) {
		const body = await res.text();
		throw new Error(`POST /chat failed with status ${res.status}: ${body}`);
	}

	const conversationId = res.headers.get("X-Conversation-Id");

	if (!res.body) {
		throw new Error("POST /chat returned no body");
	}

	const events = readNdjsonStream(res.body);
	return { conversationId, events };
}

async function* readNdjsonStream(body: ReadableStream<Uint8Array>): AsyncIterable<AgentEvent> {
	const reader = body.getReader();
	const decoder = new TextDecoder();
	let buffer = "";

	try {
		while (true) {
			const { done, value } = await reader.read();
			if (done) break;
			buffer += decoder.decode(value, { stream: true });
			const { lines, rest } = splitNdjsonLines(buffer);
			buffer = rest;
			for (const line of lines) {
				yield JSON.parse(line) as AgentEvent;
			}
		}
		if (buffer.length > 0) {
			yield JSON.parse(buffer) as AgentEvent;
		}
	} finally {
		reader.releaseLock();
	}
}

interface FetchModelsOpts {
	readonly server: string;
}

export async function fetchModels(deps: FetchDeps, opts: FetchModelsOpts): Promise<ModelsResponse> {
	const url = `${opts.server}/models`;
	const res = await deps.fetchImpl(url);

	if (!res.ok) {
		const body = await res.text();
		throw new Error(`GET /models failed with status ${res.status}: ${body}`);
	}

	return (await res.json()) as ModelsResponse;
}

interface FetchConversationsOpts {
	readonly server: string;
	readonly query?: string;
	readonly status?: string;
}

export async function fetchConversations(
	deps: FetchDeps,
	opts: FetchConversationsOpts,
): Promise<ConversationListResponse> {
	const params = new URLSearchParams();
	if (opts.query !== undefined) params.set("q", opts.query);
	if (opts.status !== undefined) params.set("status", opts.status);
	const qs = params.toString();
	const url = qs.length > 0 ? `${opts.server}/conversations?${qs}` : `${opts.server}/conversations`;
	const res = await deps.fetchImpl(url);

	if (!res.ok) {
		const body = await res.text();
		throw new Error(`GET /conversations failed with status ${res.status}: ${body}`);
	}

	return (await res.json()) as ConversationListResponse;
}

interface FetchLastMessageOpts {
	readonly server: string;
	readonly conversationId: string;
}

export async function fetchLastMessage(
	deps: FetchDeps,
	opts: FetchLastMessageOpts,
): Promise<LastMessageResponse> {
	const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/last`;
	const res = await deps.fetchImpl(url);

	if (!res.ok) {
		const body = await res.text();
		throw new Error(`GET /conversations/:id/last failed with status ${res.status}: ${body}`);
	}

	return (await res.json()) as LastMessageResponse;
}

interface EnqueueMessageOpts {
	readonly server: string;
	readonly conversationId: string;
	readonly text: string;
}

export async function enqueueMessage(
	deps: FetchDeps,
	opts: EnqueueMessageOpts,
): Promise<QueueResponse> {
	const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/queue`;
	const res = await deps.fetchImpl(url, {
		method: "POST",
		headers: { "Content-Type": "application/json" },
		body: JSON.stringify({ text: opts.text }),
	});

	if (!res.ok) {
		const body = await res.text();
		throw new Error(`POST /conversations/:id/queue failed with status ${res.status}: ${body}`);
	}

	return (await res.json()) as QueueResponse;
}

interface OpenConversationOpts {
	readonly server: string;
	readonly conversationId: string;
}

export async function openConversation(
	deps: FetchDeps,
	opts: OpenConversationOpts,
): Promise<OpenConversationResponse> {
	const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/open`;
	const res = await deps.fetchImpl(url, { method: "POST" });

	if (!res.ok) {
		const body = await res.text();
		throw new Error(`POST /conversations/:id/open failed with status ${res.status}: ${body}`);
	}

	return (await res.json()) as OpenConversationResponse;
}

interface CompactConversationOpts {
	readonly server: string;
	readonly conversationId: string;
}

export async function compactConversation(
	deps: FetchDeps,
	opts: CompactConversationOpts,
): Promise<CompactResponse> {
	const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/compact`;
	const res = await deps.fetchImpl(url, { method: "POST" });

	if (!res.ok) {
		const body = await res.text();
		throw new Error(`POST /conversations/:id/compact failed with status ${res.status}: ${body}`);
	}

	return (await res.json()) as CompactResponse;
}

interface StopTurnOpts {
	readonly server: string;
	readonly conversationId: string;
}

export async function stopTurn(
	deps: FetchDeps,
	opts: StopTurnOpts,
): Promise<{ conversationId: string; abortedTurn: boolean }> {
	const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/stop`;
	const res = await deps.fetchImpl(url, { method: "POST" });

	if (!res.ok) {
		const body = await res.text();
		throw new Error(`POST /conversations/:id/stop failed with status ${res.status}: ${body}`);
	}

	return (await res.json()) as { conversationId: string; abortedTurn: boolean };
}

/**
 * The outcome of short-ID resolution: either the full conversation id to use,
 * or a human-readable error describing why resolution failed.
 */
export type ConversationIdResolution = string | { readonly error: string };

interface ResolveConversationIdOpts {
	readonly server: string;
	readonly shortId: string;
}

/**
 * Resolve a user-typed conversation prefix to a full id. A 32+ char input is
 * assumed to be a full UUID and returned untouched. Otherwise the conversation
 * list is filtered by the prefix: 1 match → its id; 0 → error; >1 → error with
 * the candidate short ids + titles so the user can disambiguate.
 */
export async function resolveConversationId(
	deps: FetchDeps,
	opts: ResolveConversationIdOpts,
): Promise<ConversationIdResolution> {
	if (opts.shortId.length >= 32) {
		return opts.shortId;
	}

	const list = await fetchConversations(deps, { server: opts.server, query: opts.shortId });
	const matches = list.conversations;

	if (matches.length === 0) {
		return { error: `No conversation matching "${opts.shortId}"` };
	}

	if (matches.length === 1) {
		const only = matches[0];
		if (only === undefined) return { error: `No conversation matching "${opts.shortId}"` };
		return only.id;
	}

	const lines = matches.map((m) => `${m.id.slice(0, 8)}  ${m.title}`).join("\n");
	return {
		error: `Multiple conversations matching "${opts.shortId}"\n${lines}`,
	};
}
