/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.geckoview;

import android.annotation.SuppressLint;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Process;
import android.util.Log;
import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.Arrays;
import java.util.UUID;
import org.json.JSONException;
import org.json.JSONObject;
import org.mozilla.gecko.GeckoAppShell;

public class CrashHandler implements Thread.UncaughtExceptionHandler {

  private static final String LOGTAG = "GeckoCrashHandler";
  private static final Thread MAIN_THREAD = Thread.currentThread();
  private static final String DEFAULT_SERVER_URL =
      "https://crash-reports.mozilla.com/submit?id=%1$s&version=%2$s&buildid=%3$s";

  // Context for getting device information
  private @Nullable final Context mAppContext;
  // Thread that this handler applies to, or null for a global handler
  private @Nullable final Thread mHandlerThread;
  private final @Nullable Thread.UncaughtExceptionHandler systemUncaughtHandler;

  private boolean mCrashing;
  private boolean mUnregistered;

  private @Nullable final Class<? extends Service> mHandlerService;

  /**
   * Get the root exception from the 'cause' chain of an exception.
   *
   * @param exc An exception
   * @return The root exception
   */
  @AnyThread
  @NonNull
  public static Throwable getRootException(@NonNull final Throwable exc) {
    Throwable cause;
    Throwable result = exc;
    for (cause = exc; cause != null; cause = cause.getCause()) {
      result = cause;
    }

    return result;
  }

  /**
   * Get the standard stack trace string of an exception.
   *
   * @param exc An exception
   * @return The exception stack trace.
   */
  @AnyThread
  @NonNull
  public static String getExceptionStackTrace(@NonNull final Throwable exc) {
    final StringWriter sw = new StringWriter();
    final PrintWriter pw = new PrintWriter(sw);
    exc.printStackTrace(pw);
    pw.flush();
    return sw.toString();
  }

  /** Terminate the current process. */
  @AnyThread
  public static void terminateProcess() {
    Process.killProcess(Process.myPid());
  }

  /**
   * Create and register a CrashHandler for all threads and thread groups.
   *
   * @param handlerService Service receiving native code crashes
   */
  public CrashHandler(@Nullable final Class<? extends Service> handlerService) {
    this((Context) null, handlerService);
  }

  /**
   * Create and register a CrashHandler for all threads and thread groups.
   *
   * @param aAppContext A Context for retrieving application information.
   * @param aHandlerService Service receiving native code crashes
   */
  public CrashHandler(
      @Nullable final Context aAppContext,
      @Nullable final Class<? extends Service> aHandlerService) {
    this.mAppContext = aAppContext;
    this.mHandlerThread = null;
    this.mHandlerService = aHandlerService;
    this.systemUncaughtHandler = Thread.getDefaultUncaughtExceptionHandler();
    Thread.setDefaultUncaughtExceptionHandler(this);
  }

  /**
   * Create and register a CrashHandler for a particular thread.
   *
   * @param thread A thread to register the CrashHandler
   * @param handlerService Service receiving native code crashes
   */
  public CrashHandler(final Thread thread, final Class<? extends Service> handlerService) {
    this(thread, null, handlerService);
  }

  /**
   * Create and register a CrashHandler for a particular thread.
   *
   * @param thread A thread to register the CrashHandler
   * @param aAppContext A Context for retrieving application information.
   * @param aHandlerService Service receiving native code crashes
   */
  public CrashHandler(
      @Nullable final Thread thread,
      final Context aAppContext,
      final Class<? extends Service> aHandlerService) {
    this.mAppContext = aAppContext;
    this.mHandlerThread = thread;
    this.mHandlerService = aHandlerService;
    this.systemUncaughtHandler = thread.getUncaughtExceptionHandler();
    thread.setUncaughtExceptionHandler(this);
  }

  /** Unregister this CrashHandler for exception handling. */
  @AnyThread
  public void unregister() {
    mUnregistered = true;

    // Restore the previous handler if we are still the topmost handler.
    // If not, we are part of a chain of handlers, and we cannot just restore the previous
    // handler, because that would replace whatever handler that's above us in the chain.

    if (mHandlerThread != null) {
      if (mHandlerThread.getUncaughtExceptionHandler() == this) {
        mHandlerThread.setUncaughtExceptionHandler(systemUncaughtHandler);
      }
    } else {
      if (Thread.getDefaultUncaughtExceptionHandler() == this) {
        Thread.setDefaultUncaughtExceptionHandler(systemUncaughtHandler);
      }
    }
  }

