package org.ggp.base.apps.tiltyard;

import java.io.File;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import org.ggp.base.util.crypto.BaseCryptography.EncodedKeyPair;
import org.ggp.base.util.crypto.SignableJSON;
import org.ggp.base.util.files.FileUtils;
import org.ggp.base.util.http.HttpReader;
import org.ggp.base.util.http.HttpRequest;
import org.ggp.base.util.http.HttpWriter;
import org.ggp.base.util.loader.RemoteResourceLoader;

import external.JSON.JSONArray;
import external.JSON.JSONException;
import external.JSON.JSONObject;

/**
 * The Tiltyard Request Farm is a multi-threaded web server that opens network
 * connections, makes requests, and reports back responses on behalf of a remote
 * client. It serves as a backend for intermediary systems that, due to various
 * restrictions, cannot make long-lived HTTP connections themselves.
 *
 * This is the backend for the continuously-running online GGP.org Tiltyard,
 * which schedules matches between players around the world and aggregates stats
 * based on the outcome of those matches.
 *
 * SAMPLE INVOCATION (when running locally):
 *
 * ResourceLoader.load_raw('http://127.0.0.1:9124/' + escape(JSON.stringify({
 * "targetPort":9147,"targetHost":"0.player.ggp.org","timeoutClock":30000,
 * "forPlayerName":"Webplayer-0","callbackURL":"http://tiltyard.ggp.org/farm/",
 * "requestContent":"( play foo bar baz )"})))
 *
 * Tiltyard Request Farm will open up a network connection to the target, send
 * the request string, and wait for the response. Once the response arrives, it
 * will close the connection and call the callback, sending the response to the
 * remote client that issued the original request.
 *
 * You shouldn't be running this server unless you are bringing up an instance of the
 * online GGP.org Tiltyard or an equivalent service.
 *
 * @author Sam Schreiber
 */
public final class TiltyardRequestFarm
{
    public static final int SERVER_PORT = 9125;
    private static final String registrationURL = "http://tiltyard.ggp.org/backends/register/farm";

    private static final Object requestCountLock = new Object();
    private static int activeBatches = 0;
    private static int outgoingRequests = 0;
    private static int returningRequests = 0;
    private static int abandonedBatches = 0;
    static void printBatchStats() {
        System.out.println(new Date().getTime() + " [" + new Date() + "]: now " + activeBatches + " active batches, with " + outgoingRequests + " requests outgoing, " + returningRequests + " returning; " + abandonedBatches + " batches abandoned.");
    }

    public static boolean testMode = false;

    static EncodedKeyPair getKeyPair(String keyPairString) {
        if (keyPairString == null)
            return null;
        try {
            return new EncodedKeyPair(keyPairString);
        } catch (JSONException e) {
            return null;
        }
    }
    public static final EncodedKeyPair theBackendKeys = getKeyPair(FileUtils.readFileAsString(new File("src/main/java/org/ggp/base/apps/tiltyard/BackendKeys.json")));
    public static String generateSignedPing() {
        String zone = null;
        try {
            Map<String, String> metadataRequestProperties = new HashMap<String, String>();
            metadataRequestProperties.put("Metadata-Flavor", "Google");
            zone = RemoteResourceLoader.loadRaw("http://metadata/computeMetadata/v1/instance/zone", 1, metadataRequestProperties);
        } catch (IOException e1) {
            // If we can't acquire the request farm zone, just silently drop it.
        }

        JSONObject thePing = new JSONObject();
        try {
            if (zone != null) thePing.put("zone", zone);
            thePing.put("lastTimeBlock", (System.currentTimeMillis() / 3600000));
            thePing.put("nextTimeBlock", (System.currentTimeMillis() / 3600000)+1);
            SignableJSON.signJSON(thePing, theBackendKeys.thePublicKey, theBackendKeys.thePrivateKey);
        } catch (JSONException e) {
            e.printStackTrace();
        }
        return thePing.toString();
    }

    static class RunSingleRequestThread extends Thread {
        String targetHost, requestContent, forPlayerName;
        int targetPort, timeoutClock;
        boolean fastReturn;
        JSONObject myResponse;
        Map<String, String> extraHeaders;

        public RunSingleRequestThread(JSONObject theJSON) throws JSONException {
            myResponse = new JSONObject();
            myResponse.put("originalRequest", theJSON);
            targetPort = theJSON.getInt("targetPort");
            targetHost = theJSON.getString("targetHost");
            timeoutClock = theJSON.getInt("timeoutClock");
            forPlayerName = theJSON.getString("forPlayerName");
            requestContent = theJSON.getString("requestContent");
            extraHeaders = new HashMap<String, String>();
            if (theJSON.has("extraHeaders")) {
                JSONObject theExtraHeaders = theJSON.getJSONObject("extraHeaders");
                for (String key : JSONObject.getNames(theExtraHeaders)) {
                    extraHeaders.put(key, theExtraHeaders.get(key).toString());
                }
            }
            if (theJSON.has("fastReturn")) {
                fastReturn = theJSON.getBoolean("fastReturn");
            } else {
                fastReturn = true;
            }
        }

