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.Airlock2FAMobileOnlyAuthenticationStepConfig;
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.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.core.domain.model.authentication.method.KnownAuthenticationMethods;
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.Airlock2FADeviceAndFactorApplicability;
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.Airlock2FAFactorSet;
import com.airlock.iam.factor.domain.model.airlock2fa.Airlock2FAMobileAuthUri;
import com.airlock.iam.factor.domain.model.airlock2fa.Airlock2FAPayloadEncryptionSettings;
import com.airlock.iam.factor.domain.model.airlock2fa.Airlock2FAPreAuthenticationData;
import com.airlock.iam.factor.domain.model.airlock2fa.Airlock2FAUser;
import com.airlock.iam.factor.domain.model.airlock2fa.Airlock2FAUserPresenceVerification;
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.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.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.domain.model.airlock2fa.Airlock2FAMobileOnlyChallenge;
import com.airlock.iam.flow.shared.domain.model.airlock2fa.CommonAirlock2FAStepErrors;
import com.airlock.iam.flow.shared.domain.service.PollingService;
import com.airlock.iam.flow.shared.domain.service.airlock2fa.Airlock2FAFlowSessionAttributesService;
import jakarta.annotation.Nonnull;
import jakarta.inject.Inject;
import jakarta.inject.Provider;
import java.time.Duration;
import java.util.Optional;

@LogicForConfig(Airlock2FAMobileOnlyAuthenticationStepConfig.class)
public final class Airlock2FAMobileOnlyAuthenticationStepImpl implements Airlock2FAMobileOnlyAuthenticationStep {
   private static final ELogger LOG = ELoggerFactory.getLogger(Airlock2FAMobileOnlyAuthenticationStepImpl.class);
   private static final String AUTHENTICATION_ACTION_DESCRIPTION = "authentication";
   private static final String FACTOR_CHECK_ACTION_DESCRIPTION = "factor check";
   private static final String APP_APPROVAL_URL_ATTRIBUTE = "appApprovalUrl";
   private final Provider<Airlock2FAMobileOnlyAuthenticationStepSettings> settings;
   private final Provider<PrincipalInfo> user;
   private final Provider<Airlock2FAAuthenticationService> service;
   private final Provider<Airlock2FAFlowSessionAttributesService> attributes;
   private final Provider<Optional<Airlock2FAMobileOnlyAuthenticationStepSession>> stepSession;
   private final Provider<Airlock2FAUserRepository> repository;
   private final Provider<Airlock2FALogHelper> logHelper;
   private final TimeProvider timeProvider;
   private final Provider<Airlock2FADeviceEventFactory> eventFactory;
   private final Provider<Airlock2FACooldownInformationService> cooldownService;
   private final PollingService<Airlock2FAMobileOnlyAuthenticationStepSession, AuthenticationStepResult> pollingService;

   @Inject
   public Airlock2FAMobileOnlyAuthenticationStepImpl(
      Provider<Airlock2FAMobileOnlyAuthenticationStepSettings> settings,
      Provider<PrincipalInfo> user,
      Provider<Airlock2FAAuthenticationService> service,
      Provider<Airlock2FAFlowSessionAttributesService> attributes,
      Provider<Optional<Airlock2FAMobileOnlyAuthenticationStepSession>> stepSession,
      Provider<Airlock2FAUserRepository> repository,
      Provider<Airlock2FALogHelper> logHelper,
      TimeProvider timeProvider,
      Provider<Airlock2FADeviceEventFactory> eventFactory,
      Provider<Airlock2FACooldownInformationService> cooldownService
   ) {
      this.settings = settings;
      this.user = user;
      this.service = service;
      this.attributes = attributes;
      this.stepSession = stepSession;
      this.repository = repository;
      this.logHelper = logHelper;
      this.timeProvider = timeProvider;
      this.eventFactory = eventFactory;
      this.cooldownService = cooldownService;
      this.pollingService = new PollingService<>(Airlock2FAMobileOnlyAuthenticationStepSession::getPollingCache, this::pollInternal, this::createPendingResult);
   }

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

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

