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

import com.atlassian.bitbucket.mesh.config.HooksConfig;
import com.atlassian.bitbucket.mesh.execution.ExecutionContext;
import com.atlassian.bitbucket.mesh.execution.ExecutionManager;
import com.atlassian.bitbucket.mesh.git.GitTimers;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilder;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderSupport;
import com.atlassian.bitbucket.mesh.git.exception.CommandFailedException;
import com.atlassian.bitbucket.mesh.git.hosting.HostingCacheResult;
import com.atlassian.bitbucket.mesh.git.hosting.HostingOperationType;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.repository.DefaultPathRepository;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.repository.RepositoryManager;
import com.atlassian.util.contentcache.CacheResult;
import com.atlassian.util.profiling.Ticker;
import com.atlassian.util.profiling.Timers;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.BufferOverflowException;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;

import static com.atlassian.bitbucket.mesh.git.GitConstants.CONFIG_CORE_HOOKS_PATH;
import static com.atlassian.bitbucket.mesh.git.GitConstants.CONFIG_UPLOAD_PACK_PACK_OBJECTS_HOOK;
import static com.atlassian.bitbucket.mesh.git.hook.GitHookInputParser.ChunkType.GIT_DIR;
import static com.atlassian.bitbucket.mesh.util.MoreCollectors.toImmutableMap;
import static com.atlassian.bitbucket.mesh.util.ResourceUtils.unpackHook;
import static java.util.Objects.requireNonNull;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import static org.apache.commons.lang3.StringUtils.trim;
import static org.apache.commons.lang3.SystemUtils.*;

public class DefaultGitHookService implements GitHookService {

    private static final String CALLBACK_DARWIN_AARCH64 = "/hook-callback/aarch64-darwin/mesh-callback";
    private static final String CALLBACK_DARWIN_X86_64 = "/hook-callback/x86_64-darwin/mesh-callback";
    private static final String CALLBACK_FALLBACK = "/hook-callback/default/mesh-callback.pl";
    private static final String CALLBACK_LINUX_AARCH64 = "/hook-callback/aarch64-linux/mesh-callback";
    private static final String CALLBACK_LINUX_X86_64 = "/hook-callback/x86_64-linux/mesh-callback";
    /**
     * Uses the default backlog for accepting new connections. (Currently 50, but may vary by JDK)
     */
    private static final int DEFAULT_BACKLOG = 0;

    private static final String ENV_REPOSITORY_ID = "STASH_REPOSITORY_ID";
    private static final String ENV_REQUEST_ID = "STASH_REQUEST_ID";

    private static final List<String> SUPPORTED_HOOKS = ImmutableList.of(
            "pack-objects", "pre-receive", "post-receive", "reference-transaction");

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

    private final HooksConfig config;
    private final ExecutionManager executionManager;
    private final ExecutorService executor;
    private final Map<String, GitHookHandler> handlersByName;
    private final RepositoryManager repositoryManager;
    private final AtomicLong requestCounter;
    private final ConcurrentMap<String, HookContext> stateByRequest;
    private final Path tempDir;

    private Thread listenerThread;
    private ServerSocketChannel serverSocketChannel;
    private String verifiedHookResource;
    private volatile boolean shutdown;

    public DefaultGitHookService(HooksConfig config, ExecutionManager executionManager, ExecutorService executor,
                                 List<GitHookHandler> handlers, RepositoryManager repositoryManager, Path tempDir) {
        this.config = config;
        this.executionManager = executionManager;
        this.executor = executor;
        this.repositoryManager = repositoryManager;
        this.tempDir = tempDir;

        handlersByName = handlers.stream().collect(toImmutableMap(GitHookHandler::getHookName, Function.identity()));
        requestCounter = new AtomicLong();
        stateByRequest = new ConcurrentHashMap<>();
    }

