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

import com.atlassian.bitbucket.mesh.annotation.Profiled;
import com.atlassian.bitbucket.mesh.client.MeshClient;
import com.atlassian.bitbucket.mesh.event.EventPublisher;
import com.atlassian.bitbucket.mesh.git.GitAgent;
import com.atlassian.bitbucket.mesh.git.GitPackFile;
import com.atlassian.bitbucket.mesh.git.builder.GitProcess;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.branch.CreateBranchCompletionHandler;
import com.atlassian.bitbucket.mesh.git.builder.branch.CreateBranchStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.branch.DeleteBranchCompletionHandler;
import com.atlassian.bitbucket.mesh.git.builder.catfile.CallbackAnnotatedTagBatchCatFileStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.catfile.PeelTagCatFileBatchStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.checkrefformat.CheckRefFormatProcessHandler;
import com.atlassian.bitbucket.mesh.git.builder.foreachref.ForEachRefStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.foreachref.ResolveRefsForEachRefStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.tag.CreateTagCompletionHandler;
import com.atlassian.bitbucket.mesh.git.builder.tag.CreateTagStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.tag.DeleteTagCompletionHandler;
import com.atlassian.bitbucket.mesh.git.builder.updateref.DeleteUpdateRefCompletionHandler;
import com.atlassian.bitbucket.mesh.git.builder.updateref.GitUpdateRef;
import com.atlassian.bitbucket.mesh.git.builder.updateref.UpdateRefCompletionHandler;
import com.atlassian.bitbucket.mesh.git.builder.updateref.UpdateRefStdioHandler;
import com.atlassian.bitbucket.mesh.git.exception.*;
import com.atlassian.bitbucket.mesh.git.mailmap.DefaultGitMailmapResolver;
import com.atlassian.bitbucket.mesh.git.mailmap.MailmapResolver;
import com.atlassian.bitbucket.mesh.network.MeshNode;
import com.atlassian.bitbucket.mesh.repair.MissingObjectsFetcher;
import com.atlassian.bitbucket.mesh.repository.RefsUpdatedEvent;
import com.atlassian.bitbucket.mesh.repository.ReplicaState;
import com.atlassian.bitbucket.mesh.repository.ReplicaStateRegistry;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.transaction.Transaction;
import com.atlassian.bitbucket.mesh.transaction.TransactionManager;
import com.atlassian.bitbucket.mesh.transaction.UpdateRefsContextHolder;
import com.atlassian.util.profiling.Ticker;
import com.atlassian.util.profiling.Timers;
import com.google.common.collect.Sets;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.IOException;
import java.time.Duration;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static com.atlassian.bitbucket.mesh.git.GitRefPattern.HEADS;
import static com.atlassian.bitbucket.mesh.git.GitRefPattern.TAGS;
import static com.atlassian.bitbucket.mesh.rpc.v1.RpcInvalidRefName.Reason.*;
import static com.atlassian.bitbucket.mesh.rpc.v1.RpcInvalidTagName.Reason.START_POINT_NOT_FOUND;
import static com.atlassian.bitbucket.mesh.util.ShaUtils.NULL_SHA1;
import static java.util.Collections.emptyList;
import static java.util.Collections.emptyMap;
import static java.util.Objects.requireNonNull;
import static org.apache.commons.lang3.StringUtils.defaultString;
import static org.apache.commons.lang3.StringUtils.isBlank;

@Profiled
public class DefaultRefManager implements RefManager {

    private static final String REFS_HEAD = "refs/heads/";
    private static final int MAX_FQ_REF_LENGTH = 100 + REFS_HEAD.length();
    private static final String NULL = "\u0000";
    //This pattern is looser than ShaUtils.isHash, which requires exactly 40 characters
    private static final Pattern PATTERN_SHA1 = Pattern.compile("[a-z0-9]{7,40}");
    private static final Pattern PATTERN_WHITESPACE = Pattern.compile("\\s");

    private static final Logger log = LoggerFactory.getLogger(DefaultRefManager.class);

    private final GitAgent agent;
    private final GitProcessBuilderFactory builderFactory;
    private final EventPublisher eventPublisher;
    private final MeshClient meshClient;
    private final ReplicaStateRegistry replicaStateRegistry;
    private final TransactionManager transactionManager;
    private final UpdateRefsContextHolder updateRefsContextHolder;

