package com.atlassian.bitbucket.mesh.git.ref;

import com.atlassian.bitbucket.mesh.AbstractWiredExecutionContextTest;
import com.atlassian.bitbucket.mesh.git.GitAgent;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.updateref.DeleteUpdateRefCompletionHandler;
import com.atlassian.bitbucket.mesh.git.exception.InvalidRefNameException;
import com.atlassian.bitbucket.mesh.git.exception.NonFastForwardUpdateException;
import com.atlassian.bitbucket.mesh.git.exception.RefNotFoundException;
import com.atlassian.bitbucket.mesh.junit.LogListener;
import com.atlassian.bitbucket.mesh.repository.RefsUpdatedEvent;
import com.atlassian.bitbucket.mesh.repository.Repository;
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.ImmutableList;
import org.junit.Rule;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;

import javax.annotation.Resource;
import java.util.List;
import java.util.Map;

import static com.atlassian.bitbucket.mesh.git.GitRefPattern.HEADS;
import static com.atlassian.bitbucket.mesh.git.GitRefPattern.TAGS;
import static com.atlassian.bitbucket.mesh.util.ShaUtils.NULL_SHA1;
import static org.apache.commons.lang3.StringUtils.repeat;

public class DefaultRefManagerTest extends AbstractWiredExecutionContextTest {

    private static final Branch BASIC_BRANCHING = branch("basic_branching", "d6edcbf924697ab811a867421dab60d954ccad99");
    private static final Branch BRANCH_MOD_MERGE = branch("branch_mod_merge", "6053a1eaa1c009dd11092d09a72f3c41af1b59ad");
    private static final Branch MASTER = branch("master", "0a943a29376f2336b78312d99e65da17048951db");
    private static final Branch OUT_OF_ORDER = branch("out_of_order_branch", "2e10dd2d1d5eea9291b296e78312e8a703964a95");
    private static final Tag RETAGGED_SIGNED_GIT_TAG = tag("retagged_signed_tag",
            "9b4e781dea0769888fe270e06ad76675f73851b1", "12ebe2a58367347cd39f19f5a72f3cbec7b8f9a9");
    private static final Tag SIGNED_GIT_TAG = tag("signed_tag",
            "12ebe2a58367347cd39f19f5a72f3cbec7b8f9a9", "0a943a29376f2336b78312d99e65da17048951db");

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

    @Resource
    @Rule
    public ExpectedEvents expectedEvents;

    @Autowired
    private GitAgent agent;
    @Autowired
    private GitProcessBuilderFactory builderFactory;
    @Autowired
    private RefManager refManager;
    @Repo
    private Repository repository;

    @Test
    public void testCheckFormat() {
        refManager.checkFormat("master");
    }

    @Test(expected = InvalidRefNameException.class)
    public void testCheckFormatTooLong() {
        refManager.checkFormat(repeat("a", 112));
    }

    @Test(expected = InvalidRefNameException.class)
    public void testCheckFormatWithBlankId() {
        refManager.checkFormat("");
    }

    @Test(expected = InvalidRefNameException.class)
    public void testCheckFormatWithInvalidId() {
        refManager.checkFormat("-this-is-invalid");
    }

    @Test(expected = InvalidRefNameException.class)
    public void testCheckFormatWithWhitespace() {
        refManager.checkFormat("this has whitespace here");
    }

    @Test
    public void testResolveRefsForBranches() {
        Map<String, Ref> refs = refManager.resolveRefs(new ResolveRefsParameters.Builder(repository)
                .branchIds(MASTER.getId(), HEADS.unqualify(BASIC_BRANCHING.getId()), "no-such-branch")
                .build());

        assertEquals(2, refs.size());
        assertEquals(BASIC_BRANCHING, refs.get(HEADS.unqualify(BASIC_BRANCHING.getId())));
        assertEquals(MASTER, refs.get(MASTER.getId()));
    }

    @Test
    public void testResolveRefsForTags() {
        Map<String, Ref> refs = refManager.resolveRefs(new ResolveRefsParameters.Builder(repository)
                .tagIds(SIGNED_GIT_TAG.getId(), TAGS.unqualify(RETAGGED_SIGNED_GIT_TAG.getId()), "no-such-tag")
                .build());

        assertEquals(2, refs.size());
        assertEquals(SIGNED_GIT_TAG, refs.get(SIGNED_GIT_TAG.getId()));
        assertEquals(RETAGGED_SIGNED_GIT_TAG, refs.get(TAGS.unqualify(RETAGGED_SIGNED_GIT_TAG.getId())));
    }

