package com.airlock.iam.authentication.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.AuthenticationStepResultFactory;
import com.airlock.iam.authentication.application.configuration.airlock2fa.Airlock2FAUserFactorAuthenticationStepConfig;
import com.airlock.iam.base.api.domain.model.exception.FailureException;
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.user.UserIdentity;
import com.airlock.iam.common.api.domain.model.valueprovider.ValueProvider;
import com.airlock.iam.common.domain.model.user.PrincipalInfo;
import com.airlock.iam.core.domain.model.authentication.factor.KnownAuthenticationFactor;
import com.airlock.iam.core.domain.model.authentication.method.KnownAuthenticationMethods;
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.Airlock2FAAuthenticationException;
import com.airlock.iam.factor.domain.model.airlock2fa.Airlock2FADeviceAndFactorApplicability;
import com.airlock.iam.factor.domain.model.airlock2fa.Airlock2FAFactor;
import com.airlock.iam.factor.domain.model.airlock2fa.Airlock2FAFactorSet;
import com.airlock.iam.factor.domain.model.airlock2fa.Airlock2FAMultiNumberedChallenge;
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.DeferredAuthenticationAnswer;
import com.airlock.iam.factor.domain.model.airlock2fa.ImmediateAuthenticationAnswer;
import com.airlock.iam.factor.domain.model.airlock2fa.UserStatus;
import com.airlock.iam.factor.domain.model.airlock2fa.event.Airlock2FADeviceEventFactory;
import com.airlock.iam.factor.domain.model.airlock2fa.futurae.Airlock2FAFuturaeAuthSession;
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.Airlock2FAQrCodeImage;
import com.airlock.iam.factor.domain.service.airlock2fa.Airlock2FAAuthenticationService;
import com.airlock.iam.factor.domain.service.airlock2fa.Airlock2FACooldownInformationService;
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.AuthenticationTitleService;
import com.airlock.iam.factor.domain.service.airlock2fa.SessionBasedAuthenticationAnswer;
import com.airlock.iam.factor.domain.service.airlock2fa.repository.Airlock2FAUserRepository;
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.domain.model.airlock2fa.Airlock2FADeviceListData;
import com.airlock.iam.flow.shared.domain.model.airlock2fa.Airlock2FAOfflineQrCodeChallenge;
import com.airlock.iam.flow.shared.domain.model.airlock2fa.Airlock2FAOnlineQrCodeChallenge;
import com.airlock.iam.flow.shared.domain.model.airlock2fa.CommonAirlock2FAStepErrors;
import com.airlock.iam.flow.shared.domain.model.airlock2fa.StepResultHelper;
import com.airlock.iam.flow.shared.domain.service.PollingService;
import com.airlock.iam.flow.shared.domain.service.airlock2fa.Airlock2FAFlowSessionAttributesService;
import jakarta.inject.Inject;
import jakarta.inject.Provider;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.stream.Collectors;

@LogicForConfig(Airlock2FAUserFactorAuthenticationStepConfig.class)
public final class Airlock2FAUserFactorAuthenticationStepImpl implements Airlock2FAUserFactorAuthenticationStep {
   private static final ELogger LOG = ELoggerFactory.getLogger(Airlock2FAUserFactorAuthenticationStepImpl.class);
   private static final String DEVICE_CHOICES_ATTRIBUTE = "deviceChoices";
   private final Provider<Airlock2FAAuthenticationStepSettings> settings;
   private final Provider<PrincipalInfo> user;
   private final Provider<Airlock2FAAuthenticationService> service;
   private final Provider<AuthenticationTitleService> authenticationTitleService;
   private final Provider<Optional<Airlock2FAAuthenticationStepSession>> stepSession;
   private final Provider<Airlock2FAUserRepository> repository;
   private final Provider<Airlock2FAFlowSessionAttributesService> attributes;
   private final Provider<Airlock2FALogHelper> logHelper;
   private final Provider<AuthenticationStatusService> authenticationStatusService;
   private final Provider<TimeProvider> timeProvider;
   private final Provider<Airlock2FADeviceEventFactory> eventFactory;
   private final Provider<Airlock2FACooldownInformationService> cooldownService;

   @Inject
   public Airlock2FAUserFactorAuthenticationStepImpl(
      Provider<Airlock2FAAuthenticationStepSettings> stepSettings,
      Provider<PrincipalInfo> user,
      Provider<Airlock2FAAuthenticationService> service,
      Provider<AuthenticationTitleService> authenticationTitleService,
      Provider<Airlock2FAFlowSessionAttributesService> attributes,
      Provider<Optional<Airlock2FAAuthenticationStepSession>> stepSession,
      Provider<Airlock2FAUserRepository> repository,
      Provider<Airlock2FALogHelper> logHelper,
      Provider<AuthenticationStatusService> authenticationStatusService,
      Provider<TimeProvider> timeProvider,
      Provider<Airlock2FADeviceEventFactory> eventFactory,
      Provider<Airlock2FACooldownInformationService> cooldownService
   ) {
      this.settings = stepSettings;
      this.user = user;
      this.service = service;
      this.authenticationTitleService = authenticationTitleService;
      this.stepSession = stepSession;
      this.repository = repository;
      this.attributes = attributes;
      this.logHelper = logHelper;
      this.authenticationStatusService = authenticationStatusService;
      this.timeProvider = timeProvider;
      this.eventFactory = eventFactory;
      this.cooldownService = cooldownService;
   }

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

