import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js';
import {
	InvalidGrantError,
	InvalidTargetError,
} from '@modelcontextprotocol/sdk/server/auth/errors.js';
import type {
	AuthorizationParams,
	OAuthServerProvider,
} from '@modelcontextprotocol/sdk/server/auth/provider.js';
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
import type {
	OAuthClientInformationFull,
	OAuthTokens,
	OAuthTokenRevocationRequest,
} from '@modelcontextprotocol/sdk/shared/auth.js';
import type { McpClientConnectedPeriod, McpClientTypeFilter } from '@n8n/api-types';
import { getMcpClientType, MCP_CLIENT_TYPE_FILTER_BUCKETS } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { INSTANCE_MCP_RESOURCE_ID } from '@n8n/constants';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions';
import type { Response } from 'express';

import { AuthService } from '@/auth/auth.service';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { EventService } from '@/events/event.service';
import {
	ProtectedResourceRegistry,
	type ProtectedResource,
} from '@/services/protected-resource.registry';
import { UrlService } from '@/services/url.service';
import { UserManagementMailer } from '@/user-management/email';

import { OAuthClient } from './database/entities/oauth-client.entity';
import { OAuthClientRepository } from './database/repositories/oauth-client.repository';
import { UserConsentRepository } from './database/repositories/oauth-user-consent.repository';
import { OAuthAuthorizationCodeService } from './oauth-authorization-code.service';
import { OAuthConsentService } from './oauth-consent.service';
import { OAuthSessionPayload, OAuthSessionService } from './oauth-session.service';
import { OAuthTokenService } from './oauth-token.service';
import { OAuthClientLimitReachedError } from './oauth.errors';
import { isSameProtectedResource } from './resource-identity';

/** Maximum number of redirect URIs per client */
const MAX_REDIRECT_URIS = 10;

/** Maximum length for a client_name, matching the column size declared in the CreateOAuthEntities migration */
const MAX_CLIENT_NAME_LENGTH = 255;

/**
 * Grant types this OAuth server implements. Kept in sync with the
 * `grant_types_supported` list advertised by the metadata endpoint
 * (see oauth.controller.ts).
 */
const SUPPORTED_GRANT_TYPES = ['authorization_code', 'refresh_token'];

/**
 * Token endpoint auth methods this OAuth server implements. Kept in sync
 * with the `token_endpoint_auth_methods_supported` list advertised by the
 * metadata endpoint (see oauth.controller.ts).
 */
const SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS = ['none', 'client_secret_post', 'client_secret_basic'];

/**
 * Counts Unicode code points rather than UTF-16 code units, so a value
 * containing supplementary-plane characters (surrogate pairs) isn't counted
 * as longer than it is. Stops once past `limit` to avoid scanning an
 * oversized value in full.
 */
function countCodePointsUpTo(value: string, limit: number): number {
	let count = 0;

	for (const _codePoint of value) {
		count++;

		if (count > limit) {
			break;
		}
	}

	return count;
}

/** Maximum number of grant types per client — one entry per supported grant type is all a client ever needs */
const MAX_GRANT_TYPES = SUPPORTED_GRANT_TYPES.length;

export type ConnectedOAuthClientOwner = {
	id: string;
	firstName: string | null;
	lastName: string | null;
	email: string;
};

/** A client the user has consented to, enriched with the grant details of the consent. */
export type ConnectedOAuthClient = Omit<
	OAuthClient,
	'clientSecret' | 'clientSecretExpiresAt' | 'setUpdateDate'
> & {
	grantedAt: number;
	scopes: string[];
	/** Consent owner; present only when listing across users (ownership=all). */
	owner?: ConnectedOAuthClientOwner;
};

/** Per-ownership consent totals for the connected-clients tab badges. */
export type ConnectedOAuthClientTotals = { mine: number; all?: number };

