/**
 * AbracadabraMCPServer — manages connection lifecycle, provider cache, and cleanup.
 *
 * Space-aware: on connect, discovers Spaces (top-level docs with kind="space")
 * by listing children of the server root. The first Space becomes the active
 * one; use switchSpace(docId) to change.
 */

import type { DocumentMeta, ServerInfo } from "@abraca/dabra";
import {
	AbracadabraClient,
	AbracadabraProvider,
	foldRecords,
	isEncryptedContent,
	Kind,
	recordFromYAny,
	SERVER_ROOT_ID,
} from "@abraca/dabra";
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import * as Y from "yjs";
import { loadOrCreateKeypair, signChallenge } from "./crypto.ts";
import { containsMention, stripMention } from "./mentions.ts";
import { waitForSync } from "./utils.ts";

/**
 * Controls when the agent reacts to incoming chat:
 * - `all`          — respond to every message in every visible channel (legacy)
 * - `mention`      — group chats require `@<alias>`; DMs always respond
 * - `task`         — ignore chat entirely; only respond to ai:task awareness events
 * - `mention+task` — group chats require mention OR ai:task; DMs always respond (default)
 */
export type TriggerMode = "all" | "mention" | "task" | "mention+task";

export interface MCPServerConfig {
	url: string;
	agentName?: string;
	agentColor?: string;
	inviteCode?: string;
	keyFile?: string;
	triggerMode?: TriggerMode;
	/** Aliases matched in `@<alias>` tokens. Defaults to `[agentName]`. */
	mentionAliases?: string[];
}

interface SpaceConnection {
	doc: Y.Doc;
	provider: AbracadabraProvider;
	docId: string;
}

interface CachedProvider {
	provider: AbracadabraProvider;
	lastAccessed: number;
}

const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes

export class AbracadabraMCPServer {
	readonly config: MCPServerConfig;
	readonly client: AbracadabraClient;
	private _serverInfo: ServerInfo | null = null;
	private _rootDocId: string | null = null;
	private _spaces: DocumentMeta[] = [];
	private _activeConnection: SpaceConnection | null = null;
	private _spaceConnections = new Map<string, SpaceConnection>();
	private childCache = new Map<string, CachedProvider>();
	private evictionTimer: ReturnType<typeof setInterval> | null = null;
	private _mcpServerRef: McpServer | null = null;
	private _serverRef: Server | null = null;
	private _handledTaskIds = new Set<string>();
	private _userId: string | null = null;
	private _statusClearTimer: ReturnType<typeof setTimeout> | null = null;
	private _typingInterval: ReturnType<typeof setInterval> | null = null;
	private _lastChatChannel: string | null = null;
	// Channel of the CURRENTLY-ACTIVE chat/task turn (null when no turn is in
	// flight). Status updates from the hook-bridge (general Claude Code tool
	// activity) default their `statusContext` to THIS, not `_lastChatChannel`.
	// Using the (sticky) `_lastChatChannel` made unrelated session activity
	// leak the "thinking" incantation into the last chat the agent ever touched,
	// even when it never received that conversation's message.
	private _activeTurnChannel: string | null = null;
	private _signFn: ((challenge: string) => Promise<string>) | null = null;
	/** Rolling buffer of the last N tool calls in the current turn, surfaced via awareness. */
	private _toolHistory: Array<{
		tool: string;
		target?: string;
		ts: number;
		channel: string | null;
		/** Expandable detail (diff / code / command / text) for the dashboard. */
		detail?: unknown;
	}> = [];
	private static readonly TOOL_HISTORY_MAX = 20;

	// ── Inbox-driven dispatch ──────────────────────────────────────────────────
	// The server fans DMs + @mentions out as entries on this agent's per-user
	// `kind="inbox"` doc (server is the only writer). That is the *only*
	// recipient-correct signal for a DM the agent isn't subscribed to — the
	// `messages:new_message` stateless broadcast only reaches subscribers of the
	// channel/DM doc, and the agent never subscribes to DM docs. So we observe
	// the inbox's `entries` Y.Array and dispatch from there.
	private _inboxStarted = false;
	private _inboxDocId: string | null = null;
	private _inboxDoc: Y.Doc | null = null;
	private _inboxProvider: AbracadabraProvider | null = null;
	private _inboxDisposers: Array<() => void> = [];
	private _inboxInitialized = false;
	private _seenInboxIds = new Set<string>();
	/** Shared message-id dedupe so the inbox path and `_handleStatelessChat`
	 *  (subscribed-doc broadcast) never dispatch the same message twice. */
	private _dispatchedMessageIds = new Set<string>();
	private static readonly DEDUPE_MAX = 1000;

	constructor(config: MCPServerConfig) {
		this.config = config;
		this.client = new AbracadabraClient({
			url: config.url,
			persistAuth: false,
		});
	}

	get agentName(): string {
		return this.config.agentName || "AI Assistant";
	}

	get agentColor(): string {
		return this.config.agentColor || "hsl(270, 80%, 60%)";
	}

	get triggerMode(): TriggerMode {
		return this.config.triggerMode ?? "mention+task";
	}

	get mentionAliases(): string[] {
		const explicit = this.config.mentionAliases?.filter(
			(a) => a.trim().length > 0,
		);
		if (explicit && explicit.length > 0) return explicit;
		return [this.agentName];
	}

	get serverInfo(): ServerInfo | null {
		return this._serverInfo;
	}

	get rootDocId(): string | null {
		return this._rootDocId;
	}

