// 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 m from 'mithril';
import {getCurrentChannel} from '../core/channels';
import {TRACE_SUFFIX} from '../public/trace';
import {
  disableMetatracingAndGetTrace,
  enableMetatracing,
  isMetatracingEnabled,
} from '../core/metatracing';
import {Engine, EngineMode} from '../trace_processor/engine';
import {featureFlags} from '../core/feature_flags';
import {raf} from '../core/raf_scheduler';
import {SCM_REVISION, VERSION} from '../gen/perfetto_version';
import {showModal} from '../widgets/modal';
import {Animation} from './animation';
import {downloadData, downloadUrl} from '../base/download_utils';
import {globals} from './globals';
import {toggleHelp} from './help_modal';
import {shareTrace} from './trace_share_utils';
import {
  convertTraceToJsonAndDownload,
  convertTraceToSystraceAndDownload,
} from './trace_converter';
import {openInOldUIWithSizeCheck} from './legacy_trace_viewer';
import {SIDEBAR_SECTIONS, SidebarSections} from '../public/sidebar';
import {AppImpl} from '../core/app_impl';
import {Trace} from '../public/trace';
import {OptionalTraceImplAttrs, TraceImpl} from '../core/trace_impl';
import {Command} from '../public/command';
import {SidebarMenuItemInternal} from '../core/sidebar_manager';
import {exists, getOrCreate} from '../base/utils';
import {copyToClipboard} from '../base/clipboard';
import {classNames} from '../base/classnames';
import {formatHotkey} from '../base/hotkeys';
import {assetSrc} from '../base/assets';

const GITILES_URL =
  'https://android.googlesource.com/platform/external/perfetto';

function getBugReportUrl(): string {
  if (globals.isInternalUser) {
    return 'https://goto.google.com/perfetto-ui-bug';
  } else {
    return 'https://github.com/google/perfetto/issues/new';
  }
}

const HIRING_BANNER_FLAG = featureFlags.register({
  id: 'showHiringBanner',
  name: 'Show hiring banner',
  description: 'Show the "We\'re hiring" banner link in the side bar.',
  defaultValue: false,
});

function shouldShowHiringBanner(): boolean {
  return globals.isInternalUser && HIRING_BANNER_FLAG.get();
}

async function openCurrentTraceWithOldUI(trace: Trace): Promise<void> {
  AppImpl.instance.analytics.logEvent(
    'Trace Actions',
    'Open current trace in legacy UI',
  );
  const file = await trace.getTraceFile();
  await openInOldUIWithSizeCheck(file);
}

async function convertTraceToSystrace(trace: Trace): Promise<void> {
  AppImpl.instance.analytics.logEvent('Trace Actions', 'Convert to .systrace');
  const file = await trace.getTraceFile();
  await convertTraceToSystraceAndDownload(file);
}

async function convertTraceToJson(trace: Trace): Promise<void> {
  AppImpl.instance.analytics.logEvent('Trace Actions', 'Convert to .json');
  const file = await trace.getTraceFile();
  await convertTraceToJsonAndDownload(file);
}

function downloadTrace(trace: TraceImpl) {
  if (!trace.traceInfo.downloadable) return;
  AppImpl.instance.analytics.logEvent('Trace Actions', 'Download trace');

  let url = '';
  let fileName = `trace${TRACE_SUFFIX}`;
  const src = trace.traceInfo.source;
  if (src.type === 'URL') {
    url = src.url;
    fileName = url.split('/').slice(-1)[0];
  } else if (src.type === 'ARRAY_BUFFER') {
    const blob = new Blob([src.buffer], {type: 'application/octet-stream'});
    const inputFileName = window.prompt(
      'Please enter a name for your file or leave blank',
    );
    if (inputFileName) {
      fileName = `${inputFileName}.perfetto_trace.gz`;
    } else if (src.fileName) {
      fileName = src.fileName;
    }
    url = URL.createObjectURL(blob);
  } else if (src.type === 'FILE') {
    const file = src.file;
    url = URL.createObjectURL(file);
    fileName = file.name;
  } else {
    throw new Error(`Download from ${JSON.stringify(src)} is not supported`);
  }
  downloadUrl(fileName, url);
}

