package com.atlassian.bitbucket.mesh.repository;

import com.atlassian.bitbucket.mesh.config.ReplicationConfig;
import com.atlassian.bitbucket.mesh.event.EventListener;
import com.atlassian.bitbucket.mesh.git.GitAgent;
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.TransactionManager;
import com.atlassian.bitbucket.mesh.util.SerializedOperation;
import org.apache.commons.codec.binary.Hex;
import org.apache.commons.codec.digest.DigestUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.security.MessageDigest;
import java.util.Objects;
import java.util.Set;

import static com.atlassian.bitbucket.mesh.repository.RefHasher.NULL_HASH;
import static java.util.Objects.requireNonNull;

public class DefaultRepositoryHashService implements RepositoryHashService {

    private static final byte NULL = (byte) 0;
    private static final Logger log = LoggerFactory.getLogger(DefaultRepositoryHashService.class);
    private final GitAgent agent;
    private final ContentHashHelper contentHashHelper;
    private final RefHasher refHasher;
    private final RepositoryAttributeManager repositoryAttributeManager;
    private final SerializedOperation<String> serializedOperation;
    private final TranscodeService transcodeService;

    public DefaultRepositoryHashService(GitAgent agent, RefHasher refHasher,
                                        ReplicationConfig replicationConfig,
                                        RepositoryAttributeManager repositoryAttributeManager,
                                        TransactionManager transactionManager, TranscodeService transcodeService) {
        this.agent = agent;
        this.contentHashHelper = new ContentHashHelper(replicationConfig, transactionManager);
        this.refHasher = refHasher;
        this.repositoryAttributeManager = repositoryAttributeManager;
        this.transcodeService = transcodeService;

        serializedOperation = new SerializedOperation<>();
    }

    @Nonnull
    @Override
    public String calculateTransactionHash(@Nonnull Set<RefUpdate> refUpdates) {
        requireNonNull(refUpdates, "refUpdates");

        return refHasher.calculateHash(refUpdates);
    }

    @Nonnull
    @Override
    public String compareAndUpdateContentHash(@Nonnull Repository repository, @Nonnull String fromHash,
                                              @Nonnull String transactionHash) {
        requireNonNull(repository, "repository");
        requireNonNull(fromHash, "fromHash");
        requireNonNull(transactionHash, "transactionHash");

        if (!(repository instanceof ManagedRepository)) {
            return getContentHash(repository);
        }

        try {
            return serializedOperation.runOperation(repository.getId(), () -> {
                String currentHash = repositoryAttributeManager.getContentHash(repository.getId());
                String toHash;
                if (fromHash.equals(currentHash)) {
                    toHash = refHasher.combineHashes(fromHash, transactionHash);
                } else {
                    log.debug("[{}] Content hash {} does not match expected hash {}, recalculating from scratch",
                            repository, currentHash, fromHash);
                    toHash = refHasher.calculateHash(repository);
                }
                log.debug("[{}] Updating content hash - From: {}, To: {}", repository, fromHash, toHash);
                repositoryAttributeManager.setContentHash(repository.getId(), toHash);
                return toHash;
            });
        } catch (Exception e) {
            log.warn("[{}] Error updating content hash", repository, e);
            // Force full recalculation the next time the content hash is updated or requested
            repositoryAttributeManager.setContentHash(repository.getId(), null);
            throw new HashCalculationFailedException(e);
        }
    }

    @Nonnull
    @Override
    public String getContentHash(@Nonnull Repository repository) {
        requireNonNull(repository, "repository");

        if (!(repository instanceof ManagedRepository)) {
            //Sidecars can't use fast incremental hashing because they don't see all changes to the repository.
            //Nor does Mesh perform replication for them so fast hashing (or perhaps any hashing) should not be
            //required.
            return refHasher.calculateHash(repository);
        }

        String storedHash = repositoryAttributeManager.getContentHash(repository.getId());
        if (storedHash == null) {
            try {
                storedHash = serializedOperation.runOperation(repository.getId(), () -> {
                    String hash = repositoryAttributeManager.getContentHash(repository.getId());
                    if (hash == null) {
                        hash = refHasher.calculateHash(repository);
                        log.debug("[{}] Calculated content hash (Full): {}", repository, hash);
                        repositoryAttributeManager.setContentHash(repository.getId(), hash);
                    }
                    return hash;
                });
            } catch (Exception e) {
                throw new HashCalculationFailedException(e);
            }
        }
        return storedHash;
    }

