package com.atlassian.bitbucket.mesh.transaction;

import com.atlassian.bitbucket.mesh.config.GitCompatibilityConfig;
import com.atlassian.bitbucket.mesh.execution.ExecutionManager;
import com.atlassian.bitbucket.mesh.execution.GrpcExecutionManager;
import com.atlassian.bitbucket.mesh.git.GitConstants;
import com.atlassian.bitbucket.mesh.git.GitObjectInfo;
import com.atlassian.bitbucket.mesh.git.GitObjectType;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilder;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.hook.GitHookOutputWriter;
import com.atlassian.bitbucket.mesh.git.hook.GitHookRequest;
import com.atlassian.bitbucket.mesh.git.ref.RefUpdate;
import com.atlassian.bitbucket.mesh.junit.LogListener;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.test.ExecutionContextRule;
import com.atlassian.bitbucket.mesh.util.Person;
import com.atlassian.bitbucket.mesh.util.ShaUtils;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Answers;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;

import java.io.IOException;

import static com.atlassian.bitbucket.mesh.transaction.ReferenceTransactionState.*;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;

@RunWith(MockitoJUnitRunner.class)
public class GitReferenceTransactionHookHandlerMockTest {

    private static final RefUpdate REF_UPDATE = new RefUpdate.Builder("refs/heads/master")
            .oldHash("f9e1dd26fb247de5be929ed21c4318630f978311")
            .newHash("bfc8a1b8a31faadedb9d97f485eed36d88c0810a")
            .build();
    private static final Person USER = new Person("John Doe", "john@doe.com");

    @Rule
    public final ExecutionContextRule executionContextRule = new ExecutionContextRule(new GrpcExecutionManager());
    @Rule
    public final LogListener logListener = new LogListener(GitReferenceTransactionHookHandler.class);

    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
    private GitProcessBuilderFactory builderFactory;
    private GitReferenceTransactionHookHandler handler;
    @Mock
    private GitHookOutputWriter outputWriter;
    @Mock
    private Repository repository;
    @Captor
    private ArgumentCaptor<ReferenceTransaction> transactionCaptor;
    @Mock
    private ReferenceTransactionListener transactionListener;
    @Mock
    private UpdateRefsContextHolder updateRefsContextHolder;

    @Before
    public void setUp() {
        GitCompatibilityConfig compatibility = mock(GitCompatibilityConfig.class);
        lenient().when(compatibility.isRefDeleteCallbacksBroken()).thenReturn(true);

        ExecutionManager executionManager = executionContextRule.getExecutionManager();
        handler = new GitReferenceTransactionHookHandler(compatibility, builderFactory, executionManager,
                ImmutableList.of(transactionListener), updateRefsContextHolder);

        //Mock REF_UPDATE being resolved using git cat-file
        GitProcessBuilder builder = mock(GitProcessBuilder.class, Answers.RETURNS_DEEP_STUBS);
        when(builderFactory.builder(repository)).thenReturn(builder);
        when(builder.catFile().batchCheck()
                .withTimeout(any())
                .build(any())
                .run())
                .thenReturn(ImmutableMap.of(
                        REF_UPDATE.getRefId(),
                        new GitObjectInfo(REF_UPDATE.getOldHash(), 1234L, GitObjectType.COMMIT)));
    }

    @Test
    public void testForceUpdateWithRefUpdatesSet() {
        GitHookRequest forceUpdate = new GitHookRequest.Builder("git-reference-transaction")
                .argument("prepared")
                .stdin(ShaUtils.NULL_SHA1 + " " + REF_UPDATE.getNewHash() + " " + REF_UPDATE.getRefId() + "\n")
                .environment(GitConstants.ENV_AUTHOR_NAME, USER.getName())
                .environment(GitConstants.ENV_AUTHOR_EMAIL, USER.getEmail())
                .build();
        UpdateRefsContext context = mock(UpdateRefsContext.class);
        when(context.getRefUpdates()).thenReturn(ImmutableMap.of(REF_UPDATE.getRefId(), REF_UPDATE));
        when(updateRefsContextHolder.get()).thenReturn(context);

        handler.handle(repository, forceUpdate, outputWriter);

        verify(transactionListener).onReferenceTransaction(any());
        verifyNoInteractions(builderFactory);
    }

