package com.atlassian.bitbucket.mesh.repository;

import com.atlassian.bitbucket.mesh.git.GitAgent;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.porcelain.GitPorcelain;
import com.atlassian.bitbucket.mesh.git.porcelain.WorkTreeParameters;
import com.atlassian.bitbucket.mesh.git.ref.Branch;
import com.atlassian.bitbucket.mesh.git.ref.RefUpdate;
import com.atlassian.bitbucket.mesh.git.worktree.*;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.junit.TemporaryDirectory;
import com.google.common.collect.ImmutableList;
import org.apache.commons.io.IOUtils;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.transaction.PlatformTransactionManager;

import java.io.IOException;
import java.io.InputStream;
import java.nio.CharBuffer;
import java.nio.channels.WritableByteChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.List;

import static com.atlassian.bitbucket.mesh.git.GitConstants.PATH_GIT;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

@RunWith(MockitoJUnitRunner.class)
public class DefaultWorkTreeManagerMockTest {

    private static final Duration EXPIRY = Duration.ofMinutes(5);

    private static final String WORK_TREE_ID = WorkTree.formatId(0);

    @Rule
    public final TemporaryDirectory tempDir = new TemporaryDirectory();

    @Mock
    private GitAgent agent;
    @Mock
    private DataStoreManager dataStoreManager;
    @Mock
    private GitPorcelain gitPorcelain;
    @Mock
    private GitProcessBuilderFactory processBuilderFactory;
    @Mock
    private Repository repository;
    @Mock
    private RepositoryManager repositoryManager;
    @Mock
    private TaskScheduler taskScheduler;
    @Mock
    private PlatformTransactionManager transactionManager;
    private Path workTreeDirectory;
    @InjectMocks
    private DefaultWorkTreeManager workTreeManager;
    @Mock
    private WorkTreeStateDao workTreeStateDao;

    @Before
    public void setUp() throws IOException {
        workTreeDirectory = tempDir.newFolder();
        when(repository.getWorkTreesDir()).thenReturn(workTreeDirectory);
        when(workTreeStateDao.create(any(), anyBoolean(), any())).thenReturn(0L).thenReturn(1L).thenReturn(2L);
    }

    @Test
    public void testArgumentsAreTranslatedProperly() throws IOException {
        String commitId = "cd2c28f601a4f1a87bafee59492acf834fdc5a01";
        Repository alternate = mock(Repository.class);
        String branchName = "myBranch";
        CreateWorkTreeParameters parameters = new CreateWorkTreeParameters.Builder(repository, commitId, EXPIRY)
                .alternate(alternate)
                .branch(branchName)
                .pristine(true)
                .build();

        WorkTreeParameters actual = workTreeManager.buildWorkTreeParameters(parameters, tempDir.newFolder("wt-" + 0));

        assertThat(actual.getAlternates().size(), is(1));
        assertThat(actual.getAlternates(), contains(alternate));
        assertTrue(actual.getBranch().isPresent());
        assertThat(actual.getBranch().get(), is(branchName));
        assertTrue(actual.getCommit().isPresent());
        assertThat(actual.getCommit().get(), is(commitId));
        assertThat(actual.getRepository(), is(repository));
        assertTrue(actual.isPristine());
    }

    @Test
    public void testClose() throws IOException {
        WorkTree workTree = createWorkTree();

        assertTrue(MoreFiles.dirExists(workTree.getPath()));

        workTreeManager.close(workTree);

        assertFalse(MoreFiles.dirExists(workTree.getPath()));
        verify(workTreeStateDao).deleteById(workTree.getLocalId());
    }

