/*
 * Copyright (C) 2016 The Android Open Source Project
 *
 * 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 com.android.tradefed.util.sl4a;

import com.android.tradefed.device.DeviceNotAvailableException;
import com.android.tradefed.device.ITestDevice;
import com.android.tradefed.log.LogUtil.CLog;
import com.android.tradefed.util.IRunUtil;
import com.android.tradefed.util.RunUtil;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.HashMap;
import java.util.Map;

/**
 * Sl4A client to interact via RPC with SL4A scripting layer.
 */
public class Sl4aClient implements AutoCloseable {

    private static final String INIT = "initiate";
    public static final String IS_SL4A_RUNNING_CMD =
            "ps -e | grep \"S com.googlecode.android_scripting\"";
    public static final String IS_SL4A_RUNNING_CMD_OLD =
            "ps | grep \"S com.googlecode.android_scripting\"";
    public static final String SL4A_LAUNCH_CMD =
            "am start -a com.googlecode.android_scripting.action.LAUNCH_SERVER " +
            "--ei com.googlecode.android_scripting.extra.USE_SERVICE_PORT %s " +
            "com.googlecode.android_scripting/.activity.ScriptingLayerServiceLauncher";
    public static final String STOP_SL4A_CMD = "am force-stop com.googlecode.android_scripting";

    private static final int UNKNOWN_ID = -1;

    private ITestDevice mDevice;
    private int mHostPort;
    private int mDeviceSidePort;
    private Socket mSocket;
    private Long mCounter = 1L;
    private int mUid = UNKNOWN_ID;

    private Sl4aEventDispatcher mEventDispatcher;

    /**
     * Creates the Sl4A client.
     *
     * @param device the {ITestDevice} that the client will be for.
     * @param hostPort the port on the host machine to connect to the sl4a client.
     * @param devicePort the device port used to communicate to.
     */
    public Sl4aClient(ITestDevice device, int hostPort, int devicePort) {
        mDevice = device;
        mHostPort = hostPort;
        mDeviceSidePort = devicePort;
    }

