CardManager.java

/*
 * The coLAB project
 * Copyright (C) 2021-2024 AlbaSim, MEI, HEIG-VD, HES-SO
 *
 * Licensed under the MIT License
 */
package ch.colabproject.colab.api.controller.card;

import ch.colabproject.colab.api.controller.card.grid.Grid;
import ch.colabproject.colab.api.controller.card.grid.GridPosition;
import ch.colabproject.colab.api.controller.common.DeletionManager;
import ch.colabproject.colab.api.controller.document.ResourceReferenceSpreadingHelper;
import ch.colabproject.colab.api.model.card.AbstractCardType;
import ch.colabproject.colab.api.model.card.Card;
import ch.colabproject.colab.api.model.card.CardContent;
import ch.colabproject.colab.api.model.card.CardType;
import ch.colabproject.colab.api.model.link.ActivityFlowLink;
import ch.colabproject.colab.api.model.link.StickyNoteLink;
import ch.colabproject.colab.api.model.project.Project;
import ch.colabproject.colab.api.model.team.acl.Assignment;
import ch.colabproject.colab.api.persistence.jpa.card.CardDao;
import ch.colabproject.colab.generator.model.exceptions.HttpErrorMessage;
import ch.colabproject.colab.generator.model.exceptions.MessageI18nKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.ejb.LocalBean;
import javax.ejb.Stateless;
import javax.inject.Inject;
import java.util.*;
import java.util.stream.Collectors;

/**
 * Card, card type and card content specific logic
 *
 * @author sandra
 */
@Stateless
@LocalBean
public class CardManager {

    /** logger */
    private static final Logger logger = LoggerFactory.getLogger(CardManager.class);

    // *********************************************************************************************
    // injections
    // *********************************************************************************************

    /**
     * Common deletion lifecycle management
     */
    @Inject
    private DeletionManager deletionManager;

    /**
     * Card persistence handler
     */
    @Inject
    private CardDao cardDao;

    /**
     * Card type specific logic management
     */
    @Inject
    private CardTypeManager cardTypeManager;

    /**
     * Card content specific logic management
     */
    @Inject
    private CardContentManager cardContentManager;

    /**
     * Resource reference spreading specific logic handling
     */
    @Inject
    private ResourceReferenceSpreadingHelper resourceReferenceSpreadingHelper;

    // *********************************************************************************************
    // find cards
    // *********************************************************************************************

    /**
     * Retrieve the card. If not found, throw a {@link HttpErrorMessage}.
     *
     * @param cardId the id of the card
     *
     * @return the card if found
     *
     * @throws HttpErrorMessage if the card was not found
     */
    public Card assertAndGetCard(Long cardId) {
        Card card = cardDao.findCard(cardId);

        if (card == null) {
            logger.error("card #{} not found", cardId);
            throw HttpErrorMessage.dataError(MessageI18nKey.DATA_NOT_FOUND);
        }

        return card;
    }

    // *********************************************************************************************
    // find all recursively
    // *********************************************************************************************

    /**
     * Get a card and all cards within in one set.
     *
     * @param rootCard the first card
     *
     * @return the rootCard + all cards within
     */
    public Set<Card> getAllCards(Card rootCard) {
        Set<Card> cards = new HashSet<>();
        List<Card> queue = new LinkedList<>();
        queue.add(rootCard);

        while (!queue.isEmpty()) {
            Card card = queue.remove(0);
            if (!cards.contains(card)) { // prevent cycles
                cards.add(card);
                card.getContentVariants().forEach(content -> queue.addAll(content.getSubCards()));
            }
        }

        return cards;
    }

    /**
     * Get all cardContents
     *
     * @param rootCard the first card
     *
     * @return all cardContent in the card hierarchy
     */
    public Set<CardContent> getAllCardContents(Card rootCard) {
        return this.getAllCards(rootCard).stream()
                .flatMap(card -> card.getContentVariants().stream())
                .collect(Collectors.toSet());
    }

    // *********************************************************************************************
    // helper
    // *********************************************************************************************

    /**
     * @param card the card to check
     *
     * @return True if the card is the root card of a project
     */
    private boolean isARootCard(Card card) {
        return card.hasRootCardProject();
    }