    public DefaultRefManager(GitAgent agent, GitProcessBuilderFactory builderFactory, EventPublisher eventPublisher,
                             MeshClient meshClient, ReplicaStateRegistry replicaStateRegistry,
                             TransactionManager transactionManager,
                             UpdateRefsContextHolder updateRefsContextHolder) {
        this.agent = agent;
        this.builderFactory = builderFactory;
        this.eventPublisher = eventPublisher;
        this.meshClient = meshClient;
        this.replicaStateRegistry = replicaStateRegistry;
        this.transactionManager = transactionManager;
        this.updateRefsContextHolder = updateRefsContextHolder;
    }

    @Override
    public void checkFormat(@Nonnull String refId) {
        String qualifiedRefId = validateId(refId);
        if (!checkRefFormat(qualifiedRefId)) {
            throw new InvalidRefNameException(INVALID, refId);
        }
    }

    @Nonnull
    @Override
    public Branch createBranch(@Nonnull CreateBranchParameters parameters) {
        String name = requireNonNull(parameters, "parameters").getName();
        validateId(name);

        String startPoint = parameters.getStartPoint();
        if (startPoint != null && startPoint.trim().startsWith("-")) {
            throw new InvalidRefNameException(INVALID, name);
        }

        Repository repository = parameters.getRepository();
        Branch branch = parameters.configureAndRun(builderFactory.builder(repository).branch()
                .create(HEADS.unqualify(name))
                .author(parameters.getAuthor())
                .force(parameters.isForce())
                .reflog(parameters.isReflog())
                .startPoint(startPoint)
                .completionHandler(new CreateBranchCompletionHandler())
                .build(new CreateBranchStdioHandler(agent, name, repository)));
        if (branch == null) {
            throw new CreateRefFailedException(name);
        }

        eventPublisher.publish(new RefsUpdatedEvent(repository));
        return branch;
    }

    @Nonnull
    @Override
    public Tag createTag(@Nonnull CreateTagParameters parameters) {
        String name = requireNonNull(parameters, "parameters").getName();
        validateId(name);

        String startPoint = parameters.getStartPoint();
        if (startPoint != null && startPoint.trim().startsWith("-")) {
            throw new InvalidTagNameException(START_POINT_NOT_FOUND, name);
        }

        Repository repository = parameters.getRepository();
        Tag tag = parameters.configureAndRun(builderFactory.builder(repository).tag()
                .create(TAGS.unqualify(name))
                .author(parameters.getAuthor())
                .force(parameters.isForce())
                .message(parameters.getMessage())
                .startPoint(startPoint)
                .type(parameters.getType())
                .completionHandler(new CreateTagCompletionHandler())
                .build(new CreateTagStdioHandler(this, name, repository)));
        if (tag == null) {
            throw new CreateRefFailedException(name);
        }

        eventPublisher.publish(new RefsUpdatedEvent(repository));
        return tag;
    }

    @Override
    public void deleteBranch(@Nonnull DeleteBranchParameters parameters) {
        Repository repository = requireNonNull(parameters, "parameters").getRepository();
        parameters.configureAndRun(builderFactory.builder(repository).branch()
                .delete(HEADS.unqualify(parameters.getName()))
                .force(parameters.isForce())
                .author(parameters.getAuthor())
                .completionHandler(new DeleteBranchCompletionHandler())
                .build());

        eventPublisher.publish(new RefsUpdatedEvent(repository));
    }

    @Override
    public void deleteTag(@Nonnull DeleteTagParameters parameters) {
        Repository repository = requireNonNull(parameters, "parameters").getRepository();
        parameters.configureAndRun(builderFactory.builder(repository).tag()
                .delete(TAGS.unqualify(parameters.getName()))
                .author(parameters.getAuthor())
                .completionHandler(new DeleteTagCompletionHandler())
                .build());

        eventPublisher.publish(new RefsUpdatedEvent(repository));
    }

    @Nonnull
    @Override
    public Branch resolveDefaultBranch(@Nonnull Repository repository) {
        return agent.resolveHead(repository);
    }

