package com.atlassian.stash.internal.integrity;

import com.atlassian.bitbucket.i18n.KeyedMessage;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.scm.CommandFailedException;
import com.atlassian.bitbucket.scm.ForkCommandParameters;
import com.atlassian.bitbucket.server.ApplicationPropertiesService;
import com.atlassian.bitbucket.util.PageImpl;
import com.atlassian.bitbucket.util.PageRequest;
import com.atlassian.stash.internal.project.InternalProject;
import com.atlassian.stash.internal.repository.InternalRepository;
import com.atlassian.stash.internal.repository.RepositoryDao;
import com.atlassian.stash.internal.scm.InternalScmService;
import org.hibernate.HibernateException;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
import org.mockito.*;
import org.mockito.runners.MockitoJUnitRunner;
import org.springframework.transaction.PlatformTransactionManager;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static java.util.stream.Collectors.toList;
import static org.hamcrest.Matchers.lessThan;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;

@RunWith(MockitoJUnitRunner.class)
public class RepositoryIntegrityHelperTest {

    private static final List<Integer> EMPTY = Collections.emptyList();

    @Rule
    public TemporaryFolder temporaryFolder = new TemporaryFolder();
    @Captor
    private ArgumentCaptor<Repository> createdForks;
    @Captor
    private ArgumentCaptor<Repository> createdRepositories;
    @Mock
    private IntegrityCheckReporter integrityCheckReporter;
    @Mock
    private ApplicationPropertiesService propertiesService;
    @Mock
    private RepositoryDao repositoryDao;
    @InjectMocks
    private RepositoryIntegrityHelper repositoryIntegrityHelper;
    @Mock
    private InternalScmService scmService;
    @Mock
    private PlatformTransactionManager platformTransactionManager;

    @Test
    public void testCheckRepositories() throws Exception {
        // mock the home dir and db
        List<Repository> inDb = Stream.of(1, 2, 3, 4, 5).map(this::mockRepo).collect(Collectors.toList());
        List<Integer> onDisk = Arrays.asList(2, 4, 6);
        List<Integer> toBeRestored = Arrays.asList(1, 3, 5);

        setupMocks(onDisk, inDb, EMPTY, EMPTY);

        // Verify the setup is OK
        onDisk.forEach(id -> assertTrue(existOnDisk(id)));
        toBeRestored.forEach(id -> assertFalse(existOnDisk(id)));

        // Execute the restore task
        repositoryIntegrityHelper.checkRepositories();

        // Only details for repository 1,3,5 should be retrieved
        toBeRestored.forEach(repoId -> verify(repositoryDao).getById(repoId));

        // Verify that create was called for all the repositories to be restored
        List<Integer> created =
                Stream.concat(createdRepositories.getAllValues().stream(), createdForks.getAllValues().stream())
                        .map(Repository::getId)
                        .collect(toList());
        assertTrue(toBeRestored.containsAll(created));

        // Check that the repositories are on disk
        toBeRestored.forEach(id -> assertTrue(existOnDisk(id)));
    }

    @Test
    public void testCheckRepositoriesReportInconsistencies() throws Exception {
        // mock the home dir and db
        List<Repository> inDb = Stream.of(1, 2, 3, 4, 5).map(this::mockRepo).collect(Collectors.toList());
        List<Integer> onDisk = Arrays.asList(2, 4, 6);

        setupMocks(onDisk, inDb, EMPTY, EMPTY);

        // Execute the restore task
        repositoryIntegrityHelper.checkRepositories();

        verify(integrityCheckReporter).reportInconsistency(
                eq("The repository {} exists but the directory {} is missing. To restore integrity, an empty repository" +
                        " directory was created."), eq("testproject/repo1"), isA(File.class));
    }