function highPrecisionTimersAvailable(): boolean {
  // High precision timers are available either when the page is cross-origin
  // isolated or when the trace processor is a standalone binary.
  return (
    window.crossOriginIsolated ||
    AppImpl.instance.trace?.engine.mode === 'HTTP_RPC'
  );
}

function recordMetatrace(engine: Engine) {
  AppImpl.instance.analytics.logEvent('Trace Actions', 'Record metatrace');

  if (!highPrecisionTimersAvailable()) {
    const PROMPT = `High-precision timers are not available to WASM trace processor yet.

Modern browsers restrict high-precision timers to cross-origin-isolated pages.
As Perfetto UI needs to open traces via postMessage, it can't be cross-origin
isolated until browsers ship support for
'Cross-origin-opener-policy: restrict-properties'.

Do you still want to record a metatrace?
Note that events under timer precision (1ms) will dropped.
Alternatively, connect to a trace_processor_shell --httpd instance.
`;
    showModal({
      title: `Trace processor doesn't have high-precision timers`,
      content: m('.modal-pre', PROMPT),
      buttons: [
        {
          text: 'YES, record metatrace',
          primary: true,
          action: () => {
            enableMetatracing();
            engine.enableMetatrace();
          },
        },
        {
          text: 'NO, cancel',
        },
      ],
    });
  } else {
    engine.enableMetatrace();
  }
}

async function toggleMetatrace(e: Engine) {
  return isMetatracingEnabled() ? finaliseMetatrace(e) : recordMetatrace(e);
}

async function finaliseMetatrace(engine: Engine) {
  AppImpl.instance.analytics.logEvent('Trace Actions', 'Finalise metatrace');

  const jsEvents = disableMetatracingAndGetTrace();

  const result = await engine.stopAndGetMetatrace();
  if (result.error.length !== 0) {
    throw new Error(`Failed to read metatrace: ${result.error}`);
  }

  downloadData('metatrace', result.metatrace, jsEvents);
}

class EngineRPCWidget implements m.ClassComponent<OptionalTraceImplAttrs> {
  view({attrs}: m.CVnode<OptionalTraceImplAttrs>) {
    let cssClass = '';
    let title = 'Number of pending SQL queries';
    let label: string;
    let failed = false;
    let mode: EngineMode | undefined;

    const engine = attrs.trace?.engine;
    if (engine !== undefined) {
      mode = engine.mode;
      if (engine.failed !== undefined) {
        cssClass += '.red';
        title = 'Query engine crashed\n' + engine.failed;
        failed = true;
      }
    }

    // If we don't have an engine yet, guess what will be the mode that will
    // be used next time we'll create one. Even if we guess it wrong (somehow
    // trace_controller.ts takes a different decision later, e.g. because the
    // RPC server is shut down after we load the UI and cached httpRpcState)
    // this will eventually become  consistent once the engine is created.
    if (mode === undefined) {
      if (
        AppImpl.instance.httpRpc.httpRpcAvailable &&
        AppImpl.instance.httpRpc.newEngineMode === 'USE_HTTP_RPC_IF_AVAILABLE'
      ) {
        mode = 'HTTP_RPC';
      } else {
        mode = 'WASM';
      }
    }

    if (mode === 'HTTP_RPC') {
      cssClass += '.green';
      label = 'RPC';
      title += '\n(Query engine: native accelerator over HTTP+RPC)';
    } else {
      label = 'WSM';
      title += '\n(Query engine: built-in WASM)';
    }

    const numReqs = attrs.trace?.engine.numRequestsPending ?? 0;
    return m(
      `.dbg-info-square${cssClass}`,
      {title},
      m('div', label),
      m('div', `${failed ? 'FAIL' : numReqs}`),
    );
  }
}

