package com.atlassian.stash.internal.pull.rescope;

import com.atlassian.bitbucket.commit.Commit;
import com.atlassian.bitbucket.permission.Permission;
import com.atlassian.bitbucket.pull.RescopeDetails;
import com.atlassian.bitbucket.user.SecurityService;
import com.atlassian.bitbucket.util.Timer;
import com.atlassian.bitbucket.util.TimerUtils;
import com.atlassian.event.api.EventListener;
import com.atlassian.plugin.event.events.PluginFrameworkStartedEvent;
import com.atlassian.scheduler.*;
import com.atlassian.scheduler.config.*;
import com.atlassian.stash.internal.HibernateUtils;
import com.atlassian.stash.internal.pull.*;
import com.atlassian.stash.internal.scheduling.ScheduledJobSource;
import com.atlassian.stash.internal.spring.TransactionSynchronizer;
import com.google.common.base.Function;
import com.google.common.collect.Lists;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallbackWithoutResult;
import org.springframework.transaction.support.TransactionSynchronizationAdapter;
import org.springframework.transaction.support.TransactionTemplate;

import javax.annotation.Nonnull;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;

/**
 * @since 2.5
 */
public class DefaultRescopeProcessor implements RescopeProcessor, ScheduledJobSource {

    private static final ToRescopeCommit AS_ADDED = new ToRescopeCommit(PullRequestRescopeCommitAction.ADDED);
    private static final ToRescopeCommit AS_REMOVED = new ToRescopeCommit(PullRequestRescopeCommitAction.REMOVED);
    private static final JobId CLEANUP_JOB_ID = JobId.of(CleanupEmptyRescopesJob.class.getSimpleName());
    private static final JobRunnerKey CLEANUP_JOB_RUNNER_KEY = JobRunnerKey.of(CleanupEmptyRescopesJob.class.getName());

    private static final Logger log = LoggerFactory.getLogger(DefaultRescopeProcessor.class);

    private final PullRequestActivityDao activityDao;
    private final InternalRescopeActivityDetailsProvider detailsProvider;
    private final ExecutorService executorService;
    private final SecurityService securityService;
    private final TransactionSynchronizer synchronizer;
    private final TransactionTemplate transactionTemplate;

    private long cleanupInterval;
    private volatile boolean initialized;

    public DefaultRescopeProcessor(PullRequestActivityDao activityDao,
                                   InternalRescopeActivityDetailsProvider detailsProvider,
                                   ExecutorService executorService,
                                   TransactionSynchronizer synchronizer,
                                   TransactionTemplate transactionTemplate,
                                   SecurityService securityService) {
        this.activityDao = activityDao;
        this.detailsProvider = detailsProvider;
        this.executorService = executorService;
        this.securityService = securityService;
        this.synchronizer = synchronizer;
        this.transactionTemplate = transactionTemplate;
    }

    /**
     * Deletes any empty rescopes, logging the number for informational purposes. This is run periodically to prevent a
     * build-up of empty rescopes from slowing down retrieving pull request activity.
     */
    public void deleteEmptyRescopes() {
        transactionTemplate.execute(new TransactionCallbackWithoutResult() {

            @Override
            protected void doInTransactionWithoutResult(TransactionStatus status) {
                int count = activityDao.deleteEmptyRescopes();
                if (count == 0) {
                    log.debug("No empty rescopes were found");
                } else {
                    log.debug("Deleted {} empty rescope(s)", count);
                }
            }
        });
    }

    @EventListener
    public synchronized void onPluginFrameworkStarted(@SuppressWarnings("unused") PluginFrameworkStartedEvent ignored) {
        if (initialized) {
            return;
        }
        initialized = true;

        List<Long> ids = transactionTemplate.execute(status -> activityDao.findCalculableRescopes());

        if (ids.isEmpty()) {
            log.debug("Rescope details have already been calculated for all open pull requests");
        } else {
            log.info("Scheduling detail calculation for {} pull request rescopes", ids.size());
            for (long id : ids) {
                executorService.submit(new RescopeCalculator(id));
            }
        }
    }

    @Override
    public void process(@Nonnull InternalPullRequestRescopeActivity activity) {
        processAll(activity.getPullRequest(), Collections.singletonList(activity));
    }

    @Override
    public void processAll(@Nonnull InternalPullRequest pullRequest,
                           @Nonnull List<InternalPullRequestRescopeActivity> activities) {
        try (Timer ignored = TimerUtils.start(getClass().getName() + ".processAll(...)")) {
            if (activities.isEmpty()) {
                return;
            }

            List<InternalPullRequestRescopeActivity> updated = Lists.newArrayList();
            for (InternalPullRequestRescopeActivity rescope : activities) {
                if (rescope.isPrecalculated()) {
                    continue;
                }

                InternalRescopeActivityDetails details = detailsProvider.getDetails(rescope);
                rescope.setAdded(details.getAdded());
                rescope.setRemoved(details.getRemoved());

                //This _may_ set the added and removed counts to 0 (an empty rescope). Previously such rescope activites
                //were simply deleted here. However, on MySQL, that causes deadlocks due to index locks. To avoid that,
                //the activities are updated to set the counts and the query that loads activities ignores empty ones.
                //
                //A scheduled task runs periodically to select out all "empty" rescopes in the database and delete them.
                updated.add(rescope);
            }

            if (!updated.isEmpty()) {
                //To reduce database load updates are all processed in a single transaction, since concurrent updates
                //may not actually trigger rollbacks
                updateActivities(pullRequest, updated);
            }
        }
    }

