package com.atlassian.bitbucket.internal.mirroring.upstream;

import com.atlassian.applinks.spi.link.MutatingApplicationLinkService;
import com.atlassian.bitbucket.event.repository.RepositoryRefsChangedEvent;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.internal.mirroring.MirroringRequest;
import com.atlassian.bitbucket.internal.mirroring.repositories.RepositoryContentHashService;
import com.atlassian.bitbucket.internal.mirroring.upstream.dao.AoMirrorServer;
import com.atlassian.bitbucket.internal.mirroring.upstream.dao.MirrorServerDao;
import com.atlassian.bitbucket.mirroring.upstream.*;
import com.atlassian.bitbucket.permission.Permission;
import com.atlassian.bitbucket.permission.PermissionValidationService;
import com.atlassian.bitbucket.user.AbstractApplicationUserVisitor;
import com.atlassian.bitbucket.user.ApplicationUser;
import com.atlassian.bitbucket.user.ServiceUser;
import com.atlassian.bitbucket.util.MoreStreams;
import com.atlassian.bitbucket.util.Page;
import com.atlassian.bitbucket.util.PageRequest;
import com.atlassian.bitbucket.util.PageUtils;
import com.atlassian.event.api.EventListener;
import com.atlassian.event.api.EventPublisher;
import com.atlassian.plugin.connect.plugin.lifecycle.event.ConnectAddonDisabledEvent;
import com.atlassian.plugin.connect.plugin.lifecycle.event.ConnectAddonEnabledEvent;
import com.atlassian.plugin.connect.plugin.lifecycle.event.ConnectAddonUninstalledEvent;
import com.atlassian.sal.api.lifecycle.LifecycleAware;
import com.atlassian.sal.api.transaction.TransactionTemplate;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Maps;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.util.Map;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

import static com.atlassian.bitbucket.util.PageUtils.asPageOf;
import static java.util.Objects.requireNonNull;

public class DefaultMirrorService implements InternalMirrorService, LifecycleAware {

    @VisibleForTesting
    static final String PROP_ADD_ON_KEY = "plugin-key";

    private static final int MAX_PAGE_REQUESTS = 100;

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

    private final AddonUserHelper addonUserHelper;
    private final MutatingApplicationLinkService applicationLinkService;
    private final RepositoryContentHashService contentHashService;
    private final EventPublisher eventPublisher;
    private final ScheduledExecutorService executorService;
    private final I18nService i18nService;
    private final MirrorAddonManager mirrorAddonManager;
    private final MirrorServerDao mirrorDao;
    private final SmartMirroringFeature mirroringFeature;
    private final PermissionValidationService permissionValidationService;
    private final Map<String, Future<?>> scheduledTasks;
    private final TransactionTemplate transactionTemplate;

    public DefaultMirrorService(AddonUserHelper addonUserHelper,
                                MutatingApplicationLinkService applicationLinkService,
                                RepositoryContentHashService contentHashService, EventPublisher eventPublisher,
                                ScheduledExecutorService executorService, I18nService i18nService,
                                MirrorAddonManager mirrorAddonManager, MirrorServerDao mirrorDao,
                                SmartMirroringFeature mirroringFeature,
                                PermissionValidationService permissionValidationService,
                                TransactionTemplate transactionTemplate) {
        this.addonUserHelper = addonUserHelper;
        this.applicationLinkService = applicationLinkService;
        this.contentHashService = contentHashService;
        this.eventPublisher = eventPublisher;
        this.executorService = executorService;
        this.i18nService = i18nService;
        this.mirrorAddonManager = mirrorAddonManager;
        this.mirrorDao = mirrorDao;
        this.mirroringFeature = mirroringFeature;
        this.permissionValidationService = permissionValidationService;
        this.transactionTemplate = transactionTemplate;

        scheduledTasks = Maps.newConcurrentMap();
    }