  /**
   * Record an exception stack in logs.
   *
   * @param thread The exception thread
   * @param exc An exception
   */
  @AnyThread
  public static void logException(@NonNull final Thread thread, @NonNull final Throwable exc) {
    try {
      Log.e(
          LOGTAG,
          ">>> REPORTING UNCAUGHT EXCEPTION FROM THREAD "
              + thread.getId()
              + " (\""
              + thread.getName()
              + "\")",
          exc);

      if (MAIN_THREAD != thread) {
        Log.e(LOGTAG, "Main thread (" + MAIN_THREAD.getId() + ") stack:");
        for (final StackTraceElement ste : MAIN_THREAD.getStackTrace()) {
          Log.e(LOGTAG, "    " + ste.toString());
        }
      }
    } catch (final Throwable e) {
      // If something throws here, we want to continue to report the exception,
      // so we catch all exceptions and ignore them.
    }
  }

  private static long getCrashTime() {
    return System.currentTimeMillis() / 1000;
  }

  private static long getStartupTime() {
    // Process start time is also the proc file modified time.
    final long uptimeMins = (new File("/proc/self/cmdline")).lastModified();
    if (uptimeMins == 0L) {
      return getCrashTime();
    }
    return uptimeMins / 1000;
  }

  private static String getJavaPackageName() {
    return CrashHandler.class.getPackage().getName();
  }

  @Nullable
  private static String getProcessName() {
    try {
      final FileReader reader = new FileReader("/proc/self/cmdline");
      final char[] buffer = new char[64];
      try {
        if (reader.read(buffer) > 0) {
          // cmdline is delimited by '\0', and we want the first token.
          final int nul = Arrays.asList(buffer).indexOf('\0');
          return (new String(buffer, 0, nul < 0 ? buffer.length : nul)).trim();
        }
      } finally {
        reader.close();
      }
    } catch (final IOException e) {
    }

    return null;
  }

  /**
   * @return the application package name. if context is not null; if context is null,
   *     CrashHandler's package name will be returned.
   */
  @Nullable
  @AnyThread
  public String getAppPackageName() {
    final Context context = getAppContext();

    if (context != null) {
      return context.getPackageName();
    }

    // Package name is also the process name in most cases.
    final String processName = getProcessName();
    if (processName != null) {
      return processName;
    }

    // Fallback to using CrashHandler's package name.
    return getJavaPackageName();
  }

  /**
   * @return application context.
   */
  @AnyThread
  @Nullable
  public Context getAppContext() {
    return mAppContext;
  }

  /**
   * Get the crash "extras" to be reported.
   *
   * @param thread The exception thread
   * @param exc An exception
   * @return "Extras" in the from of a Bundle
   */
  @AnyThread
  @NonNull
  public Bundle getCrashExtras(@NonNull final Thread thread, @NonNull final Throwable exc) {
    final Context context = getAppContext();
    final Bundle extras = new Bundle();
    final String pkgName = getAppPackageName();

    extras.putLong("CrashTime", getCrashTime());
    extras.putLong("StartupTime", getStartupTime());
    extras.putString("Android_ProcessName", getProcessName());
    extras.putString("Android_PackageName", pkgName);

    final String notes = GeckoAppShell.getAppNotes();
    if (notes != null) {
      extras.putString("Notes", notes);
    }

    if (context != null) {
      final PackageManager pkgMgr = context.getPackageManager();
      try {
        final PackageInfo pkgInfo = pkgMgr.getPackageInfo(pkgName, 0);
        extras.putString("Version", pkgInfo.versionName);
        extras.putInt("BuildID", pkgInfo.versionCode);
        extras.putLong("InstallTime", pkgInfo.lastUpdateTime / 1000);
      } catch (final PackageManager.NameNotFoundException e) {
        Log.i(LOGTAG, "Error getting package info", e);
      }
    }

    extras.putString("JavaStackTrace", getExceptionStackTrace(exc));
    return extras;
  }

