// Phase 28 reactive store for the in-game diplomatic-mail view. Owns
// the inbox / sent listings, the per-race threading projection, the
// unread badge counter, and the imperative compose / mark-read /
// delete actions. The companion API wrappers live in
// `src/api/diplomail.ts`; this store coordinates them with the rest
// of the in-game shell.

import type { GalaxyClient } from "../api/galaxy-client";
import type { Cache } from "../platform/store/index";
import {
	deleteMessage,
	fetchInbox,
	fetchMessage,
	fetchSent,
	markRead,
	sendAdmin,
	sendBroadcast,
	sendPersonal,
	type MailMessage,
	type SendAdminArgs,
	type SendBroadcastArgs,
	type SendPersonalArgs,
} from "../api/diplomail";

/**
 * MailThread groups personal messages exchanged with a single other
 * race into one entry. The local player's outgoing messages live
 * alongside incoming messages from the same race so the UI renders a
 * chat-style transcript. `unreadCount` counts only incoming messages
 * with `readAt === null`.
 */
export interface MailThread {
	kind: "thread";
	raceName: string;
	messages: MailMessage[];
	unreadCount: number;
	latestAt: Date;
}

/**
 * MailStandalone wraps a single message that does not participate in
 * a race-thread: system mail, admin notifications, and the caller's
 * own paid-tier broadcasts. The UI renders these as read-only items
 * in the same list as the per-race threads.
 */
export interface MailStandalone {
	kind: "standalone";
	message: MailMessage;
	latestAt: Date;
}

export type MailListEntry = MailThread | MailStandalone;

const CACHE_NAMESPACE = "diplomail";

/**
 * MailStore is the reactive surface consumed by the active view, the
 * header badge, and the push-event handler. One instance per signed-
 * in session is enough — the rune fields are scoped to the current
 * game and replaced on every `setGame` call so navigating between
 * games stays clean.
 */
export class MailStore {
	gameId = $state("");
	status: "idle" | "loading" | "ready" | "error" = $state("idle");
	error: string | null = $state(null);
	inbox: MailMessage[] = $state([]);
	sent: MailMessage[] = $state([]);

	private client: GalaxyClient | null = null;
	private cache: Cache | null = null;

	/**
	 * entries surfaces the unified list-pane projection: per-race
	 * threads built from incoming + outgoing personal messages plus
	 * stand-alone items for system / admin / own-broadcast rows.
	 * Sorted newest-first by the latest message inside each entry.
	 */
	entries: MailListEntry[] = $derived.by(() => buildEntries(this.inbox, this.sent));

	/**
	 * unreadCount drives the header view-menu badge. Counts only
	 * incoming personal / admin / system messages with `readAt === null`.
	 * `read_at` is not surfaced to the user in the UI but still
	 * drives this counter.
	 */
	unreadCount = $derived.by(() => this.inbox.reduce((acc, m) => (m.readAt === null ? acc + 1 : acc), 0));

	/**
	 * init configures the dependencies and fires the initial fetch.
	 * Safe to call multiple times — calls after the first one are
	 * routed to `setGame`. `localUserId` is captured so the threading
	 * projection can tell outgoing messages from incoming when the
	 * inbox and sent lists are unified.
	 */
	async init(opts: {
		client: GalaxyClient;
		cache: Cache;
		gameId: string;
	}): Promise<void> {
		this.client = opts.client;
		this.cache = opts.cache;
		await this.setGame(opts.gameId);
	}

	/**
	 * setGame switches the store to a different game id and refreshes
	 * its inbox / sent state. Idempotent on the same id — the network
	 * fetch fires only when the id actually changed or the previous
	 * load ended in `error`.
	 */
	async setGame(gameId: string): Promise<void> {
		if (this.client === null) {
			throw new Error("mail-store: setGame called before init");
		}
		if (this.gameId === gameId && this.status === "ready") {
			return;
		}
		this.gameId = gameId;
		this.status = "loading";
		this.error = null;
		this.inbox = [];
		this.sent = [];
		try {
			const [inbox, sent] = await Promise.all([
				fetchInbox(this.client, gameId),
				fetchSent(this.client, gameId),
			]);
			this.inbox = inbox;
			this.sent = sent;
			this.status = "ready";
			await this.rememberLastSeen();
		} catch (err) {
			this.status = "error";
			this.error = errorMessage(err);
		}
	}

