package com.airlock.iam.flow.shared.domain.model.airlock2fa;

import com.airlock.iam.airlock2fa.api.domain.model.Airlock2FAAccountId;
import com.airlock.iam.airlock2fa.api.domain.model.Airlock2FADeviceId;
import com.airlock.iam.authentication.api.domain.model.step.AuthenticationFlowStep;
import com.airlock.iam.authentication.api.domain.model.step.AuthenticationStepResult;
import com.airlock.iam.authentication.api.domain.model.step.AuthenticationStepResultBuilder;
import com.airlock.iam.authentication.api.domain.model.step.AuthenticationStepResultFactory;
import com.airlock.iam.base.api.domain.model.exception.FailureException;
import com.airlock.iam.base.api.domain.model.exception.ViolationException;
import com.airlock.iam.base.api.domain.model.plugin.LogicForConfig;
import com.airlock.iam.base.api.domain.model.user.UserId;
import com.airlock.iam.base.misc.log.LogUtil;
import com.airlock.iam.base.misc.result.Result;
import com.airlock.iam.base.misc.time.TimeProvider;
import com.airlock.iam.common.api.domain.model.authmethod.AuthenticationMethod;
import com.airlock.iam.common.api.domain.model.device.KnownDeviceTypes;
import com.airlock.iam.common.api.domain.model.device.usage.AuthenticationDeviceUsage;
import com.airlock.iam.common.api.domain.model.user.UserIdentity;
import com.airlock.iam.common.domain.model.user.PrincipalInfo;
import com.airlock.iam.core.domain.model.authentication.factor.KnownAuthenticationFactorDetail;
import com.airlock.iam.elog.api.EDefaultLogType;
import com.airlock.iam.elog.api.ELogger;
import com.airlock.iam.elog.api.ELoggerFactory;
import com.airlock.iam.factor.domain.model.airlock2fa.Airlock2FAAuthDevice;
import com.airlock.iam.factor.domain.model.airlock2fa.Airlock2FAAuthLogData;
import com.airlock.iam.factor.domain.model.airlock2fa.Airlock2FAAuthTokenId;
import com.airlock.iam.factor.domain.model.airlock2fa.Airlock2FADevices;
import com.airlock.iam.factor.domain.model.airlock2fa.Airlock2FAFactor;
import com.airlock.iam.factor.domain.model.airlock2fa.Airlock2FAFactorPriority;
import com.airlock.iam.factor.domain.model.airlock2fa.Airlock2FAPreAuthenticationData;
import com.airlock.iam.factor.domain.model.airlock2fa.Airlock2FAShortLivedOnlineQrCodeSettings;
import com.airlock.iam.factor.domain.model.airlock2fa.Airlock2FAUser;
import com.airlock.iam.factor.domain.model.airlock2fa.AuthenticationStatus;
import com.airlock.iam.factor.domain.model.airlock2fa.ImmediateAuthenticationAnswer;
import com.airlock.iam.factor.domain.model.airlock2fa.MobileOnlyDeferredMessageApprovalAnswer;
import com.airlock.iam.factor.domain.model.airlock2fa.MobileOnlyMessageApprovalAnswer;
import com.airlock.iam.factor.domain.model.airlock2fa.OfflineQrCodeDeferredMessageApprovalAnswer;
import com.airlock.iam.factor.domain.model.airlock2fa.OfflineQrCodeMessageApprovalAnswer;
import com.airlock.iam.factor.domain.model.airlock2fa.OneTouchAndOfflineQrCodeDeferredMessageApprovalAnswer;
import com.airlock.iam.factor.domain.model.airlock2fa.OneTouchAndOfflineQrCodeMessageApprovalAnswer;
import com.airlock.iam.factor.domain.model.airlock2fa.OneTouchDeferredMessageApprovalAnswer;
import com.airlock.iam.factor.domain.model.airlock2fa.OneTouchMessageApprovalAnswer;
import com.airlock.iam.factor.domain.model.airlock2fa.OnlineQrCodeDeferredMessageApprovalAnswer;
import com.airlock.iam.factor.domain.model.airlock2fa.OnlineQrCodeMessageApprovalAnswer;
import com.airlock.iam.factor.domain.model.airlock2fa.event.Airlock2FADeviceEventFactory;
import com.airlock.iam.factor.domain.model.airlock2fa.futurae.FuturaeErrorResponseException;
import com.airlock.iam.factor.domain.model.airlock2fa.otp.Airlock2FAOtp;
import com.airlock.iam.factor.domain.model.airlock2fa.otp.Airlock2FAPasscode;
import com.airlock.iam.factor.domain.model.airlock2fa.qrcode.Airlock2FAExchangeTokensAnswer;
import com.airlock.iam.factor.domain.model.airlock2fa.qrcode.Airlock2FAQrCodeUrl;
import com.airlock.iam.factor.domain.service.airlock2fa.Airlock2FAApprovalAuthTokenIdService;
import com.airlock.iam.factor.domain.service.airlock2fa.Airlock2FAApprovalService;
import com.airlock.iam.factor.domain.service.airlock2fa.Airlock2FAAuthenticationService;
import com.airlock.iam.factor.domain.service.airlock2fa.Airlock2FALogHelper;
import com.airlock.iam.factor.domain.service.airlock2fa.Airlock2FAPreAuthenticationService;
import com.airlock.iam.factor.domain.service.airlock2fa.AuthenticationStatusService;
import com.airlock.iam.factor.domain.service.airlock2fa.repository.Airlock2FAUserRepository;
import com.airlock.iam.flow.api.domain.model.step.GenericStepError;
import com.airlock.iam.flow.api.domain.model.step.StepAction;
import com.airlock.iam.flow.api.domain.model.step.StepResult;
import com.airlock.iam.flow.api.domain.model.step.StepSession;
import com.airlock.iam.flow.shared.api.domain.model.message.Airlock2FAMessage;
import com.airlock.iam.flow.shared.api.domain.model.message.Airlock2FAMessageProvider;
import com.airlock.iam.flow.shared.api.domain.model.message.Airlock2FAMessageProviderException;
import com.airlock.iam.flow.shared.application.configuration.airlock2fa.Airlock2FAApprovalStepConfig;
import com.airlock.iam.flow.shared.domain.service.PollingService;
import com.airlock.iam.log.api.helpdesk.HelpdeskLogger;
import com.airlock.iam.log.api.signing.SigningLogger;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import jakarta.annotation.Nonnull;
import jakarta.inject.Inject;
import jakarta.inject.Provider;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.function.Function;
import lombok.NonNull;

@LogicForConfig(Airlock2FAApprovalStepConfig.class)
public final class Airlock2FAApprovalStepImpl implements Airlock2FAApprovalStep, AuthenticationFlowStep, Airlock2FADeviceSelectionStep {
   private static final ELogger LOG = ELoggerFactory.getLogger(Airlock2FAApprovalStepImpl.class);
   private static final String APPROVAL_ACTION_DESCRIPTION = "approval";
   private static final String APP_APPROVAL_URL_ATTRIBUTE = "appApprovalUrl";
   private static final String DEVICE_CHOICES_ATTRIBUTE = "deviceChoices";
   private final Provider<PrincipalInfo> user;
   private final Provider<Airlock2FAApprovalService> service;
   private final Provider<Optional<Airlock2FAApprovalStepSession>> stepSession;
   private final Provider<Airlock2FAUserRepository> repository;
   private final Provider<AuthenticationMethod> authMethod;
   private final Provider<Airlock2FAMessageProvider> messageProvider;
   private final Provider<Optional<Airlock2FAApprovalAuthTokenIdService>> authTokenIdService;
   private final Provider<Airlock2FAApprovalStepSettings> settings;
   private final Provider<Airlock2FALogHelper> logHelper;
   private final Provider<AuthenticationStatusService> authenticationStatusService;
   private final HelpdeskLogger helpdeskLogger;
   private final SigningLogger signingLogger;
   private final Provider<Airlock2FAApprovalStep.Airlock2FAApprovalStepActions> stepActions;
   private final Provider<Airlock2FAApprovalStep.Airlock2FAApprovalStepErrors> stepErrors;
   private final TimeProvider timeProvider;
   private final Provider<Airlock2FADeviceEventFactory> eventFactory;
   private final PollingService<Airlock2FAApprovalStepSession, AuthenticationStepResult> pollingService;

