package com.atlassian.stash.internal.integrity;

import com.atlassian.bitbucket.ServiceException;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.scm.ForkCommandParameters;
import com.atlassian.bitbucket.server.ApplicationPropertiesService;
import com.atlassian.stash.internal.mode.DefaultApplicationMode;
import com.atlassian.stash.internal.repository.InternalRepository;
import com.atlassian.stash.internal.repository.RepositoryDao;
import com.atlassian.stash.internal.scm.InternalScmService;
import com.atlassian.stash.internal.spring.SpringTransactionUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static com.atlassian.bitbucket.util.PageUtils.toStream;

/**
 * Repository integrity restoration utilities
 */
@DefaultApplicationMode
@Component
public class RepositoryIntegrityHelper {

    private static final int DEFAULT_BATCH_SIZE = 1000;
    private final IntegrityCheckReporter integrityCheckReporter;
    private final ApplicationPropertiesService propertiesService;
    private final RepositoryDao repositoryDao;
    private final InternalScmService scmService;
    private final TransactionTemplate transactionTemplate;
    private int batchSize;

    @Autowired
    public RepositoryIntegrityHelper(IntegrityCheckReporter integrityCheckReporter,
                                     ApplicationPropertiesService propertiesService,
                                     RepositoryDao repositoryDao,
                                     InternalScmService scmService,
                                     PlatformTransactionManager transactionManager) {
        this.batchSize = DEFAULT_BATCH_SIZE;

        this.integrityCheckReporter = integrityCheckReporter;
        this.propertiesService = propertiesService;
        this.repositoryDao = repositoryDao;
        this.scmService = scmService;
        transactionTemplate = new TransactionTemplate(transactionManager, SpringTransactionUtils.REQUIRES_NEW);
        transactionTemplate.setReadOnly(true);
    }

    /**
     * Detect repository inconsistencies and create and initialize to restore integrity
     *
     * @return the repositories that were added because they were missing
     */
    @Nonnull
    Set<Repository> checkRepositories() {
        integrityCheckReporter.info("Integrity checking all repositories");
        // Get repository IDs from the database
        LinkedList<Integer> missingRepositories = new LinkedList<>();
        try {
            toStream(request -> transactionTemplate.execute(status -> repositoryDao.getIds(request)), batchSize)
                    .forEach(missingRepositories::add);
        } catch (Exception e) {
            integrityCheckReporter.error("Unable to obtain a list of repositories from the database", e);
            return Collections.emptySet();
        }

        // Exclude the IDs found on disk
        File repositoriesDir = propertiesService.getRepositoriesDir();
        try {
            Files.list(repositoriesDir.toPath())
                    .filter(Files::isDirectory)
                    .flatMap(this::pathToId)
                    .forEach(missingRepositories::remove);
        } catch (IOException e) {
            integrityCheckReporter.error("A Input/Output error occurred while restoring repositories", e);
            return Collections.emptySet();
        }

        // Restore any repositories missing on disk
        if (!missingRepositories.isEmpty()) {
            LinkedList<Repository> repositoriesToRestore = missingRepositories.stream()
                    .map(this::getRepositoryById)
                    .filter(Objects::nonNull)
                    .collect(Collectors.toCollection(LinkedList::new));

            Set<Repository> restoredRepositories = new HashSet<>(repositoriesToRestore.size());
            while (!repositoriesToRestore.isEmpty()) {
                Repository restored = restoreRepository(repositoriesToRestore.removeFirst(), repositoriesToRestore);
                if (restored != null) {
                    restoredRepositories.add(restored);
                }
            }
            return restoredRepositories;
        } else {
            return Collections.emptySet();
        }
    }

    @Value("${integrity.check.repository.batchSize}")
    public void setBatchSize(int batchSize) {
        this.batchSize = batchSize;
    }

    /**
     * Fetches a repository from the database using its id
     *
     * @param id the ID of the {@link Repository repository} to retrieve
     * @return the requested {@link Repository}, else {@code null} on failure
     */
    @Nullable
    private InternalRepository getRepositoryById(Integer id) {
        try {
            return transactionTemplate.execute(action -> repositoryDao.getById(id));
        } catch (Exception e) {
            integrityCheckReporter.error("Unable to retrieve repository with ID [{}], from database", id, e);
        }
        return null;
    }

    private Stream<Integer> pathToId(Path path) {
        try {
            return Stream.of(Integer.parseInt(path.getFileName().toString()));
        } catch (NumberFormatException e) {
            integrityCheckReporter.debug("Path does not correspond to a valid repository ID. Path: {}", path);
            return Stream.empty();
        }
    }

    /**
     * Restore the repositories and/or forks. This function may call itself to restore fork origins first.
     * In the latter case, {@link Repository repository} list will be modified by removing any repositories
     * that have been restored.
     *
     * @param repository   the repository to restore
     * @param repositories a {@link List} of {@link Repository repositories} that need to be restored
     * @return the repository that was restored, otherwise {@code null} to indicate failure
     */
    @Nullable
    private Repository restoreRepository(Repository repository, List<Repository> repositories) {
        try {
            if (repository.isFork()) {
                Repository origin = repository.getOrigin();
                if (repositories.remove(origin)) {
                    // If this repository's origin exists, we need to jump ahead and recursively and
                    // restore the origin *first*.
                    //
                    // By jumping ahead and recursively creating origins first, we are effectively
                    // doing a depth-first topological sort of the hierarchy on the fly.
                    restoreRepository(origin, repositories);
                }
                scmService.fork(repository, new ForkCommandParameters.Builder(repository).build());
            } else {
                scmService.create(repository);
            }
            integrityCheckReporter.reportInconsistency("The repository {} exists but the directory {} is missing. " +
                            "To restore integrity, an empty repository directory was created.",
                    repository.getProject().getKey() + "/" + repository.getSlug(),
                    propertiesService.getRepositoryDir(repository));
            return repository;
        } catch (ServiceException | UnsupportedOperationException e) {
            // Unable to restore the repo or fork, something went wrong with calling a SCM command
            integrityCheckReporter.error("The repository {} could not be restored. An error occurred while working in" +
                            " directory {}", repository.getProject().getKey() + "/" + repository.getSlug(),
                    propertiesService.getRepositoryDir(repository), e);
        }
        return null;
    }
}
