package com.airlock.iam.factor.infrastructure.service.fido;

import com.airlock.iam.base.api.domain.model.exception.FailureException;
import com.airlock.iam.base.api.domain.model.user.UserId;
import com.airlock.iam.base.misc.result.Result;
import com.airlock.iam.base.misc.time.TimeProvider;
import com.airlock.iam.common.api.domain.model.binary.ByteSequence;
import com.airlock.iam.elog.api.ELogger;
import com.airlock.iam.elog.api.ELoggerFactory;
import com.airlock.iam.factor.domain.model.fido.FidoApprovalData;
import com.airlock.iam.factor.domain.model.fido.FidoAuthTokenId;
import com.airlock.iam.factor.domain.model.fido.FidoAuthenticatorUnverifiedAssertionResponse;
import com.airlock.iam.factor.domain.model.fido.FidoAuthenticatorVerifiedAssertionResponse;
import com.airlock.iam.factor.domain.model.fido.FidoChallenge;
import com.airlock.iam.factor.domain.model.fido.FidoChallengeGenerator;
import com.airlock.iam.factor.domain.model.fido.FidoCredentialAuthenticationSettings;
import com.airlock.iam.factor.domain.model.fido.FidoCredentialIdAndTransports;
import com.airlock.iam.factor.domain.model.fido.FidoCredentialRepository;
import com.airlock.iam.factor.domain.model.fido.FidoPublicKeyCredentialRequestOptions;
import com.airlock.iam.factor.domain.model.fido.FidoRegisteredCredential;
import com.airlock.iam.factor.domain.model.fido.FidoRelyingParty;
import com.airlock.iam.factor.domain.model.fido.FidoUserRepository;
import com.airlock.iam.factor.domain.model.fido.user.FidoUser;
import com.airlock.iam.factor.domain.model.fido.user.FidoUserPrincipal;
import com.airlock.iam.factor.domain.service.fido.FidoAuthenticationService;
import com.airlock.iam.fido.api.domain.model.FidoRelyingPartyId;
import com.airlock.iam.log.api.helpdesk.HelpdeskLogger;
import com.airlock.iam.log.api.signing.SigningLogger;
import jakarta.annotation.Nonnull;
import jakarta.inject.Inject;
import jakarta.inject.Provider;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import lombok.NonNull;

public final class FidoAuthenticationServiceImpl implements FidoAuthenticationService {
   private static final ELogger LOG = ELoggerFactory.getLogger(FidoAuthenticationServiceImpl.class);
   private final Provider<FidoUserRepository> userRepository;
   private final Provider<FidoCredentialRepository> credentialRepository;
   private final Provider<FidoCredentialAuthenticationSettings> authenticationSettings;
   private final FidoRelyingPartyId relyingPartyId;
   private final Provider<FidoRelyingParty> relyingParty;
   private final FidoChallengeGenerator challengeGenerator;
   private final TimeProvider timeProvider;
   private final SigningLogger signingLogger;
   private final HelpdeskLogger helpdeskLogger;

   @Inject
   public FidoAuthenticationServiceImpl(
      @NonNull Provider<FidoUserRepository> userRepository,
      @NonNull Provider<FidoCredentialRepository> credentialRepository,
      @NonNull Provider<FidoCredentialAuthenticationSettings> authenticationStepConfig,
      @NonNull FidoRelyingPartyId relyingPartyId,
      @NonNull Provider<FidoRelyingParty> relyingParty,
      @NonNull FidoChallengeGenerator challengeGenerator,
      @NonNull TimeProvider timeProvider,
      @NonNull SigningLogger signingLogger,
      @NonNull HelpdeskLogger helpdeskLogger
   ) {
      if (userRepository == null) {
         throw new NullPointerException("userRepository is marked non-null but is null");
      }

      if (credentialRepository == null) {
         throw new NullPointerException("credentialRepository is marked non-null but is null");
      }

      if (authenticationStepConfig == null) {
         throw new NullPointerException("authenticationStepConfig is marked non-null but is null");
      }

      if (relyingPartyId == null) {
         throw new NullPointerException("relyingPartyId is marked non-null but is null");
      }

      if (relyingParty == null) {
         throw new NullPointerException("relyingParty is marked non-null but is null");
      }

      if (challengeGenerator == null) {
         throw new NullPointerException("challengeGenerator is marked non-null but is null");
      }

      if (timeProvider == null) {
         throw new NullPointerException("timeProvider is marked non-null but is null");
      }

      if (signingLogger == null) {
         throw new NullPointerException("signingLogger is marked non-null but is null");
      }

      if (helpdeskLogger == null) {
         throw new NullPointerException("helpdeskLogger is marked non-null but is null");
      }

      this.userRepository = userRepository;
      this.credentialRepository = credentialRepository;
      this.relyingPartyId = relyingPartyId;
      this.relyingParty = relyingParty;
      this.challengeGenerator = challengeGenerator;
      this.authenticationSettings = authenticationStepConfig;
      this.timeProvider = timeProvider;
      this.signingLogger = signingLogger;
      this.helpdeskLogger = helpdeskLogger;
   }

