package com.atlassian.bitbucket.internal.branch;

import com.atlassian.bitbucket.ForbiddenException;
import com.atlassian.bitbucket.ServerException;
import com.atlassian.bitbucket.event.ApplicationEvent;
import com.atlassian.bitbucket.event.branch.BranchDeletedEvent;
import com.atlassian.bitbucket.event.branch.BranchDeletionCanceledException;
import com.atlassian.bitbucket.event.branch.BranchDeletionRequestedEvent;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.i18n.KeyedMessage;
import com.atlassian.bitbucket.i18n.SimpleI18nService;
import com.atlassian.bitbucket.internal.branch.automerge.AutoMergeTestUtils;
import com.atlassian.bitbucket.internal.branch.info.BranchInfoRequest;
import com.atlassian.bitbucket.internal.branch.info.BranchInformation;
import com.atlassian.bitbucket.mockito.MockCallChainAnswer;
import com.atlassian.bitbucket.permission.Permission;
import com.atlassian.bitbucket.permission.PermissionService;
import com.atlassian.bitbucket.project.Project;
import com.atlassian.bitbucket.pull.PullRequest;
import com.atlassian.bitbucket.pull.PullRequestService;
import com.atlassian.bitbucket.repository.*;
import com.atlassian.bitbucket.scm.git.GitAgent;
import com.atlassian.bitbucket.scm.git.GitRefPattern;
import com.atlassian.bitbucket.scm.git.GitScm;
import com.atlassian.bitbucket.scm.git.command.GitCommand;
import com.atlassian.bitbucket.scm.git.command.GitCommandBuilderFactory;
import com.atlassian.bitbucket.scm.git.command.GitScmCommandBuilder;
import com.atlassian.bitbucket.scm.git.command.updateref.GitUpdateRef;
import com.atlassian.bitbucket.scm.git.command.updateref.GitUpdateRefDeleteBuilder;
import com.atlassian.bitbucket.server.ApplicationPropertiesService;
import com.atlassian.bitbucket.util.Page;
import com.atlassian.bitbucket.util.PageUtils;
import com.atlassian.event.api.EventPublisher;
import com.atlassian.utils.process.ProcessException;
import com.google.common.base.Strings;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.function.Function;

import static com.atlassian.bitbucket.internal.branch.automerge.AutoMergeTestUtils.branch;
import static java.util.stream.Collectors.toList;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.fail;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.*;

public class DefaultBranchServiceTest {

    @Rule
    public MockitoRule mockitoRule = MockitoJUnit.rule();

    @Mock
    private BranchInformation branchInfo;
    @Mock
    private EventPublisher eventPublisher;
    @Mock
    private GitAgent gitAgent;
    @Mock
    private GitCommandBuilderFactory gitCommandFactory;
    @Mock
    private PermissionService permissionService;
    @Mock
    private ApplicationPropertiesService propertiesService;
    @Mock
    private PullRequestService pullRequestService;
    @Mock
    private RefService refService;
    @Spy
    private I18nService i18nService = new SimpleI18nService();

    @InjectMocks
    private DefaultBranchService service;

    @Mock
    private Repository repo;
    @Mock
    private Project proj;
    @Mock
    private Page<PullRequest> pullRequestPage;

    @Before
    public void setup() {
        when(repo.getId()).thenReturn(4);
        when(repo.getProject()).thenReturn(proj);
        when(repo.getSlug()).thenReturn("slug");
        when(repo.getScmId()).thenReturn(GitScm.ID);
        when(proj.getKey()).thenReturn("key");
        when(permissionService.hasRepositoryPermission(any(), same(Permission.REPO_WRITE))).thenReturn(true);
        when(pullRequestService.search(any(), any())).thenReturn(pullRequestPage);
    }

    @Test(expected = InvalidRefNameException.class)
    public void testCreateBranchWithInvalidRefName() {
        String tooLongAName = Strings.repeat("0", 50) + "/" + Strings.repeat("0", 50);
        when(refService.createBranch(any())).thenThrow(new InvalidRefNameException(new KeyedMessage("", "", ""), ""));

        createBranch(repo, tooLongAName, "hash");
    }

    @Test(expected = DuplicateRefException.class)
    public void testDuplicateBranch() {
        when(gitAgent.resolveBranch(same(repo), eq("refs/heads/newbranch"), eq(true))).thenReturn(branch("refs/heads/newbranch"));
        when(refService.createBranch(any())).thenThrow(new DuplicateRefException(new KeyedMessage("", "", ""), "newbranch"));
        createBranch(repo, "newbranch", "hash");
    }

