TraceServiceImpl.java

package fr.avenirsesr.portfolio.trace.domain.service;

import static fr.avenirsesr.portfolio.common.validation.domain.constraints.CommonLimits.MAX_TRACES_OVERVIEW;
import static fr.avenirsesr.portfolio.common.validation.domain.constraints.FieldMaxLengths.LINK_LENGTH;
import static fr.avenirsesr.portfolio.common.validation.domain.constraints.FieldMaxLengths.TITLE_LENGTH;
import static fr.avenirsesr.portfolio.common.validation.domain.utils.FieldValidationUtils.requireNotBlankAndMaxLength;
import static fr.avenirsesr.portfolio.common.validation.domain.utils.FieldValidationUtils.validateOptionalTextMaxLength;
import static fr.avenirsesr.portfolio.common.validation.domain.utils.FieldValidationUtils.validateUrl;

import fr.avenirsesr.portfolio.association.domain.data.AssociationData;
import fr.avenirsesr.portfolio.association.domain.data.AssociationSearchResultData;
import fr.avenirsesr.portfolio.association.domain.exception.AssociationDoesNotExistException;
import fr.avenirsesr.portfolio.association.domain.model.Association;
import fr.avenirsesr.portfolio.association.domain.model.EAssociationType;
import fr.avenirsesr.portfolio.association.domain.port.input.AssociationService;
import fr.avenirsesr.portfolio.association.domain.service.AssociationSearchHelper;
import fr.avenirsesr.portfolio.common.configuration.domain.model.TraceConfiguration;
import fr.avenirsesr.portfolio.common.data.domain.model.*;
import fr.avenirsesr.portfolio.common.language.domain.model.enums.ELanguage;
import fr.avenirsesr.portfolio.common.security.domain.exception.UserNotAuthorizedException;
import fr.avenirsesr.portfolio.file.domain.exception.FileNotFoundException;
import fr.avenirsesr.portfolio.file.domain.model.TraceAttachment;
import fr.avenirsesr.portfolio.file.domain.model.shared.File;
import fr.avenirsesr.portfolio.file.domain.port.input.TraceAttachmentService;
import fr.avenirsesr.portfolio.shared.domain.model.enums.EPortfolioType;
import fr.avenirsesr.portfolio.shared.domain.port.input.LoggedInUserService;
import fr.avenirsesr.portfolio.student.progress.declared.activity.domain.data.DeclaredActivityAssociationData;
import fr.avenirsesr.portfolio.student.progress.declared.activity.domain.exception.DeclaredActivityAlreadyFinishedException;
import fr.avenirsesr.portfolio.student.progress.declared.activity.domain.exception.DeclaredActivityNotFoundException;
import fr.avenirsesr.portfolio.student.progress.declared.activity.domain.model.DeclaredActivity;
import fr.avenirsesr.portfolio.student.progress.declared.activity.domain.port.input.DeclaredActivityService;
import fr.avenirsesr.portfolio.student.progress.declared.experience.domain.data.DeclaredExperienceAssociationData;
import fr.avenirsesr.portfolio.student.progress.declared.experience.domain.exception.DeclaredExperienceNotFoundException;
import fr.avenirsesr.portfolio.student.progress.declared.experience.domain.model.DeclaredExperience;
import fr.avenirsesr.portfolio.student.progress.declared.experience.domain.port.input.DeclaredExperienceService;
import fr.avenirsesr.portfolio.student.progress.declared.skill.domain.data.DeclaredSkillAssociationData;
import fr.avenirsesr.portfolio.student.progress.declared.skill.domain.exception.DeclaredSkillProgressNotFoundException;
import fr.avenirsesr.portfolio.student.progress.declared.skill.domain.model.DeclaredSkillProgress;
import fr.avenirsesr.portfolio.student.progress.declared.skill.domain.port.input.DeclaredSkillProgressService;
import fr.avenirsesr.portfolio.trace.domain.data.*;
import fr.avenirsesr.portfolio.trace.domain.exception.TraceNotFoundException;
import fr.avenirsesr.portfolio.trace.domain.filter.TraceFilter;
import fr.avenirsesr.portfolio.trace.domain.model.*;
import fr.avenirsesr.portfolio.trace.domain.port.input.TraceService;
import fr.avenirsesr.portfolio.trace.domain.port.output.repository.TraceRepository;
import fr.avenirsesr.portfolio.trace.infrastructure.adapter.client.TraceConfigurationClient;
import fr.avenirsesr.portfolio.user.domain.port.input.UserService;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.*;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@AllArgsConstructor
public class TraceServiceImpl implements TraceService {
  private final TraceRepository traceRepository;
  private final UserService userService;
  private final TraceAttachmentService traceAttachmentService;
  private final DeclaredActivityService declaredActivityService;
  private final DeclaredSkillProgressService declaredSkillProgressService;
  private final DeclaredExperienceService declaredExperienceService;
  private final TraceConfigurationClient traceConfigurationClient;
  private final LoggedInUserService loggedInUserService;
  private final AssociationService associationService;
  private final AssociationSearchHelper associationSearchHelper;

