package com.atlassian.bitbucket.mesh.git.builder.lstree;

import com.atlassian.bitbucket.mesh.git.GitTreeNode;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.catfile.AbstractBatchCatFileStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.catfile.BatchHeader;
import com.atlassian.bitbucket.mesh.git.content.Directory;
import com.atlassian.bitbucket.mesh.process.OsProcess;
import com.atlassian.bitbucket.mesh.process.ProcessStdioHandler;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.rpc.v1.RpcDirectory;
import com.google.common.base.Throwables;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.nio.ByteBuffer;
import java.util.concurrent.*;

import static java.util.Objects.requireNonNull;

/**
 * A {@link DirectoryCreator} which uses a secondary {@code git cat-file --batch} process to detect directories
 * that contain only a single subdirectory and "collapse" them.
 * <p>
 * For example, consider the following repository layout (which is very common for Java projects using Maven):
 * <pre>
 * src/
 *   main/
 *     java/
 *       com/
 *         atlassian/
 *           bitbucket/
 *             comment/
 *               CommentService.java
 *             repository/
 *               RepositoryService.java
 *   test/
 *     java/
 *       com/
 *         atlassian/
 *           bitbucket/
 *             SomeTest.java
 * </pre>
 * When listing {@code src}, both {@code main} and {@code test} each contain only a single subdirectory, which
 * itself contains only a single subdirectory. That repeats for several layers of nesting. Without collapsing,
 * navigating the directory structure would require clicking through several layers, with each layer requiring
 * a server roundtrip to load the next.
 * <ul>
 *     <li>Collapsing {@code main} would yield {@code main/java/com/atlassian/bitbucket}: All the single-
 *     subdirectory layers are collapsed, leaving a direct link to the first subdirectory that contains
 *     multiple subdirectories (e.g. the {@code comment} and {@code repository} subdirectories).</li>
 *     <li>Collapsing {@code test} would yield {@code test/java/com/atlassian/bitbucket}: Again, all the
 *     single-subdirectory layers are collapsed, this time until the first subdirectory containing a file.
 *     Files and submodules are never collapsed, even if a given directory only contains a single one.</li>
 * </ul>
 */
public class CollapsingDirectoryCreator implements DirectoryCreator {

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

    private final GitProcessBuilderFactory builderFactory;
    private final Repository repository;

    private DirectoryCollapsingBatchCatFileStdioHandler handler;
    private Future<Void> future;

    public CollapsingDirectoryCreator(@Nonnull GitProcessBuilderFactory builderFactory,
                                      @Nonnull Repository repository) {
        this.builderFactory = requireNonNull(builderFactory, "builderFactory");
        this.repository = requireNonNull(repository, "repository");
    }

    @Override
    public void close() {
        if (handler != null) {
            handler.close();
            handler = null;
        }

        if (future != null) {
            try {
                //By this point the cat-file --batch process should have terminated cleanly. If it
                //hasn't, this get(...) will timeout and we'll kill it instead
                future.get(1L, TimeUnit.SECONDS);
            } catch (CancellationException ignored) {
                //If the callback cancels ls-tree, the nested cat-file may also be canceled
            } catch (ExecutionException e) {
                Throwable cause = e.getCause();
                Throwables.throwIfUnchecked(cause);

                throw new IllegalStateException("Failed collapsing directories", cause);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                future.cancel(true); //Just in case the process is still running
            } catch (TimeoutException e) {
                log.debug("git cat-file --batch didn't stop cleanly; terminating");
                future.cancel(true);
            } finally {
                future = null;
            }
        }
    }

    @Nonnull
    @Override
    public Directory create(@Nonnull String path, @Nonnull String contentId) {
        if (handler == null) {
            future = builderFactory.builder(repository).catFile()
                    .batch()
                    .build(handler = new DirectoryCollapsingBatchCatFileStdioHandler())
                    .start();
        }

        return handler.collapse(new Directory.Builder(path, contentId).build());
    }

