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

import com.google.common.base.Charsets;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.file.NoSuchFileException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;

import static com.google.common.base.Preconditions.checkState;

public class DefaultTranscodeServer implements TranscodeServer {

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

    private final ExecutorService executorService;
    private final String transcodeAddress;
    private final int transcodePort;
    private final Transcoder transcoder;

    private ServerSocket serverSocket;
    private Thread serverThread;

    public DefaultTranscodeServer(ExecutorService executorService, Transcoder transcoder,
                                  String transcodeAddress, int transcodePort) {
        this.executorService = executorService;
        this.transcoder = transcoder;
        this.transcodeAddress = transcodeAddress;
        this.transcodePort = transcodePort;
    }

    @Override
    @Nonnull
    public String getAddress() {
        checkRunning();

        return serverSocket.getInetAddress().getHostAddress();
    }

    @Override
    public int getPort() {
        checkRunning();

        return serverSocket.getLocalPort();
    }

    @Override
    public boolean isRunning() {
        return serverSocket != null && !serverSocket.isClosed();
    }

    public void start() throws IOException {
        serverSocket = new ServerSocket(transcodePort, 0, InetAddress.getByName(transcodeAddress));
        log.info("Callback socket listening at {}:{}",
                serverSocket.getInetAddress().getHostAddress(), serverSocket.getLocalPort());

        //Accepting connections will be a long-running task (generally for the entire duration the server is up),
        //so it is _not_ done with the ExecutorService (where it would effectively reduce the size by 1). However,
        //because transcoding is a one-and-done affair, transcode operations are done using the ExecutorService
        serverThread = new Thread("git-transcode-listener") {

            @Override
            public void run() {
                int exceptions = 0;
                while (true) {
                    try {
                        Socket socket = serverSocket.accept();
                        exceptions = 0;

                        executorService.submit(new TranscodeChannelHandler(socket));
                    } catch (IOException e) {
                        if (serverSocket.isClosed()) {
                            //If the socket was closed, it must mean the server is shutting down
                            log.debug("The server socket has been closed; stopping...");
                            break;
                        }

                        if (exceptions++ < 4) {
                            log.warn("Accepting a request has failed; will retry ({}/5)", exceptions, e);
                        } else {
                            log.error("Accepting a request has failed {} times; abandoning", exceptions, e);
                        }
                    }
                }
            }
        };
        serverThread.setDaemon(true);
        serverThread.start();
    }

    public void stop() throws IOException {
        if (serverSocket != null) {
            log.debug("Closing callback socket");
            serverSocket.close();

            if (serverThread != null) {
                //Closing the server socket should bring down the acceptor thread automatically
                try {
                    serverThread.join(TimeUnit.SECONDS.toMillis(5));
                    log.debug("The acceptor thread has stopped cleanly");
                } catch (InterruptedException e) {
                    log.warn("The acceptor thread did not stop in the allotted time. It will be abandoned");
                }
                serverThread = null; //Whether it stopped or not, we're done trying
            }

            //This is done after shutting down the server thread to allow it to see the socket was closed
            serverSocket = null;
        }
    }

    private void checkRunning() {
        checkState(serverSocket != null, "The transcode server is not running.");
    }

    private class TranscodeChannelHandler implements Runnable {

        private final Socket socket;

        private TranscodeChannelHandler(Socket socket) {
            this.socket = socket;
        }

        @Override
        public void run() {
            try (Socket ignored = socket; //Ensure the socket is closed
                 InputStream inputStream = socket.getInputStream();
                 OutputStream outputStream = socket.getOutputStream()) {
                transcode(inputStream, outputStream);
            } catch (Exception e) {
                log.warn("The request could not be processed", e);
            }
        }

        private void transcode(InputStream inputStream, OutputStream outputStream) throws IOException {
            //Read the path, which is expected to be the only thing the client will provide. Note that
            //something like CharStreams.toString is not used here because that would allow a malicious
            //client to stream arbitrary data and consume server resources. Instead, at most 16k will
            //be read and converted to a path. 16k is used to allow room for wide Unicode characters.
            byte[] buffer = new byte[16384];
            int read = inputStream.read(buffer);

            String path = new String(buffer, 0, read, Charsets.UTF_8);
            if (read == buffer.length) {
                log.warn("The \"path\" provided to transcode is absurdly long ({}); ignoring request", path);
                return; //Drop the transcode request without providing any output
            }
            socket.shutdownInput(); //After reading the path, we will not read anything else

            try {
                transcoder.transcode(path, outputStream);
                log.debug("Transcode request completed");
            } catch (FileNotFoundException | NoSuchFileException e) {
                //If the requested file has been removed, the most likely reason is the Git process that
                //created it was canceled and cleaned up its temporary files before the server read it.
                //Since the exact reason the file is gone isn't known this is still logged as a warning,
                //but without the stack trace
                log.warn("{} could not be transcoded; the file no longer exists (Canceled process?)", path);
            } catch (IllegalArgumentException e) {
                //If the transcode cannot be performed for any reason, just drop the request without
                //returning any output. This will cause the transcode.pl script (if that's the client)
                //to return a non-zero exit code which will, in turn, make git blow up
                log.warn("{} could not be transcoded; ignoring request", path, e);
            }
        }
    }
}