    @Test
    public void testResolveRefsWithMixedTypes() {
        Map<String, Ref> refs = refManager.resolveRefs(new ResolveRefsParameters.Builder(repository)
                .branchIds(MASTER.getId(), HEADS.unqualify(HEADS.unqualify(BASIC_BRANCHING.getId())))
                .branchId("no-such-branch")
                .tagIds(SIGNED_GIT_TAG.getId(), TAGS.unqualify(RETAGGED_SIGNED_GIT_TAG.getId()))
                .tagId("no-such-tag")
                .refIds(HEADS.unqualify(OUT_OF_ORDER.getId()))
                .refId("no-such-ref")
                .build());

        assertEquals(5, refs.size());
        assertEquals(BASIC_BRANCHING, refs.get(HEADS.unqualify(HEADS.unqualify(BASIC_BRANCHING.getId()))));
        assertEquals(MASTER, refs.get(MASTER.getId()));
        assertEquals(OUT_OF_ORDER, refs.get(HEADS.unqualify(OUT_OF_ORDER.getId())));
        assertEquals(RETAGGED_SIGNED_GIT_TAG, refs.get(TAGS.unqualify(RETAGGED_SIGNED_GIT_TAG.getId())));
        assertEquals(SIGNED_GIT_TAG, refs.get(SIGNED_GIT_TAG.getId()));
    }

    @DirtiesRepo
    @Test
    public void testResolveRefsWithShas() {
        // create two branches:
        // - firstBranch (000aaa) which should be returned first because of alpha ordering
        // - problemBranch (57150c54c3) whose branch-name matches the hash of firstBranch
        // when we try to resolve "57150c54c3", we should get problemBranch and _not_ firstBranch
        Branch firstBranch = branch("000aaa", "57150c54c38d6570b2fd5e6d6cfc19476de44e84");
        Branch problemBranch = branch(firstBranch.getLatestCommit().substring(0, 10), "e26cab95a50f6e99d2c46c498a23cee307cc8e27");
        createBranch(firstBranch);
        createBranch(problemBranch);

        Map<String, Ref> refs = refManager.resolveRefs(new ResolveRefsParameters.Builder(repository)
                .branchIds("heads/master", HEADS.unqualify(BASIC_BRANCHING.getId()))
                .branchId("no-such-branch")
                .tagIds(SIGNED_GIT_TAG.getId(), RETAGGED_SIGNED_GIT_TAG.getId())
                .tagId("no-such-tag")
                .refIds(OUT_OF_ORDER.getId())
                .refId("no-such-ref")
                .refIds("6053a1eaa", problemBranch.getId())
                .build());

        assertEquals(BASIC_BRANCHING, refs.get(HEADS.unqualify(BASIC_BRANCHING.getId())));
        assertEquals(MASTER, refs.get("heads/master"));
        assertEquals(RETAGGED_SIGNED_GIT_TAG, refs.get(RETAGGED_SIGNED_GIT_TAG.getId()));
        assertEquals(OUT_OF_ORDER, refs.get(OUT_OF_ORDER.getId()));
        assertEquals(SIGNED_GIT_TAG, refs.get(SIGNED_GIT_TAG.getId()));
        assertEquals(BRANCH_MOD_MERGE, refs.get("6053a1eaa"));
        // problem branch should be returned _instead_ of firstBranch
        assertEquals(problemBranch, refs.get(problemBranch.getId()));
        assertEquals(7, refs.size());

    }

    //We do permutation testing for all the different update behaviors using RefManager.updateRef,
    //and then we do lighter, targeted testing for RefManager.updateRefs

    @DirtiesRepo
    @Test
    public void testUpdateRefForAdd() {
        expectedEvents.expect(RefsUpdatedEvent.class, 1);

        //null oldHash instead of NULL_SHA1
        ProposedRefUpdate add = ProposedRefUpdate.of("refs/heads/added", null,
                "0a943a29376f2336b78312d99e65da17048951db");

        Branch added = applyUpdate(add);
        assertNotNull(added);
        assertEquals(add.getNewHash(), added.getLatestCommit());
    }

    @DirtiesRepo
    @Test
    public void testUpdateRefForConditionalDelete() {
        expectedEvents.expect(RefsUpdatedEvent.class, 1);

        //null newHash instead of NULL_SHA1
        ProposedRefUpdate delete = ProposedRefUpdate.of("refs/heads/basic_branching",
                "d6edcbf924697ab811a867421dab60d954ccad99", null);

        Branch deleted = applyUpdate(delete);
        assertNull(deleted);
    }