    @Override
    public void configure(@Nonnull GitProcessBuilder builder) {
        Repository repository = builder.getRepository();
        if (repository != null) {
            //Make the repository ID available in the environment. The callbackScript sends this back
            builder.withConfiguration(CONFIG_CORE_HOOKS_PATH, repository.getHooksDir().toString())
                    .withEnvironment(ENV_REPOSITORY_ID, repository.getId());
        }
        //Make the request ID available in the environment. If provided, the hook handler uses this to look up the
        //correct ExecutionContext and make any handler results available on the context.
        executionManager.getContext().ifPresent(
                context -> builder.withEnvironment(ENV_REQUEST_ID, context.getId()));

        builder.withEnvironment("STASH_HOOK_ADDRESS", getHostAddress())
                .withEnvironment("STASH_HOOK_PORT", Integer.toString(getPort()));
    }

    @Override
    public void installHooks(@Nonnull Path hooksDir) throws IOException {
        requireNonNull(hooksDir, "hooksDir");

        MoreFiles.mkdir(hooksDir);
        String hookResource = getHookResource();
        for (String hookName : SUPPORTED_HOOKS) {
            unpackHook(hookResource, hooksDir.resolve(hookName));
        }
    }

    @Nonnull
    @Override
    public HookRequestHandle registerRequest(@Nonnull Repository repository, @Nonnull GitHookHandler... handlers) {
        ExecutionContext executionContext = executionManager.getContextOrThrow();
        String requestId = Long.toString(requestCounter.incrementAndGet());

        DefaultGitHookRequestHandle handle = new DefaultGitHookRequestHandle(repository, requestId);
        executionContext.addCleanupCallback(handle::close);

        stateByRequest.put(requestId,
                new HookContext(executionContext, handle, repository, requireNonNull(handlers, "handlers")));

        return handle;
    }

    public void shutdown() {
        //Set the shutdown flag first, so the listener thread and processing code can see it
        shutdown = true;

        //Interrupt the listener thread before closing the socket, so it won't be blocked in
        //accept(). Otherwise there's a race condition where ServerSocketChannel.close() can
        //fail because it tries to interrupt the listener itself and fails because accept()
        //threw an exception and the thread already terminated
        if (listenerThread != null) {
            listenerThread.interrupt();
        }

        try {
            if (serverSocketChannel != null) {
                serverSocketChannel.close();
            }
        } catch (IOException e) {
            log.warn("Could not close socket for hook callbacks", e);
        }
    }

    public void startup() throws IOException {
        //hookPort may be 0, in which case an open port will automatically be chosen and used.
        //hookAddress may be null, in which case the _loopback_ (localhost) is used. This is generally the
        //best configuration as it ensures the callback socket is only available to hooks on-box.
        serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.socket().bind(
                new InetSocketAddress(InetAddress.getByName(config.getSocketAddress()), config.getSocketPort()),
                DEFAULT_BACKLOG);

        listenerThread = new Thread("git-hook-callback-listener") {

            @Override
            public void run() {
                while (!shutdown) {
                    SocketChannel socketChannel;
                    try {
                        socketChannel = serverSocketChannel.accept();
                    } catch (Exception e) {
                        if (shutdown) {
                            break;
                        } else {
                            log.warn("A hook connection could not be established; accept failed", e);
                            continue;
                        }
                    }

                    //Use execute, instead of submit, because we never get() the returned future. This avoids
                    //a little overhead inside the executor wrapping our Runnable
                    executor.execute(() -> {
                        try (SocketChannel ignored = socketChannel) { //To close the socket automatically
                            GitHookInputParser parser = new GitHookInputParser(socketChannel, tempDir, config.getBufferCapacity());
                            GitHookOutputWriter writer = new GitHookOutputWriter(socketChannel);

                            handleRawRequest(parser, writer);
                        } catch (Exception e) {
                            if (!shutdown) {
                                log.warn("Hook socket I/O failed before the repository/hook could be identified", e);
                            }
                        }
                    });
                }
            }
        };
        listenerThread.setDaemon(true);
        listenerThread.start();

        log.info("Git hook callback socket listening on {}:{}",
                getLocalAddress().getAddress().getHostAddress(), getLocalAddress().getPort());
    }

    protected GitHookHandler getDefaultHandler(String hookName) {
        return handlersByName.get(hookName);
    }