  /**
   * Get the crash minidump content to be reported.
   *
   * @param thread The exception thread
   * @param exc An exception
   * @return Minidump content
   */
  @NonNull
  @AnyThread
  public byte[] getCrashDump(@Nullable final Thread thread, @Nullable final Throwable exc) {
    return new byte[0]; // No minidump.
  }

  @AnyThread
  @NonNull
  private static String normalizeUrlString(@Nullable final String str) {
    if (str == null) {
      return "";
    }
    return Uri.encode(str);
  }

  /**
   * Get the server URL to send the crash report to.
   *
   * @param extras The crash extras Bundle
   * @return the URL that the crash reporter will submit reports to.
   */
  @NonNull
  @AnyThread
  public String getServerUrl(@NonNull final Bundle extras) {
    return String.format(
        DEFAULT_SERVER_URL,
        normalizeUrlString(extras.getString("ProductID")),
        normalizeUrlString(extras.getString("Version")),
        normalizeUrlString(extras.getString("BuildID")));
  }

  /**
   * Launch the crash reporter activity that sends the crash report to the server.
   *
   * @param dumpFile Path for the minidump file
   * @param extraFile Path for the crash extra file
   * @return Whether the crash reporter was successfully launched
   */
  @AnyThread
  public boolean launchCrashReporter(
      @NonNull final String dumpFile, @NonNull final String extraFile) {
    try {
      final Context context = getAppContext();
      final ProcessBuilder pb;

      if (mHandlerService == null) {
        Log.w(LOGTAG, "No crash handler service defined, unable to report crash");
        return false;
      }

      if (context != null) {
        final Intent intent = new Intent(GeckoRuntime.ACTION_CRASHED);
        intent.putExtra(GeckoRuntime.EXTRA_MINIDUMP_PATH, dumpFile);
        intent.putExtra(GeckoRuntime.EXTRA_EXTRAS_PATH, extraFile);
        intent.putExtra(
            GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE, GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN);
        intent.setClass(context, mHandlerService);

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
          context.startForegroundService(intent);
        } else {
          context.startService(intent);
        }
        return true;
      }

      final int deviceSdkVersion = Build.VERSION.SDK_INT;
      if (deviceSdkVersion < 17) {
        pb =
            new ProcessBuilder(
                "/system/bin/am",
                "startservice",
                "-a",
                GeckoRuntime.ACTION_CRASHED,
                "-n",
                getAppPackageName() + '/' + mHandlerService.getName(),
                "--es",
                GeckoRuntime.EXTRA_MINIDUMP_PATH,
                dumpFile,
                "--es",
                GeckoRuntime.EXTRA_EXTRAS_PATH,
                extraFile,
                "--es",
                GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE,
                GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN);
      } else {
        final String startServiceCommand;
        if (deviceSdkVersion >= 26) {
          startServiceCommand = "start-foreground-service";
        } else {
          startServiceCommand = "startservice";
        }

        pb =
            new ProcessBuilder(
                "/system/bin/am",
                startServiceCommand,
                "--user", /* USER_CURRENT_OR_SELF */
                "-3",
                "-a",
                GeckoRuntime.ACTION_CRASHED,
                "-n",
                getAppPackageName() + '/' + mHandlerService.getName(),
                "--es",
                GeckoRuntime.EXTRA_MINIDUMP_PATH,
                dumpFile,
                "--es",
                GeckoRuntime.EXTRA_EXTRAS_PATH,
                extraFile,
                "--es",
                GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE,
                GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN);
      }

      pb.start().waitFor();

    } catch (final IOException e) {
      Log.e(LOGTAG, "Error launching crash reporter", e);
      return false;

    } catch (final InterruptedException e) {
      Log.i(LOGTAG, "Interrupted while waiting to launch crash reporter", e);
      // Fall-through
    }
    return true;
  }

  /**
   * Report an exception to Socorro.
   *
   * @param thread The exception thread
   * @param exc An exception
   * @return Whether the exception was successfully reported
   */
  @AnyThread
  @SuppressLint("SdCardPath")
  public boolean reportException(@NonNull final Thread thread, @NonNull final Throwable exc) {
    final Context context = getAppContext();
    final String id = UUID.randomUUID().toString();

    // Use the cache directory under the app directory to store crash files.
    final File dir;
    if (context != null) {
      dir = context.getCacheDir();
    } else {
      dir = new File("/data/data/" + getAppPackageName() + "/cache");
    }

    dir.mkdirs();
    if (!dir.exists()) {
      return false;
    }

    final File dmpFile = new File(dir, id + ".dmp");
    final File extraFile = new File(dir, id + ".extra");

    try {
      // Write out minidump file as binary.

      final byte[] minidump = getCrashDump(thread, exc);
      final FileOutputStream dmpStream = new FileOutputStream(dmpFile);
      try {
        dmpStream.write(minidump);
      } finally {
        dmpStream.close();
      }

    } catch (final IOException e) {
      Log.e(LOGTAG, "Error writing minidump file", e);
      return false;
    }

    try {
      // Write out crash extra file as text.

      final Bundle extras = getCrashExtras(thread, exc);
      final String url = getServerUrl(extras);
      extras.putString("ServerURL", url);

      final JSONObject json = new JSONObject();
      for (final String key : extras.keySet()) {
        json.put(key, extras.get(key));
      }

      final BufferedWriter extraWriter = new BufferedWriter(new FileWriter(extraFile));
      try {
        extraWriter.write(json.toString());
      } finally {
        extraWriter.close();
      }
    } catch (final IOException | JSONException e) {
      Log.e(LOGTAG, "Error writing extra file", e);
      return false;
    }

    return launchCrashReporter(dmpFile.getAbsolutePath(), extraFile.getAbsolutePath());
  }

  /**
   * Implements the default behavior for handling uncaught exceptions.
   *
   * @param thread The exception thread
   * @param exc An uncaught exception
   */
  @Override
  public void uncaughtException(@Nullable final Thread thread, @NonNull final Throwable exc) {
    if (this.mCrashing) {
      // Prevent possible infinite recusions.
      return;
    }

    Thread resolvedThread = thread;
    if (resolvedThread == null) {
      // Gecko may pass in null for thread to denote the current thread.
      resolvedThread = Thread.currentThread();
    }

    try {
      Throwable rootException = exc;
      if (!this.mUnregistered) {
        // Only process crash ourselves if we have not been unregistered.

        this.mCrashing = true;
        rootException = getRootException(exc);
        logException(resolvedThread, rootException);

        if (reportException(resolvedThread, rootException)) {
          // Reporting succeeded; we can terminate our process now.
          return;
        }
      }

      if (systemUncaughtHandler != null) {
        // Follow the chain of uncaught handlers.
        systemUncaughtHandler.uncaughtException(resolvedThread, rootException);
      }
    } finally {
      terminateProcess();
    }
  }

  /**
   * Return a default CrashHandler object for all threads and thread groups.
   *
   * @param context application context
   * @return a default CrashHandler object
   */
  @AnyThread
  @NonNull
  public static CrashHandler createDefaultCrashHandler(@NonNull final Context context) {
    return new CrashHandler(context, null) {
      @Override
      public Bundle getCrashExtras(final Thread thread, final Throwable exc) {
        final Bundle extras = super.getCrashExtras(thread, exc);

        extras.putString("ProductName", BuildConfig.MOZ_APP_BASENAME);
        extras.putString("ProductID", BuildConfig.MOZ_APP_ID);
        extras.putString("Version", BuildConfig.MOZ_APP_VERSION);
        extras.putString("BuildID", BuildConfig.MOZ_APP_BUILDID);
        extras.putString("Vendor", BuildConfig.MOZ_APP_VENDOR);
        extras.putString("ReleaseChannel", BuildConfig.MOZ_UPDATE_CHANNEL);
        return extras;
      }

      @Override
      public boolean reportException(final Thread thread, final Throwable exc) {
        if (BuildConfig.MOZ_CRASHREPORTER && BuildConfig.MOZILLA_OFFICIAL) {
          // Only use Java crash reporter if enabled on official build.
          return super.reportException(thread, exc);
        }
        return false;
      }
    };
  }
}