   @Override
   public AuthenticationStepResult initialize() {
      this.stepSession().initialize((Airlock2FAAuthenticationStepSettings)this.settings.get());
      Optional<Airlock2FAUser> account = ((Airlock2FAUserRepository)this.repository.get()).findBy(this.userId());
      if (account.isEmpty()) {
         return this.stepResultFactory().fail(AuthenticationFlowStep.GeneralAuthenticationStepError.NO_VALID_TOKEN).build();
      }

      this.stepSession().setAccountId(account.get().getAccountId());
      return this.preAuthenticate();
   }

   private AuthenticationStepResult preAuthenticate() {
      Airlock2FAPreAuthenticationService.PreAuthenticationAnswer answer = ((Airlock2FAAuthenticationService)this.service.get())
         .preAuthenticate(new Airlock2FAPreAuthenticationData(this.stepSession().getAccountIdOrThrow(), this.getUsername()));
      this.logHelper().logPreAuthenticationAnswerDetails(answer);

      return switch (answer.getResult()) {
         case AUTH -> this.handlePreAuthSuccess(answer);
         case ALLOW -> this.handleBypass();
         case DENY -> this.authenticationDenied(answer);
         case UNKNOWN -> {
            LOG.info(
               String.format(
                  "User %s with Airlock 2FA account %s is not recognized by Futurae's server.",
                  LogUtil.quote(this.userId()),
                  LogUtil.quote(this.stepSession().getAccountId().orElse(null))
               )
            );
            yield this.stepResultFactory().fail(AuthenticationFlowStep.GeneralAuthenticationStepError.AUTHENTICATION_FAILED).build();
         }
      };
   }

   private String getUsername() {
      return ((PrincipalInfo)this.user.get()).getKnownIdentity().orElseThrow().providedUsername().getValue();
   }

   private AuthenticationStepResult handlePreAuthSuccess(Airlock2FAPreAuthenticationService.PreAuthenticationAnswer answer) {
      this.stepSession()
         .initializeFactorsAndDevices(
            ((Airlock2FAAuthenticationStepSettings)this.settings.get()).getDeviceSettings(), answer.getAllowedFactors(), answer.getDevices()
         );
      if (this.stepSession().remainingDevices().isEmpty()) {
         return this.handleNoValidDevice(answer);
      } else {
         return ((Airlock2FAAuthenticationStepSettings)this.settings.get()).isEnforceDeviceSelection() ? this.deviceSelection() : this.authenticate();
      }
   }