    @Test
    public void testCreate() throws IOException {
        Instant expiryInstant = Instant.now().plus(EXPIRY);
        CreateWorkTreeParameters parameters = new CreateWorkTreeParameters.Builder(repository, null, EXPIRY).build();
        Path workTreePath = tempDir.newFolder("worktree-1");
        when(gitPorcelain.workTree(any())).thenReturn(workTreePath);

        DefaultWorkTree workTree = (DefaultWorkTree) workTreeManager.create(parameters);

        // allow 50ms difference because of the time between configuring and building
        assertEquals(expiryInstant.toEpochMilli(), workTree.getExpiry().toEpochMilli(), 50.0);
        assertThat(workTree.getId(), is(WORK_TREE_ID));
        assertThat(workTree.getPath(), is(workTreePath));
        assertThat(workTree.getRepository(), is(repository));
    }

    @Test
    public void testCreatingMultiplesWork() throws IOException {
        CreateWorkTreeParameters parameters = new CreateWorkTreeParameters.Builder(repository, null, EXPIRY).build();
        Path workTreePath1 = tempDir.newFolder("worktree-1");
        Path workTreePath2 = tempDir.newFolder("worktree-2");
        Path workTreePath3 = tempDir.newFolder("worktree-3");
        when(gitPorcelain.workTree(any())).thenReturn(workTreePath1).thenReturn(workTreePath2).thenReturn(workTreePath3);

        WorkTree wtOne = workTreeManager.create(parameters);
        assertThat(wtOne.getId(), is(WORK_TREE_ID));

        WorkTree wtTwo = workTreeManager.create(parameters);
        assertThat(wtTwo.getId(), is(WorkTree.formatId(1)));

        WorkTree wtThree = workTreeManager.create(parameters);
        assertThat(wtThree.getId(), is(WorkTree.formatId(2)));
    }

    @Test
    public void testExpiry() {
        WorkTree workTree = mock(WorkTree.class);
        when(workTree.isExpired()).thenReturn(true);
        assertThrows(InvalidWorkTreeException.class, () -> workTreeManager.mkdir(workTree, "sub/dir"));
        assertThrows(InvalidWorkTreeException.class, () -> workTreeManager.publish(workTree, mock(PublishWorkTreeParameters.class)));
        assertThrows(InvalidWorkTreeException.class, () -> workTreeManager.updateRef(workTree,
                mock(PublishWorkTreeParameters.class), mock(RefUpdate.class)));
        assertThrows(InvalidWorkTreeException.class, () -> workTreeManager.writeFile(workTree, "will/throw"));
    }

    @Test
    public void testGetById() {
        WorkTreeState workTreeState = mock(WorkTreeState.class);
        long localId = 123;

        when(repositoryManager.getById("repoId")).thenReturn(repository);
        when(workTreeStateDao.findById(localId)).thenReturn(workTreeState);
        when(workTreeState.getRepositoryId()).thenReturn("repoId");
        when(workTreeState.getExpiry()).thenReturn(Instant.now());
        when(workTreeState.getId()).thenReturn(localId);

        WorkTree workTree = workTreeManager.getById(WorkTree.formatId(localId));

        assertThat(workTree.getLocalId(), is(localId));
    }

    @Test
    public void testGetByInvalidId() {
        assertThrows(WorkTreeNotFoundException.class, () -> workTreeManager.getById("wt/1234"));
    }

    @Test
    public void testMkdirForDirectoryThatExists() throws IOException {
        String relativePath = "directory";
        DefaultWorkTree workTree = createWorkTree();

        //create a directory with the same name as the directory to be created
        MoreFiles.mkdir(workTree.getPath().resolve(relativePath));

        workTreeManager.mkdir(workTree, relativePath);
    }

    @Test
    public void testMkdirForFileThatExists() throws IOException {
        //create a file with the same name as the directory to be created
        String relativePath = "directory";
        DefaultWorkTree workTree = createWorkTree();
        MoreFiles.touch(workTree.getPath().resolve(relativePath));

        assertThrows(InvalidWorkTreePathException.class, () -> workTreeManager.mkdir(workTree, relativePath));
    }

    @Test
    public void testMkdirSuccessful() throws IOException {
        String relativePath = "src";
        DefaultWorkTree workTree = createWorkTree();

        workTreeManager.mkdir(workTree, relativePath);

        assertTrue("Directory was not created",
                Files.exists(workTree.getPath().resolve(relativePath)));
    }