const ServiceWorkerWidget: m.Component = {
  view() {
    let cssClass = '';
    let title = 'Service Worker: ';
    let label = 'N/A';
    const ctl = AppImpl.instance.serviceWorkerController;
    if (!('serviceWorker' in navigator)) {
      label = 'N/A';
      title += 'not supported by the browser (requires HTTPS)';
    } else if (ctl.bypassed) {
      label = 'OFF';
      cssClass = '.red';
      title += 'Bypassed, using live network. Double-click to re-enable';
    } else if (ctl.installing) {
      label = 'UPD';
      cssClass = '.amber';
      title += 'Installing / updating ...';
    } else if (!navigator.serviceWorker.controller) {
      label = 'N/A';
      title += 'Not available, using network';
    } else {
      label = 'ON';
      cssClass = '.green';
      title += 'Serving from cache. Ready for offline use';
    }

    const toggle = async () => {
      if (ctl.bypassed) {
        ctl.setBypass(false);
        return;
      }
      showModal({
        title: 'Disable service worker?',
        content: m(
          'div',
          m(
            'p',
            `If you continue the service worker will be disabled until
                      manually re-enabled.`,
          ),
          m(
            'p',
            `All future requests will be served from the network and the
                    UI won't be available offline.`,
          ),
          m(
            'p',
            `You should do this only if you are debugging the UI
                    or if you are experiencing caching-related problems.`,
          ),
          m(
            'p',
            `Disabling will cause a refresh of the UI, the current state
                    will be lost.`,
          ),
        ),
        buttons: [
          {
            text: 'Disable and reload',
            primary: true,
            action: () => ctl.setBypass(true).then(() => location.reload()),
          },
          {text: 'Cancel'},
        ],
      });
    };

    return m(
      `.dbg-info-square${cssClass}`,
      {title, ondblclick: toggle},
      m('div', 'SW'),
      m('div', label),
    );
  },
};

class SidebarFooter implements m.ClassComponent<OptionalTraceImplAttrs> {
  view({attrs}: m.CVnode<OptionalTraceImplAttrs>) {
    return m(
      '.sidebar-footer',
      m(EngineRPCWidget, attrs),
      m(ServiceWorkerWidget),
      m(
        '.version',
        m(
          'a',
          {
            href: `${GITILES_URL}/+/${SCM_REVISION}/ui`,
            title: `Channel: ${getCurrentChannel()}`,
            target: '_blank',
          },
          VERSION,
        ),
      ),
    );
  }
}

class HiringBanner implements m.ClassComponent {
  view() {
    return m(
      '.hiring-banner',
      m(
        'a',
        {
          href: 'http://go/perfetto-open-roles',
          target: '_blank',
        },
        "We're hiring!",
      ),
    );
  }
}

export class Sidebar implements m.ClassComponent<OptionalTraceImplAttrs> {
  private _redrawWhileAnimating = new Animation(() =>
    raf.scheduleFullRedraw('force'),
  );
  private _asyncJobPending = new Set<string>();
  private _sectionExpanded = new Map<string, boolean>();

  constructor() {
    registerMenuItems();
  }

  view({attrs}: m.CVnode<OptionalTraceImplAttrs>) {
    const sidebar = AppImpl.instance.sidebar;
    if (!sidebar.enabled) return null;
    return m(
      'nav.sidebar',
      {
        class: sidebar.visible ? 'show-sidebar' : 'hide-sidebar',
        // 150 here matches --sidebar-timing in the css.
        // TODO(hjd): Should link to the CSS variable.
        ontransitionstart: (e: TransitionEvent) => {
          if (e.target !== e.currentTarget) return;
          this._redrawWhileAnimating.start(150);
        },
        ontransitionend: (e: TransitionEvent) => {
          if (e.target !== e.currentTarget) return;
          this._redrawWhileAnimating.stop();
        },
      },
      shouldShowHiringBanner() ? m(HiringBanner) : null,
      m(
        `header.${getCurrentChannel()}`,
        m(`img[src=${assetSrc('assets/brand.png')}].brand`),
        m(
          'button.sidebar-button',
          {
            onclick: () => sidebar.toggleVisibility(),
          },
          m(
            'i.material-icons',
            {
              title: sidebar.visible ? 'Hide menu' : 'Show menu',
            },
            'menu',
          ),
        ),
      ),
      m(
        '.sidebar-scroll',
        m(
          '.sidebar-scroll-container',
          ...(Object.keys(SIDEBAR_SECTIONS) as SidebarSections[]).map((s) =>
            this.renderSection(s),
          ),
          m(SidebarFooter, attrs),
        ),
      ),
    );
  }