	/** refresh re-fetches inbox + sent for the active game. */
	async refresh(): Promise<void> {
		if (this.gameId === "") {
			return;
		}
		await this.setGame(this.gameId);
	}

	/**
	 * applyPushEvent reacts to a verified `diplomail.message.received`
	 * push frame by refetching the inbox for the active game. The
	 * payload carries only a preview, so the store hits the server for
	 * the canonical row.
	 */
	async applyPushEvent(payloadGameId: string): Promise<void> {
		if (payloadGameId !== this.gameId || this.client === null) {
			return;
		}
		try {
			this.inbox = await fetchInbox(this.client, this.gameId);
			await this.rememberLastSeen();
		} catch (err) {
			this.error = errorMessage(err);
		}
	}

	/**
	 * markRead transitions an incoming message to `read`. The local
	 * inbox row is flipped optimistically; on failure the previous
	 * state is restored and the error surfaces via `error`.
	 */
	async markRead(messageId: string): Promise<void> {
		if (this.client === null) {
			return;
		}
		const before = this.inbox;
		this.inbox = before.map((m) => {
			if (m.messageId !== messageId) {
				return m;
			}
			return { ...m, readAt: m.readAt ?? new Date() };
		});
		try {
			await markRead(this.client, this.gameId, messageId);
		} catch (err) {
			this.inbox = before;
			this.error = errorMessage(err);
		}
	}

	/**
	 * softDelete removes a read incoming message from the inbox. The
	 * server enforces "read before delete"; on conflict the row is
	 * restored and the error surfaces.
	 */
	async softDelete(messageId: string): Promise<void> {
		if (this.client === null) {
			return;
		}
		const before = this.inbox;
		this.inbox = before.filter((m) => m.messageId !== messageId);
		try {
			await deleteMessage(this.client, this.gameId, messageId);
		} catch (err) {
			this.inbox = before;
			this.error = errorMessage(err);
		}
	}

	/**
	 * composePersonal sends a single-recipient personal message,
	 * addressed by race name (resolved server-side). On success the
	 * resulting row is appended to the sent list so the matching
	 * thread surfaces it immediately. Throws on failure so callers
	 * can render inline form errors.
	 */
	async composePersonal(input: Omit<SendPersonalArgs, "gameId">): Promise<MailMessage> {
		if (this.client === null) {
			throw new Error("mail-store: composePersonal called before init");
		}
		const created = await sendPersonal(this.client, { ...input, gameId: this.gameId });
		this.sent = [created, ...this.sent];
		return created;
	}

	/**
	 * composeBroadcast posts a paid-tier player broadcast. The sent
	 * list is refreshed to surface the new entries.
	 */
	async composeBroadcast(input: Omit<SendBroadcastArgs, "gameId">): Promise<void> {
		if (this.client === null) {
			throw new Error("mail-store: composeBroadcast called before init");
		}
		await sendBroadcast(this.client, { ...input, gameId: this.gameId });
		this.sent = await fetchSent(this.client, this.gameId);
	}

	/**
	 * composeAdmin posts an owner-only admin notification. Single
	 * sends refresh the sent list; broadcasts also refresh the sent
	 * list (the author does not appear as a recipient and is excluded
	 * from the resulting fan-out).
	 */
	async composeAdmin(input: Omit<SendAdminArgs, "gameId">): Promise<void> {
		if (this.client === null) {
			throw new Error("mail-store: composeAdmin called before init");
		}
		await sendAdmin(this.client, { ...input, gameId: this.gameId });
		this.sent = await fetchSent(this.client, this.gameId);
	}

	/**
	 * loadMessage fetches a single message detail (used when the UI
	 * needs the freshest translation status for a specific row).
	 * The returned row is merged into the inbox copy if it lives
	 * there; sent rows are not refreshed here.
	 */
	async loadMessage(messageId: string): Promise<MailMessage | null> {
		if (this.client === null) {
			return null;
		}
		try {
			const fresh = await fetchMessage(this.client, this.gameId, messageId);
			this.inbox = this.inbox.map((m) => (m.messageId === messageId ? fresh : m));
			return fresh;
		} catch (err) {
			this.error = errorMessage(err);
			return null;
		}
	}

