package com.atlassian.bitbucket.mesh.repository;

import com.atlassian.bitbucket.mesh.config.ReplicationConfig;
import com.atlassian.bitbucket.mesh.git.GitAgent;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.exception.NoDefaultBranchException;
import com.atlassian.bitbucket.mesh.git.ref.RefUpdate;
import com.atlassian.bitbucket.mesh.git.transcode.TranscodeService;
import com.atlassian.bitbucket.mesh.transaction.Transaction;
import com.atlassian.bitbucket.mesh.transaction.TransactionManager;
import com.google.common.collect.ImmutableSet;
import org.apache.commons.lang3.StringUtils;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.MockitoJUnitRunner;

import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;

import static com.atlassian.bitbucket.mesh.repository.RefHasher.NULL_HASH;
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.*;

@RunWith(MockitoJUnitRunner.class)
public class DefaultRepositoryHashServiceTest {

    private static final Pattern PATTERN_HASH = Pattern.compile("[a-fA-F0-9]{64}");
    private static final String TEST_HASH = StringUtils.repeat('a', 64);
    private static final String TEST_HASH2 = StringUtils.repeat('b', 64);

    @Mock
    private GitAgent agent;
    @Mock
    private GitProcessBuilderFactory builderFactory;
    @Mock
    private ReplicationConfig config;
    @Spy
    private DefaultRefHasher refHasher = new DefaultRefHasher(builderFactory);
    @Mock
    private RepositoryAttributeManager repositoryAttributeManager;
    private DefaultRepositoryHashService repositoryHashService;
    @Mock
    private TransactionManager transactionManager;
    @Mock
    private TranscodeService transcodeService;

    public static boolean isHash(String value) {
        return value != null && PATTERN_HASH.matcher(value).matches();
    }

    @Before
    public void setup() {
        repositoryHashService =
                new DefaultRepositoryHashService(agent, refHasher, config, repositoryAttributeManager,
                        transactionManager, transcodeService);
    }

    @Test
    public void testCalculateTransactionHash() {
        Set<RefUpdate> refUpdates = ImmutableSet.of(
                RefUpdate.ofAdd("refs/heads/b", StringUtils.repeat('B', 64)),
                RefUpdate.ofAdd("refs/heads/c", StringUtils.repeat('C', 64))
        );

        String hash = repositoryHashService.calculateTransactionHash(refUpdates);

        // The hash algorithm needs to be stable otherwise compatibility between mesh nodes or different versions
        // will break. If this return value changes, backwards compatibility is broken
        assertEquals("2db54e91823e20b1fb2c6d0e7e6e40a23b73f86d9b498efda77d9022e7d6a283", hash);
    }

    // For a non-ManagedRepository the content hash is always calculated, never cached
    @Test
    public void testCompareAndUpdateContentHashForBitbucketServerRepository() {
        Repository repository = mockRepository(BitbucketServerRepository.class);
        String fullHash = "43808bef856e441826c3515a1f3ba5aa4082b3c5c8b5186c7a4d4c4ea3a88445";
        doReturn(fullHash).when(refHasher).calculateHash(repository);

        assertEquals(fullHash,
                repositoryHashService.compareAndUpdateContentHash(repository, TEST_HASH, "txHash"));

        verifyNoInteractions(repositoryAttributeManager);
    }

    @Test
    public void testCompareAndUpdateContentHashForManagedRepository() {
        Repository repository = mockRepository(ManagedRepository.class);
        String combinedHash = "2db54e91823e20b1fb2c6d0e7e6e40a23b73f86d9b498efda77d9022e7d6a283";
        String txHash = "871fe43b28948a1b5186c7a4d4c4ea0891d952c731e324570dd73a884d7c0829";
        when(repositoryAttributeManager.getContentHash(repository.getId())).thenReturn(TEST_HASH);
        doReturn(combinedHash).when(refHasher).combineHashes(TEST_HASH, txHash);

        assertEquals(combinedHash,
                repositoryHashService.compareAndUpdateContentHash(repository, TEST_HASH, txHash));

        verify(repositoryAttributeManager).setContentHash(repository.getId(), combinedHash);
    }

    @Test
    public void testCompareAndUpdateContentHashForManagedRepositoryDifferentFromHash() {
        Repository repository = mockRepository(ManagedRepository.class);
        String fullHash = "43808bef856e441826c3515a1f3ba5aa4082b3c5c8b5186c7a4d4c4ea3a88445";
        when(repositoryAttributeManager.getContentHash(repository.getId())).thenReturn("different");
        doReturn(fullHash).when(refHasher).calculateHash(repository);

        assertEquals(fullHash,
                repositoryHashService.compareAndUpdateContentHash(repository, TEST_HASH, "txHash"));

        verify(refHasher, never()).combineHashes(any(), any());
        verify(repositoryAttributeManager).setContentHash(repository.getId(), fullHash);
    }