      Airlock2FAAccountId accountId = account.get().getAccountId();
      this.stepSession().setAccountId(accountId);
      return this.preAuthenticate(accountId).orElseGet(() -> this.startAuthentication(accountId));
   }

   private Optional<AuthenticationStepResult> preAuthenticate(Airlock2FAAccountId accountId) {
      Airlock2FAPreAuthenticationService.PreAuthenticationAnswer answer = ((Airlock2FAAuthenticationService)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.handlePreAuthSuccess(accountId, answer);
         case ALLOW -> this.handlePreAuthAllow();
         case DENY -> this.handlePreAuthDeny(accountId, answer);
         case UNKNOWN -> {
            LOG.info("Airlock 2FA account " + accountId + " is not recognized by the Futurae server.");
            yield Optional.of(this.authenticationFailedStepResult());
         }
      };
   }

   @Nonnull
   private Optional<AuthenticationStepResult> handlePreAuthSuccess(
      Airlock2FAAccountId accountId, Airlock2FAPreAuthenticationService.PreAuthenticationAnswer answer
   ) {
      this.stepSession()
         .setPreAuthInformation(
            answer.getAllowedFactors(),
            ((Airlock2FAMobileOnlyAuthenticationStepSettings)this.settings.get()).deviceSettings(),
            applicableDevices(answer, ((Airlock2FAMobileOnlyAuthenticationStepSettings)this.settings.get()).deviceSettings().payloadEncryptionSettings())
         );
      if (this.stepSession().remainingDevices().isEmpty()) {
         if (this.stepSession().cooldownDevices().isEmpty()) {
            LOG.info(
               "Airlock 2FA account "
                  + accountId
                  + " does not allow Mobile Only authentication factor or has no enrolled devices that support the factor Mobile Only."
            );
            this.logHelper().logFailed(Optional.of(Airlock2FAFactor.MOBILE_ONLY), this.logData());
            return Optional.of(this.noValidTokenStepResult());
         } else {
            LOG.info("Airlock 2FA account " + accountId + " has no enrolled devices outside the cooldown period.");
            this.logHelper().logFailed(Optional.of(Airlock2FAFactor.MOBILE_ONLY), this.logData());
            return Optional.of(this.noDeviceOutsideCooldownPeriodStepResult());
         }
      } else {
         return Optional.empty();
      }
   }

   @Nonnull
   private Optional<AuthenticationStepResult> handlePreAuthAllow() {
      if (((Airlock2FAMobileOnlyAuthenticationStepSettings)this.settings.get()).enableBypassMode()) {
         this.stepSession().setPreAuthDoneWithBypass();
         return Optional.of(this.bypassSucceeded());
      } else {
         return Optional.of(this.failedDueToBypassMode());
      }
   }

   @Nonnull
   private Optional<AuthenticationStepResult> handlePreAuthDeny(
      Airlock2FAAccountId accountId, Airlock2FAPreAuthenticationService.PreAuthenticationAnswer answer
   ) {
      this.logHelper().logFailed(Optional.of(Airlock2FAFactor.MOBILE_ONLY), this.logData());
      if (accountIsLocked(answer)) {
         LOG.info("Airlock 2FA account " + accountId + " is locked.");
         return Optional.of(this.authMethodInactiveStepResult());
      } else if (accountHasNoDevices(answer)) {
         LOG.info("Airlock 2FA account " + accountId + " has no enrolled devices.");
         return Optional.of(this.noValidTokenStepResult());
      } else {
         LOG.info("Pre-authentication Airlock 2FA account " + accountId + " denied.");
         return Optional.of(this.authenticationFailedStepResult());
      }
   }

   private AuthenticationStepResult authenticationFailedStepResult() {
      return this.stepResultFactory().fail(AuthenticationFlowStep.GeneralAuthenticationStepError.AUTHENTICATION_FAILED).build();
   }

   private AuthenticationStepResult authMethodInactiveStepResult() {
      return this.stepResultFactory().fail(AuthenticationFlowStep.GeneralAuthenticationStepError.AUTH_METHOD_INACTIVE).build();
   }

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

   private AuthenticationStepResult noDeviceOutsideCooldownPeriodStepResult() {
      return this.stepResultFactory().fail(CommonAirlock2FAStepErrors.ALL_DEVICES_IN_COOLDOWN).isFailedFactor(false).build();
   }

   private AuthenticationStepResult startAuthentication(Airlock2FAAccountId accountId) {
      this.logHelper().log(String.format("Starting mobile-only authentication for account %s", LogUtil.quote(accountId)), this.logData());
      SessionBasedAuthenticationAnswer.MobileOnlyAuthenticationAnswer answer = ((Airlock2FAAuthenticationService)this.service.get())
         .authenticateWithMobileOnly(accountId, ((Airlock2FAMobileOnlyAuthenticationStepSettings)this.settings.get()).redirectUri());
      if (answer.immediate().isPresent()) {
         return this.handleImmediateAnswer(answer.immediate().get());
      }

      DeferredAuthenticationAnswer.MobileOnlyDeferredAuthenticationAnswer deferredAnswer = (DeferredAuthenticationAnswer.MobileOnlyDeferredAuthenticationAnswer)answer.deferred()
         .orElseThrow();
      Optional.of(this.stepSession().remainingDevices())
         .filter(Airlock2FADevices::hasOneDevice)
         .map(Airlock2FADevices::onlyDeviceOrThrow)
         .ifPresent(device -> this.stepSession().setDeviceId(device.getId()));
      this.stepSession().setFuturaeSession(deferredAnswer.getSessionId());
      this.logHelper().logSessionStarted(Airlock2FAFactor.MOBILE_ONLY, this.logData());
      Airlock2FAMobileAuthUri approvalUrl = deferredAnswer.getMobileOnlyAuthUri()
         .withScheme(((Airlock2FAMobileOnlyAuthenticationStepSettings)this.settings.get()).schemeOverride());
      this.stepSession().setApprovalUrl(approvalUrl);
      return this.stepResultFactory()
         .stay(Airlock2FAMobileOnlyAuthenticationStep.Airlock2FAMobileOnlyAuthenticationStepAction.AIRLOCK_2FA_MOBILE_ONLY_CHALLENGE_RETRIEVAL_REQUIRED)
         .addAdditionalAttribute("appApprovalUrl", approvalUrl.uri())
         .build();
   }

   private static Airlock2FADevices applicableDevices(
      Airlock2FAPreAuthenticationService.PreAuthenticationAnswer answer, Airlock2FAPayloadEncryptionSettings payloadEncryptionSettings
   ) {
      return Airlock2FADeviceAndFactorApplicability.builder()
         .allowedFactorsByBackend(answer.getAllowedFactors())
         .allowedFactorsByConfig(Airlock2FAFactorSet.of(Airlock2FAFactor.MOBILE_ONLY))
         .devices(answer.getDevices())
         .payloadEncryptionSettings(payloadEncryptionSettings)
         .build()
         .devicesForSelection();
   }

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

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

   public AuthenticationStepResult retrieveChallenge() {
      Airlock2FAMobileAuthUri mobileAuthUri = this.stepSession().getApprovalUrl().orElseThrow();
      return this.stepResultFactory()
         .stay(Airlock2FAAuthenticationStep.Airlock2FAAuthenticationStepAction.AIRLOCK_2FA_POLLING_REQUIRED)
         .resultData(new Airlock2FAMobileOnlyChallenge(mobileAuthUri))
         .build();
   }

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

   private AuthenticationStepResult pollInternal(Airlock2FAMobileOnlyAuthenticationStepSession session) {
      if (session.getFuturaeAuthSession().isEmpty()) {
         return this.stepResultFactory().failDueToUnexpectedCall("No ongoing Futurae authentication session.").build();
      }

      Airlock2FAFuturaeAuthSession futuraeSession = session.getFuturaeAuthSession().get();
      Airlock2FAAuthenticationService.AuthenticationStatusAnswer answer = ((Airlock2FAAuthenticationService)this.service.get())
         .checkAuthenticationStatus(futuraeSession);
      AuthenticationStepResult stepResult = this.handleStatusAnswer(answer);
      this.saveUsedDeviceInfo(stepResult);
      return stepResult;
   }

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

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

   public AuthenticationStepResultFactory stepResultFactory() {
      return ((Optional)this.stepSession.get()).filter(Airlock2FAMobileOnlyAuthenticationStepSession::isPreauthDone).isPresent()
         ? AuthenticationStepResultFactory.forFactorDetail(KnownAuthenticationFactorDetail.CRAM_AIRLOCK_2FA_MOBILE_ONLY)
         : AuthenticationStepResultFactory.forFactorDetail(KnownAuthenticationFactorDetail.PREAUTH_AIRLOCK_2FA);
   }

   private AuthenticationStepResult handleStatusAnswer(Airlock2FAAuthenticationService.AuthenticationStatusAnswer answer) {
      Airlock2FAMobileOnlyAuthenticationStepSession session = this.stepSession();
      answer.getDeviceId().ifPresent(session::setDeviceId);
      this.logHelper().logAuthenticationAnswerDetails(answer, this.logData());

      return switch (answer.getResult()) {
         case ALLOW -> {
            Airlock2FADeviceId deviceId = answer.getDeviceId().orElseThrow(() -> new FailureException("Expected device ID to be present, but it's missing"));
            if (session.isDeviceInCooldown(deviceId)) {
               if (((Airlock2FAMobileOnlyAuthenticationStepSettings)this.settings.get()).deviceSettings().cooldownSettings().cooldownConfiguredAndRespected()) {
                  yield this.failedDueToCooldownPeriod(deviceId);
               }

               this.publishDeviceInCooldownUsedEvent(this.userId(), session.getAccountId().orElseThrow(), deviceId);
            }

            Airlock2FAAuthDevice device = session.remainingDevices()
               .deviceWithId(deviceId)
               .orElseThrow(() -> new FailureException("Unknown device was used for authentication."));
            yield this.factorCheckSucceeded(device, answer.getUserPresenceVerification());
         }
         case DENY, TIMEOUT -> this.failed(Optional.of(Airlock2FAFactor.MOBILE_ONLY), true);
         case WAITING -> this.createPendingResult(session);
      };
   }

   private AuthenticationStepResult createPendingResult(Airlock2FAMobileOnlyAuthenticationStepSession session) {
      return this.stepResultFactory()
         .stay(Airlock2FAAuthenticationStep.Airlock2FAAuthenticationStepAction.AIRLOCK_2FA_POLLING_REQUIRED)
         .addAdditionalAttribute("appApprovalUrl", session.getApprovalUrl().orElseThrow().uri())
         .build();
   }

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

   private AuthenticationStepResult handleImmediateAnswer(ImmediateAuthenticationAnswer answer) {
      return switch (answer.getResult()) {
         case ALLOW -> this.failedDueToBypassMode();
         case DENY -> this.failed(Optional.empty(), false);
      };
   }

   private AuthenticationStepResult factorCheckSucceeded(Airlock2FAAuthDevice authDevice, Optional<Airlock2FAUserPresenceVerification> userPresenceVerification) {
      this.logHelper()
         .logSuccess(Airlock2FAFactor.MOBILE_ONLY, authDevice, "factor check", this.logData().withUserPresenceVerificationIfPresent(userPresenceVerification));
      return this.stepResultFactory().success().deviceUsageData(this.deviceUsageData(authDevice)).build();
   }

   private AuthenticationStepResult bypassSucceeded() {
      this.logHelper().logSuccessfulBypass();
      return this.stepResultFactory().success().build();
   }

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

   private AuthenticationStepResult failedDueToCooldownPeriod(Airlock2FADeviceId deviceId) {
      this.logHelper().logFailedDueToCooldownPeriod("authentication", deviceId);
      return this.stepResultFactory().fail(AuthenticationFlowStep.GeneralAuthenticationStepError.AUTHENTICATION_FAILED).isFailedFactor(false).build();
   }

   private AuthenticationStepResult failedDueToBypassMode() {
      this.logHelper().logFailedDueToBypassMode("authentication");
      return this.stepResultFactory().fail(AuthenticationFlowStep.GeneralAuthenticationStepError.AUTHENTICATION_FAILED).build();
   }

   private AuthenticationStepResult failed(Optional<Airlock2FAFactor> factor, boolean failedFactor) {
      this.logHelper().logFailed(factor, this.logData());
      return this.stepResultFactory().fail(AuthenticationFlowStep.GeneralAuthenticationStepError.AUTHENTICATION_FAILED).isFailedFactor(failedFactor).build();
   }

   private void saveUsedDeviceInfo(AuthenticationStepResult stepResult) {
      if (stepResult.isSuccess()) {
         Airlock2FADeviceId deviceId = this.stepSession().getDeviceId();
         Airlock2FAFactorSet deviceFactors = this.stepSession()
            .remainingDevices()
            .deviceWithId(deviceId)
            .map(
               device -> device.getSupportedFactors(
                  ((Airlock2FAMobileOnlyAuthenticationStepSettings)this.settings.get()).deviceSettings().payloadEncryptionSettings()
               )
            )
            .orElse(Airlock2FAFactorSet.allAirlock2FAUserFactors());
         Airlock2FAFactorSet allowedFactors = this.stepSession().getAllowedFactors();
         ((Airlock2FAFlowSessionAttributesService)this.attributes.get())
            .saveAuthTokenId(Airlock2FAFactor.MOBILE_ONLY, deviceId, allowedFactors.intersect(deviceFactors));
         Optional<Duration> cooldownPeriod = ((Airlock2FAMobileOnlyAuthenticationStepSettings)this.settings.get())
            .deviceSettings()
            .cooldownSettings()
            .cooldownPeriod();
         Airlock2FAAccountId accountId = this.stepSession().getAccountId().orElseThrow();
         ((Airlock2FAFlowSessionAttributesService)this.attributes.get())
            .saveAuthDevice(
               deviceId,
               ((Airlock2FACooldownInformationService)this.cooldownService.get()).determineAuthDeviceCooldownStatus(accountId, deviceId, cooldownPeriod),
               ((Airlock2FACooldownInformationService)this.cooldownService.get()).determineAuthDeviceCooldownEnds(accountId, deviceId, cooldownPeriod),
               cooldownPeriod
            );
      }
   }

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

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

   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().logData();
   }
}
