package com.airlock.iam.flow.shared.domain.service.cronto;

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.mapification.Mapifiable;
import com.airlock.iam.base.api.domain.model.mapification.MapifiedValues;
import com.airlock.iam.base.api.domain.model.user.UserId;
import com.airlock.iam.base.domain.model.mapification.MapifiedValuesBuilder;
import com.airlock.iam.base.misc.log.LogUtil;
import com.airlock.iam.common.api.domain.model.cronto.CrontoMessageProvider;
import com.airlock.iam.common.api.domain.model.cronto.CrontoTransactionInformation;
import com.airlock.iam.common.api.domain.model.user.UserIdentity;
import com.airlock.iam.common.domain.model.user.PrincipalInfo;
import com.airlock.iam.common.domain.service.mapification.Mapifier;
import com.airlock.iam.core.domain.model.cronto.CrontoCheckingSettings;
import com.airlock.iam.core.domain.model.cronto.CrontoOtp;
import com.airlock.iam.core.misc.cronto.CrontoChallenge;
import com.airlock.iam.core.misc.cronto.CrontoResponse;
import com.airlock.iam.core.misc.impl.authen.CrontoUsedDeviceInfo;
import com.airlock.iam.core.misc.impl.authen.SimpleStringGenerator;
import com.airlock.iam.core.misc.impl.authen.cronto.DeviceSelectionInformation;
import com.airlock.iam.core.misc.impl.cronto.CrontoChallengeResult;
import com.airlock.iam.core.misc.impl.token.cronto.CrontoDeviceToken;
import com.airlock.iam.core.misc.impl.token.cronto.CrontoLetterToken;
import com.airlock.iam.elog.api.ELogger;
import com.airlock.iam.elog.api.ELoggerFactory;
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.inject.Inject;
import java.util.List;
import java.util.Optional;

public final class CrontoCheckingServiceImpl implements CrontoCheckingService {
   private static final SimpleStringGenerator PUSH_LOGIN_ID_GENERATOR = new SimpleStringGenerator("{DISTINCT:8}");
   private static final ELogger LOG = ELoggerFactory.getLogger(CrontoCheckingServiceImpl.class);
   private final CrontoCheckingSettings settings;
   private final CrontoCheckingServiceImpl.CrontoCheckingSession session;
   private final PrincipalInfo user;
   private final CrontoMessageProvider messageProvider;
   private final CrontoMethodAndDeviceSelectionHandler selectionHandler;
   private final SigningLogger signingLogger;
   private final HelpdeskLogger helpdeskLogger;

   @Inject
   public CrontoCheckingServiceImpl(
      SigningLogger signingLogger,
      CrontoCheckingSettings settings,
      CrontoCheckingServiceImpl.CrontoCheckingSession session,
      PrincipalInfo user,
      CrontoMessageProvider messageProvider,
      CrontoMethodAndDeviceSelectionHandler selectionHandler,
      HelpdeskLogger helpdeskLogger
   ) {
      this.signingLogger = signingLogger;
      this.settings = settings;
      this.session = session;
      this.user = user;
      this.messageProvider = messageProvider;
      this.selectionHandler = selectionHandler;
      this.helpdeskLogger = helpdeskLogger;
   }

   public static CrontoCheckingServiceImpl.CrontoCheckingSession createNewSession(CrontoCheckingSettings settings) {
      return new CrontoCheckingServiceImpl.CrontoCheckingSession(settings.getMaxResponseRetries());
   }

   @Override
   public CrontoCheckingService.CrontoInitCheckResult initiate(Optional<CrontoUsedDeviceInfo> authTokenId) {
      Optional<CrontoCheckingService.CrontoInitCheckResult> noSuitableDeviceResult = this.createFailedResultIfUserHasNoSuitableDevice();
      if (noSuitableDeviceResult.isPresent()) {
         return noSuitableDeviceResult.get();
      }

      DeviceSelectionInformation usableDevices = this.createDeviceSelectionInformation(authTokenId, Optional.empty());
      return usableDevices.isUserChoiceRequired()
         ? CrontoCheckingService.CrontoInitCheckResult.initSelection(usableDevices.getDeviceChoices())
         : CrontoCheckingService.CrontoInitCheckResult.initChallenge(this.createChallenge(usableDevices));
   }

   private Optional<String> generatePushLoginIdIfConfigured() {
      return this.settings.isShowLoginIdOnPush() ? Optional.of(PUSH_LOGIN_ID_GENERATOR.createString(null)) : Optional.empty();
   }