    @Test(expected = BranchDeletionCanceledException.class)
    public void testCanCancelBranchDeletion() {
        doAnswer(invocation -> {
            BranchDeletionRequestedEvent event = (BranchDeletionRequestedEvent) invocation.getArguments()[0];
            event.cancel(new KeyedMessage("", "", ""));
            return null;
        }).when(eventPublisher).publish(any());

        mockBranchResolution("del-branch", false);

        try {
            service.deleteBranch(repo, "del-branch", "hash", true);
            fail("Expected BranchDeletionCanceledException for dry-run of cancelled branch deletion");
        } catch (BranchDeletionCanceledException e) {
            // Expected
        }
        // Now the @Test(expected = ...) takes care of it
        service.deleteBranch(repo, "del-branch", "hash", false);
    }

    @Test
    public void testCanNotDeleteBranchWithBlankName() {
        List<Runnable> tests = Arrays.asList(
                () -> service.deleteBranch(repo, "", "hash", true),
                () -> service.deleteBranch(repo, "", "hash", false),
                () -> service.deleteBranch(repo, " ", "hash", true),
                () -> service.deleteBranch(repo, " ", "hash", false),
                () -> service.deleteBranch(repo, null, "hash", true),
                () -> service.deleteBranch(repo, null, "hash", false));
        for (Runnable test : tests) {
            try {
                test.run();
                fail("Expected a BranchNameValidationException");
            } catch (InvalidRefNameException e) {
                // Expected
            }
        }

        verifyZeroInteractions(gitAgent);
    }

    @Test(expected = IllegalRepositoryStateException.class)
    public void testCanNotDeleteBranchOnNonGitRepo() {
        when(repo.getScmId()).thenReturn("clearcase");
        service.deleteBranch(repo, "branchy", "hash", false);
    }

    @Test(expected = UnsupportedOnBranchException.class)
    public void testCanNotDeleteDefaultBranch() {
        mockBranchResolution("branchy", true);
        service.deleteBranch(repo, "branchy", "hash", false);
    }

    @Test(expected = BranchHasMovedException.class)
    public void testCanNotDeleteBranchBecauseItMoved() {
        mockBranchResolution("branchy", false);
        service.deleteBranch(repo, "branchy", "new-hash", false);
    }

    @Test(expected = BranchHasMovedException.class)
    public void testCanNotDeleteBranchBecauseItMovedAfterVerification() {
        mockBranchResolution("branchy", false);
        MockCallChainAnswer answerChain = new MockCallChainAnswer();
        when(gitCommandFactory.builder(same(repo))).thenAnswer(answerChain);
        when(answerChain.mockFor(GitCommand.class).call())
                .thenThrow(new ServerException(
                        new KeyedMessage("key", "", "An error occurred while executing an external process: '/usr/bin/git update-ref -d refs/heads/branchy aaaabb9e749b2e36a204a9ed77131a90b0daaaaa' exited with code 1 saying: error: Ref refs/heads/branchy is at 4c7cfdb3cf3c8fad3b1f92c6b1456004598034e0 but expected dc6bbdc255e62ada14a8febcc417fcbdba957b87"),
                        new ProcessException("Non-zero exit code: 1")));
        service.deleteBranch(repo, "branchy", "hash", false);
    }

    @Test(expected = ForbiddenException.class)
    public void testCanNotDeleteBranchWithoutWritePermission() {
        mockBranchResolution("branchy", false);
        when(permissionService.hasRepositoryPermission(any(), same(Permission.REPO_WRITE))).thenReturn(false);
        service.deleteBranch(repo, "branchy", "hash", false);
    }

    @Test(expected = UnsupportedOnBranchException.class)
    public void testCanNotDeleteBranchOneOutstandingPR() {
        mockBranchResolution("branchy", false);
        when(pullRequestPage.getSize()).thenReturn(1);
        service.deleteBranch(repo, "branchy", "hash", false);
    }

    @Test(expected = UnsupportedOnBranchException.class)
    public void testCanNotDeleteBranchWithManyOutstandingPRs() {
        mockBranchResolution("branchy", false);
        when(pullRequestPage.getSize()).thenReturn(2);
        service.deleteBranch(repo, "branchy", "hash", false);
    }

    @Test
    public void testDeleteBranch() {
        verifyDeleteBranch(true);
    }

    @Test
    public void testDeleteBranchWithoutMoveCheck() {
        verifyDeleteBranch(false);
    }

    @Test
    public void testFindByCommitDefaultBranch() {
        Page<Branch> result = findByCommit(request -> {
            List<MinimalRef> refs = request.getBranches().stream()
                    .map(branch -> toRef(branch.getId()))
                    .collect(toList());
            return page(refs);
        }, "master", 10);
        assertBranches(result, toBranch("master", true));
    }

