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

import com.atlassian.bitbucket.mesh.git.GitAgent;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.foreachref.AllRefsForEachRefStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.lsremote.LsRemoteStdioHandler;
import com.atlassian.bitbucket.mesh.git.exception.CommandTimeoutException;
import com.atlassian.bitbucket.mesh.git.exception.DetachedHeadException;
import com.atlassian.bitbucket.mesh.git.exception.NoDefaultBranchException;
import com.atlassian.bitbucket.mesh.git.ref.Branch;
import com.atlassian.bitbucket.mesh.git.ref.Ref;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.process.ProcessSummary;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.util.CachingSatiableConsumer;
import com.atlassian.bitbucket.mesh.util.SatiableConsumer;
import com.atlassian.bitbucket.mesh.util.ShaUtils;
import com.google.common.collect.Streams;
import io.grpc.Context;
import io.grpc.Deadline;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Instant;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

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.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;

/**
 * Streams all public and dark refs, but does <em>not</em> emit symbolic dark refs. These shouldn't exist for
 * {@link com.atlassian.bitbucket.mesh.repository.ManagedRepository managed repositories}.
 * <p>
 * <em>Important</em>: this differs from git ref advertisement in a number of ways:
 * <ul>
 *     <li>Tags are not peeled</li>
 *     <li>Symbolic dark refs are not returned</li>
 * </ul>
 */
class RefStreamer {

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

    private final GitAgent agent;
    private final GitProcessBuilderFactory builderFactory;

    RefStreamer(GitAgent agent, GitProcessBuilderFactory builderFactory) {
        this.agent = agent;
        this.builderFactory = builderFactory;
    }

    void streamRefs(@Nonnull StreamRefsParameters parameters,
                    @Nonnull SatiableConsumer<Ref> callback) throws IOException {
        requireNonNull(parameters, "parameters");
        requireNonNull(callback, "callback");

        CachingSatiableConsumer<Ref> cachingCallback = new CachingSatiableConsumer<>(callback);
        Repository repository = parameters.getRepository();
        Instant deadline = getTimeoutInstant(parameters);
        if (parameters.isIncludePublicRefs()) {
            String defaultBranch = getDefaultBranch(repository);
            //First stream the public refs
            if (parameters.isFailOnInvalidRefs()) {
                //Use forEachRef, which validates that the referenced objects actually exist
                parameters.applyTimeouts(builderFactory.builder(repository).forEachRef()
                        .format(AllRefsForEachRefStdioHandler.FORMAT))
                        .build(new AllRefsForEachRefStdioHandler(cachingCallback, defaultBranch))
                        .run();
            } else {
                //Use lsRemote, which does not validate that the referenced objects actually exist
                parameters.applyTimeouts(builderFactory.builder(repository).lsRemote())
                        .build(new LsRemoteStdioHandler(cachingCallback, defaultBranch))
                        .run();

            }
        }

        if (!cachingCallback.isSatiated() && parameters.isIncludeDarkRefs()) {
            Path darkRefDir = repository.getPath().resolve(PATH_DARK_REFS);
            visitFiles(callback, repository, deadline, darkRefDir);
        }

        if (!cachingCallback.isSatiated() && parameters.isIncludeStashRefs()) {
            Path stashRefDir = repository.getPath().resolve(PATH_STASH_REFS);
            visitFiles(callback, repository, deadline, stashRefDir);

        }
    }

    private static Instant getTimeoutInstant(StreamRefsParameters parameters) {
        Instant now = Instant.now();

        //Use a long default timeout in case no timeout has been specified on the parameters
        long timeoutMillis = TimeUnit.MINUTES.toMillis(30);
        if (parameters.getExecutionTimeout() > 0) {
            //If an execution timeout has been provided, that should override the default
            timeoutMillis = TimeUnit.SECONDS.toMillis(parameters.getExecutionTimeout());
        }
        Deadline deadline = Context.current().getDeadline();
        if (deadline != null) {
            //If a gRPC deadline is active, that should restrict the maximum allowed default
            timeoutMillis = Math.min(deadline.timeRemaining(TimeUnit.MILLISECONDS), timeoutMillis);
        }

        return now.plusMillis(timeoutMillis);
    }

    private static Ref readLooseDarkRef(Repository repository, Path refFile) {
        String refId = Streams.stream(repository.getPath().relativize(refFile))
                .map(Path::toString)
                .collect(Collectors.joining("/"));

        for (int i = 0; i < 7; ++i) {
            try {
                String commitId = MoreFiles.toString(refFile, UTF_8).trim();
                if (!ShaUtils.isHash(commitId)) {
                    //We only resolve simple loose refs for Mesh's internal usage of ref streaming
                    return null;
                }
                return new Branch.Builder()
                        .id(refId)
                        .latestCommit(commitId)
                        .build();
            } catch (FileNotFoundException | NoSuchFileException e) {
                return null;
            } catch (IOException e) {
                if (i == 6) {
                    log.warn("[{}] Could not read loose ref {}", repository, refId, e);
                }
            }
        }
        return null;
    }

    private static void visitFiles(SatiableConsumer<Ref> callback, Repository repository, Instant deadline,
                                   Path refsDirectory) throws IOException {
        try {
            MoreFiles.walkFileTreeSorted(refsDirectory, new SimpleFileVisitor<Path>() {

                int fileCount;

                @Override
                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                    //Reduce the timeout checking overhead by only checking once every n files
                    if (fileCount++ % 100 == 0 && Instant.now().isAfter(deadline)) {
                        throw new CommandTimeoutException("Timed out while streaming ref from " + refsDirectory.getFileName(),
                                new ProcessSummary.Builder("stream " + refsDirectory.getFileName())
                                        .timedOut(true)
                                        .build());
                    }
                    if (isFromRef(file, attrs)) {
                        Ref resolved = readLooseDarkRef(repository, file);
                        if (resolved != null && callback.accept(resolved)) {
                            return FileVisitResult.TERMINATE;
                        }
                    }
                    return FileVisitResult.CONTINUE;
                }

                private boolean isFromRef(Path file, BasicFileAttributes attrs) {
                    //skip files that don't look like _loose_ PR from refs
                    return attrs.isRegularFile() && attrs.size() < 100 && "from".equals(file.getFileName().toString());
                }
            });
        } catch (FileNotFoundException | NoSuchFileException e) {
            //If the refs directory does not exist, there are no refs to pass to the callback.
            if (Files.isDirectory(refsDirectory)) {
                throw e;
            }
        }
    }

    private String getDefaultBranch(Repository repository) {
        try {
            return agent.getHead(repository);
        } catch (NoDefaultBranchException | DetachedHeadException e) {
            return "";
        }
    }
}