   private AuthenticationStepResult handleNoValidDevice(Airlock2FAPreAuthenticationService.PreAuthenticationAnswer answer) {
      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.deviceApplicability(answer)
               )
            );
         return this.stepResultFactory().fail(AuthenticationFlowStep.GeneralAuthenticationStepError.NO_VALID_TOKEN).isFailedFactor(false).build();
      } 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 AuthenticationStepResult handleBypass() {
      if (((Airlock2FAAuthenticationStepSettings)this.settings.get()).isEnableBypassMode()) {
         ((Airlock2FAAuthenticationStepSettings)this.settings.get())
            .getDeviceSettings()
            .factorPriority()
            .topFactor()
            .ifPresent(this.stepSession()::setBypassedFactor);
         return this.stepResultHelper().stepResultForSuccessfulBypass();
      } else {
         return this.stepResultHelper().failedDueToBypassMode();
      }
   }

   private AuthenticationStepResult deviceSelection() {
      return this.stepResultFactory()
         .stay(Airlock2FAUserFactorAuthenticationStep.Airlock2FAUserFactorAuthenticationStepAction.AIRLOCK_2FA_DEVICE_CHOICE_REQUIRED)
         .addAdditionalAttribute("deviceChoices", StepResultHelper.createAdditionalAttribute(this.stepSession().remainingDevices()))
         .build();
   }

   private AuthenticationStepResult authenticationDenied(Airlock2FAPreAuthenticationService.PreAuthenticationAnswer preAuthenticationAnswer) {
      this.logHelper().logFailed(Optional.empty(), this.logData());
      if (this.accountIsLocked(preAuthenticationAnswer)) {
         LOG.info(
            String.format(
               "User %s with Airlock 2FA account %s is locked out.",
               LogUtil.quote(this.userId()),
               LogUtil.quote(this.stepSession().getAccountId().orElse(null))
            )
         );
         return this.stepResultFactory().fail(AuthenticationFlowStep.GeneralAuthenticationStepError.AUTH_METHOD_INACTIVE).isFailedFactor(false).build();
      } else if (this.accountHasNoDevices(preAuthenticationAnswer)) {
         LOG.info(
            String.format(
               "User %s with Airlock 2FA account %s has no enrolled devices.",
               LogUtil.quote(this.userId()),
               LogUtil.quote(this.stepSession().getAccountId().orElse(null))
            )
         );
         return this.stepResultFactory().fail(AuthenticationFlowStep.GeneralAuthenticationStepError.NO_VALID_TOKEN).isFailedFactor(false).build();
      } else {
         LOG.info(
            String.format(
               "Pre-authentication for user %s with Airlock 2FA account %s denied.",
               LogUtil.quote(this.userId()),
               LogUtil.quote(this.stepSession().getAccountId().orElse(null))
            )
         );
         return this.stepResultFactory().fail(AuthenticationFlowStep.GeneralAuthenticationStepError.AUTHENTICATION_FAILED).build();
      }
   }

   private boolean accountHasNoDevices(Airlock2FAPreAuthenticationService.PreAuthenticationAnswer preAuthenticationAnswer) {
      return preAuthenticationAnswer.getUserStatus().map(UserStatus.DISABLED::equals).orElse(false);
   }

   private boolean accountIsLocked(Airlock2FAPreAuthenticationService.PreAuthenticationAnswer preAuthenticationAnswer) {
      return preAuthenticationAnswer.getUserStatus().map(UserStatus.LOCKED::equals).orElse(false);
   }

   private AuthenticationStepResult authenticate() {
      Airlock2FAAuthenticationStepSession session = this.stepSession();
      if (session.requiresDeviceSelection(((Airlock2FAAuthenticationStepSettings)this.settings.get()).isPushToAllEnabled())) {
         return this.deviceSelection();
      } else if (session.canStartOnlineSession()) {
         Airlock2FAFactor factor = session.topFactor().orElseThrow();
         return this.startOnlineAuthentication(factor, session.getAccountIdOrThrow());
      } else {
         this.stepSession().switchToOfflineMode(((Airlock2FAAuthenticationStepSettings)this.settings.get()).getDeviceSettings().payloadEncryptionSettings());
         this.generateOfflineQrCodeIfNecessary();
         return this.stepResultHelper().nextStayStepResult(session);
      }
   }

   private AuthenticationStepResult startOnlineAuthentication(Airlock2FAFactor factor, Airlock2FAAccountId accountId) {
      try {
         return switch (factor) {
            case ONLINE_QR_CODE -> this.startOnlineQrCodeAuthentication(accountId, this.generateQrCodeMessage());
            case ONE_TOUCH -> this.startOneTouchAuthentication(accountId, this.generateOneTouchMessage());
            case OFFLINE_QR_CODE, PASSCODE, MOBILE_ONLY, USERNAMELESS_QR_CODE -> throw new FailureException(
               "Cannot start online session because given factor is not an online user factor: " + factor
            );
         };
      } catch (Airlock2FAMessageProviderException e) {
         return this.failDueToFailedMessageGeneration(e);
      }
   }

   private AuthenticationStepResult failDueToFailedMessageGeneration(Airlock2FAMessageProviderException cause) {
      this.logHelper().log("Could not generate Airlock 2FA message: " + cause.getMessage() + ". Aborting.", this.logData());
      return this.stepResultFactory().internalError().isFailedFactor(false).build();
   }

   private Airlock2FAMessage generateOneTouchMessage() throws Airlock2FAMessageProviderException {
      Optional<ValueProvider<String>> loginIdProvider = ((Airlock2FAAuthenticationStepSettings)this.settings.get()).getLoginIdProvider();
      if (loginIdProvider.isPresent()) {
         this.stepSession().setLoginId(loginIdProvider.get().getValue());
      }

      Optional<Airlock2FAMessageProvider> messageProvider = ((Airlock2FAAuthenticationStepSettings)this.settings.get()).getOneTouchMessageProvider();
      return messageProvider.isPresent()
         ? messageProvider.get().generateAirlock2FAMessage()
         : ((AuthenticationTitleService)this.authenticationTitleService.get()).defaultOneTouchMessage();
   }

   private Optional<Airlock2FAMessage> generateQrCodeMessage() throws Airlock2FAMessageProviderException {
      Optional<Airlock2FAMessageProvider> messageProvider = ((Airlock2FAAuthenticationStepSettings)this.settings.get()).getQrCodeMessageProvider();
      return messageProvider.isPresent() ? Optional.of(messageProvider.get().generateAirlock2FAMessage()) : Optional.empty();
   }

   private AuthenticationStepResult startOneTouchAuthentication(Airlock2FAAccountId accountId, Airlock2FAMessage message) {
      Airlock2FAAuthenticationStepSession session = this.stepSession();
      session.setFactorUsedForAuthentication(Airlock2FAFactor.ONE_TOUCH);
      SessionBasedAuthenticationAnswer.OneTouchAuthenticationAnswer answer;
      if (((Airlock2FAAuthenticationStepSettings)this.settings.get()).isPushToAllEnabled()) {
         this.logHelper().log("Starting authentication with push to all", this.logData());
         answer = this.startPushToAllSession(accountId, message);
      } else {
         Airlock2FAAuthDevice device = session.getOnlyDeviceOrThrow();
         this.logHelper().log(String.format("Starting authentication with device %s", LogUtil.quote(device.getId())), this.logData());
         answer = this.startOneTouchSession(accountId, device, message);
      }

      if (answer.immediate().isPresent()) {
         return this.stepResultHelper().stepResultForSessionStartImmediateAnswer(answer.immediate().get());
      }

      this.handleDeferredAnswer((DeferredAuthenticationAnswer)answer.deferred().orElseThrow(), Airlock2FAFactor.ONE_TOUCH);
      Optional<Airlock2FAMultiNumberedChallenge> multiNumberedChallenge = ((DeferredAuthenticationAnswer.OneTouchDeferredAuthenticationAnswer)answer.deferred()
            .orElseThrow())
         .getMultiNumberedChallenge();
      session.setMultiNumberedChallenge(multiNumberedChallenge);
      if (((Airlock2FAAuthenticationStepSettings)this.settings.get()).getLoginIdProvider().isPresent() && multiNumberedChallenge.isPresent()) {
         LOG.warn(
            EDefaultLogType.GENERIC_LOG_TYPE,
            "Login ID should not be generated if multi-numbered challenge is enabled. Either the IAM configuration should be changed so that no login ID is generated, or the multi-numbered-challenge feature should be disabled on the Futurae service."
         );
      }

      return this.stepResultHelper().nextStayStepResult(session);
   }

   private AuthenticationStepResult startOnlineQrCodeAuthentication(Airlock2FAAccountId accountId, Optional<Airlock2FAMessage> message) {
      Airlock2FAAuthenticationStepSession session = this.stepSession();
      Optional<Airlock2FAShortLivedOnlineQrCodeSettings> shortLivedOnlineQrCodeSettings = ((Airlock2FAAuthenticationStepSettings)this.settings.get())
         .getShortLivedOnlineQrCodeSettings();
      Optional<Duration> sessionTimeout = shortLivedOnlineQrCodeSettings.map(Airlock2FAShortLivedOnlineQrCodeSettings::sessionTimeout);
      this.logHelper().log("Starting Online QR Code authentication", this.logData());
      session.setFactorUsedForAuthentication(Airlock2FAFactor.ONLINE_QR_CODE);
      SessionBasedAuthenticationAnswer.OnlineQrCodeAuthenticationAnswer answer = ((Airlock2FAAuthenticationService)this.service.get())
         .authenticateWithOnlineQrCode(accountId, message, sessionTimeout);
      if (answer.immediate().isPresent()) {
         return this.stepResultHelper().stepResultForSessionStartImmediateAnswer(answer.immediate().get());
      }

      DeferredAuthenticationAnswer.OnlineQrCodeDeferredAuthenticationAnswer deferredAnswer = (DeferredAuthenticationAnswer.OnlineQrCodeDeferredAuthenticationAnswer)answer.deferred()
         .orElseThrow();
      this.handleDeferredAnswer(deferredAnswer, Airlock2FAFactor.ONLINE_QR_CODE);
      if (shortLivedOnlineQrCodeSettings.isPresent()) {
         Instant authenticationStartTime = ((TimeProvider)this.timeProvider.get()).getCurrentInstant();
         Airlock2FAExchangeTokensAnswer exchangeTokenAnswer = this.fetchExchangeTokens(accountId, deferredAnswer, shortLivedOnlineQrCodeSettings.get());
         session.setSessionExchangeTokens(exchangeTokenAnswer.getExchangeTokens());
         session.setSessionStartTime(authenticationStartTime);
      } else {
         Airlock2FAOnlineQrCodeChallenge challenge = new Airlock2FAOnlineQrCodeChallenge(deferredAnswer.getQrCodeImage(), deferredAnswer.getMobileAuthUri());
         session.setOnlineQrCodeChallenge(challenge);
      }

      return this.stepResultHelper().nextStayStepResult(session);
   }

   private Airlock2FAExchangeTokensAnswer fetchExchangeTokens(
      Airlock2FAAccountId accountId,
      DeferredAuthenticationAnswer.OnlineQrCodeDeferredAuthenticationAnswer authAnswer,
      Airlock2FAShortLivedOnlineQrCodeSettings settings
   ) {
      return ((Airlock2FAAuthenticationService)this.service.get())
         .fetchAuthenticationExchangeTokens(
            accountId,
            authAnswer.getSessionId(),
            settings.tokenLifetime(),
            settings.exchangeTokenCount(),
            authAnswer.getMobileAuthUri().getUriScheme(),
            authAnswer.getMobileAuthUniversalLink().getDomain()
         );
   }

   private SessionBasedAuthenticationAnswer.OneTouchAuthenticationAnswer startOneTouchSession(
      Airlock2FAAccountId accountId, Airlock2FAAuthDevice device, Airlock2FAMessage message
   ) {
      return ((Airlock2FAAuthenticationService)this.service.get()).authenticateWithOneTouch(accountId, device, message);
   }

   private SessionBasedAuthenticationAnswer.OneTouchAuthenticationAnswer startPushToAllSession(Airlock2FAAccountId accountId, Airlock2FAMessage message) {
      return ((Airlock2FAAuthenticationService)this.service.get()).authenticateWithPushToAll(accountId, message);
   }

   public AuthenticationStepResult checkPasscode(Airlock2FAPasscode passcode) {
      Airlock2FAAuthenticationStepSession session = this.stepSession();
      if (!session.topFactorEquals(Airlock2FAFactor.PASSCODE)) {
         return this.stepResultFactory()
            .failDueToUnexpectedCall("Passcode authentication is not enabled, not possible or not the highest priority factor.")
            .build();
      }

      if (!session.isInOfflineMode()) {
         return this.stepResultFactory().failDueToUnexpectedCall("Offline mode must be enabled before using passcode authentication.").build();
      }

      session.setExclusiveFactor(
         Airlock2FAFactor.PASSCODE, ((Airlock2FAAuthenticationStepSettings)this.settings.get()).getDeviceSettings().payloadEncryptionSettings()
      );
      session.setFactorUsedForAuthentication(Airlock2FAFactor.PASSCODE);
      ImmediateAuthenticationAnswer answer = ((Airlock2FAAuthenticationService)this.service.get())
         .authenticateWithPasscode(session.getAccountIdOrThrow(), passcode);
      if (this.accountIsLocked(answer)) {
         this.logHelper().logFailed(Optional.empty(), this.logData());
         LOG.info(
            String.format(
               "User %s with Airlock 2FA account %s is locked out.", LogUtil.quote(this.userId()), LogUtil.quote(session.getAccountId().orElse(null))
            )
         );
         return this.stepResultFactory().fail(AuthenticationFlowStep.GeneralAuthenticationStepError.AUTH_METHOD_INACTIVE).build();
      }

      if (this.stepResultHelper().isSuccessfulAuthenticationResult(answer.getResult())) {
         Airlock2FADeviceId deviceId = answer.getDeviceId().orElseThrow(() -> new FailureException("Expected device ID in \"allow\" answer."));
         Optional<AuthenticationStepResult> failureResult = this.validateCooldownPeriod(deviceId);
         if (failureResult.isPresent()) {
            return failureResult.get();
         }

         session.selectDeviceById(deviceId, ((Airlock2FAAuthenticationStepSettings)this.settings.get()).getDeviceSettings().payloadEncryptionSettings());
         AuthenticationStepResult stepResult = this.stepResultHelper().factorCheckSucceeded(Airlock2FAFactor.PASSCODE);
         this.saveFlowAttributes(session, Airlock2FAFactor.PASSCODE);
         return stepResult;
      } else {
         return this.handleFailedPasscodeCheck();
      }
   }

   private Optional<AuthenticationStepResult> validateCooldownPeriod(Airlock2FADeviceId deviceId) {
      if (this.stepSession().isDeviceInCooldown(deviceId)) {
         if (((Airlock2FAAuthenticationStepSettings)this.settings.get()).getDeviceSettings().cooldownSettings().cooldownConfiguredAndRespected()) {
            ((Airlock2FALogHelper)this.logHelper.get()).logFailedDueToCooldownPeriod("authentication", deviceId);
            return Optional.of(
               this.stepResultFactory().fail(AuthenticationFlowStep.GeneralAuthenticationStepError.AUTHENTICATION_FAILED).isFailedFactor(false).build()
            );
         }

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

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

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

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

   public AuthenticationStepResult listDevices() {
      return this.stepSession().remainingDevices().isEmpty()
         ? this.stepResultFactory().failDueToUnexpectedCall("no Airlock 2FA Devices available").build()
         : this.stepResultFactory()
            .stay(Airlock2FAUserFactorAuthenticationStep.Airlock2FAUserFactorAuthenticationStepAction.AIRLOCK_2FA_DEVICE_CHOICE_REQUIRED)
            .resultData(new Airlock2FADeviceListData(this.stepSession().remainingDevices()))
            .build();
   }

   private Airlock2FADeviceAndFactorApplicability deviceApplicability(Airlock2FAPreAuthenticationService.PreAuthenticationAnswer answer) {
      return Airlock2FADeviceAndFactorApplicability.builder()
         .allowedFactorsByConfig(Airlock2FAFactorSet.of(((Airlock2FAAuthenticationStepSettings)this.settings.get()).getDeviceSettings().factorPriority()))
         .allowedFactorsByBackend(answer.getAllowedFactors())
         .devices(answer.getDevices())
         .payloadEncryptionSettings(((Airlock2FAAuthenticationStepSettings)this.settings.get()).getDeviceSettings().payloadEncryptionSettings())
         .build();
   }

   public AuthenticationStepResult selectDevice(Airlock2FADeviceId deviceId) {
      return this.stepSession().getFuturaeSession().isPresent()
         ? this.stepResultFactory().failDueToUnexpectedCall("authentication session already in progress").build()
         : this.selectDeviceHandler(deviceId);
   }

   private AuthenticationStepResult selectDeviceHandler(Airlock2FADeviceId deviceId) {
      Optional<Airlock2FAAuthDevice> device = this.stepSession().remainingDevices().deviceWithId(deviceId);
      if (device.isEmpty()) {
         return this.stepResultFactory()
            .failRetry(
               CommonAirlock2FAStepErrors.AIRLOCK_2FA_DEVICE_CHOICE_FAILED,
               Airlock2FAUserFactorAuthenticationStep.Airlock2FAUserFactorAuthenticationStepAction.AIRLOCK_2FA_DEVICE_CHOICE_REQUIRED
            )
            .addAdditionalAttribute("deviceChoices", StepResultHelper.createAdditionalAttribute(this.stepSession().remainingDevices()))
            .build();
      }

      this.stepSession()
         .selectDevice(device.get(), ((Airlock2FAAuthenticationStepSettings)this.settings.get()).getDeviceSettings().payloadEncryptionSettings());
      return this.authenticate();
   }

   private AuthenticationStepResult handleFailedPasscodeCheck() {
      this.stepSession().reduceRemainingPasscodeCheckAttempts();
      return this.stepSession().getRemainingPasscodeCheckAttempts() >= 1
         ? this.stepResultHelper()
            .factorCheckFailRetry(
               Airlock2FAFactor.PASSCODE, Airlock2FAUserFactorAuthenticationStep.Airlock2FAUserFactorAuthenticationStepError.AIRLOCK_2FA_PASSCODE_WRONG
            )
         : this.stepResultHelper()
            .factorCheckFailed(
               Airlock2FAFactor.PASSCODE, Airlock2FAUserFactorAuthenticationStep.Airlock2FAUserFactorAuthenticationStepError.AIRLOCK_2FA_PASSCODE_WRONG
            );
   }

   public AuthenticationStepResult switchToOffline() {
      Airlock2FAAuthenticationStepSession session = this.stepSession();
      if (session.isInOfflineMode()) {
         return this.stepResultHelper().nextStayStepResult(session);
      }

      if (session.getAccountId().isEmpty()) {
         this.stepResultFactory().failDueToUnexpectedCall("No Airlock 2FA account is present.").build();
      }

      if (!session.canEnableOfflineMode()) {
         return this.stepResultFactory()
            .failDueToUnexpectedCall("No offline authentication factor is enabled/available: " + session.getRemainingFactors())
            .build();
      }

      if (session.getFuturaeSession().isPresent()) {
         Airlock2FAAuthenticationService.AuthenticationStatusAnswer unconsumedAnswer = ((Airlock2FAAuthenticationService)this.service.get())
            .checkAuthenticationStatus(session.getFuturaeSession().get());
         if (unconsumedAnswer.getResult().isFinalResult()) {
            session.clearFuturaeSession();
            return this.failDueToUnconsumedFinalOnlineSessionResult();
         }

         this.cancelFuturaeSession();
      }

      session.switchToOfflineMode(((Airlock2FAAuthenticationStepSettings)this.settings.get()).getDeviceSettings().payloadEncryptionSettings());
      this.logHelper()
         .log(
            "Switched to offline authentication factors: "
               + session.getRemainingFactors().factors().stream().map(Airlock2FAFactor::getDisplayName).collect(Collectors.joining(", ")),
            this.logData()
         );
      return this.authenticate();
   }

   private AuthenticationStepResult failDueToUnconsumedFinalOnlineSessionResult() {
      this.logHelper()
         .log(
            "Refused to switch to offline mode since final result from online session is available. The client should poll that result. Aborting.",
            this.logData()
         );
      return this.stepResultFactory().fail(AuthenticationFlowStep.GeneralAuthenticationStepError.AUTHENTICATION_FAILED).build();
   }

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

   private AuthenticationStepResult pollInternal(Airlock2FAAuthenticationStepSession session) {
      if (session.isInOfflineMode()) {
         return this.stepResultFactory().failDueToUnexpectedCall("Polling not allowed when offline mode enabled.").build();
      }

      if (session.getFuturaeSession().isEmpty()) {
         return this.stepResultFactory().failDueToUnexpectedCall("No ongoing Futurae authentication session.").build();
      }

      Airlock2FAFuturaeAuthSession futuraeSession = session.getFuturaeSession().get();
      Airlock2FAAuthenticationService.AuthenticationStatusAnswer answer = ((Airlock2FAAuthenticationService)this.service.get())
         .checkAuthenticationStatus(futuraeSession);
      if (this.stepResultHelper().isSuccessfulAuthenticationResult(answer.getResult())) {
         Airlock2FADeviceId deviceId = answer.getDeviceId()
            .orElseThrow(() -> new FailureException("Expected device Id to be present in authentication status answer \"allow\"."));
         Optional<AuthenticationStepResult> failureResult = this.validateCooldownPeriod(deviceId);
         if (failureResult.isPresent()) {
            ((Airlock2FALogHelper)this.logHelper.get()).logAuthenticationAnswerDetails(answer, this.logData());
            return failureResult.get();
         }

         this.stepSession()
            .selectDeviceById(deviceId, ((Airlock2FAAuthenticationStepSettings)this.settings.get()).getDeviceSettings().payloadEncryptionSettings());
      }

      if (this.stepResultHelper().isDenyAuthenticationResult(answer.getResult()) && answer.getDeviceId().isPresent()) {
         Airlock2FADeviceId deviceId = answer.getDeviceId().get();
         if (this.stepSession().isDeviceInCooldown(deviceId)) {
            ((Airlock2FALogHelper)this.logHelper.get()).logDenyWithDeviceInCooldown("authentication", deviceId);
         }
      }

      AuthenticationStepResult stepResult = this.stepResultHelper().stepResultForStatusAnswer(answer, futuraeSession.getFactor());
      answer.getStatus().ifPresent(status -> ((AuthenticationStatusService)this.authenticationStatusService.get()).handleStatus(status));
      if (stepResult.isSuccess()) {
         this.saveFlowAttributes(session, futuraeSession.getFactor());
      }

      if (answer.getResult().isFinalResult()) {
         session.clearFuturaeSession();
      }

      return stepResult;
   }

   @Override
   public AuthenticationMethod getAuthenticationMethod() {
      return KnownAuthenticationMethods.AIRLOCK_2FA;
   }

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

   @Override
   public void finalizeStep() {
      this.cancelFuturaeSession();
   }

   private void cancelFuturaeSession() {
      Optional<Airlock2FAFuturaeAuthSession> futuraeSession = this.stepSession().getFuturaeSession();
      if (futuraeSession.isPresent()) {
         this.logHelper().logAbortingSession(this.logData());
         Result<ImmediateAuthenticationAnswer, FuturaeErrorResponseException> answer = ((Airlock2FAAuthenticationService)this.service.get())
            .abortAuthentication(futuraeSession.get().getAccountId(), futuraeSession.get().getSessionId());
         if (answer.isFailure() && answer.getFailure().type() != FuturaeErrorResponseException.FuturaeErrorResponseType.BAD_REQUEST) {
            throw Airlock2FAAuthenticationException.airlock2FAAuthenticationException(
               String.format("Could not abort authentication for user with Airlock 2FA account %s", this.stepSession().getAccountIdOrThrow()),
               answer.getFailure()
            );
         }
      }

      this.stepSession().clearFuturaeSession();
   }

   public AuthenticationStepResult retrieveZeroTouchInformation() {
      if (this.stepSession().getPollingCache().hasResult()) {
         return this.stepResultFactory().failDueToUnexpectedCall("Call is not allowed after the final polling result is ready.").build();
      } else if (this.stepSession().isInOfflineMode()) {
         return this.stepResultFactory().failDueToUnexpectedCall("No Zero-Touch information available when offline mode enabled.").build();
      } else {
         return this.stepSession().getFuturaeSession().isEmpty()
            ? this.stepResultFactory().failDueToUnexpectedCall("No ongoing Futurae authentication session.").build()
            : this.stepResultHelper().stepResultForZeroTouchInformation();
      }
   }

   public AuthenticationStepResult retrieveOfflineQrCodeChallenge() {
      Airlock2FAAuthenticationStepSession session = this.stepSession();
      if (session.getPollingCache().hasResult()) {
         return this.stepResultFactory().failDueToUnexpectedCall("Call is not allowed after the final polling result is ready.").build();
      } else {
         return session.getOfflineQrCodeChallenge().isPresent()
            ? this.stepResultHelper()
               .nextStayStepResult(session)
               .toBuilder()
               .resultData(session.getOfflineQrCodeChallenge().get())
               .clearAdditionalAttributes()
               .build()
            : this.stepResultFactory().failDueToUnexpectedCall("No Offline QR Code authentication ongoing.").build();
      }
   }

   private void generateOfflineQrCodeIfNecessary() {
      Airlock2FAAuthenticationStepSession session = this.stepSession();
      if (!session.getOfflineQrCodeChallenge().isPresent()
         && session.getRemainingFactors().contains(Airlock2FAFactor.OFFLINE_QR_CODE)
         && session.remainingDevices().hasOneDevice()) {
         Airlock2FAAccountId accountId = session.getAccountIdOrThrow();
         Airlock2FAAuthDevice device = session.remainingDevices().onlyDeviceOrThrow();

         try {
            SessionBasedAuthenticationAnswer.OfflineQrCodeAuthenticationAnswer answer = ((Airlock2FAAuthenticationService)this.service.get())
               .authenticateWithOfflineQrCode(accountId, device, this.generateQrCodeMessage());
            if (answer.immediate().isPresent()) {
               this.logHelper()
                  .log(
                     "Not creating Offline QR Code challenge because "
                        + answer.immediate().get().getKnownStatus().map(AuthenticationStatus::getValue).orElse("unknown"),
                     this.logData()
                  );
            } else {
               session.setFactorUsedForAuthentication(session.topFactor().orElseThrow());
               DeferredAuthenticationAnswer.OfflineQrCodeDeferredAuthenticationAnswer deferredAnswer = (DeferredAuthenticationAnswer.OfflineQrCodeDeferredAuthenticationAnswer)answer.deferred()
                  .orElseThrow();
               this.handleDeferredAnswer(deferredAnswer, Airlock2FAFactor.OFFLINE_QR_CODE);
               Airlock2FAQrCodeImage qrCodeImage = ((Airlock2FAAuthenticationService)this.service.get())
                  .retrieveOfflineQrCodeImage(accountId, deferredAnswer.getQrCodeUrl());
               Airlock2FAOfflineQrCodeChallenge challenge = new Airlock2FAOfflineQrCodeChallenge(qrCodeImage);
               session.setOfflineQrCodeChallenge(challenge);
               this.logHelper().log("Created offline QR code challenge.", this.logData());
            }
         } catch (Airlock2FAMessageProviderException e) {
            throw new FailureException(e);
         }
      }
   }

   private void handleDeferredAnswer(DeferredAuthenticationAnswer answer, Airlock2FAFactor factor) {
      this.stepSession().setFuturaeSession(answer.getSessionId(), factor);
      this.logHelper().logSessionStarted(factor, this.logData());
   }

   public AuthenticationStepResult checkOfflineQrCodeOtp(Airlock2FAOtp otp) {
      Airlock2FAAuthenticationStepSession session = this.stepSession();
      if (!session.isInOfflineMode()) {
         return this.stepResultFactory().failDueToUnexpectedCall("Offline mode must be enabled before using Offline QR Code OTP authentication.").build();
      }

      if (session.getOfflineQrCodeChallenge().isEmpty()
         || !session.getRemainingFactors().contains(Airlock2FAFactor.OFFLINE_QR_CODE)
         || session.getFuturaeSession().isEmpty()) {
         return this.stepResultFactory().failDueToUnexpectedCall("Offline QR Code OTP check not expected.").build();
      }

      if (!session.remainingDevices().hasOneDevice()) {
         return this.stepResultFactory().failDueToUnexpectedCall("Offline QR Code is not possible with multiple remaining devices.").build();
      }

      Airlock2FAAccountId accountId = session.getAccountIdOrThrow();
      Result<Airlock2FAAuthenticationService.ValidateOfflineQrCodeAnswer, FuturaeErrorResponseException> answer = ((Airlock2FAAuthenticationService)this.service
            .get())
         .validateOfflineQrCode(accountId, session.getFuturaeSession().orElseThrow().getSessionId(), otp);
      if (answer.isPresent()) {
         Airlock2FADeviceId deviceId = answer.get().getDeviceId().orElseThrow(() -> new FailureException("Expected device ID in \"allow\" answer."));
         Optional<AuthenticationStepResult> failureResult = this.validateCooldownPeriod(deviceId);
         if (failureResult.isPresent()) {
            return failureResult.get();
         } else if (answer.get().getDeviceId().stream().anyMatch(id -> !id.equals(session.getOnlyDeviceIdOrThrow()))) {
            throw new FailureException("Inconsistent device Id between IAM and Futurae.");
         } else {
            return this.handleCheckOfflineQrCodeOtpResult(answer.get().getResult());
         }
      } else if (answer.getFailure().type() == FuturaeErrorResponseException.FuturaeErrorResponseType.BAD_REQUEST) {
         LOG.debug("Failed to check Offline QR Code OTP.", answer.getFailure());
         return this.stepResultHelper()
            .factorCheckFailed(
               Airlock2FAFactor.OFFLINE_QR_CODE,
               Airlock2FAUserFactorAuthenticationStep.Airlock2FAUserFactorAuthenticationStepError.AIRLOCK_2FA_QR_CODE_OTP_WRONG
            );
      } else {
         throw new Airlock2FAAuthenticationException(answer.getFailure());
      }
   }

   private AuthenticationStepResult handleCheckOfflineQrCodeOtpResult(Airlock2FAAuthenticationService.OfflineValidationResult validationResult) {
      AuthenticationStepResult result = switch (validationResult) {
         case ALLOW -> {
            this.saveFlowAttributes(this.stepSession(), Airlock2FAFactor.OFFLINE_QR_CODE);
            this.stepSession().setFactorUsedForAuthentication(Airlock2FAFactor.OFFLINE_QR_CODE);
            yield this.stepResultHelper().factorCheckSucceeded(Airlock2FAFactor.OFFLINE_QR_CODE);
         }
         case WAITING -> this.stepResultHelper()
            .factorCheckFailRetry(
               Airlock2FAFactor.OFFLINE_QR_CODE,
               Airlock2FAUserFactorAuthenticationStep.Airlock2FAUserFactorAuthenticationStepError.AIRLOCK_2FA_QR_CODE_OTP_WRONG
            );
         case DENY -> this.stepResultHelper()
            .factorCheckFailed(
               Airlock2FAFactor.OFFLINE_QR_CODE,
               Airlock2FAUserFactorAuthenticationStep.Airlock2FAUserFactorAuthenticationStepError.AIRLOCK_2FA_QR_CODE_OTP_WRONG
            );
      };
      if (validationResult.isFinalResult()) {
         this.stepSession().clearFuturaeSession();
      }

      return result;
   }

   private void saveFlowAttributes(Airlock2FAAuthenticationStepSession session, Airlock2FAFactor factor) {
      Airlock2FADeviceId deviceId = session.getOnlyDeviceIdOrThrow();
      ((Airlock2FAFlowSessionAttributesService)this.attributes.get())
         .saveAuthTokenId(
            factor,
            deviceId,
            session.getFactorsAllowedByFuturaeAndWithCapableDevices(
               ((Airlock2FAAuthenticationStepSettings)this.settings.get()).getDeviceSettings().payloadEncryptionSettings()
            )
         );
      Optional<Duration> cooldownPeriod = ((Airlock2FAAuthenticationStepSettings)this.settings.get()).getDeviceSettings().cooldownSettings().cooldownPeriod();
      Airlock2FAAccountId accountId = session.getAccountIdOrThrow();
      ((Airlock2FAFlowSessionAttributesService)this.attributes.get())
         .saveAuthDevice(
            deviceId,
            ((Airlock2FACooldownInformationService)this.cooldownService.get()).determineAuthDeviceCooldownStatus(accountId, deviceId, cooldownPeriod),
            ((Airlock2FACooldownInformationService)this.cooldownService.get()).determineAuthDeviceCooldownEnds(accountId, deviceId, cooldownPeriod),
            cooldownPeriod
         );
   }

   public AuthenticationStepResultFactory stepResultFactory() {
      return ((Optional)this.stepSession.get())
         .<AuthenticationStepResultFactory>map(session -> AuthenticationStepResultFactory.forFactorDetail(session.authenticationFactorDetail()))
         .orElseGet(() -> AuthenticationStepResultFactory.forFactor(KnownAuthenticationFactor.PREAUTH));
   }

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

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

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

   private Airlock2FAAuthenticationStepSession stepSession() {
      return (Airlock2FAAuthenticationStepSession)((Optional)this.stepSession.get()).orElseThrow();
   }

   private AuthenticationStepResultHelper stepResultHelper() {
      return new AuthenticationStepResultHelper(
         this.settings, () -> (Airlock2FAAuthenticationStepSession)((Optional)this.stepSession.get()).orElseThrow(), this.logHelper, this.timeProvider
      );
   }

   private PollingService<Airlock2FAAuthenticationStepSession, AuthenticationStepResult> pollingService() {
      return new PollingService<>(Airlock2FAAuthenticationStepSession::getPollingCache, this::pollInternal, this.stepResultHelper()::nextStayStepResult);
   }
}