export type ListConnectedClientsOptions = {
	ownership?: 'mine' | 'all';
	skip?: number;
	take?: number;
	name?: string;
	ownerId?: string;
	type?: McpClientTypeFilter;
	connected?: McpClientConnectedPeriod;
};

/** Whether a client's derived brand type falls in the requested filter bucket. */
function matchesTypeFilter(name: string, type: McpClientTypeFilter): boolean {
	const clientType = getMcpClientType(name);
	return clientType !== null && MCP_CLIENT_TYPE_FILTER_BUCKETS[type].includes(clientType);
}

/** Sort owners by display name so the "Connected by" dropdown reads naturally. */
function sortOwners(owners: ConnectedOAuthClientOwner[]): ConnectedOAuthClientOwner[] {
	return [...owners].sort((a, b) => {
		const nameA = [a.firstName, a.lastName].filter(Boolean).join(' ') || a.email;
		const nameB = [b.firstName, b.lastName].filter(Boolean).join(' ') || b.email;
		return nameA.localeCompare(nameB);
	});
}

/** Maximum length for a single redirect URI */
const MAX_REDIRECT_URI_LENGTH = 2048;

/**
 * OAuth 2.1 server implementation shared by all registered protected resources.
 * Implements MCP SDK OAuthServerProvider interface for client registration, authorization, and token management
 */
@Service()
export class OAuthServerService implements OAuthServerProvider {
	constructor(
		private readonly logger: Logger,
		private readonly globalConfig: GlobalConfig,
		private readonly oauthSessionService: OAuthSessionService,
		private readonly oauthClientRepository: OAuthClientRepository,
		private readonly tokenService: OAuthTokenService,
		private readonly authorizationCodeService: OAuthAuthorizationCodeService,
		private readonly userConsentRepository: UserConsentRepository,
		private readonly resourceRegistry: ProtectedResourceRegistry,
		private readonly mailer: UserManagementMailer,
		private readonly urlService: UrlService,
		private readonly eventService: EventService,
		private readonly authService: AuthService,
		private readonly oauthConsentService: OAuthConsentService,
	) {}

	get clientsStore(): OAuthRegisteredClientsStore {
		return {
			getClient: async (clientId: string): Promise<OAuthClientInformationFull | undefined> => {
				const client = await this.oauthClientRepository.findOneBy({ id: clientId });
				if (!client) {
					return await this.resolveVirtualClient(clientId);
				}

				// Some clients echo back the `scope` they saw on registration and
				// reject responses that include `scope: ''`. Omit the field
				// entirely when no scopes are advertised.
				const supportedScopes = this.resourceRegistry.getAllScopes();

				return {
					client_id: client.id,
					client_name: client.name,
					redirect_uris: client.redirectUris,
					grant_types: client.grantTypes,
					token_endpoint_auth_method: client.tokenEndpointAuthMethod,
					...(client.clientSecret && { client_secret: client.clientSecret }),
					...(client.clientSecretExpiresAt && {
						client_secret_expires_at: client.clientSecretExpiresAt,
					}),
					response_types: ['code'],
					...(supportedScopes.length > 0 && { scope: supportedScopes.join(' ') }),
					logo_uri: undefined,
					tos_uri: undefined,
				};
			},
			registerClient: async (
				client: OAuthClientInformationFull,
			): Promise<OAuthClientInformationFull> => {
				this.validateClientRegistration(client);

				await this.oauthClientRepository.insert({
					id: client.client_id,
					name: client.client_name,
					redirectUris: client.redirect_uris,
					grantTypes: client.grant_types,
					clientSecret: client.client_secret ?? null,
					clientSecretExpiresAt: client.client_secret_expires_at ?? null,
					tokenEndpointAuthMethod: client.token_endpoint_auth_method ?? 'none',
					isFirstParty: false,
				});

				await this.enforceClientLimit(client.client_id);

				return client;
			},
		};
	}