        @Override
        public void run() {
            synchronized (requestCountLock) {
                outgoingRequests++;
                printBatchStats();
            }
            long startTime = System.currentTimeMillis();
            try {
                try {
                    String response = HttpRequest.issueRequest(targetHost, targetPort, forPlayerName, requestContent, timeoutClock, extraHeaders);
                    response = response.replaceAll("\\P{InBasic_Latin}", "");
                    myResponse.put("response", response);
                    myResponse.put("responseType", "OK");
                } catch (SocketTimeoutException te) {
                    myResponse.put("responseType", "TO");
                } catch (IOException ie) {
                    myResponse.put("responseType", "CE");
                }
            } catch (JSONException je) {
                throw new RuntimeException(je);
            }
            synchronized (requestCountLock) {
                outgoingRequests--;
                printBatchStats();
            }
            long timeSpent = System.currentTimeMillis() - startTime;
            if (!fastReturn && timeSpent < timeoutClock) {
                try {
                    Thread.sleep(timeoutClock - timeSpent);
                } catch (InterruptedException e) {
                    ;
                }
            }
        }

        public JSONObject getResponse() {
            return myResponse;
        }
    }

    // Connections are run asynchronously in their own threads.
    static class RunBatchRequestThread extends Thread {
        String originalRequest, callbackURL;
        Set<RunSingleRequestThread> theRequestThreads;
        Set<String> activeRequests;

        public RunBatchRequestThread(Socket connection, Set<String> activeRequests) throws IOException, JSONException {
            String line = HttpReader.readAsServer(connection);
            System.out.println(new Date().getTime() + " [" + new Date() + "] received batch request: " + line);

            String response = null;
            if (line.equals("ping")) {
                response = generateSignedPing();
            } else {
                synchronized (activeRequests) {
                    if (activeRequests.contains(line)) {
                        System.out.println("Got duplicate request; ignoring.");
                        connection.close();
                        return;
                    } else {
                        activeRequests.add(line);
                    }
                    this.activeRequests = activeRequests;
                }

                JSONObject theBatchJSON = new JSONObject(line);
                JSONArray theRequests = theBatchJSON.getJSONArray("requests");
                theRequestThreads = new HashSet<RunSingleRequestThread>();
                for (int i = 0; i < theRequests.length(); i++) {
                    JSONObject aRequest = theRequests.getJSONObject(i);
                    RunSingleRequestThread aRequestThread = new RunSingleRequestThread(aRequest);
                    theRequestThreads.add(aRequestThread);
                }
                callbackURL = theBatchJSON.getString("callbackURL");

                originalRequest = line;
                response = "okay";
            }

            HttpWriter.writeAsServer(connection, response, null);
            connection.close();
        }

        @Override
        public void run() {
            if (originalRequest == null)
                return;

            synchronized (requestCountLock) {
                activeBatches++;
                printBatchStats();
            }

            // Start running all of the requests in the batch parallel.
            for (RunSingleRequestThread aRequestThread : theRequestThreads) {
                aRequestThread.start();
            }

            // Wait for all of the requests to finish; aggregate them into a batch response.
            JSONObject responseJSON = new JSONObject();
            JSONArray responses = new JSONArray();
            for (RunSingleRequestThread aRequestThread : theRequestThreads) {
                try {
                    aRequestThread.join();
                    responses.put(aRequestThread.getResponse());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            try {
                responseJSON.put("responses", responses);
                if (!testMode) {
                    SignableJSON.signJSON(responseJSON, theBackendKeys.thePublicKey, theBackendKeys.thePrivateKey);
                }
            } catch (JSONException je) {
                je.printStackTrace();
                synchronized (requestCountLock) {
                    abandonedBatches++;
                    activeBatches--;
                    printBatchStats();
                }
                synchronized (activeRequests) {
                    activeRequests.remove(originalRequest);
                }
                return;
            }

            // Send the batch response back to the callback URL.
            synchronized (requestCountLock) {
                returningRequests++;
                printBatchStats();
            }
            int nPostAttempts = 0;
            while (true) {
                try {
                    RemoteResourceLoader.postRawWithTimeout(callbackURL, responseJSON.toString(), Integer.MAX_VALUE);
                    break;
                } catch (IOException ie) {
                    nPostAttempts++;
                    try {
                        Thread.sleep(nPostAttempts < 10 ? 1000 : 15000);
                    } catch (InterruptedException e) {
                        ;
                    }
                }
            }
            synchronized (requestCountLock) {
                returningRequests--;
                activeBatches--;
                printBatchStats();
                if (activeBatches == 0) {
                    System.gc();
                    System.out.println("Garbage collecting since there are no active batches.");
                }
            }
            synchronized (activeRequests) {
                activeRequests.remove(originalRequest);
            }
        }
    }

    static class TiltyardRegistration extends Thread {
        @Override
        public void run() {
            // Send a registration ping to Tiltyard every five minutes.
            while (true) {
                try {
                    RemoteResourceLoader.postRawWithTimeout(registrationURL, generateSignedPing(), 2500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                try {
                    Thread.sleep(5 * 60 * 1000);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
        try (ServerSocket listener = new ServerSocket(SERVER_PORT)) {
            if (!testMode) {
                if (theBackendKeys == null) {
                    System.err.println("Could not load cryptographic keys for signing request responses.");
                    return;
                }
                new TiltyardRegistration().start();
            }

            Set<String> activeRequests = new HashSet<String>();
            while (true) {
                try {
                    Socket connection = listener.accept();
                    RunBatchRequestThread handlerThread = new RunBatchRequestThread(connection, activeRequests);
                    handlerThread.start();
                } catch (Exception e) {
                    System.err.println(e);
                }
            }
        } catch (IOException e) {
            System.err.println("Could not open server on port " + SERVER_PORT + ": " + e);
            e.printStackTrace();
            return;
        }
    }
}
