package com.airlock.iam.admin.application.service.token.vasco;

import com.airlock.iam.admin.api.domain.model.administrator.CurrentAdministrator;
import com.airlock.iam.admin.domain.model.token.vasco.VascoOtpAssignment;
import com.airlock.iam.admin.domain.model.token.vasco.VascoOtpDevice;
import com.airlock.iam.admin.domain.model.token.vasco.VascoOtpDeviceUserView;
import com.airlock.iam.admin.infrastructure.exception.VascoOtpValidationHelper;
import com.airlock.iam.base.api.domain.model.exception.EntityNotFoundException;
import com.airlock.iam.base.api.domain.model.exception.ServiceConfigurationException;
import com.airlock.iam.base.api.domain.model.exception.ViolationException;
import com.airlock.iam.base.api.domain.model.validation.GenericError;
import com.airlock.iam.base.api.domain.model.validation.ValidationResult;
import com.airlock.iam.base.api.domain.model.validation.ValidatorError;
import com.airlock.iam.base.misc.log.LogUtil;
import com.airlock.iam.common.api.domain.model.store.user.UserStore;
import com.airlock.iam.common.infrastructure.exceptions.ValidationException;
import com.airlock.iam.elog.api.ELogger;
import com.airlock.iam.elog.api.ELoggerFactory;
import com.airlock.iam.log.api.signing.SigningLogger;
import com.google.common.annotations.VisibleForTesting;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import jakarta.inject.Inject;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

public class VascoOtpUserDeviceService {
   private static final ELogger LOG = ELoggerFactory.getLogger(VascoOtpUserDeviceService.class);
   private final VascoOtpAdminSettings settings;
   private final VascoOtpDeviceRepository deviceRepo;
   private final VascoOtpDeviceUserViewRepository userDeviceRepo;
   private final VascoOtpAssignmentNotifier notifier;
   private final Optional<VascoOtpDirectLetterPrint> directPrint;
   private final CurrentAdministrator admin;
   private final UserStore userStore;
   private final SigningLogger signingLogger;

   @Inject
   public VascoOtpUserDeviceService(
      VascoOtpAdminSettings settings,
      VascoOtpDeviceRepository deviceRepo,
      VascoOtpDeviceUserViewRepository userDeviceRepo,
      VascoOtpAssignmentNotifier notifier,
      Optional<VascoOtpDirectLetterPrint> directPrint,
      CurrentAdministrator admin,
      UserStore userStore,
      SigningLogger signingLogger
   ) {
      this.settings = settings;
      this.deviceRepo = deviceRepo;
      this.userDeviceRepo = userDeviceRepo;
      this.notifier = notifier;
      this.directPrint = directPrint;
      this.admin = admin;
      this.userStore = userStore;
      this.signingLogger = signingLogger;
   }

   public List<VascoOtpDeviceUserView> getAllDevicesForUser(String userId) {
      return this.userDeviceRepo.getAllDevicesForUser(userId);
   }

   public VascoOtpDeviceUserView getUserDeviceById(String userId, VascoOtpDeviceId deviceId) {
      return this.userDeviceRepo.getUserDeviceById(userId, deviceId);
   }

   public void assignDevice(String userId, VascoOtpDeviceId deviceId, Optional<String> additionalInfo, Optional<String> comment) {
      this.addMultipleAssignments(Arrays.asList(new VascoOtpAssignParameters(userId, deviceId, additionalInfo, comment)));
   }

   public ValidationResult validateAssignments(List<VascoOtpAssignParameters> assignments) {
      Map<VascoOtpDeviceId, VascoOtpDevice> deviceCache = this.createExistingDevicesCache(assignments);
      List<VascoOtpAssignParameters> assignmentsForInexistentDevices = this.findInexistentDevices(assignments, deviceCache);
      if (!assignmentsForInexistentDevices.isEmpty()) {
         return this.devicesNotFoundError(assignments, assignmentsForInexistentDevices);
      }

      for (VascoOtpAssignParameters assignment : assignments) {
         VascoOtpDevice device = deviceCache.get(assignment.getDeviceId());
         if (this.settings.isOnlyEnabledTokensAssignable() && !device.isEnabled()) {
            throw new ViolationException(String.format("Disabled Vasco OTP device '%s' cannot be assigned to users.", assignment.getDeviceId().asString()));
         }
      }

      List<ValidatorError> allErrors = new ArrayList<>();
      this.collectValidAssignments(deviceCache, assignments, allErrors);
      return allErrors.isEmpty() ? ValidationResult.valid() : ValidationResult.invalid(allErrors);
   }