    @Override
    @Nonnull
    public String getMetadataHash(@Nonnull Repository repository) {
        requireNonNull(repository, "repository");

        if (!(repository instanceof ManagedRepository)) {
            return calculateMetadataHash(repository);
        }

        String hash = repositoryAttributeManager.getMetadataHash(repository.getId());
        if (hash == null) {
            hash = calculateMetadataHash(repository);
            log.debug("[{}] Calculated metadata hash: {}", repository, hash);
            repositoryAttributeManager.setMetadataHash(repository.getId(), hash);
        }
        return hash;
    }

    @Override
    public boolean maybeRecalculateContentHash(@Nonnull Repository repository) {
        if (contentHashHelper.shouldForceRecalculate()) {
            try {
                return serializedOperation.runOperation(repository.getId(), () -> {
                    String hash = repositoryAttributeManager.getContentHash(repository.getId());
                    if (hash == null) {
                        return false;
                    }
                    String recalculatedHash = refHasher.calculateHash(repository);
                    log.debug("[{}] Calculated content hash (Full, forced): {}", repository, recalculatedHash);
                    if (!Objects.equals(hash, recalculatedHash)) {
                        log.info("[{}] Discrepancy between stored ({}) and recalculated ({}) content hash detected! " +
                                        "Returning recalculated content hash.",
                                repository, hash, recalculatedHash);
                        repositoryAttributeManager.setContentHash(repository.getId(), recalculatedHash);
                    }
                    return true;
                });
            } catch (Exception e) {
                throw new HashCalculationFailedException(e);
            }
        }
        return false;
    }

    @EventListener
    public void onRepositoryCreated(@Nonnull RepositoryCreatedEvent event) {
        if (event.getRepository() instanceof ManagedRepository && !(event instanceof ForkCreatedEvent)) {
            //Set the NULL_HASH explicitly to prevent a git for-each-ref process when we know that there are no refs
            repositoryAttributeManager.setContentHash(event.getRepository().getId(), NULL_HASH);
        }
    }

    @EventListener
    public void onRepositoryDefaultBranchChangedEvent(@Nonnull DefaultBranchChangedEvent event) {
        if (event.getRepository() instanceof ManagedRepository) {
            repositoryAttributeManager.setMetadataHash(event.getRepository().getId(), null);
        }
    }

    @EventListener
    public void onTranscodeDiffSettingChanged(@Nonnull TranscodeDiffSettingChangedEvent event) {
        if (event.getRepository() instanceof ManagedRepository) {
            repositoryAttributeManager.setMetadataHash(event.getRepository().getId(), null);
        }
    }

    @Override
    public void resetContentHash(@Nonnull Repository repository) {
        requireNonNull(repository, "repository");

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

    @Override
    public void resetMetadataHash(@Nonnull Repository repository) {
        requireNonNull(repository, "repository");

        repositoryAttributeManager.setMetadataHash(repository.getId(), null);
    }

    private String calculateMetadataHash(Repository repository) {
        MessageDigest digest = DigestUtils.getSha256Digest();

        DigestUtils.updateDigest(digest, getDefaultBranchId(repository));
        //Null separators are used after every property to prevent them running together and producing identical
        //hashes when the properties aren't actually the same
        digest.update(NULL);
        digest.update((byte) (transcodeService.isEnabled(repository) ? 1 : 0));

        return Hex.encodeHexString(digest.digest());
    }

    private String getDefaultBranchId(Repository repository) {
        try {
            return agent.getHead(repository);
        } catch (NoDefaultBranchException e) {
            return e.getBranchId() != null ? e.getBranchId() : "";
        } catch (Exception e) {
            log.warn("{}: The default branch could not be determined", repository, e);
            return "";
        }
    }
}
