/**
 * conversationsStore - Reactive State Store for Conversations
 *
 * Manages conversation lifecycle, persistence, navigation, and MCP server overrides.
 *
 * **Architecture & Relationships:**
 * - **DatabaseService**: Stateless IndexedDB layer
 * - **conversationsStore** (this): Reactive state + business logic
 * - **chatStore**: Chat-specific state (streaming, loading)
 *
 * **Key Responsibilities:**
 * - Conversation CRUD (create, load, delete)
 * - Message management and tree navigation
 * - MCP server per-chat overrides
 * - Import/Export functionality
 * - Title management with confirmation
 *
 * @see DatabaseService in services/database.ts for IndexedDB operations
 */

import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
import { DatabaseService } from '$lib/services/database.service';
import { MigrationService } from '$lib/services/migration.service';
import { config } from '$lib/stores/settings.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
import type { McpServerOverride } from '$lib/types/database';
import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate';
import {
	MessageRole,
	FileExtensionText,
	MimeTypeText,
	MimeTypeApplication,
	ReasoningEffort,
	SessionRecordType
} from '$lib/enums';
import {
	ISO_DATE_TIME_SEPARATOR,
	ISO_DATE_TIME_SEPARATOR_REPLACEMENT,
	ISO_TIMESTAMP_SLICE_LENGTH,
	EXPORT_CONV_ID_TRIM_LENGTH,
	EXPORT_CONV_NONALNUM_REPLACEMENT,
	EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH,
	ISO_TIME_SEPARATOR,
	ISO_TIME_SEPARATOR_REPLACEMENT,
	NON_ALPHANUMERIC_REGEX,
	MULTIPLE_UNDERSCORE_REGEX,
	REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY,
	NEWLINE,
	SESSION_HARNESS,
	ZIP_MAGIC
} from '$lib/constants';

import { ROUTES } from '$lib/constants/routes';
import { RouterService } from '$lib/services/router.service';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';

export interface ConversationTreeItem {
	conversation: DatabaseConversation;
	depth: number;
}

class ConversationsStore {
	/**
	 *
	 *
	 * State
	 *
	 *
	 */

	/** List of all conversations */
	conversations = $state<DatabaseConversation[]>([]);

	/** Currently active conversation */
	activeConversation = $state<DatabaseConversation | null>(null);

	/** Messages in the active conversation (filtered by currNode path) */
	activeMessages = $state<DatabaseMessage[]>([]);

	/** Whether the store has been initialized */
	isInitialized = $state(false);

	/** Global (non-conversation-specific) reasoning effort default */
	pendingReasoningEffort = $state<ReasoningEffort>(ConversationsStore.loadReasoningEffortDefault());