   @Override
   public boolean hasEligibleFidoCredentials(UserId userId) {
      return ((FidoUserRepository)this.userRepository.get())
         .findBy(this.relyingPartyId, userId)
         .map(fidoUser -> ((FidoCredentialRepository)this.credentialRepository.get()).credentialIdsAndTransportsUsableBy(fidoUser))
         .map(credentials -> !credentials.isEmpty())
         .orElse(false);
   }

   @Override
   public FidoPublicKeyCredentialRequestOptions generateChallenge(FidoUserPrincipal fidoUser) {
      List<FidoCredentialIdAndTransports> allowedCredentialIdAndTransports = this.getFidoCredentialIdAndTransports(fidoUser, Optional.empty());
      return this.challengeBuilder()
         .challenge(this.challengeGenerator.generateRandom())
         .allowedCredentialIdsAndTransports(allowedCredentialIdAndTransports)
         .build();
   }

   @Override
   public FidoPublicKeyCredentialRequestOptions generateChallengeForPasswordless() {
      return this.challengeBuilder().challenge(this.challengeGenerator.generateRandom()).allowedCredentialIdsAndTransports(null).build();
   }

   @Override
   public FidoPublicKeyCredentialRequestOptions generateChallengeWithApprovalData(
      FidoUserPrincipal fidoUser, FidoApprovalData fidoApprovalData, Optional<FidoAuthTokenId> fidoAuthTokenId
   ) {
      List<FidoCredentialIdAndTransports> allowedCredentialIdAndTransports = this.getFidoCredentialIdAndTransports(fidoUser, fidoAuthTokenId);
      FidoChallenge challenge = this.challengeGenerator.generateFromData(fidoApprovalData);
      return this.challengeBuilder().challenge(challenge).allowedCredentialIdsAndTransports(allowedCredentialIdAndTransports).build();
   }

   @Nonnull
   private List<FidoCredentialIdAndTransports> getFidoCredentialIdAndTransports(FidoUserPrincipal fidoUser, Optional<FidoAuthTokenId> fidoAuthTokenId) {
      List<FidoCredentialIdAndTransports> allowedCredentialIdAndTransports = this.retrieveCredentials(fidoUser, fidoAuthTokenId);
      if (allowedCredentialIdAndTransports.isEmpty()) {
         throw new FailureException("Failed to create FIDO challenge. User does not have any FIDO credentials.");
      } else {
         return allowedCredentialIdAndTransports;
      }
   }

   private FidoPublicKeyCredentialRequestOptions.FidoPublicKeyCredentialRequestOptionsBuilder challengeBuilder() {
      return FidoPublicKeyCredentialRequestOptions.builder()
         .relyingPartyId(this.relyingPartyId)
         .authenticationSettings((FidoCredentialAuthenticationSettings)this.authenticationSettings.get())
         .createdAt(this.timeProvider.getCurrentInstant());
   }