	private async rememberLastSeen(): Promise<void> {
		if (this.cache === null || this.gameId === "" || this.inbox.length === 0) {
			return;
		}
		const last = this.inbox[0];
		await this.cache.put(CACHE_NAMESPACE, `${this.gameId}/last-seen`, last.messageId);
	}
}

function buildEntries(inbox: MailMessage[], sent: MailMessage[]): MailListEntry[] {
	// Each personal message keyed by another race contributes to a
	// race thread. Other shapes become stand-alone entries.
	const threadsByRace = new Map<string, MailThread>();
	const standalones: MailStandalone[] = [];

	for (const m of inbox) {
		if (isStandaloneIncoming(m)) {
			standalones.push({ kind: "standalone", message: m, latestAt: m.createdAt });
			continue;
		}
		const race = m.senderRaceName ?? "";
		if (race === "") {
			standalones.push({ kind: "standalone", message: m, latestAt: m.createdAt });
			continue;
		}
		mergeIntoThread(threadsByRace, race, m, /*isIncoming=*/true);
	}

	for (const m of sent) {
		if (isStandaloneOutgoing(m)) {
			standalones.push({ kind: "standalone", message: m, latestAt: m.createdAt });
			continue;
		}
		const race = m.recipientRaceName ?? "";
		if (race === "") {
			standalones.push({ kind: "standalone", message: m, latestAt: m.createdAt });
			continue;
		}
		mergeIntoThread(threadsByRace, race, m, /*isIncoming=*/false);
	}

	// Sort each thread's messages oldest → newest for chat-style
	// rendering; the entry list itself sorts newest-first by the
	// most-recent message timestamp.
	for (const thread of threadsByRace.values()) {
		thread.messages.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
		const last = thread.messages[thread.messages.length - 1];
		thread.latestAt = last.createdAt;
	}

	// Broadcast and admin fan-outs return one row per recipient from
	// the `/sent` endpoint (so the admin UI sees the materialised
	// audience). The in-game list pane collapses them by `message_id`
	// — without this dedupe the {#each} key in `thread-list.svelte`
	// repeats and Svelte 5 aborts the render with `each_key_duplicate`.
	const seen = new Set<string>();
	const dedupedStandalones: MailStandalone[] = [];
	for (const s of standalones) {
		if (seen.has(s.message.messageId)) {
			continue;
		}
		seen.add(s.message.messageId);
		dedupedStandalones.push(s);
	}

	const entries: MailListEntry[] = [
		...Array.from(threadsByRace.values()),
		...dedupedStandalones,
	];
	entries.sort((a, b) => b.latestAt.getTime() - a.latestAt.getTime());
	return entries;
}

function mergeIntoThread(
	threads: Map<string, MailThread>,
	race: string,
	message: MailMessage,
	isIncoming: boolean,
): void {
	let thread = threads.get(race);
	if (thread === undefined) {
		thread = {
			kind: "thread",
			raceName: race,
			messages: [],
			unreadCount: 0,
			latestAt: message.createdAt,
		};
		threads.set(race, thread);
	}
	thread.messages.push(message);
	if (isIncoming && message.readAt === null) {
		thread.unreadCount += 1;
	}
	if (message.createdAt.getTime() > thread.latestAt.getTime()) {
		thread.latestAt = message.createdAt;
	}
}

function isStandaloneIncoming(m: MailMessage): boolean {
	// System / admin notifications never thread by race even when a
	// snapshot is available — they are one-way operational mail.
	return m.senderKind !== "player";
}

function isStandaloneOutgoing(m: MailMessage): boolean {
	// Paid-tier broadcasts that the caller authored target many
	// recipients; the UI renders them once as a stand-alone item.
	return m.broadcastScope !== "single";
}

function errorMessage(err: unknown): string {
	if (err instanceof Error) {
		return err.message;
	}
	return String(err);
}

export const mailStore = new MailStore();