    @VisibleForTesting
    void handleRawRequest(GitHookInputParser parser, GitHookOutputWriter writer) throws IOException {
        //First part = context id
        HookContextId contextId = parser.readContextId();
        log.trace("Received hook callback with request ID {}", contextId);

        //Create request and fetch hook state
        HookContext hookContext = getHookContext(contextId);
        if (hookContext == null) {
            log.warn("Could not find hook state for request {}", contextId);
            writer.writeExitCode(1);
            return;
        }

        Path gitDir = Paths.get(trim(parser.readChunk(GIT_DIR)));
        Repository repository = hookContext.getRepository();
        if (!isGitDir(repository, gitDir)) {
            HookRequest request = parser.readRequest();
            log.warn("{} hook invocation detected in {} for git operation started from {} (path = {})",
                    request.getHookName(), gitDir, repository, repository.getPath());
            writer.writeExitCode(0);
            return;
        }

        //This needs to be done before any profiling, to ensure the logging MDC has been set up to
        //include details about the original request
        hookContext.applyAndRun(() -> {
            try (Ticker ignored = Timers.timer("git hook: process callback").start(hookContext.getRepository())) {
                int exitCode = 1; //Default to rejecting the hook
                HookRequest request = null;
                try {
                    try (Ticker ignore = Timers.start("git hook: read request")) {
                        request = parser.readRequest();
                    }
                    try (Ticker ignore = GitTimers.hook(request.getHookName()).start()) {
                        exitCode = handleRequest(hookContext, request, writer) ? 0 : 1;
                    }
                } catch (BufferOverflowException e) {
                    try {
                        writer.writeStderr("The hook request is too large to process.");
                    } catch (IOException ioException) {
                        log.warn("{}: Hook handling failed", request, e);
                    }
                } catch (Exception e) {
                    if (!shutdown) {
                        if (request == null) {
                            log.error("{}: Failed reading hook request", hookContext.getRepository(), e);
                        } else if (isLikelyDisconnect(e)) {
                            log.trace("{}: Client disconnected while handling hook", request, e);
                        } else {
                            log.warn("{}: Hook handling failed", request, e);
                        }
                        if (e instanceof CommandFailedException) {
                            //Return the process exit code if it's non-zero
                            exitCode = Math.max(1, ((CommandFailedException) e).getExitCode());
                        }
                    }
                } finally {
                    hookContext.setCacheResult(writer.getCacheResult());
                    hookContext.setFilter(writer.getFilter());
                    hookContext.setOperationType(writer.getOperationType());

                    //If a request was successfully parsed, ensure its stdin is released
                    if (request != null) {
                        request.getStdin().release();
                    }

                    //No matter what, we want to try and write an exit code to the callback. Otherwise, by
                    //default, the hook callback returns 0 and the request is accepted
                    try {
                        writer.writeExitCode(exitCode);
                    } catch (IOException e) {
                        if (isLikelyDisconnect(e)) {
                            log.trace("{}: Client disconnected before exit code was written",
                                    request == null ? hookContext.getRepository() : request, e);
                        } else {
                            log.warn("{}: Writing exit code failed",
                                    request == null ? hookContext.getRepository() : request, e);
                        }
                    }
                }
            }
        });
    }

    @VisibleForTesting
    boolean handleRequest(HookContext hookContext, HookRequest request, GitHookOutputWriter writer) {
        DefaultGitHookRequestHandle handle = hookContext.getHandle();
        handle.setCalled();

        String hookName = request.getHookName();
        GitHookHandler handler = Arrays.stream(hookContext.getHandlers())
                .filter(h -> h.getHookName().equals(request.getHookName()))
                .findAny()
                .orElseGet(() -> getDefaultHandler(hookName));

        if (handler != null && handler.handle(hookContext.getRepository(), request, writer)) {
            handle.onHookHandlerInvoked(handler);
            handle.setAccepted();
            return true;
        }
        return false;
    }

    private static String getNativeHookCallbackResource() {
        if (IS_OS_LINUX && ("x86_64".equals(OS_ARCH) || "amd64".equals(OS_ARCH))) {
            return CALLBACK_LINUX_X86_64;
        }
        if (IS_OS_LINUX && "aarch64".equals(OS_ARCH)) {
            return CALLBACK_LINUX_AARCH64;
        }
        if (IS_OS_MAC_OSX && !"aarch64".equals(OS_ARCH)) {
            return CALLBACK_DARWIN_X86_64;
        }
        if (IS_OS_MAC_OSX) {
            return CALLBACK_DARWIN_AARCH64;
        }
        return null;
    }