    private CardContent getRootCardContent(Project project) {
        Card rootCard = project.getRootCard();
        return rootCard.getContentVariants().get(0);
    }

    /**
     * @param parent the card content parent
     *
     * @return All not-deleted cards under the given card content.
     */
    public List<Card> getAliveSubCards(CardContent parent) {
        if (parent != null) {
            List<Card> subCardsOfParent = new ArrayList<>(parent.getSubCards());
            return subCardsOfParent.stream()
                    .filter((card) -> deletionManager.isAlive(card)).collect(Collectors.toList());
        }

        return new ArrayList<>();
    }

    private void reorganizeGrid(CardContent parent) {
        if (parent != null) {
            List<Card> aliveSubcards = getAliveSubCards(parent);

            if (!aliveSubcards.isEmpty()) {
                // resolve any conflict in the current situation
                Grid grid = Grid.resolveConflicts(aliveSubcards);
                // ascertain that the min x is 1 and the min y is 1
                grid.shift();
            }
        }
    }

    // *********************************************************************************************
    // life cycle
    // *********************************************************************************************

    /**
     * Complete and persist a new card into the given card content with the given
     * card type.
     * <p>
     * Also create its default resource references.
     *
     * @param parentId   the id of the parent of the new card
     * @param cardTypeId the id of the card type of the new card. Can be null
     *
     * @return a new, initialized and persisted card
     */
    public Card createNewCard(Long parentId, Long cardTypeId) {
        logger.debug("create a new sub card of #{} with the type of #{}", parentId,
                cardTypeId);

        CardContent parent = cardContentManager.assertAndGetCardContent(parentId);

        AbstractCardType cardType;
        if (cardTypeId == null) {
            cardType = null;
        } else {
            cardType = cardTypeManager.assertAndGetCardTypeOrRef(cardTypeId);
        }

        Card card = initNewCard(parent, cardType);

        resourceReferenceSpreadingHelper.extractReferencesFromUp(card);

        return cardDao.persistCard(card);
    }

    /**
     * Initialize a new card. Card will be bound to the given type.
     * <p>
     * If the type does not belong to the same project as the card do, a type ref
     * is created.
     *
     * @param parent   the parent of the new card
     * @param cardType the related card type. Can be null
     *
     * @return a new card containing a new card content with cardType
     */
    private Card initNewCard(CardContent parent, AbstractCardType cardType) {
        Card card = initNewCard();

        initCardInCardContent(card, parent);

        initCardTypeOfCard(card, parent, cardType);

        return card;
    }

    /**
     * Add the card in its parent and initialize the position of the card in its parent.
     *
     * @param card   the card
     * @param parent the new parent of the card
     */
    private void initCardInCardContent(Card card, CardContent parent) {
        List<Card> aliveSubcards = getAliveSubCards(parent);

        // resolve any conflict in the current situation
        Grid grid = Grid.resolveConflicts(aliveSubcards);
        // reset the grid data of the card
        grid.resetCell(card);
        // then add the card in the grid
        grid.addCell(card);
        // ascertain that xMin = 1 and yMin = 1
        grid.shift();

        // Note : must be set after the grid resolution, else it throws a NPE because of null id
        card.setParent(parent);
        parent.getSubCards().add(card);
    }

    private void initCardTypeOfCard(Card card, CardContent parent, AbstractCardType cardType) {
        Project project = parent.getProject();
        if (project != null && cardType != null) {
            AbstractCardType effectiveType = cardTypeManager.computeEffectiveCardTypeOrRef(cardType,
                    project);

            if (effectiveType != null) {

                card.setCardType(effectiveType);
                effectiveType.getImplementingCards().add(card);

                CardType resolved = effectiveType.resolve();
                if (resolved != null) {
                    card.setTitle(resolved.getTitle());
                } else {
                    logger.error("Unresolvable card type {}", effectiveType);
                }
            } else {
                logger.error("Unable to find effective type for {}", cardType);
                throw HttpErrorMessage.dataError(MessageI18nKey.DATA_INTEGRITY_FAILURE);
            }
        }
    }

    /**
     * Initialize a new root card. This card contains every other cards of a
     * project.
     * <p>
     * No persistence stuff in there
     *
     * @return a new card dedicated to be the root card of a project
     */
    public Card initNewRootCard() {
        logger.debug("initialize a new root card");

        return initNewCard();
    }

