import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize.js';
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register.js';
import { revocationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/revoke.js';
import { tokenHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/token.js';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import {
	createIpRateLimit,
	Get,
	Options,
	RootLevelController,
	StaticRouterMetadata,
} from '@n8n/decorators';
import { Container } from '@n8n/di';
import type { Response, Request, RequestHandler, Router } from 'express';

import type { ProtectedResource } from '@/services/protected-resource.registry';
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
import { UrlService } from '@/services/url.service';

import { OAuthServerConfig } from './oauth-server.config';
import { OAuthServerService } from './oauth-server.service';
import { buildOAuthClientLimitReachedMessage } from './oauth.errors';
import { OAuthHelpers } from './oauth.helpers';

const oauthServerService = Container.get(OAuthServerService);
const globalConfig = Container.get(GlobalConfig);
const oauthServerConfig = Container.get(OAuthServerConfig);
const logger = Container.get(Logger);
const urlService = Container.get(UrlService);

/**
 * Pre-check guard for the unauthenticated DCR endpoint. Short-circuits with
 * a structured `server_error` response when the instance is at the
 * registered-client cap. Returns HTTP 503 because limit exhaustion is a
 * temporary capacity condition, not an internal failure.
 *
 * The post-insert rollback in `enforceClientLimit` throws
 * `OAuthClientLimitReachedError` (a `ServerError` subclass) so the SDK
 * surfaces the same body shape on the rare race path; the SDK's register
 * handler hardcodes 500 for `ServerError`, so that path returns 500 with
 * an identical body.
 */
const oauthClientLimitGuard: RequestHandler = async (_req, res, next) => {
	if (await oauthServerService.isClientLimitReached()) {
		const limit = globalConfig.endpoints.mcpMaxRegisteredClients;
		logger.warn('OAuth client registration rejected: instance limit reached (pre-check)', {
			limit,
		});
		res.status(503).json({
			error: 'server_error',
			error_description: buildOAuthClientLimitReachedMessage(limit),
		});
		return;
	}
	next();
};

/**
 * The SDK's authorization handler redirects request-validation errors (e.g.
 * missing `code_challenge`) back to the client without the RFC 9207 `iss`
 * parameter, while our metadata advertises
 * `authorization_response_iss_parameter_supported`. Wrap `res.location`
 * (which `res.redirect` sets its target through) so every absolute-URL
 * redirect from the authorize route carries `iss` matching the advertised
 * issuer. Internal relative redirects (consent screen) pass through
 * untouched. Remove once `@modelcontextprotocol/sdk` ships
 * `authorizationHandler({ issuerUrl })` (on upstream main, unreleased as of
 * 1.29.0) and the catalog version is bumped past it.
 */
const rfc9207IssuerParam: RequestHandler = (_req, res, next) => {
	const originalLocation = res.location.bind(res);
	res.location = (url: string) =>
		originalLocation(OAuthHelpers.setIssuerParam(url, urlService.getInstanceBaseUrl()));
	next();
};

// Built once and mounted under both the legacy `/mcp-oauth/*` paths (existing
// DCR clients hold them in their stored discovery metadata) and the neutral
// `/oauth/*` paths that future, non-MCP protected resources will advertise.
const registerRouter = clientRegistrationHandler({
	clientsStore: oauthServerService.clientsStore,
}) as Router;
const authorizeRouter = authorizationHandler({ provider: oauthServerService }) as Router;
const tokenRouter = tokenHandler({ provider: oauthServerService }) as Router;
const revokeRouter = revocationHandler({ provider: oauthServerService }) as Router;

const sharedEndpointRouters = (basePath: '/mcp-oauth' | '/oauth'): StaticRouterMetadata[] => [
	{
		path: `${basePath}/register`,
		router: registerRouter,
		skipAuth: true,
		middlewares: [oauthClientLimitGuard],
		ipRateLimit: createIpRateLimit(
			oauthServerConfig.rateLimitRegister,
			5 * Time.minutes.toMilliseconds,
		),
	},
	{
		path: `${basePath}/authorize`,
		router: authorizeRouter,
		skipAuth: true,
		middlewares: [rfc9207IssuerParam],
		ipRateLimit: createIpRateLimit(
			oauthServerConfig.rateLimitAuthorize,
			5 * Time.minutes.toMilliseconds,
		),
	},
	{
		path: `${basePath}/token`,
		router: tokenRouter,
		skipAuth: true,
		ipRateLimit: createIpRateLimit(
			oauthServerConfig.rateLimitToken,
			5 * Time.minutes.toMilliseconds,
		),
	},
	{
		path: `${basePath}/revoke`,
		router: revokeRouter,
		skipAuth: true,
		ipRateLimit: createIpRateLimit(
			oauthServerConfig.rateLimitRevoke,
			5 * Time.minutes.toMilliseconds,
		),
	},
];

const discoverableResource = async (resource?: ProtectedResource) =>
	resource && ((await resource.isAvailable?.()) ?? true) ? resource : undefined;

const wellKnownIpRateLimit = createIpRateLimit(
	oauthServerConfig.rateLimitWellKnown,
	5 * Time.minutes.toMilliseconds,
);

@RootLevelController('/')
export class OAuthController {
	constructor(
		private readonly urlService: UrlService,
		private readonly resourceRegistry: ProtectedResourceRegistry,
	) {}

	// Add CORS headers for OAuth discovery endpoints
	private setCorsHeaders(res: Response) {
		// Allow requests from any origin for OAuth discovery
		res.header('Access-Control-Allow-Origin', '*');
		res.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
		res.header('Access-Control-Allow-Headers', 'Content-Type');
	}

	static routers: StaticRouterMetadata[] = [
		...sharedEndpointRouters('/mcp-oauth'),
		...sharedEndpointRouters('/oauth'),
	];

	@Options('/.well-known/oauth-authorization-server', {
		skipAuth: true,
		usesTemplates: true,
		ipRateLimit: wellKnownIpRateLimit,
	})
	metadataOptions(_req: Request, res: Response) {
		this.setCorsHeaders(res);
		res.status(204).end();
	}

	/**
	 * Single RFC 8414 authorization-server metadata document, shared by all
	 * protected resources: one issuer (the instance origin), one set of
	 * endpoints, one signing key.
	 *
	 * Keeps advertising the legacy `/mcp-oauth/*` endpoint paths — clients that
	 * registered via DCR persist these URLs, so changing them would strand
	 * every already-connected client.
	 */
	@Get('/.well-known/oauth-authorization-server', {
		skipAuth: true,
		usesTemplates: true,
		ipRateLimit: wellKnownIpRateLimit,
	})
	metadata(_req: Request, res: Response) {
		this.setCorsHeaders(res);

		const baseUrl = this.urlService.getInstanceBaseUrl();
		const allScopes = this.resourceRegistry.getAllScopes();
		const metadata: Record<string, unknown> = {
			issuer: baseUrl,
			authorization_endpoint: `${baseUrl}/mcp-oauth/authorize`,
			token_endpoint: `${baseUrl}/mcp-oauth/token`,
			registration_endpoint: `${baseUrl}/mcp-oauth/register`,
			revocation_endpoint: `${baseUrl}/mcp-oauth/revoke`,
			response_types_supported: ['code'],
			grant_types_supported: ['authorization_code', 'refresh_token'],
			token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
			code_challenge_methods_supported: ['S256'],
			// RFC 9207: we include the `iss` parameter on authorization responses
			authorization_response_iss_parameter_supported: true,
		};

		if (allScopes.length > 0) {
			metadata.scopes_supported = allScopes;
		}

		res.json(metadata);
	}

	@Options('/.well-known/oauth-authorization-server/*issuerPath', {
		skipAuth: true,
		usesTemplates: true,
		ipRateLimit: wellKnownIpRateLimit,
	})
	pathInsertedMetadataOptions(_req: Request, res: Response) {
		this.setCorsHeaders(res);
		res.status(204).end();
	}

	/**
	 * RFC 8414 path-inserted probes target issuers with a path component; this
	 * instance's issuer is the bare origin, so answer with a machine-readable
	 * 404 rather than letting the probe fall through to Express's HTML 404.
	 */
	@Get('/.well-known/oauth-authorization-server/*issuerPath', {
		skipAuth: true,
		usesTemplates: true,
		ipRateLimit: wellKnownIpRateLimit,
	})
	pathInsertedMetadata(_req: Request, res: Response) {
		this.setCorsHeaders(res);
		res.status(404).json({ message: 'Unknown authorization server' });
	}

	@Options('/.well-known/oauth-protected-resource/*resourcePath', {
		skipAuth: true,
		usesTemplates: true,
		ipRateLimit: wellKnownIpRateLimit,
	})
	protectedResourceMetadataOptions(_req: Request, res: Response) {
		this.setCorsHeaders(res);
		res.status(204).end();
	}

	/**
	 * RFC 9728 protected-resource metadata, resolved dynamically through the
	 * registry so any registered resource path is served by one route — the
	 * static instance MCP resource today, per-workflow resources later.
	 */
	@Get('/.well-known/oauth-protected-resource/*resourcePath', {
		skipAuth: true,
		usesTemplates: true,
		ipRateLimit: wellKnownIpRateLimit,
	})
	async protectedResourceMetadata(req: Request, res: Response) {
		this.setCorsHeaders(res);

		const resourcePath =
			'/' +
			(Array.isArray(req.params.resourcePath)
				? req.params.resourcePath.join('/')
				: req.params.resourcePath); // Wildcard params are captured as arrays

		// The wildcard param drops the query, which some resources use as a selector
		// (e.g. a webhook trigger's `?method=`), so forward it for the registry to route.
		const queryStart = req.originalUrl?.indexOf('?') ?? -1;
		const search = queryStart === -1 ? '' : req.originalUrl.slice(queryStart);

		const resource = await discoverableResource(
			await this.resourceRegistry.getByResourcePath(resourcePath + search),
		);
		if (!resource) {
			res.status(404).json({ message: 'Unknown protected resource' });
			return;
		}

		res.json(this.buildProtectedResourceMetadata(resource));
	}

	@Options('/.well-known/oauth-protected-resource', {
		skipAuth: true,
		usesTemplates: true,
		ipRateLimit: wellKnownIpRateLimit,
	})
	defaultProtectedResourceMetadataOptions(_req: Request, res: Response) {
		this.setCorsHeaders(res);
		res.status(204).end();
	}

	/**
	 * RFC 9728 protected-resource metadata for the bare `/.well-known/
	 * oauth-protected-resource` path (no resource-path suffix).
	 *
	 * Per RFC 9728 §3.1, a resource server whose resource identifier has no
	 * path component publishes its metadata at this exact well-known URI.
	 * We also serve it as a courtesy for clients that probe the origin-level
	 * document before (or instead of) the resource-scoped one advertised via
	 * `WWW-Authenticate: resource_metadata=...` — without this route, such a
	 * probe previously fell through to the SPA's catch-all handler, which
	 * answered with `200 text/html` instead of a clean `404`, breaking OAuth
	 * discovery for clients that don't special-case a non-JSON `200`.
	 *
	 * Resolves to this instance's default protected resource (today, always
	 * the single instance-wide MCP resource); returns 404 when no default is
	 * registered, or when the registered one is currently unavailable, so the
	 * caller can fall back to the resource-scoped URL.
	 */
	@Get('/.well-known/oauth-protected-resource', {
		skipAuth: true,
		usesTemplates: true,
		ipRateLimit: wellKnownIpRateLimit,
	})
	async defaultProtectedResourceMetadata(_req: Request, res: Response) {
		this.setCorsHeaders(res);

		const resource = await discoverableResource(this.resourceRegistry.getDefaultResource());
		if (!resource) {
			res.status(404).json({ message: 'Unknown protected resource' });
			return;
		}

		res.json(this.buildProtectedResourceMetadata(resource));
	}

	private buildProtectedResourceMetadata(resource: ProtectedResource): Record<string, unknown> {
		const baseUrl = this.urlService.getInstanceBaseUrl();
		const metadata: Record<string, unknown> = {
			resource: resource.getResourceUrl(),
			bearer_methods_supported: ['header'],
			authorization_servers: [baseUrl],
		};

		if (resource.scopes.length > 0) {
			metadata.scopes_supported = resource.scopes;
		}

		return metadata;
	}
}