    @Test
    public void testCheckRepositoriesWithDeletedMarker() throws Exception {
        // mock the home dir and db
        List<Repository> inDb = Stream.of(77, 103, 45, 4, 64).map(this::mockRepo).collect(Collectors.toList());
        List<Integer> onDisk = Arrays.asList(45, 92, 77, 1466);
        List<Integer> toBeRestored = Arrays.asList(4, 64, 103);

        setupMocks(onDisk, inDb, EMPTY, EMPTY);
        temporaryFolder.newFolder("extra");

        // Verify the setup is OK
        onDisk.forEach(id -> assertTrue(existOnDisk(id)));
        toBeRestored.forEach(id -> assertFalse(existOnDisk(id)));

        // Execute the restore task
        repositoryIntegrityHelper.checkRepositories();

        toBeRestored.forEach(repoId -> verify(repositoryDao).getById(repoId));

        // Verify that create was called for all the repositories to be restored
        List<Integer> created =
                Stream.concat(createdRepositories.getAllValues().stream(), createdForks.getAllValues().stream())
                        .map(Repository::getId)
                        .collect(toList());
        assertTrue(toBeRestored.containsAll(created));

        // Check that the repositories are on disk
        toBeRestored.forEach(id -> assertTrue(existOnDisk(id)));
    }

    @Test
    public void testCreateWithDatabaseErrorInGetById() {
        // mock the home dir and db
        List<Repository> inDb = Stream.of(1, 2, 3, 4, 5).map(this::mockRepo).collect(Collectors.toList());
        List<Integer> onDisk = Arrays.asList(2, 4, 6);
        List<Integer> toBeRestored = Arrays.asList(1, 3, 5);
        setupMocks(onDisk, inDb, EMPTY, Collections.singletonList(3));

        // Verify the setup is OK
        onDisk.forEach(id -> assertTrue(existOnDisk(id)));
        toBeRestored.forEach(id -> assertFalse(existOnDisk(id)));

        // Execute the restore task
        repositoryIntegrityHelper.checkRepositories();

        toBeRestored.forEach(repoId -> verify(repositoryDao).getById(repoId));

        // Verify that create was called for all the repositories to be restored
        List<Integer> created =
                Stream.concat(createdRepositories.getAllValues().stream(), createdForks.getAllValues().stream())
                        .map(Repository::getId)
                        .collect(toList());
        assertTrue(toBeRestored.containsAll(created));

        // Check that the repositories are on disk
        assertTrue(existOnDisk(1));
        assertFalse(existOnDisk(3)); // 3 could not be created because of a db error
        assertTrue(existOnDisk(5));
    }

    @Test
    public void testCreateWithDatabaseErrorInGetIds() {
        // mock the home dir and db
        setupMocks(Arrays.asList(2, 4), Collections.singletonList(mockRepo(54)), EMPTY, EMPTY);

        // Throw an exception in repositoryDao.getIds()
        reset(repositoryDao);
        doThrow(new HibernateException("Mock hibernate exception")).when(repositoryDao).getIds(any(PageRequest.class));

        // Execute the restore task
        repositoryIntegrityHelper.checkRepositories();

        verifyZeroInteractions(propertiesService);
        verifyZeroInteractions(scmService);
    }

    @Test
    public void testCreateWithIOException() {
        // mock the home dir and db
        List<Repository> inDb = Stream.of(1, 2, 3, 4, 5).map(this::mockRepo).collect(Collectors.toList());
        List<Integer> onDisk = Arrays.asList(2, 4, 6);
        List<Integer> toBeRestored = Arrays.asList(1, 3, 5);
        setupMocks(onDisk, inDb, Collections.singletonList(5), EMPTY);

        // Verify the setup is OK
        onDisk.forEach(id -> assertTrue(existOnDisk(id)));
        toBeRestored.forEach(id -> assertFalse(existOnDisk(id)));

        // Execute the restore task
        repositoryIntegrityHelper.checkRepositories();

        toBeRestored.forEach(repoId -> verify(repositoryDao).getById(repoId));

        // Verify that create was called for all the repositories to be restored
        List<Integer> created =
                Stream.concat(createdRepositories.getAllValues().stream(), createdForks.getAllValues().stream())
                        .map(Repository::getId)
                        .collect(toList());
        assertTrue(toBeRestored.containsAll(created));

        // Check that the repositories are on disk
        assertTrue(existOnDisk(1));
        assertTrue(existOnDisk(3));
        assertFalse(existOnDisk(5)); // 5 should not exist because it actually failed
    }