    @Nonnull
    @Override
    public Map<String, Ref> resolveRefs(@Nonnull ResolveRefsParameters parameters) {
        if (requireNonNull(parameters, "parameters").isEmpty()) {
            return emptyMap();
        }

        // Qualify the branch and tag IDs, so they can be resolved efficiently. We need to track the id->qualified
        // ID mapping so we can return the Map<id, Ref>
        Map<String, String> idToRequestedId = new HashMap<>();
        parameters.getRefIds().forEach(id -> idToRequestedId.put(id, id)); // refIds are not expanded
        parameters.getBranchIds().forEach(id -> idToRequestedId.put(HEADS.qualify(id), id));
        parameters.getTagIds().forEach(id -> idToRequestedId.put(TAGS.qualify(id), id));
        Set<String> refIds = new HashSet<>(idToRequestedId.keySet());

        Map<String, Ref> resolved = agent.shallowResolveRefs(parameters.getRepository(), refIds, refIds.isEmpty());
        Set<String> unresolved = Sets.difference(refIds, resolved.keySet());
        if (unresolved.stream().anyMatch(PATTERN_SHA1.asPredicate())) {
            // Not all refIds could be resolved and at least one of the unresolved refs could be a SHA hash that
            // needs to be matched to a ref. We'll _have_ to call for-each-ref to resolve the SHA hashes
            resolved = resolveRefsWithForEachRef(parameters, refIds);
        } else {
            // No need to call for-each-ref. Any tags returned by shallowResolveRefs need to be peeled before
            // returning them
            expandShallowTags(parameters, resolved);
        }

        Map<String, Ref> result = new HashMap<>();
        for (Map.Entry<String, Ref> entry : resolved.entrySet()) {
            String requestedId = idToRequestedId.get(entry.getKey());
            if (requestedId != null) {
                result.put(requestedId, entry.getValue());
            } else {
                log.warn("Ignoring resolved ref {}, which wasn't requested", entry.getKey());
            }
        }

        return Collections.unmodifiableMap(result);
    }

    @Override
    public void streamAnnotatedTags(@Nonnull StreamAnnotatedTagsParameters parameters,
                                    @Nonnull AnnotatedTagCallback callback) {
        requireNonNull(parameters, "parameters");
        requireNonNull(callback, "callback");

        Set<String> tagIds = parameters.getTagIds();
        if (tagIds.isEmpty()) {
            return;
        }

        Repository repository = parameters.getRepository();
        try (MailmapResolver mailmapResolver = new DefaultGitMailmapResolver(builderFactory.builder(repository))) {
            parameters.configureAndRun(builderFactory.builder(repository).catFile()
                    .batch()
                    .build(new CallbackAnnotatedTagBatchCatFileStdioHandler(tagIds, parameters.getMaxMessageLength(),
                            mailmapResolver, callback)));
        }
    }

    @Override
    public void updateRef(@Nonnull UpdateRefParameters parameters) {
        requireNonNull(parameters, "parameters");

        parameters.configureAndRun(buildUpdateProcess(
                parameters.getRepository(), parameters.getUpdate(), parameters.isDereferenceSymbolicRefs()));
        eventPublisher.publish(new RefsUpdatedEvent(parameters.getRepository()));
    }

    @Nonnull
    @Override
    public List<String> updateRefs(@Nonnull UpdateRefsParameters parameters) {
        requireNonNull(parameters, "parameters");

        maybeRegisterRefUpdates(parameters);
        try {
            return updateRefsInternal(parameters);
        } catch (CommitNotFoundException e) {
            Repository repository = parameters.getRepository();

            ReplicaState localState = replicaStateRegistry.getState(repository);
            if (localState != ReplicaState.CONSISTENT) {
                log.debug("[{}] Skipping just-in-time fetch of missing objects because replica is {}",
                        repository, localState);
                throw e;
            }
            //Fetch any objects that were missing
            GitPackFile packFile = maybeFetchMissingObjects(parameters, e.getCommitId());
            if (packFile == null) {
                log.warn("[{}] {} was reported missing but no objects could be fetched", repository, e.getCommitId());
                throw e;
            }

            //And try again (1 retry only)
            try {
                return updateRefsInternal(parameters);
            } finally {
                //Remove the .keep file that protects the pack file that was just fetched
                try {
                    packFile.close();
                } catch (IOException ioException) {
                    log.warn("[{}] Failed to delete .keep file for fetched objects", repository, ioException);
                }
            }
        }
    }