    private static boolean isGitDir(Repository repository, Path path) {
        Path repoPath = repository.getPath();
        if (path.equals(repoPath)) {
            return true;
        }
        try {
            if (repoPath.toRealPath().equals(path.toRealPath())) {
                return true;
            }
        } catch (IOException e) {
            //Ignore
        }
        return false;
    }

    /**
     * Applies some simple heuristics to try and detect errors that are likely triggered by a client aborting
     * their request while the server was still processing it (like Ctrl+C'ing a {@code git clone}).
     *
     * @param e the exception that was thrown
     * @return {@code true} if the exception was likely caused by a client disconnecting; otherwise, {@code false}
     *         if it's more likely the exception represents a failure in hook processing
     */
    private static boolean isLikelyDisconnect(Exception e) {
        String message = Throwables.getRootCause(e).getMessage();
        if (message == null) {
            //Unlikely, but the getMessage() contract does not forbid it
            return false;
        }

        String lowerMessage = message.toLowerCase(Locale.ROOT);
        return "broken pipe".equals(lowerMessage) ||
                "stream closed".equals(lowerMessage) ||
                lowerMessage.startsWith("connection reset by peer");
    }

    private HookContext getHookContext(HookContextId contextId) {
        if (contextId.hasHookRequestId()) {
            return stateByRequest.get(contextId.getHookRequestId());
        }
        //No hook request ID provided, a repository ID must be present
        Repository repository = repositoryManager.findById(contextId.getRepositoryId());
        if (repository == null) {
            log.warn("Repository {} not found", contextId.getRepositoryId());
            return null;
        }

        String executionId = contextId.getExecutionContextId();
        ExecutionContext context;
        if (isNotBlank(executionId)) {
            context = executionManager.getContext(contextId.getExecutionContextId()).orElse(null);
            if (context == null) {
                log.warn("Request context {} not found", contextId.getExecutionContextId());
                return null;
            }
        } else {
            log.debug("[{}] Hook invocation detected without an execution context present", repository);
            context = executionManager.withContext(ctx -> ctx);
        }

        DefaultGitHookRequestHandle handle = new DefaultGitHookRequestHandle(repository, contextId.getExecutionContextId());
        return new HookContext(context, handle, repository);
    }

    private String getHookResource() {
        if (verifiedHookResource == null) {
            if (config.isNativeCallbackEnabled()) {
                String hookExec = getNativeHookCallbackResource();
                if (hookExec == null) {
                    log.warn("No native hook callback is available for this platform ({} {} {}). " +
                            "Falling back to the perl version.", OS_NAME, OS_VERSION, OS_ARCH);
                    hookExec = CALLBACK_FALLBACK;
                } else if (!isHookCallbackValid(hookExec)) {
                    log.warn("Native hook callback '{}' is incompatible with the current platform ({} {} {}). " +
                            "Falling back to the perl version.", hookExec, OS_NAME, OS_VERSION, OS_ARCH);
                    hookExec = CALLBACK_FALLBACK;
                } else {
                    log.debug("Installing native hook callback '{}' for platform {} {} {}.",
                            hookExec, OS_NAME, OS_VERSION, OS_ARCH);
                }
                verifiedHookResource = hookExec;
            } else {
                log.debug("Native hook callbacks have been disabled. Installing the perl version.");
                verifiedHookResource = CALLBACK_FALLBACK;
            }
        }
        return verifiedHookResource;
    }

    private String getHostAddress() {
        try {
            return getLocalAddress().getAddress().getHostAddress();
        } catch (IOException e) {
            return "";
        }
    }

    private InetSocketAddress getLocalAddress() throws IOException {
        return (InetSocketAddress) serverSocketChannel.getLocalAddress();
    }

    private int getPort() {
        try {
            return getLocalAddress().getPort();
        } catch (IOException e) {
            return -1;
        }
    }

