// Copyright (C) 2018 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 * as m from 'mithril';

import {Actions} from '../common/actions';
import {featureFlags} from '../common/feature_flags';
import {
  AdbRecordingTarget,
  getDefaultRecordingTargets,
  hasActiveProbes,
  isAdbTarget,
  isAndroidP,
  isAndroidTarget,
  isChromeTarget,
  isCrOSTarget,
  isLinuxTarget,
  LoadedConfig,
  MAX_TIME,
  RecordingTarget,
} from '../common/state';
import {AdbOverWebUsb} from '../controller/adb';
import {
  createEmptyRecordConfig,
  RecordConfig,
} from '../controller/record_config_types';

import {globals} from './globals';
import {createPage, PageAttrs} from './pages';
import {
  autosaveConfigStore,
  recordConfigStore,
  recordTargetStore,
} from './record_config';
import {
  CodeSnippet,
} from './record_widgets';
import {AdvancedSettings} from './recording/advanced_settings';
import {AndroidSettings} from './recording/android_settings';
import {ChromeSettings} from './recording/chrome_settings';
import {CpuSettings} from './recording/cpu_settings';
import {GpuSettings} from './recording/gpu_settings';
import {MemorySettings} from './recording/memory_settings';
import {PowerSettings} from './recording/power_settings';
import {RecordingSectionAttrs} from './recording/recording_sections';
import {RecordingSettings} from './recording/recording_settings';

export const PERSIST_CONFIG_FLAG = featureFlags.register({
  id: 'persistConfigsUI',
  name: 'Config persistence UI',
  description: 'Show experimental config persistence UI on the record page.',
  defaultValue: true,
});

export const RECORDING_SECTIONS = [
  'buffers',
  'instructions',
  'config',
  'cpu',
  'gpu',
  'power',
  'memory',
  'android',
  'chrome',
  'advanced',
];

function RecordHeader() {
  return m(
      '.record-header',
      m('.top-part',
        m('.target-and-status',
          RecordingPlatformSelection(),
          RecordingStatusLabel(),
          ErrorLabel()),
        recordingButtons()),
      RecordingNotes());
}

function RecordingPlatformSelection() {
  if (globals.state.recordingInProgress) return [];

  const availableAndroidDevices = globals.state.availableAdbDevices;
  const recordingTarget = globals.state.recordingTarget;

  const targets = [];
  for (const {os, name} of getDefaultRecordingTargets()) {
    targets.push(m('option', {value: os}, name));
  }
  for (const d of availableAndroidDevices) {
    targets.push(m('option', {value: d.serial}, d.name));
  }

  const selectedIndex = isAdbTarget(recordingTarget) ?
      targets.findIndex((node) => node.attrs.value === recordingTarget.serial) :
      targets.findIndex((node) => node.attrs.value === recordingTarget.os);

  return m(
      '.target',
      m(
          'label',
          'Target platform:',
          m('select',
            {
              selectedIndex,
              onchange: (e: Event) => {
                onTargetChange((e.target as HTMLSelectElement).value);
              },
              onupdate: (select) => {
                // Work around mithril bug
                // (https://github.com/MithrilJS/mithril.js/issues/2107): We may
                // update the select's options while also changing the
                // selectedIndex at the same time. The update of selectedIndex
                // may be applied before the new options are added to the select
                // element. Because the new selectedIndex may be outside of the
                // select's options at that time, we have to reselect the
                // correct index here after any new children were added.
                (select.dom as HTMLSelectElement).selectedIndex = selectedIndex;
              },
            },
            ...targets),
          ),
      m('.chip',
        {onclick: addAndroidDevice},
        m('button', 'Add ADB Device'),
        m('i.material-icons', 'add')));
}

// |target| can be the TargetOs or the android serial.
function onTargetChange(target: string) {
  const recordingTarget: RecordingTarget =
      globals.state.availableAdbDevices.find((d) => d.serial === target) ||
      getDefaultRecordingTargets().find((t) => t.os === target) ||
      getDefaultRecordingTargets()[0];

  if (isChromeTarget(recordingTarget)) {
    globals.dispatch(Actions.setFetchChromeCategories({fetch: true}));
  }

  globals.dispatch(Actions.setRecordingTarget({target: recordingTarget}));
  recordTargetStore.save(target);
  globals.rafScheduler.scheduleFullRedraw();
}