	/**
	 * Spaces visible to the caller — direct children of the server root with
	 * `kind === "space"`. Populated by {@link connect}.
	 */
	get spaces(): DocumentMeta[] {
		return this._spaces;
	}

	get rootDocument(): Y.Doc | null {
		return this._activeConnection?.doc ?? null;
	}

	get rootYProvider(): AbracadabraProvider | null {
		return this._activeConnection?.provider ?? null;
	}

	get userId(): string | null {
		return this._userId;
	}

	/** Start the server: authenticate with Ed25519 key, discover spaces/root doc, connect provider. */
	async connect(): Promise<void> {
		// Step 1: Load or generate Ed25519 keypair
		const keypair = await loadOrCreateKeypair(this.config.keyFile);
		this._userId = keypair.publicKeyB64;
		const signFn = (challenge: string) =>
			Promise.resolve(signChallenge(challenge, keypair.privateKey));
		this._signFn = signFn;

		// Step 2: Authenticate via challenge-response (register on first run)
		try {
			await this.client.loginWithKey(keypair.publicKeyB64, signFn);
		} catch (err: any) {
			// Key not registered — auto-register and retry.
			// Servers signal this with 404/422, or 401 + "public key not registered" / "user not found".
			const status = err?.status ?? err?.response?.status;
			const msg = String(err?.message ?? "").toLowerCase();
			const notRegistered =
				status === 404 ||
				status === 422 ||
				(status === 401 &&
					/not registered|user not found|no such user/.test(msg));
			if (notRegistered) {
				console.error(
					"[abracadabra-mcp] Key not registered, creating new account...",
				);
				await this.client.registerWithKey({
					publicKey: keypair.publicKeyB64,
					username: this.agentName.replace(/\s+/g, "-").toLowerCase(),
					displayName: this.agentName,
					deviceName: "MCP Agent",
					inviteCode: this.config.inviteCode,
				});
				await this.client.loginWithKey(keypair.publicKeyB64, signFn);
			} else {
				throw err;
			}
		}
		console.error(
			`[abracadabra-mcp] Authenticated as ${this.agentName} (pubkey=${keypair.publicKeyB64})`,
		);

		// Step 3: Discover server info
		this._serverInfo = await this.client.serverInfo();

		// Step 4: Discover Spaces — top-level docs (children of the server root)
		// tagged with kind="space". The first Space is the default landing doc;
		// any other top-level doc serves as a fallback if no Spaces exist.
		const roots = await this.client.listChildren();
		this._spaces = roots.filter((d) => d.kind === Kind.Space);
		const first = this._spaces[0] ?? roots[0];
		const initialDocId = first?.id ?? null;
		if (first) {
			console.error(
				`[abracadabra-mcp] Active space: ${first.label ?? first.id} (${first.id})`,
			);
		}

		if (!initialDocId) {
			throw new Error(
				`No entry point found: server has no top-level documents under ${SERVER_ROOT_ID}. Create a Space first.`,
			);
		}

		this._rootDocId = initialDocId;
		console.error(`[abracadabra-mcp] Active space doc: ${initialDocId}`);

		// Step 5: Connect to initial space
		await this._connectToSpace(initialDocId);
		console.error("[abracadabra-mcp] Space doc synced");

		// Step 6: Start eviction timer
		this.evictionTimer = setInterval(() => this.evictIdle(), 60_000);
	}

	/** Connect to a space's root doc and cache it. Sets it as the active connection. */
	private async _connectToSpace(docId: string): Promise<SpaceConnection> {
		const existing = this._spaceConnections.get(docId);
		if (existing) {
			this._activeConnection = existing;
			this._rootDocId = docId;
			return existing;
		}

		// Re-authenticate if JWT has expired (prevents WS auth failures)
		if (!this.client.isTokenValid() && this._signFn && this._userId) {
			console.error("[abracadabra-mcp] JWT expired, re-authenticating...");
			await this.client.loginWithKey(this._userId, this._signFn);
			console.error("[abracadabra-mcp] Re-authenticated successfully");
		}

		const doc = new Y.Doc({ guid: docId });
		const provider = new AbracadabraProvider({
			name: docId,
			document: doc,
			client: this.client,
			disableOfflineStore: true,
			subdocLoading: "lazy",
		});

		await waitForSync(provider);

		provider.awareness.setLocalStateField("user", {
			name: this.agentName,
			color: this.agentColor,
			publicKey: this._userId,
			isAgent: true,
		});
		// Ensure no stale status from a previous session
		provider.awareness.setLocalStateField("status", null);
		provider.awareness.setLocalStateField("activeToolCall", null);
		provider.awareness.setLocalStateField("statusContext", null);
		provider.awareness.setLocalStateField("turnId", null);
		provider.awareness.setLocalStateField("toolHistory", []);

		const conn: SpaceConnection = { doc, provider, docId };
		this._spaceConnections.set(docId, conn);
		this._activeConnection = conn;
		this._rootDocId = docId;

		// Re-attach awareness + stateless listeners if messaging is active (handles space switches)
		if (this._mcpServerRef) {
			this._observeRootAwareness(provider);
			provider.on("stateless", ({ payload }: { payload: string }) => {
				this._handleStatelessChat(payload);
			});
		}

		return conn;
	}

	/**
	 * Switch the active space to the given doc ID.
	 * Clears the child provider cache (children belong to the previous space).
	 */
	async switchSpace(docId: string): Promise<void> {
		for (const [, cached] of this.childCache) {
			cached.provider.destroy();
		}
		this.childCache.clear();
		await this._connectToSpace(docId);
		console.error(`[abracadabra-mcp] Switched active space to ${docId}`);
	}