   private DeviceSelectionInformation createDeviceSelectionInformation(Optional<CrontoUsedDeviceInfo> requestedDeviceInfo, Optional<String> selectedDeviceId) {
      return this.selectionHandler.createDeviceSelectionInformation(requestedDeviceInfo, this.userId(), selectedDeviceId);
   }

   @Override
   public CrontoCheckingService.PushDeviceSelectionResult selectPushDevice(String selectionId) {
      ViolationException.checkNotPresent(this.session.getDisplayMessage(), "Cannot select device after authentication is initiated");
      DeviceSelectionInformation usableDevices = this.createDeviceSelectionInformation(Optional.empty(), Optional.of(selectionId));
      return usableDevices.isUserChoiceRequired()
         ? CrontoCheckingService.PushDeviceSelectionResult.selectionFailure(usableDevices.getDeviceChoices())
         : CrontoCheckingService.PushDeviceSelectionResult.selectionSuccess(this.createChallenge(usableDevices));
   }

   @Override
   public CrontoCheckingService.PushDeviceSelectionResult selectOffline() {
      return CrontoCheckingService.PushDeviceSelectionResult.selectionSuccess(
         this.createChallenge(DeviceSelectionInformation.createNonPushSelection(Optional.of(CrontoChallengeResult.CrontoMethod.SCAN_TAN)))
      );
   }

   @Override
   public CrontoCheckingService.CrontoCheckResult checkOtp(CrontoOtp otp) {
      CrontoChallenge challenge = this.session.getCrontoChallenge().orElseThrow(() -> new FailureException("No challenge in session"));
      Optional<CrontoCheckingService.CrontoCheckResult> onlineResult = this.queryTokenState(challenge).flatMap(this::createResultIfFailedOnlineValidation);
      if (onlineResult.isPresent()) {
         return onlineResult.get();
      }

      CrontoResponse resp = this.getResponseFromCode(challenge, otp);
      if (challenge.isOnlineValidation()) {
         this.deleteChallengeToken(challenge);
         this.session.setMethod(CrontoChallengeResult.CrontoMethod.SCAN_TAN);
      }

      return this.createResultFromResponse(resp);
   }

   private CrontoResponse getResponseFromCode(CrontoChallenge challenge, CrontoOtp otp) {
      return this.settings.getCrontoHandler().getResponseFromCode(challenge, otp);
   }

   @Override
   public CrontoCheckingService.CrontoCheckResult pollOnlineValidation() {
      CrontoChallenge challenge = this.session.getCrontoChallenge().orElseThrow(() -> new FailureException("No challenge in session"));
      Optional<CrontoResponse> onlineResponse = this.queryTokenState(challenge);
      if (onlineResponse.isEmpty()) {
         return CrontoCheckingService.CrontoCheckResult.checkFailure(CrontoCheckingService.CheckFailureCode.PENDING, this.session.getMethod().orElseThrow());
      }

      this.deleteChallengeToken(challenge);
      Optional<CrontoCheckingService.CrontoCheckResult> failedOnline = this.createResultIfFailedOnlineValidation(onlineResponse.get());
      return failedOnline.isPresent() ? failedOnline.get() : this.createResultFromResponse(onlineResponse.get());
   }

   @Override
   public CrontoCheckingService.CrontoCheckResult cancelValidation() {
      CrontoChallenge challenge = this.session.getCrontoChallenge().orElseThrow(() -> new FailureException("No challenge in session"));
      Optional<CrontoCheckingService.CrontoCheckResult> failedOnline = this.queryTokenState(challenge).flatMap(this::createResultIfFailedOnlineValidation);
      this.deleteChallengeToken(challenge);
      if (failedOnline.isPresent()) {
         return failedOnline.get();
      }

      this.helpdeskLogger.log(this.userId(), "Canceled Cronto verification.");
      this.signingLogger.log("User " + LogUtil.quote(this.userId()) + " canceled Cronto verification.");
      return CrontoCheckingService.CrontoCheckResult.checkFailure(CrontoCheckingService.CheckFailureCode.CANCELED, this.session.getMethod().orElseThrow());
   }