function Instructions(cssClass: string) {
  return m(
      `.record-section.instructions${cssClass}`,
      m('header', 'Recording command'),
      PERSIST_CONFIG_FLAG.get() ?
          m('button.permalinkconfig',
            {
              onclick: () => {
                globals.dispatch(
                    Actions.createPermalink({isRecordingConfig: true}));
              },
            },
            'Share recording settings') :
          null,
      RecordingSnippet(),
      BufferUsageProgressBar(),
      m('.buttons', StopCancelButtons()),
      recordingLog());
}

export function loadedConfigEqual(
    cfg1: LoadedConfig, cfg2: LoadedConfig): boolean {
  return cfg1.type === 'NAMED' && cfg2.type === 'NAMED' ?
      cfg1.name === cfg2.name :
      cfg1.type === cfg2.type;
}

export function loadConfigButton(
    config: RecordConfig, configType: LoadedConfig): m.Vnode {
  return m(
      'button',
      {
        class: 'config-button',
        title: 'Apply configuration settings',
        disabled: loadedConfigEqual(configType, globals.state.lastLoadedConfig),
        onclick: () => {
          globals.dispatch(Actions.setRecordConfig({config, configType}));
          globals.rafScheduler.scheduleFullRedraw();
        },
      },
      m('i.material-icons', 'file_upload'));
}

export function displayRecordConfigs() {
  const configs = [];
  if (autosaveConfigStore.hasSavedConfig) {
    configs.push(m('.config', [
      m('span.title-config', m('strong', 'Latest started recording')),
      loadConfigButton(autosaveConfigStore.get(), {type: 'AUTOMATIC'}),
    ]));
  }
  for (const validated of recordConfigStore.recordConfigs) {
    const item = validated.result;
    configs.push(m('.config', [
      m('span.title-config', item.title),
      loadConfigButton(item.config, {type: 'NAMED', name: item.title}),
      m('button',
        {
          class: 'config-button',
          title: 'Overwrite configuration with current settings',
          onclick: () => {
            if (confirm(`Overwrite config "${
                    item.title}" with current settings?`)) {
              recordConfigStore.overwrite(globals.state.recordConfig, item.key);
              globals.dispatch(Actions.setRecordConfig({
                config: item.config,
                configType: {type: 'NAMED', name: item.title},
              }));
              globals.rafScheduler.scheduleFullRedraw();
            }
          },
        },
        m('i.material-icons', 'save')),
      m('button',
        {
          class: 'config-button',
          title: 'Remove configuration',
          onclick: () => {
            recordConfigStore.delete(item.key);
            globals.rafScheduler.scheduleFullRedraw();
          },
        },
        m('i.material-icons', 'delete')),
    ]));

    const errorItems = [];
    for (const extraKey of validated.extraKeys) {
      errorItems.push(m('li', `${extraKey} is unrecognised`));
    }
    for (const invalidKey of validated.invalidKeys) {
      errorItems.push(m('li', `${invalidKey} contained an invalid value`));
    }

    if (errorItems.length > 0) {
      configs.push(
          m('.parsing-errors',
            'One or more errors have been found while loading configuration "' +
                item.title + '". Loading is possible, but make sure to check ' +
                'the settings afterwards.',
            m('ul', errorItems)));
    }
  }
  return configs;
}

export const ConfigTitleState = {
  title: '',
  getTitle: () => {
    return ConfigTitleState.title;
  },
  setTitle: (value: string) => {
    ConfigTitleState.title = value;
  },
  clearTitle: () => {
    ConfigTitleState.title = '';
  },
};