	/** Get the root doc-tree Y.Map of the active space. */
	getTreeMap(): Y.Map<any> | null {
		return this._activeConnection?.doc.getMap("doc-tree") ?? null;
	}

	/**
	 * Resolve a doc's `{label, type}` from the active space's tree map. Used to
	 * enrich chat-dispatch notifications so Claude knows what kind of doc the
	 * chat is attached to (e.g. checklist vs. kanban) without burning tool
	 * calls to discover it. Returns null when the doc isn't in this tree.
	 */
	getDocSummary(docId: string): { label?: string; type?: string } | null {
		const tree = this.getTreeMap();
		if (!tree) return null;
		const raw = tree.get(docId);
		if (!raw) return null;
		// Entries may be Y.Map or plain JS objects depending on how they were
		// written. Normalize both.
		const entry = typeof raw?.toJSON === "function" ? raw.toJSON() : raw;
		return {
			label: typeof entry?.label === "string" ? entry.label : undefined,
			type: typeof entry?.type === "string" ? entry.type : undefined,
		};
	}

	/** Get the root doc-trash Y.Map of the active space. */
	getTrashMap(): Y.Map<any> | null {
		return this._activeConnection?.doc.getMap("doc-trash") ?? null;
	}

	/** Get plugin names enabled in the active space via space-plugins Y.Map. */
	getEnabledPluginNames(): string[] {
		const doc = this._activeConnection?.doc;
		if (!doc) return [];
		const pluginsMap = doc.getMap("space-plugins");
		const names: string[] = [];
		pluginsMap.forEach((value: any, key: string) => {
			const entry = value?.toJSON ? value.toJSON() : value;
			if (entry?.enabled) names.push(key);
		});
		return names;
	}

	/**
	 * Get or create a child provider for a given document ID.
	 * Caches providers and waits for sync before returning.
	 */
	async getChildProvider(docId: string): Promise<AbracadabraProvider> {
		const cached = this.childCache.get(docId);
		if (cached) {
			cached.lastAccessed = Date.now();
			return cached.provider;
		}

		const activeProvider = this._activeConnection?.provider;
		if (!activeProvider) {
			throw new Error("Not connected. Call connect() first.");
		}

		// Re-authenticate if JWT has expired (prevents child WS auth failures)
		if (!this.client.isTokenValid() && this._signFn && this._userId) {
			console.error("[abracadabra-mcp] JWT expired, re-authenticating...");
			await this.client.loginWithKey(this._userId, this._signFn);
			console.error("[abracadabra-mcp] Re-authenticated successfully");
		}

		const childProvider = await activeProvider.loadChild(docId);
		await waitForSync(childProvider);

		childProvider.awareness.setLocalStateField("user", {
			name: this.agentName,
			color: this.agentColor,
			publicKey: this._userId,
			isAgent: true,
		});

		this.childCache.set(docId, {
			provider: childProvider,
			lastAccessed: Date.now(),
		});

		return childProvider;
	}

	/** Update root awareness to reflect the currently focused document. */
	setFocusedDoc(docId: string): void {
		this.rootYProvider?.awareness.setLocalStateField("docId", docId);
	}

	/**
	 * Set a TipTap-compatible collapsed cursor (anchor === head) on a child doc's awareness.
	 * Only call after getChildProvider has cached the provider.
	 * @param index Character index in xmlFragment ('default'). Clamped to [0, length].
	 */
	setDocCursor(docId: string, index: number): void {
		const cached = this.childCache.get(docId);
		if (!cached) return;

		const fragment = cached.provider.document.getXmlFragment("default");
		const clampedIndex = Math.max(0, Math.min(index, fragment.length));
		const relPos = Y.createRelativePositionFromTypeIndex(
			fragment,
			clampedIndex,
		);
		const relPosJson = Y.relativePositionToJSON(relPos);

		cached.provider.awareness.setLocalStateField("anchor", relPosJson);
		cached.provider.awareness.setLocalStateField("head", relPosJson);
	}

	/** Evict child providers that have been idle for too long. */
	private evictIdle(): void {
		const now = Date.now();
		for (const [docId, cached] of this.childCache) {
			if (now - cached.lastAccessed > IDLE_TIMEOUT_MS) {
				// Clear cursor before destroying so TipTap removes the caret overlay
				cached.provider.awareness.setLocalStateField("anchor", null);
				cached.provider.awareness.setLocalStateField("head", null);
				cached.provider.destroy();
				this.childCache.delete(docId);
				console.error(`[abracadabra-mcp] Evicted idle provider: ${docId}`);
			}
		}
	}

	/** Wire up real-time channel notifications via awareness observation and stateless chat. */
	startChannelNotifications(mcpServer: McpServer): void {
		this._mcpServerRef = mcpServer;
		this._serverRef = mcpServer.server;
		const provider = this._activeConnection?.provider;
		if (provider) {
			this._observeRootAwareness(provider);
			provider.on("stateless", ({ payload }: { payload: string }) => {
				this._handleStatelessChat(payload);
			});
			console.error("[abracadabra-mcp] Stateless chat listener attached");
			void this._startInboxNotifications();
		}
	}