   private List<VascoOtpAssignParameters> findInexistentDevices(List<VascoOtpAssignParameters> assignments, Map<VascoOtpDeviceId, VascoOtpDevice> deviceCache) {
      return assignments.stream().filter(a -> !deviceCache.containsKey(a.getDeviceId())).toList();
   }

   private ValidationResult devicesNotFoundError(List<VascoOtpAssignParameters> originalAssignments, List<VascoOtpAssignParameters> notFoundAssignments) {
      return ValidationResult.invalid(notFoundAssignments.stream().map(notFound -> notFoundValidationError(originalAssignments.indexOf(notFound))).toList());
   }

   private static ValidatorError notFoundValidationError(int deviceIndex) {
      return ValidatorError.builder().field("deviceId[" + deviceIndex + "]").type(GenericError.NOT_FOUND).build();
   }

   private Map<VascoOtpDeviceId, VascoOtpDevice> createExistingDevicesCache(List<VascoOtpAssignParameters> assignments) {
      Map<VascoOtpDeviceId, VascoOtpDevice> deviceCache = new HashMap<>();
      assignments.forEach(a -> {
         try {
            deviceCache.put(a.getDeviceId(), this.deviceRepo.getDeviceById(a.getDeviceId()));
         } catch (EntityNotFoundException var4) {
         }
      });
      return deviceCache;
   }

   public List<VascoOtpAssignParameters> addMultipleAssignments(List<VascoOtpAssignParameters> assignments) {
      this.assertAllUsersExist(assignments.stream().map(VascoOtpAssignParameters::getUserId).collect(Collectors.toList()));
      Map<VascoOtpDeviceId, VascoOtpDevice> deviceCache = this.createExistingDevicesCache(assignments);
      List<ValidatorError> allErrors = new ArrayList<>();
      List<VascoOtpAssignParameters> validAssignments = this.collectValidAssignments(deviceCache, assignments, allErrors);
      if (!allErrors.isEmpty()) {
         throw new ValidationException(allErrors);
      }

      for (VascoOtpAssignParameters assignment : validAssignments) {
         this.completeAssigned(deviceCache.get(assignment.getDeviceId()), assignment);
      }

      return validAssignments;
   }

   private void assertAllUsersExist(List<String> userIds) {
      userIds.forEach(this.userStore::getByIdentity);
   }

   private List<VascoOtpAssignParameters> collectValidAssignments(
      Map<VascoOtpDeviceId, VascoOtpDevice> deviceCache, List<VascoOtpAssignParameters> assignments, List<ValidatorError> allErrors
   ) {
      List<VascoOtpAssignParameters> allAssignments = new ArrayList<>();

      for (int i = 0; i < assignments.size(); i++) {
         VascoOtpAssignParameters params = assignments.get(i);
         if (!deviceCache.containsKey(params.getDeviceId())) {
            throw new ViolationException("Vasco device with ID " + LogUtil.quote(params.getDeviceId()) + " not found");
         }

         VascoOtpDevice device = deviceCache.get(params.getDeviceId());
         List<ValidatorError> errors = this.tryAssign(device, params);
         if (!errors.isEmpty()) {
            allErrors.addAll(this.mapErrors(errors, i));
         } else {
            allAssignments.add(params);
         }
      }

      return allAssignments;
   }

   private void completeAssigned(VascoOtpDevice device, VascoOtpAssignParameters params) {
      this.userDeviceRepo.deletePlaceHolderToken(params.getUserId());
      this.deviceRepo.saveDevice(device);
      this.logAdminAction("assigned Vasco device " + device.getDeviceId() + " to user " + LogUtil.quote(params.getUserId()) + this.extraInfo(params));
      this.notifier.notifyUserAboutAssignment(params.getUserId(), device.getDeviceId());
      if (this.settings.isAutoOrderLetter()) {
         this.orderLetter(params.getUserId(), device.getDeviceId());
      }
   }