    @Test
    public void testMkdirSuccessfulSeveralLevels() throws IOException {
        String relativePath = "src/one/two";
        DefaultWorkTree workTree = createWorkTree();

        workTreeManager.mkdir(workTree, relativePath);

        assertTrue("Directory was not created",
                Files.exists(workTree.getPath().resolve(relativePath)));
    }

    @Test
    public void testMkdirWithInvalidPaths() throws IOException {
        List<String> invalidPaths = getInvalidPaths();
        DefaultWorkTree workTree = createWorkTree();

        for (String relativePath : invalidPaths) {
            assertThrows(String.format("Expected path '%s' to throw, but it did not", relativePath),
                    InvalidWorkTreePathException.class, () -> workTreeManager.mkdir(workTree, relativePath));
            assertFalse(String.format("Directory '%s' was created, but should not have been", relativePath),
                    Files.exists(tempDir.getRoot().resolve(relativePath)));
        }
    }

    @Test
    public void testOnRepositoryDeleted() {
        RepositoryDeletedEvent event = new RepositoryDeletedEvent(repository);
        CreateWorkTreeParameters parameters = new CreateWorkTreeParameters.Builder(repository, null, EXPIRY).build();
        Path workTreePath = DefaultWorkTreeManager.resolveWorkTreeDirectory(repository, 0);
        WorkTreeState workTreeState = mock(WorkTreeState.class);

        when(gitPorcelain.workTree(any())).thenReturn(workTreePath);
        when(repository.getId()).thenReturn("repo-1");
        when(workTreeStateDao.findByRepository("repo-1")).thenReturn(Collections.singletonList(workTreeState));
        WorkTree wt = workTreeManager.create(parameters);
        when(workTreeState.getId()).thenReturn(wt.getLocalId());

        workTreeManager.onRepositoryDeleted(event);

        assertFalse("Work tree directory was not deleted", MoreFiles.dirExists(wt.getPath()));
        assertNull(workTreeManager.findById(wt.getId()));
        assertTrue("Work tree parent directory was removed", MoreFiles.dirExists(repository.getWorkTreesDir()));
        verify(workTreeStateDao).findByRepository("repo-1");
        verify(workTreeStateDao).deleteById(wt.getLocalId());
    }

    @Test
    public void testPublish() throws IOException {
        DefaultWorkTree workTree = createWorkTree();
        String newHash = "5f34e83936c9f166f30f6e27a57b799ceaf8e7d8";
        when(agent.revParse(workTree.getPath().resolve(PATH_GIT), GitAgent.HEAD)).thenReturn(newHash);
        PublishWorkTreeParameters parameters = new PublishWorkTreeParameters.Builder().branch("main").build();

        RefUpdate refUpdate = workTreeManager.publish(workTree, parameters);

        assertEquals(newHash, refUpdate.getNewHash());
        assertEquals("0000000000000000000000000000000000000000", refUpdate.getOldHash());
    }

    @Test
    public void testPublishThrowsForNoCommit() throws IOException {
        DefaultWorkTree workTree = createWorkTree();
        when(agent.revParse(workTree.getPath().resolve(PATH_GIT), GitAgent.HEAD)).thenReturn(GitAgent.HEAD);
        PublishWorkTreeParameters parameters = new PublishWorkTreeParameters.Builder().branch("main").build();

        assertThrows(InvalidWorkTreeException.class, () -> workTreeManager.publish(workTree, parameters));
    }