    /**
     * Initialize a new card.
     *
     * @return a new card containing a new card content
     */
    private Card initNewCard() {
        Card card = new Card();

        cardContentManager.initNewCardContentForCard(card);

        return card;
    }

    /**
     * Put the given card in the bin. (= set DeletionStatus to BIN + set erasure
     * tracking data)
     *
     * @param cardId the id of the card
     */
    public void putCardInBin(Long cardId) {
        logger.debug("put in bin card #{}", cardId);

        Card card = assertAndGetCard(cardId);

        deletionManager.putInBin(card);

        CardContent parent = card.getParent();
        if (parent != null) {
            reorganizeGrid(parent);
        }
    }

    /**
     * Restore from the bin. The object won't contain any deletion or erasure data anymore.
     * <p>
     * It means that the card is back at its place (as much as possible).
     * <p>
     * If the parent card is deleted, the card is moved at the root of the project.
     *
     * @param cardId the id of the card
     */
    public void restoreCardFromBin(Long cardId) {
        logger.debug("restore from bin card #{}", cardId);

        Card card = assertAndGetCard(cardId);

        deletionManager.restoreFromBin(card);

        if (isAnyAncestorDeleted(card)) {
            // if one of its ancestor is deleted, we put the card at root level
            CardContent rootCardContent = getRootCardContent(card.getProject());
            initCardInCardContent(card, rootCardContent);
        }

        CardContent parent = card.getParent();
        if (parent != null) {
            List<Card> aliveSubcards = getAliveSubCards(parent);

            // compute the grid without the cell to move
            aliveSubcards.remove(card);
            Grid grid = Grid.resolveConflicts(aliveSubcards);

            if (!aliveSubcards.isEmpty()) {
                // ascertain that the min x is 1 and the min y is 1
                grid.shift();
            }

            // then add the card
            // So, if the position is taken by another card, its position is recomputed
            grid.addCell(card);
        }
    }

    /**
     * Check if any ancestor of the card is deleted
     *
     * @param card the card
     * @return True iff any ancestor of the card is deleted.
     */
    private boolean isAnyAncestorDeleted(Card card) {
        CardContent parentCardContent = card.getParent();
        if (parentCardContent == null) {
            return false;
        }

        Card parentCard = parentCardContent.getCard();
        if (parentCard == null) {
            return false;
        }

        if (deletionManager.isDeleted(parentCard)) {
            return true;
        } else {
            return isAnyAncestorDeleted(parentCard);
        }
    }

    /**
     * Set the deletion status to TO_DELETE.
     * <p>
     * It means that the card is only visible in the bin panel.
     *
     * @param cardId the id of the card
     */
    public void markCardAsToDeleteForever(Long cardId) {
        logger.debug("mark card #{} as to delete forever", cardId);

        Card card = assertAndGetCard(cardId);

        deletionManager.markAsToDeleteForever(card);
    }

    /**
     * Delete the given card
     *
     * @param cardId the id of the card to delete
     */
    public void deleteCard(Long cardId) {
        Card card = assertAndGetCard(cardId);

        if (!checkDeletionAcceptability(card)) {
            throw HttpErrorMessage.dataError(MessageI18nKey.DATA_INTEGRITY_FAILURE);
        }

        card.getParent().getSubCards().remove(card);

        if (card.hasCardType()) {
            card.getCardType().getImplementingCards().remove(card);
        }

        cardDao.deleteCard(card);
    }

    /**
     * Ascertain that the card can be deleted
     *
     * @param card the card to check for deletion
     *
     * @return True iff it can be safely deleted
     */
    private boolean checkDeletionAcceptability(Card card) {
        // no way to delete the root card
        if (card.getRootCardProject() != null) {
            return false;
        }

        return true;
    }

    /**
     * Change the position of the card (stay in the same parent, just change
     * position within grid)
     * <p>
     * Recompute the position of all the sister cards
     *
     * @param cardId   the id of the card
     * @param position the new position to set
     */
    public void changeCardPosition(Long cardId, GridPosition position) {
        Card card = this.assertAndGetCard(cardId);
        CardContent parent = card.getParent();
        if (parent != null) {
            List<Card> aliveSubcards = getAliveSubCards(parent);
            // make sure there is no conflict in the current situation
            Grid.resolveConflicts(aliveSubcards);

            // compute the grid without the cell to move
            aliveSubcards.remove(card);
            Grid grid = Grid.resolveConflicts(aliveSubcards);

            card.moveTo(position);
            grid.addCell(card);

            // ascertain that xMin = 1 and yMin = 1
            grid.shift();
        }

        // indexManager.changeItemPosition(card, index, card.getParent().getSubCards());
    }