  @Override
  public Trace getTraceById(UUID id) {
    return traceRepository.findById(id).orElseThrow(TraceNotFoundException::new);
  }

  @Override
  public List<Trace> findAllTracesById(List<UUID> ids) {
    return traceRepository.findAllById(ids);
  }

  @Override
  public String programNameOfTrace(Trace trace) {
    return EPortfolioType.LIFE_PROJECT.name();
  }

  @Override
  public List<Trace> lastTracesOf() {
    User loggedInUser = loggedInUserService.getLoggedInUser();
    return traceRepository.findLastsOf(loggedInUser, MAX_TRACES_OVERVIEW);
  }

  @Override
  public List<Trace> getTracesLinkedWithDeclaredSkillProgress(
      DeclaredSkillProgress declaredSkillProgress) {
    User loggedInUser = loggedInUserService.getLoggedInUser();
    List<Trace> traces = traceRepository.linkedWith(declaredSkillProgress);
    traces.forEach(trace -> checkIfUserIsAuthorizedOnTrace(loggedInUser, trace));
    return traces;
  }

  @Override
  public PagedResult<TraceViewData> getTracesView(
      String keyword, TraceFilter filter, DateFilter dateFilter, PageCriteria pageCriteria) {
    User loggedInUser = loggedInUserService.getLoggedInUser();
    var config = traceConfigurationClient.getTraceConfiguration();
    PagedResult<Trace> pagedResult =
        traceRepository.findAll(loggedInUser, keyword, filter, dateFilter, pageCriteria);
    Map<Trace, Boolean> traceAssociated = traceRepository.isAssociated(pagedResult.content());
    return new PagedResult<>(
        pagedResult.content().stream()
            .map(
                trace ->
                    new TraceViewData(
                        trace.getId(),
                        trace.getTitle(),
                        traceAssociated.get(trace),
                        trace.getCreatedAt(),
                        trace.getUpdatedAt(),
                        traceAssociated.get(trace)
                            ? Optional.empty()
                            : Optional.of(computeDeletionDateForUnassociatedTrace(trace, config))))
            .toList(),
        pagedResult.pageInfo());
  }

  private LocalDate computeDeletionDateForUnassociatedTrace(
      Trace unassociatedTrace, TraceConfiguration configuration) {
    return unassociatedTrace
        .getCreatedAt()
        .plus(Duration.ofDays(configuration.maxRemainingDays()))
        .atZone(ZoneId.systemDefault())
        .toLocalDate();
  }