  private renderSection(sectionId: SidebarSections) {
    const section = SIDEBAR_SECTIONS[sectionId];
    const menuItems = AppImpl.instance.sidebar.menuItems
      .valuesAsArray()
      .filter((item) => item.section === sectionId)
      .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
      .map((item) => this.renderItem(item));

    // Don't render empty sections.
    if (menuItems.length === 0) return undefined;

    const expanded = getOrCreate(this._sectionExpanded, sectionId, () => true);
    return m(
      `section${expanded ? '.expanded' : ''}`,
      m(
        '.section-header',
        {
          onclick: () => {
            this._sectionExpanded.set(sectionId, !expanded);
            raf.scheduleFullRedraw();
          },
        },
        m('h1', {title: section.title}, section.title),
        m('h2', section.summary),
      ),
      m('.section-content', m('ul', menuItems)),
    );
  }

  private renderItem(item: SidebarMenuItemInternal): m.Child {
    let href = '#';
    let disabled = false;
    let target = null;
    let command: Command | undefined = undefined;
    let tooltip = valueOrCallback(item.tooltip);
    let onclick: (() => unknown | Promise<unknown>) | undefined = undefined;
    const commandId = 'commandId' in item ? item.commandId : undefined;
    const action = 'action' in item ? item.action : undefined;
    let text = valueOrCallback(item.text);
    const disabReason: boolean | string | undefined = valueOrCallback(
      item.disabled,
    );

    if (disabReason === true || typeof disabReason === 'string') {
      disabled = true;
      onclick = () => typeof disabReason === 'string' && alert(disabReason);
    } else if (action !== undefined) {
      onclick = action;
    } else if (commandId !== undefined) {
      const cmdMgr = AppImpl.instance.commands;
      command = cmdMgr.hasCommand(commandId ?? '')
        ? cmdMgr.getCommand(commandId)
        : undefined;
      if (command === undefined) {
        disabled = true;
      } else {
        text = text !== undefined ? text : command.name;
        if (command.defaultHotkey !== undefined) {
          tooltip =
            `${tooltip ?? command.name}` +
            ` [${formatHotkey(command.defaultHotkey)}]`;
        }
        onclick = () => cmdMgr.runCommand(commandId);
      }
    }

    // This is not an else if because in some rare cases the user might want
    // to have both an href and onclick, with different behaviors. The only case
    // today is the trace name / URL, where we want the URL in the href to
    // support right-click -> copy URL, but the onclick does copyToClipboard().
    if ('href' in item && item.href !== undefined) {
      href = item.href;
      target = href.startsWith('#') ? null : '_blank';
    }
    return m(
      'li',
      m(
        'a',
        {
          className: classNames(
            valueOrCallback(item.cssClass),
            this._asyncJobPending.has(item.id) && 'pending',
          ),
          onclick: onclick && this.wrapClickHandler(item.id, onclick),
          href,
          target,
          disabled,
          title: tooltip,
        },
        exists(item.icon) && m('i.material-icons', valueOrCallback(item.icon)),
        text,
      ),
    );
  }

  // Creates the onClick handlers for the items which provided a function in the
  // `action` member. The function can be either sync or async.
  // What we want to achieve here is the following:
  // - If the action is async (returns a Promise), we want to render a spinner,
  //   next to the menu item, until the promise is resolved.
  // - [Minor] we want to call e.preventDefault() to override the behaviour of
  //   the <a href='#'> which gets rendered for accessibility reasons.
  private wrapClickHandler(itemId: string, itemAction: Function) {
    return (e: Event) => {
      e.preventDefault(); // Make the <a href="#"> a no-op.
      const res = itemAction();
      if (!(res instanceof Promise)) return;
      if (this._asyncJobPending.has(itemId)) {
        return; // Don't queue up another action if not yet finished.
      }
      this._asyncJobPending.add(itemId);
      raf.scheduleFullRedraw();
      res.finally(() => {
        this._asyncJobPending.delete(itemId);
        raf.scheduleFullRedraw('force');
      });
    };
  }
}