   private List<FidoCredentialIdAndTransports> retrieveCredentials(FidoUserPrincipal fidoUser, Optional<FidoAuthTokenId> fidoAuthTokenId) {
      List<FidoCredentialIdAndTransports> fidoCredentialIdAndTransports = new ArrayList<>(
         ((FidoCredentialRepository)this.credentialRepository.get()).credentialIdsAndTransportsUsableBy(fidoUser)
      );
      if (fidoAuthTokenId.isPresent()) {
         Optional<FidoRegisteredCredential> preferedCredential = ((FidoCredentialRepository)this.credentialRepository.get())
            .findBy(fidoUser, fidoAuthTokenId.get().fidoCredentialId());
         Optional<FidoCredentialIdAndTransports> preferredCredentialAndTransport = preferedCredential.stream()
            .flatMap(credential -> fidoCredentialIdAndTransports.stream())
            .filter(item -> item.publicKeyCredentialId().equals(preferedCredential.get().getPublicKeyCredentialId()))
            .findFirst();
         preferredCredentialAndTransport.ifPresentOrElse(
            t -> {
               fidoCredentialIdAndTransports.remove(t);
               fidoCredentialIdAndTransports.addFirst(t);
            },
            () -> LOG.debug(
               "The authTokenId contains %s not registered to user %s. Continuing without setting a preferred credential."
                  .formatted(fidoAuthTokenId.get().formatAsString(), fidoUser.affectedUser())
            )
         );
      }

      return fidoCredentialIdAndTransports;
   }

   @Override
   public Result<FidoRegisteredCredential, FidoRelyingParty.FidoAuthenticationVerificationErrorCode> verifyFidoResponse(
      FidoPublicKeyCredentialRequestOptions challenge, FidoUser fidoUser, FidoAuthenticatorUnverifiedAssertionResponse assertionResponse
   ) {
      if (!this.isUsableCredentialId(fidoUser, assertionResponse.getPublicKeyCredentialId())) {
         String credentialId = ByteSequence.fromBase64Url(assertionResponse.getPublicKeyCredentialId())
            .map(ByteSequence::base64UrlTruncated)
            .orElse("<invalid public key credential ID>");
         this.logFailure(fidoUser, String.format("Public key credential ID '%s' is not valid for user '%s'.", credentialId, fidoUser.affectedUser()));
         return Result.failure(FidoRelyingParty.FidoAuthenticationVerificationErrorCode.AUTHENTICATION_FAILED);
      } else if (!challenge.isValidAt(this.timeProvider.getCurrentInstant())) {
         this.logFailure(fidoUser, "Challenge for authentication of a FIDO credential is no longer valid");
         return Result.failure(FidoRelyingParty.FidoAuthenticationVerificationErrorCode.AUTHENTICATION_TIMEOUT);
      } else {
         return ((FidoRelyingParty)this.relyingParty.get())
            .verifyAuthentication(challenge, fidoUser, assertionResponse)
            .map(verifiedResponse -> this.updateStatistics(verifiedResponse.getAuthenticatedUser(), verifiedResponse))
            .map(Result::result)
            .orElse(Result.failure(FidoRelyingParty.FidoAuthenticationVerificationErrorCode.AUTHENTICATION_FAILED));
      }
   }

   private FidoRegisteredCredential updateStatistics(FidoUserPrincipal fidoUser, FidoAuthenticatorVerifiedAssertionResponse verifiedResponse) {
      FidoRegisteredCredential credential = ((FidoCredentialRepository)this.credentialRepository.get())
         .findBy(fidoUser, verifiedResponse.getPublicKeyCredentialId())
         .orElseThrow();
      credential.updateUsageStatistics(verifiedResponse.getSignatureCounter());
      ((FidoCredentialRepository)this.credentialRepository.get()).update(credential);
      return credential;
   }

   private boolean isUsableCredentialId(FidoUser fidoUser, String publicKeyCredentialId) {
      return ((FidoCredentialRepository)this.credentialRepository.get())
         .credentialIdsAndTransportsUsableBy(fidoUser)
         .stream()
         .anyMatch(credentialIdAndTransports -> credentialIdAndTransports.publicKeyCredentialId().base64Url().equals(publicKeyCredentialId));
   }

   private void logFailure(FidoUser fidoUser, String message) {
      this.helpdeskLogger.log(fidoUser.affectedUser(), "FIDO authentication failed: " + message);
      this.signingLogger.log("FIDO authentication failed for user '%s': " + message, new Object[]{fidoUser.affectedUser()});
   }
}
