package com.atlassian.bitbucket.mesh.repository;

import com.atlassian.bitbucket.mesh.AbstractWiredTest;
import com.atlassian.bitbucket.mesh.git.GitAgent;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.config.ListConfigStdioHandler;
import com.atlassian.bitbucket.mesh.git.ref.Branch;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.io.UnderLock;
import com.atlassian.bitbucket.mesh.junit.LogListener;
import com.atlassian.bitbucket.mesh.process.handler.LoggingStdioHandler;
import com.atlassian.bitbucket.mesh.process.handler.StringStdioHandler;
import com.atlassian.bitbucket.mesh.test.ExpectedEvents;
import com.atlassian.bitbucket.mesh.test.annotation.DirtiesRepo;
import com.atlassian.bitbucket.mesh.test.annotation.Repo;
import com.google.common.collect.ImmutableMap;
import org.hamcrest.collection.IsIterableContainingInOrder;
import org.junit.Rule;
import org.junit.Test;

import javax.annotation.Resource;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import static com.atlassian.bitbucket.mesh.git.GitConstants.*;
import static java.util.Collections.singletonList;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;

public class DefaultRepositoryManagerTest extends AbstractWiredTest {

    @Rule
    public final LogListener logListener = new LogListener(DefaultRepositoryManager.class);

    @Rule
    @Resource
    public ExpectedEvents publishedEvents;

    @Resource
    private GitAgent agent;
    @Resource
    private GitProcessBuilderFactory builderFactory;
    @Resource
    private DataStoreManager dataStoreManager;
    @Resource
    private RepositoryManager repositoryManager;
    @Repo
    private ManagedRepository repository;

    @Test
    public void testCreateFailsIfCannotLockHierarchy() throws IOException {
        DefaultManagedRepository toCreate = new DefaultManagedRepository("0011", "00129721abcdef123456", "786236");

        //Attach storage to determine the repository directory
        assertEquals(StorageStatus.NONEXISTENT, dataStoreManager.attachStorage(toCreate));

        //Take out a lock on the hierarchy. This should prevent the repository from being created
        Path hierarchyLockFile = toCreate.getHierarchyLockFile();
        MoreFiles.mkdir(hierarchyLockFile.getParent());
        try (UnderLock.LockedFile ignored = UnderLock.forFile(hierarchyLockFile).lock()) {
            CreateRepositoryParameters parameters = new CreateRepositoryParameters.Builder(toCreate.getId())
                    .defaultBranch("refs/heads/develop")
                    .executionTimeout(1L)
                    .build();

            CreateRepositoryFailedException exception = assertThrows(CreateRepositoryFailedException.class,
                    () -> repositoryManager.create(parameters));
            assertThat(exception.getMessage(), containsString("Hierarchy is already locked"));
        } finally {
            //Delete the hierarchy
            MoreFiles.deleteQuietly(toCreate.getPath().getParent());
        }

        logListener.assertError("Failed to create repository");
    }

    @Test
    public void testCreateForManaged() throws IOException {
        publishedEvents.expect(RepositoryCreatedEvent.class);

        String repositoryId = ManagedRepository.formatId("0011", "00129999abcdef123456", "0011227766");
        Repository repository = repositoryManager.create(new CreateRepositoryParameters.Builder(repositoryId)
                .defaultBranch("refs/heads/main")
                .build());
        assertThat(repository, instanceOf(ManagedRepository.class));
        assertTrue(isBareRepository(repository.getPath()));

        SharedObjectsRepository sharedObjects = ((ManagedRepository) repository).getSharedObjects();
        assertTrue(isBareRepository(sharedObjects.getPath()));

        assertEquals(singletonList("../../shared-objects/objects"),
                Files.readAllLines(repository.getPath().resolve("objects/info/alternates")));

        //Verify that includes were applied by templates
        assertConfigured(repository);
        //Verify the explicit default branch we specified was applied
        assertEquals("refs/heads/main", agent.getHead(repository));
    }

