// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.net.test;

import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.IBinder;
import android.os.Looper;
import android.os.RemoteException;

import androidx.annotation.GuardedBy;

import org.junit.Assert;

import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.ThreadUtils;
import org.chromium.base.test.BaseJUnit4ClassRunner.ClassHook;
import org.chromium.base.test.util.UrlUtils;
import org.chromium.net.X509Util;
import org.chromium.net.test.util.CertTestUtil;

import java.io.File;

/**
 * A simple file server for java tests.
 *
 * An example use:
 * <pre>
 * EmbeddedTestServer s = EmbeddedTestServer.createAndStartServer(context);
 *
 * // serve requests...
 * s.getURL("/foo/bar.txt");
 *
 * // Generally safe to omit as ResettersForTesting will call it.
 * s.stopAndDestroyServer();
 * </pre>
 *
 * Note that this runs net::test_server::EmbeddedTestServer in a service in a separate APK.
 */
public class EmbeddedTestServer {
    private static final String TAG = "TestServer";

    private static final String EMBEDDED_TEST_SERVER_SERVICE =
            "org.chromium.net.test.EMBEDDED_TEST_SERVER_SERVICE";
    private static final long SERVICE_CONNECTION_WAIT_INTERVAL_MS = 5000;

    private static boolean sTestRootInitDone;

    @GuardedBy("mImplMonitor")
    private IEmbeddedTestServerImpl mImpl;
    private ServiceConnection mConn = new ServiceConnection() {
        @Override
        public void onServiceConnected(ComponentName name, IBinder service) {
            synchronized (mImplMonitor) {
                mImpl = IEmbeddedTestServerImpl.Stub.asInterface(service);
                mImplMonitor.notify();
            }
        }

        @Override
        public void onServiceDisconnected(ComponentName name) {
            synchronized (mImplMonitor) {
                mImpl = null;
                mImplMonitor.notify();
            }
        }
    };

    private Context mContext;
    private final Object mImplMonitor = new Object();
    boolean mDisableResetterForTesting;

    // Whether the server should use HTTP or HTTPS.
    public enum ServerHTTPSSetting {
        USE_HTTP,
        USE_HTTPS,
    }

    /**
     * Exception class raised on failure in the EmbeddedTestServer.
     */
    public static final class EmbeddedTestServerFailure extends Error {
        public EmbeddedTestServerFailure(String errorDesc) {
            super(errorDesc);
        }

        public EmbeddedTestServerFailure(String errorDesc, Throwable cause) {
            super(errorDesc, cause);
        }
    }