    private boolean isHookCallbackValid(String resource) {
        Path tmpFile = null;
        try {
            tmpFile = Files.createTempFile("mesh-callback", "");
            unpackHook(resource, tmpFile);
            return testHookCallback(tmpFile);
        } catch (IOException e) {
            log.debug("Failed to verify whether hook {} is valid", resource, e);
            return false;
        } finally {
            if (tmpFile != null) {
                MoreFiles.deleteQuietly(tmpFile);
            }
        }
    }

    private boolean testEndToEndCommunication(Path hook) {
        long start = System.currentTimeMillis();
        log.trace("Starting end-to-end git hook test");

        String token = UUID.randomUUID().toString();
        ConnectivityTestHookHandler testHandler = new ConnectivityTestHookHandler(hook, token);
        Repository dummyRepository = new DefaultPathRepository(hook.getParent());
        try (HookRequestHandle handle = registerRequest(dummyRepository, testHandler)) {
            //Try to execute the hook, providing all the environment variables needed to perform a successful hook
            //callback. The callback will trigger ConnectivityTestHookHandler.handle, which records the stdin received
            //from the process and writes tokens to stdout and stderr.
            ProcessBuilder builder = new ProcessBuilder(hook.toString());
            builder.environment().put("GIT_DIR", dummyRepository.getPath().toString());
            builder.environment().put("STASH_HOOK_ADDRESS", getHostAddress());
            builder.environment().put("STASH_HOOK_PORT", Integer.toString(getPort()));
            builder.environment().put("STASH_HOOK_REQUEST_ID", handle.getRequestId());
            Process process = builder.start();

            //Write to stdin and close it to signal the end of stdin
            String stdin = "stdin:" + token;
            try (OutputStream processStdin = process.getOutputStream()) {
                processStdin.write(stdin.getBytes(StandardCharsets.UTF_8));
            }

            try {
                //Wait for the process to finish
                if (!process.waitFor(10, TimeUnit.SECONDS)) {
                    log.warn("End-to-end git hook test timed out");
                    process.destroy();
                }
            } catch (InterruptedException interruptedException) {
                Thread.currentThread().interrupt();
            }

            //Retrieve the data written to stdout and stderr. These should be the tokens written by
            //ConnectivityTestHookHandler
            String stdout = IOUtils.toString(process.getInputStream(), StandardCharsets.UTF_8);
            String stderr = IOUtils.toString(process.getErrorStream(), StandardCharsets.UTF_8);

            //Finally, let the handler verify that the process stdin, stdout and stderr matches what was read/written
            //by the handler
            log.trace("Completed end-to-end git hook test in {}ms (exit-code = {}, stdout = '{}', stderr = '{}')",
                    System.currentTimeMillis() - start, process.isAlive() ? "still running" : process.exitValue(),
                    stdout, stderr);
            return testHandler.verify(stdin, stdout, stderr);
        } catch (IOException e) {
            log.debug("End-to-end git hook test failed", e);
            return false;
        }
    }

    /**
     * Tests whether the provided hook callback binary can be executed and call back into Mesh
     *
     * @param hookCallbackPath the path to the hook callback binary
     * @return {@code true} if the callback succeeded, otherwise {@code false}
     */
    private boolean testHookCallback(@Nonnull Path hookCallbackPath) {
        requireNonNull(hookCallbackPath, "hookCallbackPath");

        //Ensure an execution context exists for the end-to-end communication test
        if (executionManager.getContext().isPresent()) {
            return testEndToEndCommunication(hookCallbackPath);
        }
        Boolean result = executionManager.withContext(context -> testEndToEndCommunication(hookCallbackPath));
        return result != null && result;
    }

    @VisibleForTesting
    static class HookContext {

        private final ExecutionContext executionContext;
        private final DefaultGitHookRequestHandle handle;
        private final GitHookHandler[] handlers;
        private final Repository repository;

        HookContext(ExecutionContext executionContext, DefaultGitHookRequestHandle handle,
                    Repository repository, GitHookHandler... handlers) {
            this.executionContext = executionContext;
            this.handle = handle;
            this.handlers = handlers;
            this.repository = repository;
        }

        public void applyAndRun(Runnable runnable) {
            executionContext.run(runnable);
        }

