/*
 * Copyright (C) 2017 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.googlecode.android_scripting.service;

import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Binder;
import android.os.IBinder;
import android.os.StrictMode;
import android.preference.PreferenceManager;

import com.googlecode.android_scripting.AndroidProxy;
import com.googlecode.android_scripting.BaseApplication;
import com.googlecode.android_scripting.Constants;
import com.googlecode.android_scripting.ForegroundService;
import com.googlecode.android_scripting.Log;
import com.googlecode.android_scripting.NotificationIdFactory;
import com.googlecode.android_scripting.R;
import com.googlecode.android_scripting.ScriptLauncher;
import com.googlecode.android_scripting.ScriptProcess;
import com.googlecode.android_scripting.activity.ScriptProcessMonitor;
import com.googlecode.android_scripting.interpreter.InterpreterConfiguration;
import com.googlecode.android_scripting.interpreter.InterpreterProcess;
import com.googlecode.android_scripting.interpreter.shell.ShellInterpreter;

import org.connectbot.ConsoleActivity;
import org.connectbot.service.TerminalManager;

import java.io.File;
import java.lang.ref.WeakReference;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * A service that allows scripts and the RPC server to run in the background.
 */
public class ScriptingLayerService extends ForegroundService {
    private static final int NOTIFICATION_ID = NotificationIdFactory.create();

    private final IBinder mBinder;
    private final Map<Integer, InterpreterProcess> mProcessMap;
    private static final String CHANNEL_ID = "scripting_layer_service_channel";
    private volatile int mModCount = 0;
    private Notification mNotification;
    private PendingIntent mNotificationPendingIntent;
    private InterpreterConfiguration mInterpreterConfiguration;

    private volatile WeakReference<InterpreterProcess> mRecentlyKilledProcess;

    private TerminalManager mTerminalManager;

    private SharedPreferences mPreferences = null;
    private boolean mHide;

    /**
     * A binder object that contains a reference to the ScriptingLayerService.
     */
    public class LocalBinder extends Binder {
        public ScriptingLayerService getService() {
            return ScriptingLayerService.this;
        }
    }

    @Override
    public IBinder onBind(Intent intent) {
        return mBinder;
    }

    public ScriptingLayerService() {
        super(NOTIFICATION_ID);
        mProcessMap = new ConcurrentHashMap<>();
        mBinder = new LocalBinder();
    }

    @Override
    public void onCreate() {
        super.onCreate();
        mInterpreterConfiguration = ((BaseApplication) getApplication())
                .getInterpreterConfiguration();
        mRecentlyKilledProcess = new WeakReference<>(null);
        mTerminalManager = new TerminalManager(this);
        mPreferences = PreferenceManager.getDefaultSharedPreferences(this);
        mHide = mPreferences.getBoolean(Constants.HIDE_NOTIFY, false);
    }