    /**
     * Move a card to a new parent
     *
     * @param cardId      id of the card to move
     * @param newParentId id of the new parent
     *
     * @throws HttpErrorMessage if card or parent does not exist or if parent is a
     *                          child of the card
     */
    public void moveCard(Long cardId, Long newParentId) {
        Card card = this.assertAndGetCard(cardId);
        CardContent parent = cardContentManager.assertAndGetCardContent(newParentId);
        this.moveCard(card, parent);
    }

    /**
     * Move a card to its grandparent card content. (aka move a level up)
     *
     * @param cardId the id of the card to move
     *
     * @throws HttpErrorMessage if card or parent does not exist
     */
    public void moveCardAbove(Long cardId) {
        Card card = this.assertAndGetCard(cardId);

        if (card.getParent() == null) {
            throw HttpErrorMessage.dataError(MessageI18nKey.DATA_INTEGRITY_FAILURE);
        }

        CardContent destinationCardContent = cardContentManager
                .assertAndGetGrandParentCardContent(card);

        moveCard(card, destinationCardContent);
    }

    /**
     * Move a card to a new parent.
     * <p>
     * Mark all the resource references to the former parent as residual.
     * <p>
     * Make resource references to the new parent.
     *
     * @param card      the card to move
     * @param newParent the new parent
     *
     * @throws HttpErrorMessage if card or parent does not exist or if parent is a
     *                          child of the card
     */
    private void moveCard(Card card, CardContent newParent) {
        if (!checkMoveAcceptability(card, newParent)) {
            throw HttpErrorMessage.dataError(MessageI18nKey.DATA_INTEGRITY_FAILURE);
        }

        if (!Objects.equals(card.getParent(), newParent)) {

            CardContent previousParent = card.getParent();
            if (previousParent != null) {
                resourceReferenceSpreadingHelper.spreadDisableResourceDown(card, previousParent);

                previousParent.getSubCards().remove(card);
            }

            // the position on the new parent is all new
            resetCardCoordinates(card);

            List<Card> newParentAliveSubcards = getAliveSubCards(newParent);
            // resolve any conflict in the current situation
            Grid grid = Grid.resolveConflicts(newParentAliveSubcards);
            // then add the card in the grid
            grid.addCell(card);
            // ascertain that xMin = 1 and yMin = 1
            grid.shift();

            card.setParent(newParent);
            newParent.getSubCards().add(card);

            resourceReferenceSpreadingHelper.extractReferencesFromUp(card);
        }
    }

    /**
     * Changes the coordinates of the card to be the default ones
     *
     * @param card the card
     */
    private void resetCardCoordinates(Card card) {
        card.setX(Grid.DEFAULT_X_COORDINATE);
        card.setY(Grid.DEFAULT_Y_COORDINATE);
    }

    /**
     * Ascertain that the given card can be moved to the given card content
     *
     * @param card      the card to move
     * @param newParent the new potential parent of the card
     *
     * @return True iff it can be safely moved
     */
    private boolean checkMoveAcceptability(Card card, CardContent newParent) {
        if (card == null) {
            return false;
        }

        if (newParent == null) {
            return false;
        }

        // Do never move the root card
        if (isARootCard(card)) {
            return false;
        }

        // check if newParent is a child of the card
        Card ancestorOfParent = newParent.getCard();
        while (ancestorOfParent != null) {
            if (ancestorOfParent.equals(card)) {
                return false;
            }
            CardContent parent = ancestorOfParent.getParent();
            if (parent != null) {
                ancestorOfParent = parent.getCard();
            } else {
                ancestorOfParent = null;
            }
        }

        return true;
    }

    // *********************************************************************************************
    // retrieve the elements of a card
    // *********************************************************************************************

