package com.atlassian.bitbucket.mesh.repository;

import com.atlassian.bitbucket.mesh.event.EventPublisher;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.exception.CommandFailedException;
import com.atlassian.bitbucket.mesh.git.exception.FailedPreconditionException;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.io.UnderLock;
import com.atlassian.bitbucket.mesh.rpc.v1.RpcDeleteRepositoryRequest;
import com.atlassian.bitbucket.mesh.rpc.v1.RpcTransaction;
import com.atlassian.bitbucket.mesh.transaction.PreparedAction;
import com.atlassian.bitbucket.mesh.transaction.Transaction;
import com.atlassian.bitbucket.mesh.transaction.TransactionCommitFailedException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.channels.OverlappingFileLockException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;

import static com.atlassian.bitbucket.mesh.git.GitConstants.CONFIG_GC_PRUNE_EXPIRE;
import static com.atlassian.bitbucket.mesh.gprc.GrpcSupport.maybeReduceToDeadline;
import static com.atlassian.bitbucket.mesh.rpc.v1.RpcTransactionCommitFailed.Reason.PUBLISH_FAILED;
import static java.util.Objects.requireNonNull;

class DeleteRepositoryOperation extends ConcreteRepositoryVisitor<Void> {

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

    private final GitProcessBuilderFactory builderFactory;
    private final EventPublisher eventPublisher;
    private final ScheduledExecutorService executorService;
    private final DeleteRepositoryParameters parameters;
    private final Transaction transaction;

    DeleteRepositoryOperation(GitProcessBuilderFactory builderFactory, EventPublisher eventPublisher,
                              ScheduledExecutorService executorService, DeleteRepositoryParameters parameters,
                              Transaction transaction) {
        this.builderFactory = builderFactory;
        this.eventPublisher = eventPublisher;
        this.executorService = executorService;
        this.parameters = parameters;
        this.transaction = transaction;
    }

    @Override
    public Void defaultValue(Repository repository) {
        throw new FailedPreconditionException("Repository deletion is not supported for " +
                repository.getClass().getSimpleName());
    }

    @Override
    Void visit(@Nonnull DefaultBitbucketServerRepository repository) {
        Path repositoryDir = repository.getPath();

        //Create a DELETED marker to ensure the system detects that the repository has been deleted. If the repository
        //has forks, because it cannot be known which of its objects they're borrowing, the repository's directory
        //cannot be removed. It can only be "marked" for deletion, so that when the hierarchy itself is fully deleted
        //the directory can be removed with all others in the hierarchy. Even if the repository does not have forks,
        //it might take some time to (asynchronously if it fails initially) delete the repository on disk.
        String hierarchyId = repository.getHierarchyId();
        try {
            MoreFiles.write(repository.getDeletedMarker(), hierarchyId);
        } catch (IOException e) {
            log.warn("Repository {} could not be marked as deleted. It may not be automatically cleaned up " +
                    "with its hierarchy", parameters.getRepository(), e);
        }

        if (parameters.getForks().isEmpty()) {
            List<Path> deleteDirs = new ArrayList<>();
            deleteDirs.add(repositoryDir);

            if (parameters.isOnlyMember()) {
                //If this repository is the last one in the hierarchy, search for repositories in the hierarchy that
                //were only marked as deleted, previously, and clean them up too. All repositories in the same hierarchy
                //are guaranteed to be in sibling directories, so searching the repository's parent directory is
                //sufficient.
                try (Stream<Path> stream = Files.list(repositoryDir.getParent())) {
                    //Ignore files, non-DELETED repositories and DELETED repositories which are from other
                    //hierarchies. They will not be included in this cleanup
                    stream.filter(Files::isDirectory)
                            .filter(dir -> {
                                try {
                                    Path marker = DefaultBitbucketServerRepository.getDeletedMarker(dir);
                                    if (hierarchyId.equals(MoreFiles.toString(marker))) {
                                        return true;
                                    }
                                } catch (FileNotFoundException | NoSuchFileException ignored) {
                                    //The repository hasn't been deleted
                                } catch (IOException e) {
                                    log.warn("Cannot verify hierarchy for deleted repository in [{}]; " +
                                            "it will not be removed", dir.getFileName(), e);
                                }

                                return false;
                            })
                            .forEach(deleteDirs::add);

                    deleteDirs.add(repository.getLfsDir());
                } catch (IOException e) {
                    log.warn("Could not scan for other deleted repositories in the [{}] hierarchy; some deleted " +
                            "repositories may not be removed", hierarchyId, e);
                }
            } else if (parameters.isOnlyForkOfOrigin()) {
                //This repository was the only (remaining) fork of the origin repository, which means that it is safe
                //to re-enable pruning for the origin repository
                Repository origin = requireNonNull(parameters.getOrigin(), "origin");
                try {
                    builderFactory.builder(origin).config().unset(CONFIG_GC_PRUNE_EXPIRE).build().run();
                    log.info("[{}] Re-enabled pruning of unreachable objects because the repository no longer has forks",
                            origin);
                } catch (CommandFailedException e) {
                    log.warn("[{}] Re-enabling pruning of unreachable objects failed", origin, e);
                }
            }

            new DeleteRunnable(executorService, deleteDirs, null).run();
            eventPublisher.publish(new RepositoryDeletedEvent(repository));
        }

        return null;
    }

