package com.atlassian.stash.internal.repository.ref.restriction;

import com.atlassian.bitbucket.auth.AuthenticationContext;
import com.atlassian.bitbucket.event.branch.BranchCreationRequestedEvent;
import com.atlassian.bitbucket.event.branch.BranchDeletionRequestedEvent;
import com.atlassian.bitbucket.event.tag.TagCreationRequestedEvent;
import com.atlassian.bitbucket.event.tag.TagDeletionRequestedEvent;
import com.atlassian.bitbucket.hook.HookResponse;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.i18n.KeyedMessage;
import com.atlassian.bitbucket.permission.Permission;
import com.atlassian.bitbucket.pull.PullRequest;
import com.atlassian.bitbucket.pull.PullRequestRef;
import com.atlassian.bitbucket.repository.*;
import com.atlassian.bitbucket.repository.ref.restriction.*;
import com.atlassian.bitbucket.scm.pull.MergeRequest;
import com.atlassian.bitbucket.user.*;
import com.atlassian.bitbucket.util.CancelState;
import com.atlassian.bitbucket.util.PageImpl;
import com.atlassian.bitbucket.util.PageRequest;
import com.atlassian.stash.internal.repository.ref.restriction.provider.BranchMatcher;
import com.google.common.base.Supplier;
import com.google.common.collect.*;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.runners.MockitoJUnitRunner;

import javax.annotation.Nonnull;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
import static org.hamcrest.core.Is.is;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.*;

@RunWith(MockitoJUnitRunner.class)
public class RestrictionEnforcerTest {

    private static final String CANCEL_READ_ONLY = "bitbucket.branch.permission.cancel.read.only";
    private static final String REJECT_NO_DELETE = "bitbucket.branch.permission.reject.no.delete";
    private static final String REJECT_FAST_FORWARD_ONLY = "bitbucket.branch.permission.reject.fast.forward.only";
    private static final String REJECT_PULL_REQUEST_ONLY = "bitbucket.branch.permission.reject.pull.request.only";
    private static final String REJECT_READ_ONLY = "bitbucket.branch.permission.reject.read.only";
    private static final String CHECK_SETTINGS_ADMINISTRATOR = "bitbucket.branch.permission.check.settings.administrator";
    private static final String MERGE_CHECK_SUMMARY = "bitbucket.branch.permission.branch.permission.merge.check.summary";
    private static final String MERGE_CHECK = "bitbucket.branch.permission.branch.permission.merge.check";


    private static final int CURRENT_USER_ID = 1;
    private static final int REPOSITORY_ID = 33;
    private static final String FROM_HASH = "0123456789abcdef0123456789abcdef01234567";
    private static final String TO_HASH = "123456789abcdef0123456789abcdef012345678";
    private static final String MASTER_REF = "refs/heads/master";
    private static final String MASTER = "master";
    private static final KeyedMessage KEYED_MESSAGE = new KeyedMessage("key", "message", "root");
    private static final String RELEASE_TAG = "release-tag";
    private final Repository repository = mock(Repository.class);
    @Mock
    private AuthenticationContext authenticationContext;
    private ApplicationUser currentUser;
    @Mock
    private I18nService i18nService;
    private StringWriter output;
    @Mock
    private RefRestrictionService restrictionService;
    @Mock
    private EscalatedSecurityContext withAdmin;
    @Mock
    private SecurityService securityService;

    private RestrictionEnforcer underTest;

    @Before
    public void setup() {
        currentUser = mockUser(CURRENT_USER_ID);
        when(authenticationContext.getCurrentUser()).thenReturn(currentUser);
        when(securityService.withPermission(Permission.REPO_ADMIN, "For branch permission"))
                .thenReturn(new DummyEscalatedSecurityContext(user(), Sets.newHashSet(Permission.REPO_ADMIN)));

        output = new StringWriter();

        when(restrictionService.match(any(RestrictionMatchRequest.class))).thenReturn(mock(Multimap.class));
        when(restrictionService.search(any(RestrictionSearchRequest.class), any(PageRequest.class)))
                .thenReturn(mock(PageImpl.class));
        when(repository.getId()).thenReturn(REPOSITORY_ID);
        when(i18nService.getMessage(anyString(), anyString())).thenReturn("ERROR");

        underTest = new RestrictionEnforcer(authenticationContext, i18nService, restrictionService, securityService);
    }