    /**
     * An {@link ProcessStdioHandler} for {@code git cat-file --batch} which interactively requests trees and collapses
     * single-entry subdirectories for a given starting {@link RpcDirectory}.
     * <p>
     * <b>Implementation note</b>: While this class extends from {@link AbstractBatchCatFileStdioHandler}, and relies
     * on its {@link #onStdout} processing, the way object IDs are pumped to {@code git cat-file --batch} deviates
     * from how the base class would handle it. Rather than blocking on a queue in {@link #getNextObjectId()}, this
     * uses {@link #requestObject(String)} directly to request trees as necessary.
     */
    private static class DirectoryCollapsingBatchCatFileStdioHandler
            extends AbstractBatchCatFileStdioHandler<Void> {

        private final BlockingQueue<Directory> out;

        //The directory currently being collapsed. This is retained in a class field because there
        //are multiple callbacks involved in (potentially) collapsing a single subdirectory
        private volatile Directory directory;
        private volatile boolean running; // onExit and collapse are called on different threads

        DirectoryCollapsingBatchCatFileStdioHandler() {
            out = new ArrayBlockingQueue<>(1);
            running = true;
        }

        @Override
        public Void getResult() {
            return null;
        }

        @Override
        public void onExit(int exitCode) {
            running = false;
        }

        //This override intentionally suppresses the BatchedCatFileStdioHandlerSupport handling,
        //because we don't want to signal wantWrite() here
        @Override
        public void onStart(@Nonnull OsProcess process) {
            //This assignment covers AbstractNioHandler.onStart's behavior, which we still need
            this.process = process;
        }

        @Override
        protected String getNextObjectId() {
            //Between our onStart and pumpIfRunning overrides, this method should never be called
            throw new UnsupportedOperationException(getClass().getSimpleName() +
                    ".getNextObjectId() should never be called");
        }

        @Override
        protected boolean onContent(@Nonnull ByteBuffer content, boolean closed) {
            GitTreeNode node = GitTreeNode.parse(content);
            if (node == null) {
                if (closed) {
                    throw new IllegalStateException("Unexpected end-of-stream reading tree for [" +
                            directory.getContentId() + "]");
                }
                //We haven't read enough of the content to parse a tree node yet
                return true;
            }

            if (node.isDirectory() && closed && !content.hasRemaining()) {
                String contentId = node.getContentId(); //Replace the content ID with the tip directory
                String path = directory.getPath() + "/" + node.getPath(); //Merge the paths

                requestDirectory(new Directory.Builder(path, contentId).build());
            } else {
                //If we get here, it either means the tree entry we read wasn't a directory, or there were
                //multiple entries in the tree. Either way, we're not collapsing any further entries
                offerDirectory();
            }
            return false;
        }

        @Override
        protected void onMissing(@Nonnull String objectId) {
            //This should never happen, but if the tree we requested can't be found, rather than blowing
            //up, stop collapsing and return whatever we've done so far
            offerDirectory();
        }

        @Override
        protected boolean onObject(@Nonnull BatchHeader header) {
            //We only request trees, and we'll need to look at their contents in order to decide whether
            //to collapse or not
            return true;
        }

        @Override
        protected void pumpIfRunning() {
            //Do nothing; we'll handle requesting our own IDs as necessary, and we'll close stdin when we
            //know there will be no more IDs to request
        }

        void close() {
            closeStdin();
        }

        @Nonnull
        Directory collapse(@Nonnull Directory directory) {
            try {
                if (running) {
                    requestDirectory(directory);

                    do {
                        //Then block waiting for the (potentially collapsed) result
                        Directory collapsed = out.poll(250L, TimeUnit.MILLISECONDS);
                        if (collapsed != null) {
                            return collapsed;
                        }
                    } while (running);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();

                throw new RuntimeException("Interrupted while collapsing " + directory.getPath(), e);
            }

            log.warn("{} terminated unexpectedly", process);
            return directory;
        }

        private void offerDirectory() {
            Directory directory = this.directory;
            this.directory = null;

            out.offer(directory);
        }

        private void requestDirectory(Directory directory) {
            this.directory = directory;

            requestObject(directory.getContentId());
        }
    }
}