    @Test
    public void testCompareAndUpdateContentHashForManagedRepositoryHandlesException() {
        Repository repository = mockRepository(ManagedRepository.class);
        when(repositoryAttributeManager.getContentHash(repository.getId())).thenThrow(new RuntimeException());

        assertThrows(HashCalculationFailedException.class,
                () -> repositoryHashService.compareAndUpdateContentHash(repository, TEST_HASH, "txHash"));

        verify(repositoryAttributeManager).setContentHash(repository.getId(), null);
    }

    @Test
    public void testGetContentHashCached() {
        Repository repository = mockRepository(ManagedRepository.class);
        when(repositoryAttributeManager.getContentHash(repository.getId())).thenReturn(TEST_HASH);

        assertEquals(TEST_HASH, repositoryHashService.getContentHash(repository));

        verifyNoInteractions(refHasher);
        verify(repositoryAttributeManager, never()).setContentHash(any(), any());
    }

    // For a non-ManagedRepository the content hash is always calculated, never cached
    @Test
    public void testGetContentHashForNonManagedRepository() {
        Repository repository = mockRepository(BitbucketServerRepository.class);
        when(refHasher.calculateHash(repository)).thenReturn(TEST_HASH);

        assertEquals(TEST_HASH, repositoryHashService.getContentHash(repository));

        verifyNoInteractions(repositoryAttributeManager);
        verify(repositoryAttributeManager, never()).setContentHash(any(), any());
    }

    @Test
    public void testGetContentHashNotCached() {
        Repository repository = mockRepository(ManagedRepository.class);
        when(refHasher.calculateHash(repository)).thenReturn(TEST_HASH);
        when(repositoryAttributeManager.getContentHash(repository.getId())).thenReturn(null);

        assertEquals(TEST_HASH, repositoryHashService.getContentHash(repository));

        verify(repositoryAttributeManager).setContentHash(repository.getId(), TEST_HASH);
    }

    @Test
    public void testGetContentHashNotCachedHandlesException() {
        Repository repository = mockRepository(ManagedRepository.class);
        when(repositoryAttributeManager.getContentHash(repository.getId()))
                .thenReturn(null)
                .thenThrow(new RuntimeException());

        assertThrows(HashCalculationFailedException.class, () -> repositoryHashService.getContentHash(repository));

        verifyNoInteractions(refHasher);
        verify(repositoryAttributeManager, never()).setContentHash(any(), any());
    }

    @Test
    public void testGetContentHashNotCachedThenCached() {
        Repository repository = mockRepository(ManagedRepository.class);
        when(repositoryAttributeManager.getContentHash(repository.getId()))
                //First access no cached value, then wait for lock
                .thenReturn(null)
                //Inside lock, second access gives a value (i.e. updated elsewhere while waiting)
                .thenReturn(TEST_HASH);

        assertEquals(TEST_HASH, repositoryHashService.getContentHash(repository));

        verifyNoInteractions(refHasher);
        verify(repositoryAttributeManager, never()).setContentHash(any(), any());
    }

    @Test
    public void testGetMetadataHashCached() {
        Repository repository = mockRepository(ManagedRepository.class);
        when(repositoryAttributeManager.getMetadataHash(repository.getId())).thenReturn(TEST_HASH);

        assertEquals(TEST_HASH, repositoryHashService.getMetadataHash(repository));

        verifyNoInteractions(agent, transcodeService);
    }

    // For a non-ManagedRepository the metadata hash is always calculated, never cached
    @Test
    public void testGetMetadataHashForNonManagedRepository() {
        Repository repository = mockRepository(BitbucketServerRepository.class);
        when(agent.getHead(same(repository))).thenReturn("master");
        when(transcodeService.isEnabled(same(repository))).thenReturn(true);

        assertTrue(isHash(repositoryHashService.getMetadataHash(repository)));

        verify(agent).getHead(same(repository));
        verify(transcodeService).isEnabled(same(repository));
        verifyNoInteractions(repositoryAttributeManager);
    }

    @Test
    public void testGetMetadataHashHandlesException() {
        Repository repository = mockRepository(ManagedRepository.class);
        when(agent.getHead(same(repository))).thenThrow(RuntimeException.class);
        when(transcodeService.isEnabled(same(repository))).thenReturn(true);

        assertTrue(isHash(repositoryHashService.getMetadataHash(repository)));

        verify(repositoryAttributeManager).setMetadataHash(eq(repository.getId()), any());
    }