    /**
     * Get all variants content for the given card
     *
     * @param cardId the id of the card
     *
     * @return all card contents of the card
     */
    public List<CardContent> getContentVariants(Long cardId) {
        logger.debug("Get card contents of card #{}", cardId);

        Card card = assertAndGetCard(cardId);

        return card.getContentVariants();
    }

    /**
     * Get all sticky note links of which the given card is the destination
     *
     * @param cardId the id of the card
     *
     * @return all sticky note linked from the card
     */
    public List<StickyNoteLink> getStickyNoteLinkAsDest(Long cardId) {
        logger.debug("get sticky note links where the card #{} is the destination", cardId);

        Card card = assertAndGetCard(cardId);

        return card.getStickyNoteLinksAsDest();
    }

    /**
     * Get all sticky note links of which the given card is the source
     *
     * @param cardId the id of the card
     *
     * @return all sticky note linked to the card
     */
    public List<StickyNoteLink> getStickyNoteLinkAsSrcCard(Long cardId) {
        logger.debug("get sticky note links where the card #{} is the source", cardId);

        Card card = assertAndGetCard(cardId);

        return card.getStickyNoteLinksAsSrc();
    }

    /**
     * Get all activity flow links of which the given card is the previous one
     *
     * @param cardId the id of the card
     *
     * @return all activity flow linked to the card
     */
    public List<ActivityFlowLink> getActivityFlowLinkAsPrevious(Long cardId) {
        logger.debug("get activity flow links where the card #{} is the previous one", cardId);

        Card card = assertAndGetCard(cardId);

        return card.getActivityFlowLinksAsPrevious();
    }

    /**
     * Get all activity flow links of which the given card is the next one
     *
     * @param cardId the id of the card
     *
     * @return all activity flow linked from the card
     */
    public List<ActivityFlowLink> getActivityFlowLinkAsNext(Long cardId) {
        logger.debug("get activity flow links where the card #{} is the next one", cardId);

        Card card = assertAndGetCard(cardId);

        return card.getActivityFlowLinksAsNext();
    }

    /**
     * Retrieve the list of assignments for the given card
     *
     * @param cardId id of the card
     *
     * @return list of assignments
     */
    public List<Assignment> getAssignments(Long cardId) {
        logger.debug("Get Card #{} assignments", cardId);

        Card card = assertAndGetCard(cardId);

        return card.getAssignments();
    }

    // *********************************************************************************************
    // dedicated to access control
    // *********************************************************************************************

    // *********************************************************************************************
    // integrity check
    // *********************************************************************************************

    /**
     * Check the integrity of the card
     *
     * @param card the card to check
     *
     * @return true iff the card is complete and safe
     */
    public boolean checkIntegrity(Card card) {
        if (card == null) {
            return false;
        }

        if (!isARootCard(card)) {
            return false;
        }

        return true;
    }

    // *********************************************************************************************
    //
    // *********************************************************************************************

    // *********************************************************************************************
    //
    // *********************************************************************************************

    /**
     * Create a card type for a card which has none
     *
     * @param cardId the card id
     */
    public void createCardType(Long cardId) {
        Card card = assertAndGetCard(cardId);
        if (card.getCardType() != null) {
            throw HttpErrorMessage.dataError(MessageI18nKey.DATA_INTEGRITY_FAILURE);
        }

        CardType newCardType = new CardType();
        newCardType.setProject(card.getProject());
        newCardType.setTitle(card.getTitle());

        cardTypeManager.createCardType(newCardType);

        card.getProject().getElementsToBeDefined().add(newCardType);

        card.setCardType(newCardType);

        newCardType.getImplementingCards().add(card);
    }

    /**
     * Remove the card type of the card.
     * <p>
     * For now, it handles only card types that have no resource
     *
     * @param cardId the card id
     */
    public void removeCardType(Long cardId) {
        Card card = assertAndGetCard(cardId);

        if (card.getCardType() == null) {
            // already ok
            return;
        }

        AbstractCardType cardType = card.getCardType();

        // Just a check to handle only simple cases.
        // When ready to handle everything concerning resources, and also when it is
        // useful, do it
        if (!cardType.getDirectAbstractResources().isEmpty()) {
            throw HttpErrorMessage.dataError(MessageI18nKey.DATA_INTEGRITY_FAILURE);
        }

        cardType.getImplementingCards().remove(card);
        card.setCardType(null);
    }

}