import {
	InvalidGrantError,
	InvalidTargetError,
} from '@modelcontextprotocol/sdk/server/auth/errors.js';
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
import { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
import { Logger } from '@n8n/backend-common';
import { Time } from '@n8n/constants';
import { TransactionRunner, User, UserRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import type { OAuthResourceGrant } from 'n8n-workflow';
import { UnexpectedError } from 'n8n-workflow';
import { randomBytes, randomUUID } from 'node:crypto';

import { JwtService } from '@/services/jwt.service';
import type {
	OAuthTokenVerifier,
	UserWithContext,
} from '@/services/oauth-token-verifier-proxy.service';
import type { ProtectedResource } from '@/services/protected-resource.registry';
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';

import { AccessTokenRepository } from './database/repositories/oauth-access-token.repository';
import { RefreshTokenRepository } from './database/repositories/oauth-refresh-token.repository';
import { AccessTokenNotFoundError, JWTVerificationError } from './oauth.errors';
import { authorizeAgainstGrant } from './resource-gate';
import { isSameProtectedResource } from './resource-identity';

/**
 * Manages the OAuth 2.1 token lifecycle for the shared OAuth server.
 * Generates, validates, rotates, and revokes access and refresh tokens.
 *
 * Registered as the `OAuthTokenVerifierProxy` provider on module init, so
 * protected-resource modules can verify tokens through the core proxy
 * without importing this module.
 */
@Service()
export class OAuthTokenService implements OAuthTokenVerifier {
	private readonly ACCESS_TOKEN_EXPIRY_SECONDS = 1 * Time.hours.toSeconds;
	private readonly REFRESH_TOKEN_EXPIRY_MS = 30 * Time.days.toMilliseconds;

	constructor(
		private readonly logger: Logger,
		private readonly jwtService: JwtService,
		private readonly userRepository: UserRepository,
		private readonly accessTokenRepository: AccessTokenRepository,
		private readonly refreshTokenRepository: RefreshTokenRepository,
		private readonly resourceRegistry: ProtectedResourceRegistry,
		private readonly txRunner: TransactionRunner,
		private readonly workflowFinderService: WorkflowFinderService,
	) {}

	getAccessTokenExpirySeconds(): number {
		return this.ACCESS_TOKEN_EXPIRY_SECONDS;
	}

	/**
	 * Mints an access/refresh pair. Returns the `audience` it resolved, so the
	 * caller can persist the grant's resource alongside the refresh token — the
	 * resolved value, not the (optional) request one, is what the token is bound to.
	 */
	generateTokenPair(
		userId: string,
		clientId: string,
		resource: string | undefined,
		scopes: string[],
	): { accessToken: string; refreshToken: string; audience: string } {
		// Pre-RFC-8707 clients omit the resource indicator; fall back to the
		// registry's default resource (the instance MCP server).
		const audience = resource ?? this.resourceRegistry.getDefaultResource()?.getResourceUrl();
		if (!audience) {
			throw new UnexpectedError(
				'Cannot mint an OAuth access token: no resource requested and no default protected resource is registered',
			);
		}

		const accessToken = this.jwtService.sign({
			sub: userId,
			aud: audience,
			client_id: clientId,
			jti: randomUUID(),
			iat: Math.floor(Date.now() / 1000),
			exp: Math.floor(Date.now() / 1000) + this.ACCESS_TOKEN_EXPIRY_SECONDS,
			// RFC 9068 space-delimited scope claim. Always present on new tokens
			// (empty string for scope-less grants), so an absent claim
			// unambiguously identifies a token minted before scoping shipped.
			scope: scopes.join(' '),
			meta: {
				isOAuth: true,
			},
		});

		const refreshToken = randomBytes(32).toString('hex');

		return { accessToken, refreshToken, audience };
	}

	async saveTokenPair(
		accessToken: string,
		refreshToken: string,
		clientId: string,
		userId: string,
		scopes: string[],
		resource: string,
	): Promise<void> {
		await this.txRunner.run({}, async (ctx) => {
			await this.accessTokenRepository.insertToken({ token: accessToken, clientId, userId }, ctx);
			await this.refreshTokenRepository.insertToken(
				{
					token: refreshToken,
					clientId,
					userId,
					expiresAt: Date.now() + this.REFRESH_TOKEN_EXPIRY_MS,
					scope: scopes,
					resource,
				},
				ctx,
			);
		});
	}

	/**
	 * Rotates a refresh token into a fresh pair, reissuing the grant's scopes and
	 * its resource. `resource`, when the client sends one, must name the resource
	 * the grant was approved for (RFC 8707 §2.2).
	 */
	async validateAndRotateRefreshToken(
		refreshToken: string,
		clientId: string,
		resource?: string,
	): Promise<OAuthTokens> {
		return await this.txRunner.run({}, async (ctx) => {
			const now = Date.now();

			const refreshTokenRecord = await this.refreshTokenRepository.findByToken(
				refreshToken,
				clientId,
				ctx,
			);

			// InvalidGrantError so the SDK token handler responds 400 invalid_grant (RFC 6749 §5.2)
			// instead of 500 server_error, letting clients fall back to re-authorization.
			if (!refreshTokenRecord) {
				throw new InvalidGrantError('Invalid refresh token');
			}

			// The resource approved at authorization bounds every later token request on
			// the grant (RFC 8707 §2.2), so a request naming another one is refused and a
			// request naming none reuses the grant's own — never the default resource.
			// Checked before the row is consumed, so a refused request leaves the refresh
			// token usable.
			const grantedResource = refreshTokenRecord.resource;
			if (
				resource &&
				!(await isSameProtectedResource(this.resourceRegistry, resource, grantedResource))
			) {
				this.logger.warn('Refresh token request denied: resource is outside the grant', {
					clientId,
					userId: refreshTokenRecord.userId,
				});
				throw new InvalidTargetError('Requested resource is not part of this grant');
			}

			const numAffected = await this.refreshTokenRepository.deleteValidByToken(
				refreshToken,
				clientId,
				now,
				ctx,
			);
			if (numAffected < 1) {
				throw new InvalidGrantError('Invalid refresh token');
			}

			const scopes = refreshTokenRecord.scope;

			// Minted from the stored resource, not the requested spelling, so the binding
			// cannot drift across rotations.
			const { accessToken, refreshToken: newRefreshToken } = this.generateTokenPair(
				refreshTokenRecord.userId,
				clientId,
				grantedResource,
				scopes,
			);

			await this.accessTokenRepository.insertToken(
				{ token: accessToken, clientId, userId: refreshTokenRecord.userId },
				ctx,
			);

			await this.refreshTokenRepository.insertToken(
				{
					token: newRefreshToken,
					clientId,
					userId: refreshTokenRecord.userId,
					expiresAt: now + this.REFRESH_TOKEN_EXPIRY_MS,
					scope: scopes,
					resource: grantedResource,
				},
				ctx,
			);

			this.logger.info('Refresh token rotated and new access token issued', {
				clientId,
				userId: refreshTokenRecord.userId,
			});

			return {
				access_token: accessToken,
				token_type: 'Bearer',
				expires_in: this.ACCESS_TOKEN_EXPIRY_SECONDS,
				refresh_token: newRefreshToken,
				scope: scopes.join(' '),
			};
		});
	}

	async verifyAccessToken(token: string, expectedAudience?: string): Promise<AuthInfo> {
		return await this.verifyTokenWithAudiences(
			token,
			await this.getAllowedAudiences(expectedAudience),
		);
	}

	private async verifyTokenWithAudiences(
		token: string,
		allowedAudiences: string[],
	): Promise<AuthInfo> {
		let decoded: unknown;

		try {
			decoded = this.verifyJwtWithAllowedAudiences(token, allowedAudiences);
		} catch (error) {
			throw new JWTVerificationError();
		}

		const clientId = this.getStringClaim(decoded, 'client_id');
		const userId = this.getStringClaim(decoded, 'sub');
		if (!clientId || !userId) {
			throw new JWTVerificationError();
		}

		const accessTokenRecord = await this.accessTokenRepository.findOne({
			where: { token },
		});

		if (!accessTokenRecord) {
			throw new AccessTokenNotFoundError();
		}

		return {
			token,
			clientId,
			scopes: this.parseScopeClaim(decoded),
			extra: {
				userId,
			},
		};
	}

	/**
	 * Scopes carried by an access token. Tokens always carry a `scope` claim
	 * (empty string for scope-less grants).
	 */
	private parseScopeClaim(decoded: unknown): string[] {
		const scopeClaim = this.getStringClaim(decoded, 'scope');

		// Migration 1784000000047 deleted every access token minted before
		// scoping shipped, so a claim-less token cannot legitimately occur.
		// Fail closed rather than granting anything.
		if (scopeClaim === null) {
			return [];
		}

		return scopeClaim === '' ? [] : scopeClaim.split(' ');
	}

	async verifyOAuthAccessToken(
		token: string,
		expectedAudience?: string,
		grant?: OAuthResourceGrant,
	): Promise<UserWithContext> {
		try {
			const resource = await this.getResourceByAudience(expectedAudience);

			// Fail closed: a token bearing a resource-scoped audience whose resource
			// can't be resolved (deleted, or a transient resolver failure the registry
			// swallows to `undefined`) must NOT bypass the authorize gate below.
			//
			// A sealed grant stands in for the resource rather than bypassing it — it is
			// minted by this instance only once this gate has passed, and supplies the
			// same audiences and execute check the resource would have.
			if (expectedAudience && !resource && !grant) {
				return { user: null, context: { reason: 'insufficient_scope', auth_type: 'oauth' } };
			}

			const authInfo = await this.verifyTokenWithAudiences(
				token,
				resource || !grant
					? this.audiencesForResource(resource, expectedAudience)
					: grant.audiences,
			);

			const userId =
				authInfo.extra && typeof authInfo.extra === 'object'
					? this.getStringClaim(authInfo.extra, 'userId')
					: null;
			if (!userId) {
				return { user: null, context: { reason: 'user_id_not_in_auth_info', auth_type: 'oauth' } };
			}

			const user = await this.userRepository.findOne({
				where: { id: userId },
				relations: ['role'],
			});

			if (!user) {
				return { user: null, context: { reason: 'user_not_found', auth_type: 'oauth' } };
			}

			if (!(await this.isAuthorized(user, resource, grant))) {
				this.logger.warn('OAuth token denied: user lacks execute access', {
					userId: user.id,
					expectedAudience,
				});
				return { user: null, context: { reason: 'insufficient_scope', auth_type: 'oauth' } };
			}

			// Handed back so a caller whose work outlives this resource can seal the gate it
			// was just admitted by.
			return {
				user,
				caller: { authType: 'oauth', clientId: authInfo.clientId },
				scopes: authInfo.scopes,
				grant: resource?.getGrant?.(),
			};
		} catch (error) {
			const errorForSure = ensureError(error);
			const reason =
				errorForSure instanceof JWTVerificationError
					? 'invalid_token'
					: errorForSure instanceof AccessTokenNotFoundError
						? 'token_not_found_in_db'
						: 'unknown_error';
			return {
				user: null,
				context: {
					reason,
					auth_type: 'oauth',
					error_details: errorForSure.message,
				},
			};
		}
	}

	/**
	 * Re-take a sealed grant's decision for a user, without the token. The sealed-identity
	 * credential path calls this on every resolve, so a revoked `workflow:execute` stops
	 * resolution mid-run. Loads the user with its role (a bare id lookup carries no scopes).
	 */
	async authorizeSealedGrant(userId: string, grant: OAuthResourceGrant): Promise<boolean> {
		const user = await this.userRepository.findOne({ where: { id: userId }, relations: ['role'] });
		if (!user || user.disabled) return false;
		return await authorizeAgainstGrant(this.workflowFinderService, grant, user);
	}

	/** Deletes every access and refresh token a user holds for a client. */
	async revokeAllTokensForGrant(clientId: string, userId: string): Promise<void> {
		await Promise.all([
			this.accessTokenRepository.delete({ clientId, userId }),
			this.refreshTokenRepository.delete({ clientId, userId }),
		]);
	}

	async revokeAccessToken(token: string, clientId: string): Promise<boolean> {
		const result = await this.accessTokenRepository.delete({
			token,
			clientId,
		});

		const revoked = (result.affected ?? 0) > 0;

		if (revoked) {
			this.logger.info('Access token revoked', { clientId });
		}

		return revoked;
	}

	async revokeRefreshToken(token: string, clientId: string): Promise<boolean> {
		const result = await this.refreshTokenRepository.delete({
			token,
			clientId,
		});

		const revoked = (result.affected ?? 0) > 0;

		if (revoked) {
			this.logger.info('Refresh token revoked', { clientId });
		}

		return revoked;
	}

	/**
	 * Resolve the `aud` values a token may carry for the given resource.
	 *
	 * Audiences come from the matching registered resource ONLY — never union
	 * audiences across resources, otherwise a token minted for one resource
	 * would pass another resource's gate (cross-resource token replay).
	 * Resource-specific legacy audiences (e.g. the instance MCP server's
	 * pre-RFC-8707 `mcp-server-api`) stay scoped to their own resource this way.
	 */
	private async getAllowedAudiences(expectedAudience?: string): Promise<string[]> {
		const resource = expectedAudience
			? await this.resourceRegistry.getByResourceUrl(expectedAudience)
			: undefined;
		return this.audiencesForResource(resource, expectedAudience);
	}

	/**
	 * Derive the `aud` values accepted for a (possibly pre-resolved) resource.
	 * Single source of the audience-derivation rule shared by `getAllowedAudiences`
	 * and the resolve-once path in `verifyOAuthAccessToken`.
	 */
	private audiencesForResource(
		resource: ProtectedResource | undefined,
		expectedAudience?: string,
	): string[] {
		if (expectedAudience) {
			return resource ? resource.getAudiences() : [expectedAudience];
		}

		// No expected audience: the caller cannot know which resource the token
		// targets (MCP SDK generic verification), so accept any registered
		// resource's audiences. Resource gates must pass `expectedAudience`.
		return this.resourceRegistry.getAllAudiences();
	}

	/**
	 * The resource's own gate while it resolves, otherwise the sealed grant — through the
	 * same function that gate is built from, so a grant can't allow more than it did.
	 */
	private async isAuthorized(
		user: User,
		resource: ProtectedResource | undefined,
		grant: OAuthResourceGrant | undefined,
	): Promise<boolean> {
		if (resource) return await resource.authorize(user);

		if (!grant) return true;

		return await authorizeAgainstGrant(this.workflowFinderService, grant, user);
	}

	private async getResourceByAudience(
		expectedAudience?: string,
	): Promise<ProtectedResource | undefined> {
		if (!expectedAudience) {
			return this.resourceRegistry.getDefaultResource();
		}
		return await this.resourceRegistry.getByResourceUrl(expectedAudience);
	}

	// TODO: drop legacy audiences and the per-audience fallback once all legacy
	// tokens minted before n8n v2.19 have aged out (refresh-token lifespan).
	private verifyJwtWithAllowedAudiences(token: string, audiences: string[]): unknown {
		try {
			return this.jwtService.verify(token, {
				audience: audiences as [string, ...string[]],
			});
		} catch (error) {
			// Some jsonwebtoken builds reject the array form for tokens signed with a single-string aud.
			for (const audience of audiences) {
				try {
					return this.jwtService.verify(token, { audience });
				} catch {
					continue;
				}
			}

			throw error;
		}
	}

	private getStringClaim(payload: unknown, claim: string): string | null {
		if (!payload || typeof payload !== 'object') return null;
		const claimValue = (payload as Record<string, unknown>)[claim];
		return typeof claimValue === 'string' ? claimValue : null;
	}
}
