package com.atlassian.bitbucket.mesh.repository;

import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.lsremote.LsRemoteStdioHandler;
import com.atlassian.bitbucket.mesh.git.ref.RefUpdate;
import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Hex;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.codec.digest.MessageDigestAlgorithms;
import org.apache.commons.lang3.mutable.MutableObject;

import javax.annotation.Nonnull;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.util.Set;

import static com.atlassian.bitbucket.mesh.git.GitConstants.PATH_DARK_REFS;
import static com.atlassian.bitbucket.mesh.git.GitConstants.PATH_STASH_REFS;
import static java.util.Objects.requireNonNull;

/**
 * A component that generates repository content hashes from a set of refs.
 *
 * Incremental hashes are implemented as follows:
 * <pre>
 * new_checksum = old_checksum XOR hash(refname, oldvalue) XOR hash(refname, newvalue)
 * </pre>
 *
 * Implementing incremental hash generation using this approach has a number of advantages:
 * <ul>
 *   <li>Because XOR is commutative the same hash will be arrived at regardless of order</li>
 *   <li>Ref updates where the "oldvalue" and "newvalue" are identical cancel each other out and hence
 *   the "old_checksum" equals the "new_checksum"</li>
 *   <li>Consecutive ref changes that cancel each other out result in an unchanged content hash. For example
 *   creating a branch and then deleting it results in the hash equal to the the value immediately prior to
 *   the branch creation</li>
 * </ul>
 *
 * <p>
 *  Full hashes are generated using the same algorithm, with a hash initial value of zero used:
 *  <pre>
 *  checksum = 0
 *      for each ref:
 *      checksum = checksum XOR hash(ref.id, 000000000...) XOR hash(ref.id, ref.commitHash)
 *  </pre>
 */
public class DefaultRefHasher implements RefHasher {

    private static final String DIGEST_ALGORITHM = MessageDigestAlgorithms.SHA_256;
    private static final int DIGEST_LENGTH = DigestUtils.getDigest(DIGEST_ALGORITHM).getDigestLength();

    private final GitProcessBuilderFactory builderFactory;

    public DefaultRefHasher(GitProcessBuilderFactory builderFactory) {
        this.builderFactory = builderFactory;
    }

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

        byte[] hashBytes = new byte[DIGEST_LENGTH];
        for (RefUpdate refUpdate : refUpdates) {
            if (isIncludedRef(refUpdate.getRefId())) {
                hashBytes = updateHash(hashBytes, refUpdate);
            }
        }

        return Hex.encodeHexString(hashBytes);
    }

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

        MutableObject<byte[]> hash = new MutableObject<>(new byte[DIGEST_LENGTH]);

        try {
            builderFactory.builder(repository).lsRemote()
                    .refsOnly(true) // no need to peel tags
                    .quiet(true)
                    //allow a long timeout for repositories with massive numbers of loose refs.
                    .withTimeout(Duration.ofHours(1L))
                    .build(new LsRemoteStdioHandler(ref -> {
                        if (isIncludedRef(ref.getId())) {
                            hash.setValue(updateHash(hash.getValue(), RefUpdate.ofAdd(ref.getId(), ref.getObjectId())));
                        }
                        return false;
                    }, "")) // Doesn't matter because isDefault isn't used
                    .run();
        } catch (RuntimeException e) {
            if (!repository.exists()) {
                //Repository has been deleted, return 00000... hash
                return NULL_HASH;
            }
            throw e;
        }

        return Hex.encodeHexString(hash.getValue());
    }

    @Nonnull
    @Override
    public String combineHashes(@Nonnull String a, @Nonnull String b) {
        requireNonNull(a, "a");
        requireNonNull(b, "b");

        byte[] aBytes = decodeHexHash(a);
        byte[] bBytes = decodeHexHash(b);
        for (int i = 0; i < aBytes.length; i++) {
            aBytes[i] ^= bBytes[i];
        }
        return Hex.encodeHexString(aBytes);
    }

    private static byte[] decodeHexHash(String hash) {
        try {
            byte[] bytes = Hex.decodeHex(hash);
            if (bytes.length != DIGEST_LENGTH) {
                throw new IllegalArgumentException("Invalid hash");
            }
            return bytes;
        } catch (DecoderException e) {
            throw new IllegalArgumentException("Invalid hash", e);
        }
    }

    private static byte[] hash(String ref, String value) {
        MessageDigest digest = DigestUtils.getDigest(DIGEST_ALGORITHM);
        digest.update((ref + value).getBytes(StandardCharsets.UTF_8));
        return digest.digest();
    }

    private static boolean isIncludedRef(String refId) {
        return !"HEAD".equals(refId) && !refId.startsWith(PATH_DARK_REFS) && !refId.startsWith(PATH_STASH_REFS);
    }

    private static byte[] updateHash(byte[] currentHash, RefUpdate refUpdate) {
        String refId = refUpdate.getRefId();
        return xor(currentHash, hash(refId, refUpdate.getOldHash()), hash(refId, refUpdate.getNewHash()));
    }

    private static byte[] xor(byte[] a, byte[] b, byte[] c) {
        byte[] result = new byte[DIGEST_LENGTH];
        for (int i = 0; i < result.length; i++) {
            result[i] = (byte) (a[i] ^ b[i] ^ c[i]);
        }
        return result;
    }
}