    @Test
    public void testCreateForSharedHome() {
        publishedEvents.expect(RepositoryCreatedEvent.class);

        String repositoryId = BitbucketServerRepository.formatId("012345678900987654321", "998");
        Repository repository = repositoryManager.create(new CreateRepositoryParameters.Builder(repositoryId)
                .defaultBranch("refs/heads/main")
                .build());
        assertThat(repository, instanceOf(BitbucketServerRepository.class));
        assertTrue(isBareRepository(repository.getPath()));
        //Verify that includes were applied by templates
        assertConfigured(repository);
        //Verify the explicit default branch we specified was applied
        assertEquals("refs/heads/main", agent.getHead(repository));
    }

    @Test
    public void testDeleteForManaged() {
        publishedEvents.expect(RepositoryCreatedEvent.class);
        publishedEvents.expect(RepositoryDeletedEvent.class);

        //We need to create our own repository to delete; deleting @Repo repositories causes problems
        String repositoryId = ManagedRepository.formatId("0011", "01129999abcdef123456", "0011227765");
        Repository repository = repositoryManager.create(new CreateRepositoryParameters.Builder(repositoryId).build());

        repositoryManager.delete(new DeleteRepositoryParameters.Builder(repository)
                .onlyMember(true)
                .build());
        //The repository may actually still exist on disk here, but attempting to retrieve it should
        //always return null (either because the directory is removed, or is marked deleted)
        assertNull(repositoryManager.findById(repositoryId));
    }

    @Test
    public void testDeleteForManagedKeepsSharedObjectsIfHierarchyCannotBeLocked() throws IOException {
        String repositoryId = ManagedRepository.formatId("0011", "00723721abcdef123456", "12364");
        Repository created = repositoryManager.create(new CreateRepositoryParameters.Builder(repositoryId).build());

        //Take out a lock on the hierarchy. This should prevent the shared-objects repository from being deleted
        Path hierarchyLockFile = ((DefaultManagedRepository) created).getHierarchyLockFile();
        MoreFiles.mkdir(hierarchyLockFile.getParent());
        try (UnderLock.LockedFile ignored = UnderLock.forFile(hierarchyLockFile).lock()) {
            DeleteRepositoryParameters parameters = new DeleteRepositoryParameters.Builder(created)
                    .lockTimeout(Duration.ZERO)
                    .build();

            //The failure to lock the repository should not prevent the repository from being deleted, but it should
            //prevent shared-objects from being deleted.
            repositoryManager.delete(parameters);

            assertTrue(Files.isDirectory(((ManagedRepository) created).getSharedObjects().getPath()));
            assertFalse(Files.isDirectory(created.getPath()));
        } finally {
            //Delete the hierarchy
            MoreFiles.deleteQuietly(created.getPath().getParent());
        }
    }

    @Test
    public void testDeleteForSharedHome() {
        publishedEvents.expect(RepositoryCreatedEvent.class);
        publishedEvents.expect(RepositoryDeletedEvent.class);

        //We need to create our own repository to delete; deleting @Repo repositories causes problems
        String repositoryId = BitbucketServerRepository.formatId("012345678900987654321", "996");
        Repository repository = repositoryManager.create(new CreateRepositoryParameters.Builder(repositoryId).build());

        repositoryManager.delete(new DeleteRepositoryParameters.Builder(repository)
                .onlyMember(true)
                .build());
        //The repository may actually still exist on disk here, but attempting to retrieve it should
        //always return null (either because the directory is removed, or is marked deleted)
        assertNull(repositoryManager.findById(repositoryId));
    }

    @Test
    public void testFindById() {
        Repository found = repositoryManager.findById(repository.getId());
        assertNotNull(found);
        assertEquals(found.getId(), repository.getId());
        assertNotSame(repository, found); //A new instance should have been created
        assertTrue(Files.isDirectory(repository.getPath())); //The repository should exist on disk
    }

    @Test
    public void testFindByIdNotFound() {
        String repositoryId = BitbucketServerRepository.formatId("11223344556677889900", "997");

        Repository found = repositoryManager.findById(repositoryId);
        assertNull(found);
    }

    @Test
    public void testFindByIdReturnsNotFoundEntityWhenPassedFlag() {
        String repositoryId = BitbucketServerRepository.formatId("11223344556677889900", "997");

        Repository found = repositoryManager.findById(repositoryId, true);
        assertNotNull(found);
        assertEquals(found.getId(), repositoryId);
        assertFalse(found.exists());
    }