    @Test
    public void testPublishUsesHashFromBranch() throws IOException {
        Branch branch = mock(Branch.class);
        DefaultWorkTree workTree = createWorkTree();
        String newHash = "7c29de9f5070fb46538ec28c6b09b1684aa03c13";
        String oldHash = "5f34e83936c9f166f30f6e27a57b799ceaf8e7d8";
        PublishWorkTreeParameters parameters = new PublishWorkTreeParameters.Builder().branch("main").build();
        when(branch.getLatestCommit()).thenReturn(oldHash);
        when(agent.resolveBranch(any(), any(), anyBoolean())).thenReturn(branch);
        when(agent.revParse(workTree.getPath().resolve(PATH_GIT), GitAgent.HEAD)).thenReturn(newHash);

        RefUpdate refUpdate = workTreeManager.publish(workTree, parameters);

        assertEquals(newHash, refUpdate.getNewHash());
        assertEquals(oldHash, refUpdate.getOldHash());
    }

    @Test
    public void testPublishWithExpectedHash() throws IOException {
        DefaultWorkTree workTree = createWorkTree();
        String newHash = "7c29de9f5070fb46538ec28c6b09b1684aa03c13";
        String oldHash = "4c5eeddaa22fea9047cd3769292bfde502c2f81e";
        PublishWorkTreeParameters parameters = new PublishWorkTreeParameters.Builder()
                .branch("main")
                .expectedHash(oldHash)
                .build();
        when(agent.revParse(workTree.getPath().resolve(PATH_GIT), GitAgent.HEAD)).thenReturn(newHash);

        RefUpdate refUpdate = workTreeManager.publish(workTree, parameters);

        assertEquals(newHash, refUpdate.getNewHash());
        assertEquals(oldHash, refUpdate.getOldHash());
    }

    @Test
    public void testTaskIsScheduled() {
        verify(taskScheduler).scheduleAtFixedRate(any(WorkTreeCleanupTask.class), any(), any());
    }

    @Test
    public void testWriteFile() throws IOException {
        DefaultWorkTree workTree = createWorkTree();
        String content = "new content";
        String relativePath = "file.txt";

        try (WritableByteChannel output = workTreeManager.writeFile(workTree, relativePath)) {
            output.write(StandardCharsets.UTF_8.encode(CharBuffer.wrap(content)));
        }

        assertTrue(Files.exists(workTree.getPath().resolve(relativePath)));
        try (InputStream in = Files.newInputStream(workTree.getPath().resolve(relativePath))) {
            assertEquals(content, IOUtils.toString(in, StandardCharsets.UTF_8));
        }
    }

    @Test
    public void testWriteFileToNonexistentDirectory() throws IOException {
        DefaultWorkTree workTree = createWorkTree();
        String relativePath = "src/nested/file.txt";

        assertThrows("Expected write to throw, but it did not",
                WorkTreeWriteException.class, () -> workTreeManager.writeFile(workTree, relativePath));
        assertFalse(Files.exists(workTree.getPath().resolve(relativePath)));
    }

    @Test
    public void testWriteInvalidPaths() throws IOException {
        List<String> invalidPaths = getInvalidPaths();
        DefaultWorkTree workTree = createWorkTree();

        for (String relativePath : invalidPaths) {
            String fileName = relativePath + "/file.txt";
            assertThrows(String.format("Expected write to file '%s' to throw, but it did not", fileName),
                    InvalidWorkTreePathException.class, () -> {
                        workTreeManager.writeFile(workTree, fileName);
                    });
            assertFalse(String.format("File '%s' was created, but should not have been", fileName),
                    Files.exists(tempDir.getRoot().resolve(fileName)));
        }
    }

    private DefaultWorkTree createWorkTree() throws IOException {
        long localId = System.currentTimeMillis();
        Path workTreePath = tempDir.newFolder("worktree-" + localId);
        MoreFiles.mkdir(workTreePath.resolve(".git"));
        return new DefaultWorkTree(Instant.now().plusSeconds(90), false, localId, workTreePath, repository);
    }

    private List<String> getInvalidPaths() {
        return new ImmutableList.Builder<String>()
                .add(".git/myDirectory")
                .add(".git")
                .add("myDirectory/../.git/myDirectory")
                .add("../myDirectory")
                .add(tempDir.getRoot().resolve("directory").toString())
                .build();
    }
}