	/** Load reasoning effort default from localStorage, DEFAULT defers to the server */
	private static loadReasoningEffortDefault(): ReasoningEffort {
		if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.DEFAULT;
		try {
			const raw = localStorage.getItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY);
			return (raw as ReasoningEffort) || ReasoningEffort.DEFAULT;
		} catch {
			return ReasoningEffort.DEFAULT;
		}
	}

	/** Persist reasoning effort default to localStorage */
	private saveReasoningEffortDefaults(): void {
		if (typeof globalThis.localStorage === 'undefined') return;
		localStorage.setItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY, this.pendingReasoningEffort);
	}

	/**
	 * Callback for updating message content in chatStore.
	 * Registered by chatStore to enable cross-store updates without circular dependency.
	 */
	private messageUpdateCallback:
		| ((messageId: string, updates: Partial<DatabaseMessage>) => void)
		| null = null;

	/**
	 *
	 *
	 * Lifecycle
	 *
	 *
	 */

	/**
	 * Initialize the store by loading conversations from database.
	 * Must be called once after app startup.
	 */
	async init(): Promise<void> {
		if (!browser) return;
		if (this.isInitialized) return;

		try {
			await MigrationService.runAllMigrations();
			await this.loadConversations();
			this.isInitialized = true;
		} catch (error) {
			console.error('Failed to initialize conversations:', error);
		}
	}

	/**
	 * Alias for init() for backward compatibility.
	 */
	async initialize(): Promise<void> {
		return this.init();
	}

	/**
	 * Register a callback for message updates from other stores.
	 * Called by chatStore during initialization.
	 */
	registerMessageUpdateCallback(
		callback: (messageId: string, updates: Partial<DatabaseMessage>) => void
	): void {
		this.messageUpdateCallback = callback;
	}

	/**
	 *
	 *
	 * Message Array Operations
	 *
	 *
	 */

	/**
	 * Adds a message to the active messages array
	 */
	addMessageToActive(message: DatabaseMessage): void {
		this.activeMessages.push(message);
	}

	/**
	 * Updates a message at a specific index in active messages
	 */
	updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
		const message = index === -1 ? undefined : this.activeMessages[index];

		if (!message) return;

		// Assign field by field rather than replacing the object. Replacing it
		// changes the array slot, which invalidates every consumer that merely
		// walks the list - notably ChatMessages.displayMessages, which rebuilds
		// entries for every message in the conversation. Deep $state proxies make
		// per-field writes fine-grained, so only readers of the changed field wake.
		const target = message as unknown as Record<string, unknown>;

		for (const [key, value] of Object.entries(updates)) {
			if (target[key] !== value) {
				target[key] = value;
			}
		}
	}

	/**
	 * Finds the index of a message in active messages
	 */
	findMessageIndex(messageId: string): number {
		return this.activeMessages.findIndex((m) => m.id === messageId);
	}

	/**
	 * Removes messages from active messages starting at an index
	 */
	sliceActiveMessages(startIndex: number): void {
		this.activeMessages = this.activeMessages.slice(0, startIndex);
	}

	/**
	 * Removes a message from active messages by index
	 */
	removeMessageAtIndex(index: number): DatabaseMessage | undefined {
		if (index !== -1) {
			return this.activeMessages.splice(index, 1)[0];
		}
		return undefined;
	}

	/**
	 *
	 *
	 * Conversation CRUD
	 *
	 *
	 */

	/**
	 * Loads all conversations from the database
	 */
	async loadConversations(): Promise<void> {
		const conversations = await DatabaseService.getAllConversations();
		this.conversations = conversations;
	}

	/**
	 * Creates a new conversation and navigates to it
	 * @param name - Optional name for the conversation
	 * @returns The ID of the created conversation
	 */
	async createConversation(name?: string): Promise<string> {
		const conversationName = name || `Chat ${new Date().toLocaleString()}`;
		const conversation = await DatabaseService.createConversation(conversationName);

		// No MCP override list is seeded: getAllMcpServerOverrides resolves
		// servers without a per-conversation override to `mcpServers[i].enabled`,
		// and only explicit toggles are stored on the conversation.

		// Inherit the global reasoning default into the new conversation
		conversation.reasoningEffort = this.pendingReasoningEffort;
		await DatabaseService.updateConversation(conversation.id, {
			reasoningEffort: this.pendingReasoningEffort
		});

		this.conversations = [conversation, ...this.conversations];
		this.activeConversation = conversation;
		this.activeMessages = [];

		await goto(RouterService.chat(conversation.id));

		return conversation.id;
	}

	/**
	 * Loads a specific conversation and its messages
	 * @param convId - The conversation ID to load
	 * @returns True if conversation was loaded successfully
	 */
	async loadConversation(convId: string): Promise<boolean> {
		try {
			const conversation = await DatabaseService.getConversation(convId);

			if (!conversation) {
				return false;
			}

			this.activeConversation = conversation;

			if (conversation.currNode) {
				const allMessages = await DatabaseService.getConversationMessages(convId);
				const filteredMessages = filterByLeafNodeId(
					allMessages,
					conversation.currNode,
					false
				) as DatabaseMessage[];
				this.activeMessages = filteredMessages;
			} else {
				const messages = await DatabaseService.getConversationMessages(convId);
				this.activeMessages = messages;
			}

			return true;
		} catch (error) {
			console.error('Failed to load conversation:', error);
			return false;
		}
	}

	/**
	 * Clears the active conversation and messages.
	 */
	clearActiveConversation(): void {
		this.activeConversation = null;
		this.activeMessages = [];
		// reload defaults so new chats inherit persisted state
		this.pendingReasoningEffort = ConversationsStore.loadReasoningEffortDefault();
	}

	/**
	 * Deletes a conversation and all its messages
	 * @param convId - The conversation ID to delete
	 */
	async deleteConversation(convId: string, options?: { deleteWithForks?: boolean }): Promise<void> {
		try {
			await DatabaseService.deleteConversation(convId, options);

			if (options?.deleteWithForks) {
				// Collect all descendants recursively
				const idsToRemove = new SvelteSet([convId]);
				const queue = [convId];
				while (queue.length > 0) {
					const parentId = queue.pop()!;
					for (const c of this.conversations) {
						if (c.forkedFromConversationId === parentId && !idsToRemove.has(c.id)) {
							idsToRemove.add(c.id);
							queue.push(c.id);
						}
					}
				}
				this.conversations = this.conversations.filter((c) => !idsToRemove.has(c.id));

				if (this.activeConversation && idsToRemove.has(this.activeConversation.id)) {
					this.clearActiveConversation();
					await goto(ROUTES.NEW_CHAT);
				}
			} else {
				// Reparent direct children to deleted conv's parent (or promote to top-level)
				const deletedConv = this.conversations.find((c) => c.id === convId);
				const newParent = deletedConv?.forkedFromConversationId;
				this.conversations = this.conversations
					.filter((c) => c.id !== convId)
					.map((c) =>
						c.forkedFromConversationId === convId
							? { ...c, forkedFromConversationId: newParent }
							: c
					);

				if (this.activeConversation?.id === convId) {
					this.clearActiveConversation();
					await goto(ROUTES.NEW_CHAT);
				}
			}
		} catch (error) {
			console.error('Failed to delete conversation:', error);
		}
	}

	/**
	 * Deletes all conversations and their messages
	 */
	async deleteAll(): Promise<void> {
		try {
			const allConversations = await DatabaseService.getAllConversations();

			for (const conv of allConversations) {
				await DatabaseService.deleteConversation(conv.id);
			}

			this.clearActiveConversation();
			this.conversations = [];

			toast.success('All conversations deleted');

			await goto(ROUTES.NEW_CHAT);
		} catch (error) {
			console.error('Failed to delete all conversations:', error);
			toast.error('Failed to delete conversations');
		}
	}

	/**
	 * Deletes multiple conversations in sequence.
	 * Mirrors deleteConversation() per-id; navigates to NEW_CHAT only if the
	 * currently-open chat was among the deleted ones.
	 * @param convIds - Conversation IDs to delete
	 */
	async bulkDeleteConversations(convIds: string[]): Promise<void> {
		if (convIds.length === 0) return;

		try {
			const idsToRemove = new SvelteSet(convIds);
			// Collect all descendants recursively so the local cache stays consistent
			// even when deleteWithForks is omitted.
			const queue = [...convIds];
			while (queue.length > 0) {
				const parentId = queue.pop()!;
				for (const c of this.conversations) {
					if (c.forkedFromConversationId === parentId && !idsToRemove.has(c.id)) {
						idsToRemove.add(c.id);
						queue.push(c.id);
					}
				}
			}

			const activeWasDeleted =
				this.activeConversation !== null && idsToRemove.has(this.activeConversation.id);

			await DatabaseService.bulkDeleteConversations([...idsToRemove]);

			this.conversations = this.conversations.filter((c) => !idsToRemove.has(c.id));

			if (activeWasDeleted) {
				this.clearActiveConversation();
				await goto(ROUTES.NEW_CHAT);
			}

			toast.success(
				convIds.length === 1 ? 'Conversation deleted' : `${convIds.length} conversations deleted`
			);
		} catch (error) {
			console.error('Failed to bulk delete conversations:', error);
			toast.error('Failed to delete conversations');
		}
	}

	/**
	 * Toggles the pinned state of each conversation individually.
	 * Mixed-pin selections are intentionally not normalised here; the bulk
	 * action UI surfaces them as a disabled mixed-state instead.
	 * @param convIds - Conversation IDs to toggle
	 */
	async bulkToggleConversationPin(convIds: string[]): Promise<void> {
		if (convIds.length === 0) return;

		try {
			const updates = await DatabaseService.bulkToggleConversationPins(convIds);

			const activeId = this.activeConversation?.id;
			if (activeId && updates.has(activeId)) {
				this.activeConversation = {
					...this.activeConversation!,
					pinned: updates.get(activeId)!
				};
			}
			for (let i = 0; i < this.conversations.length; i++) {
				const newPinned = updates.get(this.conversations[i].id);
				if (newPinned !== undefined) this.conversations[i].pinned = newPinned;
			}
			this.conversations = [...this.conversations];

			toast.success(
				convIds.length === 1
					? 'Conversation pin toggled'
					: `Updated pin state for ${convIds.length} conversations`
			);
		} catch (error) {
			console.error('Failed to bulk toggle pin:', error);
			toast.error('Failed to update pin state');
		}
	}

	/**
	 * Bundles the given conversations into a single zip archive and triggers a
	 * browser download (one JSONL file per conversation).
	 * @param convIds - Conversation IDs to export
	 */
	async bulkExportConversations(convIds: string[]): Promise<void> {
		if (convIds.length === 0) return;

		try {
			const fetched = await DatabaseService.getConversationsWithMessages(convIds);

			const activeId = this.activeConversation?.id;
			const overridden = fetched.get(activeId ?? '');
			if (overridden && activeId) {
				overridden.conv = { ...this.activeConversation! };
			}

			const exported = [...fetched.values()];
			if (exported.length === 0) {
				toast.error('No conversations to export');
				return;
			}

			this.downloadConversationsArchive(exported);

			toast.success(
				exported.length === 1
					? 'Conversation exported'
					: `${exported.length} conversations exported`
			);
		} catch (error) {
			console.error('Failed to bulk export conversations:', error);
			toast.error('Failed to export conversations');
		}
	}

	/**
	 *
	 *
	 * Message Management
	 *
	 *
	 */

	/**
	 * Refreshes active messages based on currNode after branch navigation.
	 */
	async refreshActiveMessages(): Promise<void> {
		if (!this.activeConversation) return;

		const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);

		if (allMessages.length === 0) {
			this.activeMessages = [];
			return;
		}

		const leafNodeId =
			this.activeConversation.currNode ||
			allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;

		const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];

		this.activeMessages = currentPath;
	}

	/**
	 * Gets all messages for a specific conversation
	 * @param convId - The conversation ID
	 * @returns Array of messages
	 */
	async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
		return await DatabaseService.getConversationMessages(convId);
	}

	/**
	 *
	 *
	 * Title Management
	 *
	 *
	 */

	/**
	 * Updates the name of a conversation.
	 * @param convId - The conversation ID to update
	 * @param name - The new name for the conversation
	 */
	async updateConversationName(convId: string, name: string): Promise<void> {
		try {
			await DatabaseService.updateConversation(convId, { name });

			const convIndex = this.conversations.findIndex((c) => c.id === convId);

			if (convIndex !== -1) {
				this.conversations[convIndex].name = name;
				this.conversations = [...this.conversations];
			}

			if (this.activeConversation?.id === convId) {
				this.activeConversation = { ...this.activeConversation, name };
			}
		} catch (error) {
			console.error('Failed to update conversation name:', error);
		}
	}

	/**
	 * Toggles the pinned status of a conversation.
	 * @param convId - The conversation ID to toggle
	 * @returns The new pinned status
	 */
	async toggleConversationPin(convId: string): Promise<boolean> {
		try {
			const newPinnedState = await DatabaseService.toggleConversationPin(convId);

			const convIndex = this.conversations.findIndex((c) => c.id === convId);

			if (convIndex !== -1) {
				this.conversations[convIndex].pinned = newPinnedState;
				this.conversations = [...this.conversations];
			}

			if (this.activeConversation?.id === convId) {
				this.activeConversation = { ...this.activeConversation, pinned: newPinnedState };
			}

			return newPinnedState;
		} catch (error) {
			console.error('Failed to toggle conversation pin:', error);
			return false;
		}
	}

	/**
	 * Updates conversation lastModified timestamp and moves it to top of list
	 */
	updateConversationTimestamp(): void {
		if (!this.activeConversation) return;

		const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);

		if (chatIndex !== -1) {
			this.conversations[chatIndex].lastModified = Date.now();
			const updatedConv = this.conversations.splice(chatIndex, 1)[0];
			this.conversations = [updatedConv, ...this.conversations];
		}
	}

	/**
	 * Updates the current node of the active conversation
	 * @param nodeId - The new current node ID
	 */
	async updateCurrentNode(nodeId: string): Promise<void> {
		if (!this.activeConversation) return;

		await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
		this.activeConversation = { ...this.activeConversation, currNode: nodeId };
	}

	/**
	 *
	 *
	 * Branch Navigation
	 *
	 *
	 */

	/**
	 * Navigates to a specific sibling branch by updating currNode and refreshing messages.
	 * @param siblingId - The sibling message ID to navigate to
	 */
	async navigateToSibling(siblingId: string): Promise<void> {
		if (!this.activeConversation) return;

		const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
		const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
		const currentFirstUserMessage = this.activeMessages.find(
			(m) => m.role === MessageRole.USER && m.parent === rootMessage?.id
		);

		const currentLeafNodeId = findLeafNode(allMessages, siblingId);

		await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
		this.activeConversation = { ...this.activeConversation, currNode: currentLeafNodeId };
		await this.refreshActiveMessages();

		if (rootMessage && this.activeMessages.length > 0) {
			const newFirstUserMessage = this.activeMessages.find(
				(m) => m.role === MessageRole.USER && m.parent === rootMessage.id
			);

			if (
				newFirstUserMessage &&
				newFirstUserMessage.content.trim() &&
				(!currentFirstUserMessage ||
					newFirstUserMessage.id !== currentFirstUserMessage.id ||
					newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
			) {
				await this.updateConversationName(
					this.activeConversation.id,
					generateConversationTitle(
						newFirstUserMessage.content,
						Boolean(config().titleGenerationUseFirstLine)
					)
				);
			}
		}
	}

	/**
	 *
	 *
	 * MCP Server Overrides
	 *
	 *
	 */

	/**
	 * Resolve the default enabled value for a server: its own `enabled`
	 * flag in `mcpServers`, so the global on/off state lives in one place.
	 */
	#getDefaultOverride(serverId: string): McpServerOverride | undefined {
		const server = mcpStore.getServers().find((s) => s.id === serverId);
		if (!server) return undefined;
		return { serverId, enabled: server.enabled };
	}

	/**
	 * Gets the effective MCP server override for a specific server.
	 * A per-conversation override wins when present; a server without one
	 * resolves to its `mcpServers[i].enabled` default.
	 * @param serverId - The server ID to check
	 * @returns The effective override, undefined if no matching server
	 */
	getMcpServerOverride(serverId: string): McpServerOverride | undefined {
		const override = this.activeConversation?.mcpServerOverrides?.find(
			(o: McpServerOverride) => o.serverId === serverId
		);
		if (override) return override;
		return this.#getDefaultOverride(serverId);
	}

	/**
	 * Gets the effective override list for the current conversation:
	 * one entry per configured server, resolved per server. The stored
	 * per-conversation list is sparse and only holds explicit toggles.
	 */
	getAllMcpServerOverrides(): McpServerOverride[] {
		const overrides = this.activeConversation?.mcpServerOverrides;
		return mcpStore.getServers().map((s) => {
			const override = overrides?.find((o: McpServerOverride) => o.serverId === s.id);
			return { serverId: s.id, enabled: override?.enabled ?? s.enabled };
		});
	}

	/**
	 * Checks if an MCP server is enabled for the active conversation.
	 * @param serverId - The server ID to check
	 * @returns True if server is enabled for this conversation
	 */
	isMcpServerEnabledForChat(serverId: string): boolean {
		const override = this.getMcpServerOverride(serverId);
		return override?.enabled ?? false;
	}

	/**
	 * Sets or removes MCP server override for the active conversation.
	 * If no conversation exists, persists `enabled` onto `mcpServers[i].enabled`
	 * (the single source of truth for new-chat defaults).
	 * @param serverId - The server ID to override
	 * @param enabled - The enabled state, or undefined to remove per-conversation override
	 */
	async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> {
		if (!this.activeConversation) {
			if (enabled !== undefined) {
				mcpStore.updateServer(serverId, { enabled });
			}
			return;
		}

		// Clone to plain objects to avoid Proxy serialization issues with IndexedDB
		const currentOverrides = (this.activeConversation.mcpServerOverrides || []).map(
			(o: McpServerOverride) => ({
				serverId: o.serverId,
				enabled: o.enabled
			})
		);
		let newOverrides: McpServerOverride[];

		if (enabled === undefined) {
			newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId);
		} else {
			const existingIndex = currentOverrides.findIndex(
				(o: McpServerOverride) => o.serverId === serverId
			);
			if (existingIndex >= 0) {
				newOverrides = [...currentOverrides];
				newOverrides[existingIndex] = { serverId, enabled };
			} else {
				newOverrides = [...currentOverrides, { serverId, enabled }];
			}
		}

		await DatabaseService.updateConversation(this.activeConversation.id, {
			mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
		});

		this.activeConversation = {
			...this.activeConversation,
			mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
		};

		const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
		if (convIndex !== -1) {
			this.conversations[convIndex].mcpServerOverrides =
				newOverrides.length > 0 ? newOverrides : undefined;
			this.conversations = [...this.conversations];
		}
	}

	/**
	 * Toggles MCP server enabled state for the active conversation.
	 * @param serverId - The server ID to toggle
	 */
	async toggleMcpServerForChat(serverId: string): Promise<void> {
		const currentEnabled = this.isMcpServerEnabledForChat(serverId);
		await this.setMcpServerOverride(serverId, !currentEnabled);
	}

	/**
	 * Removes MCP server override for the active conversation.
	 * @param serverId - The server ID to remove override for
	 */
	async removeMcpServerOverride(serverId: string): Promise<void> {
		await this.setMcpServerOverride(serverId, undefined);
	}

	/**
	 * Gets the effective reasoning effort for the active conversation.
	 * Returns the conversation override if set, otherwise the global default.
	 * DEFAULT means no override is sent and the server decides.
	 */
	getReasoningEffort(): ReasoningEffort {
		if (this.activeConversation) {
			if (this.activeConversation.reasoningEffort !== undefined) {
				return this.activeConversation.reasoningEffort;
			}
			// conversations created before the tri-state store an explicit
			// opt-out only as thinkingEnabled = false
			if (this.activeConversation.thinkingEnabled === false) {
				return ReasoningEffort.OFF;
			}
		}
		return this.pendingReasoningEffort;
	}

	/**
	 * Sets the reasoning effort for the active conversation.
	 * If no conversation exists, stores the global default.
	 * @param effort - The effort level ('default' | 'off' | 'low' | 'medium' | 'high' | 'max')
	 */
	async setReasoningEffort(effort: ReasoningEffort): Promise<void> {
		if (!this.activeConversation) {
			this.pendingReasoningEffort = effort;
			this.saveReasoningEffortDefaults();
			return;
		}

		this.activeConversation = {
			...this.activeConversation,
			reasoningEffort: effort
		};

		await DatabaseService.updateConversation(this.activeConversation.id, {
			reasoningEffort: effort
		});

		const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
		if (convIndex !== -1) {
			this.conversations[convIndex].reasoningEffort = effort;
			this.conversations = [...this.conversations];
		}
	}

	/**
	 * Forks a conversation at a specific message, creating a new conversation
	 * containing messages from root up to the target message, then navigates to it.
	 *
	 * @param messageId - The message ID to fork at
	 * @param options - Fork options (name and whether to include attachments)
	 * @returns The new conversation ID, or null if fork failed
	 */
	async forkConversation(
		messageId: string,
		options: { name: string; includeAttachments: boolean }
	): Promise<string | null> {
		if (!this.activeConversation) return null;

		try {
			const newConv = await DatabaseService.forkConversation(
				this.activeConversation.id,
				messageId,
				options
			);

			this.conversations = [newConv, ...this.conversations];

			await goto(RouterService.chat(newConv.id));

			toast.success('Conversation forked');

			return newConv.id;
		} catch (error) {
			console.error('Failed to fork conversation:', error);
			toast.error('Failed to fork conversation');

			return null;
		}
	}

	/**
	 *
	 *
	 * Import & Export
	 *
	 *
	 */

	/**
	 * Generates a sanitized filename for a conversation export
	 * @param conversation - The conversation metadata
	 * @param msgs - Optional array of messages belonging to the conversation
	 * @returns The generated filename string
	 */
	generateConversationFilename(
		conversation: { id?: string; name?: string },
		msgs?: DatabaseMessage[]
	): string {
		const conversationName = (conversation.name ?? '').trim().toLowerCase();

		const sanitizedName = conversationName
			.replace(NON_ALPHANUMERIC_REGEX, EXPORT_CONV_NONALNUM_REPLACEMENT)
			.replace(MULTIPLE_UNDERSCORE_REGEX, '_')
			.substring(0, EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH);

		// If we have messages, use the timestamp of the newest message
		const referenceDate = msgs?.length
			? new Date(Math.max(...msgs.map((m) => m.timestamp)))
			: new Date();

		const iso = referenceDate.toISOString().slice(0, ISO_TIMESTAMP_SLICE_LENGTH);
		const formattedDate = iso
			.replace(ISO_DATE_TIME_SEPARATOR, ISO_DATE_TIME_SEPARATOR_REPLACEMENT)
			.replaceAll(ISO_TIME_SEPARATOR, ISO_TIME_SEPARATOR_REPLACEMENT);
		const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV_ID_TRIM_LENGTH) ?? '';
		return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}${FileExtensionText.JSONL}`;
	}

	/**
	 * Serializes a session (a conversation with its messages) as JSONL.
	 * The first line is the session header (a `SessionRecordType.SESSION` record
	 * carrying the conversation properties); each subsequent line is a single message.
	 * @param data - The exported conversation payload
	 * @returns The JSONL string (one record per line)
	 */
	serializeSessionToJsonl(data: ExportedConversation): string {
		const { conv, messages } = data;

		const sessionLine = JSON.stringify({
			type: SessionRecordType.SESSION,
			harness: SESSION_HARNESS,
			...conv
		});
		const messageLines = messages.map((message: DatabaseMessage) => {
			// `toolCalls` is stored as a JSON string; drop it when empty, otherwise parse it.
			const { toolCalls, ...rest } = message;
			const normalized = toolCalls ? { ...rest, toolCalls: JSON.parse(toolCalls) } : rest;

			return JSON.stringify({ type: SessionRecordType.MESSAGE, message: normalized });
		});

		return [sessionLine, ...messageLines].join(NEWLINE);
	}

	/**
	 * Parses the JSONL session format produced by {@link serializeSessionToJsonl}.
	 * A `SessionRecordType.SESSION` line starts a new session; following
	 * `SessionRecordType.MESSAGE` lines are appended to it. Supports multiple
	 * sessions in a single file.
	 * @param text - The JSONL file contents
	 * @returns The parsed conversations with their messages
	 */
	parseSessionsJsonl(text: string): ExportedConversation[] {
		const sessions: ExportedConversation[] = [];
		let current: ExportedConversation | null = null;

		for (const line of text.split(NEWLINE)) {
			const trimmed = line.trim();
			if (!trimmed) continue;

			const record = JSON.parse(trimmed);

			if (record.type === SessionRecordType.SESSION) {
				// Drop the discriminator and harness marker; the rest is the conversation.
				const conv = { ...record };
				delete conv.type;
				delete conv.harness;
				current = { conv: conv as DatabaseConversation, messages: [] };
				sessions.push(current);
			} else if (record.type === SessionRecordType.MESSAGE) {
				if (!current) {
					throw new Error('Invalid JSONL: message record before any session record');
				}

				const message = record.message as DatabaseMessage;
				// `toolCalls` is parsed to an array on export; the DB stores it as a string.
				if (message.toolCalls !== undefined && typeof message.toolCalls !== 'string') {
					message.toolCalls = JSON.stringify(message.toolCalls);
				}
				current.messages.push(message);
			}
			// Ignore unknown record types for forward compatibility.
		}

		return sessions;
	}

	/**
	 * Reports whether the text is the JSONL session format, whose first non-empty
	 * line is a `SessionRecordType.SESSION` record. A legacy JSON export starts
	 * with an array or an object that has no such discriminator.
	 * @param text - The file contents
	 */
	private isSessionsJsonl(text: string): boolean {
		const trimmed = text.trimStart();
		const lineEnd = trimmed.indexOf(NEWLINE);
		const firstLine = lineEnd === -1 ? trimmed : trimmed.slice(0, lineEnd);

		try {
			return JSON.parse(firstLine).type === SessionRecordType.SESSION;
		} catch {
			// Not a standalone JSON record, so not the JSONL format.
			return false;
		}
	}

	/**
	 * Parses an import file into conversations, accepting the current JSONL and
	 * ZIP formats as well as the legacy JSON format. The format comes from the
	 * contents, so an import works whatever the file is named.
	 * @param file - The user-selected file
	 * @returns The parsed conversations with their messages
	 */
	async parseImportFile(file: File): Promise<ExportedConversation[]> {
		const bytes = new Uint8Array(await file.arrayBuffer());

		if (ZIP_MAGIC.every((byte, index) => bytes[index] === byte)) {
			const entries = unzipSync(bytes);
			const sessions: ExportedConversation[] = [];
			for (const [entryName, entryBytes] of Object.entries(entries)) {
				if (!entryName.toLowerCase().endsWith(FileExtensionText.JSONL)) continue;
				sessions.push(...this.parseSessionsJsonl(strFromU8(entryBytes)));
			}
			return sessions;
		}

		const text = strFromU8(bytes);

		if (this.isSessionsJsonl(text)) {
			return this.parseSessionsJsonl(text);
		}

		// Legacy JSON format: an array of conversations or a single conversation object.
		const parsed = JSON.parse(text);
		if (Array.isArray(parsed)) {
			return parsed;
		}
		if (parsed && typeof parsed === 'object' && 'conv' in parsed && 'messages' in parsed) {
			return [parsed];
		}
		throw new Error(
			'Invalid file format: expected array of conversations or single conversation object'
		);
	}

	/**
	 * Triggers a browser download of the provided exported conversation data
	 * @param data - The exported conversation payload (a single conversation with its messages)
	 * @param filename - Filename; if omitted, a deterministic name is generated
	 */
	downloadConversationFile(data: ExportedConversation, filename?: string): void {
		const { conv: conversation, messages: msgs } = data;

		if (!conversation) {
			console.error('Invalid data: missing conversation');
			return;
		}

		const downloadFilename = filename ?? this.generateConversationFilename(conversation, msgs);

		const jsonl = this.serializeSessionToJsonl(data);
		const blob = new Blob([jsonl], { type: MimeTypeText.JSONL });
		this.triggerDownload(blob, downloadFilename);
	}

	/**
	 * Triggers a browser download of multiple conversations as a `.zip`, one
	 * `.jsonl` file per conversation.
	 * @param data - The conversations to export
	 */
	downloadConversationsArchive(data: ExportedConversation[]): void {
		if (data.length === 0) {
			console.error('Invalid data: no conversations to export');
			return;
		}

		const usedNames = new SvelteSet<string>();
		const files: Record<string, Uint8Array> = {};

		for (const session of data) {
			const baseName = this.generateConversationFilename(session.conv, session.messages);

			// Disambiguate any duplicate filenames within the archive.
			let entryName = baseName;
			let suffix = 1;
			while (usedNames.has(entryName)) {
				entryName = baseName.replace(
					new RegExp(`${FileExtensionText.JSONL}$`),
					`_${suffix++}${FileExtensionText.JSONL}`
				);
			}
			usedNames.add(entryName);

			files[entryName] = strToU8(this.serializeSessionToJsonl(session));
		}

		const archiveName = `${new Date().toISOString().split(ISO_DATE_TIME_SEPARATOR)[0]}_conversations${FileExtensionText.ZIP}`;

		const zipped = zipSync(files);
		const blob = new Blob([zipped], { type: MimeTypeApplication.ZIP });
		this.triggerDownload(blob, archiveName);
	}

	/**
	 * Triggers a browser download of a blob under the given filename.
	 */
	private triggerDownload(blob: Blob, filename: string): void {
		const url = URL.createObjectURL(blob);
		const a = document.createElement('a');
		a.href = url;
		a.download = filename;
		document.body.appendChild(a);
		a.click();
		document.body.removeChild(a);
		URL.revokeObjectURL(url);
	}

	/**
	 * Downloads a single conversation as a JSONL file, serializing the full message tree.
	 * @param convId - The conversation ID to download
	 */
	async downloadConversation(convId: string): Promise<void> {
		const conversation =
			this.activeConversation?.id === convId
				? this.activeConversation
				: await DatabaseService.getConversation(convId);

		if (!conversation) return;

		const messages = await DatabaseService.getConversationMessages(convId);

		this.downloadConversationFile({ conv: conversation, messages });
	}

	/**
	 * Imports conversations from provided data (without file picker)
	 * @param data - Array of conversation data with messages
	 * @returns The conversations written to the database and the ones skipped
	 */
	async importConversationsData(
		data: ExportedConversations
	): Promise<{ imported: DatabaseConversation[]; skipped: DatabaseConversation[] }> {
		const result = await DatabaseService.importConversations(data);
		await this.loadConversations();
		return result;
	}
}

export const conversationsStore = new ConversationsStore();

// Auto-initialize in browser
if (browser) {
	conversationsStore.init();
}

export const conversations = () => conversationsStore.conversations;
export const activeConversation = () => conversationsStore.activeConversation;
export const activeMessages = () => conversationsStore.activeMessages;
export const isConversationsInitialized = () => conversationsStore.isInitialized;

/**
 * Builds a flat tree of conversations with depth levels for nested forks.
 * Accepts a pre-filtered list so search filtering stays in the component.
 *
 * Output order matches the sidebar render exactly: pinned first, then
 * unpinned by lastModified desc, with forks interleaved under their parents.
 * Range-select / marquee in the sidebar rely on this alignment.
 */

// Pinned conversations first, then by lastModified descending
const comparePinnedThenRecent = (a: DatabaseConversation, b: DatabaseConversation) => {
	if (a.pinned && !b.pinned) return -1;
	if (!a.pinned && b.pinned) return 1;
	return b.lastModified - a.lastModified;
};

export function buildConversationTree(convs: DatabaseConversation[]): ConversationTreeItem[] {
	const childrenByParent = new SvelteMap<string, DatabaseConversation[]>();
	const forkIds = new SvelteSet<string>();

	for (const conv of convs) {
		if (conv.forkedFromConversationId) {
			forkIds.add(conv.id);

			const siblings = childrenByParent.get(conv.forkedFromConversationId) || [];

			siblings.push(conv);
			childrenByParent.set(conv.forkedFromConversationId, siblings);
		}
	}

	const result: ConversationTreeItem[] = [];
	const visited = new SvelteSet<string>();

	function walk(conv: DatabaseConversation, depth: number) {
		visited.add(conv.id);
		result.push({ conversation: conv, depth });

		const children = childrenByParent.get(conv.id);
		if (children) {
			children.sort(comparePinnedThenRecent);

			for (const child of children) {
				walk(child, depth + 1);
			}
		}
	}

	const roots = convs.filter((c) => !forkIds.has(c.id)).sort(comparePinnedThenRecent);
	for (const root of roots) {
		walk(root, 0);
	}

	for (const conv of convs) {
		if (!visited.has(conv.id)) {
			walk(conv, 1);
		}
	}

	return result;
}