   @Inject
   public Airlock2FAApprovalStepImpl(
      @Nonnull Provider<PrincipalInfo> user,
      @Nonnull Provider<Airlock2FAApprovalService> service,
      @Nonnull Provider<Optional<Airlock2FAApprovalStepSession>> stepSession,
      @Nonnull Provider<Airlock2FAUserRepository> repository,
      @Nonnull Provider<AuthenticationMethod> authMethod,
      @Nonnull Provider<Airlock2FAMessageProvider> messageProvider,
      @NonNull Provider<Optional<Airlock2FAApprovalAuthTokenIdService>> authTokenIdService,
      @Nonnull Provider<Airlock2FAApprovalStepSettings> settings,
      @Nonnull HelpdeskLogger helpdeskLogger,
      @Nonnull SigningLogger signingLogger,
      @Nonnull Provider<Airlock2FALogHelper> logHelper,
      @Nonnull Provider<AuthenticationStatusService> authenticationStatusService,
      @Nonnull Provider<Airlock2FAApprovalStep.Airlock2FAApprovalStepActions> stepActions,
      @Nonnull Provider<Airlock2FAApprovalStep.Airlock2FAApprovalStepErrors> stepErrors,
      TimeProvider timeProvider,
      Provider<Airlock2FADeviceEventFactory> eventFactory
   ) {
      if (authTokenIdService == null) {
         throw new NullPointerException("authTokenIdService is marked non-null but is null");
      }

      this.user = user;
      this.service = service;
      this.stepSession = stepSession;
      this.repository = repository;
      this.authMethod = authMethod;
      this.messageProvider = messageProvider;
      this.authTokenIdService = authTokenIdService;
      this.settings = settings;
      this.helpdeskLogger = helpdeskLogger;
      this.signingLogger = signingLogger;
      this.logHelper = logHelper;
      this.authenticationStatusService = authenticationStatusService;
      this.stepActions = stepActions;
      this.stepErrors = stepErrors;
      this.timeProvider = timeProvider;
      this.eventFactory = eventFactory;
      this.pollingService = new PollingService<>(Airlock2FAApprovalStepSession::getPollingCache, this::pollInternal, this::nextStayStepResult);
   }

   @Override
   public String getDescription() {
      return "Airlock 2FA Message Approval Step";
   }

   @Override
   public Optional<StepSession> createSession() {
      return Optional.of(Airlock2FAApprovalStepSession.emptySession());
   }

   @Override
   public AuthenticationStepResult initialize() {
      Optional<Airlock2FAUser> airlock2FAUser = ((Airlock2FAUserRepository)this.repository.get()).findBy(this.userId());
      if (airlock2FAUser.isEmpty()) {
         return this.failDueToNoValidToken();
      }

      Airlock2FAApprovalStepSession stepSession = this.stepSession();
      stepSession.setAccountId(airlock2FAUser.get().getAccountId());
      return this.generateAirlock2faMessage().mapResult(message -> this.preAuthenticate(stepSession, message)).orMapFailure(Function.identity());
   }

   private AuthenticationStepResult onPreAuthenticationAuthResult(
      Airlock2FAApprovalStepSession stepSession, Airlock2FAPreAuthenticationService.PreAuthenticationAnswer preAuthenticationAnswer, Airlock2FAMessage message
   ) {
      stepSession.initializeFactorsAndDevices(
         ((Airlock2FAApprovalStepSettings)this.settings.get()).deviceSettings(),
         preAuthenticationAnswer.getAllowedFactors(),
         preAuthenticationAnswer.getDevices()
      );
      Result<Airlock2FAAuthTokenId, Airlock2FAApprovalAuthTokenIdService.Airlock2FAAuthTokenIdFailure> authTokenIdResult = this.loadAuthTokenId();
      if (authTokenIdResult.containsFailure(Airlock2FAApprovalAuthTokenIdService.Airlock2FAAuthTokenIdFailure.INVALID_FORMAT)) {
         LOG.warn(EDefaultLogType.GENERIC_LOG_TYPE, "The format of the provided Auth Token ID is invalid.");
         return this.stepResultFactory().fail(GenericStepError.VALIDATION_FAILED).build();
      }

      Optional<Airlock2FAAuthTokenId> authTokenId = authTokenIdResult.peek();
      if (authTokenId.isPresent()) {
         if (((Airlock2FAApprovalStepSettings)this.settings.get()).deviceSettings().cooldownSettings().cooldownConfiguredAndRespected()
            && this.stepSession().isDeviceInCooldown(authTokenId.get().deviceId())) {
            LOG.warn(
               EDefaultLogType.GENERIC_LOG_TYPE,
               "The provided Auth Token ID corresponds to a device which is currently in cooldown and can not be used for approval."
            );
            return this.stepResultFactory().fail(GenericStepError.VALIDATION_FAILED).build();
         }

         if (!this.isAuthTokenIdConsistent(stepSession, authTokenId.get())) {
            LOG.warn(EDefaultLogType.GENERIC_LOG_TYPE, "The content of the provided Auth Token ID is invalid or inconsistent.");
            return this.stepResultFactory().fail(GenericStepError.VALIDATION_FAILED).build();
         }
      }

      authTokenId.ifPresent(tokenId -> stepSession.selectBasedOnAuthTokenId(tokenId, ((Airlock2FAApprovalStepSettings)this.settings.get()).deviceSettings()));
      return this.stepSession().remainingDevices().isEmpty() ? this.handleNoValidDevice() : this.approve(stepSession, message);
   }

   private AuthenticationStepResult handleNoValidDevice() {
      if (this.stepSession().cooldownDevices().isEmpty()) {
         this.logHelper()
            .log(
               String.format(
                  "IAM user %s with Airlock 2FA account %s does not have any applicable devices for factors: %s",
                  LogUtil.quote(this.userId()),
                  LogUtil.quote(this.stepSession().getAccountId().orElse(null)),
                  this.stepSession().getRemainingFactors()
               )
            );
         return this.failDueToNoValidToken();
      } else {
         this.logHelper()
            .log(
               String.format(
                  "IAM user %s with Airlock 2FA account %s has no enrolled devices outside the cooldown period.",
                  LogUtil.quote(this.userId()),
                  LogUtil.quote(this.stepSession().getAccountId().orElse(null))
               )
            );
         return this.stepResultFactory().fail(CommonAirlock2FAStepErrors.ALL_DEVICES_IN_COOLDOWN).isFailedFactor(false).build();
      }
   }

   private Result<Airlock2FAAuthTokenId, Airlock2FAApprovalAuthTokenIdService.Airlock2FAAuthTokenIdFailure> loadAuthTokenId() {
      return ((Optional)this.authTokenIdService.get())
         .map(Airlock2FAApprovalAuthTokenIdService::loadAuthTokenId)
         .orElse(Result.failure(Airlock2FAApprovalAuthTokenIdService.Airlock2FAAuthTokenIdFailure.NOT_PRESENT));
   }

   private AuthenticationStepResult approve(Airlock2FAApprovalStepSession stepSession, Airlock2FAMessage message) {
      return this.approveWithMobileOnly(stepSession, message)
         .or(() -> this.startApprovalWithOnlineQrCode(stepSession, message))
         .or(() -> this.startApprovalWithPushToAll(message))
         .or(() -> this.deviceChoiceRequired(stepSession))
         .or(() -> this.isPasscodeRequired(stepSession))
         .orElseGet(() -> this.startApprovalWithDevice(stepSession, message));
   }

   private Optional<AuthenticationStepResult> isPasscodeRequired(Airlock2FAApprovalStepSession stepSession) {
      return stepSession.topFactorEquals(Airlock2FAFactor.PASSCODE)
         ? Optional.of(this.stepResultFactory().stay(((Airlock2FAApprovalStep.Airlock2FAApprovalStepActions)this.stepActions.get()).passcodeRequired()).build())
         : Optional.empty();
   }

   private AuthenticationStepResult bypassSucceeded() {
      ((Airlock2FAApprovalStepSettings)this.settings.get()).deviceSettings().factorPriority().topFactor().ifPresent(this.stepSession()::setBypassedFactor);
      this.logHelper().logSuccessfulBypass();
      return this.stepResultFactory().success().build();
   }

   private boolean isAuthTokenIdConsistent(Airlock2FAApprovalStepSession stepSession, Airlock2FAAuthTokenId authTokenId) {
      return stepSession.remainingDevices()
         .deviceWithId(authTokenId.deviceId())
         .map(
            d -> d.isCapableOfFactor(
               authTokenId.loginFactor(), ((Airlock2FAApprovalStepSettings)this.settings.get()).deviceSettings().payloadEncryptionSettings()
            )
         )
         .orElse(false);
   }