    @Test
    public void testForceUpdateWithoutRefUpdatesSet() {
        GitHookRequest forceUpdate = new GitHookRequest.Builder("git-reference-transaction")
                .argument("prepared")
                .stdin(ShaUtils.NULL_SHA1 + " " + REF_UPDATE.getNewHash() + " " + REF_UPDATE.getRefId() + "\n")
                .environment(GitConstants.ENV_AUTHOR_NAME, USER.getName())
                .environment(GitConstants.ENV_AUTHOR_EMAIL, USER.getEmail())
                .build();

        handler.handle(repository, forceUpdate, outputWriter);

        verify(transactionListener).onReferenceTransaction(any());
        verify(builderFactory).builder(repository);
    }

    @Test
    public void testHandlesCommittedWithMultipleRefUpdates() {
        UpdateRefsContext updateRefsContext = mock(UpdateRefsContext.class);
        when(updateRefsContextHolder.get()).thenReturn(updateRefsContext);
        RefUpdate refUpdate2 = new RefUpdate.Builder("refs/heads/branch1")
                .oldHash("c99ee73107d3a1e0fd6071d82e548edccfbf4bae")
                .newHash("8a7539e64b7152242d79ce0334847deea726fbc0")
                .build();
        GitHookRequest request = new GitHookRequest.Builder("git-reference-transaction")
                .argument("committed")
                .stdin(REF_UPDATE.getOldHash() + " " + REF_UPDATE.getNewHash() + " " + REF_UPDATE.getRefId() + "\n" +
                        refUpdate2.getOldHash() + " " + refUpdate2.getNewHash() + " " + refUpdate2.getRefId() + "\n")
                .environment(GitConstants.ENV_AUTHOR_NAME, USER.getName())
                .environment(GitConstants.ENV_AUTHOR_EMAIL, USER.getEmail())
                .build();
        assertTrue(handler.handle(repository, request, outputWriter));

        verify(transactionListener).onReferenceTransaction(transactionCaptor.capture());
        ReferenceTransaction transaction = transactionCaptor.getValue();
        assertEquals(repository, transaction.getRepository());
        assertEquals(COMMITTED, transaction.getState());
        assertEquals(ImmutableSet.of(REF_UPDATE, refUpdate2), transaction.getRefUpdates());
        assertEquals(USER, transaction.getUser());
        verify(updateRefsContext).markCommitted(ImmutableList.of(REF_UPDATE.getRefId(), refUpdate2.getRefId()));
    }

    @Test
    public void testHandlesCommittedWithUpdateRefsContextMarkedAsCommitted() {
        UpdateRefsContext updateRefsContext = mock(UpdateRefsContext.class);
        when(updateRefsContextHolder.get()).thenReturn(updateRefsContext);
        assertTrue(handler.handle(repository, createRequest("committed"), outputWriter));

        verify(transactionListener).onReferenceTransaction(transactionCaptor.capture());
        ReferenceTransaction transaction = transactionCaptor.getValue();
        assertEquals(repository, transaction.getRepository());
        assertEquals(COMMITTED, transaction.getState());
        assertEquals(ImmutableSet.of(REF_UPDATE), transaction.getRefUpdates());
        assertEquals(USER, transaction.getUser());
        verify(updateRefsContext).markCommitted(ImmutableList.of(REF_UPDATE.getRefId()));
    }

    @Test
    public void testHandlesExceptionWhenListenerThrowsAndTxIsAborted() throws IOException {
        doTestHandlesExceptionWhenListenerThrowsAndTxCannotBeRejected(ABORTED);
    }

