/*
 * Copyright 2014 the original author or authors.
 *
 * 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.gradle.test.fixtures.server.http;

import junit.framework.AssertionFailedError;
import org.gradle.api.UncheckedIOException;
import org.gradle.internal.os.OperatingSystem;
import org.junit.rules.ExternalResource;

import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.channels.AsynchronousCloseException;
import java.nio.channels.Channels;
import java.nio.channels.ClosedChannelException;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
 * Allows the test process and a single build process to synchronize.
 *
 * TODO - merge into {@link BlockingHttpServer} as they now have overlapping APIs.
 */
public class CyclicBarrierHttpServer extends ExternalResource {
    private ExecutorService executor;
    private ServerSocketChannel serverSocket;
    private final Object lock = new Object();
    private boolean connected;
    private boolean released;
    private boolean stopped;

    @Override
    protected void before() {
        start();
    }

    @Override
    protected void after() {
        stop();
    }

    void start() {
        // Note: this is implemented using raw sockets. Originally implemented using Jetty, but some concurrency problems there caused Jetty to hang
        try {
            serverSocket = ServerSocketChannel.open();
            serverSocket.socket().bind(new InetSocketAddress(0));
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }

        executor = Executors.newCachedThreadPool();
        executor.execute(new Runnable() {
            public void run() {
                int i = 0;

                while (true) {
                    try {
                        SocketChannel connection;
                        try {
                            connection = serverSocket.accept();
                        } catch (AsynchronousCloseException e) {
                            // Socket has been closed, so we're stopping
                            return;
                        } catch (ClosedChannelException e) {
                            // Socket has been closed, so we're stopping
                            return;
                        }
                        try {
                            OutputStream outputStream = Channels.newOutputStream(connection);
                            System.out.println("Handle connection request no." + (++i));
                            handleConnection(outputStream);
                            outputStream.flush();
                        } finally {
                            connection.close();
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }

            private void handleConnection(OutputStream outputStream) throws IOException {
                System.out.println("Handling HTTP request");

                synchronized (lock) {
                    if (connected) {
                        System.out.println("Received unexpected connection.");
                        outputStream.write("HTTP/1.1 500 Received an unexpected connection.\r\nConnection: close\r\nContent-length: 0\r\n\r\n".getBytes());
                        return;
                    }

                    System.out.println("Connection received");
                    connected = true;
                    lock.notifyAll();

                    long expiry = monotonicClockMillis() + 30000;
                    while (!released && !stopped) {
                        long delay = expiry - monotonicClockMillis();
                        if (delay <= 0) {
                            System.out.println("Timeout waiting for client to be released.");
                            outputStream.write("HTTP/1.1 500 Timeout waiting for client to be released.\r\nConnection: close\r\nContent-length: 0\r\n\r\n".getBytes());
                            return;
                        }
                        try {
                            lock.wait(delay);
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                    }
                    if (stopped) {
                        System.out.println("Releasing client on stop.");
                        outputStream.write("HTTP/1.1 500 Server stopped.\r\nConnection: close\r\nContent-length: 0\r\n\r\n".getBytes());
                        return;
                    }

                    connected = false;
                    released = false;
                    lock.notifyAll();
                }

                System.out.println("Sending response to client");
                outputStream.write("HTTP/1.1 200 Ok.\r\nConnection: close\r\nContent-length: 0\r\n\r\n".getBytes());
            }
        });
    }

    void stop() {
        System.out.println("Stopping server");
        synchronized (lock) {
            stopped = true;
            lock.notifyAll();
        }
        try {
            serverSocket.close();
            executor.shutdown();
            executor.awaitTermination(30, TimeUnit.SECONDS);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public URI getUri() {
        int port = serverSocket.socket().getLocalPort();
        if (port <= 0) {
            throw new IllegalStateException(String.format("Unexpected port %s for HTTP server.", port));
        }
        try {
            return new URI(String.format("http://localhost:%s", port));
        } catch (URISyntaxException e) {
            throw new RuntimeException(e);
        }
    }

    public boolean waitFor() {
        return waitFor(true, 20);
    }

    public boolean waitFor(boolean failAtTimeout) {
        return waitFor(failAtTimeout, 20);
    }

    public boolean waitFor(int timeoutSeconds) {
        return waitFor(true, timeoutSeconds);
    }

    /**
     * Blocks until a connection to the URI has been received. No response is returned to the client until
     * {@link #release()} is called.
     *
     * @param failAtTimeout if client connection timeout occurs: should the build fail directly or return with 'false'? (if it fails, it might hide the original error)
     * @param timeoutSeconds timeout in seconds
     *
     * @return false on timeout
     */
    public boolean waitFor(boolean failAtTimeout, int timeoutSeconds) {
        long expiry = monotonicClockMillis() + timeoutSeconds * 1000;
        synchronized (lock) {
            while (!connected && !stopped) {
                long delay = expiry - monotonicClockMillis();
                if (delay <= 0) {
                    String message = String.format("Timeout waiting for client to connect to %s.", getUri());
                    if (failAtTimeout) {
                        throw new AssertionFailedError(message);
                    } else {
                        System.out.println(message);
                        return false;
                    }
                }
                System.out.println("waiting for client to connect");
                try {
                    lock.wait(delay);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            if (stopped) {
                throw new AssertionFailedError(String.format("Server was stopped while waiting for client to connect to %s.", getUri()));
            }
            System.out.println("client connected - unblocking");
            return true;
        }
    }

    private long monotonicClockMillis() {
        return System.nanoTime() / 1000000L;
    }

    /**
     * Sends a response back on the connection.
     */
    public void release() {
        // TODO(radim): quick socket operation on Windows is not noticed by client
        // and it re-opens the connection immediately. Need to find a better way here.
        if (OperatingSystem.current().isWindows()) {
            try {
                Thread.sleep(2000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        synchronized (lock) {
            released = true;
            lock.notifyAll();
        }
    }

    /**
     * Blocks until a connection to the URI has been received, then sends a response back to the client and returns.
     *
     * <p>Note that this method will generally return before the client has received the response.
     */
    public void sync() {
        sync(20);
    }

    public void sync(final int timeoutSeconds) {
        synchronized (lock) {
            waitFor(timeoutSeconds);
            release();
        }
    }
}