   private Optional<AuthenticationStepResult> approveWithMobileOnly(Airlock2FAApprovalStepSession stepSession, Airlock2FAMessage message) {
      if (stepSession.topFactorEquals(Airlock2FAFactor.MOBILE_ONLY)) {
         return Optional.of(this.startMobileOnlyApproval(stepSession, message));
      } else {
         return ((Airlock2FAApprovalStepSettings)this.settings.get()).deviceSettings().factorPriority().containsExclusivelyMobileOnly()
            ? Optional.of(this.failDueToNoSelectableDevice())
            : Optional.empty();
      }
   }

   private Optional<AuthenticationStepResult> deviceChoiceRequired(Airlock2FAApprovalStepSession stepSession) {
      return stepSession.requiresDeviceSelection(((Airlock2FAApprovalStepSettings)this.settings.get()).pushToAllEnabled())
         ? Optional.of(
            this.stepResultFactory()
               .stay(((Airlock2FAApprovalStep.Airlock2FAApprovalStepActions)this.stepActions.get()).deviceChoiceRequired())
               .addAdditionalAttribute("deviceChoices", StepResultHelper.createAdditionalAttribute(stepSession.remainingDevices()))
               .build()
         )
         : Optional.empty();
   }

   private AuthenticationStepResult startMobileOnlyApproval(Airlock2FAApprovalStepSession stepSession, Airlock2FAMessage message) {
      stepSession.setExclusiveFactor(
         Airlock2FAFactor.MOBILE_ONLY, ((Airlock2FAApprovalStepSettings)this.settings.get()).deviceSettings().payloadEncryptionSettings()
      );
      MobileOnlyMessageApprovalAnswer answer = ((Airlock2FAApprovalService)this.service.get())
         .approveWithMobileOnly(stepSession.getAccountIdOrThrow(), ((Airlock2FAApprovalStepSettings)this.settings.get()).mobileOnlyRedirectUri(), message);
      if (answer.immediate().isPresent()) {
         return this.failWithImmediateAnswer(stepSession, answer.immediate().get());
      }

      MobileOnlyDeferredMessageApprovalAnswer deferredAuthenticationAnswer = answer.deferred().orElseThrow();
      this.saveToSession(stepSession, deferredAuthenticationAnswer);
      this.logHelper().logSessionStarted(Airlock2FAFactor.MOBILE_ONLY, this.logData());
      return this.stepResultFactory()
         .stay(((Airlock2FAApprovalStep.Airlock2FAApprovalStepActions)this.stepActions.get()).mobileOnlyChallengeRetrievalRequired())
         .addAdditionalAttribute("appApprovalUrl", stepSession.getMobileOnlyChallenge().orElseThrow().getAuthUri().uri())
         .build();
   }

   private Result<Airlock2FAMessage, AuthenticationStepResult> generateAirlock2faMessage() {
      try {
         return Result.result(((Airlock2FAMessageProvider)this.messageProvider.get()).generateAirlock2FAMessage());
      } catch (Airlock2FAMessageProviderException e) {
         return Result.failure(this.failDueToFailedMessageGeneration(e));
      }
   }

   private Optional<AuthenticationStepResult> startApprovalWithOnlineQrCode(Airlock2FAApprovalStepSession stepSession, Airlock2FAMessage message) {
      if (!stepSession.topFactorEquals(Airlock2FAFactor.ONLINE_QR_CODE)) {
         return Optional.empty();
      }

      Optional<Airlock2FAShortLivedOnlineQrCodeSettings> shortLivedOnlineQrCodeSettings = ((Airlock2FAApprovalStepSettings)this.settings.get())
         .shortLivedOnlineQrCodeSettings();
      Optional<Duration> sessionTimeout = shortLivedOnlineQrCodeSettings.map(Airlock2FAShortLivedOnlineQrCodeSettings::sessionTimeout);
      this.logHelper().log("Starting message approval with Online QR Code", this.logData());
      Airlock2FAAccountId accountId = stepSession.getAccountId().orElseThrow();
      OnlineQrCodeMessageApprovalAnswer answer = ((Airlock2FAApprovalService)this.service.get())
         .approveWithOnlineQrCode(accountId, ((Airlock2FAApprovalStepSettings)this.settings.get()).mobileOnlyRedirectUri(), message, sessionTimeout);
      if (answer.immediate().isPresent()) {
         return Optional.of(this.failWithImmediateAnswer(stepSession, answer.immediate().get()));
      }

      OnlineQrCodeDeferredMessageApprovalAnswer deferredAnswer = answer.deferred().orElseThrow();
      stepSession.setOnlineSession(deferredAnswer.sessionId(), Airlock2FAFactor.ONLINE_QR_CODE);
      this.logHelper().logSessionStarted(Airlock2FAFactor.ONLINE_QR_CODE, this.logData());
      if (shortLivedOnlineQrCodeSettings.isPresent()) {
         Instant approvalStartTime = this.timeProvider.getCurrentInstant();
         Airlock2FAExchangeTokensAnswer exchangeTokenAnswer = this.fetchExchangeTokens(accountId, deferredAnswer, shortLivedOnlineQrCodeSettings.get());
         stepSession.setSessionExchangeTokens(exchangeTokenAnswer.getExchangeTokens());
         stepSession.setSessionStartTime(approvalStartTime);
      } else {
         Airlock2FAOnlineQrCodeChallenge challenge = new Airlock2FAOnlineQrCodeChallenge(
            deferredAnswer.qrCodeImage(),
            deferredAnswer.mobileAuthUri().withScheme(((Airlock2FAApprovalStepSettings)this.settings.get()).mobileOnlySchemeOverride())
         );
         stepSession.setOnlineQrCodeChallenge(challenge);
      }

      return Optional.of(this.nextStayStepResult(stepSession));
   }

   private Airlock2FAExchangeTokensAnswer fetchExchangeTokens(
      Airlock2FAAccountId accountId, OnlineQrCodeDeferredMessageApprovalAnswer authAnswer, Airlock2FAShortLivedOnlineQrCodeSettings settings
   ) {
      return ((Airlock2FAApprovalService)this.service.get())
         .fetchAuthenticationExchangeTokens(
            accountId,
            authAnswer.getSessionId(),
            settings.tokenLifetime(),
            settings.exchangeTokenCount(),
            authAnswer.mobileAuthUri().getUriScheme(),
            authAnswer.mobileAuthUniversalLink().getDomain()
         );
   }

   private Optional<AuthenticationStepResult> startApprovalWithPushToAll(Airlock2FAMessage message) {
      if (this.stepSession().topFactorEquals(Airlock2FAFactor.ONE_TOUCH)
         && ((Airlock2FAApprovalStepSettings)this.settings.get()).pushToAllEnabled()
         && !this.loadAuthTokenId().isPresent()) {
         this.logHelper().log("Starting message approval with One-Touch (Push-to-All)", this.logData());
         return Optional.of(this.approveWithPushToAll(message));
      } else {
         return Optional.empty();
      }
   }

   private AuthenticationStepResult startApprovalWithDevice(Airlock2FAApprovalStepSession stepSession, Airlock2FAMessage message) {
      Airlock2FAFactorPriority factors = stepSession.getFactors();
      Airlock2FAAuthDevice device = stepSession.getOnlyDeviceOrThrow();
      if (factors.contains(Airlock2FAFactor.ONE_TOUCH) || factors.contains(Airlock2FAFactor.OFFLINE_QR_CODE)) {
         this.logHelper().log(String.format("Starting message approval with device %s", LogUtil.quote(device.getId())), this.logData());
      }

      if (factors.contains(Airlock2FAFactor.ONE_TOUCH) && factors.contains(Airlock2FAFactor.OFFLINE_QR_CODE)) {
         return this.approveWithOneTouchAndOfflineQrCode(stepSession, device, message);
      } else if (factors.contains(Airlock2FAFactor.ONE_TOUCH)) {
         return this.approveWithOneTouch(stepSession, device, message);
      } else if (factors.contains(Airlock2FAFactor.OFFLINE_QR_CODE)) {
         stepSession.switchToOfflineMode(((Airlock2FAApprovalStepSettings)this.settings.get()).deviceSettings().payloadEncryptionSettings());
         return this.approveWithOfflineQrCode(stepSession, device, message);
      } else if (factors.contains(Airlock2FAFactor.PASSCODE)) {
         stepSession.switchToOfflineMode(((Airlock2FAApprovalStepSettings)this.settings.get()).deviceSettings().payloadEncryptionSettings());
         return this.stepResultFactory().stay(((Airlock2FAApprovalStep.Airlock2FAApprovalStepActions)this.stepActions.get()).passcodeRequired()).build();
      } else {
         return this.failDueToNoValidToken();
      }
   }