   private Optional<CrontoCheckingService.CrontoInitCheckResult> createFailedResultIfUserHasNoSuitableDevice() {
      List<CrontoDeviceToken> devices = this.settings.getCrontoHandler().getAllDevicesForUser(this.userId());
      List<CrontoLetterToken> letters = this.settings.getCrontoHandler().getAllLettersForUser(this.userId());
      if (devices.isEmpty() && !letters.isEmpty()) {
         return this.optionalResult(CrontoCheckingService.InitFailureCode.ACTIVATION_REQUIRED);
      } else if (this.settings.isAuthenticatePushDevicesOnly() && !this.selectionHandler.hasActivePushDevice(this.userId())) {
         LOG.info(
            "The user with username "
               + LogUtil.quote(this.userId().getValue())
               + " does not have an active push-enabled device but the configuration allows only push devices"
         );
         return this.optionalResult(CrontoCheckingService.InitFailureCode.NO_PUSH_DEVICE);
      } else {
         return !this.settings.getCrontoHandler().hasActiveToken(this.userId())
            ? this.optionalResult(CrontoCheckingService.InitFailureCode.NO_ACTIVE_DEVICE)
            : Optional.empty();
      }
   }

   private Optional<CrontoCheckingService.CrontoInitCheckResult> optionalResult(CrontoCheckingService.InitFailureCode code) {
      return Optional.of(CrontoCheckingService.CrontoInitCheckResult.initFailure(code));
   }

   private CrontoCheckingService.CrontoServiceChallenge createChallenge(DeviceSelectionInformation deviceSelectionInformation) {
      boolean isPush = deviceSelectionInformation.isPush();
      Optional<String> loginId = this.generatePushLoginIdIfConfigured().filter(x -> isPush);
      CrontoTransactionInformation message = this.messageProvider.generateCrontoMessage(loginId);
      this.session.setPushLoginId(loginId);
      this.session.setDisplayMessage(message);
      if (isPush && deviceSelectionInformation.isPushToAll()) {
         CrontoCheckingService.CrontoServiceChallenge pushChallengeResult = this.createResultWithPushToAllChallenge(
            deviceSelectionInformation.getSelectedDevices()
         );
         LOG.info(String.format("Created push to all Cronto challenge for user \"%s\"", this.userId().getValue()));
         return pushChallengeResult;
      } else if (isPush) {
         CrontoCheckingService.CrontoServiceChallenge pushChallengeResult = this.createResultWithPushChallenge(deviceSelectionInformation.getSinglePushDevice());
         LOG.info(String.format("Created push Cronto challenge for user \"%s\"", this.userId().getValue()));
         return pushChallengeResult;
      } else {
         CrontoCheckingService.CrontoServiceChallenge nonPushChallengeResult = this.createResultWithNonPushChallenge(deviceSelectionInformation);
         LOG.info(String.format("Created non-push Cronto challenge for user \"%s\"", this.userId().getValue()));
         return nonPushChallengeResult;
      }
   }

   private CrontoCheckingService.CrontoServiceChallenge createResultWithNonPushChallenge(DeviceSelectionInformation deviceSelectionInformation) {
      CrontoChallengeResult result = this.settings
         .getCrontoHandler()
         .createImageChallenge(
            this.userId(),
            deviceSelectionInformation.getCrontoMethod(),
            this.session.getDisplayMessage().orElseThrow(() -> new FailureException("Must have display message to create image challenge")),
            this.messageProvider.generateCrontoPushInformation(this.session.getPushLoginId())
         );
      return this.updateAuthenticationSessionAndCreateResult(result);
   }

   private CrontoCheckingService.CrontoServiceChallenge createResultWithPushChallenge(CrontoDeviceToken selectedDevice) {
      CrontoChallengeResult result = this.createPushChallengeResult(selectedDevice);
      return this.updateAuthenticationSessionAndCreateResult(result);
   }

   private CrontoCheckingService.CrontoServiceChallenge createResultWithPushToAllChallenge(List<CrontoDeviceToken> deviceChoices) {
      CrontoChallengeResult result = this.createPushToAllChallengeResult(deviceChoices);
      return this.updateAuthenticationSessionAndCreateResult(result);
   }

   private CrontoCheckingService.CrontoServiceChallenge updateAuthenticationSessionAndCreateResult(CrontoChallengeResult result) {
      this.session.setCrontoChallenge(result.getChallenge());
      this.session.setMethod(result.getMethod().orElseThrow());
      return new CrontoCheckingService.CrontoServiceChallenge(result.getChallenge(), result.getMethod(), result.getDevices(), this.session.getPushLoginId());
   }

   private CrontoChallengeResult createPushToAllChallengeResult(List<CrontoDeviceToken> deviceChoices) {
      return this.settings
         .getCrontoHandler()
         .createPushChallengeAndSendToAllDevices(
            this.userId(),
            deviceChoices,
            this.settings.getChallengeType(),
            this.session.getDisplayMessage().orElseThrow(() -> new FailureException("Must have display message to create push challenge")),
            this.messageProvider.generateCrontoPushInformation(this.session.getPushLoginId())
         );
   }