    @Test
    public void testFindByIdUnsupported() {
        Repository found = repositoryManager.findById("This ID won't work");
        assertNull(found);
    }

    @Test
    public void testForkForManaged() throws IOException {
        int forkDepth = CreateRepositoryOperation.MAX_DEPTH + 1;
        publishedEvents.expect(RepositoryCreatedEvent.class, forkDepth + 1);
        publishedEvents.expect(ForkCreatedEvent.class, forkDepth);

        String hierarchyId = "abcdefabcdef12345678";
        String partition = "0011";
        String repositoryId = ManagedRepository.formatId(partition, hierarchyId, "0011227766");
        Repository origin = repositoryManager.create(new CreateRepositoryParameters.Builder(repositoryId).build());

        //Add some content to the repository
        builderFactory.builder(origin).fetch()
                .repository(repository.getPath())
                .refspecs("refs/*:refs/*")
                .withConfiguration("core.hooksPath", "./hooks")
                .build(new LoggingStdioHandler())
                .run();

        //Create a deep repository hierarchy, which should be possible without errors in a ManagedDataStore
        for (int i = 0; i < forkDepth; ++i) {
            String forkId = ManagedRepository.formatId(partition, hierarchyId, "fffffffff" + i);
            Repository fork = repositoryManager.create(new CreateRepositoryParameters.Builder(forkId)
                    .origin(origin)
                    .build());
            assertThat(fork, instanceOf(ManagedRepository.class));
            assertEquals(((ManagedRepository) fork).getLocalId(), fork.getPath().getFileName().toString());
            assertTrue(isBareRepository(fork.getPath()));
            assertEquals(singletonList("../../shared-objects/objects"),
                    Files.readAllLines(fork.getPath().resolve("objects/info/alternates")));

            //Verify that includes were applied by templates
            assertConfigured(fork);
            //Verify Git's default branch was used
            assertEquals("refs/heads/master", agent.getHead(fork));
            //Verify the default branch is at the expected commit
            Branch branch = agent.resolveBranch(fork, "master");
            assertNotNull(branch);
            assertEquals("0a943a29376f2336b78312d99e65da17048951db", branch.getLatestCommit());

            origin = fork;
        }
    }

    @Test
    public void testForkForSharedHome() throws IOException {
        publishedEvents.expect(RepositoryCreatedEvent.class, CreateRepositoryOperation.MAX_DEPTH + 1);
        publishedEvents.expect(ForkCreatedEvent.class, CreateRepositoryOperation.MAX_DEPTH);

        String hierarchyId = "012345678900987654321";
        String repositoryId = BitbucketServerRepository.formatId(hierarchyId, "999");
        Repository origin = repositoryManager.create(new CreateRepositoryParameters.Builder(repositoryId).build());

        //Add some content to the repository
        builderFactory.builder(origin).fetch()
                .repository(repository.getPath())
                .refspecs("refs/*:refs/*")
                .build(new LoggingStdioHandler())
                .run();

        //Create some forks and verify that they get initialized properly
        for (int i = 0; i <= CreateRepositoryOperation.MAX_DEPTH; ++i) {
            String forkId = BitbucketServerRepository.formatId(hierarchyId, "100" + i);

            if (i == CreateRepositoryOperation.MAX_DEPTH) {
                Repository originRepo = origin;
                ForkHierarchyTooDeepException exception = assertThrows(ForkHierarchyTooDeepException.class, () ->
                        repositoryManager.create(new CreateRepositoryParameters.Builder(forkId)
                                .origin(originRepo)
                                .build()));
                assertThat(exception.getMessage(), containsString("fork hierarchies are limited to a maximum depth"));
                logListener.assertError("Failed to create repository");
                continue;
            }

            Repository fork = repositoryManager.create(new CreateRepositoryParameters.Builder(forkId)
                    .origin(origin)
                    .build());
            assertThat(fork, instanceOf(BitbucketServerRepository.class));
            assertEquals(((BitbucketServerRepository) fork).getBitbucketId(), fork.getPath().getFileName().toString());
            assertTrue(isBareRepository(fork.getPath()));
            assertEquals(singletonList(origin.getPath().resolve("objects")),
                    Files.readAllLines(fork.getPath().resolve("objects/info/alternates")).stream()
                            .map(Paths::get)
                            .collect(Collectors.toList()));

            //Verify that includes were applied by templates
            assertConfigured(fork);
            //Verify that pruning is disabled in the origin
            assertEquals(CONFIG_GC_PRUNE_EXPIRE_NEVER,
                    builderFactory.builder(origin).config()
                            .get(CONFIG_GC_PRUNE_EXPIRE)
                            .build(new StringStdioHandler())
                            .run());
            //Verify Git's default branch was used
            assertEquals("refs/heads/master", agent.getHead(fork));
            //Verify the default branch is at the expected commit
            Branch branch = agent.resolveBranch(fork, "master");
            assertNotNull(branch);
            assertEquals("0a943a29376f2336b78312d99e65da17048951db", branch.getLatestCommit());

            origin = fork;
        }
    }