export function Configurations(cssClass: string) {
  const canSave = recordConfigStore.canSave(ConfigTitleState.getTitle());
  return m(
      `.record-section${cssClass}`,
      m('header', 'Save and load configurations'),
      m('.input-config',
        [
          m('input', {
            value: ConfigTitleState.title,
            placeholder: 'Title for config',
            oninput() {
              ConfigTitleState.setTitle(this.value);
              globals.rafScheduler.scheduleFullRedraw();
            },
          }),
          m('button',
            {
              class: 'config-button',
              disabled: !canSave,
              title: canSave ? 'Save current config' :
                               'Duplicate name, saving disabled',
              onclick: () => {
                recordConfigStore.save(
                    globals.state.recordConfig, ConfigTitleState.getTitle());
                globals.rafScheduler.scheduleFullRedraw();
                ConfigTitleState.clearTitle();
              },
            },
            m('i.material-icons', 'save')),
          m('button',
            {
              class: 'config-button',
              title: 'Clear current configuration',
              onclick: () => {
                if (confirm(
                        'Current configuration will be cleared. ' +
                        'Are you sure?')) {
                  globals.dispatch(Actions.setRecordConfig({
                    config: createEmptyRecordConfig(),
                    configType: {type: 'NONE'},
                  }));
                  globals.rafScheduler.scheduleFullRedraw();
                }
              },
            },
            m('i.material-icons', 'delete_forever')),
        ]),
      displayRecordConfigs());
}

function BufferUsageProgressBar() {
  if (!globals.state.recordingInProgress) return [];

  const bufferUsage = globals.bufferUsage ? globals.bufferUsage : 0.0;
  // Buffer usage is not available yet on Android.
  if (bufferUsage === 0) return [];

  return m(
      'label',
      'Buffer usage: ',
      m('progress', {max: 100, value: bufferUsage * 100}));
}

function RecordingNotes() {
  const sideloadUrl =
      'https://perfetto.dev/docs/contributing/build-instructions#get-the-code';
  const linuxUrl = 'https://perfetto.dev/docs/quickstart/linux-tracing';
  const cmdlineUrl =
      'https://perfetto.dev/docs/quickstart/android-tracing#perfetto-cmdline';
  const extensionURL = `https://chrome.google.com/webstore/detail/
      perfetto-ui/lfmkphfpdbjijhpomgecfikhfohaoine`;

  const notes: m.Children = [];

  const msgFeatNotSupported =
      m('span', `Some probes are only supported in Perfetto versions running
      on Android Q+. `);

  const msgPerfettoNotSupported =
      m('span', `Perfetto is not supported natively before Android P. `);

  const msgSideload =
      m('span',
        `If you have a rooted device you can `,
        m('a',
          {href: sideloadUrl, target: '_blank'},
          `sideload the latest version of
         Perfetto.`));

  const msgRecordingNotSupported =
      m('.note',
        `Recording Perfetto traces from the UI is not supported natively
     before Android Q. If you are using a P device, please select 'Android P'
     as the 'Target Platform' and `,
        m('a',
          {href: cmdlineUrl, target: '_blank'},
          `collect the trace using ADB.`));

  const msgChrome =
      m('.note',
        `To trace Chrome from the Perfetto UI, you need to install our `,
        m('a', {href: extensionURL, target: '_blank'}, 'Chrome extension'),
        ' and then reload this page.');

  const msgLinux =
      m('.note',
        `Use this `,
        m('a', {href: linuxUrl, target: '_blank'}, `quickstart guide`),
        ` to get started with tracing on Linux.`);

  const msgLongTraces = m(
      '.note',
      `Recording in long trace mode through the UI is not supported. Please copy
    the command and `,
      m('a',
        {href: cmdlineUrl, target: '_blank'},
        `collect the trace using ADB.`));

  const msgZeroProbes =
      m('.note',
        'It looks like you didn\'t add any probes. ' +
            'Please add at least one to get a non-empty trace.');

  if (!hasActiveProbes(globals.state.recordConfig)) {
    notes.push(msgZeroProbes);
  }

  if (isAdbTarget(globals.state.recordingTarget)) {
    notes.push(msgRecordingNotSupported);
  }
  switch (globals.state.recordingTarget.os) {
    case 'Q':
      break;
    case 'P':
      notes.push(m('.note', msgFeatNotSupported, msgSideload));
      break;
    case 'O':
      notes.push(m('.note', msgPerfettoNotSupported, msgSideload));
      break;
    case 'L':
      notes.push(msgLinux);
      break;
    case 'C':
      if (!globals.state.extensionInstalled) notes.push(msgChrome);
      break;
    case 'CrOS':
      if (!globals.state.extensionInstalled) notes.push(msgChrome);
      break;
    default:
  }
  if (globals.state.recordConfig.mode === 'LONG_TRACE') {
    notes.unshift(msgLongTraces);
  }

  return notes.length > 0 ? m('div', notes) : [];
}