	/** Returns true when the instance is already at or above the registered-client cap. */
	async isClientLimitReached(): Promise<boolean> {
		const clientCount = await this.oauthClientRepository.countBy({ isFirstParty: false });
		return clientCount >= this.globalConfig.endpoints.mcpMaxRegisteredClients;
	}

	async getInstanceClientStats(): Promise<{
		count: number;
		limit: number;
		atCapacity: boolean;
	}> {
		const count = await this.oauthClientRepository.countBy({ isFirstParty: false });
		const limit = this.globalConfig.endpoints.mcpMaxRegisteredClients;
		return { count, limit, atCapacity: count >= limit };
	}

	/**
	 * Check count after insert to avoid race condition between count() and insert().
	 * If over limit, rolls back by deleting the just-inserted client.
	 *
	 * Throws `OAuthClientLimitReachedError` (a `ServerError` subclass), which the
	 * MCP SDK's register handler will surface as a 500 with our descriptive body
	 * — matching the response shape of the pre-check guard at the route layer.
	 */
	private async enforceClientLimit(clientId: string): Promise<void> {
		const clientCount = await this.oauthClientRepository.countBy({ isFirstParty: false });
		const limit = this.globalConfig.endpoints.mcpMaxRegisteredClients;
		if (clientCount > limit) {
			await this.oauthClientRepository.delete({ id: clientId });
			this.logger.warn(
				'OAuth client registration rejected: instance limit reached (post-insert rollback)',
				{ limit, clientCount },
			);
			throw new OAuthClientLimitReachedError(limit);
		}
	}

	/**
	 * On-demand per-trigger virtual client for a first-party protected resource
	 * (form or chat trigger). Public + PKCE, single redirect_uri = the trigger URL
	 * (which equals the client_id and the resource URL). The row is persisted lazily
	 * only to satisfy the FKs from auth codes / tokens; it is never a DCR client and
	 * is excluded from the registered-client cap.
	 */
	private async resolveVirtualClient(
		clientId: string,
	): Promise<OAuthClientInformationFull | undefined> {
		// First-party resources are form and chat triggers served under the (test) webhook
		// base URL, so a client_id that isn't can never resolve to one. Skip the resolver
		// sweep + lazy upsert for anything else, so the unauthenticated /authorize path
		// can't be used to fan out DB lookups on arbitrary client_ids.
		if (!this.isTriggerResourceClientId(clientId)) {
			return undefined;
		}

		const resource = await this.resourceRegistry.getByResourceUrl(clientId);
		if (!resource?.isFirstParty) {
			return undefined;
		}

		await this.oauthClientRepository.upsert(
			{
				id: clientId,
				name: resource.displayName ?? clientId,
				redirectUris: [clientId],
				// `refresh_token` so the column matches what the grant actually supports:
				// the AS issues a refresh token on every authorization-code exchange, and
				// long-lived trigger pages rotate it rather than redirect again.
				grantTypes: ['authorization_code', 'refresh_token'],
				tokenEndpointAuthMethod: 'none',
				clientSecret: null,
				clientSecretExpiresAt: null,
				isFirstParty: true,
			},
			['id'],
		);

		return {
			client_id: clientId,
			client_name: resource.displayName ?? clientId,
			redirect_uris: [clientId],
			grant_types: ['authorization_code', 'refresh_token'],
			token_endpoint_auth_method: 'none',
			response_types: ['code'],
			logo_uri: undefined,
			tos_uri: undefined,
		};
	}

	/** Whether a client_id could be a trigger resource URL (served under a webhook base URL). */
	private isTriggerResourceClientId(clientId: string): boolean {
		return [this.urlService.getWebhookBaseUrl(), this.urlService.getTestWebhookBaseUrl()]
			.map((base) => (base.endsWith('/') ? base : `${base}/`))
			.some((base) => clientId.startsWith(base));
	}