   private AuthenticationStepResult preAuthenticate(Airlock2FAApprovalStepSession stepSession, Airlock2FAMessage message) {
      Airlock2FAAccountId accountId = stepSession.getAccountIdOrThrow();
      Airlock2FAPreAuthenticationService.PreAuthenticationAnswer answer = ((Airlock2FAApprovalService)this.service.get())
         .preAuthenticate(
            new Airlock2FAPreAuthenticationData(accountId, ((PrincipalInfo)this.user.get()).getKnownIdentity().orElseThrow().providedUsername().getValue())
         );
      this.logHelper().logPreAuthenticationAnswerDetails(answer);

      return switch (answer.getResult()) {
         case AUTH -> this.onPreAuthenticationAuthResult(stepSession, answer, message);
         case ALLOW -> {
            if (((Airlock2FAApprovalStepSettings)this.settings.get()).bypassModeEnabled()) {
               yield this.bypassSucceeded();
            } else {
               this.logHelper().logFailedDueToBypassMode("approval");
               yield this.stepResultFactory().fail(AuthenticationFlowStep.GeneralAuthenticationStepError.AUTHENTICATION_FAILED).build();
            }
         }
         case DENY -> {
            LOG.info("User with Airlock 2FA account " + accountId + " is either locked out on Futurae's server or has no enrolled devices.");
            this.logApprovalFailed(Optional.empty());
            yield this.stepResultFactory().fail(((Airlock2FAApprovalStep.Airlock2FAApprovalStepErrors)this.stepErrors.get()).approvalFailed()).build();
         }
         case UNKNOWN -> {
            LOG.info("User with Airlock 2FA account " + accountId + " is not recognized by Futurae's server.");
            yield this.stepResultFactory().fail(AuthenticationFlowStep.GeneralAuthenticationStepError.AUTHENTICATION_FAILED).build();
         }
      };
   }

   private AuthenticationStepResult approveWithOneTouchAndOfflineQrCode(
      Airlock2FAApprovalStepSession stepSession, Airlock2FAAuthDevice device, Airlock2FAMessage message
   ) {
      OneTouchAndOfflineQrCodeMessageApprovalAnswer answer = ((Airlock2FAApprovalService)this.service.get())
         .approveWithOneTouchAndOfflineQrCode(stepSession.getAccountIdOrThrow(), device, message);
      if (answer.immediate().isPresent()) {
         return this.failWithImmediateAnswer(stepSession, answer.immediate().get());
      }

      OneTouchAndOfflineQrCodeDeferredMessageApprovalAnswer deferredTransactionAuthenticationAnswer = answer.deferred().orElseThrow();
      this.saveToSession(stepSession, deferredTransactionAuthenticationAnswer);
      this.logHelper().logSessionStarted(Airlock2FAFactor.ONE_TOUCH, this.logData());
      return this.nextStayStepResult(stepSession);
   }

   private AuthenticationStepResult approveWithPushToAll(Airlock2FAMessage message) {
      OneTouchMessageApprovalAnswer answer = ((Airlock2FAApprovalService)this.service.get())
         .approveWithPushToAll(this.stepSession().getAccountIdOrThrow(), message);
      return this.handleOneTouchAnswer(this.stepSession(), answer);
   }

   private AuthenticationStepResult approveWithOneTouch(Airlock2FAApprovalStepSession stepSession, Airlock2FAAuthDevice device, Airlock2FAMessage message) {
      OneTouchMessageApprovalAnswer answer = ((Airlock2FAApprovalService)this.service.get())
         .approveWithOneTouch(stepSession.getAccountIdOrThrow(), device, message);
      return this.handleOneTouchAnswer(stepSession, answer);
   }

   private AuthenticationStepResult handleOneTouchAnswer(Airlock2FAApprovalStepSession stepSession, OneTouchMessageApprovalAnswer answer) {
      if (answer.immediate().isPresent()) {
         return this.failWithImmediateAnswer(stepSession, answer.immediate().get());
      }

      OneTouchDeferredMessageApprovalAnswer deferredTransactionAuthenticationAnswer = answer.deferred().orElseThrow();
      this.saveToSession(stepSession, deferredTransactionAuthenticationAnswer);
      this.logHelper().logSessionStarted(Airlock2FAFactor.ONE_TOUCH, this.logData());
      return this.nextStayStepResult(stepSession);
   }

   private AuthenticationStepResult failWithImmediateAnswer(Airlock2FAApprovalStepSession stepSession, ImmediateAuthenticationAnswer answer) {
      if (!stepSession.isAccountIdPresent()) {
         return this.failDueToMissingAirlock2FAAccountId();
      } else {
         Airlock2FAAccountId accountId = stepSession.getAccountIdOrThrow();
         if (answer.getResult() == ImmediateAuthenticationAnswer.ImmediateAuthenticationResult.ALLOW) {
            this.logHelper().logFailedDueToBypassMode("approval");
            return this.stepResultFactory().fail(((Airlock2FAApprovalStep.Airlock2FAApprovalStepErrors)this.stepErrors.get()).approvalFailed()).build();
         } else {
            return this.failDueToUnexpectedCall(
               String.format("User with Airlock 2FA id '%s' is either locked out on Futurae's server or has no enrolled devices.", accountId)
            );
         }
      }
   }

   private void saveToSession(Airlock2FAApprovalStepSession stepSession, OneTouchAndOfflineQrCodeDeferredMessageApprovalAnswer answer) {
      stepSession.setMultiNumberedChallenge(answer.getMultiNumberedChallenge());
      stepSession.setOnlineSession(answer.getSessionId(), Airlock2FAFactor.ONE_TOUCH);
      stepSession.setOfflineSession(answer.getOfflineSessionId());
      this.retrieveOfflineQrCodeAndSaveToSession(answer.getOfflineQrCodeUrl());
   }

   private void saveToSession(Airlock2FAApprovalStepSession stepSession, OneTouchDeferredMessageApprovalAnswer answer) {
      stepSession.setMultiNumberedChallenge(answer.getMultiNumberedChallenge());
      stepSession.setOnlineSession(answer.getSessionId(), Airlock2FAFactor.ONE_TOUCH);
   }

   private void saveToSession(Airlock2FAApprovalStepSession stepSession, MobileOnlyDeferredMessageApprovalAnswer answer) {
      stepSession.setOnlineSession(answer.sessionId(), Airlock2FAFactor.MOBILE_ONLY);
      stepSession.setMobileOnlyChallenge(
         new Airlock2FAMobileOnlyChallenge(answer.mobileAuthUri().withScheme(((Airlock2FAApprovalStepSettings)this.settings.get()).mobileOnlySchemeOverride()))
      );
   }

   private void saveToSession(Airlock2FAApprovalStepSession stepSession, OfflineQrCodeDeferredMessageApprovalAnswer answer) {
      stepSession.setOfflineSession(answer.getSessionId());
      this.retrieveOfflineQrCodeAndSaveToSession(answer.getOfflineQrCodeUrl());
   }

   private void retrieveOfflineQrCodeAndSaveToSession(Airlock2FAQrCodeUrl url) {
      Airlock2FAOfflineQrCodeChallenge challenge = new Airlock2FAOfflineQrCodeChallenge(
         ((Airlock2FAApprovalService)this.service.get()).retrieveOfflineQrCodeImage(this.stepSession().getAccountIdOrThrow(), url)
      );
      this.stepSession().setOfflineQrCodeChallenge(challenge);
   }

