/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */

package org.mozilla.geckoview.test;

import android.app.Service;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.os.Messenger;
import android.os.RemoteException;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import org.json.JSONException;
import org.json.JSONObject;
import org.mozilla.geckoview.GeckoRuntime;
import org.mozilla.geckoview.test.util.UiThreadUtils;

public class TestCrashHandler extends Service {
  private static final int MSG_EVAL_NEXT_CRASH_DUMP = 1;
  private static final int MSG_CRASH_DUMP_EVAL_RESULT = 2;
  private static final String LOGTAG = "TestCrashHandler";

  public static final class EvalResult {
    private static final String BUNDLE_KEY_RESULT = "TestCrashHandler.EvalResult.mResult";
    private static final String BUNDLE_KEY_MSG = "TestCrashHandler.EvalResult.mMsg";

    public EvalResult(final boolean result, final String msg) {
      mResult = result;
      mMsg = msg;
    }

    public EvalResult(final Bundle bundle) {
      mResult = bundle.getBoolean(BUNDLE_KEY_RESULT, false);
      mMsg = bundle.getString(BUNDLE_KEY_MSG);
    }

    public Bundle asBundle() {
      final Bundle bundle = new Bundle();
      bundle.putBoolean(BUNDLE_KEY_RESULT, mResult);
      bundle.putString(BUNDLE_KEY_MSG, mMsg);
      return bundle;
    }

    public boolean mResult;
    public String mMsg;
  }

  public static final class Client {
    private static final String LOGTAG = "TestCrashHandler.Client";

    private class Receiver extends Handler {
      public Receiver(final Looper looper) {
        super(looper);
      }

      @Override
      public void handleMessage(final Message msg) {
        if (msg.what == MSG_CRASH_DUMP_EVAL_RESULT) {
          setEvalResult(new EvalResult(msg.getData()));
          return;
        }

        super.handleMessage(msg);
      }
    }

    private Receiver mReceiver;
    private boolean mDoUnbind = false;
    private Messenger mService = null;
    private Messenger mMessenger;
    private Context mContext;
    private HandlerThread mThread;
    private EvalResult mResult = null;

    private ServiceConnection mConnection =
        new ServiceConnection() {
          @Override
          public void onServiceConnected(final ComponentName className, final IBinder service) {
            mService = new Messenger(service);
          }

          @Override
          public void onServiceDisconnected(final ComponentName className) {
            disconnect();
          }
        };

    public Client(final Context context) {
      mContext = context;
      mThread = new HandlerThread("TestCrashHandler.Client");
      mThread.start();
      mReceiver = new Receiver(mThread.getLooper());
      mMessenger = new Messenger(mReceiver);
    }

    /**
     * Tests should call this to notify the crash handler that the next crash it sees is intentional
     * and that its intent should be checked for correctness.
     *
     * @param expectedProcessType The type of process the incoming crash is expected to be for.
     */
    public void setEvalNextCrashDump(final String expectedProcessType) {
      setEvalResult(null);
      mReceiver.post(
          new Runnable() {
            @Override
            public void run() {
              final Bundle bundle = new Bundle();
              bundle.putString(GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE, expectedProcessType);
              final Message msg = Message.obtain(null, MSG_EVAL_NEXT_CRASH_DUMP, bundle);
              msg.replyTo = mMessenger;

              try {
                mService.send(msg);
              } catch (final RemoteException e) {
                throw new RuntimeException(e.getMessage());
              }
            }
          });
    }

    public boolean connect(final long timeoutMillis) {
      final Intent intent = new Intent(mContext, TestCrashHandler.class);
      mDoUnbind =
          mContext.bindService(
              intent, mConnection, Context.BIND_AUTO_CREATE | Context.BIND_IMPORTANT);
      if (!mDoUnbind) {
        return false;
      }

      UiThreadUtils.waitForCondition(() -> mService != null, timeoutMillis);

      return mService != null;
    }

    public void disconnect() {
      if (mDoUnbind) {
        mContext.unbindService(mConnection);
        mService = null;
        mDoUnbind = false;
      }
      mThread.quitSafely();
    }

    private synchronized void setEvalResult(final EvalResult result) {
      mResult = result;
    }

    private synchronized EvalResult getEvalResult() {
      return mResult;
    }

    /**
     * Tests should call this method after initiating the intentional crash to wait for the result
     * from the crash handler.
     *
     * @param timeoutMillis timeout in milliseconds
     * @return EvalResult containing the boolean result of the test and an error message.
     */
    public EvalResult getEvalResult(final long timeoutMillis) {
      UiThreadUtils.waitForCondition(() -> getEvalResult() != null, timeoutMillis);
      return getEvalResult();
    }
  }