	private validateClientRegistration(client: OAuthClientInformationFull): void {
		if (!client.client_name) {
			throw new Error('client_name is required');
		}

		if (countCodePointsUpTo(client.client_name, MAX_CLIENT_NAME_LENGTH) > MAX_CLIENT_NAME_LENGTH) {
			throw new Error(`client_name exceeds maximum length of ${MAX_CLIENT_NAME_LENGTH} characters`);
		}

		if (!client.grant_types || client.grant_types.length === 0) {
			throw new Error('grant_types is required');
		}

		if (client.grant_types.length > MAX_GRANT_TYPES) {
			throw new Error(`grant_types exceeds maximum count of ${MAX_GRANT_TYPES}`);
		}

		for (const grantType of client.grant_types) {
			if (!SUPPORTED_GRANT_TYPES.includes(grantType)) {
				throw new Error('grant_types contains an unsupported value');
			}
		}

		if (!client.redirect_uris || client.redirect_uris.length === 0) {
			throw new Error('redirect_uris is required');
		}

		if (client.redirect_uris.length > MAX_REDIRECT_URIS) {
			throw new Error(`redirect_uris exceeds maximum count of ${MAX_REDIRECT_URIS}`);
		}

		for (const uri of client.redirect_uris) {
			if (uri.length > MAX_REDIRECT_URI_LENGTH) {
				throw new Error(
					`redirect_uri exceeds maximum length of ${MAX_REDIRECT_URI_LENGTH} characters`,
				);
			}
		}

		if (
			client.token_endpoint_auth_method !== undefined &&
			!SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS.includes(client.token_endpoint_auth_method)
		) {
			throw new Error('token_endpoint_auth_method contains an unsupported value');
		}
	}

	/**
	 * Checks a requested redirect URI against the configured allowlist.
	 *
	 * Non-loopback URIs must match an allowlist entry exactly. Loopback URIs
	 * (localhost / 127.0.0.1 / [::1]) match a loopback allowlist entry that
	 * shares the same scheme, host and path regardless of port: native clients
	 * bind an ephemeral port at request time, so the port cannot be known in
	 * advance (RFC 8252 §7.3).
	 */
	private isRedirectUriAllowed(allowedUris: string[], redirectUri: string): boolean {
		if (allowedUris.includes(redirectUri)) {
			return true;
		}

		let requested: URL;
		try {
			requested = new URL(redirectUri);
		} catch {
			return false;
		}

		if (!this.isLoopbackHost(requested.hostname)) {
			return false;
		}

		return allowedUris.some((allowed) => {
			let candidate: URL;
			try {
				candidate = new URL(allowed);
			} catch {
				return false;
			}

			return (
				this.isLoopbackHost(candidate.hostname) &&
				candidate.protocol === requested.protocol &&
				candidate.hostname === requested.hostname &&
				candidate.pathname === requested.pathname
			);
		});
	}

	private isLoopbackHost(hostname: string): boolean {
		return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
	}