    @Test
    public void onReceiveGetsUserRestrictions() throws Exception {
        underTest.onReceive(repository, Lists.newArrayList(), hookResponse());
        ArgumentCaptor<RestrictionMatchRequest> RestrictionMatchRequest = ArgumentCaptor.forClass(RestrictionMatchRequest.class);

        verify(restrictionService).match(RestrictionMatchRequest.capture());

        assertThat(RestrictionMatchRequest.getValue().getRepository().getId(), is(equalTo(REPOSITORY_ID)));
    }

    @Test
    public void deleteChangeWithDeleteRestrictionTriggersDeleteError() {
        when(restrictionService.match(any(RestrictionMatchRequest.class))).thenReturn(
                refRestrictionMap(RefRestrictionType.NO_DELETES, RefChangeType.DELETE));

        RefChange deleteMaster = simpleRefChange(MASTER_REF, RefChangeType.DELETE);

        underTest.onReceive(repository, Lists.newArrayList(deleteMaster), hookResponse());

        verify(i18nService).getMessage(REJECT_NO_DELETE, MASTER_REF);
    }

    @Test
    public void updateWithPullRequestOnlyRestrictionTriggersError() {
        RefChange updateMaster = simpleRefChange(MASTER_REF, RefChangeType.UPDATE);

        when(restrictionService.match(any(RestrictionMatchRequest.class))).thenReturn(
                refRestrictionMap(RefRestrictionType.PULL_REQUEST_ONLY, RefChangeType.UPDATE));

        underTest.onReceive(repository, Lists.newArrayList(updateMaster), hookResponse());

        verify(i18nService).getMessage(REJECT_PULL_REQUEST_ONLY, MASTER_REF);
    }

    @Test
    public void updateWithFastForwardOnlyRestrictionTriggersError() {
        RefChange updateMaster = simpleRefChange(MASTER_REF, RefChangeType.UPDATE);

        when(restrictionService.match(any(RestrictionMatchRequest.class))).thenReturn(
                refRestrictionMap(RefRestrictionType.FAST_FORWARD_ONLY, RefChangeType.UPDATE));

        underTest.onReceive(repository, Lists.newArrayList(updateMaster), hookResponse());

        verify(i18nService).getMessage(REJECT_FAST_FORWARD_ONLY, MASTER_REF);
    }

    @Test
    public void checkNoMatchingRestrictionsAllowsWrite() {
        RefChange updateMaster = simpleRefChange(MASTER_REF, RefChangeType.UPDATE);

        ArgumentCaptor<RestrictionMatchRequest> captor = ArgumentCaptor.forClass(RestrictionMatchRequest.class);
        when(restrictionService.match(captor.capture())).thenReturn(ImmutableMultimap.of());

        underTest.onReceive(repository, Lists.newArrayList(updateMaster), hookResponse(output));

        RestrictionMatchRequest request = captor.getValue();
        assertThat("All restriction types were searched", request.getTypes().isEmpty());
        assertThat("Only the relevant ref was checked",
                request.getRefChanges().stream().allMatch(refChange -> refChange.getRef().getId().equals(MASTER_REF)));
        assertThat("The correct repository was checked", request.getRepository().equals(repository));
        assertThat("There are no errors", output.toString().equals(""));
    }