   private CrontoChallengeResult createPushChallengeResult(CrontoDeviceToken selectedDevice) {
      return this.settings
         .getCrontoHandler()
         .createPushChallenge(
            this.userId(),
            selectedDevice,
            this.settings.getChallengeType(),
            this.session.getDisplayMessage().orElseThrow(() -> new FailureException("Must have display message to create push challenge")),
            this.messageProvider.generateCrontoPushInformation(this.session.getPushLoginId())
         );
   }

   private Optional<CrontoCheckingService.CrontoCheckResult> createResultIfFailedOnlineValidation(CrontoResponse onlineResponse) {
      if (onlineResponse.isValid()) {
         return Optional.empty();
      } else {
         this.session.setFailedDefinitely();
         if (onlineResponse.isRejected()) {
            this.helpdeskLogger.log(this.userId(), "Canceled Cronto verification on device.");
            this.signingLogger.log("User " + LogUtil.quote(this.userId()) + " canceled Cronto verification on device");
            return Optional.of(
               CrontoCheckingService.CrontoCheckResult.checkFailure(CrontoCheckingService.CheckFailureCode.CANCELED, this.session.getMethod().orElseThrow())
            );
         } else {
            return Optional.of(this.createResultFromResponse(onlineResponse));
         }
      }
   }

   private void deleteChallengeToken(CrontoChallenge challenge) {
      this.settings.getCrontoHandler().deleteChallengeToken(challenge);
   }

   private CrontoCheckingService.CrontoCheckResult createResultFromResponse(CrontoResponse response) {
      if (!response.isValid()) {
         LOG.info(String.format("Received invalid Cronto response for user \"%s\"", this.userId().getValue()));
         return createResponseWrongResult(this.session);
      } else {
         return this.handleCorrectOtp(response);
      }
   }

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

   private Optional<CrontoResponse> queryTokenState(CrontoChallenge challenge) {
      return this.settings.getCrontoHandler().queryChallengeTokenState(challenge);
   }

   private static CrontoCheckingService.CrontoCheckResult createResponseWrongResult(CrontoCheckingServiceImpl.CrontoCheckingSession session) {
      session.increaseNumberOfTries();
      return session.isFailedDefinitely()
         ? CrontoCheckingService.CrontoCheckResult.checkFailure(CrontoCheckingService.CheckFailureCode.WRONG_NO_RETRY, session.getMethod().orElseThrow())
         : CrontoCheckingService.CrontoCheckResult.checkFailure(CrontoCheckingService.CheckFailureCode.WRONG_RETRY, session.getMethod().orElseThrow());
   }

   private CrontoCheckingService.CrontoCheckResult handleCorrectOtp(CrontoResponse response) {
      if (this.settings.getCrontoHandler().isBlocked(this.userId(), response.getDeviceId().orElseThrow())) {
         return CrontoCheckingService.CrontoCheckResult.checkFailure(
            CrontoCheckingService.CheckFailureCode.DEVICE_BLOCKED, this.session.getMethod().orElseThrow()
         );
      }

      this.settings
         .getCrontoHandler()
         .registerSuccessfulDeviceUsage(
            this.userId(), response.getDeviceId().orElseThrow(), this.session.getMethod().orElseThrow().isOnlineValidation(), this.signingLogger
         );
      return CrontoCheckingService.CrontoCheckResult.checkSuccess(response.getDeviceId().orElseThrow(), this.session.getMethod().orElseThrow());
   }

   public static final class CrontoCheckingSession implements Mapifiable {
      private static final String MAX_RETRIES_KEY = "maxRetries";
      private static final String LOGIN_ID_KEY = "loginId";
      private static final String MESSAGE_KEY = "message";
      private static final String MSG_DETAILS_KEY = "details";
      private static final String MSG_AMOUNT_KEY = "amount";
      private static final String MSG_CURRENCY_KEY = "currency";
      private static final String MSG_AMOUNT_LABEL_KEY = "amountLabel";
      private static final String CHALLENGE_KEY = "challenge";
      private static final String METHOD_KEY = "method";
      private static final String NUM_TRIES_KEY = "tries";
      private static final String FAILED_KEY = "failed";
      private final int maxResponseRetries;
      private Optional<String> pushLoginId = Optional.empty();
      private Optional<CrontoTransactionInformation> displayMessage = Optional.empty();
      private Optional<CrontoChallenge> crontoChallenge = Optional.empty();
      private Optional<CrontoChallengeResult.CrontoMethod> method = Optional.empty();
      private int numberOfTries;
      private boolean failedDefinitely;