	async authorize(
		client: OAuthClientInformationFull,
		params: AuthorizationParams,
		res: Response,
	): Promise<void> {
		this.logger.debug('Starting OAuth authorization', { clientId: client.client_id });

		try {
			const resource = await this.resolveAndValidateResourceIndicator(params.resource?.toString());

			const targetResource = resource
				? await this.resourceRegistry.getByResourceUrl(resource)
				: this.resourceRegistry.getDefaultResource();

			// An unavailable resource is hidden from RFC 9728 discovery, but a client
			// holding a cached resource URL skips discovery — reject it here so the
			// flow fails before the user is sent through login and consent.
			if (targetResource && (await this.isResourceUnavailable(targetResource))) {
				this.logger.warn('OAuth authorization rejected: target resource is unavailable', {
					clientId: client.client_id,
					resource: targetResource.getResourceUrl(),
				});
				res.status(400).json({
					error: 'invalid_target',
					error_description: 'Resource is not available for authorization',
				});
				return;
			}

			const allowedUris = (await targetResource?.getAllowedRedirectUris?.()) ?? [];
			if (allowedUris.length > 0 && !this.isRedirectUriAllowed(allowedUris, params.redirectUri)) {
				this.logger.warn(
					'MCP OAuth authorization rejected: requested redirect URI is not in the configured allowlist',
					{
						clientId: client.client_id,
						attemptedUri: params.redirectUri,
					},
				);
				res.status(400).json({
					error: 'invalid_request',
					error_description: 'Redirect URI not in allowed list',
				});
				return;
			}

			// Unknown requested scopes (e.g. `openid`) are dropped rather than
			// rejected — the user picks the effective scopes on the consent screen.
			const supportedScopes = targetResource?.scopes ?? [];
			const requestedScopes = params.scopes?.filter((scope) => supportedScopes.includes(scope));

			const sessionPayload: OAuthSessionPayload = {
				clientId: client.client_id,
				redirectUri: params.redirectUri,
				codeChallenge: params.codeChallenge,
				state: params.state ?? null,
				resource,
				...(requestedScopes && requestedScopes.length > 0 && { requestedScopes }),
			};

			const autoApproval = await this.tryAutoApproveConsent(res, sessionPayload, client);

			if (autoApproval) {
				res.redirect(autoApproval.redirectUrl);
				return;
			}

			this.oauthSessionService.createSession(res, sessionPayload);

			res.redirect('/oauth/consent');
		} catch (error) {
			if (error instanceof InvalidResourceIndicatorError) {
				this.logger.warn('Rejecting OAuth authorization request with invalid resource', {
					clientId: client.client_id,
					resource: error.resource,
					expectedResource: error.expectedResource,
				});
				this.oauthSessionService.clearSession(res);
				res.status(400).json({
					error: 'invalid_target',
					error_description: 'Invalid resource indicator',
				});
				return;
			}

			this.logger.error('Error in authorize method', { error, clientId: client.client_id });
			this.oauthSessionService.clearSession(res);
			res.status(500).json({ error: 'server_error', error_description: 'Internal server error' });
		}
	}

	async challengeForAuthorizationCode(
		client: OAuthClientInformationFull,
		authorizationCode: string,
	): Promise<string> {
		return await this.authorizationCodeService.getCodeChallenge(
			authorizationCode,
			client.client_id,
		);
	}

	async exchangeAuthorizationCode(
		client: OAuthClientInformationFull,
		authorizationCode: string,
		_codeVerifier?: string,
		redirectUri?: string,
		resource?: URL,
	): Promise<OAuthTokens> {
		const authRecord = await this.authorizationCodeService.findAuthorizationCode(
			authorizationCode,
			client.client_id,
			redirectUri,
		);

		if (!authRecord) {
			throw new InvalidGrantError('Invalid authorization code');
		}

		const resourceStr = resource?.toString();
		const tokenResource = await this.resolveAndValidateResourceIndicator(resourceStr);

		// RFC 8707 §2.2: the authorization request's resource applies to the whole grant, so
		// the token request may only name the resource the user approved. A code carrying no
		// resource was approved against the registry's default resource — that is what the
		// consent screen showed — so the token request may only name that one.
		const approvedResource =
			authRecord.resource ?? this.resourceRegistry.getDefaultResource()?.getResourceUrl();

		if (
			tokenResource &&
			!(
				approvedResource &&
				(await isSameProtectedResource(this.resourceRegistry, tokenResource, approvedResource))
			)
		) {
			throw new InvalidResourceIndicatorError(tokenResource, approvedResource ?? 'none');
		}

		await this.authorizationCodeService.markAuthorizationCodeAsUsed(authorizationCode);

		const grantedScopes = authRecord.scope;

		const { accessToken, refreshToken, audience } = this.tokenService.generateTokenPair(
			authRecord.userId,
			client.client_id,
			approvedResource,
			grantedScopes,
		);

		await this.tokenService.saveTokenPair(
			accessToken,
			refreshToken,
			client.client_id,
			authRecord.userId,
			grantedScopes,
			audience,
		);

		// Completion of the authorization-code grant is the point at which the user
		// has finished the OAuth flow for this client. The authorization server is
		// shared by every protected resource on the instance (MCP, forms, ...), so
		// only grants targeting the instance MCP server count as MCP usage.
		const grantedResource = approvedResource
			? await this.resourceRegistry.getByResourceUrl(approvedResource)
			: this.resourceRegistry.getDefaultResource();
		if (grantedResource?.id === INSTANCE_MCP_RESOURCE_ID) {
			this.eventService.emit('mcp-oauth-completed', {
				userId: authRecord.userId,
				clientId: client.client_id,
				clientName: client.client_name,
			});
		}

		return {
			access_token: accessToken,
			token_type: 'Bearer',
			expires_in: this.tokenService.getAccessTokenExpirySeconds(),
			refresh_token: refreshToken,
			// RFC 6749 §5.1: REQUIRED when the granted scopes differ from the
			// requested ones — the user picks them on the consent screen.
			scope: grantedScopes.join(' '),
		};
	}