	/**
	 * Bootstrap inbox observation. Sends `messages:inbox_fetch` over the active
	 * provider; the server's `messages:inbox_history` reply carries the
	 * `inbox_doc_id`. We then open a dedicated provider on that doc and observe
	 * its `entries` Y.Array for live DM/mention dispatch. Mirrors the
	 * dashboard's `useNotifications` pattern.
	 */
	private async _startInboxNotifications(): Promise<void> {
		if (this._inboxStarted) return;
		const provider = this._activeConnection?.provider;
		if (!provider) return;
		this._inboxStarted = true;

		provider.on("stateless", ({ payload }: { payload: string }) => {
			if (!payload.includes('"messages:inbox_history"')) return;
			try {
				const data = JSON.parse(payload);
				if (data?.type !== "messages:inbox_history") return;
				const inboxDocId =
					typeof data.inbox_doc_id === "string" ? data.inbox_doc_id : null;
				if (inboxDocId) void this._ensureInboxObserver(inboxDocId);
			} catch {
				/* ignore malformed */
			}
		});

		// Elicit the inbox_history (and thus inbox_doc_id). unread_only:false so a
		// fresh agent learns its inbox doc even with no unread entries.
		provider.sendStateless(
			JSON.stringify({
				type: "messages:inbox_fetch",
				limit: 50,
				unread_only: false,
			}),
		);
		console.error(
			"[abracadabra-mcp] Inbox bootstrap sent (messages:inbox_fetch)",
		);
	}

	/**
	 * Open a dedicated provider on the agent's inbox doc and observe its
	 * `entries` Y.Array. The server is the only writer; we only read.
	 */
	private async _ensureInboxObserver(inboxDocId: string): Promise<void> {
		if (this._inboxDocId) return; // already observing
		this._inboxDocId = inboxDocId;

		// Re-authenticate if the JWT expired (mirrors _connectToSpace).
		if (!this.client.isTokenValid() && this._signFn && this._userId) {
			await this.client.loginWithKey(this._userId, this._signFn);
		}

		const doc = new Y.Doc({ guid: inboxDocId });
		const provider = new AbracadabraProvider({
			name: inboxDocId,
			document: doc,
			client: this.client,
			disableOfflineStore: true,
			subdocLoading: "lazy",
		});
		this._inboxDoc = doc;
		this._inboxProvider = provider;

		try {
			await waitForSync(provider);
		} catch (err: any) {
			console.error(
				`[abracadabra-mcp] Inbox sync failed: ${err?.message ?? err}`,
			);
			return;
		}

		const entriesArr = doc.getArray("entries");
		const readMap = doc.getMap("read");
		const onChange = () => {
			void this._pumpInbox(entriesArr, readMap);
		};
		entriesArr.observe(onChange);
		readMap.observe(onChange);
		this._inboxDisposers.push(() => entriesArr.unobserve(onChange));
		this._inboxDisposers.push(() => readMap.unobserve(onChange));

		await this._pumpInbox(entriesArr, readMap);
		console.error(`[abracadabra-mcp] Inbox observer attached (${inboxDocId})`);
	}

	/**
	 * Diff the inbox `entries` array against what we've already seen. On the
	 * first pump we only record a baseline (don't replay history). New entries
	 * are classified by their authoritative `kind` and dispatched.
	 */
	private async _pumpInbox(
		entriesArr: Y.Array<any>,
		readMap: Y.Map<any>,
	): Promise<void> {
		const raw = entriesArr.toArray() as any[];
		const entries = raw.map((e) =>
			typeof e?.toJSON === "function" ? e.toJSON() : e,
		);

		if (!this._inboxInitialized) {
			for (const e of entries) if (e?.id) this._seenInboxIds.add(e.id);
			this._inboxInitialized = true;
			console.error(
				`[abracadabra-mcp] Inbox baseline: ${this._seenInboxIds.size} existing entries (not replayed)`,
			);
			return;
		}

		for (const e of entries) {
			const id: string | undefined = e?.id;
			if (!id || this._seenInboxIds.has(id)) continue;
			this._seenInboxIds.add(id);
			console.error(
				`[abracadabra-mcp] inbox NEW entry: kind=${e?.kind} channel=${e?.channel_doc_id} sender=${e?.sender_name ?? e?.sender_id} read=${!!readMap.get(id)}`,
			);
			if (readMap.get(id)) continue; // already read elsewhere
			try {
				await this._dispatchInboxEntry(e);
			} catch (err: any) {
				console.error(
					`[abracadabra-mcp] Inbox dispatch failed for ${id}: ${err?.message ?? err}`,
				);
			}
		}
		this._trimSet(this._seenInboxIds);
	}