    @Test
    public void testCreateWithIOExceptionInOrigin() {
        InternalRepository repo1 = mockRepo(311);
        InternalRepository fork1 = mockFork(312, repo1);
        InternalRepository fork2 = mockFork(313, fork1);
        InternalRepository fork3 = mockFork(314, fork2);

        List<Integer> toBeRestored = Arrays.asList(311, 312, 313, 314);
        List<Repository> repositories = Arrays.asList(repo1, fork1, fork2, fork3);
        setupMocks(EMPTY, repositories, Arrays.asList(313, 314), EMPTY);

        // Verify the setup is OK
        toBeRestored.forEach(id -> assertFalse(existOnDisk(id)));

        // Execute the restored task
        repositoryIntegrityHelper.checkRepositories();

        toBeRestored.forEach(repoId -> verify(repositoryDao).getById(repoId));

        // Verify that create was called for all the repositories to be restored
        List<Integer> created =
                Stream.concat(createdRepositories.getAllValues().stream(), createdForks.getAllValues().stream())
                        .map(Repository::getId)
                        .collect(toList());
        assertTrue(toBeRestored.containsAll(created));

        // Check that the repositories are on disk
        assertTrue(existOnDisk(311));
        assertTrue(existOnDisk(312));
        assertFalse(existOnDisk(313)); // 313 should not exist because it actually failed
        assertFalse(existOnDisk(314)); // 314 should not exist because it actually failed
    }

    @Test
    @SuppressWarnings("unchecked")
    public void testForksAreCreatedInCorrectOrder() {
        // mock the home dir and db
        InternalRepository repo1 = mockRepo(221);
        InternalRepository repo2 = mockRepo(222);
        InternalRepository fork1 = mockFork(223, repo1);
        InternalRepository fork2 = mockFork(224, repo2);
        InternalRepository fork3 = mockFork(225, fork2);
        InternalRepository fork4 = mockFork(226, fork3);
        InternalRepository fork5 = mockFork(227, fork4);

        List<Integer> toBeRestored = Arrays.asList(221, 222, 223, 224, 225, 226, 227);
        List<Repository> inDb = Arrays.asList(fork4, fork5, repo1, repo2, fork1, fork2, fork3);
        setupMocks(EMPTY, inDb, EMPTY, EMPTY);

        // Verify the setup is OK
        toBeRestored.forEach(id -> assertFalse(existOnDisk(221)));

        repositoryIntegrityHelper.checkRepositories();

        toBeRestored.forEach(repoId -> verify(repositoryDao).getById(repoId));

        // Verify that create was called for all the repositories to be restored
        List<Integer> created =
                Stream.concat(createdRepositories.getAllValues().stream(), createdForks.getAllValues().stream())
                        .map(Repository::getId)
                        .collect(toList());
        assertTrue(toBeRestored.containsAll(created));

        // Verify the forks were created in the correct order
        assertThat(created.indexOf(repo1.getId()), lessThan(created.indexOf(fork1.getId())));
        assertThat(created.indexOf(repo2.getId()), lessThan(created.indexOf(fork2.getId())));
        assertThat(created.indexOf(repo2.getId()), lessThan(created.indexOf(fork3.getId())));
        assertThat(created.indexOf(fork2.getId()), lessThan(created.indexOf(fork3.getId())));
        assertThat(created.indexOf(fork3.getId()), lessThan(created.indexOf(fork4.getId())));
        assertThat(created.indexOf(fork4.getId()), lessThan(created.indexOf(fork5.getId())));

        // Check that the repositories are on disk
        toBeRestored.forEach(id -> assertTrue(existOnDisk(id)));
    }

    @Test
    @SuppressWarnings("unchecked")
    public void testOnlyForksNeedCreating() {
        InternalRepository repo1 = mockRepo(111);
        InternalRepository fork1 = mockFork(112, repo1);
        InternalRepository fork2 = mockFork(113, repo1);
        InternalRepository fork3 = mockFork(114, repo1);

        List<Integer> toBeRestored = Arrays.asList(112, 113, 114);
        List<Repository> repositories = Arrays.asList(repo1, fork1, fork2, fork3);
        setupMocks(Collections.singletonList(111), repositories, EMPTY, EMPTY);

        // Verify the setup is OK
        assertTrue(existOnDisk(111));
        toBeRestored.forEach(id -> assertFalse(existOnDisk(id)));

        repositoryIntegrityHelper.checkRepositories();

        toBeRestored.forEach(repoId -> verify(repositoryDao).getById(repoId));

        // Verify that create was called for all the repositories to be restored
        List<Integer> created = createdForks.getAllValues().stream()
                .map(Repository::getId)
                .collect(toList());
        assertTrue(toBeRestored.containsAll(created));

        // Check that the repositories are on disk
        toBeRestored.forEach(id -> assertTrue(existOnDisk(id)));
    }

