// Copyright (C) 2019 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.

import m from 'mithril';

import {ErrorDetails} from '../base/logging';
import {EXTENSION_URL} from '../common/recordingV2/recording_utils';
import {TraceGcsUploader} from '../common/upload_utils';
import {RECORDING_V2_FLAG} from '../core/feature_flags';
import {raf} from '../core/raf_scheduler';
import {VERSION} from '../gen/perfetto_version';
import {getCurrentModalKey, showModal} from '../widgets/modal';

import {globals} from './globals';

const MODAL_KEY = 'crash_modal';

// Never show more than one dialog per 10s.
const MIN_REPORT_PERIOD_MS = 10000;
let timeLastReport = 0;

export function maybeShowErrorDialog(err: ErrorDetails) {
  const now = performance.now();

  // Here we rely on the exception message from onCannotGrowMemory function
  if (err.message.includes('Cannot enlarge memory')) {
    showOutOfMemoryDialog();
    // Refresh timeLastReport to prevent a different error showing a dialog
    timeLastReport = now;
    return;
  }

  if (!RECORDING_V2_FLAG.get()) {
    if (err.message.includes('Unable to claim interface')) {
      showWebUSBError();
      timeLastReport = now;
      return;
    }

    if (err.message.includes('A transfer error has occurred') ||
        err.message.includes('The device was disconnected') ||
        err.message.includes('The transfer was cancelled')) {
      showConnectionLostError();
      timeLastReport = now;
      return;
    }
  }

  if (err.message.includes('(ERR:fmt)')) {
    showUnknownFileError();
    return;
  }

  if (err.message.includes('(ERR:rpc_seq)')) {
    showRpcSequencingError();
    return;
  }

  if (timeLastReport > 0 && now - timeLastReport <= MIN_REPORT_PERIOD_MS) {
    console.log('Suppressing crash dialog, last error notified too soon.');
    return;
  }
  timeLastReport = now;

  // If we are already showing a crash dialog, don't overwrite it with a newer
  // crash. Usually the first crash matters, the rest avalanching effects.
  if (getCurrentModalKey() === MODAL_KEY) {
    return;
  }

  showModal({
    key: MODAL_KEY,
    title: 'Oops, something went wrong. Please file a bug.',
    content: () => m(ErrorDialogComponent, err),
  });
}


class ErrorDialogComponent implements m.ClassComponent<ErrorDetails> {
  private traceState: 'NOT_AVAILABLE'|'NOT_UPLOADED'|'UPLOADING'|'UPLOADED';
  private traceType: string = 'No trace loaded';
  private traceData?: ArrayBuffer|File;
  private traceUrl?: string;
  private attachTrace = false;
  private uploadStatus = '';
  private userDescription = '';
  private errorMessage = '';
  private uploader?: TraceGcsUploader;

  constructor() {
    this.traceState = 'NOT_AVAILABLE';
    const engine = globals.getCurrentEngine();
    if (engine === undefined) return;
    this.traceType = engine.source.type;
    // If the trace is either already uploaded, or comes from a postmessage+url
    // we don't need any re-upload.
    if (engine && 'url' in engine.source && engine.source.url !== undefined) {
      this.traceUrl = engine.source.url;
      this.traceState = 'UPLOADED';
      // The trace is already uploaded, so assume the user is fine attaching to
      // the bugreport (this make the checkbox ticked by default).
      this.attachTrace = true;
      return;
    }

    // If the user is not a googler, don't even offer the option to upload it.
    if (!globals.isInternalUser) return;

    if (engine.source.type === 'FILE') {
      this.traceState = 'NOT_UPLOADED';
      this.traceData = engine.source.file;
      // this.traceSize = this.traceData.size;
    } else if (engine.source.type === 'ARRAY_BUFFER') {
      this.traceData = engine.source.buffer;
      // this.traceSize = this.traceData.byteLength;
    } else {
      return;  // Can't upload HTTP+RPC.
    }
    this.traceState = 'NOT_UPLOADED';
  }