	/** Classify + dispatch one inbox entry as a channel notification. */
	private async _dispatchInboxEntry(entry: any): Promise<void> {
		if (!this._serverRef) return;
		const kind = typeof entry?.kind === "string" ? entry.kind : "";
		const channelDocId =
			typeof entry?.channel_doc_id === "string" ? entry.channel_doc_id : "";
		const messageId =
			typeof entry?.message_id === "string" ? entry.message_id : null;
		const senderId =
			typeof entry?.sender_id === "string" ? entry.sender_id : "";
		if (!channelDocId) return;
		if (senderId && senderId === this._userId) return; // own message (defensive)

		// Authoritative trigger gate — `kind` comes from the server, no guessing.
		//   dm            → always dispatch (DMs trigger regardless of mode)
		//   mention/reply → dispatch unless mode === 'task' (chat-ignoring)
		//   system/other  → skip
		const mode = this.triggerMode;
		if (kind === "mention" || kind === "reply") {
			if (mode === "task") return;
		} else if (kind !== "dm") {
			return;
		}

		// Cross-path dedupe: a mention on a doc the agent is also subscribed to
		// arrives via both the inbox and `_handleStatelessChat`. First one wins.
		if (messageId && this._rememberDispatched(messageId)) return;

		const content = await this._resolveInboxContent(
			channelDocId,
			messageId,
			entry?.preview,
		);

		this._lastChatChannel = channelDocId;
		this._beginTurn(channelDocId);
		this.setAutoStatus("thinking");

		const attachedDoc = this.getDocSummary(channelDocId);
		const attachedHint =
			attachedDoc &&
			attachedDoc.type &&
			attachedDoc.type !== "channel" &&
			attachedDoc.type !== "dm"
				? ` The chat is attached to the document channel_doc_id="${channelDocId}" (label: ${JSON.stringify(attachedDoc.label ?? "")}, type: "${attachedDoc.type}"). If the user refers to "this <doc-kind>" or talks about the document the chat is attached to, they mean THIS doc — operate on it directly via its id without searching.`
				: "";

		await this._serverRef.notification({
			method: "notifications/claude/channel",
			params: {
				content,
				instructions: `You MUST use send_chat_message with channel_doc_id="${channelDocId}" for ALL responses — both progress updates and final answers. The user CANNOT see plain text output; they only see messages sent via send_chat_message. When doing multi-step work, send brief status updates via send_chat_message (e.g. "Looking into that..." or "Found it, writing up results...") so the user knows you're working. Never output plain text as a substitute for send_chat_message.${attachedHint}`,
				meta: {
					source: "abracadabra",
					type: kind === "dm" ? "dm_message" : "chat_message",
					channel_doc_id: channelDocId,
					sender: entry?.sender_name ?? "Unknown",
					sender_id: senderId,
					doc_id: channelDocId,
					...(attachedDoc
						? {
								attached_doc: {
									id: channelDocId,
									label: attachedDoc.label,
									type: attachedDoc.type,
								},
							}
						: {}),
				},
			},
		});
		console.error(
			`[abracadabra-mcp] Dispatched ${kind} on ${channelDocId} from ${entry?.sender_name ?? (senderId || "unknown")}`,
		);

		// Mark the inbox entry read so it doesn't re-pump on reconnect.
		this._activeConnection?.provider?.sendStateless(
			JSON.stringify({
				type: "messages:inbox_mark_read",
				id: entry.id,
			}),
		);
	}

	/**
	 * Resolve full message content. The inbox `preview` is truncated to ≤200
	 * bytes (and null for E2E), so for fidelity we read the actual record from
	 * the channel/DM doc's active period — same shape the dashboard reads.
	 * Falls back to the preview, then a short notice.
	 */
	private async _resolveInboxContent(
		channelDocId: string,
		messageId: string | null,
		preview: unknown,
	): Promise<string> {
		const previewStr =
			typeof preview === "string" && preview.length > 0 ? preview : null;
		const root = this._activeConnection?.provider;
		if (root && messageId) {
			try {
				const wrapper = await root.loadChild(channelDocId);
				if (!wrapper.synced) await waitForSync(wrapper);
				const periods = wrapper.document.getArray("periods");
				const len = periods.length;
				// Scan the last two periods (resilient to a rollover between send + read).
				for (let i = len - 1; i >= 0 && i >= len - 2; i--) {
					const p: any = periods.get(i);
					const periodId: string | undefined = p?.id ?? p?.get?.("id");
					if (!periodId) continue;
					const period = await root.loadChild(periodId);
					if (!period.synced) await waitForSync(period);
					const records = (
						period.document.getArray("messages").toArray() as unknown[]
					)
						.map((v) => recordFromYAny(v))
						.filter(
							(r): r is NonNullable<ReturnType<typeof recordFromYAny>> =>
								r !== null,
						);
					const folded = foldRecords(records);
					const hit = folded.find((f) => f.id === messageId);
					if (hit) {
						if (isEncryptedContent(hit.content)) {
							return (
								previewStr ??
								"[encrypted message — this agent is not provisioned to read this channel]"
							);
						}
						return hit.content;
					}
				}
			} catch (err: any) {
				console.error(
					`[abracadabra-mcp] content read failed for ${channelDocId}/${messageId}: ${err?.message ?? err}`,
				);
			}
		}
		return previewStr ?? "[message content unavailable]";
	}

	/** Check-and-mark: true if already dispatched, else records it and returns false. */
	private _rememberDispatched(messageId: string): boolean {
		if (this._dispatchedMessageIds.has(messageId)) return true;
		this._dispatchedMessageIds.add(messageId);
		this._trimSet(this._dispatchedMessageIds);
		return false;
	}

	/** Check-only: true if this id was already dispatched (does NOT record it). */
	private _wasDispatched(messageId: string): boolean {
		return this._dispatchedMessageIds.has(messageId);
	}

	private _trimSet(s: Set<string>): void {
		if (s.size <= AbracadabraMCPServer.DEDUPE_MAX) return;
		const excess = s.size - AbracadabraMCPServer.DEDUPE_MAX;
		let i = 0;
		for (const v of s) {
			if (i++ >= excess) break;
			s.delete(v);
		}
	}