    @Test
    public void checkInvalidAccessGrantNotAllowsWrite() {
        RefChange updateMaster = simpleRefChange(MASTER_REF, RefChangeType.UPDATE);

        when(restrictionService.match(any(RestrictionMatchRequest.class)))
                .thenReturn(refRestrictionMap(RefRestrictionType.READ_ONLY, RefChangeType.UPDATE));

        when(i18nService.getMessage(REJECT_READ_ONLY, MASTER_REF))
                .thenReturn("error1");
        when(i18nService.getMessage(CHECK_SETTINGS_ADMINISTRATOR))
                .thenReturn("check with admin");

        underTest.onReceive(repository, Lists.newArrayList(updateMaster), hookResponse(output));

        assertThat(output.toString(), allOf(containsString("error1"), containsString("check with admin")));
    }

    @Test
    public void checkCallsMatchWithCorrectArguments() {
        MergeRequest mergeRequest = createMergeRequest();

        underTest.check(mergeRequest);

        ArgumentCaptor<RestrictionMatchRequest> captor = ArgumentCaptor.forClass(RestrictionMatchRequest.class);

        verify(restrictionService).match(captor.capture());

        assertThat(captor.getValue().getRepository().getId(), is(equalTo(REPOSITORY_ID)));
        assertThat(captor.getValue().getRefChanges().iterator().next().getType(), is(equalTo(RefChangeType.UPDATE)));
        assertThat(captor.getValue().getRefChanges().iterator().next().getRef().getId(), is(equalTo(MASTER_REF)));
    }

    @Test
    public void correctErrorMessageGeneratedWhenRestrictionServiceDeniesWrite() {
        MergeRequest mergeRequest = createMergeRequest();
        when(restrictionService.match(any(RestrictionMatchRequest.class)))
                .thenReturn(refRestrictionMap(RefRestrictionType.READ_ONLY, RefChangeType.UPDATE));

        underTest.check(mergeRequest);

        verify(i18nService).getMessage(MERGE_CHECK_SUMMARY);
        verify(i18nService).getMessage(MERGE_CHECK, MASTER);
    }

    @Test
    public void checkPullRequestVetoedIfTargetIsReadOnly() {
        MergeRequest mergeRequest = createMergeRequest();
        when(i18nService.getMessage(MERGE_CHECK_SUMMARY))
                .thenReturn("SUMMARY_ERROR");
        when(i18nService.getMessage(MERGE_CHECK, MASTER))
                .thenReturn("FULL_ERROR master");
        when(restrictionService.match(any(RestrictionMatchRequest.class)))
                .thenReturn(refRestrictionMap(RefRestrictionType.READ_ONLY, RefChangeType.UPDATE));

        underTest.check(mergeRequest);

        verify(mergeRequest).veto("SUMMARY_ERROR", "FULL_ERROR master");
    }

    @Test
    public void checkDoesNotVetoRequestIfRestrictionServiceAllowsWrite() {
        MergeRequest mergeRequest = createMergeRequest();
        when(restrictionService.match(any(RestrictionMatchRequest.class))).thenReturn(ArrayListMultimap.create());

        underTest.check(mergeRequest);

        verify(mergeRequest, never()).veto(anyString(), anyString());
    }

    @Test
    public void branchCreationEventChecksWithRestrictionServiceIfBranchCanWritten() {
        Branch branch = createBranch();

        underTest.onBranchCreationRequested(newBranchCreationRequestedEvent(branch));

        assertHasPermissionCalledWith(RefAccessType.CREATE, branch);
    }

    @Test
    public void branchCreationEventGeneratesCorrectErrorMessagesWhenDenied() {
        Branch branch = createBranch();
        when(restrictionService.hasPermission(any(RefAccessRequest.class))).thenReturn(false);

        underTest.onBranchCreationRequested(newBranchCreationRequestedEvent(branch));

        verify(i18nService).createKeyedMessage(CANCEL_READ_ONLY, MASTER);
    }

    @Test
    public void branchCreationEventCancelledWhenDenied() {
        Branch branch = createBranch();
        when(restrictionService.hasPermission(any(RefAccessRequest.class))).thenReturn(false);
        when(i18nService.createKeyedMessage(CANCEL_READ_ONLY, MASTER))
                .thenReturn(KEYED_MESSAGE);
        BranchCreationRequestedEvent branchCreationRequestedEvent = spy(newBranchCreationRequestedEvent(branch));
        underTest.onBranchCreationRequested(branchCreationRequestedEvent);

        verify(branchCreationRequestedEvent).cancel(KEYED_MESSAGE);
    }