   private List<ValidatorError> mapErrors(List<ValidatorError> errors, int index) {
      String indexSuffix = "[" + index + "]";
      return errors.stream().map(e -> ValidatorError.validatorError(e.getField() + indexSuffix, e.getType())).collect(Collectors.toList());
   }

   private List<ValidatorError> tryAssign(VascoOtpDevice device, VascoOtpAssignParameters params) {
      VascoOtpAssignment assignment = VascoOtpAssignment.fromParameters(params, this.admin.getName());
      List<ValidatorError> errors = this.validateAssignment(device, assignment);
      if (!errors.isEmpty()) {
         return errors;
      }

      if (device.isAssignedTo(params.getUserId())) {
         LOG.debug("Device " + device.getDeviceId() + " is already assigned to user " + LogUtil.quote(params.getUserId()) + ". Only updating meta data.");
         device.updateValidAssignment(assignment);
      } else {
         device.addValidAssignment(assignment);
      }

      return Collections.emptyList();
   }

   private List<ValidatorError> validateAssignment(VascoOtpDevice device, VascoOtpAssignment ass) {
      VascoOtpAssignmentValidator validator = new VascoOtpAssignmentValidator(device, this.settings);
      ValidationResult result = validator.validate(ass);
      return result.getErrors();
   }

   public void unassignMultipleDevices(String userId, List<VascoOtpDeviceId> deviceIds) {
      for (VascoOtpDeviceId deviceId : deviceIds) {
         try {
            this.unassignDevice(userId, deviceId);
         } catch (EntityNotFoundException e) {
            LOG.debug("Ignoring unassign Vasco device operation on user " + LogUtil.quote(userId) + " because device " + deviceId + " does not exist");
         }
      }
   }

   private void unassignDevice(String userId, VascoOtpDeviceId deviceId) {
      Optional<VascoOtpDeviceUserView> device = this.userDeviceRepo.findUserDeviceById(userId, deviceId);
      if (device.isPresent()) {
         VascoOtpDeviceUserView view = device.get();
         view.unassign();
         this.userDeviceRepo.saveUserView(view);
         this.logAdminAction("unassigned Vasco device " + view.getDeviceId() + " from user " + LogUtil.quote(view.getUserId()));
      } else {
         LOG.debug("Ignoring unassign Vasco device operation because device " + deviceId + " is not assigned to user " + LogUtil.quote(userId));
      }
   }

   public void orderLetter(String userId, VascoOtpDeviceId deviceId) {
      VascoOtpDeviceUserView deviceView = this.userDeviceRepo.getUserDeviceById(userId, deviceId);
      deviceView.orderLetter(this.admin.getName());
      this.userDeviceRepo.saveUserView(deviceView);
      this.logAdminAction("ordered letter for Vasco device " + deviceId + " and user " + LogUtil.quote(userId));
   }

   public void cancelLetterOrder(String userId, VascoOtpDeviceId deviceId) {
      VascoOtpDeviceUserView deviceView = this.userDeviceRepo.getUserDeviceById(userId, deviceId);
      deviceView.cancelLetterOrder();
      this.userDeviceRepo.saveUserView(deviceView);
      this.logAdminAction("cancelled letter order for Vasco device " + deviceId + " and user " + LogUtil.quote(userId));
   }

   public void directPrintLetter(String userId, VascoOtpDeviceId deviceId) {
      ServiceConfigurationException.checkPresent(this.directPrint, "Vasco OTP direct print is not configured");
      this.directPrint.get().directPrintLetter(userId, deviceId);
      this.logAdminAction("executed direct print of letter for Vasco device " + deviceId + " and user " + LogUtil.quote(userId));
   }

   private String extraInfo(VascoOtpAssignParameters params) {
      StringBuilder info = new StringBuilder(200);
      if (params.hasAdditionalInformation() || params.hasComment()) {
         info.append(" with");
      }

      if (params.hasAdditionalInformation()) {
         info.append(" additional info=").append(LogUtil.quote(params.getAdditionalInformation().get()));
      }

      if (params.hasComment()) {
         info.append(" comment=").append(LogUtil.quote(params.getComment().get()));
      }

      return info.toString();
   }