    @Nonnull
    @Override
    public Page<MirrorServer> findAll(@Nonnull PageRequest pageRequest) {
        /**
         * No permission validation here: it is perfectly acceptable for anonymous users to discover mirrors via the
         * {@link com.atlassian.bitbucket.internal.mirror.rest.MirrorServerResource#listMirrors} resource.
         */
        requireNonNull(pageRequest, "pageRequest");
        mirroringFeature.checkEnabled();

        return transactionTemplate.execute(() ->
                asPageOf(MirrorServer.class, mirrorDao.findAll(pageRequest.buildRestrictedPageRequest(MAX_PAGE_REQUESTS))));
    }

    @Override
    public InternalMirrorServer getById(@Nonnull String mirrorId) {
        requireNonNull(mirrorId, "mirrorId");
        mirroringFeature.checkEnabled();

        return transactionTemplate.execute(() -> mirrorDao.getById(mirrorId));
    }

    @Override
    public MirrorServer getForUser(ApplicationUser user) {
        ServiceUser serviceUser = user == null ? null : user.accept(new AbstractApplicationUserVisitor<ServiceUser>() {
            @Override
            public ServiceUser visit(@Nonnull ServiceUser user) {
                return user;
            }
        });

        if (serviceUser == null || !serviceUser.isActive()) {
            return null;
        }

        String addonKey = DefaultAddonUserHelper.getAddonKeyFromUsername(serviceUser.getName());
        if (addonKey == null) {
            return null;
        }

        return transactionTemplate.execute(() -> mirrorDao.getByAddonKey(addonKey));
    }

    @Override
    public boolean hasAny() {
        return mirrorDao.hasMirror();
    }

    @Nonnull
    @Override
    public MirrorServer install(@Nonnull MirroringRequest request, @Nonnull String addonKey) {
        requireNonNull(request, "request");
        requireNonNull(addonKey, "addonKey");

        mirroringFeature.checkEnabled();
        permissionValidationService.validateForGlobal(Permission.SYS_ADMIN);

        return transactionTemplate.execute(() -> {
            AoMirrorServer existing = mirrorDao.getById(request.getMirrorId());
            if (existing != null) {
                throw new MirrorAlreadyRegisteredException(
                        i18nService.getKeyedText("bitbucket.mirroring.alreadyRegistered", request.getMirrorId()));
            }

            AoMirrorServer installed = mirrorDao.create(request.getMirrorId(), addonKey, request.getBaseUrl(),
                    request.getMirrorName(), request.getProductType(), request.getProductVersion(), true);

            eventPublisher.publish(new MirrorInstalledEvent(DefaultMirrorService.this, installed, request.getId()));
            // the ConnectAddonEnabledEvent event is triggered before this transaction is committed
            // and so its handler in onAddonEnabled will not find the mirror and so MirrorEnabledEvent will not be
            // raised. AC makes the transition from installed to enabled regardless of whether the /enabled
            // callback succeeds so we follow this behaviour too
            eventPublisher.publish(new MirrorEnabledEvent(DefaultMirrorService.this, installed));

            return installed;
        });
    }

    @EventListener
    public void onAddonDisabled(ConnectAddonDisabledEvent event) {
        maybeUpdateState(event.getAddonKey(), false);
    }

    @EventListener
    public void onAddonEnabled(ConnectAddonEnabledEvent event) {
        maybeUpdateState(event.getAddonKey(), true);
    }

    @EventListener
    public void onAddonUninstalled(ConnectAddonUninstalledEvent event) {
        // The connect add-on has been uninstalled. This can happen as a result of a real uninstall through mirroring
        // REST/UI or through UPM. It can also happen when an add-on is being upgraded, in which case we don't want to
        // do the clean up.
        String addonKey = event.getAddonKey();
        Future<?> task = executorService.schedule(() -> {
            try {
                cleanupIfUninstalled(addonKey);
            } catch (RuntimeException e) {
                log.warn("Could not check whether mirror {} needs to be cleaned up", addonKey, e);
            } finally {
                scheduledTasks.remove(addonKey);
            }
        }, 2, TimeUnit.MINUTES);

        scheduledTasks.put(addonKey, task);
    }