    @Test
    public void testHandlesExceptionWhenListenerThrowsAndTxIsCommitted() throws IOException {
        doTestHandlesExceptionWhenListenerThrowsAndTxCannotBeRejected(COMMITTED);
    }

    @Test
    public void testHandlesInvalidRequestMissingState() {
        assertFalse(handler.handle(repository, new GitHookRequest.Builder("git-reference-transaction")
                        .stdin(REF_UPDATE.getOldHash() + " " + REF_UPDATE.getNewHash() + " " + REF_UPDATE.getRefId() + "\n")
                        .build(),
                outputWriter));

        verifyNoInteractions(transactionListener);
    }

    @Test
    public void testHandlesInvalidRequestUnknownState() {
        assertFalse(handler.handle(repository, createRequest("unknown"), outputWriter));

        verifyNoInteractions(transactionListener);
    }

    @Test
    public void testHandlesPrepared() {
        assertTrue(handler.handle(repository, createRequest("prepared"), outputWriter));

        verify(transactionListener).onReferenceTransaction(transactionCaptor.capture());
        ReferenceTransaction transaction = transactionCaptor.getValue();
        assertEquals(repository, transaction.getRepository());
        assertEquals(PREPARED, transaction.getState());
        assertEquals(ImmutableSet.of(REF_UPDATE), transaction.getRefUpdates());
        assertEquals(USER, transaction.getUser());
    }

    @Test
    public void testRejectsPreparedTxWhenListenerThrows() throws IOException {
        doThrow(new IllegalStateException("testing exception handling"))
                .when(transactionListener)
                .onReferenceTransaction(any());

        assertFalse(handler.handle(repository, createRequest("prepared"), outputWriter));

        verify(transactionListener).onReferenceTransaction(transactionCaptor.capture());
        ReferenceTransaction transaction = transactionCaptor.getValue();
        assertEquals(repository, transaction.getRepository());
        assertEquals(PREPARED, transaction.getState());
        assertEquals(ImmutableSet.of(REF_UPDATE), transaction.getRefUpdates());
        assertEquals(USER, transaction.getUser());
        verify(outputWriter).writeStderr(anyString());
        assertTrue(transaction.isRejected());
        logListener.assertWarn("failed while processing ref update (PREPARED)");
    }

    private static GitHookRequest createRequest(String state) {
        return new GitHookRequest.Builder("git-reference-transaction")
                .argument(state)
                .stdin(REF_UPDATE.getOldHash() + " " + REF_UPDATE.getNewHash() + " " + REF_UPDATE.getRefId() + "\n")
                .environment(GitConstants.ENV_AUTHOR_NAME, USER.getName())
                .environment(GitConstants.ENV_AUTHOR_EMAIL, USER.getEmail())
                .build();
    }

    private void doTestHandlesExceptionWhenListenerThrowsAndTxCannotBeRejected(
            ReferenceTransactionState state) throws IOException {
        if (state != PREPARED) {
            //First put the execution context in a state where an ABORTED/COMMITTED is expected
            assertTrue(handler.handle(repository, createRequest("prepared"), outputWriter));

            reset(transactionListener, outputWriter);
        }

        doThrow(new IllegalStateException("testing exception handling"))
                .when(transactionListener)
                .onReferenceTransaction(any());
        assertTrue(handler.handle(repository, createRequest(state.toString().toLowerCase()), outputWriter));

        verify(transactionListener).onReferenceTransaction(transactionCaptor.capture());
        ReferenceTransaction transaction = transactionCaptor.getValue();
        assertEquals(repository, transaction.getRepository());
        assertEquals(state, transaction.getState());
        assertEquals(ImmutableSet.of(REF_UPDATE), transaction.getRefUpdates());
        assertEquals(USER, transaction.getUser());
        //Verify that nothing is written to stderr, since we cannot reject the transaction
        verify(outputWriter, never()).writeStderr(anyString());
        logListener.assertWarn("failed while processing ref update (" + state + ")");
    }
}