   public void setNextDevice(String userId, VascoOtpDeviceId deviceId, Optional<VascoOtpDeviceId> nextDeviceId) {
      VascoOtpDeviceUserView device = this.userDeviceRepo.getUserDeviceById(userId, deviceId);
      this.validateSetNextDevice(userId, deviceId, nextDeviceId);
      if (nextDeviceId.isPresent()) {
         device.setNextDeviceId(nextDeviceId.get());
         this.logAdminAction("set next device " + nextDeviceId.get() + " for device " + deviceId + " for user " + LogUtil.quote(userId));
      } else {
         device.clearNextDevice();
         this.logAdminAction("removed next device for device " + deviceId + " for user " + LogUtil.quote(userId));
      }

      this.userDeviceRepo.saveUserView(device);
   }

   @VisibleForTesting
   void validateSetNextDevice(String userId, VascoOtpDeviceId deviceId, Optional<VascoOtpDeviceId> nextDeviceId) {
      if (!nextDeviceId.isEmpty()) {
         if (!this.deviceRepo.getDeviceById(nextDeviceId.get()).isAssignedTo(userId)) {
            throw VascoOtpValidationHelper.deviceNotAssignedToUserException(userId, nextDeviceId.get());
         }

         if (nextDeviceId.get().equals(deviceId)) {
            throw new ViolationException("Device " + deviceId + " cannot be set as its own next device.");
         }

         VascoOtpDeviceUserView nextDevice = this.userDeviceRepo.getUserDeviceById(userId, nextDeviceId.get());
         if (nextDevice.getNextDeviceId().isPresent() && nextDevice.getNextDeviceId().get().equals(deviceId)) {
            throw new ViolationException("Next device " + nextDeviceId.get() + " has " + deviceId + " as next device. Reciprocal relationship is not allowed.");
         }

         Set<String> assigneesOfCurrentNextDevice = this.userDeviceRepo.getAllAssigneesOfNextDevice(deviceId);
         if (!assigneesOfCurrentNextDevice.isEmpty() && !assigneesOfCurrentNextDevice.contains(userId)) {
            throw new ViolationException("Device " + deviceId + " already has a next device for another user.");
         }
      }
   }

   public void setPreviousDevice(String userId, VascoOtpDeviceId deviceId, Optional<VascoOtpDeviceId> previousDeviceId) {
      VascoOtpDeviceUserView device = this.userDeviceRepo.getUserDeviceById(userId, deviceId);
      this.validateSetPreviousDevice(userId, deviceId, previousDeviceId);
      if (previousDeviceId.isPresent()) {
         if (!this.deviceRepo.getDeviceById(previousDeviceId.get()).isAssignedTo(userId)) {
            throw VascoOtpValidationHelper.deviceNotAssignedToUserException(userId, previousDeviceId.get());
         }

         device.setPreviousDeviceId(previousDeviceId.get());
         this.logAdminAction("set previous device " + previousDeviceId.get() + " for device " + deviceId + " for user " + LogUtil.quote(userId));
      } else {
         device.clearPreviousDevice();
         this.logAdminAction("removed previous device for device " + deviceId + " for user " + LogUtil.quote(userId));
      }

      this.userDeviceRepo.saveUserView(device);
   }

   @VisibleForTesting
   void validateSetPreviousDevice(String userId, VascoOtpDeviceId deviceId, Optional<VascoOtpDeviceId> prevDeviceId) {
      if (!prevDeviceId.isEmpty()) {
         if (!this.deviceRepo.getDeviceById(prevDeviceId.get()).isAssignedTo(userId)) {
            throw VascoOtpValidationHelper.deviceNotAssignedToUserException(userId, prevDeviceId.get());
         }

         if (prevDeviceId.get().equals(deviceId)) {
            throw new ViolationException("Device " + deviceId + " cannot be set as its own previous device.");
         }

         VascoOtpDeviceUserView prevDevice = this.userDeviceRepo.getUserDeviceById(userId, prevDeviceId.get());
         if (prevDevice.getPreviousDeviceId().isPresent() && prevDevice.getPreviousDeviceId().get().equals(deviceId)) {
            throw new ViolationException(
               "Previous device " + prevDeviceId.get() + " has " + deviceId + " as previous device. Reciprocal relationship is not allowed."
            );
         }

         Set<String> assigneesOfCurrentNextDevice = this.userDeviceRepo.getAllAssigneesOfNextDevice(prevDeviceId.get());
         if (!assigneesOfCurrentNextDevice.isEmpty() && !assigneesOfCurrentNextDevice.contains(userId)) {
            throw new ViolationException("Device " + prevDeviceId.get() + " already has a next device for another user.");
         }
      }
   }