    @DirtiesRepo
    @Test
    public void testUpdateRefForConditionalDeleteThrowsOnNonexistent() {
        expectedEvents.expect(RefsUpdatedEvent.class, 0);

        RefNotFoundException e = assertThrows(RefNotFoundException.class, () -> {
            //git update-ref only fails deleting nonexistent refs if an explicit oldHash
            //_other than NULL_SHA1_ is supplied. Otherwise, it returns success
            ProposedRefUpdate delete = ProposedRefUpdate.toDelete("refs/heads/nonexistent",
                    "0a943a29376f2336b78312d99e65da17048951db");

            refManager.updateRef(new UpdateRefParameters.Builder(repository, delete).build());
        });
        assertEquals("refs/heads/nonexistent", e.getRefName());
        logListener.assertWarn("unable to resolve reference 'refs/heads/nonexistent'");
    }

    @DirtiesRepo
    @Test
    public void testUpdateRefForConditionalExplicitDelete() {
        expectedEvents.expect(RefsUpdatedEvent.class, 1);

        //This applies an explicit NULL_SHA1 as the new hash
        ProposedRefUpdate delete = ProposedRefUpdate.toDelete("refs/heads/basic_branching",
                "d6edcbf924697ab811a867421dab60d954ccad99");

        Branch deleted = applyUpdate(delete);
        assertNull(deleted);
    }

    @DirtiesRepo
    @Test
    public void testUpdateRefForConditionalUpdate() {
        expectedEvents.expect(RefsUpdatedEvent.class, 1);

        //Both hashes are explicit, so the update is only applied if the oldHash matches
        ProposedRefUpdate update = ProposedRefUpdate.of("refs/heads/branch_mod_merge",
                "6053a1eaa1c009dd11092d09a72f3c41af1b59ad", "0a943a29376f2336b78312d99e65da17048951db");

        Branch updated = applyUpdate(update);
        assertEquals(update.getNewHash(), updated.getLatestCommit());
    }

    @DirtiesRepo
    @Test
    public void testUpdateRefForDelete() {
        expectedEvents.expect(RefsUpdatedEvent.class, 1);

        //Both hashes are null, which should be applied as an unconditional delete
        ProposedRefUpdate delete = ProposedRefUpdate.of("refs/heads/basic_branching", null, null);

        Branch deleted = applyUpdate(delete);
        assertNull(deleted);
    }

    @DirtiesRepo
    @Test
    public void testUpdateRefForExplicitAdd() {
        expectedEvents.expect(RefsUpdatedEvent.class, 1);

        //This applies an explicit NULL_SHA1 as the old hash
        ProposedRefUpdate add = ProposedRefUpdate.toAdd("refs/heads/added", "0a943a29376f2336b78312d99e65da17048951db");

        Branch added = applyUpdate(add);
        assertNotNull(added);
        assertEquals(add.getNewHash(), added.getLatestCommit());
    }

    @DirtiesRepo
    @Test
    public void testUpdateRefForExplicitDelete() {
        expectedEvents.expect(RefsUpdatedEvent.class, 1);

        //Both hashes are NULL_SHA1, which should be applied as an unconditional delete
        ProposedRefUpdate delete = ProposedRefUpdate.of("refs/heads/basic_branching", NULL_SHA1, NULL_SHA1);

        Branch deleted = applyUpdate(delete);
        assertNull(deleted);
    }

    @DirtiesRepo
    @Test
    public void testUpdateRefForUpdate() {
        expectedEvents.expect(RefsUpdatedEvent.class, 1);

        //null oldHash, should should be applied as an unconditional update
        ProposedRefUpdate update = ProposedRefUpdate.of("refs/heads/branch_mod_merge", null,
                "0a943a29376f2336b78312d99e65da17048951db");

        Branch updated = applyUpdate(update);
        assertEquals(update.getNewHash(), updated.getLatestCommit());
    }