    @Test
    public void testFindByCommitOtherBranches() {
        Page<Branch> result = findByCommit(request -> {
            // Empty means "no filter"
            if (request.getBranches().isEmpty()) {
                return page(Arrays.asList(toRef("a"), toRef("b")));
            } else {
                return page(Collections.emptyList());
            }
        }, "master", 10);
        assertBranches(result, toBranch("a", false), toBranch("b", false));
    }

    private void verifyDeleteBranch(boolean withMoveCheck) {
        mockBranchResolution("branchy", false);
        MockCallChainAnswer answerChain = new MockCallChainAnswer();
        when(gitCommandFactory.builder(same(repo))).thenAnswer(answerChain);
        service.deleteBranch(repo, "branchy", withMoveCheck ? "hash" : null, false);

        verify(answerChain.mockFor(GitScmCommandBuilder.class)).updateRef();
        verify(answerChain.mockFor(GitUpdateRef.class)).delete(eq(GitRefPattern.HEADS.qualify("branchy")));
        if (withMoveCheck) {
            verify(answerChain.mockFor(GitUpdateRefDeleteBuilder.class)).oldValue(eq("hash"));
            verify(answerChain.mockFor(GitUpdateRefDeleteBuilder.class)).build();
        } else {
            verify(answerChain.mockFor(GitUpdateRefDeleteBuilder.class)).build();
        }
        verify(answerChain.mockFor(GitCommand.class)).call();

        ArgumentCaptor<ApplicationEvent> captor = ArgumentCaptor.forClass(ApplicationEvent.class);
        verify(eventPublisher, times(2)).publish(captor.capture());
        assertThat(captor.getAllValues().get(0), instanceOf(BranchDeletionRequestedEvent.class));
        assertThat(captor.getAllValues().get(1), instanceOf(BranchDeletedEvent.class));

        BranchDeletedEvent deletedEvent = (BranchDeletedEvent) captor.getValue();
        assertThat(deletedEvent.getRepository(), is(repo));

        RefChange refChange = deletedEvent.getRefChanges().iterator().next();
        assertThat(refChange.getToHash(), is(Strings.repeat("0", 40)));
        assertThat(refChange.getRef().getId(), is(GitRefPattern.HEADS.qualify("branchy")));
        assertThat(refChange.getFromHash(), is("hash"));
        assertThat(refChange.getType(), is(RefChangeType.DELETE));
    }

    private Page<Branch> findByCommit(Function<BranchInfoRequest, Page<MinimalRef>> callback,
                                      String defaultBranch, int limit) {
        Branch branch = branch(defaultBranch);

        when(branchInfo.getBranches(any()))
                .thenAnswer(invocation -> callback.apply((BranchInfoRequest) invocation.getArguments()[0]));

        when(propertiesService.getPluginProperty(eq(DefaultBranchService.PROPERTY_MAX_BRANCHES), anyInt())).thenReturn(limit);
        when(refService.getDefaultBranch(repo)).thenReturn(branch);

        return service.findByCommit(repo, "", PageUtils.newRequest(0, limit));
    }

    private <T> Page<T> page(List<T> values) {
        return PageUtils.createPage(values, PageUtils.newRequest(0, values.size()));
    }

    private void assertBranches(Page<Branch> branches, Branch... expected) {
        List<Branch> expectedList = Arrays.asList(expected);
        assertThat(branches.stream().collect(toList()), is(expectedList));
        assertThat(branches.stream().map(Branch::getIsDefault).collect(toList()),
                is(expectedList.stream().map(Branch::getIsDefault).collect(toList())));
    }

    private Branch createBranch(Repository repository, String branchName, String startPoint) {
        return service.createBranch(repository, branchName, startPoint);
    }

    private void mockBranchResolution(String branchDisplayId, boolean isDefault) {
        Branch branch = mockBranch(branchDisplayId, GitRefPattern.HEADS.qualify(branchDisplayId), isDefault);
        when(gitAgent.resolveBranch(same(repo), eq(branchDisplayId), eq(false))).thenReturn(branch);
    }

    private Branch mockBranch(String branchDisplayId, String branchId, boolean isDefault) {
        Branch branch = mock(Branch.class);
        when(branch.getId()).thenReturn(branchId);
        when(branch.getDisplayId()).thenReturn(branchDisplayId);
        when(branch.getLatestCommit()).thenReturn("hash");
        when(branch.getIsDefault()).thenReturn(isDefault);
        return branch;
    }

    private Branch toBranch(String branch, boolean isDefault) {
        return new SimpleBranch.Builder()
                .displayId(branch)
                .isDefault(isDefault)
                .build();
    }

    private MinimalRef toRef(String branch) {
        return new SimpleMinimalRef.Builder()
                .displayId(branch)
                .type(StandardRefType.BRANCH)
                .build();
    }
}
