import {
	BLOCK_ACCESS_ASSIGNMENT,
	ProvisioningConfigDto,
	ProvisioningConfigPatchDto,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import {
	RoleRepository,
	RoleMappingRuleRepository,
	Settings,
	SettingsRepository,
	User,
	UserRepository,
	Role,
	ProjectRepository,
	ProjectRelation,
} from '@n8n/db';
import { OnPubSubEvent } from '@n8n/decorators';
import { Service } from '@n8n/di';
import { GLOBAL_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { Not, In } from '@n8n/typeorm';
import { InstanceSettings } from 'n8n-core';
import { jsonParse } from 'n8n-workflow';
import { ZodError } from 'zod';

import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { EventService } from '@/events/event.service';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { ProjectService } from '@/services/project.service.ee';
import { UserService } from '@/services/user.service';

import { PROVISIONING_PREFERENCES_DB_KEY } from './constants';
import { SsoAccessDeniedError } from './errors/sso-access-denied.error';
import { RoleMappingRuleService } from './role-mapping-rule.service.ee';
import type { RoleMappingConfig, ResolvedRoles, RoleResolverContext } from './role-resolver-types';
import { RoleResolverService } from './role-resolver.service.ee';

@Service()
export class ProvisioningService {
	private provisioningConfig: ProvisioningConfigDto;

	constructor(
		private readonly eventService: EventService,
		private readonly globalConfig: GlobalConfig,
		private readonly settingsRepository: SettingsRepository,
		private readonly projectRepository: ProjectRepository,
		private readonly projectService: ProjectService,
		private readonly roleRepository: RoleRepository,
		private readonly userRepository: UserRepository,
		private readonly userService: UserService,
		private readonly logger: Logger,
		private readonly publisher: Publisher,
		private readonly instanceSettings: InstanceSettings,
		private readonly roleMappingRuleRepository: RoleMappingRuleRepository,
		private readonly roleResolverService: RoleResolverService,
		private readonly roleMappingRuleService: RoleMappingRuleService,
	) {}

	async init() {
		this.provisioningConfig = await this.loadConfig();
	}

	async getConfig(): Promise<ProvisioningConfigDto> {
		if (!this.provisioningConfig) {
			this.provisioningConfig = await this.loadConfig();
		}

		return this.provisioningConfig;
	}

	async provisionInstanceRoleForUser(user: User, roleSlug: unknown) {
		if (!(await this.isInstanceRoleProvisioningEnabled())) {
			return;
		}

		const globalOwnerRoleSlug = GLOBAL_OWNER_ROLE_SLUG;

		const dbRole = await this.resolveClaimedInstanceRole(user, roleSlug);
		if (!dbRole) {
			return;
		}

		if (dbRole.slug === globalOwnerRoleSlug && user.role.slug !== globalOwnerRoleSlug) {
			this.logger.warn(
				`Skipping instance role provisioning. Cannot assign owner role: ${globalOwnerRoleSlug} to user: ${user.id}`,
				{ userId: user.id, roleSlug: dbRole.slug },
			);
			return;
		}

		/*
		 * If the user is changing from an owner to a non-owner role,
		 * we need to check if they are the last owner to avoid an instance losing its only owner
		 */
		if (user.role.slug === globalOwnerRoleSlug && dbRole.slug !== globalOwnerRoleSlug) {
			const otherOwners = await this.userRepository.count({
				where: { role: { slug: globalOwnerRoleSlug }, id: Not(user.id) },
			});

			if (otherOwners === 0) {
				this.logger.warn(
					`Skipping instance role provisioning. Cannot remove last owner role: ${globalOwnerRoleSlug} from user: ${user.id}`,
					{ userId: user.id, roleSlug: dbRole.slug },
				);
				return;
			}
		}

		// No need to update record if the role hasn't changed
		if (user.role.slug !== dbRole.slug) {
			await this.userService.changeUserRole(user, { newRoleName: dbRole.slug });

			this.eventService.emit('sso-user-instance-role-updated', {
				userId: user.id,
				role: dbRole.slug,
			});
		}
	}

	/**
	 * Resolves the role claim sent by the IdP to an assignable global role,
	 * applying the configured default condition when the claim is missing or
	 * unrecognised. Returns null when no role should be assigned.
	 */
	private async resolveClaimedInstanceRole(user: User, roleSlug: unknown): Promise<Role | null> {
		if (typeof roleSlug === 'string') {
			const dbRole = await this.roleRepository.findOne({ where: { slug: roleSlug } });
			if (dbRole?.roleType === 'global') {
				return dbRole;
			}
			this.logger.warn(
				dbRole
					? `Instance role claim ${roleSlug} is not a global role`
					: `A role matching the claimed slug ${roleSlug} was not found`,
				{ userId: user.id, roleSlug },
			);
		} else if (roleSlug !== undefined && roleSlug !== null) {
			this.logger.warn(
				`Invalid instance role claim: expected string, received ${typeof roleSlug}`,
				{ userId: user.id, roleSlug },
			);
		}

		const { defaultInstanceRole } = await this.getConfig();
		// Block access is enforced before login in assertSsoLoginAllowed and is never assigned
		if (!defaultInstanceRole || defaultInstanceRole === BLOCK_ACCESS_ASSIGNMENT) {
			return null;
		}

		const fallbackRole = await this.roleRepository.findOne({
			where: { slug: defaultInstanceRole },
		});
		if (fallbackRole?.roleType !== 'global') {
			this.logger.warn(
				`Skipping instance role provisioning. Default condition role ${defaultInstanceRole} is not an assignable global role`,
				{ userId: user.id },
			);
			return null;
		}

		this.logger.debug('Applying default condition instance role', {
			userId: user.id,
			role: fallbackRole.slug,
		});
		return fallbackRole;
	}

	/**
	 * @param projectIdToRole expected to be an array of strings like this:
	 * [
	 *   "<projectUuid>:<projectRoleDisplayName>",
	 *   "<projectUuid>:<projectRoleDisplayName>",
	 *   ...
	 * ]
	 */
	async provisionProjectRolesForUser(userId: string, projectIdToRoles: unknown): Promise<void> {
		if (!(await this.isProjectRolesProvisioningEnabled())) {
			return;
		}

		if (!Array.isArray(projectIdToRoles)) {
			this.logger.warn(
				`Skipping project role provisioning. Invalid projectIdToRole type: expected array, received ${typeof projectIdToRoles}`,
				{ userId, projectIdToRoles },
			);
			return;
		}

		let projectRoleMap: Record<string, string>;
		try {
			projectRoleMap = {};
			for (const entry of projectIdToRoles) {
				if (typeof entry !== 'string') {
					this.logger.warn(
						`Skipping invalid project role mapping entry. Expected string, received ${typeof entry}.`,
						{ userId, entry },
					);
					continue;
				}

				const [projectId, roleSlugSuffix] = entry.split(':');
				if (!projectId || !roleSlugSuffix) {
					this.logger.warn(
						`Skipping invalid project role mapping entry. Expected format "projectId:displayName", received "${entry}".`,
						{ userId, entry },
					);
					continue;
				}

				// This means that if the external SSO setup has two roles configured
				// for the same projectId, we only provision the one we see last in the sent list.
				projectRoleMap[projectId] = `project:${roleSlugSuffix}`;
			}
		} catch (error) {
			this.logger.warn(
				'Skipping project role provisioning. Failed to parse project to role mapping.',
				{ userId, projectIdToRoles },
			);
			return;
		}

		const projectIds = Object.keys(projectRoleMap);
		const roleSlugs = [...new Set(Object.values(projectRoleMap))];

		if (projectIds.length === 0) {
			return;
		}

		const [existingProjects, existingRoles] = await Promise.all([
			this.projectRepository.find({
				where: { id: In(projectIds), type: Not('personal') },
				select: ['id'],
			}),
			this.roleRepository.find({
				where: {
					slug: In(roleSlugs),
					roleType: 'project',
				},
				select: ['displayName', 'slug'],
			}),
		]);
		const existingProjectIds = new Set(existingProjects.map((project) => project.id));

		const validProjectToRoleMappings: Array<{ projectId: string; roleSlug: string }> = [];

		// populate validProjectToRoleMappings
		for (const [projectId, roleSlug] of Object.entries(projectRoleMap)) {
			if (!existingProjectIds.has(projectId)) {
				this.logger.warn(
					`Skipped provisioning project role for project with ID ${projectId}, because project does not exist or is a personal project.`,
					{ userId, projectId, roleSlug },
				);
				continue;
			}
			const role = existingRoles.find((role) => role.slug === roleSlug);
			if (!role) {
				this.logger.warn(
					`Skipping project role provisioning for role with slug ${roleSlug}, because role does not exist or is not specific to projects.`,
					{ userId, projectId, roleSlug },
				);
				continue;
			}

			validProjectToRoleMappings.push({ projectId, roleSlug: role.slug });
		}

		if (validProjectToRoleMappings.length === 0) {
			this.logger.warn(
				'Skipping project role provisioning altogether. No valid project to role mappings found.',
				{ userId, projectRoleMap },
			);
			return;
		}

		const currentlyAccessibleProjects = await this.projectRepository.find({
			where: {
				type: Not('personal'),
				projectRelations: {
					userId,
				},
			},
			relations: ['projectRelations'],
		});

		const validProjectIds = new Set(validProjectToRoleMappings.map((m) => m.projectId));
		const projectsToRemoveAccessFrom = currentlyAccessibleProjects.filter(
			(project) => !validProjectIds.has(project.id),
		);

		await this.projectRepository.manager.transaction(async (tx) => {
			for (const project of projectsToRemoveAccessFrom) {
				await tx.delete(ProjectRelation, { projectId: project.id, userId });
			}

			for (const { projectId, roleSlug } of validProjectToRoleMappings) {
				await this.projectService.addUser(projectId, { userId, role: roleSlug }, tx);
			}
		});

		this.eventService.emit('sso-user-project-access-updated', {
			projectsAdded: validProjectIds.size,
			projectsRemoved: projectsToRemoveAccessFrom.length,
			userId,
		});
	}

	async patchConfig(rawConfig: unknown): Promise<ProvisioningConfigDto> {
		let patchConfig: ProvisioningConfigPatchDto;

		try {
			patchConfig = ProvisioningConfigPatchDto.parse(rawConfig);
		} catch (error) {
			if (error instanceof ZodError) {
				throw new BadRequestError(error.message);
			}

			throw error;
		}

		const currentConfig = await this.getConfig();

		const supportedPatchFields = [
			'scopesProvisionInstanceRole',
			'scopesProvisionProjectRoles',
			'scopesName',
			'scopesInstanceRoleClaimName',
			'scopesProjectsRolesClaimName',
			'scopesUseExpressionMapping',
			'defaultInstanceRole',
		] as const;

		const { deleteProjectRules: explicitDeleteProjectRules, ...configPatch } = patchConfig;

		const updatedConfig: Record<string, unknown> = {
			...currentConfig,
			...configPatch,
		};

		for (const supportedPatchField of supportedPatchFields) {
			if (configPatch[supportedPatchField] === null) {
				delete updatedConfig[supportedPatchField];
			}
		}

		if (
			updatedConfig.scopesUseExpressionMapping &&
			(updatedConfig.scopesProvisionInstanceRole || updatedConfig.scopesProvisionProjectRoles)
		) {
			throw new BadRequestError(
				'Expression-based mapping and direct-claim provisioning cannot both be enabled at the same time.',
			);
		}

		ProvisioningConfigDto.parse(updatedConfig);

		await this.validateDefaultInstanceRole(updatedConfig.defaultInstanceRole);

		const previousProjectRoleManaged =
			currentConfig.scopesProvisionProjectRoles || currentConfig.scopesUseExpressionMapping;
		const newProjectRoleManaged =
			(updatedConfig.scopesProvisionProjectRoles as boolean) ||
			(updatedConfig.scopesUseExpressionMapping as boolean);
		const shouldDeleteProjectRules =
			explicitDeleteProjectRules === true || (previousProjectRoleManaged && !newProjectRoleManaged);

		let deletedProjectRulesCount = 0;

		await this.settingsRepository.manager.transaction(async (tx) => {
			await tx.getRepository(Settings).upsert(
				{
					key: PROVISIONING_PREFERENCES_DB_KEY,
					value: JSON.stringify(updatedConfig),
					loadOnStartup: true,
				},
				{ conflictPaths: ['key'] },
			);

			if (shouldDeleteProjectRules) {
				deletedProjectRulesCount = await this.roleMappingRuleService.deleteAllOfType('project', tx);
			}
		});

		if (shouldDeleteProjectRules) {
			this.eventService.emit('role-mapping-rules-bulk-deleted', {
				ruleType: 'project',
				count: deletedProjectRulesCount,
				reason: 'strategy-switch',
			});
		}

		this.provisioningConfig = await this.loadConfig();

		if (this.instanceSettings.isMultiMain) {
			await this.publisher.publishCommand({ command: 'reload-sso-provisioning-configuration' });
		}

		return await this.getConfig();
	}

	/** The default condition must be Block access or an assignable global role. */
	private async validateDefaultInstanceRole(value: unknown): Promise<void> {
		if (value === undefined || value === BLOCK_ACCESS_ASSIGNMENT) return;

		const role =
			typeof value === 'string'
				? await this.roleRepository.findOne({ where: { slug: value } })
				: null;

		if (!role || role.roleType !== 'global' || role.slug === GLOBAL_OWNER_ROLE_SLUG) {
			throw new BadRequestError(
				`defaultInstanceRole must be "${BLOCK_ACCESS_ASSIGNMENT}" or the slug of an assignable global role`,
			);
		}
	}

	@OnPubSubEvent('reload-sso-provisioning-configuration')
	async handleReloadSsoProvisioningConfiguration() {
		this.provisioningConfig = await this.loadConfig();
	}

	async loadConfigurationFromDatabase(): Promise<ProvisioningConfigDto | undefined> {
		const configFromDB = await this.settingsRepository.findByKey(PROVISIONING_PREFERENCES_DB_KEY);

		if (configFromDB) {
			try {
				const configValue = jsonParse<ProvisioningConfigDto>(configFromDB.value);

				return ProvisioningConfigDto.parse(configValue);
			} catch (error) {
				this.logger.warn(
					'Failed to load Provisioning configuration from database, falling back to default configuration.',

					{ error },
				);
			}
		}
		return undefined;
	}

	async loadConfig(): Promise<ProvisioningConfigDto> {
		const envProvidedConfig = ProvisioningConfigDto.parse(this.globalConfig.sso.provisioning);

		const dbProvidedConfig = await this.loadConfigurationFromDatabase();

		if (dbProvidedConfig) {
			return {
				...envProvidedConfig,
				...dbProvidedConfig,
			};
		}

		return envProvidedConfig;
	}

	async getInstanceRoleClaimName(): Promise<string | null> {
		if (!(await this.isInstanceRoleProvisioningEnabled())) {
			return null;
		}
		const provisioningConfig = await this.getConfig();
		return provisioningConfig.scopesInstanceRoleClaimName;
	}

	async getProjectsRolesClaimName(): Promise<string | null> {
		if (!(await this.isProjectRolesProvisioningEnabled())) {
			return null;
		}
		const provisioningConfig = await this.getConfig();
		return provisioningConfig.scopesProjectsRolesClaimName;
	}

	async isProvisioningEnabled(): Promise<boolean> {
		const provisioningConfig = await this.getConfig();
		return (
			provisioningConfig.scopesProvisionInstanceRole ||
			provisioningConfig.scopesProvisionProjectRoles
		);
	}

	private async isInstanceRoleProvisioningEnabled(): Promise<boolean> {
		const provisioningConfig = await this.getConfig();
		return provisioningConfig.scopesProvisionInstanceRole;
	}

	private async isProjectRolesProvisioningEnabled(): Promise<boolean> {
		const provisioningConfig = await this.getConfig();
		return provisioningConfig.scopesProvisionProjectRoles;
	}

	async isExpressionMappingEnabled(): Promise<boolean> {
		const provisioningConfig = await this.getConfig();
		return provisioningConfig.scopesUseExpressionMapping;
	}

	/**
	 * Expression mapping is a single global toggle covering both scopes, so it only marks a scope
	 * as managed when rules for that specific scope actually exist.
	 */
	private async hasRoleMappingRulesOfType(type: 'instance' | 'project'): Promise<boolean> {
		return (await this.roleMappingRuleRepository.count({ where: { type } })) > 0;
	}

	async isInstanceRoleManaged(): Promise<boolean> {
		if (await this.isInstanceRoleProvisioningEnabled()) return true;
		return (
			(await this.isExpressionMappingEnabled()) &&
			(await this.hasRoleMappingRulesOfType('instance'))
		);
	}

	async isProjectRoleManaged(): Promise<boolean> {
		if (await this.isProjectRolesProvisioningEnabled()) return true;
		return (
			(await this.isExpressionMappingEnabled()) && (await this.hasRoleMappingRulesOfType('project'))
		);
	}

	private async buildRoleMappingConfig(): Promise<RoleMappingConfig> {
		const dbRules = await this.roleMappingRuleRepository.find({
			relations: ['role', 'projects'],
			order: { order: 'ASC' },
		});

		const instanceRoleRules: RoleMappingConfig['instanceRoleRules'] = [];
		const projectRoleRules: RoleMappingConfig['projectRoleRules'] = [];

		for (const dbRule of dbRules) {
			if (dbRule.type === 'instance') {
				instanceRoleRules.push({
					id: dbRule.id,
					expression: dbRule.expression,
					role: dbRule.role.slug,
					enabled: true,
				});
			} else {
				for (const project of dbRule.projects) {
					projectRoleRules.push({
						id: `${dbRule.id}:${project.id}`,
						expression: dbRule.expression,
						role: dbRule.role.slug,
						projectId: project.id,
						enabled: true,
					});
				}
			}
		}

		const { defaultInstanceRole } = await this.getConfig();

		return {
			instanceRoleRules,
			projectRoleRules,
			fallbackInstanceRole: defaultInstanceRole ?? 'global:member',
		};
	}

	private async applyExpressionMappedRoles(
		user: User,
		resolved: ResolvedRoles,
		managed: { instanceRole: boolean; projectRoles: boolean },
	): Promise<void> {
		// Reconcile a scope only when its mapping rules exist — the same predicate as
		// isInstanceRoleManaged()/isProjectRoleManaged(). Otherwise using expression mapping for one
		// scope would revoke manually-assigned roles in the other scope on every SSO login.
		if (managed.instanceRole) {
			await this.applyExpressionMappedInstanceRole(user, resolved.instanceRole.role);
		}

		if (managed.projectRoles) {
			const projectRolesMap = new Map<string, string>();
			for (const [projectId, pr] of resolved.projectRoles) {
				projectRolesMap.set(projectId, pr.role);
			}
			await this.applyExpressionMappedProjectRoles(user.id, projectRolesMap);
		}
	}

	private async getPreviousProjectRoles(userId: string): Promise<Record<string, string>> {
		const projects = await this.projectRepository.find({
			where: { type: Not('personal'), projectRelations: { userId } },
			relations: ['projectRelations', 'projectRelations.role'],
		});
		const result: Record<string, string> = {};
		for (const project of projects) {
			const relation = project.projectRelations.find((r) => r.userId === userId);
			if (relation) {
				result[project.id] = relation.role.slug;
			}
		}
		return result;
	}

	private async applyExpressionMappedInstanceRole(
		user: User,
		instanceRoleSlug: string,
	): Promise<void> {
		// Block access is enforced before login in assertSsoLoginAllowed; it can
		// only reach this point if rules changed mid-login, and is never assigned.
		if (instanceRoleSlug === BLOCK_ACCESS_ASSIGNMENT) {
			return;
		}

		let dbRole: Role;
		try {
			dbRole = await this.roleRepository.findOneOrFail({ where: { slug: instanceRoleSlug } });
		} catch {
			this.logger.warn(
				`Expression mapping: skipping instance role, slug "${instanceRoleSlug}" not found`,
				{ userId: user.id },
			);
			return;
		}

		if (dbRole.roleType !== 'global') {
			this.logger.warn(
				`Expression mapping: skipping instance role, "${instanceRoleSlug}" is not a global role`,
				{ userId: user.id },
			);
			return;
		}

		if (dbRole.slug === GLOBAL_OWNER_ROLE_SLUG && user.role.slug !== GLOBAL_OWNER_ROLE_SLUG) {
			this.logger.warn(
				`Skipping instance role provisioning. Cannot assign owner role: ${GLOBAL_OWNER_ROLE_SLUG} to user: ${user.id}`,
				{ userId: user.id, roleSlug: GLOBAL_OWNER_ROLE_SLUG },
			);
			return;
		}

		if (user.role.slug === GLOBAL_OWNER_ROLE_SLUG && dbRole.slug !== GLOBAL_OWNER_ROLE_SLUG) {
			const otherOwners = await this.userRepository.count({
				where: { role: { slug: GLOBAL_OWNER_ROLE_SLUG }, id: Not(user.id) },
			});
			if (otherOwners === 0) {
				this.logger.warn(
					'Expression mapping: skipping instance role update, cannot demote last owner',
					{ userId: user.id },
				);
				return;
			}
		}

		if (user.role.slug !== dbRole.slug) {
			await this.userService.changeUserRole(user, { newRoleName: dbRole.slug });
			this.eventService.emit('sso-user-instance-role-updated', {
				userId: user.id,
				role: dbRole.slug,
			});
		}
	}

	private async applyExpressionMappedProjectRoles(
		userId: string,
		projectRoleMap: Map<string, string>,
	): Promise<void> {
		// Fetch existing access first so revocation always runs, even when projectRoleMap is empty
		const currentlyAccessibleProjects = await this.projectRepository.find({
			where: { type: Not('personal'), projectRelations: { userId } },
			relations: ['projectRelations'],
		});

		const validMappings: Array<{ projectId: string; roleSlug: string }> = [];

		if (projectRoleMap.size > 0) {
			const projectIds = [...projectRoleMap.keys()];
			const roleSlugs = [...new Set(projectRoleMap.values())];

			const [existingProjects, existingRoles] = await Promise.all([
				this.projectRepository.find({
					where: { id: In(projectIds), type: Not('personal') },
					select: ['id'],
				}),
				this.roleRepository.find({
					where: { slug: In(roleSlugs), roleType: 'project' },
					select: ['displayName', 'slug'],
				}),
			]);

			const existingProjectIds = new Set(existingProjects.map((p) => p.id));

			for (const [projectId, roleSlug] of projectRoleMap.entries()) {
				if (!existingProjectIds.has(projectId)) {
					this.logger.warn(
						`Expression mapping: skipping project ${projectId}, not found or is a personal project`,
						{ userId, projectId, roleSlug },
					);
					continue;
				}
				const role = existingRoles.find((r) => r.slug === roleSlug);
				if (!role) {
					this.logger.warn(
						`Expression mapping: skipping role "${roleSlug}", not found or not a project role`,
						{ userId, projectId, roleSlug },
					);
					continue;
				}
				validMappings.push({ projectId, roleSlug: role.slug });
			}
		}

		const validProjectIds = new Set(validMappings.map((m) => m.projectId));
		const projectsToRemoveAccessFrom = currentlyAccessibleProjects.filter(
			(p) => !validProjectIds.has(p.id),
		);

		if (projectsToRemoveAccessFrom.length === 0 && validMappings.length === 0) return;

		await this.projectRepository.manager.transaction(async (tx) => {
			for (const project of projectsToRemoveAccessFrom) {
				await tx.delete(ProjectRelation, { projectId: project.id, userId });
			}
			for (const { projectId, roleSlug } of validMappings) {
				await this.projectService.addUser(projectId, { userId, role: roleSlug }, tx);
			}
		});

		this.eventService.emit('sso-user-project-access-updated', {
			projectsAdded: validProjectIds.size,
			projectsRemoved: projectsToRemoveAccessFrom.length,
			userId,
		});
	}

	/**
	 * Denies an SSO login when role mapping resolves to "Block access".
	 * Must run before account creation and before the session is issued, so a
	 * blocked login creates no account and an existing user's account is left
	 * untouched — they are denied per-login, never deactivated.
	 */
	async assertSsoLoginAllowed(
		context: RoleResolverContext,
		instanceRoleClaim: unknown,
	): Promise<void> {
		if (await this.isExpressionMappingEnabled()) {
			// With no instance rules the default condition alone decides. Block is an
			// access policy and applies whenever configured; a role-valued default
			// is still only assigned while the scope is managed (rules exist), so
			// it cannot stomp manually-assigned roles.
			if (!(await this.hasRoleMappingRulesOfType('instance'))) {
				const { defaultInstanceRole } = await this.getConfig();
				if (defaultInstanceRole === BLOCK_ACCESS_ASSIGNMENT) {
					this.logger.warn('SSO login blocked by role mapping', {
						provider: context.$provider,
						matchedRuleId: null,
						isFallback: true,
					});
					throw new SsoAccessDeniedError();
				}
				return;
			}

			const config = await this.buildRoleMappingConfig();
			const resolved = await this.roleResolverService.resolveRoles(
				{ ...config, projectRoleRules: [] },
				context,
			);

			if (resolved.instanceRole.role === BLOCK_ACCESS_ASSIGNMENT) {
				this.logger.warn('SSO login blocked by role mapping', {
					provider: context.$provider,
					matchedRuleId: resolved.instanceRole.matchedRuleId,
					isFallback: resolved.instanceRole.isFallback,
				});
				throw new SsoAccessDeniedError();
			}
			return;
		}

		const config = await this.getConfig();
		if (!config.scopesProvisionInstanceRole) return;
		if (config.defaultInstanceRole !== BLOCK_ACCESS_ASSIGNMENT) return;

		if (!(await this.isAssignableGlobalRole(instanceRoleClaim))) {
			this.logger.warn('SSO login blocked: no recognised instance role claim', {
				provider: context.$provider,
			});
			throw new SsoAccessDeniedError();
		}
	}

	private async isAssignableGlobalRole(roleSlug: unknown): Promise<boolean> {
		if (typeof roleSlug !== 'string' || roleSlug.length === 0) return false;
		// Provisioning can never assign owner, so an owner claim does not count
		// as a role assignment and cannot bypass a Block default condition.
		if (roleSlug === GLOBAL_OWNER_ROLE_SLUG) return false;
		const role = await this.roleRepository.findOne({ where: { slug: roleSlug } });
		return role?.roleType === 'global';
	}

	async provisionExpressionMappedRolesForUser(
		user: User,
		context: RoleResolverContext,
	): Promise<void> {
		if (!(await this.isExpressionMappingEnabled())) return;

		const previousInstanceRole = user.role.slug;
		const previousProjectRoles = await this.getPreviousProjectRoles(user.id);

		const config = await this.buildRoleMappingConfig();
		const resolved = await this.roleResolverService.resolveRoles(config, context);

		// Only reconcile a scope whose mapping rules exist, matching the manual-management guards.
		const [instanceRolesManaged, projectRolesManaged] = await Promise.all([
			this.hasRoleMappingRulesOfType('instance'),
			this.hasRoleMappingRulesOfType('project'),
		]);

		this.logger.debug('SSO role resolution complete', {
			userId: user.id,
			provider: context.$provider,
			claimKeys: Object.keys(context.$claims ?? {}).sort(),
			matchedInstanceRuleId: resolved.instanceRole.matchedRuleId,
			isFallback: resolved.instanceRole.isFallback,
			matchedProjectRuleIds: [...resolved.projectRoles.values()].map((r) => r.matchedRuleId),
		});

		await this.applyExpressionMappedRoles(user, resolved, {
			instanceRole: instanceRolesManaged,
			projectRoles: projectRolesManaged,
		});

		const newInstanceRole = resolved.instanceRole;
		// Report only what was actually applied, so an unmanaged scope doesn't emit phantom changes.
		const projectRoles = projectRolesManaged
			? [...resolved.projectRoles.values()].map((pr) => {
					const prev = previousProjectRoles[pr.projectId] ?? null;
					return { ...pr, previousRole: prev, changed: prev !== pr.role };
				})
			: [];
		const removedProjectIds = projectRolesManaged
			? Object.keys(previousProjectRoles).filter((id) => !resolved.projectRoles.has(id))
			: [];

		this.eventService.emit('expression-mapping-roles-resolved', {
			userId: user.id,
			userEmail: user.email,
			provider: context.$provider,
			instanceRole: {
				...newInstanceRole,
				previousRole: previousInstanceRole,
				changed: instanceRolesManaged && newInstanceRole.role !== previousInstanceRole,
			},
			projectRoles,
			removedProjectIds,
		});
	}
}