   public AuthenticationStepResult checkOtp(Airlock2FAOtp code) {
      Airlock2FAApprovalStepSession stepSession = this.stepSession();
      if (stepSession.isMobileOnlyApprovalOngoing()) {
         return this.failDueToMobileOnlyOngoing();
      }

      if (!stepSession.isAccountIdPresent()) {
         return this.failDueToMissingAirlock2FAAccountId();
      }

      if (!stepSession.isOfflineSessionPresent()) {
         return this.failDueToMissingOfflineSessionId();
      }

      if (!stepSession.isOfflineModeEnabled()) {
         return this.failDueToUnexpectedCall("offline authentication was not selected.");
      }

      Result<Airlock2FAAuthenticationService.ValidateOfflineQrCodeAnswer, FuturaeErrorResponseException> answer = ((Airlock2FAApprovalService)this.service
            .get())
         .validateOfflineQrCode(stepSession.getAccountIdOrThrow(), stepSession.getOfflineSessionOrThrow().getSessionId(), code);
      if (answer.isPresent()) {
         AuthenticationStepResult result = this.handleOfflineValidationResult(stepSession, answer.get());
         if (answer.get().getResult().isFinalResult()) {
            stepSession.clearOfflineSession();
         }

         return result;
      } else if (answer.getFailure().type() == FuturaeErrorResponseException.FuturaeErrorResponseType.BAD_REQUEST) {
         LOG.debug("Failed to check Offline QR Code OTP.", answer.getFailure());
         this.logApprovalFailed(Airlock2FAFactor.OFFLINE_QR_CODE);
         return this.stepResultFactory().fail(((Airlock2FAApprovalStep.Airlock2FAApprovalStepErrors)this.stepErrors.get()).qrCodeOtpWrong()).build();
      } else {
         throw new FailureException(
            "Could not validate Offline QR Code OTP given by user with Airlock 2FA account: " + stepSession.getAccountIdOrThrow(), answer.getFailure()
         );
      }
   }

   public AuthenticationStepResult retrieveOfflineQrCode() {
      if (this.stepSession().getPollingCache().hasResult()) {
         return this.stepResultFactory().failDueToUnexpectedCall("Call is not allowed after the final polling result is ready.").build();
      }

      Airlock2FAApprovalStepSession stepSession = this.stepSession();
      return stepSession.getOfflineQrCodeChallenge().isPresent()
         ? this.stepResultFactory().stay(this.nextStepAction(stepSession)).resultData(stepSession.getOfflineQrCodeChallenge().get()).build()
         : this.stepResultFactory().failDueToUnexpectedCall("No Offline QR Code authentication ongoing.").build();
   }

   public AuthenticationStepResult retrieveMobileOnlyChallenge() {
      if (this.stepSession().getPollingCache().hasResult()) {
         return this.stepResultFactory().failDueToUnexpectedCall("Call is not allowed after the final polling result is ready.").build();
      } else {
         Airlock2FAApprovalStepSession stepSession = this.stepSession();
         if (!stepSession.isMobileOnlyApprovalOngoing()) {
            return this.failDueToUnexpectedCall("No Mobile Only approval is ongoing.");
         } else {
            return stepSession.getMobileOnlyChallenge().isEmpty()
               ? this.failDueToUnexpectedCall("No Mobile Only challenge present")
               : this.stepResultFactory()
                  .stay(((Airlock2FAApprovalStep.Airlock2FAApprovalStepActions)this.stepActions.get()).pollingRequired())
                  .resultData(stepSession.getMobileOnlyChallenge().get())
                  .build();
         }
      }
   }

   public AuthenticationStepResult pollOnlineValidation(boolean queryOnly) {
      return this.pollingService.poll(this.stepSession(), queryOnly);
   }

   private AuthenticationStepResult pollInternal(Airlock2FAApprovalStepSession session) {
      if (!session.isAccountIdPresent()) {
         return this.failDueToMissingAirlock2FAAccountId();
      }

      if (!session.isOnlineSessionPresent()) {
         return this.failDueToMissingOnlineSessionId();
      }

      Airlock2FAAuthenticationService.AuthenticationStatusAnswer answer = ((Airlock2FAApprovalService)this.service.get())
         .checkTransactionAuthenticationStatus(session.getOnlineSessionOrThrow());
      AuthenticationStepResult result = this.handleStatusAnswer(session, answer);
      answer.getStatus().ifPresent(status -> ((AuthenticationStatusService)this.authenticationStatusService.get()).handleStatus(status));
      if (answer.getResult().isFinalResult()) {
         session.clearOnlineSession();
      }

      return result;
   }

   private AuthenticationStepResult handleStatusAnswer(
      Airlock2FAApprovalStepSession stepSession, Airlock2FAAuthenticationService.AuthenticationStatusAnswer answer
   ) {
      this.logHelper().logAuthenticationAnswerDetails(answer, this.logData());
      switch (answer.getResult()) {
         case ALLOW:
            Airlock2FADeviceId usedDeviceId = answer.getDeviceId()
               .orElseThrow(() -> new FailureException("Expected device ID to be present, but it's missing"));
            Optional<AuthenticationStepResult> failureResult = this.validateCooldownPeriod(usedDeviceId);
            if (failureResult.isPresent()) {
               return failureResult.get();
            }

            this.stepSession()
               .selectDeviceById(usedDeviceId, ((Airlock2FAApprovalStepSettings)this.settings.get()).deviceSettings().payloadEncryptionSettings());
            Airlock2FAAuthDevice device = stepSession.remainingDevices().deviceWithId(usedDeviceId).orElseThrow();
            this.logHelper()
               .logSuccess(
                  stepSession.topFactor().orElseThrow(),
                  device,
                  "approval",
                  this.logData().withUserPresenceVerificationIfPresent(answer.getUserPresenceVerification())
               );
            return this.stepResultFactory().success().deviceUsageData(this.deviceUsageData(device)).build();
         case DENY:
         case TIMEOUT:
            this.logApprovalFailed(stepSession.topFactor());
            return this.stepResultFactory().fail(((Airlock2FAApprovalStep.Airlock2FAApprovalStepErrors)this.stepErrors.get()).approvalFailed()).build();
         case WAITING:
            return this.nextStayStepResult(stepSession);
         default:
            throw new IllegalStateException("Unknown authentication status result " + answer.getResult());
      }
   }

   private Airlock2FAAuthLogData logData() {
      return this.stepSession().getLogData();
   }

   private void publishDeviceInCooldownUsedEvent(UserId userId, Airlock2FAAccountId accountId, Airlock2FADeviceId airlock2FADeviceId) {
      ((Airlock2FADeviceEventFactory)this.eventFactory.get()).publishAirlock2FADeviceInCooldownUsed(userId, accountId, airlock2FADeviceId);
   }

   private AuthenticationStepResult nextStayStepResult(Airlock2FAApprovalStepSession stepSession) {
      AuthenticationStepResultBuilder resultBuilder = this.stepResultFactory().stay(this.nextStepAction(stepSession));
      if (stepSession.topFactorEquals(Airlock2FAFactor.ONE_TOUCH)) {
         stepSession.getMultiNumberedChallenge().ifPresent(challenge -> StepResultHelper.addMultiNumberedChallenge(resultBuilder, challenge));
      } else if (stepSession.topFactorEquals(Airlock2FAFactor.ONLINE_QR_CODE)) {
         Optional<Airlock2FAShortLivedOnlineQrCodeSettings> shortLivedOnlineQrCodeSettings = ((Airlock2FAApprovalStepSettings)this.settings.get())
            .shortLivedOnlineQrCodeSettings();
         if (shortLivedOnlineQrCodeSettings.isPresent()) {
            Instant currentTime = this.timeProvider.getCurrentInstant();
            if (StepResultHelper.hasSessionTimeout(stepSession.getSessionStartTime(), currentTime, shortLivedOnlineQrCodeSettings.get().sessionTimeout())) {
               return this.stepResultFactory().fail(((Airlock2FAApprovalStep.Airlock2FAApprovalStepErrors)this.stepErrors.get()).approvalFailed()).build();
            }

            StepResultHelper.addAuthenticationShortLivedQrCodeChallenge(
               resultBuilder, stepSession.getSessionExchangeTokens(), currentTime, shortLivedOnlineQrCodeSettings.get().validityOverlap()
            );
         } else if (stepSession.getOnlineQrCodeChallenge().isPresent()) {
            StepResultHelper.addOnlineQrCodeChallenge(resultBuilder, stepSession.getOnlineQrCodeChallenge().get());
         }
      } else if (stepSession.topFactorEquals(Airlock2FAFactor.OFFLINE_QR_CODE)) {
         stepSession.getOfflineQrCodeChallenge().ifPresent(challenge -> StepResultHelper.addOfflineQrCodeChallenge(resultBuilder, challenge));
      } else if (stepSession.topFactorEquals(Airlock2FAFactor.MOBILE_ONLY)) {
         stepSession.getMobileOnlyChallenge().ifPresent(challenge -> resultBuilder.addAdditionalAttribute("appApprovalUrl", challenge.getAuthUri().uri()));
      }

      return resultBuilder.build();
   }

