package com.atlassian.bitbucket.mesh.test.context;

import com.atlassian.bitbucket.mesh.git.GitRepositories;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.layout.StorageLayout;
import com.atlassian.bitbucket.mesh.repository.*;
import com.atlassian.bitbucket.mesh.test.annotation.DirtiesRepo;
import com.atlassian.bitbucket.mesh.test.annotation.Repo;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import org.apache.commons.codec.binary.Hex;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ResourceLoaderAware;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.test.context.TestContext;

import javax.annotation.Nonnull;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.*;

/**
 * Applies the {@link Repo} and {@link DirtiesRepo} annotations during test execution.
 */
public class RepoTestExecutionListener
        extends AbstractAnnotationTestExecutionListener<Repo>
        implements ResourceLoaderAware {

    private static final String[] ALL_REPOSITORIES = (String[]) AnnotationUtils.getDefaultValue(DirtiesRepo.class);
    private static final String DEFAULT_LOCATION = (String) AnnotationUtils.getDefaultValue(Repo.class);
    private static final Map<Class<?>, Repo.Type> REPOSITORY_TYPES = ImmutableMap.of(
            BitbucketServerRepository.class, Repo.Type.BITBUCKET_SERVER,
            ManagedRepository.class, Repo.Type.MANAGED,
            PathRepository.class, Repo.Type.PATH);
    private static final Logger log = LoggerFactory.getLogger(RepoTestExecutionListener.class);

    protected final RepositoryCache repositoryCache;

    private final Path pathRepositoriesDir;
    private final SecureRandom random;
    private final RepositoryHashService repositoryHashService;
    private final RepositoryManager repositoryManager;
    private final Path tempDir;

    private int nextRepositoryId;
    private ResourceLoader resourceLoader;

    public RepoTestExecutionListener(RepositoryCache repositoryCache, RepositoryHashService repositoryHashService,
                                     RepositoryManager repositoryManager, StorageLayout storageLayout) {
        super(Repo.class);

        this.repositoryCache = repositoryCache;
        this.repositoryHashService = repositoryHashService;
        this.repositoryManager = repositoryManager;

        pathRepositoriesDir = MoreFiles.mkdir(storageLayout.getHomeDir(), "path-repos");
        random = new SecureRandom(Instant.now().toString().getBytes(StandardCharsets.UTF_8));
        tempDir = storageLayout.getTempDir();
    }

    @Override
    public void afterTestMethod(@Nonnull TestContext testContext) throws Exception {
        //If the test failed, clean all repositories the test may have used as a precaution
        DirtiesRepo annotation = findDirtiesRepo(testContext);
        if (annotation != null || testContext.getTestException() != null) {
            cleanRepositories(testContext, annotation);
        }
    }

    @Override
    public void setResourceLoader(@Nonnull ResourceLoader resourceLoader) {
        this.resourceLoader = resourceLoader;
    }

    @Override
    protected Object createValue(Field field, Repo annotation) {
        String archiveLocation = getRepoArchiveLocation(annotation);
        Repo.Type type = getType(field, annotation);
        String cacheKey = getCacheKey(archiveLocation, type);
        Repository repository = repositoryCache.getRepository(cacheKey);
        if (repository == null) {
            String id = generateRepositoryId(type);

            Resource archive = resourceLoader.getResource(archiveLocation);

            repository = repositoryManager.create(new CreateRepositoryParameters.Builder(id).build());
            repository.accept(new RepositoryVisitor<Void>() {

                @Override
                public Void visit(@Nonnull BitbucketServerRepository repository) {
                    GitRepositories.copyFromArchive(archive, repository.getPath(), tempDir);
                    return null;
                }

                @Override
                public Void visit(@Nonnull ManagedRepository repository) {
                    GitRepositories.copyFromArchive(archive, repository.getPath(), tempDir);
                    repositoryHashService.resetContentHash(repository);
                    repositoryHashService.resetMetadataHash(repository);
                    return null;
                }

                @Override
                public Void visit(@Nonnull PathRepository repository) {
                    GitRepositories.unpackArchive(archive, repository.getPath());
                    return null;
                }
            });

            onCreated(repository);
            //Cache the existence of this repository so that other tests can reuse it without the slowdown of
            //unpacking it all over again
            repositoryCache.setRepository(cacheKey, repository);
        }

        return repository;
    }

    /**
     * Called after a repository has been created to allow subclasses to perform additional initialization
     * (e.g. repair repository replicas)
     *
     * @param repository the repository that was created
     */
    protected void onCreated(Repository repository) {
    }

    @Override
    protected void validateAnnotation(Field field, Repo annotation) {
        Class<?> clazz = field.getType();
        if (!(REPOSITORY_TYPES.containsKey(clazz) || clazz.isAssignableFrom(Repository.class))) {
            throw new IllegalStateException("@Repo may only be applied to Repository fields");
        }
    }

    private static DirtiesRepo findDirtiesRepo(TestContext testContext) {
        DirtiesRepo annotation = testContext.getTestMethod().getAnnotation(DirtiesRepo.class);
        if (annotation == null) {
            annotation = testContext.getTestClass().getAnnotation(DirtiesRepo.class);
        }

        return annotation;
    }

    private static String getCacheKey(String location, Repo.Type type) {
        return location + "#" + type;
    }

    private static String getRepoArchiveLocation(Repo annotation) {
        String location = annotation.value();
        if (Objects.equals(DEFAULT_LOCATION, location)) {
            location = GitRepositories.DEFAULT;
        }

        return location;
    }

    private static Map<Field, Repository> mapRepositoriesByField(TestContext testContext) throws IllegalAccessException {
        Class<?> testClass = testContext.getTestClass();
        Field[] fields = FieldUtils.getAllFields(testClass);

        Map<Field, Repository> map = new HashMap<>(fields.length);
        for (Field field : fields) {
            if (field.isAnnotationPresent(Repo.class)) {
                field.setAccessible(true);

                map.put(field, (Repository) field.get(testContext.getTestInstance()));
            }
        }
        return map;
    }

    private void cleanRepositories(TestContext testContext, DirtiesRepo annotation) throws IllegalAccessException {
        boolean all = annotation == null || Arrays.equals(ALL_REPOSITORIES, annotation.value());
        Set<String> dirtiedRepositories = all ? Collections.emptySet() : ImmutableSet.copyOf(annotation.value());

        //This test method dirties one or more repositories. The annotation will tell us which ones, but we need
        //to look at the class's fields to find the matches
        mapRepositoriesByField(testContext).forEach((field, repository) -> {
            //Once we have the repositories used by the test mapped by field name, the next step is to determine
            //which are now dirty. By default, the annotation marks all repositories as dirty, but to reduce the
            //workload specific repositories may be marked instead. So we check for both. Either all, if the value
            //of the annotation matches the default, or selected if the field name matches a repository referenced
            //specifically in the annotation's value.
            if (all || dirtiedRepositories.contains(field.getName())) {
                Repo repoAnnotation = getAnnotation(field, Repo.class);
                if (repoAnnotation != null) {
                    Repo.Type type = getType(field, repoAnnotation);
                    repositoryCache.removeRepository(getCacheKey(getRepoArchiveLocation(repoAnnotation), type));
                    try {
                        repositoryManager.delete(new DeleteRepositoryParameters.Builder(repository)
                                .onlyMember(true)
                                .build());
                    } catch (RuntimeException e) {
                        boolean failedTest = testContext.getTestException() == null;
                        log.warn("[{}] Failed to delete dirty repository after {}test {}", repository,
                                failedTest ? "" : "failed ", testContext.getTestMethod(), e);
                    }
                }
            }
        });
    }

    private String generateRepositoryId(Repo.Type type) {
        switch (type) {
            case BITBUCKET_SERVER:
                return BitbucketServerRepository.formatId(randomHierarchyId(), nextLocalId());
            case MANAGED:
                String hierarchyId = randomHierarchyId();

                return ManagedRepository.formatId(Math.abs(hierarchyId.hashCode() % 64), hierarchyId, nextLocalId());
            case PATH:
                return PathRepository.formatId(pathRepositoriesDir.resolve(nextLocalId()));
            default:
                throw new IllegalArgumentException("Unsupported repository type: " + type);
        }
    }

    private Repo.Type getType(Field field, Repo annotation) {
        Repo.Type type = REPOSITORY_TYPES.get(field.getType());
        if (type == null) {
            type = annotation.type();
        }
        return type;
    }

    private String nextLocalId() {
        return Integer.toString(nextRepositoryId++);
    }

    private String randomHierarchyId() {
        byte[] bytes = new byte[10];
        random.nextBytes(bytes);

        return Hex.encodeHexString(bytes, true);
    }
}