	/** Attach awareness observer to detect `ai:task` fields from human users. */
	private _observeRootAwareness(provider: AbracadabraProvider): void {
		const selfId = provider.awareness.clientID;
		provider.awareness.on("change", () => {
			// Strict `mention` mode ignores ai:task awareness; every other mode honors it.
			if (this.triggerMode === "mention") return;

			const states = provider.awareness.getStates();
			for (const [clientId, state] of states) {
				if (clientId === selfId) continue;
				const task = (state as Record<string, any>)["ai:task"];
				if (!task || typeof task !== "object") continue;
				const { id, text } = task as { id?: string; text?: string };
				if (!id || !text) continue;
				if (this._handledTaskIds.has(id)) continue;
				this._handledTaskIds.add(id);

				// Extract sender name from awareness state
				const user = (state as Record<string, any>)["user"];
				const senderName =
					user && typeof user === "object" && typeof user.name === "string"
						? user.name
						: "Unknown";

				console.error(
					`[abracadabra-mcp] Handling ai:task id=${id} from ${senderName}: ${text.slice(0, 80)}`,
				);
				this._beginTurn();
				this.setAutoStatus("thinking");
				this._dispatchAiTask({
					id,
					text,
					docId: (state as Record<string, any>)["docId"],
					senderName,
				});
			}
		});
		console.error("[abracadabra-mcp] Root awareness observation started");
	}

	/** Dispatch an ai:task as a channel notification. */
	private async _dispatchAiTask(task: {
		id: string;
		text: string;
		docId?: string;
		senderName: string;
	}): Promise<void> {
		if (!this._serverRef) return;
		try {
			await this._serverRef.notification({
				method: "notifications/claude/channel",
				params: {
					content: task.text,
					instructions: `You MUST use the reply tool with doc_id="${task.docId ?? ""}" and task_id="${task.id}" for your final response. The user CANNOT see plain text output; they only see replies sent via MCP tools. For progress updates during multi-step work, use send_chat_message with channel="group:${task.docId ?? ""}" (e.g. "Looking into that..." or "Found it, writing up results..."). Never output plain text as a substitute for MCP tools.`,
					meta: {
						source: "abracadabra",
						type: "ai_task",
						task_id: task.id,
						sender: task.senderName,
						doc_id: task.docId ?? "",
					},
				},
			});
			console.error(
				`[abracadabra-mcp] Channel notification sent for ai:task id=${task.id}`,
			);
		} catch (error: any) {
			console.error(
				`[abracadabra-mcp] Channel notification failed: ${error.message}`,
			);
		}
	}

	/**
	 * Clear an ai:task from awareness to signal completion.
	 * Scans all awareness states for the given task ID and removes it.
	 */
	clearAiTask(taskId: string): void {
		const provider = this._activeConnection?.provider;
		if (!provider) return;
		const selfId = provider.awareness.clientID;
		const states = provider.awareness.getStates();
		for (const [clientId, state] of states) {
			if (clientId === selfId) continue;
			const task = (state as Record<string, any>)["ai:task"];
			if (task && typeof task === "object" && (task as any).id === taskId) {
				// We can't clear another client's awareness directly, but we can
				// signal completion by setting our own awareness field
				provider.awareness.setLocalStateField("ai:task:done", taskId);
				break;
			}
		}
	}