// TODO(primiano): The registrations below should be moved to dedicated
// plugins (most of this really belongs to core_plugins/commads/index.ts).
// For now i'm keeping everything here as splitting these require moving some
// functions like share_trace() out of core, splitting out permalink, etc.

let globalItemsRegistered = false;
const traceItemsRegistered = new WeakSet<TraceImpl>();

function registerMenuItems() {
  if (!globalItemsRegistered) {
    globalItemsRegistered = true;
    registerGlobalSidebarEntries();
  }
  const trace = AppImpl.instance.trace;
  if (trace !== undefined && !traceItemsRegistered.has(trace)) {
    traceItemsRegistered.add(trace);
    registerTraceMenuItems(trace);
  }
}

function registerGlobalSidebarEntries() {
  const app = AppImpl.instance;
  // TODO(primiano): The Open file / Open with legacy entries are registered by
  // the 'perfetto.CoreCommands' plugins. Make things consistent.
  app.sidebar.addMenuItem({
    section: 'support',
    text: 'Keyboard shortcuts',
    action: toggleHelp,
    icon: 'help',
  });
  app.sidebar.addMenuItem({
    section: 'support',
    text: 'Documentation',
    href: 'https://perfetto.dev/docs',
    icon: 'find_in_page',
  });
  app.sidebar.addMenuItem({
    section: 'support',
    sortOrder: 4,
    text: 'Report a bug',
    href: getBugReportUrl(),
    icon: 'bug_report',
  });
}

function registerTraceMenuItems(trace: TraceImpl) {
  const downloadDisabled = trace.traceInfo.downloadable
    ? false
    : 'Cannot download external trace';

  const traceTitle = trace?.traceInfo.traceTitle;
  traceTitle &&
    trace.sidebar.addMenuItem({
      section: 'current_trace',
      text: traceTitle,
      href: trace.traceInfo.traceUrl,
      action: () => copyToClipboard(trace.traceInfo.traceUrl),
      tooltip: 'Click to copy the URL',
      cssClass: 'trace-file-name',
    });
  trace.sidebar.addMenuItem({
    section: 'current_trace',
    text: 'Show timeline',
    href: '#!/viewer',
    icon: 'line_style',
  });
  globals.isInternalUser &&
    trace.sidebar.addMenuItem({
      section: 'current_trace',
      text: 'Share',
      action: async () => await shareTrace(trace),
      icon: 'share',
    });
  trace.sidebar.addMenuItem({
    section: 'current_trace',
    text: 'Download',
    action: () => downloadTrace(trace),
    icon: 'file_download',
    disabled: downloadDisabled,
  });
  trace.sidebar.addMenuItem({
    section: 'convert_trace',
    text: 'Switch to legacy UI',
    action: async () => await openCurrentTraceWithOldUI(trace),
    icon: 'filter_none',
    disabled: downloadDisabled,
  });
  trace.sidebar.addMenuItem({
    section: 'convert_trace',
    text: 'Convert to .json',
    action: async () => await convertTraceToJson(trace),
    icon: 'file_download',
    disabled: downloadDisabled,
  });
  trace.traceInfo.hasFtrace &&
    trace.sidebar.addMenuItem({
      section: 'convert_trace',
      text: 'Convert to .systrace',
      action: async () => await convertTraceToSystrace(trace),
      icon: 'file_download',
      disabled: downloadDisabled,
    });
  trace.sidebar.addMenuItem({
    section: 'support',
    sortOrder: 5,
    text: () =>
      isMetatracingEnabled() ? 'Finalize metatrace' : 'Record metatrace',
    action: () => toggleMetatrace(trace.engine),
    icon: () => (isMetatracingEnabled() ? 'download' : 'fiber_smart_record'),
  });
}

// Used to deal with fields like the entry name, which can be either a direct
// string or a callback that returns the string.
function valueOrCallback<T>(value: T | (() => T)): T;
function valueOrCallback<T>(value: T | (() => T) | undefined): T | undefined;
function valueOrCallback<T>(value: T | (() => T) | undefined): T | undefined {
  if (value === undefined) return undefined;
  return value instanceof Function ? value() : value;
}