    private boolean existOnDisk(int repoId) {
        return Files.exists(temporaryFolder.getRoot().toPath().resolve(String.valueOf(repoId)));
    }

    private void mkDir(String name) {
        try {
            temporaryFolder.newFolder(name);
        } catch (IOException e) {
            fail("Caught an IOException during test execution: " + e);
        }
    }

    private InternalRepository mockBaseRepo(int id) {
        InternalProject project = mock(InternalProject.class);
        when(project.getKey()).thenReturn("testproject");

        InternalRepository repository = mock(InternalRepository.class);
        when(repository.getId()).thenReturn(id);
        when(repository.getProject()).thenReturn(project);
        when(repository.getSlug()).thenReturn("repo" + id);

        return repository;
    }

    private InternalRepository mockFork(int id, InternalRepository origin) {
        InternalRepository repository = mockBaseRepo(id);
        when(repository.isFork()).thenReturn(true);
        when(repository.getOrigin()).thenReturn(origin);
        return repository;
    }

    private InternalRepository mockRepo(int id) {
        InternalRepository repository = mockBaseRepo(id);
        when(repository.isFork()).thenReturn(false);
        when(repository.toString()).thenReturn("Repository " + id);
        return repository;
    }

    private void setupMocks(List<Integer> existingOnDisk, List<Repository> existingInDb,
                            List<Integer> diskErrorIds, List<Integer> dbErrorIds) {
        // Return the temporary folder as base for the repositories
        when(propertiesService.getRepositoriesDir()).thenReturn(temporaryFolder.getRoot());

        when(propertiesService.getRepositoryDir(any(Repository.class))).thenAnswer(answer -> {
            Repository repo = answer.getArgumentAt(0, Repository.class);
            return temporaryFolder.getRoot().toPath().resolve(String.valueOf(repo.getId())).toFile();
        });

        // Build the result for the response of repositoryDao.getByIds
        when(repositoryDao.getById(anyInt())).thenAnswer(answer -> {
            Integer requested = answer.getArgumentAt(0, Integer.class);
            if (dbErrorIds.contains(requested)) {
                throw new HibernateException("Mock HibernateException for repository with id: " + requested);
            }
            return existingInDb.stream()
                    .filter(r -> requested == r.getId())
                    .findFirst().orElse(null);
        });

        // Build the result for the response for the repositoryDao.getIds
        List<Integer> repositoryIds = new ArrayList<>();
        existingInDb.forEach(d -> repositoryIds.add(d.getId()));
        when(repositoryDao.getIds(isA(PageRequest.class))).thenAnswer(answer -> {
            PageRequest request = answer.getArgumentAt(0, PageRequest.class);
            return new PageImpl<>(request, repositoryIds, true);
        });

        // Capture the repositories that are created
        doAnswer(answer -> {
            Repository repo = answer.getArgumentAt(0, Repository.class);
            if (diskErrorIds.contains(repo.getId())) {
                String msg = "Mock ProcessException for repository with id: " + repo.getId();
                throw new CommandFailedException(new KeyedMessage("command.repo.create.failed.mock",
                        msg, msg));
            } else {
                mkDir(String.valueOf(repo.getId()));
            }
            return null;
        }).when(scmService).create(createdRepositories.capture());

        // Capture the fork that are created
        doAnswer(answer -> {
            Repository repo = answer.getArgumentAt(0, Repository.class);
            if (diskErrorIds.contains(repo.getId())) {
                String msg = "Mock ProcessException for fork with id: " + repo.getId();
                throw new CommandFailedException(new KeyedMessage("command.fork.create.failed.mock", msg, msg));
            } else {
                mkDir(String.valueOf(repo.getId()));
            }
            return null;
        }).when(scmService).fork(createdForks.capture(), any(ForkCommandParameters.class));

        existingOnDisk.stream().map(String::valueOf).forEach(this::mkDir);
    }
}