   private AuthenticationStepResult handleOfflineValidationResult(
      Airlock2FAApprovalStepSession stepSession, Airlock2FAAuthenticationService.ValidateOfflineQrCodeAnswer answer
   ) {
      switch (answer.getResult()) {
         case ALLOW:
            Airlock2FADeviceId usedDeviceId = answer.getDeviceId()
               .orElseThrow(() -> new FailureException("Expected device ID to be present in answer \"allow\"."));
            Optional<AuthenticationStepResult> failureResult = this.validateCooldownPeriod(usedDeviceId);
            if (failureResult.isPresent()) {
               return failureResult.get();
            } else {
               Airlock2FAAuthDevice sessionDevice = stepSession.getOnlyDeviceOrThrow();
               if (!sessionDevice.getId().equals(usedDeviceId)) {
                  throw new FailureException("Inconsistent device Id between IAM and Futurae.");
               }

               this.logHelper().logSuccess(Airlock2FAFactor.OFFLINE_QR_CODE, sessionDevice, "approval", this.logData());
               return this.stepResultFactory().success().deviceUsageData(this.deviceUsageData(sessionDevice)).build();
            }
         case WAITING:
            this.logHelper().log("Airlock 2FA Offline QR Code OTP validation failed. Retry possible.", this.logData());
            AuthenticationStepResultBuilder builder = this.stepResultFactory()
               .failRetry(((Airlock2FAApprovalStep.Airlock2FAApprovalStepErrors)this.stepErrors.get()).qrCodeOtpWrong(), this.nextStepAction(stepSession));
            stepSession.getOfflineQrCodeChallenge().ifPresent(challenge -> StepResultHelper.addOfflineQrCodeChallenge(builder, challenge));
            return builder.build();
         case DENY:
            this.logApprovalFailed(Airlock2FAFactor.OFFLINE_QR_CODE);
            return this.stepResultFactory().fail(((Airlock2FAApprovalStep.Airlock2FAApprovalStepErrors)this.stepErrors.get()).approvalFailed()).build();
         default:
            throw new IllegalStateException("Unknown authentication status result " + answer.getResult());
      }
   }

   private Optional<AuthenticationStepResult> validateCooldownPeriod(Airlock2FADeviceId usedDeviceId) {
      if (this.stepSession().isDeviceInCooldown(usedDeviceId)) {
         if (((Airlock2FAApprovalStepSettings)this.settings.get()).deviceSettings().cooldownSettings().cooldownConfiguredAndRespected()) {
            ((Airlock2FALogHelper)this.logHelper.get()).logFailedDueToCooldownPeriod("approval", usedDeviceId);
            return Optional.of(
               this.stepResultFactory()
                  .fail(((Airlock2FAApprovalStep.Airlock2FAApprovalStepErrors)this.stepErrors.get()).approvalFailed())
                  .isFailedFactor(false)
                  .build()
            );
         }

         this.publishDeviceInCooldownUsedEvent(this.userId(), this.stepSession().getAccountIdOrThrow(), usedDeviceId);
      }

      if (!this.stepSession().isDeviceAllowed(usedDeviceId)) {
         throw new FailureException("No device with this id present.");
      } else {
         return Optional.empty();
      }
   }

   private void logApprovalFailed(Airlock2FAFactor factor) {
      this.logApprovalFailed(Optional.of(factor));
   }

   private void logApprovalFailed(Optional<Airlock2FAFactor> factor) {
      String factorName = factor.map(Airlock2FAFactor::getDisplayName).orElse("-");
      this.signingLogger.log(String.format("Airlock 2FA approval (%s) failed for user %s %s", factorName, LogUtil.quote(this.userId()), this.logData()));
      this.helpdeskLogger.log(this.userId(), "Airlock 2FA approval (" + factorName + ") failed " + this.logData());
   }

   private StepAction nextStepAction(Airlock2FAApprovalStepSession stepSession) {
      Airlock2FAApprovalStep.Airlock2FAApprovalStepActions actions = (Airlock2FAApprovalStep.Airlock2FAApprovalStepActions)this.stepActions.get();
      Airlock2FAFactor factor = stepSession.topFactor().orElseThrow();

      return switch (factor) {
         case ONE_TOUCH, ONLINE_QR_CODE -> stepSession.canEnableOfflineMode() ? actions.pollingOrOfflineRequired() : actions.pollingRequired();
         case OFFLINE_QR_CODE -> actions.qrCodeOtpRequired();
         case MOBILE_ONLY -> actions.pollingRequired();
         case PASSCODE -> actions.passcodeRequired();
         case USERNAMELESS_QR_CODE -> throw new FailureException(factor.getDisplayName() + " is not a supported approval factor");
      };
   }

   public AuthenticationStepResult switchToOffline() {
      Airlock2FAApprovalStepSession stepSession = this.stepSession();
      if (!stepSession.isAccountIdPresent()) {
         return this.failDueToMissingAirlock2FAAccountId();
      }

      if (stepSession.isMobileOnlyApprovalOngoing()) {
         return this.failDueToMobileOnlyOngoing();
      }

      if (!stepSession.canEnableOfflineMode()) {
         return this.failDueToUnexpectedCall("Offline mode cannot be enabled due to currently allowed factors and/or device capabilities.");
      }

      if (stepSession.isOnlineSessionPresent()) {
         Airlock2FAAuthenticationService.AuthenticationStatusAnswer unconsumedFinalstatus = ((Airlock2FAApprovalService)this.service.get())
            .checkTransactionAuthenticationStatus(stepSession.getOnlineSessionOrThrow());
         if (unconsumedFinalstatus.getResult().isFinalResult()) {
            stepSession.clearOnlineSession();
         }

         this.cancelOnlineSession(stepSession);
         if (unconsumedFinalstatus.getResult().isFinalResult()) {
            return this.resultWithUnconsumedFinalOnlineSessionResult(stepSession, unconsumedFinalstatus);
         }
      }

      stepSession.switchToOfflineMode(((Airlock2FAApprovalStepSettings)this.settings.get()).deviceSettings().payloadEncryptionSettings());
      return stepSession.isOfflineSessionPresent()
         ? this.nextStayStepResult(stepSession)
         : this.generateAirlock2faMessage().mapResult(message -> this.approve(stepSession, message)).orMapFailure(Function.identity());
   }

   private AuthenticationStepResult resultWithUnconsumedFinalOnlineSessionResult(
      Airlock2FAApprovalStepSession stepSession, Airlock2FAAuthenticationService.AuthenticationStatusAnswer unconsumedFinalStatus
   ) {
      LOG.info(
         String.format(
            "User with Airlock 2FA id '%s' already approved or rejected the transaction using the previously sent Push notification. Code verification is therefore skipped.",
            stepSession.getAccountIdOrThrow()
         )
      );
      return this.handleStatusAnswer(stepSession, unconsumedFinalStatus);
   }

   private AuthenticationStepResult approveWithOfflineQrCode(Airlock2FAApprovalStepSession stepSession, Airlock2FAAuthDevice device, Airlock2FAMessage message) {
      stepSession.setExclusiveFactor(
         Airlock2FAFactor.OFFLINE_QR_CODE, ((Airlock2FAApprovalStepSettings)this.settings.get()).deviceSettings().payloadEncryptionSettings()
      );
      OfflineQrCodeMessageApprovalAnswer answer = ((Airlock2FAApprovalService)this.service.get())
         .approveWithOfflineQrCode(stepSession.getAccountIdOrThrow(), device, message);
      if (answer.immediate().isPresent()) {
         return this.failWithImmediateAnswer(stepSession, answer.immediate().get());
      }

      if (answer.deferred().isEmpty()) {
         throw new FailureException("Deferred answer should not be empty when immediate answer is already empty");
      }

      this.saveToSession(stepSession, answer.deferred().get());
      this.logHelper().logSessionStarted(Airlock2FAFactor.OFFLINE_QR_CODE, this.logData());
      return this.nextStayStepResult(stepSession);
   }

   public AuthenticationStepResult listDevices() {
      Airlock2FAApprovalStepSession stepSession = this.stepSession();
      return stepSession.remainingDevices().isEmpty()
         ? this.failDueToNoSelectableDevice()
         : this.stepResultFactory()
            .stay(((Airlock2FAApprovalStep.Airlock2FAApprovalStepActions)this.stepActions.get()).deviceChoiceRequired())
            .resultData(new Airlock2FADeviceListData(stepSession.remainingDevices()))
            .build();
   }