    /**
     * Connection listener class, to be notified of new connections and sockets reads.
     *
     * Notifications are asynchronous and delivered to the UI thread.
     */
    public static class ConnectionListener {
        private final IConnectionListener mListener = new IConnectionListener.Stub() {
            @Override
            public void acceptedSocket(final long socketId) {
                ThreadUtils.runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        ConnectionListener.this.acceptedSocket(socketId);
                    }
                });
            }

            @Override
            public void readFromSocket(final long socketId) {
                ThreadUtils.runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        ConnectionListener.this.readFromSocket(socketId);
                    }
                });
            }
        };

        /**
         * A new socket connection has been opened on the server.
         *
         * @param socketId Socket unique identifier. Unique as long as the socket stays open.
         */
        public void acceptedSocket(long socketId) {}

        /**
         * Data  has been read from a socket.
         *
         * @param socketId Socket unique identifier. Unique as long as the socket stays open.
         */
        public void readFromSocket(long socketId) {}

        private IConnectionListener getListener() {
            return mListener;
        }
    }

    /** Bind the service that will run the native server object.
     *
     *  @param context The context to use to bind the service. This will also be used to unbind
     *          the service at server destruction time.
     *  @param httpsSetting Whether the server should use HTTPS.
     */
    public void initializeNative(Context context, ServerHTTPSSetting httpsSetting) {
        mContext = context;

        Intent intent = new Intent(EMBEDDED_TEST_SERVER_SERVICE);
        setIntentClassName(intent);
        if (!mContext.bindService(intent, mConn, Context.BIND_AUTO_CREATE)) {
            throw new EmbeddedTestServerFailure(
                    "Unable to bind to the EmbeddedTestServer service.");
        }
        synchronized (mImplMonitor) {
            Log.i(TAG, "Waiting for EmbeddedTestServer service connection.");
            while (mImpl == null) {
                try {
                    mImplMonitor.wait(SERVICE_CONNECTION_WAIT_INTERVAL_MS);
                } catch (InterruptedException e) {
                    // Ignore the InterruptedException. Rely on the outer while loop to re-run.
                }
                Log.i(TAG, "Still waiting for EmbeddedTestServer service connection.");
            }
            Log.i(TAG, "EmbeddedTestServer service connected.");
            boolean initialized = false;
            try {
                initialized = mImpl.initializeNative(httpsSetting == ServerHTTPSSetting.USE_HTTPS);
            } catch (RemoteException e) {
                Log.e(TAG, "Failed to initialize native server.", e);
                initialized = false;
            }

            if (!initialized) {
                throw new EmbeddedTestServerFailure("Failed to initialize native server.");
            }
            if (!mDisableResetterForTesting) {
                ResettersForTesting.register(this::stopAndDestroyServer);
            }

            if (httpsSetting == ServerHTTPSSetting.USE_HTTPS) {
                try {
                    String rootCertPemPath = mImpl.getRootCertPemPath();
                    X509Util.addTestRootCertificate(CertTestUtil.pemToDer(rootCertPemPath));
                } catch (Exception e) {
                    throw new EmbeddedTestServerFailure(
                            "Failed to install root certificate from native server.", e);
                }
            }
        }
    }

    /** Set intent package and class name that will pass to the service.
     *
     *  @param intent The intent to use to pass into the service.
     */
    protected void setIntentClassName(Intent intent) {
        intent.setClassName(
                "org.chromium.net.test.support", "org.chromium.net.test.EmbeddedTestServerService");
    }

    /** Add the default handlers and serve files from the provided directory relative to the
     *  external storage directory.
     *
     *  @param directory The directory from which files should be served relative to the external
     *      storage directory.
     */
    public void addDefaultHandlers(File directory) {
        addDefaultHandlers(directory.getPath());
    }

    /** Add the default handlers and serve files from the provided directory relative to the
     *  external storage directory.
     *
     *  @param directoryPath The path of the directory from which files should be served relative
     *      to the external storage directory.
     */
    public void addDefaultHandlers(String directoryPath) {
        try {
            synchronized (mImplMonitor) {
                checkServiceLocked();
                mImpl.addDefaultHandlers(directoryPath);
            }
        } catch (RemoteException e) {
            throw new EmbeddedTestServerFailure(
                    "Failed to add default handlers and start serving files from " + directoryPath
                    + ": " + e.toString());
        }
    }

    /** Configure the server to use a particular type of SSL certificate.
     *
     * @param serverCertificate The type of certificate the server should use.
     */
    public void setSSLConfig(@ServerCertificate int serverCertificate) {
        try {
            synchronized (mImplMonitor) {
                checkServiceLocked();
                mImpl.setSSLConfig(serverCertificate);
            }
        } catch (RemoteException e) {
            throw new EmbeddedTestServerFailure(
                    "Failed to set server certificate: " + e.toString());
        }
    }

    /** Serve files from the provided directory.
     *
     *  @param directory The directory from which files should be served.
     */
    public void serveFilesFromDirectory(File directory) {
        serveFilesFromDirectory(directory.getPath());
    }

    /** Serve files from the provided directory.
     *
     *  @param directoryPath The path of the directory from which files should be served.
     */
    public void serveFilesFromDirectory(String directoryPath) {
        try {
            synchronized (mImplMonitor) {
                checkServiceLocked();
                mImpl.serveFilesFromDirectory(directoryPath);
            }
        } catch (RemoteException e) {
            throw new EmbeddedTestServerFailure(
                    "Failed to start serving files from " + directoryPath + ": " + e.toString());
        }
    }

    /**
     * Sets a connection listener. Must be called after the server has been initialized, but
     * before calling {@link start()}.
     *
     * @param listener The listener to set.
     */
    public void setConnectionListener(ConnectionListener listener) {
        try {
            synchronized (mImplMonitor) {
                checkServiceLocked();
                mImpl.setConnectionListener(listener.getListener());
            }
        } catch (RemoteException e) {
            throw new EmbeddedTestServerFailure("Cannot set the listener");
        }
    }

    @GuardedBy("mImplMonitor")
    private void checkServiceLocked() {
        if (mImpl == null) {
            throw new EmbeddedTestServerFailure("Service disconnected.");
        }
    }

    /** Starts the server with an automatically selected port.
     *
     *  Note that this should be called after handlers are set up, including any relevant calls
     *  serveFilesFromDirectory.
     *
     *  @return Whether the server was successfully initialized.
     */
    public boolean start() {
        return start(0);
    }

    /** Starts the server with the specified port.
     *
     *  Note that this should be called after handlers are set up, including any relevant calls
     *  serveFilesFromDirectory.
     *
     *  @param port The port to use for the server, 0 to auto-select an unused port.
     *
     *  @return Whether the server was successfully initialized.
     */
    public boolean start(int port) {
        try {
            synchronized (mImplMonitor) {
                checkServiceLocked();
                return mImpl.start(port);
            }
        } catch (RemoteException e) {
            throw new EmbeddedTestServerFailure("Failed to start server.", e);
        }
    }

    /** Create and initialize a server with the default handlers.
     *
     *  This handles native object initialization, server configuration, and server initialization.
     *  On returning, the server is ready for use.
     *
     *  @param context The context in which the server will run.
     *  @return The created server.
     */
    public static EmbeddedTestServer createAndStartServer(Context context) {
        return createAndStartServerWithPort(context, 0);
    }

    /** Create and initialize a server with the default handlers and specified port.
     *
     *  This handles native object initialization, server configuration, and server initialization.
     *  On returning, the server is ready for use.
     *
     *  @param context The context in which the server will run.
     *  @param port The port to use for the server, 0 to auto-select an unused port.
     *  @return The created server.
     */
    public static EmbeddedTestServer createAndStartServerWithPort(Context context, int port) {
        Assert.assertNotEquals("EmbeddedTestServer should not be created on UiThread, "
                + "the instantiation will hang forever waiting for tasks to post to UI thread",
                Looper.getMainLooper(), Looper.myLooper());
        EmbeddedTestServer server = new EmbeddedTestServer();
        return initializeAndStartServer(server, context, port);
    }

    /** Create and initialize an HTTPS server with the default handlers.
     *
     *  This handles native object initialization, server configuration, and server initialization.
     *  On returning, the server is ready for use.
     *
     *  @param context The context in which the server will run.
     *  @param serverCertificate The certificate option that the server will use.
     *  @return The created server.
     */
    public static EmbeddedTestServer createAndStartHTTPSServer(
            Context context, @ServerCertificate int serverCertificate) {
        return createAndStartHTTPSServerWithPort(context, serverCertificate, 0 /* port */);
    }

    /** Create and initialize an HTTPS server with the default handlers and specified port.
     *
     *  This handles native object initialization, server configuration, and server initialization.
     *  On returning, the server is ready for use.
     *
     *  @param context The context in which the server will run.
     *  @param serverCertificate The certificate option that the server will use.
     *  @param port The port to use for the server, 0 to auto-select an unused port.
     *  @return The created server.
     */
    public static EmbeddedTestServer createAndStartHTTPSServerWithPort(
            Context context, @ServerCertificate int serverCertificate, int port) {
        Assert.assertNotEquals("EmbeddedTestServer should not be created on UiThread, "
                        + "the instantiation will hang forever waiting for tasks"
                        + " to post to UI thread",
                Looper.getMainLooper(), Looper.myLooper());
        EmbeddedTestServer server = new EmbeddedTestServer();
        return initializeAndStartHTTPSServer(server, context, serverCertificate, port);
    }

    /** Initialize a server with the default handlers.
     *
     *  This handles native object initialization, server configuration, and server initialization.
     *  On returning, the server is ready for use.
     *
     *  @param server The server instance that will be initialized.
     *  @param context The context in which the server will run.
     *  @param port The port to use for the server, 0 to auto-select an unused port.
     *  @return The created server.
     */
    public static <T extends EmbeddedTestServer> T initializeAndStartServer(
            T server, Context context, int port) {
        server.initializeNative(context, ServerHTTPSSetting.USE_HTTP);
        server.addDefaultHandlers("");
        if (!server.start(port)) {
            throw new EmbeddedTestServerFailure("Failed to start serving using default handlers.");
        }
        return server;
    }

    /** Initialize a server with the default handlers that uses HTTPS with the given certificate
     * option.
     *
     *  This handles native object initialization, server configuration, and server initialization.
     *  On returning, the server is ready for use.
     *
     *  @param server The server instance that will be initialized.
     *  @param context The context in which the server will run.
     *  @param serverCertificate The certificate option that the server will use.
     *  @param port The port to use for the server.
     *  @return The created server.
     */
    public static <T extends EmbeddedTestServer> T initializeAndStartHTTPSServer(
            T server, Context context, @ServerCertificate int serverCertificate, int port) {
        server.initializeNative(context, ServerHTTPSSetting.USE_HTTPS);
        server.addDefaultHandlers("");
        server.setSSLConfig(serverCertificate);
        if (!server.start(port)) {
            throw new EmbeddedTestServerFailure("Failed to start serving using default handlers.");
        }
        return server;
    }

    /** Get the full URL for the given relative URL.
     *
     *  @param relativeUrl The relative URL for which a full URL will be obtained.
     *  @return The URL as a String.
     */
    public String getURL(String relativeUrl) {
        try {
            synchronized (mImplMonitor) {
                checkServiceLocked();
                return mImpl.getURL(relativeUrl);
            }
        } catch (RemoteException e) {
            throw new EmbeddedTestServerFailure("Failed to get URL for " + relativeUrl, e);
        }
    }

    /** Get the full URL for the given relative URL. Similar to the above method but uses the given
     *  hostname instead of 127.0.0.1. The hostname should be resolved to 127.0.0.1.
     *
     *  @param hostName The host name which should be used.
     *  @param relativeUrl The relative URL for which a full URL should be returned.
     *  @return The URL as a String.
     */
    public String getURLWithHostName(String hostname, String relativeUrl) {
        try {
            synchronized (mImplMonitor) {
                checkServiceLocked();
                return mImpl.getURLWithHostName(hostname, relativeUrl);
            }
        } catch (RemoteException e) {
            throw new EmbeddedTestServerFailure(
                    "Failed to get URL for " + hostname + " and " + relativeUrl, e);
        }
    }

    /** Get the full URLs for the given relative URLs.
     *
     *  @see #getURL(String)
     *
     *  @param relativeUrls The relative URLs for which full URLs will be obtained.
     *  @return The URLs as a String array.
     */
    public String[] getURLs(String... relativeUrls) {
        String[] absoluteUrls = new String[relativeUrls.length];

        for (int i = 0; i < relativeUrls.length; ++i) absoluteUrls[i] = getURL(relativeUrls[i]);

        return absoluteUrls;
    }

    /**
     * Stop and destroy the server.
     *
     *  This handles stopping the server and destroying the native object.
     */
    public void stopAndDestroyServer() {
        synchronized (mImplMonitor) {
            // ResettersForTesting call can cause this to be called multiple times.
            if (mImpl == null) {
                return;
            }
            try {
                if (!mImpl.shutdownAndWaitUntilComplete()) {
                    throw new EmbeddedTestServerFailure("Failed to stop server.");
                }
                mImpl.destroy();
                mImpl = null;
            } catch (RemoteException e) {
                throw new EmbeddedTestServerFailure("Failed to shut down.", e);
            } finally {
                mContext.unbindService(mConn);
            }
        }
    }

    /** Get the path of the PEM file of the root cert. */
    public String getRootCertPemPath() {
        try {
            synchronized (mImplMonitor) {
                checkServiceLocked();
                return mImpl.getRootCertPemPath();
            }
        } catch (RemoteException e) {
            throw new EmbeddedTestServerFailure("Failed to get root cert's path", e);
        }
    }

    public static ClassHook getPreClassHook() {
        return (targetContext, testClass) -> EmbeddedTestServer.setUpClass(testClass);
    }

    public static void setUpClass(Class<?> clazz) {
        if (sTestRootInitDone) {
            return;
        }

        // Always try to add the testing HTTPS root to the cert verifier. We do this here because we
        // need this to happen before the native code loads the user-added roots, and this is the
        // safest place to put it.
        try {
            // Use the same PEM file as net/test/embedded_test_server/embedded_test_server.cc.
            String rootCertPemPath =
                    UrlUtils.getIsolatedTestFilePath("net/data/ssl/certificates/root_ca_cert.pem");
            byte[] rootCertBytesDer = CertTestUtil.pemToDer(rootCertPemPath);
            X509Util.setTestRootCertificateForBuiltin(rootCertBytesDer);
            sTestRootInitDone = true;
        } catch (Exception e) {
            throw new EmbeddedTestServer.EmbeddedTestServerFailure(
                    "Failed to install root certificate.", e);
        }
    }
}