    @Override
    Void visit(@Nonnull DefaultManagedRepository repository) {
        if (!repository.exists()) {
            //Already deleted, nothing left to do
            return null;
        }

        PreparedDelete delete = new PreparedDelete(repository);
        delete.prepare();

        //Enroll the delete in the transaction
        if (!transaction.prepared(delete).isSuccess()) {
            throw new DeleteRepositoryFailedException(repository.getId(), "Coordinated repository deletion failed");
        }

        return null;
    }

    private class PreparedDelete implements PreparedAction {

        private final DefaultManagedRepository repository;

        private volatile UnderLock.LockedFile lockedFile;
        private volatile DeleteRunnable task;

        PreparedDelete(DefaultManagedRepository repository) {
            this.repository = repository;
        }

        @Override
        public void commit() {
            try {
                //Write a DELETED marker to ensure the ManagedDataStore immediately treats the repository as deleted,
                //in case it takes a bit longer to clean up the repository on disk.
                try {
                    MoreFiles.write(repository.getDeletedMarker(), repository.getHierarchyId());
                } catch (IOException e) {
                    log.debug("[{}] Failed to mark the repository as deleted;it may be visible for a bit longer",
                            repository);
                }

                //Perform the actual deletion
                task.run();
                eventPublisher.publish(new RepositoryDeletedEvent(repository));
            } catch (RuntimeException e) {
                throw new TransactionCommitFailedException(null, PUBLISH_FAILED, e);
            } finally {
                releaseLock();
            }
        }

        @Nonnull
        @Override
        public Repository getRepository() {
            return repository;
        }

        @Override
        public void rollBack() {
            releaseLock();
        }

        @Nonnull
        @Override
        public RpcTransaction.Builder populateTransaction(@Nonnull RpcTransaction.Builder builder) {
            return builder.setDeleteRepository(RpcDeleteRepositoryRequest.newBuilder()
                    .setRepository(repository.getId()));
        }