  @Override
  public void deleteById(UUID id) {
    User loggedInUser = loggedInUserService.getLoggedInUser();
    Trace trace = traceRepository.findById(id).orElseThrow(TraceNotFoundException::new);
    checkIfUserIsAuthorizedOnTrace(loggedInUser, trace);

    trace.setDeletedAt(Instant.now());

    associationService.deleteAllOf(List.of(trace.getId()), Trace.class);

    traceRepository.save(trace);
    log.info("Deleted trace {}", trace);
  }

  @Override
  public TracesSummaryData getTracesSummary() {
    User loggedInUser = loggedInUserService.getLoggedInUser();
    List<Trace> associatedTraces = traceRepository.findAll(loggedInUser, true);
    List<Trace> unassociatedTraces = traceRepository.findAll(loggedInUser, false);
    TraceConfiguration traceConfiguration = traceConfigurationClient.getTraceConfiguration();

    int criticalCount =
        unassociatedTraces.stream()
            .filter(
                t ->
                    Duration.between(t.getCreatedAt(), Instant.now())
                        .minus(Duration.ofDays(traceConfiguration.maxRemainingDays()))
                        .plus(Duration.ofDays(traceConfiguration.maxRemainingDaysBeforeCritical()))
                        .isPositive())
            .toList()
            .size();

    int warningCount =
        unassociatedTraces.stream()
            .filter(
                t ->
                    Duration.between(t.getCreatedAt(), Instant.now())
                        .minus(Duration.ofDays(traceConfiguration.maxRemainingDays()))
                        .plus(Duration.ofDays(traceConfiguration.maxRemainingDaysBeforeWarning()))
                        .isPositive())
            .toList()
            .size();

    return new TracesSummaryData(
        associatedTraces.size(), unassociatedTraces.size(), warningCount, criticalCount);
  }

  @Override
  public TraceDetailData getTraceDetail(UUID id) {
    User loggedInUser = loggedInUserService.getLoggedInUser();
    Trace trace = traceRepository.findById(id).orElseThrow(TraceNotFoundException::new);
    checkIfUserIsAuthorizedOnTrace(loggedInUser, trace);
    var isTraceAssociated = traceRepository.isAssociated(List.of(trace)).get(trace);
    return buildTraceDetailData(trace, isTraceAssociated);
  }

  private TraceDetailData buildTraceDetailData(Trace trace, boolean isAssociated) {
    Optional<TraceAttachment> traceAttachment =
        trace.getLink().isPresent() ? Optional.empty() : Optional.of(getTraceAttachment(trace));

    return new TraceDetailData(
        trace.getId(),
        trace.getTitle(),
        isAssociated,
        programNameOfTrace(trace),
        trace.isGroup(),
        trace.getAiUseJustification().orElse(null),
        trace.getPersonalNote().orElse(null),
        trace.getLink(),
        traceAttachment,
        trace.getCreatedAt(),
        trace.getUpdatedAt());
  }

  @Override
  public TraceAssociationsData getTraceAssociations(UUID traceId, boolean onlyNotCompleted) {
    var userLoggedIn = loggedInUserService.getLoggedInUser();
    var trace = traceRepository.findById(traceId).orElseThrow(TraceNotFoundException::new);

    checkIfUserIsAuthorizedOnTrace(userLoggedIn, trace);

    var associations =
        associationService.getAllOf(
            trace.getId(), Trace.class, EAssociationType.getAllBy(Trace.class));

    var declaredActivityAssociations =
        associations.stream()
            .filter(a -> a.getAssociationType() == EAssociationType.DECLARED_ACTIVITY_TRACE)
            .toList();

    List<DeclaredActivity> activities;

    if (onlyNotCompleted) {
      activities =
          declaredActivityService.findAllNotCompletedActivitiesByIds(
              declaredActivityAssociations.stream().map(Association::getId1).toList());
    } else {
      activities =
          declaredActivityService.findAllDeclaredActivitiesByIds(
              declaredActivityAssociations.stream().map(Association::getId1).toList());
    }

    var declaredSkillAssociations =
        associations.stream()
            .filter(a -> a.getAssociationType() == EAssociationType.TRACE_DECLARED_SKILL)
            .toList();
    var skills =
        declaredSkillProgressService.findAllDeclaredSkillProgressesByIds(
            declaredSkillAssociations.stream().map(Association::getId2).toList());

    var declaredExperienceAssociations =
        associations.stream()
            .filter(a -> a.getAssociationType() == EAssociationType.TRACE_DECLARED_EXPERIENCE)
            .toList();
    var experiences =
        declaredExperienceService.findAllByIds(
            declaredExperienceAssociations.stream().map(Association::getId2).toList());

    return new TraceAssociationsData(
        declaredActivityAssociations.stream()
            .map(a -> declaredActivityMapper(a, activities))
            .toList(),
        declaredSkillAssociations.stream().map(a -> declaredSkillMapper(a, skills)).toList(),
        declaredExperienceAssociations.stream()
            .map(a -> declaredExperienceMapper(a, experiences))
            .toList());
  }