   private void logAdminAction(String actionDesc) {
      this.signingLogger.log("Admin " + LogUtil.quote(this.admin.getName()) + " " + actionDesc);
   }

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

   @SuppressFBWarnings(justification = "generated code")
   public static class VascoOtpUserDeviceServiceBuilder {
      @SuppressFBWarnings(justification = "generated code")
      private VascoOtpAdminSettings settings;
      @SuppressFBWarnings(justification = "generated code")
      private VascoOtpDeviceRepository deviceRepo;
      @SuppressFBWarnings(justification = "generated code")
      private VascoOtpDeviceUserViewRepository userDeviceRepo;
      @SuppressFBWarnings(justification = "generated code")
      private VascoOtpAssignmentNotifier notifier;
      @SuppressFBWarnings(justification = "generated code")
      private Optional<VascoOtpDirectLetterPrint> directPrint;
      @SuppressFBWarnings(justification = "generated code")
      private CurrentAdministrator admin;
      @SuppressFBWarnings(justification = "generated code")
      private UserStore userStore;
      @SuppressFBWarnings(justification = "generated code")
      private SigningLogger signingLogger;

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

      @SuppressFBWarnings(justification = "generated code")
      public VascoOtpUserDeviceService.VascoOtpUserDeviceServiceBuilder settings(VascoOtpAdminSettings settings) {
         this.settings = settings;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public VascoOtpUserDeviceService.VascoOtpUserDeviceServiceBuilder deviceRepo(VascoOtpDeviceRepository deviceRepo) {
         this.deviceRepo = deviceRepo;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public VascoOtpUserDeviceService.VascoOtpUserDeviceServiceBuilder userDeviceRepo(VascoOtpDeviceUserViewRepository userDeviceRepo) {
         this.userDeviceRepo = userDeviceRepo;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public VascoOtpUserDeviceService.VascoOtpUserDeviceServiceBuilder notifier(VascoOtpAssignmentNotifier notifier) {
         this.notifier = notifier;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public VascoOtpUserDeviceService.VascoOtpUserDeviceServiceBuilder directPrint(Optional<VascoOtpDirectLetterPrint> directPrint) {
         this.directPrint = directPrint;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public VascoOtpUserDeviceService.VascoOtpUserDeviceServiceBuilder admin(CurrentAdministrator admin) {
         this.admin = admin;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public VascoOtpUserDeviceService.VascoOtpUserDeviceServiceBuilder userStore(UserStore userStore) {
         this.userStore = userStore;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public VascoOtpUserDeviceService.VascoOtpUserDeviceServiceBuilder signingLogger(SigningLogger signingLogger) {
         this.signingLogger = signingLogger;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public VascoOtpUserDeviceService build() {
         return new VascoOtpUserDeviceService(
            this.settings, this.deviceRepo, this.userDeviceRepo, this.notifier, this.directPrint, this.admin, this.userStore, this.signingLogger
         );
      }

      @SuppressFBWarnings(justification = "generated code")
      @Override
      public String toString() {
         return "VascoOtpUserDeviceService.VascoOtpUserDeviceServiceBuilder(settings="
            + this.settings
            + ", deviceRepo="
            + this.deviceRepo
            + ", userDeviceRepo="
            + this.userDeviceRepo
            + ", notifier="
            + this.notifier
            + ", directPrint="
            + this.directPrint
            + ", admin="
            + this.admin
            + ", userStore="
            + this.userStore
            + ", signingLogger="
            + this.signingLogger
            + ")";
      }
   }
}