   public AuthenticationStepResult selectDevice(Airlock2FADeviceId deviceId) {
      Airlock2FAApprovalStepSession stepSession = this.stepSession();
      if (!stepSession.requiresDeviceSelection(((Airlock2FAApprovalStepSettings)this.settings.get()).pushToAllEnabled())) {
         return this.failDueToUnexpectedCall("No device selection required.");
      } else {
         Airlock2FADevices userDevices = stepSession.remainingDevices();
         Optional<Airlock2FAAuthDevice> device = userDevices.deviceWithId(deviceId);
         if (device.isEmpty()) {
            LOG.debug(String.format("Selected Airlock 2FA device id '%s' is not among the user's available devices: '%s'", deviceId.getId(), userDevices));
            return this.stepResultFactory()
               .failRetry(
                  ((Airlock2FAApprovalStep.Airlock2FAApprovalStepErrors)this.stepErrors.get()).deviceChoiceFailed(),
                  ((Airlock2FAApprovalStep.Airlock2FAApprovalStepActions)this.stepActions.get()).deviceChoiceRequired()
               )
               .addAdditionalAttribute("deviceChoices", StepResultHelper.createAdditionalAttribute(stepSession.remainingDevices()))
               .build();
         } else {
            stepSession.selectDevice(device.get(), ((Airlock2FAApprovalStepSettings)this.settings.get()).deviceSettings().payloadEncryptionSettings());
            return this.generateAirlock2faMessage().mapResult(message -> this.startApprovalWithDevice(stepSession, message)).orMapFailure(Function.identity());
         }
      }
   }

   @Override
   public StepResult checkPasscode(Airlock2FAPasscode passcode) {
      LOG.info("Starting to check Airlock 2FA passcode.");
      Airlock2FAApprovalStepSession stepSession = this.stepSession();
      if (!stepSession.topFactorEquals(Airlock2FAFactor.PASSCODE)) {
         return this.stepResultFactory().failDueToUnexpectedCall("Passcode approval is not enabled, not possible or not the highest priority factor.").build();
      }

      if (!stepSession.isOfflineModeEnabled()) {
         return this.stepResultFactory().failDueToUnexpectedCall("Offline mode must be enabled before using passcode approval.").build();
      }

      stepSession.setExclusiveFactor(
         Airlock2FAFactor.PASSCODE, ((Airlock2FAApprovalStepSettings)this.settings.get()).deviceSettings().payloadEncryptionSettings()
      );
      ImmediateAuthenticationAnswer answer = ((Airlock2FAApprovalService)this.service.get()).approveWithPasscode(stepSession.getAccountIdOrThrow(), passcode);
      if (answer.getResult() == ImmediateAuthenticationAnswer.ImmediateAuthenticationResult.DENY) {
         this.logApprovalFailed(Airlock2FAFactor.PASSCODE);
      }

      if (this.accountIsLocked(answer)) {
         LOG.info(
            String.format(
               "User %s with Airlock 2FA account %s is locked out.", LogUtil.quote(this.userId()), LogUtil.quote(stepSession.getAccountId().orElse(null))
            )
         );
         return this.stepResultFactory().fail(((Airlock2FAApprovalStep.Airlock2FAApprovalStepErrors)this.stepErrors.get()).approvalFailed()).build();
      }

      if (answer.getResult() == ImmediateAuthenticationAnswer.ImmediateAuthenticationResult.ALLOW) {
         Airlock2FADeviceId deviceId = answer.getDeviceId()
            .orElseThrow(() -> new FailureException("Expected device Id to be present in approval status answer \"allow\"."));
         Optional<AuthenticationStepResult> failureResult = this.validateCooldownPeriod(deviceId);
         if (failureResult.isPresent()) {
            return failureResult.get();
         }

         stepSession.selectDeviceById(deviceId, ((Airlock2FAApprovalStepSettings)this.settings.get()).deviceSettings().payloadEncryptionSettings());
         this.logHelper().logSuccess(Airlock2FAFactor.PASSCODE, stepSession.getOnlyDeviceOrThrow(), "approval", this.logData());
         return this.stepResultFactory().success().deviceUsageData(this.deviceUsageData(stepSession.getOnlyDeviceOrThrow())).build();
      } else {
         return this.stepResultFactory()
            .failRetry(((Airlock2FAApprovalStep.Airlock2FAApprovalStepErrors)this.stepErrors.get()).passcodeWrong(), this.nextStepAction(stepSession))
            .build();
      }
   }

   private boolean accountIsLocked(ImmediateAuthenticationAnswer answer) {
      return answer.getKnownStatus().map(status -> status.equals(AuthenticationStatus.LOCKED_OUT)).orElse(false);
   }

   public AuthenticationStepResultFactory stepResultFactory() {
      return AuthenticationStepResultFactory.forFactorDetail(
         ((Optional)this.stepSession.get())
            .map(Airlock2FAApprovalStepSession::authenticationFactorDetail)
            .orElse(KnownAuthenticationFactorDetail.PREAUTH_AIRLOCK_2FA)
      );
   }

   @Override
   public AuthenticationMethod getAuthenticationMethod() {
      return (AuthenticationMethod)this.authMethod.get();
   }

   @Override
   public void finalizeStep() {
      ((Optional)this.stepSession.get()).ifPresent(session -> {
         this.cancelOfflineSession(session);
         this.cancelOnlineSession(session);
      });
   }

   private AuthenticationDeviceUsage deviceUsageData(Airlock2FAAuthDevice device) {
      return new AuthenticationDeviceUsage(KnownDeviceTypes.AIRLOCK_2FA, device.getId(), this.timeProvider.getCurrentInstant());
   }

   private void cancelOfflineSession(Airlock2FAApprovalStepSession session) {
      if (session.isOfflineSessionPresent()) {
         this.logHelper().logAbortingSession(this.logData());
         Result<ImmediateAuthenticationAnswer, FuturaeErrorResponseException> answer = ((Airlock2FAApprovalService)this.service.get())
            .abortTransaction(session.getAccountIdOrThrow(), session.getOfflineSessionOrThrow().getSessionId());
         if (answer.isFailure() && answer.getFailure().type() != FuturaeErrorResponseException.FuturaeErrorResponseType.BAD_REQUEST) {
            throw new FailureException("Could not abort approval of user with Airlock 2FA account: " + session.getAccountIdOrThrow(), answer.getFailure());
         }
      }

      session.clearOfflineSession();
   }

   private void cancelOnlineSession(Airlock2FAApprovalStepSession session) {
      if (session.isOnlineSessionPresent()) {
         this.logHelper().logAbortingSession(this.logData());
         Result<ImmediateAuthenticationAnswer, FuturaeErrorResponseException> answer = ((Airlock2FAApprovalService)this.service.get())
            .abortTransaction(session.getAccountIdOrThrow(), session.getOnlineSessionOrThrow().getSessionId());
         if (answer.isFailure() && answer.getFailure().type() != FuturaeErrorResponseException.FuturaeErrorResponseType.BAD_REQUEST) {
            throw new FailureException("Could not abort approval of user with Airlock 2FA account: " + session.getAccountIdOrThrow(), answer.getFailure());
         }
      }

      session.clearOnlineSession();
   }

   private AuthenticationStepResult failDueToNoValidToken() {
      return this.stepResultFactory().fail(AuthenticationFlowStep.GeneralAuthenticationStepError.NO_VALID_TOKEN).isFailedFactor(false).build();
   }

   private AuthenticationStepResult failDueToMissingAirlock2FAAccountId() {
      return this.failDueToUnexpectedCall("No Airlock 2FA account ID is present.");
   }

   private AuthenticationStepResult failDueToFailedMessageGeneration(Airlock2FAMessageProviderException cause) {
      return this.failDueToInternalError("Could not generate Airlock 2FA message: " + cause.getMessage());
   }

   private AuthenticationStepResult failDueToMissingOfflineSessionId() {
      return this.failDueToUnexpectedCall("No offline session ID is present.");
   }

   private AuthenticationStepResult failDueToMissingOnlineSessionId() {
      return this.failDueToUnexpectedCall("No online session ID is present.");
   }

   private AuthenticationStepResult failDueToUnexpectedCall(String reason) {
      return this.stepResultFactory().failDueToUnexpectedCall(reason).build();
   }

   private AuthenticationStepResult failDueToInternalError(String reason) {
      LOG.warn(EDefaultLogType.GENERIC_LOG_TYPE, "Internal error: " + reason);
      return this.stepResultFactory().internalError().isFailedFactor(false).build();
   }