        @Nonnull
        DefaultGitHookRequestHandle getHandle() {
            return handle;
        }

        GitHookHandler[] getHandlers() {
            return handlers;
        }

        @Nonnull
        Repository getRepository() {
            return repository;
        }

        void setCacheResult(CacheResult result) {
            handle.setCacheResult(result == null ? null : HostingCacheResult.fromCacheResult(result));
        }

        void setFilter(String filter) {
            handle.setFilter(filter);
        }

        void setOperationType(HostingOperationType operationType) {
            handle.setOperationType(operationType);
        }
    }

    private class DefaultGitHookRequestHandle implements HookRequestHandle {

        private final Set<GitHookHandler> invokedHandlers;
        private final Repository repository;
        private final String requestId;

        private volatile boolean accepted;
        private volatile boolean called;

        private HostingCacheResult cacheResult;
        private String filter;
        private HostingOperationType operationType;

        DefaultGitHookRequestHandle(Repository repository, String requestId) {
            this.repository = repository;
            this.requestId = requestId;

            invokedHandlers = new HashSet<>();
        }

        @Override
        public void close() {
            stateByRequest.remove(requestId);
            invokedHandlers.forEach(handler -> {
                try {
                    handler.onHookContextClose();
                } catch (RuntimeException e) {
                    log.warn("Hook handler {}.onHookContextClose() failed", handler.getHookName(), e);
                }
            });
            //Only invoke the callbacks once
            invokedHandlers.clear();
        }

        @Override
        public <B extends GitProcessBuilderSupport<B>> B configure(@Nonnull B builder) {
            requireNonNull(builder, "builder");

            Path packObjects = repository.getHooksDir().resolve("pack-objects");
            if (Files.isRegularFile(packObjects)) {
                builder.withConfiguration(CONFIG_UPLOAD_PACK_PACK_OBJECTS_HOOK, packObjects.toString());
            }

            return builder.withEnvironment("STASH_HOOK_REQUEST_ID", getRequestId());
        }

        @Nonnull
        @Override
        public Optional<HostingCacheResult> getCacheResult() {
            return Optional.ofNullable(cacheResult);
        }

        @Nonnull
        @Override
        public Optional<String> getFilter() {
            return Optional.ofNullable(filter);
        }

        @Nonnull
        @Override
        public Optional<HostingOperationType> getOperationType() {
            return Optional.ofNullable(operationType);
        }

        @Nonnull
        @Override
        public Repository getRepository() {
            return repository;
        }

        @Nonnull
        @Override
        public String getRequestId() {
            return requestId;
        }

        @Override
        public boolean isAccepted() {
            return accepted;
        }

        @Override
        public boolean isCalled() {
            return called;
        }

        void onHookHandlerInvoked(GitHookHandler handler) {
            invokedHandlers.add(handler);
        }

        void setAccepted() {
            accepted = true;
        }

        void setCacheResult(HostingCacheResult cacheResult) {
            this.cacheResult = cacheResult;
        }

        void setCalled() {
            called = true;
        }

        void setFilter(String filter) {
            this.filter = filter;
        }

        void setOperationType(HostingOperationType operationType) {
            this.operationType = operationType;
        }
    }

    private static class ConnectivityTestHookHandler implements GitHookHandler {

        private final Path hookPath;
        private final String stderr;
        private final String stdout;

        private String stdin;

        private ConnectivityTestHookHandler(Path hookPath, String token) {
            this.hookPath = hookPath;

            stderr = "err:" + token;
            stdout = "out:" + token;
        }

        @Nonnull
        @Override
        public String getHookName() {
            return "git-" + hookPath.getFileName();
        }

        @Override
        public boolean handle(@Nonnull Repository repository, @Nonnull HookRequest request,
                              @Nonnull GitHookOutputWriter outputWriter) {
            try {
                stdin = request.getStdin().read();

                outputWriter.writeStdout(stdout);
                outputWriter.writeStderr(stderr);
                return true;
            } catch (IOException e) {
                return false;
            }
        }

        public boolean verify(String stdin, String stdout, String stderr) {
            return Objects.equals(this.stdin, stdin) && this.stderr.equals(stderr) && this.stdout.equals(stdout);
        }
    }
}