        void prepare() {
            boolean prepared = false;
            try {
                Path repositoryDir = repository.getPath();
                Path sharedObjectsPath = repository.getSharedObjects().getPath();
                List<Path> dirsToDelete = new ArrayList<>();
                dirsToDelete.add(repositoryDir);
                try (Stream<Path> paths = Files.list(repositoryDir.getParent())) {
                    // Check whether there are any other repository directories (ignoring potential deleted markers)
                    boolean last = paths.noneMatch(path ->
                            !path.equals(repositoryDir) && !path.equals(sharedObjectsPath) && Files.isDirectory(path));
                    if (last) {
                        //The repository is the last in the hierarchy, so try to delete shared-objects and the parent
                        //directory as well. But first take out a lock on the hierarchy to ensure that
                        //no new repositories can be created in this hierarchy while the delete is pending; repository
                        //creation also acquires a lock on the hierarchy.
                        Duration lockTimeout = maybeReduceToDeadline(parameters.getLockTimeout());
                        lockedFile = UnderLock.forFile(repository.getHierarchyLockFile(), lockTimeout)
                                .lock();
                        dirsToDelete.add(sharedObjectsPath);
                        dirsToDelete.add(repositoryDir.getParent());
                        dirsToDelete.add(repository.getLfsDir());
                    }
                } catch (OverlappingFileLockException e) {
                    //Could not acquire lock on shared objects;
                    log.info("[{}] Could not lock the shared-objects for deletion. A fork is likely being created",
                            repository);
                } catch (IOException e) {
                    log.warn("Could not determine whether {} is the last repository in the hierarchy. " +
                            "Not deleting the shared-objects directory", repository);
                }
                task = new DeleteRunnable(executorService, dirsToDelete, repository.getDeletedMarker());
                prepared = true;
            } finally {
                if (!prepared) {
                    releaseLock();
                }
            }
        }

        void releaseLock() {
            if (lockedFile != null) {
                try {
                    lockedFile.close();
                } catch (IOException e) {
                    log.warn("[{}] Failed to release lock while deleting the repository", repository, e);
                }
            }
        }
    }

    private static class DeleteRunnable implements Runnable {

        private final Path deletedMarker;
        private final List<Path> repositoryDirs;
        private final ScheduledExecutorService scheduledExecutorService;
        private final int total;

        private int attempts;
        private int deleted;

        protected DeleteRunnable(ScheduledExecutorService scheduledExecutorService, List<Path> repositoryDirs,
                                 Path deletedMarker) {
            this.deletedMarker = deletedMarker;
            this.repositoryDirs = repositoryDirs;
            this.scheduledExecutorService = scheduledExecutorService;

            total = repositoryDirs.size();
        }

        @Override
        public void run() {
            ++attempts;

            for (Iterator<Path> iterator = repositoryDirs.iterator(); iterator.hasNext();) {
                Path repositoryDir = iterator.next();

                try {
                    MoreFiles.deleteRecursively(repositoryDir);
                    iterator.remove(); //Remove the directory from the list; it's been deleted

                    log.debug("Successfully deleted repository directory {}", repositoryDir);
                    ++deleted;
                } catch (IOException e) {
                    //The directory could not be deleted; leave it in the list to potentially retry deletion
                    log.trace("Failed to delete {}", repositoryDir, e);
                }
            }

            if (repositoryDirs.isEmpty() && deletedMarker != null) {
                try {
                    Files.deleteIfExists(deletedMarker);
                } catch (IOException e) {
                    //Could not delete the deletedMarker; Add it to repositoryDirs to be deleted to trigger a retry
                    repositoryDirs.add(deletedMarker);
                }
            }
            if (!repositoryDirs.isEmpty()) {
                if (attempts < 5) {
                    if (log.isDebugEnabled()) {
                        log.debug("Pass {}/5: Deleted {} of {}. Will retry deletion for remaining",
                                attempts, deleted, total);
                    }

                    scheduledExecutorService.schedule(this, 1L, TimeUnit.MINUTES);
                } else {
                    StringBuilder builder = new StringBuilder("The following repository director")
                            .append(repositoryDirs.size() == 1 ? "y" : "ies")
                            .append(" could not be deleted after 5 attempts:");
                    for (Path repositoryDir : repositoryDirs) {
                        builder.append("\n\t").append(repositoryDir);
                    }
                    builder.append("\nNo further attempts will be made.");

                    log.warn(builder.toString());
                }
            }
        }
    }
}