    /**
     * <i>Implementation Note</i>: Using the {@link TransactionSynchronizer} here is wrong, and is not an approach
     * to be emulated. The service which demarcates the transaction boundaries ({@link DefaultPullRequestService} in
     * this case) should be ensuring this method call is correctly synchronized with that transaction.
     *
     * @param activity the activity to process, calculating added and removed commits and storing them for reuse
     */
    @Override
    public void queue(@Nonnull final InternalPullRequestRescopeActivity activity) {
        //If the synchronization cannot be registered, it is discarded
        synchronizer.register(new TransactionSynchronizationAdapter() {

            @Override
            public void afterCommit() {
                log.debug("Queuing detail processing for rescope {}", activity.getId());
                executorService.submit(new RescopeCalculator(activity.getId()));
            }
        });
    }

    @Override
    public void schedule(@Nonnull SchedulerService schedulerService) throws SchedulerServiceException {
        schedulerService.registerJobRunner(CLEANUP_JOB_RUNNER_KEY, new CleanupEmptyRescopesJob());
        schedulerService.scheduleJob(
                CLEANUP_JOB_ID,
                JobConfig.forJobRunnerKey(CLEANUP_JOB_RUNNER_KEY)
                        .withRunMode(RunMode.RUN_ONCE_PER_CLUSTER)
                        .withSchedule(Schedule.forInterval(cleanupInterval, new Date(System.currentTimeMillis() + cleanupInterval)))
        );
    }

    @Value("${pullrequest.rescope.cleanup.interval}")
    public void setCleanupInterval(long cleanupInterval) {
        this.cleanupInterval = TimeUnit.MINUTES.toMillis(cleanupInterval);
    }

    private void updateActivities(InternalPullRequest pullRequest,
                                  final List<InternalPullRequestRescopeActivity> activities) {
        try (Timer ignored = TimerUtils.start(pullRequest.getGlobalId() + " - Update commits for " + activities.size() + " activities")) {
            transactionTemplate.execute(new TransactionCallbackWithoutResult() {

                @Override
                protected void doInTransactionWithoutResult(TransactionStatus status) {
                    for (InternalPullRequestRescopeActivity activity : activities) {
                        RescopeDetails added = activity.getAdded();
                        RescopeDetails removed = activity.getRemoved();

                        //Rescopes are only marked for update if at least one commit was added or removed, so persist
                        //the totals and the commit IDs for the displayed commits. Note that how many commits are
                        //added to the database is subject to configuration (defaulting to 5 each for added and removed)
                        InternalPullRequestRescopeActivity updated = new InternalPullRequestRescopeActivity.Builder(activity)
                                .commits(Lists.transform(added.getCommits(), AS_ADDED))
                                .commits(Lists.transform(removed.getCommits(), AS_REMOVED))
                                .totalAdded(added.getTotal())
                                .totalRemoved(removed.getTotal())
                                .build();
                        activityDao.update(updated);
                    }
                }
            });
        } catch (RuntimeException e) {
            log.warn(pullRequest.getGlobalId() + ": Could not store calculated rescope details", e);
        }
    }

    @Override
    public void unschedule(@Nonnull SchedulerService schedulerService) throws SchedulerServiceException {
        schedulerService.unregisterJobRunner(CLEANUP_JOB_RUNNER_KEY);
    }

    private class CleanupEmptyRescopesJob implements JobRunner {

        @Override
        public JobRunnerResponse runJob(@Nonnull JobRunnerRequest request) {
            deleteEmptyRescopes();

            return JobRunnerResponse.success();
        }
    }

    private class RescopeCalculator implements Runnable {

        private final long activityId;

        private RescopeCalculator(long activityId) {
            this.activityId = activityId;
        }

        @Override
        public void run() {
            try (Timer ignored = TimerUtils.start(getClass().getName() + ".run()")) {
                InternalPullRequestRescopeActivity activity = transactionTemplate.execute(
                        status -> {
                            status.setRollbackOnly();

                            return HibernateUtils.cast(activityDao.getById(activityId), InternalPullRequestRescopeActivity.class);
                        });
                //If the rescope was already processed (usually via processAll as part of enriching rescope activities
                //for the pull request activity page) it will either:
                //- Have been deleted, meaning getById will return null
                //- Be pre-calculated, in which case it does not need to be processed again
                if (!(activity == null || activity.isPrecalculated())) {
                    securityService.withPermission(Permission.REPO_READ,
                            "Calculating rescope details").<Void, RuntimeException>call(() -> {
                        process(activity);

                        return null;
                    });
                }
            }
        }
    }

    private static class ToRescopeCommit implements Function<Commit, InternalPullRequestRescopeCommit> {

        private final PullRequestRescopeCommitAction action;

        private ToRescopeCommit(PullRequestRescopeCommitAction action) {
            this.action = action;
        }

        @Override
        public InternalPullRequestRescopeCommit apply(Commit commit) {
            return new InternalPullRequestRescopeCommit.Builder()
                    .action(action)
                    .commitId(commit.getId())
                    .build();
        }
    }
}