	/** Handle incoming `messages:new_message` broadcasts and dispatch as channel notifications. */
	private async _handleStatelessChat(payload: string): Promise<void> {
		if (!this._serverRef) return;
		if (!payload.includes('"messages:new_message"')) return;

		try {
			const env = JSON.parse(payload);
			if (env.type !== "messages:new_message") return;
			const data = env.record;
			if (!data) return;
			// Only react to actual messages — skip edit/tombstone records.
			if (data.record_kind && data.record_kind !== "message") return;
			// Skip own messages
			if (data.sender_id && data.sender_id === this._userId) return;

			const channelDocId = data.channel_doc_id as string | undefined;
			if (!channelDocId) return;

			// Cross-path dedupe (CHECK ONLY here — do NOT mark yet). If the inbox
			// already dispatched this id, skip. We must not *mark* the id before
			// the trigger gate below: a non-mention group message that this path
			// skips would otherwise poison the dedupe set and block the inbox
			// observer from dispatching a legitimate @mention/DM for the same id.
			if (data.id && this._wasDispatched(data.id)) return;

			// This path only ever sees docs the agent is *subscribed* to (the active
			// space root + lazily-loaded children) — i.e. group/channel/space chat,
			// never a DM doc (the agent never subscribes to those). DM correctness
			// lives entirely in the inbox observer, so group trigger semantics are
			// correct here.
			const isGroup = true;

			// ── Trigger mode gate ─────────────────────────────────────────────
			const mode = this.triggerMode;
			const content = typeof data.content === "string" ? data.content : "";
			let dispatchContent = content;

			if (isGroup) {
				if (mode === "task") {
					return;
				}
				if (mode === "mention" || mode === "mention+task") {
					const aliases = this.mentionAliases;
					// Authoritative signal: the server-stored `mentions` array carries
					// the resolved pubkeys the sender @-mentioned. If our pubkey is in
					// it, we're mentioned — regardless of how the @text was written.
					// Fall back to text matching for older clients that don't send it.
					const mentionedByPubkey =
						Array.isArray(data.mentions) &&
						!!this._userId &&
						data.mentions.includes(this._userId);
					const mentionedByText = containsMention(content, aliases);
					if (!mentionedByPubkey && !mentionedByText) {
						console.error(
							`[abracadabra-mcp] skipped ${channelDocId} — not mentioned (mentions=${JSON.stringify(data.mentions ?? [])}, aliases=${aliases.join("|")})`,
						);
						return;
					}
					dispatchContent = stripMention(content, aliases) || content;
				}
				// mode === 'all' falls through unchanged
			}
			// ──────────────────────────────────────────────────────────────────

			// Auto-mark this message position read.
			const rootProvider = this._activeConnection?.provider;
			if (rootProvider && data.id && data.period_id) {
				rootProvider.sendStateless(
					JSON.stringify({
						type: "messages:mark_read",
						channel_doc_id: channelDocId,
						period_id: data.period_id,
						message_id: data.id,
						ts: Date.now(),
					}),
				);
			}
			this._lastChatChannel = channelDocId;

			// Mark dispatched only now that the gate passed — this is the message
			// we're actually handling, so the inbox path should skip its twin.
			if (data.id) this._rememberDispatched(data.id);

			this._beginTurn(channelDocId);
			this.setAutoStatus("thinking");

			const attachedDoc = this.getDocSummary(channelDocId);
			const attachedHint =
				attachedDoc &&
				attachedDoc.type &&
				attachedDoc.type !== "channel" &&
				attachedDoc.type !== "dm"
					? ` The chat is attached to the document channel_doc_id="${channelDocId}" (label: ${JSON.stringify(attachedDoc.label ?? "")}, type: "${attachedDoc.type}"). If the user refers to "this <doc-kind>" or talks about the document the chat is attached to, they mean THIS doc — operate on it directly via its id without searching.`
					: "";

			await this._serverRef.notification({
				method: "notifications/claude/channel",
				params: {
					content: dispatchContent,
					instructions: `You MUST use send_chat_message with channel_doc_id="${channelDocId}" for ALL responses — both progress updates and final answers. The user CANNOT see plain text output; they only see messages sent via send_chat_message. When doing multi-step work, send brief status updates via send_chat_message (e.g. "Looking into that..." or "Found it, writing up results...") so the user knows you're working. Never output plain text as a substitute for send_chat_message.${attachedHint}`,
					meta: {
						source: "abracadabra",
						type: "chat_message",
						channel_doc_id: channelDocId,
						sender: data.sender_name ?? "Unknown",
						sender_id: data.sender_id ?? "",
						doc_id: channelDocId,
						...(attachedDoc
							? {
									attached_doc: {
										id: channelDocId,
										label: attachedDoc.label,
										type: attachedDoc.type,
									},
								}
							: {}),
					},
				},
			});
			console.error(
				`[abracadabra-mcp] Chat message from ${data.sender_name ?? "unknown"} on ${channelDocId}`,
			);
		} catch {
			// Ignore non-chat or malformed payloads
		}
	}

	/**
	 * Set the agent's status in root awareness with auto-clear after idle.
	 * @param statusContext — scopes the status to a specific channel/context so the
	 *   dashboard only shows it in the relevant chat. Defaults to `_lastChatChannel`.
	 */
	setAutoStatus(
		status: string | null,
		docId?: string,
		statusContext?: string | null,
	): void {
		const provider = this._activeConnection?.provider;
		if (!provider) return;

		if (this._statusClearTimer) {
			clearTimeout(this._statusClearTimer);
			this._statusClearTimer = null;
		}

		provider.awareness.setLocalStateField("status", status);
		if (docId !== undefined) {
			provider.awareness.setLocalStateField("docId", docId);
		}

		// Scope status to the channel of the ACTIVE turn (null when none), so the
		// dashboard only shows the incantation/typing in the chat the agent is
		// actually engaged with. Defaulting to the sticky `_lastChatChannel` is
		// what leaked unrelated session activity into an idle chat.
		const context = status
			? statusContext !== undefined
				? statusContext
				: this._activeTurnChannel
			: null;
		provider.awareness.setLocalStateField("statusContext", context ?? null);

		// When clearing status this is the authoritative end-of-turn signal:
		// drop the tool pill and the turn id so the incantation + typing dots
		// collapse. We deliberately do NOT wipe `toolHistory` here — the dashboard
		// merges that array into its persistent inline tool-call log, and wiping
		// it in the same awareness flush as the final message would race the
		// client and lose the whole turn's activity. toolHistory is reset only at
		// the start of the next turn (`_beginTurn`).
		if (!status) {
			this._stopTypingInterval();
			this._activeTurnChannel = null;
			provider.awareness.setLocalStateField("activeToolCall", null);
			provider.awareness.setLocalStateField("turnId", null);
		}

		// Auto-clear status after 10s of no updates. Short enough that a real
		// idle is noticed quickly; long enough that normal inter-tool gaps
		// (PostToolUse → next PreToolUse) don't flicker. Same rule: leave
		// toolHistory in place for the persistent client log.
		if (status) {
			this._statusClearTimer = setTimeout(() => {
				provider.awareness.setLocalStateField("status", null);
				provider.awareness.setLocalStateField("activeToolCall", null);
				provider.awareness.setLocalStateField("statusContext", null);
				provider.awareness.setLocalStateField("turnId", null);
				this._activeTurnChannel = null;
				this._stopTypingInterval();
			}, 10_000);
		}
	}