    @Test
    public void testGetById() {
        Repository found = repositoryManager.getById(repository.getId());
        assertEquals(found.getId(), repository.getId());
        assertNotSame(repository, found); //A new instance should have been created
        assertTrue(Files.isDirectory(repository.getPath())); //The repository should exist on disk
    }

    @Test
    public void testGetDefaultBranch() {
        assertEquals("refs/heads/master", repositoryManager.getDefaultBranch(repository));
    }

    @Test
    public void testGetDefaultBranchDoesNotThrowIfBranchDoesNotExist() {
        String repositoryId = ManagedRepository.formatId(2, "98234602356460640176", "defaultbranchtest");
        Repository testRepo = repositoryManager.create(new CreateRepositoryParameters.Builder(repositoryId)
                .defaultBranch("refs/heads/no-such-branch")
                .build());
        try {
            assertEquals("refs/heads/no-such-branch", repositoryManager.getDefaultBranch(testRepo));
        } finally {
            repositoryManager.delete(new DeleteRepositoryParameters.Builder(testRepo).build());
        }
    }

    @Test
    public void testGetMetadata() {
        String repositoryId = ManagedRepository.formatId(2, "82234690163460640163", "metadata");
        Map<String, String> metadata = ImmutableMap.of(
                "foo", "bar",
                "baz", "quux");
        Repository testRepo = repositoryManager.create(new CreateRepositoryParameters.Builder(repositoryId)
                .defaultBranch("refs/heads/magic")
                .metadata(metadata)
                .build());
        try {
            assertEquals(metadata, repositoryManager.getMetadata(testRepo));
        } finally {
            repositoryManager.delete(new DeleteRepositoryParameters.Builder(testRepo).build());
        }
    }

    @Test
    public void testGetReplicaDetails() {
        RepositoryReplicaDetails details = repositoryManager.getReplicaDetails(repository.getId());

        assertNotNull(details);
        assertEquals(ReplicaState.CONSISTENT, details.getReplicaState());
        assertEquals("af214f95e1876b5e2b86d502ab68b5d9abcad3b9e9f37c08603112232da12a86", details.getContentHash());
        assertEquals("a00d43b206831512d56636f166a149d9f2164beadd50c30f0918cb876b83c2c4", details.getMetadataHash());
        assertThat(details.getObservedVersion(), greaterThanOrEqualTo(2L));
        assertThat(details.getVersion(), greaterThanOrEqualTo(2L));
    }

    @Test
    public void testGetReplicaDetailsForMissingRepository() {
        String repositoryId = ManagedRepository.formatId(2, "82234690163460640163", "does-not-exist");
        RepositoryReplicaDetails details = repositoryManager.getReplicaDetails(repositoryId);

        assertEquals(new RepositoryReplicaDetails.Builder(ReplicaState.MISSING).build(), details);
    }

    @Test
    public void testResetHierarchyThatDoesNotExist() {
        assertEquals(0, repositoryManager.resetHierarchy("00aa", "00000000009999999999"));
    }