  @Override
  public Trace createTrace(
      UUID traceId,
      UUID userId,
      String title,
      ELanguage language,
      boolean isGroup,
      String personalNote,
      String aiJustification,
      String link) {
    return createTrace(
        traceId,
        userService.getUser(userId),
        title,
        language,
        isGroup,
        personalNote,
        aiJustification,
        link);
  }

  @Override
  public Trace createTrace(
      String title,
      ELanguage language,
      boolean isGroup,
      String personalNote,
      String aiJustification,
      String link) {
    User loggedInUser = loggedInUserService.getLoggedInUser();
    return createTrace(
        UUID.randomUUID(),
        loggedInUser,
        title,
        language,
        isGroup,
        personalNote,
        aiJustification,
        link);
  }

  private Trace createTrace(
      UUID traceId,
      User user,
      String title,
      ELanguage language,
      boolean isGroup,
      String personalNote,
      String aiJustification,
      String link) {
    requireNotBlankAndMaxLength("title", title, TITLE_LENGTH);
    validateOptionalTextMaxLength("link", link, LINK_LENGTH);
    validateUrl(link);
    var trace =
        Trace.create(traceId, user, title, language, isGroup, aiJustification, personalNote, link);

    return traceRepository.save(trace);
  }

  @Override
  public TraceDetailData updateTrace(
      UUID traceId,
      String title,
      ELanguage language,
      boolean isGroup,
      String personalNote,
      String aiJustification) {
    User loggedInUser = loggedInUserService.getLoggedInUser();
    var trace = traceRepository.findById(traceId).orElseThrow(TraceNotFoundException::new);
    checkIfUserIsAuthorizedOnTrace(loggedInUser, trace);

    trace.setTitle(title);
    trace.setLanguage(language);
    trace.setGroup(isGroup);
    trace.setPersonalNote(personalNote);
    trace.setAiUseJustification(aiJustification);

    var savedTrace = traceRepository.save(trace);
    var isTraceAssociated = traceRepository.isAssociated(List.of(savedTrace)).get(savedTrace);
    return buildTraceDetailData(savedTrace, isTraceAssociated);
  }

  private TraceAttachment getTraceAttachment(Trace trace) {
    return traceAttachmentService.findByTrace(trace).stream()
        .filter(File::isActiveVersion)
        .findFirst()
        .orElseThrow(FileNotFoundException::new);
  }

  @Override
  public TraceAssociationsData associateTraceWithActivities(UUID traceId, List<UUID> activityIds) {
    User loggedInUser = loggedInUserService.getLoggedInUser();
    var trace = traceRepository.findById(traceId).orElseThrow(TraceNotFoundException::new);
    checkIfUserIsAuthorizedOnTrace(loggedInUser, trace);

    var activities = declaredActivityService.findAllDeclaredActivitiesByIds(activityIds);

    if (!new HashSet<>(activities.stream().map(DeclaredActivity::getId).toList())
        .containsAll(activityIds)) {
      throw new DeclaredActivityNotFoundException();
    }

    if (!activities.stream().allMatch(a -> a.getStudent().getUser().equals(loggedInUser))) {
      throw new UserNotAuthorizedException();
    }

    associationService.createAll(
        activityIds.stream()
            .map(
                activityId ->
                    new AssociationData(
                        activityId, traceId, EAssociationType.DECLARED_ACTIVITY_TRACE))
            .toList());

    return getTraceAssociations(traceId, false);
  }