function RecordingSnippet() {
  const target = globals.state.recordingTarget;

  // We don't need commands to start tracing on chrome
  if (isChromeTarget(target)) {
    return globals.state.extensionInstalled &&
            !globals.state.recordingInProgress ?
        m('div',
          m('label',
            `To trace Chrome from the Perfetto UI you just have to press
         'Start Recording'.`)) :
        [];
  }
  return m(CodeSnippet, {text: getRecordCommand(target)});
}

function getRecordCommand(target: RecordingTarget) {
  const data = globals.trackDataStore.get('config') as
          {commandline: string, pbtxt: string, pbBase64: string} |
      null;

  const cfg = globals.state.recordConfig;
  let time = cfg.durationMs / 1000;

  if (time > MAX_TIME) {
    time = MAX_TIME;
  }

  const pbBase64 = data ? data.pbBase64 : '';
  const pbtx = data ? data.pbtxt : '';
  let cmd = '';
  if (isAndroidP(target)) {
    cmd += `echo '${pbBase64}' | \n`;
    cmd += 'base64 --decode | \n';
    cmd += 'adb shell "perfetto -c - -o /data/misc/perfetto-traces/trace"\n';
  } else {
    cmd +=
        isAndroidTarget(target) ? 'adb shell perfetto \\\n' : 'perfetto \\\n';
    cmd += '  -c - --txt \\\n';
    cmd += '  -o /data/misc/perfetto-traces/trace \\\n';
    cmd += '<<EOF\n\n';
    cmd += pbtx;
    cmd += '\nEOF\n';
  }
  return cmd;
}

function recordingButtons() {
  const state = globals.state;
  const target = state.recordingTarget;
  const recInProgress = state.recordingInProgress;

  const start =
      m(`button`,
        {
          class: recInProgress ? '' : 'selected',
          onclick: onStartRecordingPressed,
        },
        'Start Recording');

  const buttons: m.Children = [];

  if (isAndroidTarget(target)) {
    if (!recInProgress && isAdbTarget(target) &&
        globals.state.recordConfig.mode !== 'LONG_TRACE') {
      buttons.push(start);
    }
  } else if (isChromeTarget(target) && state.extensionInstalled) {
    buttons.push(start);
  }
  return m('.button', buttons);
}

function StopCancelButtons() {
  if (!globals.state.recordingInProgress) return [];

  const stop =
      m(`button.selected`,
        {onclick: () => globals.dispatch(Actions.stopRecording({}))},
        'Stop');

  const cancel =
      m(`button`,
        {onclick: () => globals.dispatch(Actions.cancelRecording({}))},
        'Cancel');

  return [stop, cancel];
}

function onStartRecordingPressed() {
  location.href = '#!/record/instructions';
  globals.rafScheduler.scheduleFullRedraw();
  autosaveConfigStore.save(globals.state.recordConfig);

  const target = globals.state.recordingTarget;
  if (isAndroidTarget(target) || isChromeTarget(target)) {
    globals.logging.logEvent('Record Trace', `Record trace (${target.os})`);
    globals.dispatch(Actions.startRecording({}));
  }
}

function RecordingStatusLabel() {
  const recordingStatus = globals.state.recordingStatus;
  if (!recordingStatus) return [];
  return m('label', recordingStatus);
}

export function ErrorLabel() {
  const lastRecordingError = globals.state.lastRecordingError;
  if (!lastRecordingError) return [];
  return m('label.error-label', `Error:  ${lastRecordingError}`);
}

function recordingLog() {
  const logs = globals.recordingLog;
  if (logs === undefined) return [];
  return m('.code-snippet.no-top-bar', m('code', logs));
}