      public CrontoCheckingSession(int maxResponseRetries) {
         this.maxResponseRetries = maxResponseRetries;
      }

      public void increaseNumberOfTries() {
         this.numberOfTries++;
         if (this.numberOfTries > this.maxResponseRetries) {
            this.failedDefinitely = true;
         }
      }

      public void setDisplayMessage(CrontoTransactionInformation message) {
         this.displayMessage = Optional.of(message);
      }

      public void setCrontoChallenge(CrontoChallenge challenge) {
         this.crontoChallenge = Optional.of(challenge);
      }

      public void setMethod(CrontoChallengeResult.CrontoMethod crontoMethod) {
         this.method = Optional.of(crontoMethod);
      }

      public void setFailedDefinitely() {
         this.failedDefinitely = true;
      }

      @Override
      public MapifiedValues mapify() {
         Mapifier mapifier = Mapifier.getInstance();
         return MapifiedValuesBuilder.mapifiedValues()
            .put("maxRetries", this.maxResponseRetries)
            .putIfPresent("loginId", this.pushLoginId)
            .putIfPresent("message", this.displayMessage.map(CrontoCheckingServiceImpl.CrontoCheckingSession::mapifyMessage))
            .putIfPresent("challenge", this.crontoChallenge.map(mapifier::mapify))
            .putIfPresent("method", this.method.map(Enum::name))
            .put("tries", this.numberOfTries)
            .put("failed", this.failedDefinitely)
            .build();
      }

      private static MapifiedValues mapifyMessage(CrontoTransactionInformation message) {
         return MapifiedValuesBuilder.mapifiedValues()
            .put("details", message.getDetails())
            .putIfPresent("amount", message.getAmount())
            .putIfPresent("currency", message.getCurrency())
            .putIfPresent("amountLabel", message.getAmountLabel())
            .build();
      }

      public static CrontoCheckingServiceImpl.CrontoCheckingSession demapify(MapifiedValues values) {
         Mapifier mapifier = Mapifier.getInstance();
         return new CrontoCheckingServiceImpl.CrontoCheckingSession(
            values.<Integer>get("maxRetries"),
            values.getOptional("loginId"),
            values.demapifyOptional("message", CrontoCheckingServiceImpl.CrontoCheckingSession::demapifyMessage),
            values.demapifyOptional("challenge", mapifier::demapify),
            values.<Object>getOptional("method").map(v -> CrontoChallengeResult.CrontoMethod.valueOf((String)v)),
            values.<Integer>get("tries"),
            values.<Boolean>get("failed")
         );
      }

      private static CrontoTransactionInformation demapifyMessage(MapifiedValues values) {
         return CrontoTransactionInformation.builder()
            .details(values.get("details"))
            .amount(values.getOptional("amount"))
            .currency(values.getOptional("currency"))
            .amountLabel(values.getOptional("amountLabel"))
            .build();
      }

      @SuppressFBWarnings(justification = "generated code")
      public int getMaxResponseRetries() {
         return this.maxResponseRetries;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Optional<String> getPushLoginId() {
         return this.pushLoginId;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Optional<CrontoTransactionInformation> getDisplayMessage() {
         return this.displayMessage;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Optional<CrontoChallenge> getCrontoChallenge() {
         return this.crontoChallenge;
      }

      @SuppressFBWarnings(justification = "generated code")
      public Optional<CrontoChallengeResult.CrontoMethod> getMethod() {
         return this.method;
      }

      @SuppressFBWarnings(justification = "generated code")
      public int getNumberOfTries() {
         return this.numberOfTries;
      }

      @SuppressFBWarnings(justification = "generated code")
      public boolean isFailedDefinitely() {
         return this.failedDefinitely;
      }

      @SuppressFBWarnings(justification = "generated code")
      private CrontoCheckingSession(
         int maxResponseRetries,
         Optional<String> pushLoginId,
         Optional<CrontoTransactionInformation> displayMessage,
         Optional<CrontoChallenge> crontoChallenge,
         Optional<CrontoChallengeResult.CrontoMethod> method,
         int numberOfTries,
         boolean failedDefinitely
      ) {
         this.maxResponseRetries = maxResponseRetries;
         this.pushLoginId = pushLoginId;
         this.displayMessage = displayMessage;
         this.crontoChallenge = crontoChallenge;
         this.method = method;
         this.numberOfTries = numberOfTries;
         this.failedDefinitely = failedDefinitely;
      }

      @SuppressFBWarnings(justification = "generated code")
      public void setPushLoginId(Optional<String> pushLoginId) {
         this.pushLoginId = pushLoginId;
      }
   }
}
