// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/* eslint-disable rulesdir/no_underscored_properties */

import * as Common from '../common/common.js';
import * as Host from '../host/host.js';
import * as i18n from '../i18n/i18n.js';
import * as Platform from '../platform/platform.js';
import * as SDK from '../sdk/sdk.js';
import * as ThemeSupport from '../theme_support/theme_support.js';
import * as UI from '../ui/ui.js';

export const UIStrings = {
  /**
  *@description Aria accessible name in Performance Monitor of the Performance monitor tab
  */
  graphsDisplayingARealtimeViewOf: 'Graphs displaying a real-time view of performance metrics',
  /**
  *@description Text in Performance Monitor of the Performance monitor tab
  */
  paused: 'Paused',
  /**
  *@description Text in Performance Monitor of the Performance monitor tab
  */
  cpuUsage: 'CPU usage',
  /**
  *@description Text in Performance Monitor of the Performance monitor tab
  */
  jsHeapSize: 'JS heap size',
  /**
  *@description Text in Performance Monitor of the Performance monitor tab
  */
  domNodes: 'DOM Nodes',
  /**
  *@description Text in Performance Monitor of the Performance monitor tab
  */
  jsEventListeners: 'JS event listeners',
  /**
  *@description Text for documents, a type of resources
  */
  documents: 'Documents',
  /**
  *@description Text in Performance Monitor of the Performance monitor tab
  */
  documentFrames: 'Document Frames',
  /**
  *@description Text in Performance Monitor of the Performance monitor tab
  */
  layoutsSec: 'Layouts / sec',
  /**
  *@description Text in Performance Monitor of the Performance monitor tab
  */
  styleRecalcsSec: 'Style recalcs / sec',
};
const str_ = i18n.i18n.registerUIStrings('performance_monitor/PerformanceMonitor.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

let performanceMonitorImplInstance: PerformanceMonitorImpl;


export class PerformanceMonitorImpl extends UI.Widget.HBox implements
    SDK.SDKModel.SDKModelObserver<SDK.PerformanceMetricsModel.PerformanceMetricsModel> {
  _metricsBuffer: {timestamp: number, metrics: Map<string, number>}[];
  _pixelsPerMs: number;
  _pollIntervalMs: number;
  _scaleHeight: number;
  _graphHeight: number;
  _gridColor: string;
  _controlPane: ControlPane;
  _canvas: HTMLCanvasElement;
  _animationId!: number;
  _width!: number;
  _height!: number;
  _model?: SDK.PerformanceMetricsModel.PerformanceMetricsModel|null;
  _startTimestamp?: number;
  _pollTimer?: number;

  constructor() {
    super(true);
    this.registerRequiredCSS('performance_monitor/performanceMonitor.css', {enableLegacyPatching: true});
    this.contentElement.classList.add('perfmon-pane');
    this._metricsBuffer = [];
    /** @const */
    this._pixelsPerMs = 10 / 1000;
    /** @const */
    this._pollIntervalMs = 500;
    /** @const */
    this._scaleHeight = 16;
    /** @const */
    this._graphHeight = 90;
    this._gridColor = ThemeSupport.ThemeSupport.instance().patchColorText(
        'rgba(0, 0, 0, 0.08)', ThemeSupport.ThemeSupport.ColorUsage.Foreground);
    this._controlPane = new ControlPane(this.contentElement);
    const chartContainer = this.contentElement.createChild('div', 'perfmon-chart-container');
    this._canvas = chartContainer.createChild('canvas') as HTMLCanvasElement;
    this._canvas.tabIndex = -1;
    UI.ARIAUtils.setAccessibleName(this._canvas, i18nString(UIStrings.graphsDisplayingARealtimeViewOf));
    this.contentElement.createChild('div', 'perfmon-chart-suspend-overlay fill').createChild('div').textContent =
        i18nString(UIStrings.paused);
    this._controlPane.addEventListener(Events.MetricChanged, this._recalcChartHeight, this);
    SDK.SDKModel.TargetManager.instance().observeModels(SDK.PerformanceMetricsModel.PerformanceMetricsModel, this);
  }

  static instance(opts = {forceNew: null}): PerformanceMonitorImpl {
    const {forceNew} = opts;
    if (!performanceMonitorImplInstance || forceNew) {
      performanceMonitorImplInstance = new PerformanceMonitorImpl();
    }

    return performanceMonitorImplInstance;
  }

  wasShown(): void {
    if (!this._model) {
      return;
    }
    SDK.SDKModel.TargetManager.instance().addEventListener(
        SDK.SDKModel.Events.SuspendStateChanged, this._suspendStateChanged, this);
    this._model.enable();
    this._suspendStateChanged();
  }

  willHide(): void {
    if (!this._model) {
      return;
    }
    SDK.SDKModel.TargetManager.instance().removeEventListener(
        SDK.SDKModel.Events.SuspendStateChanged, this._suspendStateChanged, this);
    this._stopPolling();
    this._model.disable();
  }

  modelAdded(model: SDK.PerformanceMetricsModel.PerformanceMetricsModel): void {
    if (this._model) {
      return;
    }
    this._model = model;
    if (this.isShowing()) {
      this.wasShown();
    }
  }

  modelRemoved(model: SDK.PerformanceMetricsModel.PerformanceMetricsModel): void {
    if (this._model !== model) {
      return;
    }
    if (this.isShowing()) {
      this.willHide();
    }
    this._model = null;
  }

  _suspendStateChanged(): void {
    const suspended = SDK.SDKModel.TargetManager.instance().allTargetsSuspended();
    if (suspended) {
      this._stopPolling();
    } else {
      this._startPolling();
    }
    this.contentElement.classList.toggle('suspended', suspended);
  }

  _startPolling(): void {
    this._startTimestamp = 0;
    this._pollTimer = window.setInterval(() => this._poll(), this._pollIntervalMs);
    this.onResize();
    const animate = (): void => {
      this._draw();
      this._animationId = this.contentElement.window().requestAnimationFrame(() => {
        animate();
      });
    };
    animate();
  }

  _stopPolling(): void {
    window.clearInterval(this._pollTimer);
    this.contentElement.window().cancelAnimationFrame(this._animationId);
    this._metricsBuffer = [];
  }

  async _poll(): Promise<void> {
    if (!this._model) {
      return;
    }
    const data = await this._model.requestMetrics();
    const timestamp = data.timestamp;
    const metrics = data.metrics;
    this._metricsBuffer.push({timestamp, metrics: metrics});
    const millisPerWidth = this._width / this._pixelsPerMs;
    // Multiply by 2 as the pollInterval has some jitter and to have some extra samples if window is resized.
    const maxCount = Math.ceil(millisPerWidth / this._pollIntervalMs * 2);
    if (this._metricsBuffer.length > maxCount * 2)  // Multiply by 2 to have a hysteresis.
    {
      this._metricsBuffer.splice(0, this._metricsBuffer.length - maxCount);
    }
    this._controlPane.updateMetrics(metrics);
  }

  _draw(): void {
    const ctx = this._canvas.getContext('2d') as CanvasRenderingContext2D;
    ctx.save();
    ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
    ctx.clearRect(0, 0, this._width, this._height);
    ctx.save();
    ctx.translate(0, this._scaleHeight);  // Reserve space for the scale bar.
    for (const chartInfo of this._controlPane.charts()) {
      if (!this._controlPane.isActive(chartInfo.metrics[0].name)) {
        continue;
      }
      this._drawChart(ctx, chartInfo, this._graphHeight);
      ctx.translate(0, this._graphHeight);
    }
    ctx.restore();
    this._drawHorizontalGrid(ctx);
    ctx.restore();
  }

  _drawHorizontalGrid(ctx: CanvasRenderingContext2D): void {
    const labelDistanceSeconds = 10;
    const lightGray = ThemeSupport.ThemeSupport.instance().patchColorText(
        'rgba(0, 0, 0, 0.02)', ThemeSupport.ThemeSupport.ColorUsage.Foreground);
    ctx.font = '10px ' + Host.Platform.fontFamily();
    ctx.fillStyle = ThemeSupport.ThemeSupport.instance().patchColorText(
        'rgba(0, 0, 0, 0.55)', ThemeSupport.ThemeSupport.ColorUsage.Foreground);
    const currentTime = Date.now() / 1000;
    for (let sec = Math.ceil(currentTime);; --sec) {
      const x = this._width - ((currentTime - sec) * 1000 - this._pollIntervalMs) * this._pixelsPerMs;
      if (x < -50) {
        break;
      }
      ctx.beginPath();
      ctx.moveTo(x, 0);
      ctx.lineTo(x, this._height);
      if (sec >= 0 && sec % labelDistanceSeconds === 0) {
        ctx.fillText(new Date(sec * 1000).toLocaleTimeString(), x + 4, 12);
      }
      ctx.strokeStyle = sec % labelDistanceSeconds ? lightGray : this._gridColor;
      ctx.stroke();
    }
  }

  _drawChart(ctx: CanvasRenderingContext2D, chartInfo: ChartInfo, height: number): void {
    ctx.save();
    ctx.rect(0, 0, this._width, height);
    ctx.clip();
    const bottomPadding = 8;
    const extraSpace = 1.05;
    const max = this._calcMax(chartInfo) * extraSpace;
    const stackedChartBaseLandscape = chartInfo.stacked ? new Map() : null;
    const paths = [];
    for (let i = chartInfo.metrics.length - 1; i >= 0; --i) {
      const metricInfo = chartInfo.metrics[i];
      paths.push({
        path: this._buildMetricPath(
            chartInfo, metricInfo, height - bottomPadding, max, i ? stackedChartBaseLandscape : null),
        color: metricInfo.color,
      });
    }
    const backgroundColor = Common.Color.Color.parse(
        ThemeSupport.ThemeSupport.instance().patchColorText('white', ThemeSupport.ThemeSupport.ColorUsage.Background));
    if (backgroundColor) {
      for (const path of paths.reverse()) {
        const color = path.color;
        ctx.save();
        const parsedColor = Common.Color.Color.parse(color);
        if (!parsedColor) {
          continue;
        }
        ctx.fillStyle = backgroundColor.blendWith(parsedColor.setAlpha(0.2)).asString(null) || '';
        ctx.fill(path.path);
        ctx.strokeStyle = color;
        ctx.lineWidth = 0.5;
        ctx.stroke(path.path);
        ctx.restore();
      }
    }
    ctx.fillStyle = ThemeSupport.ThemeSupport.instance().patchColorText(
        'rgba(0, 0, 0, 0.55)', ThemeSupport.ThemeSupport.ColorUsage.Foreground);
    ctx.font = `10px  ${Host.Platform.fontFamily()}`;
    ctx.fillText(chartInfo.title, 8, 10);
    this._drawVerticalGrid(ctx, height - bottomPadding, max, chartInfo);
    ctx.restore();
  }

  _calcMax(chartInfo: ChartInfo): number {
    if (chartInfo.max) {
      return chartInfo.max;
    }
    const width = this._width;
    const startTime = performance.now() - this._pollIntervalMs - width / this._pixelsPerMs;
    let max: number = -Infinity;
    for (const metricInfo of chartInfo.metrics) {
      for (let i = this._metricsBuffer.length - 1; i >= 0; --i) {
        const metrics = this._metricsBuffer[i];
        const value = metrics.metrics.get(metricInfo.name);
        if (value !== undefined) {
          max = Math.max(max, value);
        }
        if (metrics.timestamp < startTime) {
          break;
        }
      }
    }
    if (!this._metricsBuffer.length) {
      return 10;
    }

    const base10 = Math.pow(10, Math.floor(Math.log10(max)));
    max = Math.ceil(max / base10 / 2) * base10 * 2;

    const alpha = 0.2;
    chartInfo.currentMax = max * alpha + (chartInfo.currentMax || max) * (1 - alpha);
    return chartInfo.currentMax;
  }

  _drawVerticalGrid(ctx: CanvasRenderingContext2D, height: number, max: number, info: ChartInfo): void {
    let base = Math.pow(10, Math.floor(Math.log10(max)));
    const firstDigit = Math.floor(max / base);
    if (firstDigit !== 1 && firstDigit % 2 === 1) {
      base *= 2;
    }
    let scaleValue = Math.floor(max / base) * base;

    const span = max;
    const topPadding = 18;
    const visibleHeight = height - topPadding;
    ctx.fillStyle = ThemeSupport.ThemeSupport.instance().patchColorText(
        'rgba(0, 0, 0, 0.55)', ThemeSupport.ThemeSupport.ColorUsage.Foreground);
    ctx.strokeStyle = this._gridColor;
    ctx.beginPath();
    for (let i = 0; i < 2; ++i) {
      const y = calcY(scaleValue);
      const labelText = MetricIndicator._formatNumber(scaleValue, info);
      ctx.moveTo(0, y);
      ctx.lineTo(4, y);
      ctx.moveTo(ctx.measureText(labelText).width + 12, y);
      ctx.lineTo(this._width, y);
      ctx.fillText(labelText, 8, calcY(scaleValue) + 3);
      scaleValue /= 2;
    }
    ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(0, height + 0.5);
    ctx.lineTo(this._width, height + 0.5);
    ctx.strokeStyle = ThemeSupport.ThemeSupport.instance().patchColorText(
        'rgba(0, 0, 0, 0.2)', ThemeSupport.ThemeSupport.ColorUsage.Foreground);
    ctx.stroke();
    function calcY(value: number): number {
      return Math.round(height - visibleHeight * value / span) + 0.5;
    }
  }

  _buildMetricPath(
      chartInfo: ChartInfo, metricInfo: MetricInfo, height: number, scaleMax: number,
      stackedChartBaseLandscape: Map<number, number>|null): Path2D {
    const path = new Path2D();
    const topPadding = 18;
    const visibleHeight = height - topPadding;
    if (visibleHeight < 1) {
      return path;
    }
    const span = scaleMax;
    const metricName = metricInfo.name;
    const pixelsPerMs = this._pixelsPerMs;
    const startTime = performance.now() - this._pollIntervalMs - this._width / pixelsPerMs;
    const smooth = chartInfo.smooth;

    let x = 0;
    let lastY = 0;
    let lastX = 0;
    if (this._metricsBuffer.length) {
      x = (this._metricsBuffer[0].timestamp - startTime) * pixelsPerMs;
      path.moveTo(x, calcY(0));
      path.lineTo(this._width + 5, calcY(0));
      lastY = calcY(
          (this._metricsBuffer[this._metricsBuffer.length - 1] as {
            metrics: Map<string, number>,
          }).metrics.get(metricName) ||
          0);
      lastX = this._width + 5;
      path.lineTo(lastX, lastY);
    }
    for (let i = this._metricsBuffer.length - 1; i >= 0; --i) {
      const metrics = this._metricsBuffer[i];
      const timestamp = metrics.timestamp;
      let value: number = metrics.metrics.get(metricName) || 0;
      if (stackedChartBaseLandscape) {
        value += stackedChartBaseLandscape.get(timestamp) || 0;
        value = Platform.NumberUtilities.clamp(value, 0, 1);
        stackedChartBaseLandscape.set(timestamp, value);
      }
      const y = calcY(value);
      x = (timestamp - startTime) * pixelsPerMs;
      if (smooth) {
        const midX = (lastX + x) / 2;
        path.bezierCurveTo(midX, lastY, midX, y, x, y);
      } else {
        path.lineTo(x, lastY);
        path.lineTo(x, y);
      }
      lastX = x;
      lastY = y;
      if (timestamp < startTime) {
        break;
      }
    }
    return path;

    function calcY(value: number): number {
      return Math.round(height - visibleHeight * value / span) + 0.5;
    }
  }

  onResize(): void {
    super.onResize();
    this._width = this._canvas.offsetWidth;
    this._canvas.width = Math.round(this._width * window.devicePixelRatio);
    this._recalcChartHeight();
  }

  _recalcChartHeight(): void {
    let height = this._scaleHeight;
    for (const chartInfo of this._controlPane.charts()) {
      if (this._controlPane.isActive(chartInfo.metrics[0].name)) {
        height += this._graphHeight;
      }
    }
    this._height = Math.ceil(height * window.devicePixelRatio);
    this._canvas.height = this._height;
    this._canvas.style.height = `${this._height / window.devicePixelRatio}px`;
  }
}

export const enum Format {
  Percent = 'Percent',
  Bytes = 'Bytes',
}

export class ControlPane extends Common.ObjectWrapper.ObjectWrapper {
  element: Element;
  _enabledChartsSetting: Common.Settings.Setting<string[]>;
  _enabledCharts: Set<string>;
  _chartsInfo: ChartInfo[];
  _indicators: Map<string, MetricIndicator>;

  constructor(parent: Element) {
    super();
    this.element = parent.createChild('div', 'perfmon-control-pane');

    this._enabledChartsSetting = Common.Settings.Settings.instance().createSetting(
        'perfmonActiveIndicators2', ['TaskDuration', 'JSHeapTotalSize', 'Nodes']);
    this._enabledCharts = new Set(this._enabledChartsSetting.get());

    const defaults = {
      color: undefined,
      format: undefined,
      currentMax: undefined,
      max: undefined,
      smooth: undefined,
      stacked: undefined,
    };

    this._chartsInfo = [
      {
        ...defaults,
        title: i18nString(UIStrings.cpuUsage),
        metrics: [
          {name: 'TaskDuration', color: '#999'},
          {name: 'ScriptDuration', color: 'orange'},
          {name: 'LayoutDuration', color: 'blueviolet'},
          {name: 'RecalcStyleDuration', color: 'violet'},
        ],
        format: Format.Percent,
        smooth: true,
        stacked: true,
        color: 'red',
        max: 1,
        currentMax: undefined,
      },
      {
        ...defaults,
        title: i18nString(UIStrings.jsHeapSize),
        metrics: [{name: 'JSHeapTotalSize', color: '#99f'}, {name: 'JSHeapUsedSize', color: 'blue'}],
        format: Format.Bytes,
        color: 'blue',
      },
      {...defaults, title: i18nString(UIStrings.domNodes), metrics: [{name: 'Nodes', color: 'green'}]},
      {
        ...defaults,
        title: i18nString(UIStrings.jsEventListeners),
        metrics: [{name: 'JSEventListeners', color: 'yellowgreen'}],
      },
      {...defaults, title: i18nString(UIStrings.documents), metrics: [{name: 'Documents', color: 'darkblue'}]},
      {...defaults, title: i18nString(UIStrings.documentFrames), metrics: [{name: 'Frames', color: 'darkcyan'}]},
      {...defaults, title: i18nString(UIStrings.layoutsSec), metrics: [{name: 'LayoutCount', color: 'hotpink'}]},
      {
        ...defaults,
        title: i18nString(UIStrings.styleRecalcsSec),
        metrics: [{name: 'RecalcStyleCount', color: 'deeppink'}],
      },
    ];
    for (const info of this._chartsInfo) {
      if (info.color) {
        info.color = ThemeSupport.ThemeSupport.instance().patchColorText(
            info.color, ThemeSupport.ThemeSupport.ColorUsage.Foreground);
      }
      for (const metric of info.metrics) {
        metric.color = ThemeSupport.ThemeSupport.instance().patchColorText(
            metric.color, ThemeSupport.ThemeSupport.ColorUsage.Foreground);
      }
    }

    this._indicators = new Map();
    for (const chartInfo of this._chartsInfo) {
      const chartName = chartInfo.metrics[0].name;
      const active = this._enabledCharts.has(chartName);
      const indicator = new MetricIndicator(this.element, chartInfo, active, this._onToggle.bind(this, chartName));
      this._indicators.set(chartName, indicator);
    }
  }

  _onToggle(chartName: string, active: boolean): void {
    if (active) {
      this._enabledCharts.add(chartName);
    } else {
      this._enabledCharts.delete(chartName);
    }
    this._enabledChartsSetting.set(Array.from(this._enabledCharts));
    this.dispatchEventToListeners(Events.MetricChanged);
  }

  charts(): ChartInfo[] {
    return this._chartsInfo;
  }

  isActive(metricName: string): boolean {
    return this._enabledCharts.has(metricName);
  }

  updateMetrics(metrics: Map<string, number>): void {
    for (const name of this._indicators.keys()) {
      const metric = metrics.get(name);
      if (metric !== undefined) {
        const indicator = this._indicators.get(name);
        if (indicator) {
          indicator.setValue(metric);
        }
      }
    }
  }
}

export const enum Events {
  MetricChanged = 'MetricChanged',
}

let numberFormatter: Intl.NumberFormat;
let percentFormatter: Intl.NumberFormat;

export class MetricIndicator {
  _info: ChartInfo;
  _active: boolean;
  _onToggle: (arg0: boolean) => void;
  element: HTMLElement;
  _swatchElement: UI.Icon.Icon;
  _valueElement: HTMLElement;

  constructor(parent: Element, info: ChartInfo, active: boolean, onToggle: (arg0: boolean) => void) {
    const color = info.color || info.metrics[0].color;
    this._info = info;
    this._active = active;
    this._onToggle = onToggle;
    this.element = parent.createChild('div', 'perfmon-indicator') as HTMLElement;
    this._swatchElement = UI.Icon.Icon.create('smallicon-checkmark-square', 'perfmon-indicator-swatch');
    this._swatchElement.style.backgroundColor = color;
    this.element.appendChild(this._swatchElement);
    this.element.createChild('div', 'perfmon-indicator-title').textContent = info.title;
    this._valueElement = this.element.createChild('div', 'perfmon-indicator-value') as HTMLElement;
    this._valueElement.style.color = color;
    this.element.addEventListener('click', () => this._toggleIndicator());
    this.element.addEventListener('keypress', event => this._handleKeypress(event));
    this.element.classList.toggle('active', active);
    UI.ARIAUtils.markAsCheckbox(this.element);
    UI.ARIAUtils.setChecked(this.element, this._active);
    this.element.tabIndex = 0;
  }

  static _formatNumber(value: number, info: ChartInfo): string {
    if (!numberFormatter) {
      numberFormatter = new Intl.NumberFormat('en-US', {maximumFractionDigits: 1});
      percentFormatter = new Intl.NumberFormat('en-US', {maximumFractionDigits: 1, style: 'percent'});
    }
    switch (info.format) {
      case Format.Percent:
        return percentFormatter.format(value);
      case Format.Bytes:
        return Platform.NumberUtilities.bytesToString(value);
      default:
        return numberFormatter.format(value);
    }
  }

  setValue(value: number): void {
    this._valueElement.textContent = MetricIndicator._formatNumber(value, this._info);
  }

  _toggleIndicator(): void {
    this._active = !this._active;
    this.element.classList.toggle('active', this._active);
    UI.ARIAUtils.setChecked(this.element, this._active);
    this._onToggle(this._active);
  }

  _handleKeypress(event: Event): void {
    const keyboardEvent = event as KeyboardEvent;
    if (keyboardEvent.key === ' ' || keyboardEvent.key === 'Enter') {
      this._toggleIndicator();
    }
  }
}

export const format = new Intl.NumberFormat('en-US', {maximumFractionDigits: 1});
export interface MetricInfo {
  name: string;
  color: string;
}
export interface ChartInfo {
  title: string;
  metrics: {name: string, color: string}[];
  max?: number;
  currentMax?: number;
  format?: Format;
  smooth?: boolean;
  color?: string;
  stacked?: boolean;
}