    @Nonnull
    @Override
    public String validateId(String refId) {
        if (isBlank(refId)) {
            throw new InvalidRefNameException(BLANK, refId);
        }
        if (refId.trim().startsWith("-")) { // The trim() here is to match DefaultGitProcessBuilder.notBlank
            throw new InvalidRefNameException(INVALID, refId);
        }
        // .lock suffix is not allowed on ref names because it conflicts with git's ref locking mechanism
        if (refId.endsWith(".lock")) {
            throw new InvalidRefNameException(INVALID, refId);
        }
        if (PATTERN_WHITESPACE.matcher(refId).find()) {
            throw new InvalidRefNameException(WHITESPACE, refId);
        }

        // Potentially unqualify a tag and qualify as a branch. The end result should be a properly qualified
        // branch that we can check for length
        String qualifiedRefId = HEADS.qualify(TAGS.unqualify(refId));
        if (qualifiedRefId.length() > MAX_FQ_REF_LENGTH) {
            throw new RefNameTooLongException(refId, MAX_FQ_REF_LENGTH);
        }

        return qualifiedRefId;
    }

    /**
     * Transforms the provided {@link ProposedRefUpdate} into an instruction for {@code git update-ref --stdin -z}.
     * <p>
     * <b>Note</b>: The {@code -z} formatting requires a trailing NUL which this transformation does not apply. It
     * will be added as a line separator when the instruction line is streamed to the {@code update-ref} process.
     *
     * @param update the update to transform
     * @return the transformed instruction, <i>without a trailing NUL</i>
     */
    private static String toInstruction(ProposedRefUpdate update) {
        //A trailing NULL is added to the instruction "lines" by the process handler
        switch (update.getType()) {
            case ADD:
            case UPDATE:
                //create syntax doesn't support an old value, since it's required to be a create.
                //ProposedRefUpdate allows for a maybe-create, where a newHash is specified and
                //the oldHash isn't (instead of being the null SHA), so we use update syntax for
                //both ADD and UPDATE
                return "update " + update.getRefId() + NULL + update.getNewHash() +
                        NULL + defaultString(update.getOldHash());
            case DELETE:
                //This can technically use update syntax as well, but we use delete for simplicity
                return "delete " + update.getRefId() + NULL + defaultString(update.getOldHash());
        }

        throw new IllegalArgumentException(update + ": Unsupported RefChangeType (" + update.getType() + ")");
    }

    /**
     * Builds a {@code git update-ref} process which will apply the provided {@link ProposedRefUpdate}.
     *
     * @param repository the repository to apply the update to
     * @param update     the update to apply
     * @param deref      whether symbolic references should be dereferenced
     * @return a process that, when run, will apply the provided update
     */
    private GitProcess<?> buildUpdateProcess(Repository repository, ProposedRefUpdate update, boolean deref) {
        GitUpdateRef updateRef = builderFactory.builder(repository).updateRef();

        switch (update.getType()) {
            case ADD:
            case UPDATE:
                return updateRef.set(update.getRefId(), update.getNewHash())
                        .completionHandler(new UpdateRefCompletionHandler())
                        .deref(deref)
                        .oldValue(update.getOldHash())
                        .build();
            case DELETE:
                return updateRef.delete(update.getRefId())
                        .completionHandler(new DeleteUpdateRefCompletionHandler())
                        .deref(deref)
                        .oldValue(update.getOldHash())
                        .build();
        }

        throw new IllegalArgumentException(update + ": Unsupported RefChangeType (" + update.getType() + ")");
    }

    private boolean checkRefFormat(String qualifiedRefName) {
        Boolean valid = builderFactory.builder().checkRefFormat()
                .ref(qualifiedRefName)
                .build(new CheckRefFormatProcessHandler())
                .run();

        return valid != null && valid;
    }

    private void expandShallowTags(ResolveRefsParameters parameters, Map<String, Ref> resolved) {
        Map<String, String> targetHashByTagHash = peelTags(parameters, resolved.values().stream()
                .filter(ref -> RefType.TAG == ref.getType())
                .map(Ref::getLatestCommit)
                .collect(Collectors.toSet()));

        for (Map.Entry<String, Ref> entry : resolved.entrySet()) {
            Ref ref = entry.getValue();
            if (RefType.TAG == ref.getType()) {
                String commitHash = targetHashByTagHash.get(ref.getLatestCommit());
                if (commitHash == null) {
                    log.warn("{}: Could not unpeel tag {}", parameters.getRepository(), ref.getId());
                    continue; // don't return the tag
                }
                String targetHash = ref.getLatestCommit().equals(commitHash) ? null : ref.getLatestCommit();
                entry.setValue(new Tag.Builder()
                        .hash(targetHash)
                        .id(ref.getId())
                        .latestCommit(commitHash)
                        .build());
            }
        }
    }

