/*
 * Copyright 2016 Martijn Brekhof. All rights reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.xbmc.kore.testutils.tcpserver;

import androidx.annotation.NonNull;

import okhttp3.internal.Util;

import org.xbmc.kore.utils.LogUtils;

import java.io.IOException;

import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Collections;
import java.util.Iterator;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

import javax.net.ServerSocketFactory;

public class MockTcpServer {
    public static final String TAG = LogUtils.makeLogTag(MockTcpServer.class);

    private final ServerSocketFactory serverSocketFactory = ServerSocketFactory.getDefault();
    private ServerSocket serverSocket;
    private boolean running;
    private ExecutorService executor;
    private int port = -1;
    private InetSocketAddress inetSocketAddress;

    private final Set<Socket> openClientSockets =
            Collections.newSetFromMap(new ConcurrentHashMap<>());

    private final TcpServerConnectionHandler connectionHandler;

    // TODO
    // Enhance handler to handle multiple connections simultaneously. It can now handle one
    // connection at a time, which makes the current setup of the MockTcpServer (with threading)
    // overkill.
    public interface TcpServerConnectionHandler {
        /**
         * Processes received input
         */
        void processInput(Socket socket);

        /**
         * Gets the answer for this handler that should be returned to the server after input has been
         * processed successfully
         * @return answer or null if no answer is available
         */
        String getResponse();
    }

    public MockTcpServer(TcpServerConnectionHandler handler) {
        connectionHandler = handler;
    }

    /**
     * Starts the server on localhost on a random free port
     */
    public void start() throws IOException {
        start(new InetSocketAddress(InetAddress.getByName("localhost"), 0));
    }

    /**
     *
     * @param inetSocketAddress set portnumber to 0 to select a random free port
     */
    public void start(InetSocketAddress inetSocketAddress) throws IOException {
        if (running) throw new IllegalStateException("start() already called");
        running = true;
        this.inetSocketAddress = inetSocketAddress;

        serverSocket = serverSocketFactory.createServerSocket();
        // Reuse port if not using a random port
        serverSocket.setReuseAddress(inetSocketAddress.getPort() != 0);
        serverSocket.bind(inetSocketAddress, 50);

        executor = Executors.newCachedThreadPool(Util.threadFactory("MockTcpServer", false));

        port = serverSocket.getLocalPort();

        LogUtils.LOGD(TAG, "start: server started on " + serverSocket.getInetAddress() + ":" + port);

        executor.execute(new Runnable() {
            @Override
            public void run() {
                while (running) {
                    try {
                        Socket socket = acceptConnection();
                        serveConnection(socket);
                    } catch(IOException e){
                        //Socket closed
                        LogUtils.LOGD(TAG, "acceptConnection: " + e.getMessage());
                    }
                }
            }

            private Socket acceptConnection() throws IOException {
                Socket socket = serverSocket.accept();

                synchronized (openClientSockets) {
                    openClientSockets.add(socket);
                }

                return socket;
            }
        });
    }

    public synchronized void shutdown() throws IOException {
        if (!running) return;

        if (serverSocket == null) throw new IllegalStateException("shutdown() before start()");

        running = false;

        // Release all sockets and all threads, even if any close fails.
        for (Iterator<Socket> s = openClientSockets.iterator(); s.hasNext(); ) {
            Socket socket = s.next();
            Util.closeQuietly(socket);
            s.remove();
        }
        Util.closeQuietly(serverSocket);

        executor.shutdown();

        // Await shutdown.
        try {
            if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
                throw new IOException("Gave up waiting for executor to shut down");
            }
        } catch (InterruptedException e) {
            LogUtils.LOGD(TAG, "shutdown: " + e.getMessage());
        }
    }

    /**
     * Gets the local port of this server socket or -1 if it is not bound
     * @return the local port this server is listening on.
     */
    public int getPort() {
        return port;
    }

    public String getHostName() {
        if ( inetSocketAddress == null )
            throw new RuntimeException("Must start server before getting hostname");

        return inetSocketAddress.getHostName();
    }

    private void serveConnection(final Socket socket) {
        executor.execute(() -> {
            try {
                LogUtils.LOGD(TAG, "serveConnection: handling client " + socket.getInetAddress()
                                   + ":" + socket.getLocalPort());

                connectionHandler.processInput(socket);
                socket.close();

                synchronized (openClientSockets) {
                    openClientSockets.remove(socket);
                }
            } catch (IOException e) {
                LogUtils.LOGW(TAG, "processing input from " + socket.getInetAddress() + " failed: " + e);
            }
        });

        executor.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    while ( ! (serverSocket.isClosed() || socket.isClosed()) ) {
                        sendResponse();
                        Thread.sleep(100);
                    }
                } catch (IOException e) {
                    LogUtils.LOGW(TAG, " sending response from " + socket.getInetAddress() + " failed: " + e);
                } catch (InterruptedException e) {
                    LogUtils.LOGW(TAG, " wait interrupted" + e);
                }
            }

            private void sendResponse() throws IOException {
                PrintWriter out =
                        new PrintWriter(socket.getOutputStream(), false);
                String answer = connectionHandler.getResponse();
                if (answer != null) {
                    LogUtils.LOGD(TAG, "serveConnection: sendResponse: " +answer);
                    out.print(answer);
                    out.flush();
                }
            }
        });
    }

    @NonNull
    @Override
    public String toString() {
        return "MockTcpServer[" + port + "]";
    }
}