    @DirtiesRepo
    @Test
    public void testResetHierarchyWithSingleRepository() throws IOException {
        //Create some dark refs and reflogs in the repository
        Path darkRef = MoreFiles.resolve(repository.getPath(), PATH_DARK_REFS, "pull-requests", "17", "from");
        Path darkRefLog = MoreFiles.resolve(repository.getPath(), PATH_LOGS, PATH_DARK_REFS, "pull-requests", "17", "from");
        Path refLog = MoreFiles.resolve(repository.getPath(), PATH_LOGS, PATH_REFS, "pull-requests", "17", "from");
        MoreFiles.mkdir(darkRef.getParent());
        MoreFiles.write(darkRef, "2e10dd2d1d5eea9291b296e78312e8a703964a95");
        MoreFiles.mkdir(darkRefLog.getParent());
        MoreFiles.touch(darkRefLog);
        MoreFiles.mkdir(refLog.getParent());
        MoreFiles.touch(refLog);

        assertEquals(1, repositoryManager.resetHierarchy(repository.getPartition(), repository.getHierarchyId()));

        //Verify that the refs and reflogs have been deleted
        assertFalse(Files.exists(darkRef));
        assertFalse(Files.exists(darkRefLog));
        assertFalse(Files.exists(refLog));

        assertIsEmptyButValid(repository);
    }

    @DirtiesRepo
    @Test
    public void testResetHierarchyWithFork() throws IOException {
        //Create some dark refs and reflogs in the repository
        Path darkRef = MoreFiles.resolve(repository.getPath(), PATH_DARK_REFS, "pull-requests", "17", "from");
        Path darkRefLog = MoreFiles.resolve(repository.getPath(), PATH_LOGS, PATH_DARK_REFS, "pull-requests", "17", "from");
        Path refLog = MoreFiles.resolve(repository.getPath(), PATH_LOGS, PATH_REFS, "pull-requests", "17", "from");
        MoreFiles.mkdir(darkRef.getParent());
        MoreFiles.write(darkRef, "2e10dd2d1d5eea9291b296e78312e8a703964a95");
        MoreFiles.mkdir(darkRefLog.getParent());
        MoreFiles.touch(darkRefLog);
        MoreFiles.mkdir(refLog.getParent());
        MoreFiles.touch(refLog);

        //Create a fork
        Repository fork = repositoryManager.create(new CreateRepositoryParameters.Builder(repository.getId() + "01")
                .origin(repository)
                .defaultBranch("refs/heads/master")
                .build());

        assertEquals(2, repositoryManager.resetHierarchy(repository.getPartition(), repository.getHierarchyId()));

        //Verify that the refs and reflogs have been deleted
        assertFalse(Files.exists(darkRef));
        assertFalse(Files.exists(darkRefLog));
        assertFalse(Files.exists(refLog));

        //Verify that both the fork and the repository are now empty
        assertIsEmptyButValid(repository);
        assertIsEmptyButValid(fork);
    }

    @DirtiesRepo
    @Test
    public void testSetDefaultBranch() {
        repositoryManager.setDefaultBranch(repository, "basic_branching");

        assertEquals("refs/heads/basic_branching", agent.getHead(repository));
    }

    @DirtiesRepo
    @Test
    public void testSetMetadata() {
        repositoryManager.setMetadata(repository, ImmutableMap.of("test-key", "test-value"));

        String value = builderFactory.builder(repository).config()
                .get("bitbucket.test-key")
                .build(new StringStdioHandler())
                .run();
        assertEquals("test-value", value);
    }

    public static boolean isBareRepository(Path directory) {
        return Files.isDirectory(directory) && !Files.isDirectory(directory.resolve(".git")) &&
                Files.isDirectory(directory.resolve("refs/heads"));
    }

    private void assertConfigured(Repository repository) {
        assertThat(listIncludes(repository), IsIterableContainingInOrder.contains(
                endsWith("system-config"),
                equalTo("repository-config")));
    }

    private void assertIsEmptyButValid(Repository repository) {
        //Verify that the repository is still valid
        builderFactory.builder(repository).command("fsck")
                .argument("--no-dangling")
                .argument("--no-progress")
                .build(new LoggingStdioHandler())
                .run();

        //Verify that the repository does not have refs
        StringStdioHandler handler = new StringStdioHandler();
        builderFactory.builder(repository).forEachRef().build(handler).run();
        assertEquals("the repository isn't empty", "", handler.getStdout());
    }

    private List<String> listIncludes(Repository repository) {
        return builderFactory.builder(repository).config()
                .getAll("include.path")
                .nullTerminated(true)
                .build(new ListConfigStdioHandler(true))
                .run();
    }
}