    private GitPackFile maybeFetchMissingObjects(UpdateRefsParameters parameters, String missingObjectId) {
        //fetch the missing objects if we can determine the origin of the update
        MeshNode leader = transactionManager.getCurrentTransaction()
                .map(Transaction::getLeader)
                .filter(MeshNode::isRemote)
                .orElse(null);
        if (leader == null) {
            return null;
        }

        Repository repository = parameters.getRepository();
        try (MissingObjectsFetcher fetcher = new MissingObjectsFetcher(
                builderFactory, meshClient, repository, leader)) {
            fetcher.includeIfMissing(missingObjectId);
            parameters.getUpdates().forEach(fetcher::includeCommitsIfMissing);
            if (parameters.getExecutionTimeout() > 0L) {
                fetcher.setTimeout(Duration.ofSeconds(parameters.getExecutionTimeout()));
            }
            return fetcher.maybeFetch();
        }
    }

    //Registers the proposed ref updates in the transaction context to make them available in the reference-transaction
    //handler. This helps avoid needing to resolve 000000000000000000000000000 from-hashes in the reference-transaction
    //hook stdin. Without context, these may represent a ref deletion, a force update or even a 'fake' callback for the
    //packed-refs backend. We register them here to provide the necessary context.
    private void maybeRegisterRefUpdates(UpdateRefsParameters parameters) {
        updateRefsContextHolder.set(parameters.getRepository(), parameters.getUpdates().stream()
                .filter(proposedUpdate -> proposedUpdate.getOldHash() != null)
                .map(it -> RefUpdate.of(it.getRefId(), it.getOldHash(), defaultString(it.getNewHash(), NULL_SHA1)))
                .collect(Collectors.toList()));
    }

    private Map<String, String> peelTags(ResolveRefsParameters parameters, Set<String> tagObjectIds) {
        if (tagObjectIds.isEmpty()) {
            return emptyMap();
        }

        try (Ticker ignored = Timers.start(getClass().getName() + ".peelTags(Set)")) {
            return parameters.configureAndRun(builderFactory.builder(parameters.getRepository()).catFile()
                    .batch()
                    .build(new PeelTagCatFileBatchStdioHandler(parameters.getRepository(), tagObjectIds)));
        }
    }

    private Map<String, Ref> resolveRefsWithForEachRef(ResolveRefsParameters parameters, Set<String> ids) {
        ForEachRefStdioHandler<Map<String, Ref>> stdoutHandler = new ResolveRefsForEachRefStdioHandler(
                agent.getHead(parameters.getRepository()), ids);

        return parameters.configureAndRun(builderFactory.builder(parameters.getRepository()).forEachRef()
                .format(stdoutHandler.getFormat())
                .patterns(HEADS.getPath(), TAGS.getPath())
                .sort("-objecttype")
                .build(stdoutHandler));
    }

    private List<String> updateRefsInternal(UpdateRefsParameters parameters) {
        if (requireNonNull(parameters, "parameters").getUpdates().isEmpty()) {
            return emptyList();
        }

        boolean deref = parameters.isDereferenceSymbolicRefs();
        Repository repository = parameters.getRepository();
        if (parameters.isTransactional()) {
            // For transactional updates, we need to use update-ref --stdin so we can process the updates
            // as all-or nothing
            Stream<String> instructions = parameters.getUpdates().stream()
                    .map(DefaultRefManager::toInstruction);

            GitProcess<Void> process = builderFactory.builder(repository).updateRef()
                    .stdin()
                    .completionHandler(new UpdateRefCompletionHandler())
                    .deref(deref)
                    .nullTerminated(true)
                    .build(new UpdateRefStdioHandler(instructions, true));
            parameters.configureAndRun(process);

            //If we get here, all the updates were applied successfully
            eventPublisher.publish(new RefsUpdatedEvent(repository));
            return emptyList();
        }

        // For non-transactional updates, _until the minimum Git version is 2.29+_, we have to apply each
        // update using separate update-ref processes and handle errors
        List<String> failedRefIds = new ArrayList<>();
        for (ProposedRefUpdate update : parameters.getUpdates()) {
            try {
                parameters.configureAndRun(buildUpdateProcess(repository, update, deref));
            } catch (RuntimeException e) {
                failedRefIds.add(update.getRefId());
            }
        }

        if (parameters.getUpdates().size() > failedRefIds.size()) {
            eventPublisher.publish(new RefsUpdatedEvent(repository));
        }
        return failedRefIds;
    }
}