    @Test
    public void branchCreationEventNotCancelledWhenCreationOk() {
        Branch branch = createBranch();

        BranchCreationRequestedEvent branchCreationRequestedEvent = spy(newBranchCreationRequestedEvent(branch));
        when(restrictionService.hasPermission(any(RefAccessRequest.class))).thenReturn(true);

        underTest.onBranchCreationRequested(branchCreationRequestedEvent);

        verify(branchCreationRequestedEvent, never()).cancel(any(KeyedMessage.class));
    }

    @Test
    public void branchDeletionEventChecksWithRestrictionServiceIfBranchCanBeDeleted() {
        Branch branch = createBranch();
        underTest.onBranchDeletionRequested(newBranchDeletionRequestedEvent(branch));

        assertHasPermissionCalledWith(RefAccessType.DELETE, branch);
    }

    @Test
    public void branchDeletionEventGeneratesCorrectErrorMessagesWhenDenied() {
        Branch branch = createBranch();
        when(restrictionService.hasPermission(any(RefAccessRequest.class))).thenReturn(false);

        underTest.onBranchDeletionRequested(newBranchDeletionRequestedEvent(branch));

        verify(i18nService).createKeyedMessage(REJECT_NO_DELETE, MASTER);
    }

    @Test
    public void branchDeletionEventCancelledWhenDenied() {
        Branch branch = createBranch();
        when(restrictionService.hasPermission(any(RefAccessRequest.class))).thenReturn(false);
        when(i18nService.createKeyedMessage(REJECT_NO_DELETE, MASTER))
                .thenReturn(KEYED_MESSAGE);
        BranchDeletionRequestedEvent branchDeletionRequestedEvent = spy(newBranchDeletionRequestedEvent(branch));

        underTest.onBranchDeletionRequested(branchDeletionRequestedEvent);

        verify(branchDeletionRequestedEvent).cancel(KEYED_MESSAGE);
    }

    @Test
    public void branchDeletionEventNotCancelledWhenDeletionOk() {
        Branch branch = createBranch();
        when(restrictionService.hasPermission(any(RefAccessRequest.class))).thenReturn(true);
        BranchDeletionRequestedEvent branchDeletionRequestedEvent = spy(newBranchDeletionRequestedEvent(branch));

        underTest.onBranchDeletionRequested(branchDeletionRequestedEvent);

        verify(branchDeletionRequestedEvent, never()).cancel(any());
    }

    @Test
    public void tagCreationEventChecksWithRestrictionServiceIfTagCanWritten() {
        Tag tag = createTag();

        underTest.onTagCreationRequested(newTagCreationRequestedEvent(tag));

        assertHasPermissionCalledWith(RefAccessType.CREATE, tag);
    }

    @Test
    public void tagCreationEventGeneratesCorrectErrorMessagesWhenDenied() {
        Tag tag = createTag();
        when(restrictionService.hasPermission(any(RefAccessRequest.class))).thenReturn(false);

        underTest.onTagCreationRequested(newTagCreationRequestedEvent(tag));

        verify(i18nService).createKeyedMessage(CANCEL_READ_ONLY, RELEASE_TAG);
    }

    @Test
    public void tagCreationEventCancelledWhenDenied() {
        Tag tag = createTag();
        when(restrictionService.hasPermission(any(RefAccessRequest.class))).thenReturn(false);
        when(i18nService.createKeyedMessage(CANCEL_READ_ONLY, RELEASE_TAG))
                .thenReturn(KEYED_MESSAGE);
        TagCreationRequestedEvent tagCreationRequestedEvent = spy(newTagCreationRequestedEvent(tag));
        underTest.onTagCreationRequested(tagCreationRequestedEvent);

        verify(tagCreationRequestedEvent).cancel(KEYED_MESSAGE);
    }