// The connection must be done in the frontend. After it, the serial ID will
// be inserted in the state, and the worker will be able to connect to the
// correct device.
async function addAndroidDevice() {
  let device: USBDevice;
  try {
    device = await new AdbOverWebUsb().findDevice();
  } catch (e) {
    const err = `No device found: ${e.name}: ${e.message}`;
    console.error(err, e);
    alert(err);
    return;
  }

  if (!device.serialNumber) {
    console.error('serial number undefined');
    return;
  }

  // After the user has selected a device with the chrome UI, it will be
  // available when listing all the available device from WebUSB. Therefore,
  // we update the list of available devices.
  await updateAvailableAdbDevices(device.serialNumber);
}

export async function updateAvailableAdbDevices(
    preferredDeviceSerial?: string) {
  const devices = await new AdbOverWebUsb().getPairedDevices();

  let recordingTarget: AdbRecordingTarget|undefined = undefined;

  const availableAdbDevices: AdbRecordingTarget[] = [];
  devices.forEach((d) => {
    if (d.productName && d.serialNumber) {
      // TODO(nicomazz): At this stage, we can't know the OS version, so we
      // assume it is 'Q'. This can create problems with devices with an old
      // version of perfetto. The os detection should be done after the adb
      // connection, from adb_record_controller
      availableAdbDevices.push(
          {name: d.productName, serial: d.serialNumber, os: 'Q'});
      if (preferredDeviceSerial && preferredDeviceSerial === d.serialNumber) {
        recordingTarget = availableAdbDevices[availableAdbDevices.length - 1];
      }
    }
  });

  globals.dispatch(
      Actions.setAvailableAdbDevices({devices: availableAdbDevices}));
  selectAndroidDeviceIfAvailable(availableAdbDevices, recordingTarget);
  globals.rafScheduler.scheduleFullRedraw();
  return availableAdbDevices;
}

function selectAndroidDeviceIfAvailable(
    availableAdbDevices: AdbRecordingTarget[],
    recordingTarget?: RecordingTarget) {
  if (!recordingTarget) {
    recordingTarget = globals.state.recordingTarget;
  }
  const deviceConnected = isAdbTarget(recordingTarget);
  const connectedDeviceDisconnected = deviceConnected &&
      availableAdbDevices.find(
          (e) => e.serial ===
              (recordingTarget as AdbRecordingTarget).serial) === undefined;

  if (availableAdbDevices.length) {
    // If there's an Android device available and the current selection isn't
    // one, select the Android device by default. If the current device isn't
    // available anymore, but another Android device is, select the other
    // Android device instead.
    if (!deviceConnected || connectedDeviceDisconnected) {
      recordingTarget = availableAdbDevices[0];
    }

    globals.dispatch(Actions.setRecordingTarget({target: recordingTarget}));
    return;
  }

  // If the currently selected device was disconnected, reset the recording
  // target to the default one.
  if (connectedDeviceDisconnected) {
    globals.dispatch(
        Actions.setRecordingTarget({target: getDefaultRecordingTargets()[0]}));
  }
}