	// `resource` (when present) is normalized and validated against the registry here, then
	// against the grant's own resource by the token service; if omitted, the token service
	// reuses the grant's resource. `_scopes` is part of the SDK contract but unused — OAuth
	// 2.1 refresh tokens reuse the original grant's scopes.
	async exchangeRefreshToken(
		client: OAuthClientInformationFull,
		refreshToken: string,
		_scopes?: string[],
		resource?: URL,
	): Promise<OAuthTokens> {
		const resourceStr = resource?.toString();
		return await this.tokenService.validateAndRotateRefreshToken(
			refreshToken,
			client.client_id,
			await this.resolveAndValidateResourceIndicator(resourceStr),
		);
	}

	async verifyAccessToken(token: string): Promise<AuthInfo> {
		return await this.tokenService.verifyAccessToken(token);
	}

	private async tryAutoApproveConsent(
		res: Response,
		sessionPayload: OAuthSessionPayload,
		client: OAuthClientInformationFull,
	): Promise<{ redirectUrl: string } | null> {
		const req = res.req;
		const cookie = this.authService.getCookieToken(req);
		if (cookie) {
			let user: User | undefined;

			try {
				user = await this.authService.authenticateUserByCookie(cookie);
			} catch (error) {
				this.logger.debug('Auto-approval failed: user not authenticated', {
					clientId: client.client_id,
				});
			}

			if (user) {
				const reuseResult = await this.oauthConsentService.tryReuseConsent(user, sessionPayload);
				if (reuseResult) {
					return { redirectUrl: reuseResult.redirectUrl };
				}
			}
		}

		return null;
	}

	// Resources without `isAvailable` are treated as always available.
	private async isResourceUnavailable(resource: ProtectedResource): Promise<boolean> {
		return !((await resource.isAvailable?.()) ?? true);
	}

	// Exact-match against a registered resource, as required by RFC 8707 §2.1.
	// Prefix/wildcard matching would open the door to malicious-host or
	// path-traversal indicators like ".../mcp-server/http/../admin".
	private async resolveAndValidateResourceIndicator(
		resource: string | undefined,
	): Promise<string | undefined> {
		if (resource === undefined) {
			return undefined;
		}

		const normalizedResource = resource.replace(/\/$/, '');
		const match = await this.resourceRegistry.getByResourceUrl(normalizedResource);
		if (!match) {
			const knownResources = this.resourceRegistry
				.getAll()
				.map((registered) => registered.getResourceUrl())
				.join(', ');
			throw new InvalidResourceIndicatorError(resource, knownResources);
		}

		// Keep the caller's spelling when it exactly names one of the resource's own
		// URLs — the MCP server publishes several, and a client reaching it through the
		// instance hostname must get the audience it asked for.
		//
		// Otherwise return the canonical URL. Lookup deliberately tolerates equivalent
		// spellings (a webhook's `?method=` query survives percent-encoding), and
		// echoing one of those back would mint an `aud` that the resource gate — which
		// compares against `getAudiences()` — can never match, leaving the client
		// holding a token that silently 401s forever.
		const declaredUrls = match.getResourceUrls?.() ?? [match.getResourceUrl()];
		const isDeclared = declaredUrls.some((url) => url.replace(/\/$/, '') === normalizedResource);

		return isDeclared ? normalizedResource : match.getResourceUrl();
	}