    @DirtiesRepo
    @Test
    public void testUpdateRefsIndividually() {
        expectedEvents.expect(RefsUpdatedEvent.class, 1);

        UpdateRefsParameters parameters = new UpdateRefsParameters.Builder(repository)
                .transactional(false)
                .updates(
                        ProposedRefUpdate.toAdd("refs/heads/added", "0a943a29376f2336b78312d99e65da17048951db"),
                        ProposedRefUpdate.toDelete("refs/heads/basic_branching", "d6edcbf924697ab811a867421dab60d954ccad99"),
                        //This update will fail, but the other updates should still be applied
                        ProposedRefUpdate.of("refs/heads/master",
                                "2e10dd2d1d5eea9291b296e78312e8a703964a95", "0a943a29376f2336b78312d99e65da17048951db"),
                        ProposedRefUpdate.of("refs/heads/branch_mod_merge",
                                "6053a1eaa1c009dd11092d09a72f3c41af1b59ad", "0a943a29376f2336b78312d99e65da17048951db")
                )
                .build();

        List<String> failedRefIds = refManager.updateRefs(parameters);
        assertEquals(ImmutableList.of("refs/heads/master"), failedRefIds);

        Branch added = agent.resolveBranch(repository, "refs/heads/added", true);
        assertNotNull(added);
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", added.getLatestCommit());

        Branch deleted = agent.resolveBranch(repository, "refs/heads/basic_branching", true);
        assertNull(deleted);

        Branch updated = agent.resolveBranch(repository, "refs/heads/branch_mod_merge", true);
        assertNotNull(updated);
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", updated.getLatestCommit());
    }

    @DirtiesRepo
    @Test
    public void testUpdateRefsTransactionally() {
        expectedEvents.expect(RefsUpdatedEvent.class, 1);

        //All of these updates need to pass so they'll be applied. A failed transaction is a separate test
        UpdateRefsParameters parameters = new UpdateRefsParameters.Builder(repository)
                .transactional(true)
                .updates(
                        ProposedRefUpdate.toAdd("refs/heads/added", "0a943a29376f2336b78312d99e65da17048951db"),
                        ProposedRefUpdate.toDelete("refs/heads/basic_branching", "d6edcbf924697ab811a867421dab60d954ccad99"),
                        ProposedRefUpdate.of("refs/heads/branch_mod_merge",
                                "6053a1eaa1c009dd11092d09a72f3c41af1b59ad", "0a943a29376f2336b78312d99e65da17048951db")
                )
                .build();

        List<String> failedRefIds = refManager.updateRefs(parameters);
        assertTrue(failedRefIds.isEmpty());

        Branch added = agent.resolveBranch(repository, "refs/heads/added", true);
        assertNotNull(added);
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", added.getLatestCommit());

        Branch deleted = agent.resolveBranch(repository, "refs/heads/basic_branching", true);
        assertNull(deleted);

        Branch updated = agent.resolveBranch(repository, "refs/heads/branch_mod_merge", true);
        assertNotNull(updated);
        assertEquals("0a943a29376f2336b78312d99e65da17048951db", updated.getLatestCommit());
    }

    @DirtiesRepo
    @Test
    public void testUpdateRefsTransactionallyRollsBackOnFailure() {
        expectedEvents.expect(RefsUpdatedEvent.class, 0);

        assertThrows(NonFastForwardUpdateException.class, () -> {
            //basic_branching should deletable, but master is not at the specified commit and so that deletion
            //should fail. No refs should actually be deleted
            UpdateRefsParameters parameters = new UpdateRefsParameters.Builder(repository)
                    .transactional(true)
                    .updates(
                            ProposedRefUpdate.toDelete("refs/heads/basic_branching", "d6edcbf924697ab811a867421dab60d954ccad99"),
                            ProposedRefUpdate.toDelete("refs/heads/master", "d6edcbf924697ab811a867421dab60d954ccad99")
                    )
                    .build();

            try {
                refManager.updateRefs(parameters);
            } finally {
                //Verify neither branch was actually deleted
                assertNotNull(agent.resolveBranch(repository, "refs/heads/basic_branching", true));
                assertNotNull(agent.resolveBranch(repository, "refs/heads/master", true));
            }
        });
    }

    private static Branch branch(String name, String commitId) {
        return new Branch.Builder()
                .id(HEADS.qualify(name))
                .latestCommit(commitId)
                .build();
    }

    private static Tag tag(String name, String hash, String commitId) {
        return new Tag.Builder()
                .id(TAGS.qualify(name))
                .hash(hash)
                .latestCommit(commitId)
                .build();
    }

    private Branch applyUpdate(ProposedRefUpdate update) {
        refManager.updateRef(new UpdateRefParameters.Builder(repository, update).build());

        return agent.resolveBranch(repository, update.getRefId(), true);
    }

    private void createBranch(Branch branch) {
        builderFactory.builder(repository).updateRef()
                .set(branch.getId(), branch.getLatestCommit())
                .build()
                .run();
    }
}