    /**
     * Creates the Sl4A client.
     *
     * @param device the {ITestDevice} that the client will be for.
     * @param sl4aApkFile file path to hte sl4a apk to install, or null if already installed.
     */
    public Sl4aClient(ITestDevice device, File sl4aApkFile) throws DeviceNotAvailableException {
        installSl4a(device, sl4aApkFile);
        ServerSocket s = null;
        int port = -1;
        try {
            s = new ServerSocket(0);
            s.setReuseAddress(true);
            port = s.getLocalPort();
            s.close();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        mDevice = device;
        mHostPort = port;
        mDeviceSidePort = 9998;
    }

    private static void installSl4a(ITestDevice device, File sl4aApkFile)
            throws DeviceNotAvailableException {
        if (sl4aApkFile != null) {
            if (!sl4aApkFile.exists()) {
                throw new RuntimeException(String.format("Sl4A apk '%s' was not found.",
                        sl4aApkFile.getAbsoluteFile()));
            }
            String res = device.installPackage(sl4aApkFile, true);
            if (res != null) {
                throw new RuntimeException(String.format("Error when installing the Sl4A apk: %s",
                        res));
            }
        }
    }

    /**
     * Convenience method to create and start a client ready to use.
     *
     * @param device the {ITestDevice} that the client will be for.
     * @param sl4aApkFile file path to hte sl4a apk to install, or null if already installed.
     * @return an {@link Sl4aClient} instance that has been started.
     * @throws DeviceNotAvailableException
     */
    public static Sl4aClient startSL4A(ITestDevice device, File sl4aApkFile)
            throws DeviceNotAvailableException {
        installSl4a(device, sl4aApkFile);
        ServerSocket s = null;
        int port = -1;
        try {
            s = new ServerSocket(0);
            s.setReuseAddress(true);
            port = s.getLocalPort();
            s.close();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        // even after being closed, socket may remain in TIME_WAIT state
        // reuse address allows to connect to it even in this state.
        Sl4aClient sl4aClient = new Sl4aClient(device, port, 9998);
        sl4aClient.startSl4A();
        return sl4aClient;
    }

    /**
     * Return the default runutil instance. Exposed for testing.
     */
    protected IRunUtil getRunUtil() {
        return RunUtil.getDefault();
    }

    /**
     * Starts the sl4a client on the device side.
     * Assume the sl4a apk is installed.
     */
    public void startSl4A() throws DeviceNotAvailableException {
        mDevice.executeShellCommand(String.format(SL4A_LAUNCH_CMD, mDeviceSidePort));
        // Allow some times for the process to start.
        getRunUtil().sleep(2000);
        if (isSl4ARunning() == false) {
            throw new RuntimeException("sl4a is not running.");
        }
        open();
    }

    /**
     * Return true if the sl4a device side client is running.
     */
    public boolean isSl4ARunning() throws DeviceNotAvailableException {
        // Grep for process with a preceding S which means it is truly started.
        // Some devices running older version do not support ps -e command, use ps instead
        // Right now there is no easy way to find out which system support -e option
        String out1 = mDevice.executeShellCommand(IS_SL4A_RUNNING_CMD_OLD);
        String out2 = mDevice.executeShellCommand(IS_SL4A_RUNNING_CMD);
        if (out1 == null || out2 == null) {
            CLog.i("Null string return");
            return false;
        } else if (out1.trim().isEmpty() && out2.trim().isEmpty()) {
            CLog.i("Empty return");
            return false;
        } else {
            return true;
        }
    }

    /** Helper to actually starts the connection host to device for sl4a. */
    public void open() {
        try {
            mDevice.executeAdbCommand("forward", "tcp:" + mHostPort, "tcp:" + mDeviceSidePort);
            String res = mDevice.executeAdbCommand("forward", "--list");
            CLog.d("forwardings: %s", res);
            mSocket = new Socket("localhost", mHostPort);
            CLog.i("is sl4a socket connected: %s", mSocket.isConnected());
            String rep = sendCommand(Sl4aClient.INIT);
            CLog.i("response sl4a INIT: '%s', from device %s", rep, mDevice.getSerialNumber());
            JSONObject init = new JSONObject(rep);
            mUid = init.getInt("uid");
            startEventDispatcher();
        } catch (IOException | DeviceNotAvailableException | JSONException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Starts the event dispatcher. Exposed for testing.
     */
    protected void startEventDispatcher() throws DeviceNotAvailableException {
        if (isSl4ARunning() == true) {
            mEventDispatcher = new Sl4aEventDispatcher(this, 5000);
            mEventDispatcher.start();
        } else {
            throw new RuntimeException("sl4a is not running.");
        }
    }

    /**
     * Helper for initial handshake with SL4A client device side.
     */
    private String sendCommand(String cmd) throws IOException {
        Map<String, String> info = new HashMap<>();
        info.put("cmd", cmd);
        info.put("uid", mUid +"");
        JSONObject message = new JSONObject(info);
        PrintWriter out = new PrintWriter(mSocket.getOutputStream(), true);
        out.print(message.toString());
        out.print('\n');
        CLog.d("flushing");
        out.flush();
        CLog.d("sent");
        BufferedReader in = new BufferedReader(new InputStreamReader(mSocket.getInputStream()));
        CLog.d("reading");
        String response = in.readLine();
        return response;
    }

    /**
     * Helper to send a message through the sl4a socket.
     *
     * @param message the JSON object to be sent through the socket.
     * @return the response of the request.
     * @throws IOException
     */
    private synchronized Object sendThroughSocket(String message) throws IOException {
        CLog.v("preparing sending: '%s' to device %s", message, mDevice.getSerialNumber());
        PrintWriter out = new PrintWriter(mSocket.getOutputStream(), false);
        out.print(message);
        out.print('\n');
        out.flush();
        BufferedReader in = new BufferedReader(new InputStreamReader(mSocket.getInputStream()));
        String response = in.readLine();
        CLog.v("response: '%s' from device %s", response, mDevice.getSerialNumber());
        try {
            JSONObject resp = new JSONObject(response);
            if (!resp.isNull("error")) {
                throw new IOException(String.format("RPC error: %s", resp.get("error")));
            }
            if (resp.isNull("result")) {
                return null;
            }
            // TODO: verify id is matching
            return resp.get("result");
        } catch (JSONException e) {
            throw new IOException(e);
        }
    }

    /**
     * Close the sl4a connection to device side and Kills any running instance of sl4a.
     * If no instance is running then nothing is done.
     */
    @Override
    public void close() {
        try {
            if (mEventDispatcher != null) {
                mEventDispatcher.cancel();
            }
            if (mSocket != null) {
                mSocket.close();
            }
            mDevice.executeShellCommand(STOP_SL4A_CMD);
            mDevice.executeAdbCommand("forward", "--remove", "tcp:" + mHostPort);
        } catch (IOException | DeviceNotAvailableException e) {
            CLog.e(e);
        }
    }

    /**
     * Execute an RPC call on the sl4a layer.
     *
     * @param methodName the name of the method to be called on device side.
     * @param args the arg list to be used on the method.
     * @return the result of the request.
     * @throws IOException if the requested method does not exists.
     */
    public Object rpcCall(String methodName, Object... args) throws IOException {
        JSONArray argsFormatted = new JSONArray();
        if (args != null) {
            for (Object arg : args) {
                argsFormatted.put(arg);
            }
        }
        JSONObject message = new JSONObject();
        try {
            message.put("id", mCounter);
            message.put("method", methodName);
            message.put("params", argsFormatted);
        } catch (JSONException e) {
            CLog.e(e);
            throw new IOException("Failed to format the message", e);
        }
        mCounter++;
        return sendThroughSocket(message.toString());
    }

    /**
     * Return the event dispatcher to wait for events.
     */
    public Sl4aEventDispatcher getEventDispatcher() {
        return mEventDispatcher;
    }
}