    @EventListener
    public void onRefsChanged(RepositoryRefsChangedEvent event) {
        if (mirroringFeature.isAvailable()) {
            contentHashService.generateAndSetHash(event.getRepository());
        }
    }

    @Override
    public void onStart() {
        // when a sysadmin manually uninstalls a mirror add-on through UPM and then shuts down before the clean up
        // has happened, the clean up will never happen. This ensures that any uninstalled mirrors get cleaned up on
        // the next startup.
        transactionTemplate.execute(() -> {
            log.debug("Validating that all configured mirror servers are still installed");
            PageRequest request = PageUtils.newRequest(0, 100);
            while (request != null) {
                Page<AoMirrorServer> mirrorPage = mirrorDao.findAll(request);
                for (AoMirrorServer mirrorServer : mirrorPage.getValues()) {
                    if (mirrorAddonManager.isInstalled(mirrorServer.getAddonKey())) {
                        log.debug("Mirror {} is still installed", mirrorServer.getName());
                    } else {
                        log.info("Mirror {} is no longer installed, cleaning up", mirrorServer.getName());
                        cleanup(mirrorServer);
                    }
                }
                request = mirrorPage.getNextPageRequest();
            }
            return null;
        });
    }

    @Override
    public void onStop() {
        log.debug("Canceling scheduled tasks ({} tasks)", scheduledTasks.size());
        for (Future<?> task : scheduledTasks.values()) {
            task.cancel(false);
        }
    }

    @Override
    public void remove(@Nonnull String mirrorId) {
        requireNonNull(mirrorId, "mirrorId");
        permissionValidationService.validateForGlobal(Permission.SYS_ADMIN);

        transactionTemplate.execute(() -> {
            AoMirrorServer mirror = mirrorDao.getById(mirrorId);
            if (mirror != null) {
                // uninstall the connect add-on for the mirror
                mirrorAddonManager.uninstall(mirror);

                // clean up all mirror related data
                cleanup(mirror);
            }
            return null;
        });
    }

    private void cleanup(AoMirrorServer mirror) {
        String addonKey = mirror.getAddonKey();

        // make a copy for the event that we'll send a bit later
        MirrorServer copy = new SimpleMirrorServer.Builder(mirror).enabled(false).build();

        // delete the mirror itself from the database
        mirrorDao.delete(mirror);

        // clean up the application link
        MoreStreams.streamIterable(applicationLinkService.getApplicationLinks())
                .filter(link -> addonKey.equals(link.getProperty(PROP_ADD_ON_KEY)))
                .findFirst()
                .ifPresent(applicationLinkService::deleteApplicationLink);

        // and finally clean up the service user
        addonUserHelper.cleanupAddonUser(mirror.getAddonKey());

        eventPublisher.publish(new MirrorUninstalledEvent(this, copy));
        log.info("Mirror {} has been uninstalled", mirror.getName());
    }

    private void cleanupIfUninstalled(String addonKey) {
        if (!mirrorAddonManager.isInstalled(addonKey)) {
            transactionTemplate.execute(() -> {
                AoMirrorServer mirrorServer = mirrorDao.getByAddonKey(addonKey);
                if (mirrorServer != null) {
                    cleanup(mirrorServer);
                }
                return null;
            });
        }
    }

    private void maybeUpdateState(String addonKey, boolean enabled) {
        transactionTemplate.execute(() -> {
            AoMirrorServer mirror = mirrorDao.getByAddonKey(addonKey);
            if (mirror == null || mirror.isEnabled() == enabled) {
                // nothing to do
                return null;
            }

            AoMirrorServer transitioned = mirrorDao.transitionState(mirror, enabled, null);
            if (enabled) {
                eventPublisher.publish(new MirrorEnabledEvent(DefaultMirrorService.this, transitioned));
            } else {
                eventPublisher.publish(new MirrorDisabledEvent(DefaultMirrorService.this, transitioned));
            }
            return null;
        });
    }
}
