package com.atlassian.stash.internal.pull;

import com.atlassian.bitbucket.comment.CommentAction;
import com.atlassian.bitbucket.pull.PullRequestState;
import com.atlassian.bitbucket.util.Page;
import com.atlassian.bitbucket.util.PageRequest;
import com.atlassian.bitbucket.util.PagedIterable;
import com.atlassian.stash.internal.AbstractHibernateDao;
import com.atlassian.stash.internal.ApplicationConstants;
import com.atlassian.stash.internal.repository.RepositoryScopedIdGenerator;
import com.atlassian.stash.internal.task.TaskDao;
import com.atlassian.stash.internal.watcher.WatchableType;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import org.hibernate.Criteria;
import org.hibernate.Query;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.criterion.Order;
import org.hibernate.criterion.Restrictions;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

import javax.annotation.Nonnull;
import java.util.*;
import java.util.function.Predicate;

import static com.atlassian.stash.internal.HibernateUtils.initialize;
import static com.atlassian.stash.internal.HibernateUtils.initializePage;

@Repository("pullRequestDao")
public class HibernatePullRequestDao extends AbstractHibernateDao<Long, InternalPullRequest> implements PullRequestDao {

    private final PullRequestActivityDao activityDao;
    private final RepositoryScopedIdGenerator idGenerator;
    private final TaskDao taskDao;

    @Autowired
    public HibernatePullRequestDao(SessionFactory sessionFactory, PullRequestActivityDao activityDao,
                                   RepositoryScopedIdGenerator idGenerator, TaskDao taskDao) {
        super(sessionFactory);

        this.activityDao = activityDao;
        this.idGenerator = idGenerator;
        this.taskDao = taskDao;
    }

    @Override
    public Map<PullRequestState, Long> countAllByStatus() {
        @SuppressWarnings("unchecked")
        List<Object[]> results = session().createQuery(
                "select state, count(*) " +
                        "from InternalPullRequest " +
                        "group by state")
                .list();
        return countByState(results);
    }

    @Override
    public long countMatching(@Nonnull PullRequestSearchCriteria searchCriteria) {
        return (Long) new PullRequestCountQueryHelper(searchCriteria).build(session())
                .setCacheable(true)
                .setCacheRegion(ApplicationConstants.QUERY_CACHE_PULL_REQUEST_COUNT)
                .uniqueResult();
    }

    @Override
    public InternalPullRequest create(InternalPullRequest entity) {
        if (entity.getScopedId() == ApplicationConstants.UNSAVED_ENTITY) {
            long scopedId = idGenerator.nextId(entity.getScopeRepository().getId(), entity.getScope());

            entity.setScopedId(scopedId);
        }

        return super.create(entity);
    }

    @Override
    public int declineByFromRepository(int repositoryId) {
        return session().createQuery("update InternalPullRequest set state = :declined " +
                "where fromRef.repository.id = :repoId and state = :open")
                .setInteger("repoId", repositoryId)
                .setParameter("declined", PullRequestState.DECLINED)
                .setParameter("open", PullRequestState.OPEN)
                .executeUpdate();
    }

    @Override
    public void delete(InternalPullRequest entity) {
        long globalId = entity.getGlobalId();
        Session session = session();

        taskDao.deleteForContexts(ImmutableList.of(entity));

        // No foreign key association between pull request and watcher means that
        // we need to delete the watchers explicitly.
        session.createQuery("delete from InternalWatcher where watchable.id = :prId and watchable.class = :typeId")
                .setLong("prId", globalId)
                .setInteger("typeId", WatchableType.PULL_REQUEST_TYPE)
                .executeUpdate();

        //Collect all comment ids that need to be deleted. Unfortunately, this has to be done in two steps. Because of
        //foreign key constraints, we can't delete the comments until all activities have been deleted. But when the
        //activity is deleted, we have no way of determining which comments should also be deleted. Therefore, we
        //collect the comment IDs here and delete them _after_ the activity has been deleted.
        //
        //Note the distinct on the query; the same comment can occur in multiple activities (consider comment edited
        //activities), but the returned list should only contain each ID once
        @SuppressWarnings("unchecked")
        List<Long> commentIds = session.createQuery("select distinct comment.id " +
                "from InternalPullRequestCommentActivity " +
                "where pullRequest.id = :prId")
                .setLong("prId", globalId)
                .list();

        // delete all the pull request activities
        activityDao.deleteByPullRequest(globalId);

        // delete all the comments
        deleteComments(commentIds);

        // flush any changes to database before we delete the entity. This is a precaution because we've seen this fail
        // occasionally
        session.flush();

        super.delete(entity);
    }

    @Override
    public int deleteByToRepository(int repositoryId) {
        Session session = session();

        // we need to delete tasks before the pull request is deleted, otherwise
        // we lose our reference from  repository to pull request to task as
        // tasks to not have repository scope directly
        deleteTasksByScopeRepository(repositoryId);

        //Clean up all watchers
        session.createQuery("delete from InternalWatcher " +
                "where watchable.id in (select id from InternalPullRequest where toRef.repository.id = :repoId) " +
                "and watchable.class = :typeId")
                .setInteger("repoId", repositoryId)
                .setInteger("typeId", WatchableType.PULL_REQUEST_TYPE)
                .executeUpdate();

        //Collect all comment ids that need to be deleted. Unfortunately, this has to be done in two steps. Because of
        //foreign key constraints, we can't delete the comments until all activities have been deleted. But when the
        //activity is deleted, we have no way of determining which comments should also be deleted. Therefore, we
        //collect the comment IDs here and delete them _after_ the activity has been deleted.
        //
        //Note the distinct on the query; the same comment can occur in multiple activities (consider comment edited
        //activities), but the returned list should only contain each ID once
        @SuppressWarnings("unchecked")
        List<Long> commentIds = session.createQuery("select distinct comment.id " +
                "from InternalPullRequestCommentActivity " +
                "where pullRequest.toRef.repository.id = :repoId " +
                "order by comment.id desc")
                .setInteger("repoId", repositoryId)
                .list();

        // delete all the pull request activities
        activityDao.deleteByRepository(repositoryId);

        // delete all the comments
        deleteComments(commentIds);

        // finally delete the pull requests too
        int updated = session.createQuery("delete from InternalPullRequest where toRef.repository.id = :repoId")
                .setInteger("repoId", repositoryId)
                .executeUpdate();
        session.flush();

        return updated;
    }