	/**
	 * Start a new agent turn. Mints a fresh UUID and writes it to awareness so
	 * the dashboard can gate the incantation on "there is an active turn",
	 * decoupled from the (racier) status field. Called from chat arrival and
	 * ai:task dispatch right before `setAutoStatus('thinking')`.
	 *
	 * Also starts a heartbeat typing indicator so the dashboard renders typing
	 * dots whenever no tool pill is active during the turn — this replaces the
	 * "dead air" gap users see between thinking and the final reply.
	 */
	private _beginTurn(channel?: string): void {
		const provider = this._activeConnection?.provider;
		if (!provider) return;
		this._toolHistory = [];
		// Remember which channel (if any) this turn belongs to, so hook-bridge
		// status updates scope to it — and ONLY it — for the turn's duration.
		this._activeTurnChannel = channel ?? null;
		provider.awareness.setLocalStateField("toolHistory", []);
		provider.awareness.setLocalStateField("turnId", crypto.randomUUID());
		// Only chat turns get a typing heartbeat — pass the channel explicitly.
		// ai:task turns (document replies, no chat channel) call _beginTurn()
		// with no arg and must NOT spray typing frames at a stale channel.
		if (channel) this._startTypingInterval(channel);
	}

	/**
	 * Enter the "writing" phase: the agent has finished reasoning and is about
	 * to send a chat message. The dashboard maps `status === 'writing'` to
	 * typing dots (not the incantation phrase). Keeps the turn alive (turnId
	 * stays set) and emits a typing frame immediately so the dots appear at
	 * once; the heartbeat keeps them alive until `setAutoStatus(null)`.
	 */
	setWritingStatus(channel: string): void {
		this._lastChatChannel = channel;
		this.setAutoStatus("writing", undefined, channel);
		this._startTypingInterval(channel);
	}

	/** Re-send typing indicator every 2s so dashboard keeps showing it (expires at 3s). */
	private _startTypingInterval(channel: string): void {
		this._stopTypingInterval();
		// Fire one immediately so the indicator appears without waiting 2s.
		this.sendTypingIndicator(channel);
		this._typingInterval = setInterval(() => {
			this.sendTypingIndicator(channel);
		}, 2000);
	}

	private _stopTypingInterval(): void {
		if (this._typingInterval) {
			clearInterval(this._typingInterval);
			this._typingInterval = null;
		}
		// Do NOT clear _lastChatChannel here — subsequent turns need it as the
		// default channel context. _lastChatChannel is rewritten on every chat
		// arrival anyway, so leaving it set across turns is correct.
	}

	/**
	 * Broadcast which tool the agent is currently executing.
	 *
	 * Renders as a ChatTool pill on the dashboard. On non-null calls, the tool
	 * is also appended to `toolHistory` (capped at TOOL_HISTORY_MAX) and written
	 * to awareness so the dashboard's inline trace can show the turn's recent
	 * activity. Tools do NOT clear (`setActiveToolCall(null)`) on completion —
	 * the pill stays until the next tool replaces it or `setAutoStatus(null)`
	 * flushes the turn. This keeps pills visible long enough to see.
	 */
	setActiveToolCall(
		toolCall: { name: string; target?: string; detail?: unknown } | null,
	): void {
		const provider = this._activeConnection?.provider;
		if (!provider) return;
		provider.awareness.setLocalStateField("activeToolCall", toolCall);
		if (toolCall) {
			this._toolHistory.push({
				tool: toolCall.name,
				target: toolCall.target,
				ts: Date.now(),
				channel: this._lastChatChannel,
				detail: toolCall.detail,
			});
			if (this._toolHistory.length > AbracadabraMCPServer.TOOL_HISTORY_MAX) {
				this._toolHistory.splice(
					0,
					this._toolHistory.length - AbracadabraMCPServer.TOOL_HISTORY_MAX,
				);
			}
			provider.awareness.setLocalStateField("toolHistory", [
				...this._toolHistory,
			]);
		}
	}

	/**
	 * Send a typing indicator to a chat channel. Pass the channel doc id
	 * (or for legacy callers, a `group:<docId>` string — we strip the prefix).
	 */
	sendTypingIndicator(channel: string): void {
		const rootProvider = this._activeConnection?.provider;
		if (!rootProvider) return;
		const channelDocId = channel.startsWith("group:")
			? channel.slice(6)
			: channel.startsWith("dm:")
				? channel
				: channel;
		rootProvider.sendStateless(
			JSON.stringify({
				type: "messages:typing",
				channel_doc_id: channelDocId,
			}),
		);
	}

	/** Graceful shutdown. */
	async destroy(): Promise<void> {
		this._stopTypingInterval();
		if (this._statusClearTimer) {
			clearTimeout(this._statusClearTimer);
			this._statusClearTimer = null;
		}
		if (this.evictionTimer) {
			clearInterval(this.evictionTimer);
			this.evictionTimer = null;
		}

		for (const dispose of this._inboxDisposers) {
			try {
				dispose();
			} catch {
				/* ignore */
			}
		}
		this._inboxDisposers = [];
		this._inboxProvider?.destroy();
		this._inboxProvider = null;
		this._inboxDoc = null;

		for (const [, cached] of this.childCache) {
			cached.provider.destroy();
		}
		this.childCache.clear();

		// Clear awareness fields before destroying so other clients don't see stale state
		for (const [, conn] of this._spaceConnections) {
			conn.provider.awareness.setLocalStateField("status", null);
			conn.provider.awareness.setLocalStateField("activeToolCall", null);
			conn.provider.awareness.setLocalStateField("statusContext", null);
			conn.provider.awareness.setLocalStateField("turnId", null);
			conn.provider.awareness.setLocalStateField("toolHistory", []);
			conn.provider.destroy();
		}
		this._toolHistory = [];
		this._spaceConnections.clear();
		this._activeConnection = null;

		console.error("[abracadabra-mcp] Shutdown complete");
	}
}