    @Test
    public void tagCreationEventNotCancelledWhenCreationOk() {
        Tag tag = createTag();

        TagCreationRequestedEvent tagCreationRequestedEvent = spy(newTagCreationRequestedEvent(tag));
        when(restrictionService.hasPermission(any(RefAccessRequest.class))).thenReturn(true);

        underTest.onTagCreationRequested(tagCreationRequestedEvent);

        verify(tagCreationRequestedEvent, never()).cancel(any(KeyedMessage.class));
    }

    @Test
    public void tagDeletionEventChecksWithRestrictionServiceIfTagCanBeDeleted() {
        Tag tag = createTag();
        underTest.onTagDeletionRequested(newTagDeletionRequestedEvent(tag));

        assertHasPermissionCalledWith(RefAccessType.DELETE, tag);
    }

    @Test
    public void tagDeletionEventGeneratesCorrectErrorMessagesWhenDenied() {
        Tag tag = createTag();
        when(restrictionService.hasPermission(any(RefAccessRequest.class))).thenReturn(false);

        underTest.onTagDeletionRequested(newTagDeletionRequestedEvent(tag));

        verify(i18nService).createKeyedMessage(REJECT_NO_DELETE, RELEASE_TAG);
    }

    @Test
    public void tagDeletionEventCancelledWhenDenied() {
        Tag tag = createTag();
        when(restrictionService.hasPermission(any(RefAccessRequest.class))).thenReturn(false);
        when(i18nService.createKeyedMessage(REJECT_NO_DELETE, RELEASE_TAG))
                .thenReturn(KEYED_MESSAGE);
        TagDeletionRequestedEvent tagDeletionRequestedEvent = spy(newTagDeletionRequestedEvent(tag));

        underTest.onTagDeletionRequested(tagDeletionRequestedEvent);

        verify(tagDeletionRequestedEvent).cancel(KEYED_MESSAGE);
    }

    @Test
    public void tagDeletionEventNotCancelledWhenDeletionOk() {
        Tag tag = createTag();
        when(restrictionService.hasPermission(any(RefAccessRequest.class))).thenReturn(true);
        TagDeletionRequestedEvent tagDeletionRequestedEvent = spy(newTagDeletionRequestedEvent(tag));

        underTest.onTagDeletionRequested(tagDeletionRequestedEvent);

        verify(tagDeletionRequestedEvent, never()).cancel(any());
    }
    
    private void assertHasPermissionCalledWith(RefAccessType type, MinimalRef ref) {
        ArgumentCaptor<RefAccessRequest> captor = ArgumentCaptor.forClass(RefAccessRequest.class);

        verify(restrictionService).hasPermission(captor.capture());

        assertThat(captor.getValue().getAccessType(), is(equalTo(type)));
        assertThat(captor.getValue().getRefs().iterator().next(), is(equalTo(ref)));
        assertThat(captor.getValue().getRepository(), is(equalTo(repository)));
    }

    private Branch createBranch() {
        Branch branch = mock(Branch.class);
        when(branch.getId()).thenReturn(MASTER_REF);
        when(branch.getDisplayId()).thenReturn(MASTER);
        return branch;
    }

    private MergeRequest createMergeRequest() {
        MergeRequest mergeRequest = mock(MergeRequest.class);
        PullRequest pullRequest = mock(PullRequest.class);
        PullRequestRef fromRef = mock(PullRequestRef.class);
        PullRequestRef toRef = mock(PullRequestRef.class);

        when(mergeRequest.getPullRequest()).thenReturn(pullRequest);
        when(pullRequest.getFromRef()).thenReturn(fromRef);
        when(pullRequest.getToRef()).thenReturn(toRef);
        when(fromRef.getRepository()).thenReturn(repository);
        when(fromRef.getId()).thenReturn("refs/heads/feature/XYZ");
        when(fromRef.getDisplayId()).thenReturn("feature/XYZ");
        when(fromRef.getLatestCommit()).thenReturn(FROM_HASH);
        when(toRef.getRepository()).thenReturn(repository);
        when(toRef.getId()).thenReturn(MASTER_REF);
        when(toRef.getDisplayId()).thenReturn(MASTER);
        when(toRef.getLatestCommit()).thenReturn(TO_HASH);
        return mergeRequest;
    }