    @Override
    public InternalPullRequest findByComment(long commentId) {
        Query query = session().createQuery("select a.pullRequest from InternalPullRequestCommentActivity a " +
                "where a.comment.id = :commentId " +
                "and a.commentAction in (:actions)")
                .setParameter("commentId", commentId)
                .setParameterList("actions", EnumSet.of(CommentAction.ADDED, CommentAction.REPLIED));

        return initialize((InternalPullRequest) query.uniqueResult());
    }

    @Override
    public InternalPullRequest findByRepositoryScopedId(int repositoryId, long pullRequestId) {
        Query query = session().createQuery("from InternalPullRequest " +
                "where toRef.repository.id = :repositoryId " +
                "and scopedId = :pullRequestId")
                .setParameter("repositoryId", repositoryId)
                .setParameter("pullRequestId", pullRequestId);

        return initialize((InternalPullRequest) query.uniqueResult());
    }

    @Override
    public InternalPullRequest findByRefs(@Nonnull InternalPullRequestRef from, @Nonnull InternalPullRequestRef to) {
        Query query = session().createQuery("from InternalPullRequest " +
                "where state = :state " +
                "and fromRef.repository.id = :fromRepositoryId and fromRef.id = :fromBranchFqn " +
                "and toRef.repository.id = :toRepositoryId and toRef.id = :toBranchFqn")
                .setString("fromBranchFqn", from.getId())
                .setInteger("fromRepositoryId", from.getRepository().getId())
                .setString("toBranchFqn", to.getId())
                .setInteger("toRepositoryId", to.getRepository().getId())
                .setParameter("state", PullRequestState.OPEN);

        return initialize((InternalPullRequest) query.uniqueResult());
    }

    @Nonnull
    @Override
    public Page<InternalPullRequest> findUnmergedByFromRepository(int repositoryId, @Nonnull PageRequest pageRequest) {
        Criteria criteria = session().createCriteria(InternalPullRequest.class)
                .add(Restrictions.eq("fromRef.repository.id", repositoryId))
                .add(Restrictions.ne("state", PullRequestState.MERGED))
                .addOrder(Order.asc("id"));

        //This page is _intentionally_ not initialized. This method is _only_ intended to be used from within a
        //narrowly controlled transaction and not to be exposed in the public API
        return pageCriteria(criteria, pageRequest);
    }
    
    @Override
    public InternalPullRequest getById(Long id) {
        return initialize(super.getById(id));
    }

    @Override
    public int overwriteFromRepository(int repositoryId) {
        Session session = session();

        int updated = session.createQuery("update InternalPullRequest " +
                "set fromRef.repository.id = toRef.repository.id " +
                "where fromRef.repository.id = :repoId")
                .setInteger("repoId", repositoryId)
                .executeUpdate();
        session.flush();

        return updated;
    }

    @Nonnull
    @Override
    public Page<InternalPullRequest> search(@Nonnull PullRequestSearchCriteria searchCriteria,
                                            @Nonnull PageRequest pageRequest,
                                            @Nonnull Predicate<? super InternalPullRequest> predicate) {

        return initializePage(pageQuery(new PullRequestSearchQueryHelper(searchCriteria)
                .build(session()), pageRequest, predicate));
    }

    @Override
    public InternalPullRequest update(InternalPullRequest entity) {
        return initialize(super.update(entity));
    }

    @Override
    protected Iterable<Order> getImplicitOrder() {
        return Collections.singleton(Order.desc("updatedDate"));
    }

    private Map<PullRequestState, Long> countByState(List<Object[]> results) {
        HashMap<PullRequestState, Long> countByState = Maps.newHashMapWithExpectedSize(results.size());
        for (Object[] column : results) {
            countByState.put((PullRequestState) column[0], (Long) column[1]);
        }
        return ImmutableMap.copyOf(countByState);
    }

    private void deleteTasksByScopeRepository(int repositoryId) {
        final PullRequestSearchCriteria searchCriteria = new PullRequestSearchCriteria.Builder()
                .toRepositoryId(repositoryId)
                .build();

        PagedIterable<InternalPullRequest> pullRequests = new PagedIterable<>(request -> {
            return search(searchCriteria, request, p -> true);
        }, DELETE_PAGE_SIZE);

        taskDao.deleteForContexts(pullRequests);
    }

    private void deleteComments(List<Long> commentIds) {
        if (commentIds.isEmpty()) {
            return;
        }

        //MySQL has performance issues with long in clauses, and Oracle has a hard limit of 1000 entries in
        //an in clause. To reduce the likelihood of encountering an issue, delete comments 100 at a time
        for (List<Long> ids : Lists.partition(commentIds, 100)) {
            session().createQuery("delete from InternalComment where id in (:commentIds)")
                    .setParameterList("commentIds", ids)
                    .executeUpdate();
        }
    }
}