  private DeclaredActivityAssociationData declaredActivityMapper(
      Association association, List<DeclaredActivity> activities) {
    return new DeclaredActivityAssociationData(
        association.getId(),
        activities.stream()
            .filter(activity -> activity.getId().equals(association.getId1()))
            .findAny()
            .orElseThrow(DeclaredActivityNotFoundException::new));
  }

  private DeclaredSkillAssociationData declaredSkillMapper(
      Association association, List<DeclaredSkillProgress> skills) {
    return new DeclaredSkillAssociationData(
        association.getId(),
        skills.stream()
            .filter(skill -> skill.getId().equals(association.getId2()))
            .findAny()
            .orElseThrow(DeclaredSkillProgressNotFoundException::new));
  }

  private DeclaredExperienceAssociationData declaredExperienceMapper(
      Association association, List<DeclaredExperience> experiences) {
    return new DeclaredExperienceAssociationData(
        association.getId(),
        experiences.stream()
            .filter(experience -> experience.getId().equals(association.getId2()))
            .findAny()
            .orElseThrow(DeclaredExperienceNotFoundException::new));
  }

  @Override
  public TraceAssociationsData associateTraceWithDeclaredSkill(UUID traceId, List<UUID> skillIds) {
    User loggedInUser = loggedInUserService.getLoggedInUser();
    var trace = traceRepository.findById(traceId).orElseThrow(TraceNotFoundException::new);
    checkIfUserIsAuthorizedOnTrace(loggedInUser, trace);

    var declaredSkills = declaredSkillProgressService.findAllDeclaredSkillProgressesByIds(skillIds);

    if (!new HashSet<>(declaredSkills.stream().map(DeclaredSkillProgress::getId).toList())
        .containsAll(skillIds)) {
      throw new DeclaredSkillProgressNotFoundException();
    }

    if (!declaredSkills.stream().allMatch(a -> a.getStudent().getUser().equals(loggedInUser))) {
      throw new UserNotAuthorizedException();
    }

    associationService.createAll(
        skillIds.stream()
            .map(
                skillId ->
                    new AssociationData(traceId, skillId, EAssociationType.TRACE_DECLARED_SKILL))
            .toList());

    return getTraceAssociations(traceId, false);
  }

  @Override
  public TraceAssociationsData associateTraceWithDeclaredExperience(
      UUID traceId, List<UUID> experienceIds) {
    User loggedInUser = loggedInUserService.getLoggedInUser();
    var trace = traceRepository.findById(traceId).orElseThrow(TraceNotFoundException::new);
    checkIfUserIsAuthorizedOnTrace(loggedInUser, trace);

    var declaredExperiences = declaredExperienceService.findAllByIds(experienceIds);

    if (!new HashSet<>(declaredExperiences.stream().map(DeclaredExperience::getId).toList())
        .containsAll(experienceIds)) {
      throw new DeclaredExperienceNotFoundException();
    }

    if (!declaredExperiences.stream()
        .allMatch(a -> a.getStudent().getUser().equals(loggedInUser))) {
      throw new UserNotAuthorizedException();
    }

    associationService.createAll(
        experienceIds.stream()
            .map(
                experienceId ->
                    new AssociationData(
                        traceId, experienceId, EAssociationType.TRACE_DECLARED_EXPERIENCE))
            .toList());

    return getTraceAssociations(traceId, false);
  }

