import {
	InstanceAiConfirmRequestDto,
	InstanceAiFeedbackRequestDto,
	InstanceAiGatewayCapabilitiesDto,
	InstanceAiGatewayCreateCredentialDto,
	InstanceAiFilesystemResponseDto,
	InstanceAiRenameThreadRequestDto,
	InstanceAiSendMessageRequest,
	InstanceAiEventsQuery,
	instanceAiGatewayKeySchema,
	InstanceAiCorrectTaskRequest,
	InstanceAiEnsureThreadRequest,
	InstanceAiThreadMessagesQuery,
	InstanceAiAdminSettingsUpdateRequest,
	InstanceAiVerifyModelRequest,
	InstanceAiVerifySandboxRequest,
	InstanceAiVerifySearchRequest,
	InstanceAiUserPreferencesUpdateRequest,
	InstanceAiEvalExecutionRequest,
	InstanceAiEvalAgentExecutionRequest,
	InstanceAiEvalCredentialAllowlistRequest,
	InstanceAiEvalRestoreThreadRequest,
	InstanceAiEvalSeedDataTableRowsRequest,
	findUnbackedSeedWorkflowTools,
} from '@n8n/api-types';
import type { InstanceAiAdminSettingsResponse, InstanceAiEvent } from '@n8n/api-types';
import { ModuleRegistry } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { AuthenticatedRequest, User, UserRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import {
	RestController,
	GlobalScope,
	Middleware,
	Get,
	Post,
	Put,
	Patch,
	Delete,
	OnPubSubEvent,
	Param,
	Body,
	Query,
} from '@n8n/decorators';
import type { StoredEvent } from '@n8n/instance-ai';
import {
	buildAgentTreeFromEvents,
	clearedAgentBuilderTargetMetadata,
	seedAgentBuilderTargetMetadata,
} from '@n8n/instance-ai';
import {
	OversizedAttachmentError,
	UnsupportedAttachmentError,
	validateAttachmentMimeTypes,
	validateAttachmentSizes,
} from '@n8n/instance-ai/parsers';
import type { NextFunction, Request, Response } from 'express';
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { InstanceAiBrowserSessionService } from './browser/instance-ai-browser-session.service';
import { EvalAgentExecutionService } from './eval/agent-execution.service';
import { EvalExecutionService } from './eval/execution.service';
import { EvalThreadCredentialAllowlistService } from './eval/thread-credential-allowlist.service';
import { EvalThreadRestoreService } from './eval/thread-restore.service';
import { DurableEventLog } from './event-bus/durable-event-log';
import { DurableLogMetrics } from './event-bus/durable-log-metrics';
import { InProcessEventBus } from './event-bus/in-process-event-bus';
import { InstanceAiErrorReporterService } from './instance-ai-error-reporter.service';
import { InstanceAiGatewayService } from './instance-ai-gateway.service';
import { InstanceAiMemoryService } from './instance-ai-memory.service';
import { InstanceAiModelCatalogService } from './instance-ai-model-catalog.service';
import { InstanceAiSettingsService } from './instance-ai-settings.service';
import { InstanceAiVerificationService } from './instance-ai-verification.service';
import { InstanceAiService } from './instance-ai.service';
import { CredentialsService } from '@/credentials/credentials.service';

import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { Push } from '@/push';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { ProjectService } from '@/services/project.service.ee';
import { UrlService } from '@/services/url.service';

type FlushableResponse = Response & { flush?: () => void };

const KEEP_ALIVE_INTERVAL_MS = 15_000;

@RestController('/instance-ai')
export class InstanceAiController {
	private readonly gatewayApiKey: string;

	constructor(
		private readonly instanceAiService: InstanceAiService,
		private readonly gatewayService: InstanceAiGatewayService,
		private readonly browserSessionService: InstanceAiBrowserSessionService,
		private readonly memoryService: InstanceAiMemoryService,
		private readonly settingsService: InstanceAiSettingsService,
		private readonly modelCatalogService: InstanceAiModelCatalogService,
		private readonly evalExecutionService: EvalExecutionService,
		private readonly evalAgentExecutionService: EvalAgentExecutionService,
		private readonly evalCredentialAllowlists: EvalThreadCredentialAllowlistService,
		private readonly evalThreadRestore: EvalThreadRestoreService,
		private readonly eventBus: InProcessEventBus,
		private readonly eventLog: DurableEventLog,
		private readonly durableLogMetrics: DurableLogMetrics,
		private readonly moduleRegistry: ModuleRegistry,
		private readonly push: Push,
		private readonly urlService: UrlService,
		private readonly userRepository: UserRepository,
		private readonly credentialsService: CredentialsService,
		private readonly projectService: ProjectService,
		private readonly instanceAiErrorReporter: InstanceAiErrorReporterService,
		private readonly publisher: Publisher,
		globalConfig: GlobalConfig,
	) {
		this.gatewayApiKey = globalConfig.instanceAi.gatewayApiKey;
	}

	private requireInstanceAiEnabled(): void {
		if (!this.settingsService.isInstanceAiEnabled()) {
			throw new ForbiddenError('Instance AI is disabled');
		}
	}

	/**
	 * Without a model the run starts and then dies inside the provider call, with
	 * nothing to tell the user why. The frontend routes an unconfigured instance
	 * to setup rather than the composer, but that is one gate per entry point and
	 * the endpoint is reachable on its own, so refuse the run here too.
	 */
	private async requireModelConfigured(): Promise<void> {
		if (!(await this.settingsService.isModelConfigured())) {
			throw new BadRequestError(
				'The AI Assistant has no model configured. An instance owner can add one in Settings > AI Assistant.',
			);
		}
	}

	private requireRunDebugEnabled(): void {
		if (!this.instanceAiService.isRunDebugEnabled()) {
			throw new NotFoundError('Run debug is not enabled');
		}
	}
	// Each BrotliCompress stream allocates ~8.6 MB of native memory for its
	// dictionary, and the compression middleware retains streams via closures on
	// the response object for the lifetime of the HTTP keep-alive connection.
	// Downgrade to gzip (~few KB per stream) for all instance-ai endpoints.
	@Middleware()
	stripBrotli(req: Request, _res: Response, next: NextFunction) {
		const ae = req.headers['accept-encoding'];
		if (typeof ae === 'string' && ae.includes('br')) {
			req.headers['accept-encoding'] = ae.replace(/\bbr\b,?\s*/g, '').replace(/,\s*$/, '');
		}
		next();
	}

	@Post('/chat/:threadId')
	@GlobalScope('instanceAi:message')
	async chat(
		req: AuthenticatedRequest,
		_res: Response,
		@Param('threadId') threadId: string,
		@Body payload: InstanceAiSendMessageRequest,
	) {
		this.requireInstanceAiEnabled();
		await this.requireModelConfigured();
		if (!payload.message && (!payload.attachments || payload.attachments.length === 0)) {
			throw new BadRequestError('Either message or attachments must be provided');
		}

		// Verify the requesting user owns this thread (or it's new)
		await this.assertThreadAccess(req.user.id, threadId, { allowNew: true });

		// Only file attachments carry a mime type to validate; workflow and agent
		// attachments are resource references the agent resolves with its tools.
		const fileAttachments = (payload.attachments ?? []).filter(
			(attachment) => attachment.type === 'file',
		);
		if (fileAttachments.length > 0) {
			try {
				validateAttachmentMimeTypes(fileAttachments);
				// Reject oversized payloads here rather than letting them reach the model.
				// The provider answers an oversized image with an opaque 400, and the
				// attachment is already persisted in thread history by then — so every
				// later turn replays it and fails too, stranding the conversation.
				//
				// Note the request schema caps each `data` field at the same per-file
				// limit, and body validation runs before this handler — so over HTTP a
				// single oversized file is answered by the schema and only the combined
				// budget reaches here. This call stays because it is the check for
				// non-HTTP callers and the one that enforces the total.
				validateAttachmentSizes(fileAttachments);
			} catch (error) {
				if (error instanceof UnsupportedAttachmentError) {
					const summary = error.unsupported.map((u) => `${u.fileName} (${u.mimeType})`).join(', ');
					throw new BadRequestError(
						`Unsupported attachment type: ${summary}. Supported types include CSV, JSON, ` +
							'PDF, DOCX, XLSX, HTML, plain text, markdown, and images.',
					);
				}
				if (error instanceof OversizedAttachmentError) {
					throw new BadRequestError(
						error.reason === 'per_file'
							? `${error.message} Attach a smaller version, or resize the image before sending.`
							: `${error.message} Send them across separate messages, or attach smaller versions.`,
					);
				}
				throw error;
			}
		}

		// One active run per thread
		if (this.instanceAiService.hasActiveRun(threadId)) {
			throw new ConflictError('A run is already active for this thread');
		}

		const runId = this.instanceAiService.startRun(
			req.user,
			threadId,
			payload.message,
			payload.attachments,
			payload.context,
			payload.timeZone,
			payload.pushRef,
		);
		return { runId };
	}

	// usesTemplates bypasses the send() wrapper so we can write SSE frames directly
	@Get('/events/:threadId', { usesTemplates: true })
	@GlobalScope('instanceAi:message')
	async events(
		req: AuthenticatedRequest,
		res: FlushableResponse,
		@Param('threadId') threadId: string,
		@Query query: InstanceAiEventsQuery,
	) {
		this.requireInstanceAiEnabled();
		// Verify the requesting user owns this thread before streaming events.
		// A thread that doesn't exist yet is allowed — the frontend opens the SSE
		// connection for new conversations before the first message creates the thread.
		const ownership = await this.memoryService.checkThreadOwnership(req.user.id, threadId);
		if (ownership === 'other_user') {
			throw new ForbiddenError('Not authorized for this thread');
		}
		// When the thread didn't exist at connect time, another user could create
		// and own it before events start flowing. We re-check once on the first
		// event and close the stream if ownership changed. Events are buffered
		// until the check resolves to prevent leaking data during the async gap.
		let ownershipVerified = ownership === 'owned';
		let ownershipCheckInFlight = false;
		const pendingEvents: StoredEvent[] = [];
		const userId = req.user.id;

		// 1. Subscribe to live events before the async bootstrap below.
		//    hasSubscribers() must be true across the awaits that follow: in
		//    multi-main, sibling mains drop relayed events for threads without a
		//    local subscriber, so a relayed event arriving during an await would
		//    otherwise be lost for good. Events emitted while bootstrapping are
		//    NOT delivered here — they land in the event store and the replay in
		//    step 6 picks them up, avoiding duplicates.
		let bootstrapping = true;

		const deliver = (stored: StoredEvent) => {
			if (ownershipVerified) {
				this.writeSseEvent(res, stored);
				return;
			}

			// When the thread was not_found at connect time, re-validate ownership
			// on the first event. Buffer all events until the check resolves to
			// avoid leaking data during the async gap.
			pendingEvents.push(stored);

			if (ownershipCheckInFlight) return;
			ownershipCheckInFlight = true;

			void this.memoryService
				.checkThreadOwnership(userId, threadId)
				.then((currentOwnership) => {
					if (currentOwnership === 'other_user') {
						res.end();
						return;
					}
					ownershipVerified = true;
					for (const buffered of pendingEvents) {
						this.writeSseEvent(res, buffered);
					}
					pendingEvents.length = 0;
				})
				.catch(() => {
					pendingEvents.length = 0;
					res.end();
				});
		};

		const unsubscribe = this.eventBus.subscribe(threadId, (stored) => {
			if (bootstrapping) return;
			deliver(stored);
		});

		// Cleanup is registered before the async bootstrap so a client disconnect
		// (or an error response) during the awaits below doesn't leak the
		// subscription.
		let closed = false;
		let keepAlive: NodeJS.Timeout | undefined = undefined;
		const cleanup = () => {
			closed = true;
			unsubscribe();
			if (keepAlive !== undefined) clearInterval(keepAlive);
		};
		req.once('close', cleanup);
		res.once('finish', cleanup);

		// 2. Re-publish any terminal outcomes that never reached the client.
		if (ownership === 'owned') {
			await this.instanceAiService.replayUndeliveredTerminalOutcomes(threadId);
		}

		// 3. Set SSE headers.
		// Disable response compression — SSE streams small chunks where compression
		// overhead exceeds the benefit, and each Brotli compressor retains ~8.6 MB
		// of native memory for the lifetime of the connection.
		(res as unknown as { compress: boolean }).compress = false;
		res.setHeader('Content-Type', 'text/event-stream; charset=UTF-8');
		res.setHeader('Cache-Control', 'no-cache, no-transform');
		res.setHeader('Connection', 'keep-alive');
		res.setHeader('X-Accel-Buffering', 'no');
		res.flushHeaders();

		// 4. Determine replay cursor
		//    Last-Event-ID header (browser auto-reconnect) takes precedence over query param.
		//    Both are validated as non-negative integers; invalid values fall back to 0.
		const headerValue = req.headers['last-event-id'];
		const parsedHeader = headerValue ? parseInt(String(headerValue), 10) : NaN;
		const cursor =
			Number.isFinite(parsedHeader) && parsedHeader >= 0 ? parsedHeader : (query.lastEventId ?? 0);

		// 5. Collect live message groups.
		//    Multiple groups can be active simultaneously when a background task
		//    from an older turn outlives its original turn.
		const threadStatus = this.instanceAiService.getThreadStatus(threadId);

		// Collect all distinct message groups that have live activity.
		const liveGroups = new Map<
			string,
			{ runIds: string[]; status: 'active' | 'suspended' | 'background' }
		>();

		// The active/suspended orchestrator run's group
		if (threadStatus.hasActiveRun || threadStatus.isSuspended) {
			const groupId = this.instanceAiService.getMessageGroupId(threadId);
			if (groupId) {
				liveGroups.set(groupId, {
					runIds: this.instanceAiService.getRunIdsForMessageGroup(groupId),
					status: threadStatus.hasActiveRun ? 'active' : 'suspended',
				});
			}
		}

		// Background tasks — each may belong to a different group
		for (const task of threadStatus.backgroundTasks) {
			if (task.status !== 'running' || !task.messageGroupId) continue;
			if (!liveGroups.has(task.messageGroupId)) {
				liveGroups.set(task.messageGroupId, {
					runIds: this.instanceAiService.getRunIdsForMessageGroup(task.messageGroupId),
					status: 'background',
				});
			}
		}

		// 6b (used by both arms below). Emit one run-sync control frame for a live
		//     message group. Each frame uses a named SSE event type
		//     (event: run-sync) with NO id: field so the browser's lastEventId is
		//     unaffected and the replay cursor stays consistent.
		const writeRunSyncFrame = (
			groupId: string,
			group: { runIds: string[]; status: 'active' | 'suspended' | 'background' },
			runEvents: InstanceAiEvent[],
		) => {
			if (runEvents.length === 0) return;

			const agentTree = buildAgentTreeFromEvents(runEvents);
			res.write(
				`event: run-sync\ndata: ${JSON.stringify({
					runId: group.runIds.at(-1),
					messageGroupId: groupId,
					runIds: group.runIds,
					agentTree,
					status: group.status,
					backgroundTasks: threadStatus.backgroundTasks,
				})}\n\n`,
			);
		};

		// 6. Replay missed events from the durable log — survives restarts and is
		//    valid on any main (the table is in the shared DB). The reads are
		//    async, so live events can land mid-bootstrap: buffer them across
		//    every await (the
		//    replay read, the run-sync tree reads, AND the gap read) and flush
		//    with seq dedupe only when no await remains before live delivery
		//    takes over (the drain persists before it emits, so a fact is never
		//    in neither place). A flushed event may already be folded into a
		//    run-sync tree; the shared reducer applies it idempotently, same as
		//    any live event arriving after a frame.
		const arrivedDuringReplay: StoredEvent[] = [];
		const stopBuffering = this.eventBus.subscribe(threadId, (stored) => {
			arrivedDuringReplay.push(stored);
		});
		try {
			const missed = await this.eventLog.getEventsAfter(threadId, cursor);
			// The client may have disconnected during the read: stop before
			// writing to the dead response or arming the keep-alive below.
			if (closed) return;
			let lastReplayedSeq = cursor;
			for (const stored of missed) {
				deliver(stored);
				if (stored.id !== undefined) lastReplayedSeq = stored.id;
			}
			// Build each live group's bootstrap tree from the durable log, so the
			// group renders fully even when the process
			// restarted, or this main never buffered the thread (sibling main).
			// Remember which coalesced blocks each delivered tree folds: the gap
			// read below may return the same rows, and re-applying a block the
			// tree already renders would append a duplicate timeline entry.
			const blockKey = (event: {
				type: string;
				runId: string;
				agentId: string;
				responseId?: string;
				payload: { text: string };
			}) =>
				`${event.type}:${event.runId}:${event.agentId}:${event.responseId ?? ''}:${event.payload.text}`;
			const foldedBlockKeys = new Set<string>();
			for (const [groupId, group] of liveGroups) {
				const runEvents = await this.eventLog.getEventsForRuns(threadId, group.runIds);
				if (closed) return;
				writeRunSyncFrame(groupId, group, runEvents);
				for (const event of runEvents) {
					if (event.type === 'text-block' || event.type === 'reasoning-block') {
						foldedBlockKeys.add(blockKey(event));
					}
				}
			}
			// One more durable read: coalesced blocks are persisted but never
			// live-emitted (live clients saw the deltas), so a segment that closed
			// during the awaits above exists only as rows the replay read predates
			// — invisible to the buffering subscription. Without this read, the
			// buffered fact that follows such a block would advance the browser
			// cursor past it and no later replay would ever return it.
			const gapRows = await this.eventLog.getEventsAfter(threadId, lastReplayedSeq);
			if (closed) return;
			// A still-streaming segment exists only in the log's coalesce buffer
			// (deltas are never persisted), so a mid-stream refresh would render
			// only the post-refresh tail. Serve each open segment as one ephemeral
			// delta frame (no `id:` line — the cursor stays on durable facts), after
			// the run-sync frames so live deltas keep appending to it and the
			// segment's eventual block replaces it. Everything from this read to
			// `bootstrapping = false` is synchronous, so a buffered delta of a
			// served segment is exactly text inside the snapshot: skipping it loses
			// nothing and delivering it would duplicate.
			const openSegments = this.eventLog.getOpenSegments(threadId);
			const segmentKey = (
				kind: 'text' | 'reasoning',
				event: { runId: string; agentId: string; responseId?: string },
			) => `${kind}:${event.runId}:${event.agentId}:${event.responseId ?? ''}`;
			const served = new Set(openSegments.map((segment) => segmentKey(segment.kind, segment)));
			// Deliver the gap rows first. A block identical to one folded into a
			// delivered run-sync tree is not re-applied (that would duplicate its
			// text) but still counts as delivered for cursor contiguity — its
			// content reached the client inside the frame. Buffered deltas of a
			// gap block's segment are skipped below like served ones: their text
			// is inside the block, and delivering them after it would duplicate.
			const gapBlockSegments = new Set<string>();
			for (const row of gapRows) {
				if (row.id === undefined || row.id <= lastReplayedSeq) continue;
				const { event } = row;
				if (event.type === 'text-block' || event.type === 'reasoning-block') {
					gapBlockSegments.add(
						segmentKey(event.type === 'text-block' ? 'text' : 'reasoning', event),
					);
					if (foldedBlockKeys.has(blockKey(event))) {
						lastReplayedSeq = row.id;
						continue;
					}
				}
				deliver(row);
				lastReplayedSeq = row.id;
			}
			for (const stored of arrivedDuringReplay) {
				if (stored.id !== undefined) {
					if (stored.id <= lastReplayedSeq) continue;
					if (stored.id === lastReplayedSeq + 1) {
						deliver(stored);
						lastReplayedSeq = stored.id;
						continue;
					}
					// Rows between the cursor and this fact were persisted after the
					// gap read (a segment closed while it was in flight): deliver the
					// fact's content but strip its id line, so the cursor never
					// crosses a row the client has not seen — the next replay returns
					// the missing block and re-applies this fact idempotently.
					deliver({ event: stored.event });
					continue;
				}
				const { event } = stored;
				if (
					(event.type === 'text-delta' || event.type === 'reasoning-delta') &&
					(served.has(segmentKey(event.type === 'text-delta' ? 'text' : 'reasoning', event)) ||
						gapBlockSegments.has(
							segmentKey(event.type === 'text-delta' ? 'text' : 'reasoning', event),
						))
				) {
					continue;
				}
				deliver(stored);
			}
			for (const segment of openSegments) {
				deliver({
					event: {
						type: segment.kind === 'text' ? 'text-delta' : 'reasoning-delta',
						runId: segment.runId,
						agentId: segment.agentId,
						...(segment.responseId ? { responseId: segment.responseId } : {}),
						payload: { text: segment.text },
					},
				});
			}
			this.durableLogMetrics.recordReplay(missed.length, Math.max(0, lastReplayedSeq - cursor));
		} finally {
			// The buffering subscription must not outlive the bootstrap, even when
			// a durable read throws.
			stopBuffering();
		}
		if (liveGroups.size > 0) res.flush?.();

		bootstrapping = false;

		// 7. Keep-alive
		keepAlive = setInterval(() => {
			res.write(': ping\n\n');
			res.flush?.();
		}, KEEP_ALIVE_INTERVAL_MS);
	}

	@Post('/confirm/:requestId')
	@GlobalScope('instanceAi:message')
	async confirm(req: AuthenticatedRequest, _res: Response, @Param('requestId') requestId: string) {
		this.requireInstanceAiEnabled();

		// Manual parse: `@Body` decorator can't resolve zod discriminated unions via reflection,
		// so validate the request body against the union schema directly.
		const parseResult = InstanceAiConfirmRequestDto.safeParse(req.body);
		if (!parseResult.success) {
			throw new BadRequestError(parseResult.error.errors[0].message);
		}

		const resolved = await this.instanceAiService.resolveConfirmation(
			req.user.id,
			requestId,
			parseResult.data,
		);
		if (!resolved) {
			throw new NotFoundError('Confirmation request not found or not authorized');
		}
		return resolved;
	}

	@Post('/chat/:threadId/cancel')
	@GlobalScope('instanceAi:message')
	async cancel(req: AuthenticatedRequest, _res: Response, @Param('threadId') threadId: string) {
		this.requireInstanceAiEnabled();
		await this.assertThreadAccess(req.user.id, threadId);
		await this.instanceAiService.routeCancelRun(threadId);
		return { ok: true };
	}

	@Post('/feedback/:threadId/:responseId')
	@GlobalScope('instanceAi:message')
	async feedback(
		req: AuthenticatedRequest,
		_res: Response,
		@Param('threadId') threadId: string,
		@Param('responseId') responseId: string,
		@Body payload: InstanceAiFeedbackRequestDto,
	) {
		this.requireInstanceAiEnabled();
		await this.assertThreadAccess(req.user.id, threadId);
		// Fire-and-forget: never surface LangSmith errors to the UI. The service
		// logs its own failures; the .catch here guards against unhandled
		// rejections from paths not wrapped internally (e.g. DB lookups).
		void this.instanceAiService
			.submitLangsmithFeedback(req.user, threadId, responseId, payload)
			.catch(() => {});
		return { ok: true };
	}

	@Post('/chat/:threadId/tasks/:taskId/cancel')
	@GlobalScope('instanceAi:message')
	async cancelTask(
		req: AuthenticatedRequest,
		_res: Response,
		@Param('threadId') threadId: string,
		@Param('taskId') taskId: string,
	) {
		this.requireInstanceAiEnabled();
		await this.assertThreadAccess(req.user.id, threadId);
		await this.instanceAiService.routeCancelBackgroundTask(threadId, taskId);
		return { ok: true };
	}

	@Post('/chat/:threadId/tasks/:taskId/correct')
	@GlobalScope('instanceAi:message')
	async correctTask(
		req: AuthenticatedRequest,
		_res: Response,
		@Param('threadId') threadId: string,
		@Param('taskId') taskId: string,
		@Body payload: InstanceAiCorrectTaskRequest,
	) {
		this.requireInstanceAiEnabled();
		await this.assertThreadAccess(req.user.id, threadId);
		await this.instanceAiService.routeCorrectionToTask(threadId, taskId, payload.message);
		return { ok: true };
	}

	// ── Credits ──────────────────────────────────────────────────────────────

	@Get('/credits')
	@GlobalScope('instanceAi:message')
	async getCredits(req: AuthenticatedRequest) {
		this.requireInstanceAiEnabled();
		return await this.instanceAiService.getCredits(req.user);
	}

	// ── Admin settings (owner/admin only) ──────────────────────────────────

	@Get('/settings')
	@GlobalScope('instanceAi:manage')
	async getAdminSettings(_req: AuthenticatedRequest) {
		return await this.settingsService.getAdminSettings();
	}

	@Get('/settings/models')
	@GlobalScope('instanceAi:manage')
	async getModelCatalog(_req: AuthenticatedRequest) {
		return await this.modelCatalogService.getModels();
	}

	@Put('/settings')
	@GlobalScope('instanceAi:manage')
	async updateAdminSettings(
		req: AuthenticatedRequest,
		_res: Response,
		@Body payload: InstanceAiAdminSettingsUpdateRequest,
	) {
		const result = await this.settingsService.updateAdminSettings(payload, req.user);
		const [publishResult] = await Promise.allSettled([
			this.publisher.publishCommand({ command: 'reload-instance-ai-settings' }),
			this.applyAdminSettingsSideEffects(result),
		]);

		if (publishResult.status === 'rejected') {
			this.instanceAiErrorReporter.report(publishResult.reason, {
				component: 'settings-publish',
				threadId: 'admin-settings',
			});
		}

		return result;
	}

	@Post('/settings/verify/model')
	@GlobalScope('instanceAi:manage')
	async verifyModel(
		req: AuthenticatedRequest,
		_res: Response,
		@Body payload: InstanceAiVerifyModelRequest,
	) {
		return await Container.get(InstanceAiVerificationService).verifyModel(req.user, payload);
	}

	@Post('/settings/verify/sandbox')
	@GlobalScope('instanceAi:manage')
	async verifySandbox(
		req: AuthenticatedRequest,
		_res: Response,
		@Body payload: InstanceAiVerifySandboxRequest,
	) {
		return await Container.get(InstanceAiVerificationService).verifySandbox(req.user, payload);
	}

	@Post('/settings/verify/search')
	@GlobalScope('instanceAi:manage')
	async verifySearch(
		_req: AuthenticatedRequest,
		_res: Response,
		@Body payload: InstanceAiVerifySearchRequest,
	) {
		return await Container.get(InstanceAiVerificationService).verifySearch(payload);
	}

	@OnPubSubEvent('reload-instance-ai-settings', { instanceType: 'main' })
	async reloadAdminSettings() {
		await this.settingsService.reloadFromDb();
		await this.applyAdminSettingsSideEffects({
			enabled: this.settingsService.isInstanceAiEnabled(),
			browserUseEnabled: this.settingsService.isBrowserUseEnabled(),
			localGatewayDisabled: this.settingsService.isLocalGatewayDisabled(),
		});
	}

	private async applyAdminSettingsSideEffects(
		settings: Pick<
			InstanceAiAdminSettingsResponse,
			'enabled' | 'browserUseEnabled' | 'localGatewayDisabled'
		>,
	) {
		const sideEffects: Array<() => Promise<void> | void> = [
			async () => {
				await this.moduleRegistry.refreshModuleSettings('instance-ai');
			},
			async () => {
				await this.moduleRegistry.refreshModuleSettings('agents');
			},
		];
		if (!settings.enabled || !settings.browserUseEnabled) {
			sideEffects.push(async () => await this.browserSessionService.shutdown());
		}

		if (!settings.enabled || settings.localGatewayDisabled) {
			sideEffects.push(() => {
				const disconnectedUserIds = this.gatewayService.disconnectAllGateways();
				if (disconnectedUserIds.length === 0) return;
				this.push.sendToUsers(
					{
						type: 'instanceAiGatewayStateChanged',
						data: {
							connected: false,
							directory: null,
							hostIdentifier: null,
							toolCategories: [],
						},
					},
					disconnectedUserIds,
				);
			});
		}

		const results = await Promise.allSettled(sideEffects.map(async (apply) => await apply()));
		for (const result of results) {
			if (result.status === 'rejected') {
				this.instanceAiErrorReporter.report(result.reason, {
					component: 'settings-side-effects',
					threadId: 'admin-settings',
				});
			}
		}
	}

	// ── User preferences (per-user, self-service) ──────────────────────────

	@Get('/preferences')
	@GlobalScope('instanceAi:message')
	async getUserPreferences(req: AuthenticatedRequest) {
		return await this.settingsService.getUserPreferences(req.user);
	}

	@Put('/preferences')
	@GlobalScope('instanceAi:message')
	async updateUserPreferences(
		req: AuthenticatedRequest,
		_res: Response,
		@Body payload: InstanceAiUserPreferencesUpdateRequest,
	) {
		const result = await this.settingsService.updateUserPreferences(req.user, payload);
		if (payload.localGatewayDisabled !== undefined) {
			await this.moduleRegistry.refreshModuleSettings('instance-ai');
		}
		return result;
	}

	@Get('/settings/service-credentials')
	@GlobalScope('instanceAi:manage')
	async listServiceCredentials(_req: AuthenticatedRequest) {
		return await this.settingsService.listInstanceServiceCredentials();
	}

	@Get('/settings/model-credentials')
	@GlobalScope('instanceAi:manage')
	async listInstanceModelCredentials(_req: AuthenticatedRequest) {
		return await this.settingsService.listInstanceModelCredentials();
	}

	@Get('/threads')
	@GlobalScope('instanceAi:message')
	async listThreads(req: AuthenticatedRequest) {
		this.requireInstanceAiEnabled();
		return await this.memoryService.listThreads(req.user.id);
	}

	@Post('/threads')
	@GlobalScope('instanceAi:message')
	async ensureThread(
		req: AuthenticatedRequest,
		_res: Response,
		@Body payload: InstanceAiEnsureThreadRequest,
	) {
		this.requireInstanceAiEnabled();
		const project = await this.projectService.getProjectWithScope(req.user, payload.projectId, [
			'project:read',
		]);
		if (!project) {
			throw new ForbiddenError('You do not have access to the requested project');
		}
		const requestedThreadId = payload.threadId ?? randomUUID();
		await this.assertThreadAccess(req.user.id, requestedThreadId, { allowNew: true });

		const launchMetadata = {
			source: payload.source,
			origin: payload.origin ?? ('internal' as const),
			sourceContext: payload.sourceContext,
		};

		try {
			return await this.memoryService.ensureThread(
				req.user.id,
				requestedThreadId,
				payload.projectId,
				launchMetadata,
			);
		} catch (error) {
			this.instanceAiErrorReporter.report(error, {
				component: 'instance-ai-ensure-thread',
				threadId: requestedThreadId,
				userId: req.user.id,
				projectId: payload.projectId,
			});
			throw error;
		}
	}

	@Delete('/threads/:threadId')
	@GlobalScope('instanceAi:message')
	async deleteThread(
		req: AuthenticatedRequest,
		_res: Response,
		@Param('threadId') threadId: string,
	) {
		this.requireInstanceAiEnabled();
		await this.assertThreadAccess(req.user.id, threadId);
		await this.instanceAiService.routeClearThreadState(threadId);
		await this.memoryService.deleteThread(threadId);
		return { ok: true };
	}

	@Patch('/threads/:threadId')
	@GlobalScope('instanceAi:message')
	async renameThread(
		req: AuthenticatedRequest,
		_res: Response,
		@Param('threadId') threadId: string,
		@Body payload: InstanceAiRenameThreadRequestDto,
	) {
		this.requireInstanceAiEnabled();
		await this.assertThreadAccess(req.user.id, threadId);
		const thread = await this.memoryService.updateThread(threadId, {
			title: payload.title,
			metadata: payload.metadata,
		});
		return { thread };
	}

	@Get('/threads/:threadId/messages')
	@GlobalScope('instanceAi:message')
	async getThreadMessages(
		req: AuthenticatedRequest,
		_res: Response,
		@Param('threadId') threadId: string,
		@Query query: InstanceAiThreadMessagesQuery,
	) {
		this.requireInstanceAiEnabled();
		await this.assertThreadAccess(req.user.id, threadId);
		await this.instanceAiService.replayUndeliveredTerminalOutcomes(threadId);

		// ?raw=true returns the old format for the thread inspector
		if (query.raw === 'true') {
			return await this.memoryService.getThreadMessages(req.user.id, threadId, {
				limit: query.limit,
				page: query.page,
			});
		}

		// Exclude snapshots for active/suspended runs — they have no matching
		// assistant message in native memory yet and would misalign the
		// positional snapshot-to-message matching in parseStoredMessages. The
		// live message-group ids ride along so the durable-log fold can exclude
		// a whole in-flight group even when the active run's own run-start row
		// has not been persisted yet (it is the group mapping's source there).
		const threadStatus = this.instanceAiService.getThreadStatus(threadId);
		const activeRunId = this.instanceAiService.getActiveRunId(threadId);
		const excludeRunIds: string[] = [];
		const excludeMessageGroupIds: string[] = [];
		if (activeRunId) {
			excludeRunIds.push(activeRunId);
			const activeGroupId = this.instanceAiService.getMessageGroupId(threadId);
			if (activeGroupId) excludeMessageGroupIds.push(activeGroupId);
		}
		for (const t of threadStatus.backgroundTasks) {
			if (t.status !== 'running') continue;
			if (t.runId) excludeRunIds.push(t.runId);
			if (t.messageGroupId) excludeMessageGroupIds.push(t.messageGroupId);
		}

		const result = await this.memoryService.getRichMessages(req.user.id, threadId, {
			limit: query.limit,
			page: query.page,
			excludeRunIds: excludeRunIds.length > 0 ? excludeRunIds : undefined,
			excludeMessageGroupIds:
				excludeMessageGroupIds.length > 0 ? excludeMessageGroupIds : undefined,
		});

		// Include the next SSE event ID so the frontend can skip past events
		// already covered by these historical messages (prevents duplicates).
		// Read from the log, so the cursor is valid across restarts and across
		// mains sharing the database.
		const nextEventId = await this.eventLog.getNextEventId(threadId);
		return { ...result, nextEventId };
	}

	@Get('/threads/:threadId/status')
	@GlobalScope('instanceAi:message')
	async getThreadStatus(
		req: AuthenticatedRequest,
		_res: Response,
		@Param('threadId') threadId: string,
	) {
		this.requireInstanceAiEnabled();
		// Allow new threads — the frontend polls status before the first message is sent
		await this.assertThreadAccess(req.user.id, threadId, { allowNew: true });
		return this.instanceAiService.getThreadStatus(threadId);
	}

	@Get('/debug/runs/:runId')
	@GlobalScope('instanceAi:message')
	async getRunDebug(req: AuthenticatedRequest, _res: Response, @Param('runId') runId: string) {
		this.requireInstanceAiEnabled();
		this.requireRunDebugEnabled();
		const record = this.instanceAiService.getRunDebug(runId);
		if (!record) {
			throw new NotFoundError('Run debug record not found');
		}
		await this.assertThreadAccess(req.user.id, record.threadId);
		return record;
	}

	@Get('/debug/threads/:threadId/runs')
	@GlobalScope('instanceAi:message')
	async listThreadDebugRuns(
		req: AuthenticatedRequest,
		_res: Response,
		@Param('threadId') threadId: string,
	) {
		this.requireInstanceAiEnabled();
		this.requireRunDebugEnabled();
		await this.assertThreadAccess(req.user.id, threadId);
		return {
			threadId,
			runs: this.instanceAiService.listThreadDebugRuns(threadId),
		};
	}

	// ── Evaluation endpoints ──────────────────────────────────────────────────

	// Runs for minutes; the eval client (N8nClient) disables undici's 300s timeout for it.
	@Post('/eval/execute-with-llm-mock/:workflowId')
	@GlobalScope('instanceAi:eval')
	async executeWithLlmMock(
		req: AuthenticatedRequest,
		_res: Response,
		@Param('workflowId') workflowId: string,
		@Body payload: InstanceAiEvalExecutionRequest,
	) {
		return await this.evalExecutionService.executeWithLlmMock(workflowId, req.user, payload);
	}

	// Runs for minutes; same client timeout handling as the workflow variant.
	@Post('/eval/execute-agent-with-llm-mock/:agentId')
	@GlobalScope('instanceAi:eval')
	async executeAgentWithLlmMock(
		req: AuthenticatedRequest,
		_res: Response,
		@Param('agentId') agentId: string,
		@Body payload: InstanceAiEvalAgentExecutionRequest,
	) {
		return await this.evalAgentExecutionService.executeWithLlmMock(agentId, req.user, payload);
	}

	/**
	 * Pin a build thread's credential view to a declared set. Only narrows:
	 * `list()` results are intersected with these IDs, so the caller cannot see
	 * anything they couldn't already access. The thread must exist — entries are
	 * cleared with the thread's state, so pins for never-created threads would
	 * be uncollectable.
	 */
	@Post('/eval/thread-credential-allowlist')
	@GlobalScope('instanceAi:eval')
	async setThreadCredentialAllowlist(
		req: AuthenticatedRequest,
		_res: Response,
		@Body payload: InstanceAiEvalCredentialAllowlistRequest,
	) {
		this.requireInstanceAiEnabled();
		await this.assertThreadAccess(req.user.id, payload.threadId);
		this.evalCredentialAllowlists.set(
			payload.threadId,
			payload.credentialIds,
			payload.bypassCredentialTest,
		);
		return { ok: true };
	}

	/**
	 * Seed an existing (owned) thread with a previously exported conversation:
	 * recreate the artifacts the history references — workflows (node credentials
	 * stripped — see `EvalThreadRestoreService`), data tables and agents — then
	 * write the native message log verbatim. The thread then continues as if the
	 * conversation really happened, so an eval can drive the next turn live.
	 */
	@Post('/eval/restore-thread')
	@GlobalScope('instanceAi:eval')
	async restoreEvalThread(
		req: AuthenticatedRequest,
		_res: Response,
		@Body payload: InstanceAiEvalRestoreThreadRequest,
	) {
		this.requireInstanceAiEnabled();
		await this.assertThreadAccess(req.user.id, payload.threadId);
		const projectId = await this.memoryService.getThreadProjectId(payload.threadId);
		if (!projectId) {
			throw new BadRequestError('Thread is not bound to a project');
		}

		const workflows = payload.workflows ?? [];
		const agents = payload.agents ?? [];
		// Cross-field, so the schema can't own it: a seeded agent's workflow tool is
		// resolved by DISPLAY NAME, and a name no seeded workflow carries restores a
		// dead tool (or binds an unrelated ambient workflow of the same name).
		const unbacked = findUnbackedSeedWorkflowTools(payload);
		if (unbacked.length > 0) {
			throw new BadRequestError(
				unbacked
					.map(
						({ agentId, target }: { agentId: string; target: unknown }) =>
							`Seed agent ${agentId} has a workflow tool targeting ${JSON.stringify(target)}, which no seeded workflow's name matches`,
					)
					.join('; '),
			);
		}
		// Data tables first: the workflows reference them, and their ids are
		// rewritten to the recreated tables' ids during workflow restore.
		const idMap = await this.evalThreadRestore.restoreDataTables(
			payload.dataTables ?? [],
			projectId,
			{ uniquifyNames: payload.uniquifyNames ?? true },
		);
		const dataTableIds = [...idMap.values()];
		// Roll back everything we created if a later step fails, so a partial
		// restore doesn't leak workflows/tables/agents into the shared eval project.
		let restored = 0;
		let createdWorkflowIds: string[] = [];
		let createdAgentIds: string[] = [];
		// Captured so the binding write is undoable: the message write happens after
		// it, and without this a message failure left a binding pointing at agents the
		// rollback had already deleted.
		let priorMetadata: Record<string, unknown> | undefined;
		let bindingWritten = false;
		try {
			createdWorkflowIds = await this.evalThreadRestore.restoreWorkflows(
				workflows,
				projectId,
				idMap,
			);
			createdAgentIds = await this.evalThreadRestore.restoreAgents(agents, projectId, idMap);
			// Built (and validated) BEFORE the message write: a rejected binding — two
			// agents whose refs collide — must fail while the restore is still fully
			// rollback-able, not after the messages have committed.
			const binding =
				createdAgentIds.length > 0
					? seedAgentBuilderTargetMetadata(
							agents.map((agent) => ({
								agentId: agent.id,
								projectId,
								name: agent.config.name,
								ref: agent.config.name,
							})),
							payload.messages,
						)
					: undefined;
			// Bind the thread as the conversation that built these agents would have, or
			// the live turn's first `build-agent` call is rejected as an unknown agentRef.
			// BEFORE the messages, and undoable: the catch restores the prior metadata,
			// so a message failure can't leave a binding pointing at deleted agents, and
			// a binding failure can't leave messages referencing them.
			if (binding) {
				priorMetadata = await this.memoryService.getThreadMetadata(req.user.id, payload.threadId);
				await this.memoryService.updateThread(payload.threadId, { metadata: binding });
				bindingWritten = true;
			}
			// A data-table-only seed (TRUST-311) sends no messages — skip the write.
			if (payload.messages.length > 0) {
				({ restored } = await this.memoryService.restoreThreadMessages(
					req.user.id,
					payload.threadId,
					payload.messages,
				));
			}
		} catch (error) {
			if (bindingWritten) {
				try {
					// `updateThread` MERGES, so the prior snapshot alone would leave the
					// binding keys standing — this names them and restores each.
					await this.memoryService.updateThread(payload.threadId, {
						metadata: clearedAgentBuilderTargetMetadata(priorMetadata),
					});
				} catch {
					// Best-effort, like the artifact deletes: never throw over the failure
					// that triggered the rollback.
				}
			}
			await this.evalThreadRestore.deleteAgents(createdAgentIds, projectId);
			await this.evalThreadRestore.deleteWorkflows(createdWorkflowIds);
			await this.evalThreadRestore.deleteDataTables(dataTableIds, projectId);
			throw error;
		}
		return {
			ok: true,
			threadId: payload.threadId,
			restored,
			workflowIds: workflows.map((workflow) => workflow.id),
			dataTableIds,
			agentIds: createdAgentIds,
		};
	}

	/**
	 * Reset an existing data table's rows to exactly the supplied set
	 * (clear-then-insert). The eval harness pre-creates a case's scenario data
	 * tables empty before the build turn (so the agent binds the real table id),
	 * then calls this per scenario to swap in that scenario's rows (TRUST-311
	 * follow-up). Auth + project scoping mirror restore-thread: the table must be
	 * in the thread's project.
	 */
	@Post('/eval/seed-data-table-rows')
	@GlobalScope('instanceAi:eval')
	async seedEvalDataTableRows(
		req: AuthenticatedRequest,
		_res: Response,
		@Body payload: InstanceAiEvalSeedDataTableRowsRequest,
	) {
		this.requireInstanceAiEnabled();
		await this.assertThreadAccess(req.user.id, payload.threadId);
		const projectId = await this.memoryService.getThreadProjectId(payload.threadId);
		if (!projectId) {
			throw new BadRequestError('Thread is not bound to a project');
		}
		await this.evalThreadRestore.reseedDataTableRows(payload.tableId, projectId, payload.rows);
		return { ok: true, tableId: payload.tableId, rowCount: payload.rows.length };
	}

	// ── Gateway endpoints (daemon ↔ server) ──────────────────────────────────

	@Post('/gateway/create-link')
	@GlobalScope('instanceAi:gateway')
	async createGatewayLink(req: AuthenticatedRequest) {
		await this.assertGatewayEnabled(req.user.id);
		const token = this.gatewayService.generatePairingToken(req.user.id);
		const expiresAt = this.gatewayService.getGatewayApiKeyExpiresAt(req.user.id, token);
		const ttlSeconds = expiresAt
			? Math.max(0, Math.ceil((expiresAt.getTime() - Date.now()) / 1000))
			: null;
		const baseUrl = this.urlService.getInstanceBaseUrl();
		const command = `npx @n8n/computer-use ${baseUrl} ${token}`;
		return { token, command, expiresAt: expiresAt?.toISOString() ?? null, ttlSeconds };
	}

	@Get('/gateway/events', { usesTemplates: true, skipAuth: true })
	async gatewayEvents(req: Request, res: FlushableResponse) {
		const userId = this.validateGatewayApiKey(this.getGatewayKeyHeader(req));
		await this.assertGatewayEnabled(userId);

		const gateway = this.gatewayService.getLocalGateway(userId);

		// If the grace-period timer already fired (e.g. after a long reconnect gap),
		// the gateway state is torn down. Reject so the daemon falls into its auth-error
		// reconnect branch, which re-uploads capabilities and re-establishes state.
		if (!gateway.isConnected) {
			throw new ForbiddenError('Local gateway not initialized');
		}

		// Daemon reconnected within the grace window — cancel the pending disconnect.
		this.gatewayService.clearDisconnectTimer(userId);

		(res as unknown as { compress: boolean }).compress = false;
		res.setHeader('Content-Type', 'text/event-stream; charset=UTF-8');
		res.setHeader('Cache-Control', 'no-cache, no-transform');
		res.setHeader('Connection', 'keep-alive');
		res.setHeader('X-Accel-Buffering', 'no');
		res.flushHeaders();

		const unsubscribeRequest = gateway.onRequest((event) => {
			res.write(`data: ${JSON.stringify(event)}\n\n`);
			res.flush?.();
		});
		const unsubscribeDisconnect = gateway.onDisconnect((event) => {
			res.write(`data: ${JSON.stringify(event)}\n\n`);
			res.flush?.();
			res.end();
		});

		const keepAlive = setInterval(() => {
			res.write(': ping\n\n');
			res.flush?.();
		}, KEEP_ALIVE_INTERVAL_MS);

		let cleanedUp = false;
		const cleanup = () => {
			if (cleanedUp) return;
			cleanedUp = true;
			unsubscribeRequest();
			unsubscribeDisconnect();
			clearInterval(keepAlive);
			this.gatewayService.startDisconnectTimer(userId, () => {
				this.push.sendToUsers(
					{
						type: 'instanceAiGatewayStateChanged',
						data: {
							connected: false,
							directory: null,
							hostIdentifier: null,
							toolCategories: [],
						},
					},
					[userId],
				);
			});
		};
		res.once('close', cleanup);
		res.once('finish', cleanup);
	}

	@Post('/gateway/init', { skipAuth: true })
	async gatewayInit(req: Request, _res: Response, @Body payload: InstanceAiGatewayCapabilitiesDto) {
		const key = this.getGatewayKeyHeader(req);
		const userId = this.validateGatewayApiKey(key);
		await this.assertGatewayEnabled(userId);

		this.gatewayService.initGateway(userId, payload);
		this.gatewayService.applyToolPolicy(userId);

		const status = this.gatewayService.getGatewayStatus(userId);
		this.push.sendToUsers(
			{
				type: 'instanceAiGatewayStateChanged',
				data: {
					connected: status.connected,
					directory: status.directory,
					hostIdentifier: status.hostIdentifier,
					toolCategories: status.toolCategories,
				},
			},
			[userId],
		);

		// Try to consume a pairing token and upgrade to a session key
		const sessionKey = key ? this.gatewayService.consumePairingToken(userId, key) : null;
		if (sessionKey) {
			return { ok: true, sessionKey };
		}
		return { ok: true };
	}

	@Post('/gateway/disconnect', { skipAuth: true })
	gatewayDisconnect(req: Request) {
		const userId = this.validateGatewayApiKey(this.getGatewayKeyHeader(req));

		this.gatewayService.clearDisconnectTimer(userId);
		this.gatewayService.disconnectGateway(userId);
		this.gatewayService.clearActiveSessionKey(userId);
		this.push.sendToUsers(
			{
				type: 'instanceAiGatewayStateChanged',
				data: { connected: false, directory: null, hostIdentifier: null, toolCategories: [] },
			},
			[userId],
		);
		return { ok: true };
	}

	@Post('/gateway/response/:requestId', { skipAuth: true })
	gatewayResponse(
		req: Request,
		_res: Response,
		@Param('requestId') requestId: string,
		@Body payload: InstanceAiFilesystemResponseDto,
	) {
		const userId = this.validateGatewayApiKey(this.getGatewayKeyHeader(req));

		const resolved = this.gatewayService.resolveGatewayRequest(
			userId,
			requestId,
			payload.result,
			payload.error,
		);
		if (!resolved) {
			throw new NotFoundError('Gateway request not found or already resolved');
		}
		return { ok: true };
	}

	@Post('/gateway/credentials', { skipAuth: true })
	async gatewayCreateCredential(
		req: Request,
		_res: Response,
		@Body payload: InstanceAiGatewayCreateCredentialDto,
	) {
		const user = await this.resolveGatewayUser(this.getGatewayKeyHeader(req));
		await this.assertGatewayEnabled(user.id);
		const credential = await this.credentialsService.createUnmanagedCredential(payload, user);
		return { credentialId: credential.id };
	}

	@Get('/gateway/status')
	@GlobalScope('instanceAi:gateway')
	async gatewayStatus(req: AuthenticatedRequest) {
		await this.assertGatewayEnabled(req.user.id);
		this.gatewayService.applyToolPolicy(req.user.id);
		return this.gatewayService.getGatewayStatus(req.user.id);
	}

	/**
	 * User-initiated gateway disconnect. Tears down the paired daemon session
	 * so its tools are no longer exposed to the agent, without changing the
	 * user's preference to disabled.
	 */
	@Post('/gateway/disconnect-session')
	@GlobalScope('instanceAi:gateway')
	async gatewayDisconnectSession(req: AuthenticatedRequest) {
		const userId = req.user.id;
		this.gatewayService.clearDisconnectTimer(userId);
		this.gatewayService.disconnectGateway(userId);
		this.gatewayService.clearActiveSessionKey(userId);
		this.push.sendToUsers(
			{
				type: 'instanceAiGatewayStateChanged',
				data: { connected: false, directory: null, hostIdentifier: null, toolCategories: [] },
			},
			[userId],
		);
		return { ok: true };
	}

	@Post('/browser/create-link')
	@GlobalScope('instanceAi:gateway')
	async createBrowserLink(req: AuthenticatedRequest) {
		this.requireInstanceAiEnabled();
		this.assertBrowserChannelEnabled();
		return await this.browserSessionService.createLink(req.user.id);
	}

	@Get('/browser/status')
	@GlobalScope('instanceAi:gateway')
	browserStatus(req: AuthenticatedRequest) {
		this.requireInstanceAiEnabled();
		this.assertBrowserChannelEnabled();
		return this.browserSessionService.getStatus(req.user.id);
	}

	@Post('/browser/disconnect-session')
	@GlobalScope('instanceAi:gateway')
	async browserDisconnectSession(req: AuthenticatedRequest) {
		this.requireInstanceAiEnabled();
		await this.browserSessionService.disconnect(req.user.id);
		return { ok: true };
	}

	// ── Helpers ──────────────────────────────────────────────────────────────

	private assertBrowserChannelEnabled(): void {
		if (!this.settingsService.isBrowserUseEnabled()) {
			throw new ForbiddenError('Browser Use is disabled');
		}
	}

	/**
	 * Verify thread ownership. Throws ForbiddenError if another user owns it.
	 * @param allowNew When true, a non-existent thread is permitted (new conversation).
	 */
	private async assertThreadAccess(
		userId: string,
		threadId: string,
		options?: { allowNew?: boolean },
	): Promise<void> {
		const ownership = await this.memoryService.checkThreadOwnership(userId, threadId);
		if (ownership === 'other_user') {
			throw new ForbiddenError('Not authorized for this thread');
		}
		if (!options?.allowNew && ownership === 'not_found') {
			throw new NotFoundError('Thread not found');
		}
	}

	/** Throw if the local gateway is disabled globally or for this user. */
	private async assertGatewayEnabled(userId: string): Promise<void> {
		if (await this.settingsService.isLocalGatewayDisabledForUser(userId)) {
			throw new ForbiddenError('Local gateway is disabled');
		}
	}

	/**
	 * Safely extract and validate the x-gateway-key header value.
	 * Headers can be string | string[] | undefined — take only the first value
	 * and validate against the shared gateway key schema.
	 */
	private getGatewayKeyHeader(req: Request): string | undefined {
		const raw = req.headers['x-gateway-key'];
		const value = Array.isArray(raw) ? raw[0] : raw;
		const parsed = instanceAiGatewayKeySchema.safeParse(value);
		return parsed.success ? parsed.data : undefined;
	}

	/**
	 * Validate the gateway API key from query param or header.
	 * Accepts: static env var key, one-time pairing token (init only), or active session key.
	 * Returns the userId associated with the key.
	 */
	private validateGatewayApiKey(key: string | undefined): string {
		if (!key) {
			throw new ForbiddenError('Missing API key');
		}
		const actual = Buffer.from(key);

		// Check static env var key — out of user-scoped flow, uses a sentinel userId
		if (this.gatewayApiKey) {
			const expected = Buffer.from(this.gatewayApiKey);
			if (expected.length === actual.length && timingSafeEqual(expected, actual)) {
				return 'env-gateway';
			}
		}

		// Check per-user pairing token or session key via reverse lookup
		const userId = this.gatewayService.getUserIdForApiKey(key);
		if (userId) return userId;

		throw new ForbiddenError('Invalid API key');
	}

	/**
	 * Resolve a gateway API key to its associated User. Requires a user-scoped
	 * key — the static env-var key (which has no associated DB user) is rejected.
	 */
	private async resolveGatewayUser(key: string | undefined): Promise<User> {
		const userId = this.validateGatewayApiKey(key);
		if (userId === 'env-gateway') {
			throw new ForbiddenError('Credential creation requires a user-scoped gateway key');
		}
		const user = await this.userRepository.findOne({
			where: { id: userId },
			relations: ['role', 'role.scopes'],
		});
		if (!user) throw new ForbiddenError('Invalid API key');
		return user;
	}

	private writeSseEvent(res: FlushableResponse, stored: StoredEvent): void {
		// No `event:` field — events are discriminated by data.type per streaming-protocol.md.
		// Ephemeral events (deltas/status) carry no `id:` line, so the browser's
		// Last-Event-ID only ever advances on durable facts — same precedent as
		// the run-sync control frames above.
		const idLine = stored.id !== undefined ? `id: ${stored.id}\n` : '';
		res.write(`${idLine}data: ${JSON.stringify(stored.event)}\n\n`);
		res.flush?.();
	}
}
