package com.atlassian.bitbucket.mesh.test.util.http;

import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilder;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.process.OsProcess;
import com.atlassian.bitbucket.mesh.process.ProcessCompletionHandler;
import com.atlassian.bitbucket.mesh.process.ProcessSummary;
import com.atlassian.bitbucket.mesh.process.handler.AbstractProcessStdioHandler;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.repository.RepositoryManager;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.concurrent.CustomizableThreadFactory;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Collections.emptySet;
import static java.util.Objects.requireNonNull;

public class TestGitHttpServer implements AutoCloseable {

    private static final Base64.Decoder BASE64 = Base64.getDecoder();
    private static final Logger log = LoggerFactory.getLogger(TestGitHttpServer.class);

    private final GitProcessBuilderFactory builderFactory;
    private final Map<String, String> credentials;
    private final Map<String, Set<String>> permissions;
    private final RepositoryManager repositoryManager;
    private ExecutorService executorService;
    private HttpServer httpServer;

    public TestGitHttpServer(GitProcessBuilderFactory builderFactory, RepositoryManager repositoryManager) {
        this.builderFactory = builderFactory;
        this.repositoryManager = repositoryManager;

        credentials = new HashMap<>();
        permissions = new HashMap<>();
    }

    /**
     * Registers the username and password with the HTTP server.
     *
     * @param username the username
     * @param password the password
     */
    public void addCredentials(@Nonnull String username, @Nonnull String password) {
        requireNonNull(username, "username");
        requireNonNull(password, "password");

        credentials.put(username, password);
    }

    @Override
    public void close() {
        stop();
    }

    /**
     * Generates a HTTP clone URL that can be used to access the repository over HTTP. To avoid unsafe characters in the
     * clone URL the {@code repositoryID} string is base64 encoded. The resulting clone URL is of the form
     * {@code http://[username] [at] localhost:[http-port]/[base64-encoded-repository-id]}. Pardon the use of [at] here,
     * it's written in this way to avoid the security scanner tripping over a potentially unsafe URL in the code.
     *
     * @param username   the username
     * @param repository the ID of the repository for which to generate a clone URL
     * @return the clone URL for the provided user and repository
     */
    public String getCloneUrl(@Nonnull String username, @Nonnull String repository) {
        requireNonNull(username, "username");
        requireNonNull(repository, "repositoryId");

        grantAccess(username, repository);
        String path = Base64.getEncoder().encodeToString(
                requireNonNull(repository).getBytes(UTF_8));

        return "http://" +
               requireNonNull(username, "username") +
               "@localhost:" +
               httpServer.getAddress().getPort() +
               "/" + path;
    }

    public OptionalInt getPort() {
        if (httpServer == null) {
            return OptionalInt.empty();
        }
        return OptionalInt.of(httpServer.getAddress().getPort());
    }

    public void grantAccess(@Nonnull String username, @Nonnull String repository) {
        requireNonNull(username, "username");
        requireNonNull(repository, "repository");

        permissions.computeIfAbsent(repository, ignored -> new HashSet<>()).add(username);
    }

    public void reset() {
        credentials.clear();
        permissions.clear();
    }

    public void revokeAccess(@Nonnull String username, @Nonnull String repository) {
        requireNonNull(username, "username");
        requireNonNull(repository, "repository");

        permissions.computeIfPresent(repository, (ignored, users) -> {
            users.remove(username);
            return users;
        });
    }