  @Override
  public void unassociate(UUID traceId, List<UUID> associationIds) {
    User loggedInUser = loggedInUserService.getLoggedInUser();
    Trace trace = traceRepository.findById(traceId).orElseThrow(TraceNotFoundException::new);

    checkIfUserIsAuthorizedOnTrace(loggedInUser, trace);

    List<Association> associationList =
        associationService
            .getAllOf(traceId, Trace.class, EAssociationType.getAllBy(Trace.class))
            .stream()
            .filter(association -> associationIds.contains(association.getId()))
            .toList();

    if (!new HashSet<>(associationList.stream().map(Association::getId).toList())
        .containsAll(associationIds)) {
      throw new AssociationDoesNotExistException();
    }

    checkIfDeclaredActivitiesAssociationsAreDeletable(associationList);

    associationService.deleteAllByIds(associationIds);
  }

  private void checkIfDeclaredActivitiesAssociationsAreDeletable(
      List<Association> associationList) {
    List<UUID> declaredActivityIds =
        associationList.stream()
            .filter(
                association ->
                    association.getAssociationType() == EAssociationType.DECLARED_ACTIVITY_TRACE)
            .map(Association::getId1)
            .toList();

    if (declaredActivityService.findAllDeclaredActivitiesByIds(declaredActivityIds).stream()
        .anyMatch(a -> a.getFinishedAt().isPresent())) {
      throw new DeclaredActivityAlreadyFinishedException();
    }
  }

  @Override
  public PagedResult<AssociationSearchResultData> searchDeclaredActivityForAssociation(
      UUID traceId, String keyword, PageCriteria pageCriteria) {
    checkTraceOwnership(traceId);
    return associationSearchHelper.searchForAssociation(
        traceId,
        Trace.class,
        EAssociationType.DECLARED_ACTIVITY_TRACE,
        Association::getId1,
        declaredActivityService.searchDeclaredActivity(keyword, pageCriteria),
        AvenirsBaseModel::getId,
        da -> da.getActivity().getTitle(),
        da -> da.getActivity().getThematic().name(),
        da -> da.getFinishedAt().isPresent());
  }

  @Override
  public PagedResult<AssociationSearchResultData> searchDeclaredSkillForAssociation(
      UUID traceId, String keyword, PageCriteria pageCriteria) {
    checkTraceOwnership(traceId);
    return associationSearchHelper.searchForAssociation(
        traceId,
        Trace.class,
        EAssociationType.TRACE_DECLARED_SKILL,
        Association::getId2,
        declaredSkillProgressService.searchDeclaredSkill(keyword, pageCriteria),
        AvenirsBaseModel::getId,
        ds -> ds.getSkill().getLibelle(),
        ds -> ds.getSkill().getType().name(),
        ds -> false);
  }

  @Override
  public PagedResult<AssociationSearchResultData> searchDeclaredExperienceForAssociation(
      UUID traceId, String keyword, PageCriteria pageCriteria) {
    checkTraceOwnership(traceId);
    return associationSearchHelper.searchForAssociation(
        traceId,
        Trace.class,
        EAssociationType.TRACE_DECLARED_EXPERIENCE,
        Association::getId2,
        declaredExperienceService.search(keyword, pageCriteria),
        AvenirsBaseModel::getId,
        DeclaredExperience::getTitle,
        de -> de.getExperienceType().name(),
        de -> false);
  }

  private void checkIfUserIsAuthorizedOnTrace(User user, Trace trace) {
    if (!trace.getUser().equals(user)) {
      throw new UserNotAuthorizedException("%s does not own this %s".formatted(user, trace));
    }
  }

  private void checkTraceOwnership(UUID traceId) {
    var loggedInUser = loggedInUserService.getLoggedInUser();
    var trace = traceRepository.findById(traceId).orElseThrow(TraceNotFoundException::new);
    checkIfUserIsAuthorizedOnTrace(loggedInUser, trace);
  }
}