  view(vnode: m.Vnode<ErrorDetails>) {
    const err = vnode.attrs;
    let msg = `UI: ${location.protocol}//${location.host}/${VERSION}\n\n`;

    // Append the trace stack.
    msg += `${err.message}\n`;
    for (const entry of err.stack) {
      msg += ` - ${entry.name} (${entry.location})\n`;
    }
    msg += '\n';

    // Append the trace URL.
    if (this.attachTrace && this.traceUrl) {
      msg += `Trace: ${this.traceUrl}\n`;
    } else if (this.attachTrace && this.traceState === 'UPLOADING') {
      msg += `Trace: uploading...\n`;
    } else {
      msg += `Trace: not available (${this.traceType}). Provide repro steps.\n`;
    }
    msg += `UA: ${navigator.userAgent}\n`;
    msg += `Referrer: ${document.referrer}\n`;
    this.errorMessage = msg;

    let shareTraceSection: m.Vnode|null = null;
    if (this.traceState !== 'NOT_AVAILABLE') {
      shareTraceSection = m(
          'div',
          m(
              'label',
              m(`input[type=checkbox]`, {
                checked: this.attachTrace,
                oninput: (ev: InputEvent) => {
                  const checked = (ev.target as HTMLInputElement).checked;
                  this.onUploadCheckboxChange(checked);
                },
              }),
              this.traceState === 'UPLOADING' ?
                  `Uploading trace... ${this.uploadStatus}` :
                  'Tick to share the current trace and help debugging',
              ),  // m('label')
          m('div.modal-small',
            `This will upload the trace and attach a link to the bug.
          You may leave it unchecked and attach the trace manually to the bug
          if preferred.`),
      );
    }  // if (this.traceState !== 'NOT_AVAILABLE')

    return [
      m(
          'div',
          m('.modal-logs', msg),
          m('span', `Please provide any additional details describing
        how the crash occurred:`),
          m('textarea.modal-textarea', {
            rows: 3,
            maxlength: 1000,
            oninput: (ev: InputEvent) => {
              this.userDescription = (ev.target as HTMLTextAreaElement).value;
            },
            onkeydown: (e: Event) => e.stopPropagation(),
            onkeyup: (e: Event) => e.stopPropagation(),
          }),
          shareTraceSection,
          ),
      m('footer',
        m('button.modal-btn.modal-btn-primary',
          {onclick: () => this.fileBug(err)},
          'File a bug (Googlers only)')),
    ];
  }

  private onUploadCheckboxChange(checked: boolean) {
    raf.scheduleFullRedraw();
    this.attachTrace = checked;

    if (checked && this.traceData !== undefined &&
        this.traceState === 'NOT_UPLOADED') {
      this.traceState = 'UPLOADING';
      this.uploadStatus = '';
      const uploader = new TraceGcsUploader(this.traceData, () => {
        raf.scheduleFullRedraw();
        this.uploadStatus = uploader.getEtaString();
        if (uploader.state === 'UPLOADED') {
          this.traceState = 'UPLOADED';
          this.traceUrl = uploader.uploadedUrl;
        } else if (uploader.state === 'ERROR') {
          this.traceState = 'NOT_UPLOADED';
          this.uploadStatus = uploader.error;
        }
      });
      this.uploader = uploader;
    } else if (!checked && this.uploader) {
      this.uploader.abort();
    }
  }

  private fileBug(err: ErrorDetails) {
    const errTitle = err.message.split('\n', 1)[0].substring(0, 80);
    let url = 'https://goto.google.com/perfetto-ui-bug';
    url += '?title=' + encodeURIComponent(`UI Error: ${errTitle}`);
    url += '&description=';
    if (this.userDescription !== '') {
      url += encodeURIComponent(
          'User description:\n' + this.userDescription + '\n\n');
    }
    url += encodeURIComponent(this.errorMessage);
    // 8kb is common limit on request size so restrict links to that long:
    url = url.substring(0, 8000);
    window.open(url, '_blank');
  }
}

function showOutOfMemoryDialog() {
  const url =
      'https://perfetto.dev/docs/quickstart/trace-analysis#get-trace-processor';

  const tpCmd = 'curl -LO https://get.perfetto.dev/trace_processor\n' +
      'chmod +x ./trace_processor\n' +
      'trace_processor --httpd /path/to/trace.pftrace\n' +
      '# Reload the UI, it will prompt to use the HTTP+RPC interface';
  showModal({
    title: 'Oops! Your WASM trace processor ran out of memory',
    content: m(
        'div',
        m('span',
          'The in-memory representation of the trace is too big ' +
              'for the browser memory limits (typically 2GB per tab).'),
        m('br'),
        m('span',
          'You can work around this problem by using the trace_processor ' +
              'native binary as an accelerator for the UI as follows:'),
        m('br'),
        m('br'),
        m('.modal-bash', tpCmd),
        m('br'),
        m('span', 'For details see '),
        m('a', {href: url, target: '_blank'}, url),
        ),
  });
}

