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

/* eslint-disable @devtools/no-imperative-dom-api */

import * as Common from '../../core/common/common.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import type * as SDK from '../../core/sdk/sdk.js';
import type * as Protocol from '../../generated/protocol.js';
import * as PerfUI from '../../ui/legacy/components/perf_ui/perf_ui.js';
import * as UI from '../../ui/legacy/legacy.js';

import paintProfilerStyles from './paintProfiler.css.js';

const UIStrings = {
  /**
   * @description Text to indicate the progress of a profile
   */
  profiling: 'Profiling…',
  /**
   * @description Text in Paint Profiler View of the Layers panel
   */
  shapes: 'Shapes',
  /**
   * @description Text in Paint Profiler View of the Layers panel
   */
  bitmap: 'Bitmap',
  /**
   * @description Generic label for any text
   */
  text: 'Text',
  /**
   * @description Text in Paint Profiler View of the Layers panel
   */
  misc: 'Misc',
  /**
   * @description ARIA label for a pie chart that shows the results of the paint profiler
   */
  profilingResults: 'Profiling results',
  /**
   * @description Label for command log tree in the Profiler tab
   */
  commandLog: 'Command Log',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/layer_viewer/PaintProfilerView.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
let categories: Record<string, PaintProfilerCategory>|null = null;

let logItemCategoriesMap: Record<string, PaintProfilerCategory>|null = null;

export class PaintProfilerView extends Common.ObjectWrapper.eventMixin<EventTypes, typeof UI.Widget.HBox>(
    UI.Widget.HBox) {
  private canvasContainer: HTMLElement;
  private readonly progressBanner: HTMLElement;
  private pieChart: PerfUI.PieChart.PieChart;
  private readonly showImageCallback: (arg0?: string|undefined) => void;
  private canvas: HTMLCanvasElement;
  private context: CanvasRenderingContext2D;
  readonly #selectionWindow: PerfUI.OverviewGrid.Window;
  private readonly innerBarWidth: number;
  private minBarHeight: number;
  private readonly barPaddingWidth: number;
  private readonly outerBarWidth: number;
  private pendingScale: number;
  private scale: number;
  private samplesPerBar: number;
  private log: SDK.PaintProfiler.PaintProfilerLogItem[];
  private snapshot?: SDK.PaintProfiler.PaintProfilerSnapshot|null;
  private logCategories?: PaintProfilerCategory[];
  private profiles?: Protocol.LayerTree.PaintProfile[]|null;
  private updateImageTimer?: number;

  constructor(showImageCallback: (arg0?: string|undefined) => void) {
    super({useShadowDom: true});
    this.registerRequiredCSS(paintProfilerStyles);

    this.contentElement.classList.add('paint-profiler-overview');
    this.canvasContainer = this.contentElement.createChild('div', 'paint-profiler-canvas-container');
    this.progressBanner = this.contentElement.createChild('div', 'empty-state hidden');
    this.progressBanner.textContent = i18nString(UIStrings.profiling);
    this.pieChart = new PerfUI.PieChart.PieChart();
    this.populatePieChart(0, []);
    this.pieChart.classList.add('paint-profiler-pie-chart');
    this.contentElement.appendChild(this.pieChart);

    this.showImageCallback = showImageCallback;
    this.canvas = this.canvasContainer.createChild('canvas', 'fill');
    this.context = this.canvas.getContext('2d') as CanvasRenderingContext2D;
    this.#selectionWindow = new PerfUI.OverviewGrid.Window(this.canvasContainer);
    this.#selectionWindow.addEventListener(PerfUI.OverviewGrid.Events.WINDOW_CHANGED, this.onWindowChanged, this);

    this.innerBarWidth = 4 * window.devicePixelRatio;
    this.minBarHeight = window.devicePixelRatio;
    this.barPaddingWidth = 2 * window.devicePixelRatio;
    this.outerBarWidth = this.innerBarWidth + this.barPaddingWidth;
    this.pendingScale = 1;
    this.scale = this.pendingScale;
    this.samplesPerBar = 0;
    this.log = [];

    this.reset();
  }

  static categories(): Record<string, PaintProfilerCategory> {
    if (!categories) {
      categories = {
        shapes: new PaintProfilerCategory('shapes', i18nString(UIStrings.shapes), 'rgb(255, 161, 129)'),
        bitmap: new PaintProfilerCategory('bitmap', i18nString(UIStrings.bitmap), 'rgb(136, 196, 255)'),
        text: new PaintProfilerCategory('text', i18nString(UIStrings.text), 'rgb(180, 255, 137)'),
        misc: new PaintProfilerCategory('misc', i18nString(UIStrings.misc), 'rgb(206, 160, 255)'),
      };
    }
    return categories;
  }

  private static initLogItemCategories(): Record<string, PaintProfilerCategory> {
    if (!logItemCategoriesMap) {
      const categories = PaintProfilerView.categories();

      const logItemCategories: Record<string, PaintProfilerCategory> = {};
      logItemCategories['Clear'] = categories['misc'];
      logItemCategories['DrawPaint'] = categories['misc'];
      logItemCategories['DrawData'] = categories['misc'];
      logItemCategories['SetMatrix'] = categories['misc'];
      logItemCategories['PushCull'] = categories['misc'];
      logItemCategories['PopCull'] = categories['misc'];
      logItemCategories['Translate'] = categories['misc'];
      logItemCategories['Scale'] = categories['misc'];
      logItemCategories['Concat'] = categories['misc'];
      logItemCategories['Restore'] = categories['misc'];
      logItemCategories['SaveLayer'] = categories['misc'];
      logItemCategories['Save'] = categories['misc'];
      logItemCategories['BeginCommentGroup'] = categories['misc'];
      logItemCategories['AddComment'] = categories['misc'];
      logItemCategories['EndCommentGroup'] = categories['misc'];
      logItemCategories['ClipRect'] = categories['misc'];
      logItemCategories['ClipRRect'] = categories['misc'];
      logItemCategories['ClipPath'] = categories['misc'];
      logItemCategories['ClipRegion'] = categories['misc'];
      logItemCategories['DrawPoints'] = categories['shapes'];
      logItemCategories['DrawRect'] = categories['shapes'];
      logItemCategories['DrawOval'] = categories['shapes'];
      logItemCategories['DrawRRect'] = categories['shapes'];
      logItemCategories['DrawPath'] = categories['shapes'];
      logItemCategories['DrawVertices'] = categories['shapes'];
      logItemCategories['DrawDRRect'] = categories['shapes'];
      logItemCategories['DrawBitmap'] = categories['bitmap'];
      logItemCategories['DrawBitmapRectToRect'] = categories['bitmap'];
      logItemCategories['DrawBitmapMatrix'] = categories['bitmap'];
      logItemCategories['DrawBitmapNine'] = categories['bitmap'];
      logItemCategories['DrawSprite'] = categories['bitmap'];
      logItemCategories['DrawPicture'] = categories['bitmap'];
      logItemCategories['DrawText'] = categories['text'];
      logItemCategories['DrawPosText'] = categories['text'];
      logItemCategories['DrawPosTextH'] = categories['text'];
      logItemCategories['DrawTextOnPath'] = categories['text'];

      logItemCategoriesMap = logItemCategories;
    }

    return logItemCategoriesMap;
  }

  private static categoryForLogItem(logItem: SDK.PaintProfiler.PaintProfilerLogItem): PaintProfilerCategory {
    const method = Platform.StringUtilities.toTitleCase(logItem.method);

    const logItemCategories = PaintProfilerView.initLogItemCategories();
    let result: PaintProfilerCategory = logItemCategories[method];
    if (!result) {
      result = PaintProfilerView.categories()['misc'];
      logItemCategories[method] = result;
    }
    return result;
  }

  override onResize(): void {
    this.update();
  }

  async setSnapshotAndLog(
      snapshot: SDK.PaintProfiler.PaintProfilerSnapshot|null, log: SDK.PaintProfiler.PaintProfilerLogItem[],
      clipRect: Protocol.DOM.Rect|null): Promise<void> {
    this.reset();
    this.snapshot = snapshot;
    if (this.snapshot) {
      this.snapshot.addReference();
    }
    this.log = log;
    this.logCategories = this.log.map(PaintProfilerView.categoryForLogItem);

    if (!snapshot) {
      this.update();
      this.populatePieChart(0, []);
      this.#selectionWindow.setResizeEnabled(false);
      return;
    }

    this.#selectionWindow.setResizeEnabled(true);
    this.progressBanner.classList.remove('hidden');
    this.updateImage();

    const profiles = await snapshot.profile(clipRect);

    this.progressBanner.classList.add('hidden');
    this.profiles = profiles;
    this.update();
    this.updatePieChart();
  }

  setScale(scale: number): void {
    const needsUpdate = scale > this.scale;
    const predictiveGrowthFactor = 2;
    this.pendingScale = Math.min(1, scale * predictiveGrowthFactor);
    if (needsUpdate && this.snapshot) {
      this.updateImage();
    }
  }

  update(): void {
    this.canvas.width = this.canvasContainer.clientWidth * window.devicePixelRatio;
    this.canvas.height = this.canvasContainer.clientHeight * window.devicePixelRatio;
    this.samplesPerBar = 0;
    if (!this.profiles?.length || !this.logCategories) {
      return;
    }

    const maxBars = Math.floor((this.canvas.width - 2 * this.barPaddingWidth) / this.outerBarWidth);
    const sampleCount = this.log.length;
    this.samplesPerBar = Math.ceil(sampleCount / maxBars);

    let maxBarTime = 0;
    const barTimes = [];
    const barHeightByCategory = [];
    let heightByCategory: Record<string, number> = {};
    for (let i = 0, lastBarIndex = 0, lastBarTime = 0; i < sampleCount;) {
      let categoryName = (this.logCategories[i]?.name) || 'misc';
      const sampleIndex = this.log[i].commandIndex;
      for (let row = 0; row < this.profiles.length; row++) {
        const sample = this.profiles[row][sampleIndex];
        lastBarTime += sample;
        heightByCategory[categoryName] = (heightByCategory[categoryName] || 0) + sample;
      }
      ++i;
      if (i - lastBarIndex === this.samplesPerBar || i === sampleCount) {
        // Normalize by total number of samples accumulated.
        const factor = this.profiles.length * (i - lastBarIndex);
        lastBarTime /= factor;
        for (categoryName in heightByCategory) {
          heightByCategory[categoryName] /= factor;
        }

        barTimes.push(lastBarTime);
        barHeightByCategory.push(heightByCategory);

        if (lastBarTime > maxBarTime) {
          maxBarTime = lastBarTime;
        }
        lastBarTime = 0;
        heightByCategory = {};
        lastBarIndex = i;
      }
    }

    const paddingHeight = 4 * window.devicePixelRatio;
    const scale = (this.canvas.height - paddingHeight - this.minBarHeight) / maxBarTime;
    for (let i = 0; i < barTimes.length; ++i) {
      for (const categoryName in barHeightByCategory[i]) {
        barHeightByCategory[i][categoryName] *= (barTimes[i] * scale + this.minBarHeight) / barTimes[i];
      }
      this.renderBar(i, barHeightByCategory[i]);
    }
  }

  private renderBar(index: number, heightByCategory: Record<string, number>): void {
    const categories = PaintProfilerView.categories();
    let currentHeight = 0;
    const x = this.barPaddingWidth + index * this.outerBarWidth;
    for (const categoryName in categories) {
      if (!heightByCategory[categoryName]) {
        continue;
      }
      currentHeight += heightByCategory[categoryName];
      const y = this.canvas.height - currentHeight;
      this.context.fillStyle = categories[categoryName].color;
      this.context.fillRect(x, y, this.innerBarWidth, heightByCategory[categoryName]);
    }
  }

  private onWindowChanged(): void {
    this.dispatchEventToListeners(Events.WINDOW_CHANGED);
    this.updatePieChart();
    if (this.updateImageTimer) {
      return;
    }
    this.updateImageTimer = window.setTimeout(this.updateImage.bind(this), 100);
  }

  private updatePieChart(): void {
    const {total, slices} = this.calculatePieChart();
    this.populatePieChart(total, slices);
  }

  private calculatePieChart(): {total: number, slices: Array<{value: number, color: string, title: string}>} {
    const window = this.selectionWindow();
    if (!this.profiles?.length || !window) {
      return {total: 0, slices: []};
    }
    let totalTime = 0;
    const timeByCategory: Record<string, number> = {};
    for (let i = window.left; i < window.right; ++i) {
      const logEntry = this.log[i];
      const category = PaintProfilerView.categoryForLogItem(logEntry);
      timeByCategory[category.color] = timeByCategory[category.color] || 0;
      for (let j = 0; j < this.profiles.length; ++j) {
        const time = this.profiles[j][logEntry.commandIndex];
        totalTime += time;
        timeByCategory[category.color] += time;
      }
    }
    const slices: PerfUI.PieChart.Slice[] = [];
    for (const color in timeByCategory) {
      slices.push({value: timeByCategory[color] / this.profiles.length, color, title: ''});
    }
    return {total: totalTime / this.profiles.length, slices};
  }

  private populatePieChart(total: number, slices: PerfUI.PieChart.Slice[]): void {
    this.pieChart.data = {
      chartName: i18nString(UIStrings.profilingResults),
      size: 55,
      formatter: this.formatPieChartTime.bind(this),
      showLegend: false,
      total,
      slices,
    };
  }

  private formatPieChartTime(value: number): string {
    return i18n.TimeUtilities.millisToString(value * 1000, true);
  }

  selectionWindow(): {left: number, right: number}|null {
    if (!this.log) {
      return null;
    }

    const screenLeft = (this.#selectionWindow.windowLeftRatio || 0) * this.canvas.width;
    const screenRight = (this.#selectionWindow.windowRightRatio || 0) * this.canvas.width;
    const barLeft = Math.floor(screenLeft / this.outerBarWidth);
    const barRight = Math.floor((screenRight + this.innerBarWidth - this.barPaddingWidth / 2) / this.outerBarWidth);
    const stepLeft = Platform.NumberUtilities.clamp(barLeft * this.samplesPerBar, 0, this.log.length - 1);
    const stepRight = Platform.NumberUtilities.clamp(barRight * this.samplesPerBar, 0, this.log.length);

    return {left: stepLeft, right: stepRight};
  }

  private updateImage(): void {
    delete this.updateImageTimer;
    let left;
    let right;
    const window = this.selectionWindow();
    if (this.profiles?.length && window) {
      left = this.log[window.left].commandIndex;
      right = this.log[window.right - 1].commandIndex;
    }
    const scale = this.pendingScale;
    if (!this.snapshot) {
      return;
    }
    void this.snapshot.replay(scale, left, right).then(image => {
      if (!image) {
        return;
      }
      this.scale = scale;
      this.showImageCallback(image);
    });
  }

  private reset(): void {
    if (this.snapshot) {
      this.snapshot.release();
    }
    this.snapshot = null;
    this.profiles = null;
    this.#selectionWindow.reset();
    this.#selectionWindow.setResizeEnabled(false);
  }
}

export const enum Events {
  WINDOW_CHANGED = 'WindowChanged',
}

export interface EventTypes {
  [Events.WINDOW_CHANGED]: void;
}

export class PaintProfilerCommandLogView extends UI.Widget.VBox {
  private readonly treeOutline: UI.TreeOutline.TreeOutlineInShadow;
  private log: SDK.PaintProfiler.PaintProfilerLogItem[];
  private readonly treeItemCache: Map<SDK.PaintProfiler.PaintProfilerLogItem, LogTreeElement>;
  private selectionWindow?: {left: number, right: number}|null;
  constructor() {
    super();
    this.setMinimumSize(100, 25);
    this.element.classList.add('overflow-auto');

    this.treeOutline = new UI.TreeOutline.TreeOutlineInShadow();
    UI.ARIAUtils.setLabel(this.treeOutline.contentElement, i18nString(UIStrings.commandLog));
    this.element.appendChild(this.treeOutline.element);
    this.setDefaultFocusedElement(this.treeOutline.contentElement);

    this.log = [];
    this.treeItemCache = new Map();
  }

  setCommandLog(log: SDK.PaintProfiler.PaintProfilerLogItem[]): void {
    this.log = log;

    this.updateWindow({left: 0, right: this.log.length});
  }

  private appendLogItem(logItem: SDK.PaintProfiler.PaintProfilerLogItem): void {
    let treeElement = this.treeItemCache.get(logItem);
    if (!treeElement) {
      treeElement = new LogTreeElement(logItem);
      this.treeItemCache.set(logItem, treeElement);
    } else if (treeElement.parent) {
      return;
    }
    this.treeOutline.appendChild(treeElement);
  }

  updateWindow(selectionWindow: {left: number, right: number}|null): void {
    this.selectionWindow = selectionWindow;
    this.requestUpdate();
  }

  override performUpdate(): Promise<void> {
    if (!this.selectionWindow || !this.log.length) {
      this.treeOutline.removeChildren();
      return Promise.resolve();
    }
    const root = this.treeOutline.rootElement();
    for (;;) {
      const child = root.firstChild() as LogTreeElement;
      if (!child || child.logItem.commandIndex >= this.selectionWindow.left) {
        break;
      }
      root.removeChildAtIndex(0);
    }
    for (;;) {
      const child = root.lastChild() as LogTreeElement;
      if (!child || child.logItem.commandIndex < this.selectionWindow.right) {
        break;
      }
      root.removeChildAtIndex(root.children().length - 1);
    }
    for (let i = this.selectionWindow.left, right = this.selectionWindow.right; i < right; ++i) {
      this.appendLogItem(this.log[i]);
    }
    return Promise.resolve();
  }
}

export class LogTreeElement extends UI.TreeOutline.TreeElement {
  readonly logItem: SDK.PaintProfiler.PaintProfilerLogItem;

  constructor(logItem: SDK.PaintProfiler.PaintProfilerLogItem) {
    super('', Boolean(logItem.params));
    this.logItem = logItem;
  }

  override onattach(): void {
    this.update();
  }

  override async onpopulate(): Promise<void> {
    for (const param in this.logItem.params) {
      LogPropertyTreeElement.appendLogPropertyItem(this, param, this.logItem.params[param]);
    }
  }

  private paramToString(param: SDK.PaintProfiler.RawPaintProfilerLogItemParamValue, name: string): string {
    if (typeof param !== 'object') {
      return typeof param === 'string' && param.length > 100 ? name : JSON.stringify(param);
    }
    let str = '';
    let keyCount = 0;
    for (const key in param) {
      const paramKey = param[key];
      if (++keyCount > 4 || paramKey === 'object' || (paramKey === 'string' && paramKey.length > 100)) {
        return name;
      }
      if (str) {
        str += ', ';
      }
      str += paramKey;
    }
    return str;
  }

  private paramsToString(params: SDK.PaintProfiler.RawPaintProfilerLogItemParams|null): string {
    let str = '';
    for (const key in params) {
      if (str) {
        str += ', ';
      }
      str += this.paramToString(params[key], key);
    }
    return str;
  }

  private update(): void {
    const title = document.createDocumentFragment();
    UI.UIUtils.createTextChild(title, this.logItem.method + '(' + this.paramsToString(this.logItem.params) + ')');
    this.title = title;
  }
}

export class LogPropertyTreeElement extends UI.TreeOutline.TreeElement {
  private property: {name: string, value: SDK.PaintProfiler.RawPaintProfilerLogItemParamValue};

  constructor(property: {name: string, value: SDK.PaintProfiler.RawPaintProfilerLogItemParamValue}) {
    super();
    this.property = property;
  }

  static appendLogPropertyItem(
      element: UI.TreeOutline.TreeElement, name: string,
      value: SDK.PaintProfiler.RawPaintProfilerLogItemParamValue): void {
    const treeElement = new LogPropertyTreeElement({name, value});
    element.appendChild(treeElement);
    if (value && typeof value === 'object') {
      for (const property in value) {
        LogPropertyTreeElement.appendLogPropertyItem(treeElement, property, value[property]);
      }
    }
  }

  override onattach(): void {
    const title = document.createDocumentFragment();
    const nameElement = title.createChild('span', 'name');
    nameElement.textContent = this.property.name;
    const separatorElement = title.createChild('span', 'separator');
    separatorElement.textContent = ': ';
    if (this.property.value === null || typeof this.property.value !== 'object') {
      const valueElement = title.createChild('span', 'value');
      valueElement.textContent = JSON.stringify(this.property.value);
      valueElement.classList.add('cm-js-' + (this.property.value === null ? 'null' : typeof this.property.value));
    }
    this.title = title;
  }
}

export class PaintProfilerCategory {
  name: string;
  title: string;
  color: string;

  constructor(name: string, title: string, color: string) {
    this.name = name;
    this.title = title;
    this.color = color;
  }
}