   private AuthenticationStepResult failDueToNoSelectableDevice() {
      return this.failDueToUnexpectedCall("no Airlock 2FA device choices available");
   }

   private AuthenticationStepResult failDueToMobileOnlyOngoing() {
      return this.failDueToUnexpectedCall("Approval with mobile-only is currently ongoing. Offline mode is not possible.");
   }

   private Airlock2FALogHelper logHelper() {
      return (Airlock2FALogHelper)this.logHelper.get();
   }

   private Airlock2FAApprovalStepSession stepSession() {
      return (Airlock2FAApprovalStepSession)((Optional)this.stepSession.get())
         .orElseThrow(() -> new ViolationException("Flow session does not contain step session of type " + Airlock2FAApprovalStepSession.class.getSimpleName()));
   }

   private UserId userId() {
      return ((PrincipalInfo)this.user.get()).getKnownIdentity().map(UserIdentity::id).orElseThrow(() -> new FailureException("User must be known"));
   }

   @SuppressFBWarnings(justification = "generated code")
   public static Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder builder() {
      return new Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder();
   }

   @SuppressFBWarnings(justification = "generated code")
   public static class Airlock2FAApprovalStepImplBuilder {
      @SuppressFBWarnings(justification = "generated code")
      private Provider<PrincipalInfo> user;
      @SuppressFBWarnings(justification = "generated code")
      private Provider<Airlock2FAApprovalService> service;
      @SuppressFBWarnings(justification = "generated code")
      private Provider<Optional<Airlock2FAApprovalStepSession>> stepSession;
      @SuppressFBWarnings(justification = "generated code")
      private Provider<Airlock2FAUserRepository> repository;
      @SuppressFBWarnings(justification = "generated code")
      private Provider<AuthenticationMethod> authMethod;
      @SuppressFBWarnings(justification = "generated code")
      private Provider<Airlock2FAMessageProvider> messageProvider;
      @SuppressFBWarnings(justification = "generated code")
      private Provider<Optional<Airlock2FAApprovalAuthTokenIdService>> authTokenIdService;
      @SuppressFBWarnings(justification = "generated code")
      private Provider<Airlock2FAApprovalStepSettings> settings;
      @SuppressFBWarnings(justification = "generated code")
      private HelpdeskLogger helpdeskLogger;
      @SuppressFBWarnings(justification = "generated code")
      private SigningLogger signingLogger;
      @SuppressFBWarnings(justification = "generated code")
      private Provider<Airlock2FALogHelper> logHelper;
      @SuppressFBWarnings(justification = "generated code")
      private Provider<AuthenticationStatusService> authenticationStatusService;
      @SuppressFBWarnings(justification = "generated code")
      private Provider<Airlock2FAApprovalStep.Airlock2FAApprovalStepActions> stepActions;
      @SuppressFBWarnings(justification = "generated code")
      private Provider<Airlock2FAApprovalStep.Airlock2FAApprovalStepErrors> stepErrors;
      @SuppressFBWarnings(justification = "generated code")
      private TimeProvider timeProvider;
      @SuppressFBWarnings(justification = "generated code")
      private Provider<Airlock2FADeviceEventFactory> eventFactory;

      @SuppressFBWarnings(justification = "generated code")
      Airlock2FAApprovalStepImplBuilder() {
      }

      @SuppressFBWarnings(justification = "generated code")
      public Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder user(@Nonnull Provider<PrincipalInfo> user) {
         if (user == null) {
            throw new NullPointerException("user is marked non-null but is null");
         }

         this.user = user;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder service(@Nonnull Provider<Airlock2FAApprovalService> service) {
         if (service == null) {
            throw new NullPointerException("service is marked non-null but is null");
         }

         this.service = service;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder stepSession(@Nonnull Provider<Optional<Airlock2FAApprovalStepSession>> stepSession) {
         if (stepSession == null) {
            throw new NullPointerException("stepSession is marked non-null but is null");
         }

         this.stepSession = stepSession;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder repository(@Nonnull Provider<Airlock2FAUserRepository> repository) {
         if (repository == null) {
            throw new NullPointerException("repository is marked non-null but is null");
         }

         this.repository = repository;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder authMethod(@Nonnull Provider<AuthenticationMethod> authMethod) {
         if (authMethod == null) {
            throw new NullPointerException("authMethod is marked non-null but is null");
         }

         this.authMethod = authMethod;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder messageProvider(@Nonnull Provider<Airlock2FAMessageProvider> messageProvider) {
         if (messageProvider == null) {
            throw new NullPointerException("messageProvider is marked non-null but is null");
         }

         this.messageProvider = messageProvider;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder authTokenIdService(
         @NonNull Provider<Optional<Airlock2FAApprovalAuthTokenIdService>> authTokenIdService
      ) {
         if (authTokenIdService == null) {
            throw new NullPointerException("authTokenIdService is marked non-null but is null");
         }

         this.authTokenIdService = authTokenIdService;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder settings(@Nonnull Provider<Airlock2FAApprovalStepSettings> settings) {
         if (settings == null) {
            throw new NullPointerException("settings is marked non-null but is null");
         }

         this.settings = settings;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder helpdeskLogger(@Nonnull HelpdeskLogger helpdeskLogger) {
         if (helpdeskLogger == null) {
            throw new NullPointerException("helpdeskLogger is marked non-null but is null");
         }

         this.helpdeskLogger = helpdeskLogger;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder signingLogger(@Nonnull SigningLogger signingLogger) {
         if (signingLogger == null) {
            throw new NullPointerException("signingLogger is marked non-null but is null");
         }

         this.signingLogger = signingLogger;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder logHelper(@Nonnull Provider<Airlock2FALogHelper> logHelper) {
         if (logHelper == null) {
            throw new NullPointerException("logHelper is marked non-null but is null");
         }

         this.logHelper = logHelper;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder authenticationStatusService(
         @Nonnull Provider<AuthenticationStatusService> authenticationStatusService
      ) {
         if (authenticationStatusService == null) {
            throw new NullPointerException("authenticationStatusService is marked non-null but is null");
         }

         this.authenticationStatusService = authenticationStatusService;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder stepActions(
         @Nonnull Provider<Airlock2FAApprovalStep.Airlock2FAApprovalStepActions> stepActions
      ) {
         if (stepActions == null) {
            throw new NullPointerException("stepActions is marked non-null but is null");
         }

         this.stepActions = stepActions;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder stepErrors(
         @Nonnull Provider<Airlock2FAApprovalStep.Airlock2FAApprovalStepErrors> stepErrors
      ) {
         if (stepErrors == null) {
            throw new NullPointerException("stepErrors is marked non-null but is null");
         }

         this.stepErrors = stepErrors;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder timeProvider(TimeProvider timeProvider) {
         this.timeProvider = timeProvider;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder eventFactory(Provider<Airlock2FADeviceEventFactory> eventFactory) {
         this.eventFactory = eventFactory;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Airlock2FAApprovalStepImpl build() {
         return new Airlock2FAApprovalStepImpl(
            this.user,
            this.service,
            this.stepSession,
            this.repository,
            this.authMethod,
            this.messageProvider,
            this.authTokenIdService,
            this.settings,
            this.helpdeskLogger,
            this.signingLogger,
            this.logHelper,
            this.authenticationStatusService,
            this.stepActions,
            this.stepErrors,
            this.timeProvider,
            this.eventFactory
         );
      }

      @SuppressFBWarnings(justification = "generated code")
      @Override
      public String toString() {
         return "Airlock2FAApprovalStepImpl.Airlock2FAApprovalStepImplBuilder(user="
            + this.user
            + ", service="
            + this.service
            + ", stepSession="
            + this.stepSession
            + ", repository="
            + this.repository
            + ", authMethod="
            + this.authMethod
            + ", messageProvider="
            + this.messageProvider
            + ", authTokenIdService="
            + this.authTokenIdService
            + ", settings="
            + this.settings
            + ", helpdeskLogger="
            + this.helpdeskLogger
            + ", signingLogger="
            + this.signingLogger
            + ", logHelper="
            + this.logHelper
            + ", authenticationStatusService="
            + this.authenticationStatusService
            + ", stepActions="
            + this.stepActions
            + ", stepErrors="
            + this.stepErrors
            + ", timeProvider="
            + this.timeProvider
            + ", eventFactory="
            + this.eventFactory
            + ")";
      }
   }
}