	async revokeToken(
		client: OAuthClientInformationFull,
		request: OAuthTokenRevocationRequest,
	): Promise<void> {
		const { token, token_type_hint } = request;

		if (!token_type_hint || token_type_hint === 'access_token') {
			const revoked = await this.tokenService.revokeAccessToken(token, client.client_id);
			if (revoked) {
				return;
			}
		}

		if (!token_type_hint || token_type_hint === 'refresh_token') {
			const revoked = await this.tokenService.revokeRefreshToken(token, client.client_id);
			if (revoked) {
				return;
			}
		}

		this.logger.debug('Token revocation requested for unknown token', {
			clientId: client.client_id,
		});
	}

	/**
	 * Get OAuth clients users have consented to (excluding sensitive data),
	 * together with the grant details of each consent. `ownership: 'all'`
	 * returns every user's consents with owner info and requires `mcp:manage`.
	 *
	 * Filters and pagination are applied in memory after loading the ownership's
	 * consents: the set is small (bounded by the instance client cap) and the
	 * type filter reuses the shared name-pattern matchers, which SQL can't
	 * express. `count` is the filtered total, `clients` the requested page.
	 */
	async getAllClients(
		user: User,
		options: ListConnectedClientsOptions = {},
	): Promise<{
		clients: ConnectedOAuthClient[];
		count: number;
		totals: ConnectedOAuthClientTotals;
		owners?: ConnectedOAuthClientOwner[];
	}> {
		const canSeeAll = hasGlobalScope(user, 'mcp:manage');
		const listAll = options.ownership === 'all';

		if (listAll && !canSeeAll) {
			throw new ForbiddenError('You are not allowed to list connected clients of other users');
		}

		// The `type` filter is a name-pattern match SQL can't express. Resolve it
		// to the matching client ids first — bounded by the registered client cap,
		// not the (client × user) consent set — so filtering and paging stay in SQL.
		let clientIds: string[] | undefined;
		if (options.type) {
			const registered = await this.oauthClientRepository.find({
				select: { id: true, name: true },
				where: { isFirstParty: false },
			});
			clientIds = registered
				.filter((client) => matchesTypeFilter(client.name, options.type!))
				.map((client) => client.id);
		}

		const { rows: consents, total } =
			clientIds?.length === 0
				? { rows: [], total: 0 }
				: await this.userConsentRepository.findConnectedClients({
						userId: listAll ? undefined : user.id,
						withOwner: listAll,
						name: options.name,
						ownerId: listAll ? options.ownerId : undefined,
						clientIds,
						connected: options.connected,
						now: Date.now(),
						skip: options.skip,
						take: options.take,
					});

		const clients: ConnectedOAuthClient[] = consents.map((consent) => {
			const { clientSecret, clientSecretExpiresAt, ...sanitizedClient } = consent.client;
			return {
				...sanitizedClient,
				// bigint columns come back as strings on Postgres
				grantedAt: Number(consent.grantedAt),
				scopes: consent.scope,
				...(listAll
					? {
							owner: {
								id: consent.user.id,
								firstName: consent.user.firstName ?? null,
								lastName: consent.user.lastName ?? null,
								email: consent.user.email,
							},
						}
					: {}),
			};
		});
		const count = total;

		// Owners and the tab totals reflect the unfiltered set, so they come from
		// dedicated counts rather than the filtered page above.
		const [consentOwners, mineCount, allCount] = await Promise.all([
			listAll ? this.userConsentRepository.findConsentOwners() : undefined,
			this.userConsentRepository.countConnectedConsents(user.id),
			canSeeAll ? this.userConsentRepository.countConnectedConsents() : undefined,
		]);
		const owners = consentOwners ? sortOwners(consentOwners) : undefined;
		const totals: ConnectedOAuthClientTotals = { mine: mineCount };
		if (allCount !== undefined) {
			totals.all = allCount;
		}

		return { clients, count, totals, owners };
	}