function showUnknownFileError() {
  showModal({
    title: 'Cannot open this file',
    content: m(
        'div',
        m('p',
          'The file opened doesn\'t look like a Perfetto trace or any ' +
              'other format recognized by the Perfetto TraceProcessor.'),
        m('p', 'Formats supported:'),
        m(
            'ul',
            m('li', 'Perfetto protobuf trace'),
            m('li', 'chrome://tracing JSON'),
            m('li', 'Android systrace'),
            m('li', 'Fuchsia trace'),
            m('li', 'Ninja build log'),
            ),
        ),
  });
}

function showWebUSBError() {
  showModal({
    title: 'A WebUSB error occurred',
    content: m(
        'div',
        m('span', `Is adb already running on the host? Run this command and
      try again.`),
        m('br'),
        m('.modal-bash', '> adb kill-server'),
        m('br'),
        m('span', 'For details see '),
        m('a', {href: 'http://b/159048331', target: '_blank'}, 'b/159048331'),
        ),
  });
}

export function showWebUSBErrorV2() {
  showModal({
    title: 'A WebUSB error occurred',
    content: m(
        'div',
        m('span', `Is adb already running on the host? Run this command and
      try again.`),
        m('br'),
        m('.modal-bash', '> adb kill-server'),
        m('br'),
        // The statement below covers the following edge case:
        // 1. 'adb server' is running on the device.
        // 2. The user selects the new Android target, so we try to fetch the
        // OS version and do QSS.
        // 3. The error modal is shown.
        // 4. The user runs 'adb kill-server'.
        // At this point we don't have a trigger to try fetching the OS version
        // + QSS again. Therefore, the user will need to refresh the page.
        m('span',
          'If after running \'adb kill-server\', you don\'t see ' +
              'a \'Start Recording\' button on the page and you don\'t see ' +
              '\'Allow USB debugging\' on the device, ' +
              'you will need to reload this page.'),
        m('br'),
        m('br'),
        m('span', 'For details see '),
        m('a', {href: 'http://b/159048331', target: '_blank'}, 'b/159048331'),
        ),
  });
}

export function showConnectionLostError(): void {
  showModal({
    title: 'Connection with the ADB device lost',
    content: m(
        'div',
        m('span', `Please connect the device again to restart the recording.`),
        m('br')),
  });
}

export function showAllowUSBDebugging(): void {
  showModal({
    title: 'Could not connect to the device',
    content: m(
        'div', m('span', 'Please allow USB debugging on the device.'), m('br')),
  });
}

export function showNoDeviceSelected(): void {
  showModal({
    title: 'No device was selected for recording',
    content:
        m('div',
          m('span', `If you want to connect to an ADB device,
           please select it from the list.`),
          m('br')),
  });
}

export function showExtensionNotInstalled(): void {
  showModal({
    title: 'Perfetto Chrome extension not installed',
    content:
        m('div',
          m('.note',
            `To trace Chrome from the Perfetto UI, you need to install our `,
            m('a', {href: EXTENSION_URL, target: '_blank'}, 'Chrome extension'),
            ' and then reload this page.'),
          m('br')),
  });
}

export function showWebsocketConnectionIssue(message: string): void {
  showModal({
    title: 'Unable to connect to the device via websocket',
    content: m('div', m('span', message), m('br')),
  });
}

export function showIssueParsingTheTracedResponse(message: string): void {
  showModal({
    title: 'A problem was encountered while connecting to' +
        ' the Perfetto tracing service',
    content: m('div', m('span', message), m('br')),
  });
}

export function showFailedToPushBinary(message: string): void {
  showModal({
    title: 'Failed to push a binary to the device',
    content:
        m('div',
          m('span',
            'This can happen if your Android device has an OS version lower ' +
                'than Q. Perfetto tried to push the latest version of its ' +
                'embedded binary but failed.'),
          m('br'),
          m('br'),
          m('span', 'Error message:'),
          m('br'),
          m('span', message)),
  });
}

function showRpcSequencingError() {
  showModal({
    title: 'A TraceProcessor RPC error occurred',
    content: m(
        'div',
        m('p', 'The trace processor RPC sequence ID was broken'),
        m('p', `This can happen when using a HTTP trace processor instance and
either accidentally sharing this between multiple tabs or
restarting the trace processor while still in use by UI.`),
        m('p', `Please refresh this tab and ensure that trace processor is used
at most one tab at a time.`),
        ),
  });
}