    public void start() {
        if (httpServer == null) {
            try {
                executorService =
                        Executors.newCachedThreadPool(new CustomizableThreadFactory("test-git-http-server"));
                httpServer = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
                httpServer.setExecutor(executorService);
                httpServer.createContext("/", new GitHttpBackendHandler());
                httpServer.start();
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        }
    }

    public void stop() {
        if (httpServer != null) {
            executorService.shutdown();
            try {
                executorService.awaitTermination(1, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                executorService.shutdownNow();
            }
            httpServer.stop(1);
            credentials.clear();
            permissions.clear();
            httpServer = null;
        }
    }

    private static class SimpleCgiStdioHandler extends AbstractProcessStdioHandler<Void> implements ProcessCompletionHandler {

        private final HttpServletRequest request;
        private final HttpServletResponse response;

        private boolean headersRead;
        private ReadableByteChannel inputChannel;
        private WritableByteChannel outputChannel;

        SimpleCgiStdioHandler(HttpServletRequest request, HttpServletResponse response) {
            this.request = request;
            this.response = response;
        }

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

        @Override
        public void onComplete(@Nonnull ProcessSummary summary) {
            if (summary.getExitCode() != 0) {
                throw new IllegalStateException(
                        "Command '" + summary.getCommandLine() + "' failed (" + summary.getExitCode() + "): " +
                        summary.getStderr());
            }
        }

        @Override
        public void onExit(int exitCode) {
            try {
                response.flushBuffer();
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        }

        @Override
        public void onStart(@Nonnull OsProcess process) {
            process.wantWrite();
            try {
                inputChannel = Channels.newChannel(request.getInputStream());
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        }

        @Override
        public boolean onStdinReady(@Nonnull ByteBuffer stdin) {
            try {
                boolean done = inputChannel.read(stdin) == -1;
                stdin.flip();
                if (done) {
                    closeStdin();
                }
                return !done;
            } catch (IOException e) {
                process.cancel();
                throw new UncheckedIOException(e);
            }
        }

        @Override
        public void onStdout(@Nonnull ByteBuffer stdout, boolean closed) {
            try {
                if (!headersRead) {
                    while (true) {
                        String line = getTextLineFromBuffer(stdout);
                        if (line == null) {
                            //If we make it here, we've run out of bytes to consider in the buffer and we haven't found
                            //the blank line that indicates all the headers have been processed
                            if (closed) {
                                //If stdout is closed, this will be the last callback. That's unexpected, because it
                                //means we never got to the end of the HTTP headers. Perhaps the process failed?
                                log.warn("Unexpected EOF copying SCM hosting process output to response");
                            }
                            return;
                        }
                        if (line.isEmpty()) {
                            //All the HTTP headers have been processed, so now it's time to start copying the body
                            headersRead = true;
                            outputChannel = Channels.newChannel(response.getOutputStream());
                            break;
                        }
                        String[] nameAndValue = line.split(": ", 2);
                        if ("Status".equalsIgnoreCase(nameAndValue[0])) {
                            String[] statusCodeAndText = nameAndValue[1].split(" ", 2);
                            response.setStatus(Integer.parseInt(statusCodeAndText[0]));
                        } else {
                            response.addHeader(nameAndValue[0], nameAndValue[1]);
                        }
                    }
                }

                outputChannel.write(stdout);
            } catch (IOException e) {
                process.cancel();
                throw new UncheckedIOException(e);
            }
        }

        private String getTextLineFromBuffer(ByteBuffer buffer) {
            int offset = buffer.position();
            int remaining = buffer.remaining();

            for (int i = 0; i < remaining; ++i) {
                if (buffer.get(offset + i) == (byte) '\n') { //Does not advance buffer
                    byte[] bytes = new byte[i + 1]; //+1 to get the newline
                    buffer.get(bytes); //Advances buffer

                    return new String(bytes, StandardCharsets.US_ASCII).trim();
                }
            }

            return null;
        }
    }

    private class GitHttpBackendHandler implements HttpHandler {

        @Override
        public void handle(HttpExchange exchange) {
            HttpServletRequest request = new HttpServletRequestAdapter(exchange);
            HttpServletResponse response = new HttpServletResponseAdapter(exchange);
            try {
                String user = handleAuthentication(exchange);
                if (user == null) {
                    return;
                }
                URI requestUri = exchange.getRequestURI();
                String[] pathSegments =
                        requestUri.getPath().split("/", 3); // 3 = leading slash, repo path, any remaining segments
                String repoId = new String(Base64.getDecoder().decode(pathSegments[1]), UTF_8);
                String pathInfo = pathSegments[2];

                if (!permissions.getOrDefault(repoId, emptySet()).contains(user)) {
                    exchange.sendResponseHeaders(403, 0);
                    return;
                }

                Repository repo = repositoryManager.getById(repoId);
                Path repoPath = repo.getPath();

                //Use a builder not tied to a repository so we don't get auto-config magic
                GitProcessBuilder builder = builderFactory.builder()
                        .workDir(repoPath)
                        .command("http-backend");
                builder.withEnvironment("GIT_HTTP_EXPORT_ALL", "true");
                builder.withEnvironment("GIT_PROJECT_ROOT", repoPath.toString());
                builder.withEnvironment("PATH_INFO", "/" + pathInfo);
                builder.withEnvironment("REMOTE_USER", user);

                // The Git client may not send a Content-Length header. The CONTENT_LENGTH variable should only be set when it
                // is known. Omitting it causes Git to read until EOF (see http-backend.c#run_service), which is the desired
                // behavior in this case.
                if (request.getContentLengthLong() != -1L) {
                    builder.withEnvironment("CONTENT_LENGTH", Long.toString(request.getContentLengthLong()));
                }
                if (!StringUtils.isBlank(request.getContentType())) {
                    builder.withEnvironment("CONTENT_TYPE", request.getContentType());
                }
                builder.withEnvironment("GATEWAY_INTERFACE", "CGI/1.1");
                if (!StringUtils.isBlank(request.getQueryString())) {
                    builder.withEnvironment("QUERY_STRING", request.getQueryString());
                }
                // n.b. REMOTE_USER is set by RemoteUserExternalProcessConfigurer in service-impl rather than here, so that it
                // is available to hooks invoked by SSH commands as well.
                builder.withEnvironment("REMOTE_ADDR", request.getRemoteAddr());
                builder.withEnvironment("REMOTE_HOST", request.getRemoteHost());
                builder.withEnvironment("REQUEST_METHOD", request.getMethod());
                builder.withEnvironment("SERVER_NAME", request.getServerName());
                builder.withEnvironment("SERVER_SOFTWARE", "TestGitHttpServer");
                builder.withEnvironment("SERVER_PORT", Integer.toString(request.getServerPort()));
                builder.withEnvironment("SERVER_PROTOCOL", request.getProtocol());
                Enumeration<String> enm = request.getHeaderNames();
                while (enm.hasMoreElements()) {
                    String nameUpperCase = enm.nextElement().toUpperCase(Locale.ROOT);
                    String value = request.getHeader(nameUpperCase);
                    if (!value.isEmpty()) {
                        builder.withEnvironment("HTTP_" + nameUpperCase.replace('-', '_'), value);
                    }
                }

                SimpleCgiStdioHandler handler = new SimpleCgiStdioHandler(request, response);
                builder.completionHandler(handler).build(handler)
                        .start()
                        .get();
            } catch (Throwable t) {
                log.error("Unhandled exception caught", t);
            } finally {
                // Needs to be closed under any circumstances so a response is sent to the client
                exchange.close();
            }
        }

        private String handleAuthentication(HttpExchange exchange) throws IOException {
            String authorization = exchange.getRequestHeaders().getFirst("Authorization");
            if (authorization == null) {
                // Authorization header missing, provide the client with a realm
                exchange.getResponseHeaders().add("WWW-Authenticate", "Basic realm=\"Test Git Server\"");
                exchange.sendResponseHeaders(401, 0);
                return null;
            }
            if (!authorization.startsWith("Basic ")) {
                // Illegal Authorization header encountered, let the client know
                exchange.sendResponseHeaders(400, 0);
                return null;
            }
            String[] requestCredentials;
            try {
                requestCredentials = new String(BASE64.decode(authorization.substring("Basic ".length())), UTF_8)
                        .split(":", 2);
            } catch (IllegalArgumentException e) {
                // Authorization value was not valid base64
                exchange.sendResponseHeaders(400, 0);
                return null;
            }
            if (requestCredentials.length != 2) {
                // No password specified or other invalid credentials given
                exchange.getResponseHeaders().add("WWW-Authenticate", "Basic realm=\"Test Git Server\"");
                exchange.sendResponseHeaders(401, 0);
                return null;
            }
            if (!Objects.equals(credentials.get(requestCredentials[0]), requestCredentials[1])) {
                // Passwords don't match
                exchange.getResponseHeaders().add("WWW-Authenticate", "Basic realm=\"Test Git Server\"");
                exchange.sendResponseHeaders(401, 0);
                return null;
            }
            return requestCredentials[0];
        }
    }
}