	/** Tool names each scope unlocks on this instance, for the clients list UI. */
	getInstanceScopeTools(): Record<string, string[]> | undefined {
		return this.resourceRegistry.getDefaultResource()?.getScopeTools?.();
	}

	/**
	 * Revoke a user's grant for a client: their consent, tokens, and
	 * authorization codes. Other users' grants for the same client are
	 * untouched. The client registration itself is garbage-collected once the
	 * last consent is gone, freeing a slot under the instance client cap.
	 * When a `revoker` other than the grant owner is given (admin revoke),
	 * the owner is notified by email.
	 */
	async deleteClient(clientId: string, userId: string, revoker?: User): Promise<void> {
		// First check if the client exists
		const client = await this.oauthClientRepository.findOne({
			where: { id: clientId },
		});

		if (!client) {
			throw new Error(`OAuth client with ID ${clientId} not found`);
		}

		// Verify the target user has a consent relationship with this client
		const consent = await this.userConsentRepository.findOne({
			where: { clientId, userId },
			relations: ['user'],
		});
		if (!consent) {
			throw new Error(`OAuth client with ID ${clientId} not found`);
		}

		this.logger.info('Revoking OAuth client access for user', { clientId, userId });

		// Independent deletes across separate tables; the GC step below only needs
		// the consent gone, so run them together rather than serially.
		await Promise.all([
			this.tokenService.revokeAllTokensForGrant(clientId, userId),
			this.authorizationCodeService.deleteForGrant(clientId, userId),
			this.userConsentRepository.delete({ clientId, userId }),
		]);

		// Garbage-collect the client only when no consents remain. One conditional
		// delete keeps it atomic: a concurrent authorization for the same client
		// either commits its consent first (NOT EXISTS keeps the client) or fails
		// cleanly on the FK instead of being silently cascade-deleted.
		const consentsTable = this.userConsentRepository.metadata.tableName;
		const result = await this.oauthClientRepository
			.createQueryBuilder()
			.delete()
			.from(OAuthClient)
			.where(
				`id = :clientId AND NOT EXISTS (SELECT 1 FROM ${consentsTable} WHERE "clientId" = :clientId)`,
				{ clientId },
			)
			.execute();

		if (result.affected && result.affected > 0) {
			this.logger.info('OAuth client deleted after last consent was revoked', {
				clientId,
				clientName: client.name,
			});
		}

		if (revoker && revoker.id !== userId) {
			this.mailer
				.notifyMcpClientRevoked({ clientName: client.name, owner: consent.user, revoker })
				.catch((e) => {
					this.logger.error('Failed to send MCP client revocation email', {
						clientId,
						ownerId: userId,
						error: e instanceof Error ? e.message : String(e),
					});
				});
		}
	}
}

// Per RFC 8707 §3.2 the error code MUST be 'invalid_target' (provided by InvalidTargetError's
// static errorCode). Don't change to 'invalid_resource': it isn't in the registered OAuth error
// set and compliant MCP clients will fail the negotiation.
class InvalidResourceIndicatorError extends InvalidTargetError {
	constructor(
		readonly resource: string,
		readonly expectedResource: string,
	) {
		super('Invalid resource indicator');
	}
}