    @Test
    public void testGetMetadataHashHandlesNoDefaultBranchException() {
        Repository repository = mockRepository(ManagedRepository.class);
        when(agent.getHead(same(repository))).thenThrow(NoDefaultBranchException.class);
        when(transcodeService.isEnabled(same(repository))).thenReturn(true);

        assertTrue(isHash(repositoryHashService.getMetadataHash(repository)));

        verify(repositoryAttributeManager).setMetadataHash(eq(repository.getId()), any());
    }

    @Test
    public void testGetMetadataHashIncorporatesDefaultBranch() {
        Repository repository = mockRepository(ManagedRepository.class);
        when(agent.getHead(same(repository))).thenReturn("a", "b");
        when(transcodeService.isEnabled(same(repository))).thenReturn(true);

        String a = repositoryHashService.getMetadataHash(repository);
        String b = repositoryHashService.getMetadataHash(repository);
        assertNotEquals(a, b);
    }

    @Test
    public void testGetMetadataHashIncorporatesTranscodeSetting() {
        Repository repository = mockRepository(ManagedRepository.class);
        when(agent.getHead(same(repository))).thenReturn("master");
        when(transcodeService.isEnabled(same(repository))).thenReturn(true, false);

        String transcodeTrue = repositoryHashService.getMetadataHash(repository);
        String transcodeFalse = repositoryHashService.getMetadataHash(repository);
        assertNotEquals(transcodeTrue, transcodeFalse);
    }

    @Test
    public void testGetMetadataHashNotCached() {
        Repository repository = mockRepository(ManagedRepository.class);
        when(agent.getHead(same(repository))).thenReturn("master");
        when(transcodeService.isEnabled(same(repository))).thenReturn(true);

        String hash = repositoryHashService.getMetadataHash(repository);

        // The hash algorithm needs to be stable otherwise compatibility between mesh nodes or different versions
        // will break. If this return value changes, backwards compatibility is broken
        assertEquals("9fd0c1e0da12b986cc65a750529fe73cd2cda025ec676cee580bf65b30491a38", hash);
        verify(repositoryAttributeManager).getMetadataHash(eq(repository.getId()));
        verify(repositoryAttributeManager).setMetadataHash(eq(repository.getId()), eq(hash));
    }

    @Test
    public void testMaybeRecalculateContentHashNoDiscrepancyDoesntUpdateStoredContentHash() {
        Repository repository = mockRepository(ManagedRepository.class);
        when(repositoryAttributeManager.getContentHash(repository.getId())).thenReturn(TEST_HASH);
        when(config.getContentHashForcedRecalculationFrequency()).thenReturn(1d);
        Transaction transaction = mockTransaction();
        when(transactionManager.getCurrentTransaction()).thenReturn(Optional.of(transaction));
        when(refHasher.calculateHash(repository)).thenReturn(TEST_HASH);

        assertTrue(repositoryHashService.maybeRecalculateContentHash(repository));

        verify(refHasher).calculateHash(repository);
        verify(repositoryAttributeManager, never()).setContentHash(any(), any());
    }

    @Test
    public void testMaybeRecalculateContentHashReturnsFalseIfFrequencyIsZero() {
        Repository repository = mockRepository(ManagedRepository.class);
        when(config.getContentHashForcedRecalculationFrequency()).thenReturn(0d);

        assertFalse(repositoryHashService.maybeRecalculateContentHash(repository));

        verifyNoInteractions(refHasher);
        verify(repositoryAttributeManager, never()).setContentHash(any(), any());
    }

    @Test
    public void testMaybeRecalculateContentHashWithDiscrepancyUpdatesStoredContentHash() {
        Repository repository = mockRepository(ManagedRepository.class);
        when(repositoryAttributeManager.getContentHash(repository.getId())).thenReturn(TEST_HASH);
        when(config.getContentHashForcedRecalculationFrequency()).thenReturn(1d);
        Transaction transaction = mockTransaction();
        when(transactionManager.getCurrentTransaction()).thenReturn(Optional.of(transaction));
        when(refHasher.calculateHash(repository)).thenReturn(TEST_HASH2);

        assertTrue(repositoryHashService.maybeRecalculateContentHash(repository));

        verify(refHasher).calculateHash(repository);
        verify(repositoryAttributeManager).setContentHash(repository.getId(), TEST_HASH2);
    }

    @Test
    public void testMaybeRecalculateContentHashWithNullHashAborts() {
        Repository repository = mockRepository(ManagedRepository.class);
        when(repositoryAttributeManager.getContentHash(repository.getId())).thenReturn(null);
        when(config.getContentHashForcedRecalculationFrequency()).thenReturn(1d);
        Transaction transaction = mockTransaction();
        when(transactionManager.getCurrentTransaction()).thenReturn(Optional.of(transaction));

        assertFalse(repositoryHashService.maybeRecalculateContentHash(repository));

        verify(refHasher, never()).calculateHash(repository);
        verify(repositoryAttributeManager, never()).setContentHash(any(), any());
    }