    private void createNotificationChannel() {
        NotificationManager notificationManager = getNotificationManager();
        CharSequence name = getString(R.string.notification_channel_name);
        String description = getString(R.string.notification_channel_description);
        int importance = NotificationManager.IMPORTANCE_DEFAULT;
        NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance);
        channel.setDescription(description);
        channel.enableLights(false);
        channel.enableVibration(false);
        notificationManager.createNotificationChannel(channel);
    }

    @Override
    protected Notification createNotification() {
        Intent notificationIntent = new Intent(this, ScriptingLayerService.class);
        notificationIntent.setAction(Constants.ACTION_SHOW_RUNNING_SCRIPTS);
        mNotificationPendingIntent =
                PendingIntent.getService(this, 0, notificationIntent,
                        PendingIntent.FLAG_IMMUTABLE);

        createNotificationChannel();
        Notification.Builder builder = new Notification.Builder(this, CHANNEL_ID);
        builder.setSmallIcon(R.drawable.sl4a_notification_logo)
                .setTicker(null)
                .setWhen(System.currentTimeMillis())
                .setContentTitle("SL4A Service")
                .setContentText("Tap to view running scripts")
                .setContentIntent(mNotificationPendingIntent);
        mNotification = builder.build();
        mNotification.flags = Notification.FLAG_NO_CLEAR | Notification.FLAG_ONGOING_EVENT;
        return mNotification;
    }

    private void updateNotification(String tickerText) {
        if (tickerText.equals(mNotification.tickerText)) {
            // Consequent notifications with the same ticker-text are displayed without any
            // ticker-text. This is a way around. Alternatively, we can display process name and
            // port.
            tickerText = tickerText + " ";
        }
        String msg;
        if (mProcessMap.size() <= 1) {
            msg = "Tap to view " + Integer.toString(mProcessMap.size()) + " running script";
        } else {
            msg = "Tap to view " + Integer.toString(mProcessMap.size()) + " running scripts";
        }
        Notification.Builder builder = new Notification.Builder(this, CHANNEL_ID);
        builder.setContentTitle("SL4A Service")
                .setContentText(msg)
                .setContentIntent(mNotificationPendingIntent)
                .setSmallIcon(R.drawable.sl4a_notification_logo, mProcessMap.size())
                .setWhen(mNotification.when)
                .setTicker(tickerText);

        mNotification = builder.build();
        getNotificationManager().notify(NOTIFICATION_ID, mNotification);
    }

    private void startAction(Intent intent, int flags, int startId) {
        if (intent == null || intent.getAction() == null) {
            return;
        }

        AndroidProxy proxy;
        InterpreterProcess interpreterProcess = null;
        String errmsg = null;
        Log.d(String.format("Received intent: %s", intent.toUri(0)));

        if (intent.getAction().equals(Constants.ACTION_KILL_ALL)) {
            killAll();
            stopSelf(startId);
        } else if (intent.getAction().equals(Constants.ACTION_KILL_PROCESS)) {
            killProcess(intent);
            if (mProcessMap.isEmpty()) {
                stopSelf(startId);
            }
        } else if (intent.getAction().equals(Constants.ACTION_SHOW_RUNNING_SCRIPTS)) {
            showRunningScripts();
        } else { //We are launching a script of some kind
            if (intent.getAction().equals(Constants.ACTION_LAUNCH_SERVER)) {
                proxy = launchServer(intent, false);
                // TODO(damonkohler): This is just to make things easier. Really, we shouldn't need
                // to start an interpreter when all we want is a server.
                interpreterProcess = new InterpreterProcess(new ShellInterpreter(), proxy);
                interpreterProcess.setName("Server");
            } else if (intent.getAction().equals(Constants.ACTION_LAUNCH_FOREGROUND_SCRIPT)) {
                proxy = launchServer(intent, true);
                launchTerminal(proxy.getAddress());
                try {
                    interpreterProcess = launchScript(intent, proxy);
                } catch (RuntimeException e) {
                    errmsg =
                            "Unable to run " + intent.getStringExtra(Constants.EXTRA_SCRIPT_PATH)
                                    + "\n" + e.getMessage();
                    interpreterProcess = null;
                }
            } else if (intent.getAction().equals(Constants.ACTION_LAUNCH_BACKGROUND_SCRIPT)) {
                proxy = launchServer(intent, true);
                interpreterProcess = launchScript(intent, proxy);
            } else if (intent.getAction().equals(Constants.ACTION_LAUNCH_INTERPRETER)) {
                proxy = launchServer(intent, true);
                launchTerminal(proxy.getAddress());
                interpreterProcess = launchInterpreter(intent, proxy);
            }
            if (interpreterProcess == null) {
                errmsg = "Action not implemented: " + intent.getAction();
            } else {
                addProcess(interpreterProcess);
            }
        }
        if (errmsg != null) {
            updateNotification(errmsg);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        super.onStartCommand(intent, flags, startId);
        StrictMode.ThreadPolicy sl4aPolicy = new StrictMode.ThreadPolicy.Builder()
                .detectAll()
                .penaltyLog()
                .build();
        StrictMode.setThreadPolicy(sl4aPolicy);
        if ((flags & START_FLAG_REDELIVERY) > 0) {
            Log.w("Intent for action " + intent.getAction() + " has been redelivered.");
        }
        // Do the heavy lifting off of the main thread. Prevents jank.
        new Thread(() -> startAction(intent, flags, startId)).start();

        return START_REDELIVER_INTENT;
    }

    private boolean tryPort(AndroidProxy androidProxy, boolean usePublicIp, int usePort) {
        if (usePublicIp) {
            return (androidProxy.startPublic(usePort) != null);
        } else {
            return (androidProxy.startLocal(usePort) != null);
        }
    }

    private AndroidProxy launchServer(Intent intent, boolean requiresHandshake) {
        AndroidProxy androidProxy = new AndroidProxy(this, intent, requiresHandshake);
        boolean usePublicIp = intent.getBooleanExtra(Constants.EXTRA_USE_EXTERNAL_IP, false);
        int usePort = intent.getIntExtra(Constants.EXTRA_USE_SERVICE_PORT, 0);
        // If port is in use, fall back to default behaviour
        if (!tryPort(androidProxy, usePublicIp, usePort)) {
            if (usePort != 0) {
                tryPort(androidProxy, usePublicIp, 0);
            }
        }
        return androidProxy;
    }

    private ScriptProcess launchScript(Intent intent, AndroidProxy proxy) {
        Log.d(String.format("Launching script with intent: %s.",
                intent.toUri(0)));
        final int port = proxy.getAddress().getPort();
        File script = new File(intent.getStringExtra(Constants.EXTRA_SCRIPT_PATH));
        return ScriptLauncher.launchScript(script, mInterpreterConfiguration, proxy, () -> {
            // TODO(damonkohler): This action actually kills the script rather than notifying the
            // service that script exited on its own. We should distinguish between these two cases.
            Intent newIntent = new Intent(ScriptingLayerService.this, ScriptingLayerService.class);
            newIntent.setAction(Constants.ACTION_KILL_PROCESS);
            newIntent.putExtra(Constants.EXTRA_PROXY_PORT, port);
            Log.i(String.format("Killing process from default shutdownHook: %s",
                    newIntent.toUri(0)));
            startService(newIntent);
        });
    }

    private InterpreterProcess launchInterpreter(Intent intent, AndroidProxy proxy) {
        Log.d(String.format("Launching interpreter with intent: %s.",
                intent.toUri(0)));
        InterpreterConfiguration config =
                ((BaseApplication) getApplication()).getInterpreterConfiguration();
        final int port = proxy.getAddress().getPort();
        return ScriptLauncher.launchInterpreter(proxy, intent, config, () -> {
            // TODO(damonkohler): This action actually kills the script rather than notifying the
            // service that script exited on its own. We should distinguish between these two cases.
            Intent newIntent = new Intent(ScriptingLayerService.this, ScriptingLayerService.class);
            newIntent.setAction(Constants.ACTION_KILL_PROCESS);
            newIntent.putExtra(Constants.EXTRA_PROXY_PORT, port);
            Log.i(String.format("Killing process from default shutdownHook: %s",
                    newIntent.toUri(0)));
            startService(newIntent);
        });
    }

    private void launchTerminal(InetSocketAddress address) {
        Intent i = new Intent(this, ConsoleActivity.class);
        i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        i.putExtra(Constants.EXTRA_PROXY_PORT, address.getPort());
        startActivity(i);
    }

    private void showRunningScripts() {
        Intent i = new Intent(this, ScriptProcessMonitor.class);
        i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        startActivity(i);
    }

    private void addProcess(InterpreterProcess process) {
        synchronized (mProcessMap) {
            mProcessMap.put(process.getPort(), process);
            mModCount++;
        }
        if (!mHide) {
            updateNotification(process.getName() + " started.");
        }
    }

    private InterpreterProcess removeProcess(int port) {
        InterpreterProcess process;
        synchronized (mProcessMap) {
            process = mProcessMap.remove(port);
            if (process == null) {
                return null;
            }
            mModCount++;
        }
        if (!mHide) {
            updateNotification(process.getName() + " exited.");
        }
        return process;
    }

    private void killProcess(Intent intent) {
        int processId = intent.getIntExtra(Constants.EXTRA_PROXY_PORT, 0);
        InterpreterProcess process = removeProcess(processId);
        if (process != null) {
            process.kill();
            mRecentlyKilledProcess = new WeakReference<>(process);
        }
    }

    public int getModCount() {
        return mModCount;
    }

    private void killAll() {
        for (InterpreterProcess process : getScriptProcessesList()) {
            process = removeProcess(process.getPort());
            if (process != null) {
                process.kill();
            }
        }
    }

    /**
     * Returns the list of all running InterpreterProcesses. This list includes RPC servers.
     *
     * @return a list of all running interpreter processes
     */
    public List<InterpreterProcess> getScriptProcessesList() {
        ArrayList<InterpreterProcess> result = new ArrayList<>();
        result.addAll(mProcessMap.values());
        return result;
    }

    /**
     * Returns the process running on the given port, if any.
     *
     * @param port the integer value corresponding to the port to find a process on
     * @return the InterpreterProcess running on that port, or null
     */
    public InterpreterProcess getProcess(int port) {
        InterpreterProcess p = mProcessMap.get(port);
        if (p == null) {
            return mRecentlyKilledProcess.get();
        }
        return p;
    }

    public TerminalManager getTerminalManager() {
        return mTerminalManager;
    }
}
