/* -*- Mode: Java; tab-width: 20; 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.gecko;

import java.util.HashMap;
import java.util.Map;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.security.MessageDigest;
import java.util.zip.GZIPOutputStream;

import org.mozilla.gecko.AppConstants.Versions;
import org.mozilla.gecko.util.ProxySelector;

import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.app.ProgressDialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.support.v7.app.AppCompatActivity;
import android.text.TextUtils;
import android.util.Log;
import android.view.View;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.EditText;

@SuppressLint("Registered") // This activity is only registered in the manifest if MOZ_CRASHREPORTER is set
public class CrashReporter extends AppCompatActivity
{
    private static final String LOGTAG = "GeckoCrashReporter";

    private static final String PASSED_MINI_DUMP_KEY = "minidumpPath";
    private static final String PASSED_MINI_DUMP_SUCCESS_KEY = "minidumpSuccess";
    private static final String MINI_DUMP_PATH_KEY = "upload_file_minidump";
    private static final String PAGE_URL_KEY = "URL";
    private static final String NOTES_KEY = "Notes";
    private static final String SERVER_URL_KEY = "ServerURL";

    private static final String CRASH_REPORT_SUFFIX = "/mozilla/Crash Reports/";
    private static final String PENDING_SUFFIX = CRASH_REPORT_SUFFIX + "pending";
    private static final String SUBMITTED_SUFFIX = CRASH_REPORT_SUFFIX + "submitted";

    private static final String PREFS_SEND_REPORT   = "sendReport";
    private static final String PREFS_INCLUDE_URL   = "includeUrl";
    private static final String PREFS_ALLOW_CONTACT = "allowContact";
    private static final String PREFS_CONTACT_EMAIL = "contactEmail";

    private Handler mHandler;
    private ProgressDialog mProgressDialog;
    private File mPendingMinidumpFile;
    private File mPendingExtrasFile;
    private HashMap<String, String> mExtrasStringMap;
    private boolean mMinidumpSucceeded;

    private boolean moveFile(File inFile, File outFile) {
        Log.i(LOGTAG, "moving " + inFile + " to " + outFile);
        if (inFile.renameTo(outFile))
            return true;
        try {
            outFile.createNewFile();
            Log.i(LOGTAG, "couldn't rename minidump file");
            // so copy it instead
            FileChannel inChannel = new FileInputStream(inFile).getChannel();
            FileChannel outChannel = new FileOutputStream(outFile).getChannel();
            long transferred = inChannel.transferTo(0, inChannel.size(), outChannel);
            inChannel.close();
            outChannel.close();

            if (transferred > 0)
                inFile.delete();
        } catch (Exception e) {
            Log.e(LOGTAG, "exception while copying minidump file: ", e);
            return false;
        }
        return true;
    }

    private void doFinish() {
        if (mHandler != null) {
            mHandler.post(new Runnable() {
                @Override
                public void run() {
                    finish();
                }
            });
        }
    }

    @Override
    public void finish() {
        try {
            if (mProgressDialog.isShowing()) {
                mProgressDialog.dismiss();
            }
        } catch (Exception e) {
            Log.e(LOGTAG, "exception while closing progress dialog: ", e);
        }
        super.finish();
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // mHandler is created here so runnables can be run on the main thread
        mHandler = new Handler();
        setContentView(R.layout.crash_reporter);
        mProgressDialog = new ProgressDialog(this);
        mProgressDialog.setMessage(getString(R.string.sending_crash_report));

        mMinidumpSucceeded = getIntent().getBooleanExtra(PASSED_MINI_DUMP_SUCCESS_KEY, false);
        if (!mMinidumpSucceeded) {
            Log.i(LOGTAG, "Failed to get minidump.");
        }
        String passedMinidumpPath = getIntent().getStringExtra(PASSED_MINI_DUMP_KEY);
        File passedMinidumpFile = new File(passedMinidumpPath);
        File pendingDir = new File(getFilesDir(), PENDING_SUFFIX);
        pendingDir.mkdirs();
        mPendingMinidumpFile = new File(pendingDir, passedMinidumpFile.getName());
        moveFile(passedMinidumpFile, mPendingMinidumpFile);

        File extrasFile = new File(passedMinidumpPath.replaceAll("\\.dmp", ".extra"));
        mPendingExtrasFile = new File(pendingDir, extrasFile.getName());
        moveFile(extrasFile, mPendingExtrasFile);

        computeMinidumpHash(mPendingExtrasFile, mPendingMinidumpFile);
        mExtrasStringMap = new HashMap<String, String>();
        readStringsFromFile(mPendingExtrasFile.getPath(), mExtrasStringMap);

        // Notify GeckoApp that we've crashed, so it can react appropriately during the next start.
        try {
            File crashFlag = new File(GeckoProfileDirectories.getMozillaDirectory(this), "CRASHED");
            crashFlag.createNewFile();
        } catch (GeckoProfileDirectories.NoMozillaDirectoryException | IOException e) {
            Log.e(LOGTAG, "Cannot set crash flag: ", e);
        }

        final CheckBox allowContactCheckBox = (CheckBox) findViewById(R.id.allow_contact);
        final CheckBox includeUrlCheckBox = (CheckBox) findViewById(R.id.include_url);
        final CheckBox sendReportCheckBox = (CheckBox) findViewById(R.id.send_report);
        final EditText commentsEditText = (EditText) findViewById(R.id.comment);
        final EditText emailEditText = (EditText) findViewById(R.id.email);

        // Load CrashReporter preferences to avoid redundant user input.
        SharedPreferences prefs = GeckoSharedPrefs.forCrashReporter(this);
        final boolean sendReport   = prefs.getBoolean(PREFS_SEND_REPORT, true);
        final boolean includeUrl   = prefs.getBoolean(PREFS_INCLUDE_URL, false);
        final boolean allowContact = prefs.getBoolean(PREFS_ALLOW_CONTACT, false);
        final String contactEmail  = prefs.getString(PREFS_CONTACT_EMAIL, "");

        allowContactCheckBox.setChecked(allowContact);
        includeUrlCheckBox.setChecked(includeUrl);
        sendReportCheckBox.setChecked(sendReport);
        emailEditText.setText(contactEmail);

        sendReportCheckBox.setOnCheckedChangeListener(new CheckBox.OnCheckedChangeListener() {
            @Override
            public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
                commentsEditText.setEnabled(isChecked);
                commentsEditText.requestFocus();

                includeUrlCheckBox.setEnabled(isChecked);
                allowContactCheckBox.setEnabled(isChecked);
                emailEditText.setEnabled(isChecked && allowContactCheckBox.isChecked());
            }
        });

        allowContactCheckBox.setOnCheckedChangeListener(new CheckBox.OnCheckedChangeListener() {
            @Override
            public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
                // We need to check isEnabled() here because this listener is
                // fired on rotation -- even when the checkbox is disabled.
                emailEditText.setEnabled(checkbox.isEnabled() && isChecked);
                emailEditText.requestFocus();
            }
        });

        emailEditText.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // Even if the email EditText is disabled, allow it to be
                // clicked and focused.
                if (sendReportCheckBox.isChecked() && !v.isEnabled()) {
                    allowContactCheckBox.setChecked(true);
                    v.setEnabled(true);
                    v.requestFocus();
                }
            }
        });
    }

    @Override
    public void onBackPressed() {
        AlertDialog.Builder builder = new AlertDialog.Builder(this);
        builder.setMessage(R.string.crash_closing_alert);
        builder.setNegativeButton(R.string.button_cancel, new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                dialog.dismiss();
            }
        });
        builder.setPositiveButton(R.string.button_ok, new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                CrashReporter.this.finish();
            }
        });
        builder.show();
    }

    private void backgroundSendReport() {
        final CheckBox sendReportCheckbox = (CheckBox) findViewById(R.id.send_report);
        if (!sendReportCheckbox.isChecked()) {
            doFinish();
            return;
        }

        // Persist settings to avoid redundant user input.
        savePrefs();

        mProgressDialog.show();
        new Thread(new Runnable() {
            @Override
            public void run() {
                sendReport(mPendingMinidumpFile, mExtrasStringMap, mPendingExtrasFile);
            }
        }, "CrashReporter Thread").start();
    }

    private void savePrefs() {
        SharedPreferences.Editor editor = GeckoSharedPrefs.forCrashReporter(this).edit();

        final boolean allowContact = ((CheckBox) findViewById(R.id.allow_contact)).isChecked();
        final boolean includeUrl   = ((CheckBox) findViewById(R.id.include_url)).isChecked();
        final boolean sendReport   = ((CheckBox) findViewById(R.id.send_report)).isChecked();
        final String contactEmail  = ((EditText) findViewById(R.id.email)).getText().toString();

        editor.putBoolean(PREFS_ALLOW_CONTACT, allowContact);
        editor.putBoolean(PREFS_INCLUDE_URL, includeUrl);
        editor.putBoolean(PREFS_SEND_REPORT, sendReport);
        editor.putString(PREFS_CONTACT_EMAIL, contactEmail);

        // A slight performance improvement via async apply() vs. blocking on commit().
        editor.apply();
    }

    public void onCloseClick(View v) {  // bound via crash_reporter.xml
        backgroundSendReport();
    }

    public void onRestartClick(View v) {  // bound via crash_reporter.xml
        doRestart();
        backgroundSendReport();
    }

    private void computeMinidumpHash(File extraFile, File minidump) {
        try {
            FileInputStream stream = new FileInputStream(minidump);
            MessageDigest md = MessageDigest.getInstance("SHA-256");

            try {
                byte[] buffer = new byte[4096];
                int readBytes;

                while ((readBytes = stream.read(buffer)) != -1) {
                    md.update(buffer, 0, readBytes);
                }
            } finally {
              stream.close();
            }

            byte[] digest = md.digest();
            StringBuilder hash = new StringBuilder(84);

            hash.append("MinidumpSha256Hash=");

            for (int i = 0; i < digest.length; i++) {
              hash.append(Integer.toHexString((digest[i] & 0xf0) >> 4));
              hash.append(Integer.toHexString(digest[i] & 0x0f));
            }

            hash.append('\n');

            FileWriter writer = new FileWriter(extraFile, /* append */ true);

            try {
                writer.write(hash.toString());
            } finally {
                writer.close();
            }
        } catch (Exception e) {
            Log.e(LOGTAG, "exception while computing the minidump hash: ", e);
        }
    }

    private boolean readStringsFromFile(String filePath, Map<String, String> stringMap) {
        try {
            BufferedReader reader = new BufferedReader(new FileReader(filePath));
            return readStringsFromReader(reader, stringMap);
        } catch (Exception e) {
            Log.e(LOGTAG, "exception while reading strings: ", e);
            return false;
        }
    }

    private boolean readStringsFromReader(BufferedReader reader, Map<String, String> stringMap) throws IOException {
        String line;
        while ((line = reader.readLine()) != null) {
            int equalsPos = -1;
            if ((equalsPos = line.indexOf('=')) != -1) {
                String key = line.substring(0, equalsPos);
                String val = unescape(line.substring(equalsPos + 1));
                stringMap.put(key, val);
            }
        }
        reader.close();
        return true;
    }

    private String generateBoundary() {
        // Generate some random numbers to fill out the boundary
        int r0 = (int)(Integer.MAX_VALUE * Math.random());
        int r1 = (int)(Integer.MAX_VALUE * Math.random());
        return String.format("---------------------------%08X%08X", r0, r1);
    }

    private void sendPart(OutputStream os, String boundary, String name, String data) {
        try {
            os.write(("--" + boundary + "\r\n" +
                      "Content-Disposition: form-data; name=\"" + name + "\"\r\n" +
                      "\r\n" +
                      data + "\r\n"
                     ).getBytes());
        } catch (Exception ex) {
            Log.e(LOGTAG, "Exception when sending \"" + name + "\"", ex);
        }
    }

    private void sendFile(OutputStream os, String boundary, String name, File file) throws IOException {
        os.write(("--" + boundary + "\r\n" +
                  "Content-Disposition: form-data; name=\"" + name + "\"; " +
                  "filename=\"" + file.getName() + "\"\r\n" +
                  "Content-Type: application/octet-stream\r\n" +
                  "\r\n"
                 ).getBytes());
        FileChannel fc = new FileInputStream(file).getChannel();
        fc.transferTo(0, fc.size(), Channels.newChannel(os));
        fc.close();
    }

    private String readLogcat() {
        final String crashReporterProc = " " + android.os.Process.myPid() + ' ';
        BufferedReader br = null;
        try {
            // get at most the last 400 lines of logcat
            Process proc = Runtime.getRuntime().exec(new String[] {
                "logcat", "-v", "threadtime", "-t", "400", "-d", "*:D"
            });
            StringBuilder sb = new StringBuilder();
            br = new BufferedReader(new InputStreamReader(proc.getInputStream()));
            for (String s = br.readLine(); s != null; s = br.readLine()) {
                if (s.contains(crashReporterProc)) {
                    // Don't include logs from the crash reporter's process.
                    break;
                }
                sb.append(s).append('\n');
            }
            return sb.toString();
        } catch (Exception e) {
            return "Unable to get logcat: " + e.toString();
        } finally {
            if (br != null) {
                try {
                    br.close();
                } catch (Exception e) {
                    // ignore
                }
            }
        }
    }

    private void sendReport(File minidumpFile, Map<String, String> extras, File extrasFile) {
        Log.i(LOGTAG, "sendReport: " + minidumpFile.getPath());
        final CheckBox includeURLCheckbox = (CheckBox) findViewById(R.id.include_url);

        String spec = extras.get(SERVER_URL_KEY);
        if (spec == null) {
            doFinish();
            return;
        }

        Log.i(LOGTAG, "server url: " + spec);
        try {
            final URL url = new URL(URLDecoder.decode(spec, "UTF-8"));
            final URI uri = new URI(url.getProtocol(), url.getUserInfo(),
                                    url.getHost(), url.getPort(),
                                    url.getPath(), url.getQuery(), url.getRef());
            HttpURLConnection conn = (HttpURLConnection)ProxySelector.openConnectionWithProxy(uri);
            conn.setRequestMethod("POST");
            String boundary = generateBoundary();
            conn.setDoOutput(true);
            conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
            conn.setRequestProperty("Content-Encoding", "gzip");

            OutputStream os = new GZIPOutputStream(conn.getOutputStream());
            for (String key : extras.keySet()) {
                if (key.equals(PAGE_URL_KEY)) {
                    if (includeURLCheckbox.isChecked())
                        sendPart(os, boundary, key, extras.get(key));
                } else if (!key.equals(SERVER_URL_KEY) && !key.equals(NOTES_KEY)) {
                    sendPart(os, boundary, key, extras.get(key));
                }
            }

            // Add some extra information to notes so its displayed by
            // crash-stats.mozilla.org. Remove this when bug 607942 is fixed.
            StringBuilder sb = new StringBuilder();
            sb.append(extras.containsKey(NOTES_KEY) ? extras.get(NOTES_KEY) + "\n" : "");
            if (AppConstants.MOZ_MIN_CPU_VERSION < 7) {
                sb.append("nothumb Build\n");
            }
            sb.append(Build.MANUFACTURER).append(' ')
              .append(Build.MODEL).append('\n')
              .append(Build.FINGERPRINT);
            sendPart(os, boundary, NOTES_KEY, sb.toString());

            sendPart(os, boundary, "Min_ARM_Version", Integer.toString(AppConstants.MOZ_MIN_CPU_VERSION));
            sendPart(os, boundary, "Android_Manufacturer", Build.MANUFACTURER);
            sendPart(os, boundary, "Android_Model", Build.MODEL);
            sendPart(os, boundary, "Android_Board", Build.BOARD);
            sendPart(os, boundary, "Android_Brand", Build.BRAND);
            sendPart(os, boundary, "Android_Device", Build.DEVICE);
            sendPart(os, boundary, "Android_Display", Build.DISPLAY);
            sendPart(os, boundary, "Android_Fingerprint", Build.FINGERPRINT);
            sendPart(os, boundary, "Android_APP_ABI", AppConstants.MOZ_APP_ABI);
            sendPart(os, boundary, "Android_CPU_ABI", Build.CPU_ABI);
            sendPart(os, boundary, "Android_MIN_SDK", Integer.toString(AppConstants.Versions.MIN_SDK_VERSION));
            sendPart(os, boundary, "Android_MAX_SDK", Integer.toString(AppConstants.Versions.MAX_SDK_VERSION));
            try {
                sendPart(os, boundary, "Android_CPU_ABI2", Build.CPU_ABI2);
                sendPart(os, boundary, "Android_Hardware", Build.HARDWARE);
            } catch (Exception ex) {
                Log.e(LOGTAG, "Exception while sending SDK version 8 keys", ex);
            }
            sendPart(os, boundary, "Android_Version",  Build.VERSION.SDK_INT + " (" + Build.VERSION.CODENAME + ")");
            if (Versions.feature16Plus && includeURLCheckbox.isChecked()) {
                sendPart(os, boundary, "Android_Logcat", readLogcat());
            }

            String comment = ((EditText) findViewById(R.id.comment)).getText().toString();
            if (!TextUtils.isEmpty(comment)) {
                sendPart(os, boundary, "Comments", comment);
            }

            if (((CheckBox) findViewById(R.id.allow_contact)).isChecked()) {
                String email = ((EditText) findViewById(R.id.email)).getText().toString();
                sendPart(os, boundary, "Email", email);
            }

            sendPart(os, boundary, PASSED_MINI_DUMP_SUCCESS_KEY, mMinidumpSucceeded ? "True" : "False");
            sendFile(os, boundary, MINI_DUMP_PATH_KEY, minidumpFile);
            os.write(("\r\n--" + boundary + "--\r\n").getBytes());
            os.flush();
            os.close();
            BufferedReader br = new BufferedReader(
                new InputStreamReader(conn.getInputStream()));
            HashMap<String, String>  responseMap = new HashMap<String, String>();
            readStringsFromReader(br, responseMap);

            if (conn.getResponseCode() == HttpURLConnection.HTTP_OK) {
                File submittedDir = new File(getFilesDir(),
                                             SUBMITTED_SUFFIX);
                submittedDir.mkdirs();
                minidumpFile.delete();
                extrasFile.delete();
                String crashid = responseMap.get("CrashID");
                File file = new File(submittedDir, crashid + ".txt");
                FileOutputStream fos = new FileOutputStream(file);
                fos.write("Crash ID: ".getBytes());
                fos.write(crashid.getBytes());
                fos.close();
            } else {
                Log.i(LOGTAG, "Received failure HTTP response code from server: " + conn.getResponseCode());
            }
        } catch (IOException e) {
            Log.e(LOGTAG, "exception during send: ", e);
        } catch (URISyntaxException e) {
            Log.e(LOGTAG, "exception during new URI: ", e);
        }

        doFinish();
    }

    private void doRestart() {
        try {
            String action = "android.intent.action.MAIN";
            Intent intent = new Intent(action);
            intent.setClassName(AppConstants.ANDROID_PACKAGE_NAME,
                                AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
            intent.putExtra("didRestart", true);
            Log.i(LOGTAG, intent.toString());
            startActivity(intent);
        } catch (Exception e) {
            Log.e(LOGTAG, "error while trying to restart", e);
        }
    }

    private String unescape(String string) {
        return string.replaceAll("\\\\\\\\", "\\").replaceAll("\\\\n", "\n").replaceAll("\\\\t", "\t");
    }
}