    @Test
    public void testOnRepositoryCreated() {
        Repository repository = mockRepository(ManagedRepository.class);
        RepositoryCreatedEvent event = mock(RepositoryCreatedEvent.class);
        when(event.getRepository()).thenReturn(repository);

        repositoryHashService.onRepositoryCreated(event);

        verify(repositoryAttributeManager).setContentHash(eq(repository.getId()), eq(NULL_HASH));
    }

    @Test
    public void testOnRepositoryCreatedFork() {
        Repository repository = mockRepository(ManagedRepository.class);
        RepositoryCreatedEvent event = mock(ForkCreatedEvent.class);
        when(event.getRepository()).thenReturn(repository);

        repositoryHashService.onRepositoryCreated(event);

        verifyNoInteractions(repositoryAttributeManager);
    }

    // For a non-ManagedRepository the metadata hash is always calculated, never cached, so no need to set a hash
    // explicitly
    @Test
    public void testOnRepositoryCreatedNonManagedRepository() {
        Repository repository = mockRepository(BitbucketServerRepository.class);
        RepositoryCreatedEvent event = mock(RepositoryCreatedEvent.class);
        when(event.getRepository()).thenReturn(repository);

        repositoryHashService.onRepositoryCreated(event);

        verifyNoInteractions(repositoryAttributeManager);
    }

    @Test
    public void testOnRepositoryDefaultBranchChanged() {
        Repository repository = mockRepository(ManagedRepository.class);
        DefaultBranchChangedEvent event = mock(DefaultBranchChangedEvent.class);
        when(event.getRepository()).thenReturn(repository);

        repositoryHashService.onRepositoryDefaultBranchChangedEvent(event);

        verify(repositoryAttributeManager).setMetadataHash(eq(repository.getId()), isNull());
    }

    // For a non-ManagedRepository the metadata hash is always calculated, never cached, so no need to invalidate
    // the entry in the database
    @Test
    public void testOnRepositoryDefaultBranchChangedNonManagedRepository() {
        Repository repository = mockRepository(BitbucketServerRepository.class);
        DefaultBranchChangedEvent event = mock(DefaultBranchChangedEvent.class);
        when(event.getRepository()).thenReturn(repository);

        repositoryHashService.onRepositoryDefaultBranchChangedEvent(event);

        verifyNoInteractions(repositoryAttributeManager);
    }

    @Test
    public void testOnTranscodeDiffSettingChanged() {
        Repository repository = mockRepository(ManagedRepository.class);
        TranscodeDiffSettingChangedEvent event = mock(TranscodeDiffSettingChangedEvent.class);
        when(event.getRepository()).thenReturn(repository);

        repositoryHashService.onTranscodeDiffSettingChanged(event);

        verify(repositoryAttributeManager).setMetadataHash(eq(repository.getId()), isNull());
    }

    // For a non-ManagedRepository the metadata hash is always calculated, never cached, so no need to invalidate
    // the entry in the database
    @Test
    public void testOnTranscodeDiffSettingChangedNonManagedRepository() {
        Repository repository = mockRepository(BitbucketServerRepository.class);
        TranscodeDiffSettingChangedEvent event = mock(TranscodeDiffSettingChangedEvent.class);
        when(event.getRepository()).thenReturn(repository);

        repositoryHashService.onTranscodeDiffSettingChanged(event);

        verifyNoInteractions(repositoryAttributeManager);
    }

    @Test
    public void testResetContentHash() {
        Repository repository = mockRepository(ManagedRepository.class);

        repositoryHashService.resetContentHash(repository);

        verify(repositoryAttributeManager).setContentHash(eq(repository.getId()), isNull());
        verify(repositoryAttributeManager, never()).setMetadataHash(any(), any());
    }

    @Test
    public void testResetMetadataHash() {
        Repository repository = mockRepository(ManagedRepository.class);

        repositoryHashService.resetMetadataHash(repository);

        verify(repositoryAttributeManager).setMetadataHash(eq(repository.getId()), isNull());
        verify(repositoryAttributeManager, never()).setContentHash(any(), any());
    }

    private static Repository mockRepository(Class<? extends Repository> repositoryClass) {
        Repository repository = mock(repositoryClass);
        when(repository.getId()).thenReturn("p/0000/h/abc123abc123abc123ab/r/0001112223");
        return repository;
    }

    private static Transaction mockTransaction() {
        return mock(Transaction.class);
    }
}