  private static final class MessageHandler extends Handler {
    private Messenger mReplyToMessenger;
    private String mExpectedProcessType;

    MessageHandler() {}

    @Override
    public void handleMessage(final Message msg) {
      if (msg.what == MSG_EVAL_NEXT_CRASH_DUMP) {
        mReplyToMessenger = msg.replyTo;
        Bundle bundle = (Bundle) msg.obj;
        mExpectedProcessType = bundle.getString(GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE);
        return;
      }

      super.handleMessage(msg);
    }

    public void reportResult(final EvalResult result) {
      if (mReplyToMessenger == null) {
        return;
      }

      final Message msg = Message.obtain(null, MSG_CRASH_DUMP_EVAL_RESULT);
      msg.setData(result.asBundle());

      try {
        mReplyToMessenger.send(msg);
      } catch (final RemoteException e) {
        throw new RuntimeException(e.getMessage());
      }

      mReplyToMessenger = null;
    }

    public String getExpectedProcessType() {
      return mExpectedProcessType;
    }
  }

  private Messenger mMessenger;
  private MessageHandler mMsgHandler;

  public TestCrashHandler() {}

  private static JSONObject readExtraFile(final String filePath) throws IOException, JSONException {
    final byte[] buffer = new byte[4096];
    final FileInputStream inputStream = new FileInputStream(filePath);
    final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
    int bytesRead = 0;

    while ((bytesRead = inputStream.read(buffer)) != -1) {
      outputStream.write(buffer, 0, bytesRead);
    }

    final String contents = new String(outputStream.toByteArray(), "UTF-8");
    return new JSONObject(contents);
  }

  private EvalResult evalCrashInfo(final Intent intent) {
    if (!intent.getAction().equals(GeckoRuntime.ACTION_CRASHED)) {
      return new EvalResult(false, "Action should match");
    }

    final File dumpFile = new File(intent.getStringExtra(GeckoRuntime.EXTRA_MINIDUMP_PATH));
    final boolean dumpFileExists = dumpFile.exists();
    dumpFile.delete();

    final File extrasFile = new File(intent.getStringExtra(GeckoRuntime.EXTRA_EXTRAS_PATH));
    final boolean extrasFileExists = extrasFile.exists();
    try {
      final JSONObject annotations = readExtraFile(extrasFile.getPath());
      final String moz_crash_reason = annotations.getString("MozCrashReason");

      if (!moz_crash_reason.startsWith("MOZ_CRASH(")) {
        return new EvalResult(false, "Missing or invalid child crash annotations");
      }

      extrasFile.delete();
    } catch (final Exception e) {
      return new EvalResult(false, e.toString());
    }

    if (!dumpFileExists) {
      return new EvalResult(false, "Dump file should exist");
    }

    if (!extrasFileExists) {
      return new EvalResult(false, "Extras file should exist");
    }

    final String expectedProcessType = mMsgHandler.getExpectedProcessType();
    final String processType = intent.getStringExtra(GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE);
    if (processType == null) {
      return new EvalResult(false, "Intent missing process type");
    }
    if (!processType.equals(expectedProcessType)) {
      return new EvalResult(
          false, "Expected process type " + expectedProcessType + ", found " + processType);
    }

    return new EvalResult(true, "Crash Dump OK");
  }

  @Override
  public synchronized int onStartCommand(final Intent intent, final int flags, final int startId) {
    if (mMsgHandler != null) {
      mMsgHandler.reportResult(evalCrashInfo(intent));
      // We must manually call stopSelf() here to ensure the Service gets killed once the client
      // unbinds. If we don't, then when the next client attempts to bind for a different test,
      // onBind() will not be called, and mMsgHandler will not get set.
      stopSelf();
      return Service.START_NOT_STICKY;
    }

    // We don't want to do anything, this handler only exists
    // so we produce a crash dump which is picked up by the
    // test harness.
    System.exit(0);
    return Service.START_NOT_STICKY;
  }

  @Override
  public synchronized IBinder onBind(final Intent intent) {
    mMsgHandler = new MessageHandler();
    mMessenger = new Messenger(mMsgHandler);
    return mMessenger.getBinder();
  }

  @Override
  public synchronized boolean onUnbind(final Intent intent) {
    mMsgHandler = null;
    mMessenger = null;
    return false;
  }
}