    private Tag createTag() {
        Tag tag = mock(Tag.class);
        when(tag.getId()).thenReturn(RELEASE_TAG);
        when(tag.getDisplayId()).thenReturn(RELEASE_TAG);
        return tag;
    }

    private HookResponse hookResponse() {
        return hookResponse(new StringWriter());
    }

    private HookResponse hookResponse(final StringWriter stringWriter) {
        return new HookResponse() {
            @Nonnull
            @Override
            public PrintWriter err() {
                return new PrintWriter(new StringWriter());
            }

            @Nonnull
            @Override
            public PrintWriter out() {
                return new PrintWriter(stringWriter);
            }
        };
    }

    private static ApplicationUser mockUser(int id) {
        ApplicationUser user = mock(ApplicationUser.class);
        when(user.getId()).thenReturn(id);
        when(user.getName()).thenReturn("user");
        when(user.getType()).thenReturn(UserType.NORMAL);

        return user;
    }

    private BranchCreationRequestedEvent newBranchCreationRequestedEvent(Branch branch) {
        return new BranchCreationRequestedEvent(this, repository, branch, mock(CancelState.class));
    }

    private BranchDeletionRequestedEvent newBranchDeletionRequestedEvent(Branch branch) {
        return new BranchDeletionRequestedEvent(this, repository, branch, mock(CancelState.class));
    }

    private TagCreationRequestedEvent newTagCreationRequestedEvent(Tag tag) {
        return new TagCreationRequestedEvent(this, repository, tag, mock(CancelState.class));
    }

    private TagDeletionRequestedEvent newTagDeletionRequestedEvent(Tag tag) {
        return new TagDeletionRequestedEvent(this, repository, tag, mock(CancelState.class));
    }

    private Multimap<RefChange, RefRestriction> refRestrictionMap(RefRestrictionType restrictionType,
                                                                  RefChangeType changeType) {
        Map<RefRestrictionType, List<AccessGrant>> restrictionSpec = new HashMap<>();
        restrictionSpec.put(restrictionType, Collections.<AccessGrant>emptyList());
        return refRestrictionMap(restrictionSpec, changeType);
    }

    private Multimap<RefChange, RefRestriction> refRestrictionMap(Map<RefRestrictionType, List<AccessGrant>> restrictionSpec,
                                                                  RefChangeType changeType) {
        MinimalRef ref = new SimpleMinimalRef.Builder()
                .id(MASTER_REF)
                .displayId(MASTER)
                .type(StandardRefType.BRANCH)
                .build();
        RefChange refChange = new SimpleRefChange.Builder()
                .ref(ref)
                .fromHash(FROM_HASH)
                .toHash(TO_HASH)
                .type(changeType)
                .build();
        Multimap<RefChange, RefRestriction> simpleRefRestrictionMap = ArrayListMultimap.create();
        for (RefRestrictionType restrictionType : restrictionSpec.keySet()) {
            RefRestriction refRestriction = refRestriction(new BranchMatcher(MASTER_REF, false, mock(Supplier.class)),
                    restrictionType, restrictionSpec.get(restrictionType));
            simpleRefRestrictionMap.put(refChange, refRestriction);
        }
        return simpleRefRestrictionMap;
    }

    private SimpleRefRestriction refRestriction(RefMatcher matcher, RefRestrictionType restrictionType, List<AccessGrant> accessGrants) {
        return new SimpleRefRestriction(1, 1, matcher, restrictionType, accessGrants);
    }

    private SimpleRefChange simpleRefChange(String value, RefChangeType changeType) {
        return new SimpleRefChange.Builder()
                .ref(new SimpleRef(value))
                .fromHash("abc")
                .toHash("xyz")
                .type(changeType).build();
    }

    private TestApplicationUser user() {
        return new TestApplicationUser("test");
    }
}