function recordMenu(routePage: string) {
  const target = globals.state.recordingTarget;
  const chromeProbe =
      m('a[href="#!/record/chrome"]',
        m(`li${routePage === 'chrome' ? '.active' : ''}`,
          m('i.material-icons', 'laptop_chromebook'),
          m('.title', 'Chrome'),
          m('.sub', 'Chrome traces')));
  const cpuProbe =
      m('a[href="#!/record/cpu"]',
        m(`li${routePage === 'cpu' ? '.active' : ''}`,
          m('i.material-icons', 'subtitles'),
          m('.title', 'CPU'),
          m('.sub', 'CPU usage, scheduling, wakeups')));
  const gpuProbe =
      m('a[href="#!/record/gpu"]',
        m(`li${routePage === 'gpu' ? '.active' : ''}`,
          m('i.material-icons', 'aspect_ratio'),
          m('.title', 'GPU'),
          m('.sub', 'GPU frequency, memory')));
  const powerProbe =
      m('a[href="#!/record/power"]',
        m(`li${routePage === 'power' ? '.active' : ''}`,
          m('i.material-icons', 'battery_charging_full'),
          m('.title', 'Power'),
          m('.sub', 'Battery and other energy counters')));
  const memoryProbe =
      m('a[href="#!/record/memory"]',
        m(`li${routePage === 'memory' ? '.active' : ''}`,
          m('i.material-icons', 'memory'),
          m('.title', 'Memory'),
          m('.sub', 'Physical mem, VM, LMK')));
  const androidProbe =
      m('a[href="#!/record/android"]',
        m(`li${routePage === 'android' ? '.active' : ''}`,
          m('i.material-icons', 'android'),
          m('.title', 'Android apps & svcs'),
          m('.sub', 'atrace and logcat')));
  const advancedProbe =
      m('a[href="#!/record/advanced"]',
        m(`li${routePage === 'advanced' ? '.active' : ''}`,
          m('i.material-icons', 'settings'),
          m('.title', 'Advanced settings'),
          m('.sub', 'Complicated stuff for wizards')));
  const recInProgress = globals.state.recordingInProgress;

  const probes = [];
  if (isCrOSTarget(target) || isLinuxTarget(target)) {
    probes.push(cpuProbe, powerProbe, memoryProbe, chromeProbe, advancedProbe);
  } else if (isChromeTarget(target)) {
    probes.push(chromeProbe);
  } else {
    probes.push(
        cpuProbe,
        gpuProbe,
        powerProbe,
        memoryProbe,
        androidProbe,
        chromeProbe,
        advancedProbe);
  }

  return m(
      '.record-menu',
      {
        class: recInProgress ? 'disabled' : '',
        onclick: () => globals.rafScheduler.scheduleFullRedraw(),
      },
      m('header', 'Trace config'),
      m('ul',
        m('a[href="#!/record/buffers"]',
          m(`li${routePage === 'buffers' ? '.active' : ''}`,
            m('i.material-icons', 'tune'),
            m('.title', 'Recording settings'),
            m('.sub', 'Buffer mode, size and duration'))),
        m('a[href="#!/record/instructions"]',
          m(`li${routePage === 'instructions' ? '.active' : ''}`,
            m('i.material-icons.rec', 'fiber_manual_record'),
            m('.title', 'Recording command'),
            m('.sub', 'Manually record trace'))),
        PERSIST_CONFIG_FLAG.get() ?
            m('a[href="#!/record/config"]',
              {
                onclick: () => {
                  recordConfigStore.reloadFromLocalStorage();
                },
              },
              m(`li${routePage === 'config' ? '.active' : ''}`,
                m('i.material-icons', 'save'),
                m('.title', 'Saved configs'),
                m('.sub', 'Manage local configs'))) :
            null),
      m('header', 'Probes'),
      m('ul', probes));
}

export function maybeGetActiveCss(routePage: string, section: string): string {
  return routePage === section ? '.active' : '';
}

export const RecordPage = createPage({
  view({attrs}: m.Vnode<PageAttrs>) {
    const pages: m.Children = [];
    // we need to remove the `/` character from the route
    let routePage = attrs.subpage ? attrs.subpage.substr(1) : '';
    if (!RECORDING_SECTIONS.includes(routePage)) {
      routePage = 'buffers';
    }
    pages.push(recordMenu(routePage));

    pages.push(m(RecordingSettings, {
      dataSources: [],
      cssClass: maybeGetActiveCss(routePage, 'buffers'),
    } as RecordingSectionAttrs));
    pages.push(Instructions(maybeGetActiveCss(routePage, 'instructions')));
    pages.push(Configurations(maybeGetActiveCss(routePage, 'config')));

    const settingsSections = new Map([
      ['cpu', CpuSettings],
      ['gpu', GpuSettings],
      ['power', PowerSettings],
      ['memory', MemorySettings],
      ['android', AndroidSettings],
      ['chrome', ChromeSettings],
      ['advanced', AdvancedSettings],
    ]);
    for (const [section, component] of settingsSections.entries()) {
      pages.push(m(component, {
        dataSources: [],
        cssClass: maybeGetActiveCss(routePage, section),
      } as RecordingSectionAttrs));
    }

    return m(
        '.record-page',
        globals.state.recordingInProgress ? m('.hider') : [],
        m('.record-container',
          RecordHeader(),
          m('.record-container-content', recordMenu(routePage), pages)));
  },
});
