/*
 * Copyright (C) 2014 Google Inc. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 *     * Redistributions of source code must retain the above copyright
 * notice, this list of conditions and the following disclaimer.
 *     * Redistributions in binary form must reproduce the above
 * copyright notice, this list of conditions and the following disclaimer
 * in the documentation and/or other materials provided with the
 * distribution.
 *     * Neither the name of Google Inc. nor the names of its
 * contributors may be used to endorse or promote products derived from
 * this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

/* eslint-disable rulesdir/no_underscored_properties */

import * as Common from '../common/common.js';
import * as i18n from '../i18n/i18n.js';
import * as Platform from '../platform/platform.js';
import * as SDK from '../sdk/sdk.js';  // eslint-disable-line no-unused-vars
import * as UI from '../ui/ui.js';

import {LayerSelection, LayerView, LayerViewHost, ScrollRectSelection, Selection, SnapshotSelection, Type} from './LayerViewHost.js';  // eslint-disable-line no-unused-vars
import {Events as TransformControllerEvents, TransformController} from './TransformController.js';

export const UIStrings = {
  /**
  *@description Text of a DOM element in DView of the Layers panel
  */
  layerInformationIsNotYet: 'Layer information is not yet available.',
  /**
  *@description Accessibility label for canvas view in Layers tool
  */
  dLayersView: '3D Layers View',
  /**
  *@description Text in DView of the Layers panel
  */
  cantDisplayLayers: 'Can\'t display layers,',
  /**
  *@description Text in DView of the Layers panel
  */
  webglSupportIsDisabledInYour: 'WebGL support is disabled in your browser.',
  /**
  *@description Text in DView of the Layers panel
  *@example {about:gpu} PH1
  */
  checkSForPossibleReasons: 'Check {PH1} for possible reasons.',
  /**
  *@description Text for a checkbox in the toolbar of the Layers panel to show the area of slow scroll rect
  */
  slowScrollRects: 'Slow scroll rects',
  /**
  *@description Text for a checkbox in the toolbar of the Layers panel. It is used as a verb to display marks
  * representing changes on the screen.
  */
  paints: 'Paints',
  /**
  *@description A context menu item in the DView of the Layers panel
  */
  resetView: 'Reset View',
  /**
  *@description A context menu item in the DView of the Layers panel
  */
  showPaintProfiler: 'Show Paint Profiler',
};
const str_ = i18n.i18n.registerUIStrings('layer_viewer/Layers3DView.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

const vertexPositionAttributes = new Map<WebGLProgram, number>();

const vertexColorAttributes = new Map<WebGLProgram, number>();

const textureCoordAttributes = new Map<WebGLProgram, number>();

const uniformMatrixLocations = new Map<WebGLProgram, WebGLUniformLocation|null>();

const uniformSamplerLocations = new Map<WebGLProgram, WebGLUniformLocation|null>();

const imageForTexture = new Map<WebGLTexture, HTMLImageElement>();

export class Layers3DView extends UI.Widget.VBox implements LayerView {
  _failBanner: UI.Widget.VBox;
  _layerViewHost: LayerViewHost;
  _transformController: TransformController;
  _canvasElement: HTMLCanvasElement;
  _lastSelection: {[x: string]: Selection|null};
  _layerTree: SDK.LayerTreeBase.LayerTreeBase|null;
  _textureManager: LayerTextureManager;
  _chromeTextures: (WebGLTexture|undefined)[];
  _rects: Rectangle[];
  _snapshotLayers: Map<SDK.LayerTreeBase.Layer, SnapshotSelection>;
  _shaderProgram!: WebGLProgram|null;
  _oldTextureScale!: number|undefined;
  _depthByLayerId!: Map<string, number>;
  _visibleLayers!: Set<SDK.LayerTreeBase.Layer>;
  _maxDepth!: number;
  _scale!: number;
  _layerTexture?: {layer: SDK.LayerTreeBase.Layer, texture: WebGLTexture}|null;
  _projectionMatrix?: DOMMatrix;
  _whiteTexture?: WebGLTexture|null;
  _gl?: WebGLRenderingContext|null;
  _dimensionsForAutoscale?: {width: number, height: number};
  _needsUpdate?: boolean;
  _panelToolbar?: UI.Toolbar.Toolbar;
  _showSlowScrollRectsSetting?: Common.Settings.Setting<boolean>;
  _showPaintsSetting?: Common.Settings.Setting<boolean>;
  _mouseDownX?: number;
  _mouseDownY?: number;

  constructor(layerViewHost: LayerViewHost) {
    super(true);

    this.registerRequiredCSS('layer_viewer/layers3DView.css', {enableLegacyPatching: true});
    this.contentElement.classList.add('layers-3d-view');
    this._failBanner = new UI.Widget.VBox();
    this._failBanner.element.classList.add('full-widget-dimmed-banner');
    UI.UIUtils.createTextChild(this._failBanner.element, i18nString(UIStrings.layerInformationIsNotYet));

    this._layerViewHost = layerViewHost;
    this._layerViewHost.registerView(this);
    this._transformController = new TransformController(this.contentElement as HTMLElement);
    this._transformController.addEventListener(TransformControllerEvents.TransformChanged, this._update, this);
    this._initToolbar();
    this._canvasElement = this.contentElement.createChild('canvas') as HTMLCanvasElement;
    this._canvasElement.tabIndex = 0;
    this._canvasElement.addEventListener('dblclick', this._onDoubleClick.bind(this), false);
    this._canvasElement.addEventListener('mousedown', this._onMouseDown.bind(this), false);
    this._canvasElement.addEventListener('mouseup', this._onMouseUp.bind(this), false);
    this._canvasElement.addEventListener('mouseleave', this._onMouseMove.bind(this), false);
    this._canvasElement.addEventListener('mousemove', this._onMouseMove.bind(this), false);
    this._canvasElement.addEventListener('contextmenu', this._onContextMenu.bind(this), false);
    UI.ARIAUtils.setAccessibleName(this._canvasElement, i18nString(UIStrings.dLayersView));

    this._lastSelection = {};
    this._layerTree = null;

    this._textureManager = new LayerTextureManager(this._update.bind(this));

    this._chromeTextures = [];

    this._rects = [];

    this._snapshotLayers = new Map();
    this._layerViewHost.setLayerSnapshotMap(this._snapshotLayers);

    this._layerViewHost.showInternalLayersSetting().addChangeListener(this._update, this);
  }

  setLayerTree(layerTree: SDK.LayerTreeBase.LayerTreeBase|null): void {
    this._layerTree = layerTree;
    this._layerTexture = null;
    delete this._oldTextureScale;
    if (this._showPaints()) {
      this._textureManager.setLayerTree(layerTree);
    }
    this._update();
  }

  showImageForLayer(layer: SDK.LayerTreeBase.Layer, imageURL?: string): void {
    if (!imageURL) {
      this._layerTexture = null;
      this._update();
      return;
    }
    UI.UIUtils.loadImage(imageURL).then(image => {
      const texture = image && LayerTextureManager._createTextureForImage(this._gl || null, image);
      this._layerTexture = texture ? {layer: layer, texture: texture} : null;
      this._update();
    });
  }

  onResize(): void {
    this._resizeCanvas();
    this._update();
  }

  willHide(): void {
    this._textureManager.suspend();
  }

  wasShown(): void {
    this._textureManager.resume();
    if (!this._needsUpdate) {
      return;
    }
    this._resizeCanvas();
    this._update();
  }

  updateLayerSnapshot(layer: SDK.LayerTreeBase.Layer): void {
    this._textureManager.layerNeedsUpdate(layer);
  }

  _setOutline(type: OutlineType, selection: Selection|null): void {
    this._lastSelection[type] = selection;
    this._update();
  }

  hoverObject(selection: Selection|null): void {
    this._setOutline(OutlineType.Hovered, selection);
  }

  selectObject(selection: Selection|null): void {
    this._setOutline(OutlineType.Hovered, null);
    this._setOutline(OutlineType.Selected, selection);
  }

  snapshotForSelection(selection: Selection): Promise<SDK.PaintProfiler.SnapshotWithRect|null> {
    if (selection.type() === Type.Snapshot) {
      const snapshotWithRect = (selection as SnapshotSelection).snapshot();
      snapshotWithRect.snapshot.addReference();
      return /** @type {!Promise<?SDK.PaintProfiler.SnapshotWithRect>} */ Promise.resolve(snapshotWithRect) as
          Promise<SDK.PaintProfiler.SnapshotWithRect|null>;
    }
    if (selection.layer()) {
      const promise = selection.layer().snapshots()[0];
      if (promise) {
        return promise;
      }
    }
    return /** @type {!Promise<?SDK.PaintProfiler.SnapshotWithRect>} */ Promise.resolve(null) as
        Promise<SDK.PaintProfiler.SnapshotWithRect|null>;
  }

  _initGL(canvas: HTMLCanvasElement): WebGLRenderingContext|null {
    const gl = canvas.getContext('webgl');
    if (!gl) {
      return null;
    }
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
    gl.enable(gl.BLEND);
    gl.clearColor(0.0, 0.0, 0.0, 0.0);
    gl.enable(gl.DEPTH_TEST);
    return /** @type {!WebGLRenderingContext} */ gl as WebGLRenderingContext;
  }

  _createShader(type: number, script: string): void {
    if (!this._gl) {
      return;
    }

    const shader = this._gl.createShader(type);
    if (shader && this._shaderProgram) {
      this._gl.shaderSource(shader, script);
      this._gl.compileShader(shader);
      this._gl.attachShader(this._shaderProgram, shader);
    }
  }

  _initShaders(): void {
    if (!this._gl) {
      return;
    }

    this._shaderProgram = this._gl.createProgram();
    if (!this._shaderProgram) {
      return;
    }
    this._createShader(this._gl.FRAGMENT_SHADER, FragmentShader);
    this._createShader(this._gl.VERTEX_SHADER, VertexShader);
    this._gl.linkProgram(this._shaderProgram);
    this._gl.useProgram(this._shaderProgram);

    const aVertexPositionAttribute = this._gl.getAttribLocation(this._shaderProgram, 'aVertexPosition');
    this._gl.enableVertexAttribArray(aVertexPositionAttribute);
    vertexPositionAttributes.set(this._shaderProgram, aVertexPositionAttribute);

    const aVertexColorAttribute = this._gl.getAttribLocation(this._shaderProgram, 'aVertexColor');
    this._gl.enableVertexAttribArray(aVertexColorAttribute);
    vertexColorAttributes.set(this._shaderProgram, aVertexColorAttribute);

    const aTextureCoordAttribute = this._gl.getAttribLocation(this._shaderProgram, 'aTextureCoord');
    this._gl.enableVertexAttribArray(aTextureCoordAttribute);
    textureCoordAttributes.set(this._shaderProgram, aTextureCoordAttribute);

    const uMatrixLocation = this._gl.getUniformLocation(this._shaderProgram, 'uPMatrix');
    uniformMatrixLocations.set(this._shaderProgram, uMatrixLocation);

    const uSamplerLocation = this._gl.getUniformLocation(this._shaderProgram, 'uSampler');
    uniformSamplerLocations.set(this._shaderProgram, uSamplerLocation);
  }

  _resizeCanvas(): void {
    this._canvasElement.width = this._canvasElement.offsetWidth * window.devicePixelRatio;
    this._canvasElement.height = this._canvasElement.offsetHeight * window.devicePixelRatio;
  }

  _updateTransformAndConstraints(): void {
    const paddingFraction = 0.1;
    const dimensionsForAutoscale = this._dimensionsForAutoscale || {width: 0, height: 0};
    const viewport = this._layerTree ? this._layerTree.viewportSize() : null;
    const baseWidth = viewport ? viewport.width : dimensionsForAutoscale.width;
    const baseHeight = viewport ? viewport.height : dimensionsForAutoscale.height;
    const canvasWidth = this._canvasElement.width;
    const canvasHeight = this._canvasElement.height;
    const paddingX = canvasWidth * paddingFraction;
    const paddingY = canvasHeight * paddingFraction;
    const scaleX = (canvasWidth - 2 * paddingX) / baseWidth;
    const scaleY = (canvasHeight - 2 * paddingY) / baseHeight;
    const viewScale = Math.min(scaleX, scaleY);
    const minScaleConstraint =
        Math.min(baseWidth / dimensionsForAutoscale.width, baseHeight / dimensionsForAutoscale.width) / 2;
    this._transformController.setScaleConstraints(
        minScaleConstraint,
        10 / viewScale);  // 1/viewScale is 1:1 in terms of pixels, so allow zooming to 10x of native size
    const scale = this._transformController.scale();
    const rotateX = this._transformController.rotateX();
    const rotateY = this._transformController.rotateY();

    this._scale = scale * viewScale;
    const textureScale = Platform.NumberUtilities.clamp(this._scale, 0.1, 1);
    if (textureScale !== this._oldTextureScale) {
      this._oldTextureScale = textureScale;
      this._textureManager.setScale(textureScale);
      this.dispatchEventToListeners(Events.ScaleChanged, textureScale);
    }
    const scaleAndRotationMatrix = new WebKitCSSMatrix()
                                       .scale(scale, scale, scale)
                                       .translate(canvasWidth / 2, canvasHeight / 2, 0)
                                       .rotate(rotateX, rotateY, 0)
                                       .scale(viewScale, viewScale, viewScale)
                                       .translate(-baseWidth / 2, -baseHeight / 2, 0);

    let bounds;
    for (let i = 0; i < this._rects.length; ++i) {
      bounds = UI.Geometry.boundsForTransformedPoints(scaleAndRotationMatrix, this._rects[i].vertices, bounds);
    }

    if (bounds) {
      this._transformController.clampOffsets(
          (paddingX - bounds.maxX) / window.devicePixelRatio,
          (canvasWidth - paddingX - bounds.minX) / window.devicePixelRatio,
          (paddingY - bounds.maxY) / window.devicePixelRatio,
          (canvasHeight - paddingY - bounds.minY) / window.devicePixelRatio);
    }
    const offsetX = this._transformController.offsetX() * window.devicePixelRatio;
    const offsetY = this._transformController.offsetY() * window.devicePixelRatio;
    // Multiply to translation matrix on the right rather than translate (which would implicitly multiply on the left).
    this._projectionMatrix = new WebKitCSSMatrix().translate(offsetX, offsetY, 0).multiply(scaleAndRotationMatrix);

    const glProjectionMatrix = new WebKitCSSMatrix()
                                   .scale(1, -1, -1)
                                   .translate(-1, -1, 0)
                                   .scale(2 / this._canvasElement.width, 2 / this._canvasElement.height, 1 / 1000000)
                                   .multiply(this._projectionMatrix);

    if (this._shaderProgram) {
      const pMatrixUniform = uniformMatrixLocations.get(this._shaderProgram);
      if (this._gl && pMatrixUniform) {
        this._gl.uniformMatrix4fv(pMatrixUniform, false, this._arrayFromMatrix(glProjectionMatrix));
      }
    }
  }

  _arrayFromMatrix(m: DOMMatrix): Float32Array {
    return new Float32Array([
      m.m11,
      m.m12,
      m.m13,
      m.m14,
      m.m21,
      m.m22,
      m.m23,
      m.m24,
      m.m31,
      m.m32,
      m.m33,
      m.m34,
      m.m41,
      m.m42,
      m.m43,
      m.m44,
    ]);
  }

  _initWhiteTexture(): void {
    if (!this._gl) {
      return;
    }

    this._whiteTexture = this._gl.createTexture();
    this._gl.bindTexture(this._gl.TEXTURE_2D, this._whiteTexture);
    const whitePixel = new Uint8Array([255, 255, 255, 255]);
    this._gl.texImage2D(
        this._gl.TEXTURE_2D, 0, this._gl.RGBA, 1, 1, 0, this._gl.RGBA, this._gl.UNSIGNED_BYTE, whitePixel);
  }

  _initChromeTextures(): void {
    function loadChromeTexture(this: Layers3DView, index: ChromeTexture, url: string): void {
      UI.UIUtils.loadImage(url).then(image => {
        this._chromeTextures[index] =
            image && LayerTextureManager._createTextureForImage(this._gl || null, image) || undefined;
      });
    }
    loadChromeTexture.call(this, ChromeTexture.Left, 'Images/chromeLeft.avif');
    loadChromeTexture.call(this, ChromeTexture.Middle, 'Images/chromeMiddle.avif');
    loadChromeTexture.call(this, ChromeTexture.Right, 'Images/chromeRight.avif');
  }

  _initGLIfNecessary(): WebGLRenderingContext|null {
    if (this._gl) {
      return this._gl;
    }
    this._gl = this._initGL(this._canvasElement);
    if (!this._gl) {
      return null;
    }
    this._initShaders();
    this._initWhiteTexture();
    this._initChromeTextures();
    this._textureManager.setContext(this._gl);
    return this._gl;
  }

  _calculateDepthsAndVisibility(): void {
    /** @type {!Map<string, number>} */
    this._depthByLayerId = new Map();
    let depth = 0;
    const showInternalLayers = this._layerViewHost.showInternalLayersSetting().get();
    if (!this._layerTree) {
      return;
    }

    const root =
        showInternalLayers ? this._layerTree.root() : (this._layerTree.contentRoot() || this._layerTree.root());
    if (!root) {
      return;
    }

    const queue = [root];
    this._depthByLayerId.set(root.id(), 0);
    /** @type {!Set<!SDK.LayerTreeBase.Layer>} */
    this._visibleLayers = new Set();
    while (queue.length > 0) {
      const layer = queue.shift();
      if (!layer) {
        break;
      }

      if (showInternalLayers || layer.drawsContent()) {
        this._visibleLayers.add(layer);
      }
      const children = layer.children();
      for (let i = 0; i < children.length; ++i) {
        this._depthByLayerId.set(children[i].id(), ++depth);
        queue.push(children[i]);
      }
    }
    this._maxDepth = depth;
  }

  _depthForLayer(layer: SDK.LayerTreeBase.Layer): number {
    return (this._depthByLayerId.get(layer.id()) || 0) * LayerSpacing;
  }

  _calculateScrollRectDepth(layer: SDK.LayerTreeBase.Layer, index: number): number {
    return this._depthForLayer(layer) + index * ScrollRectSpacing + 1;
  }

  _updateDimensionsForAutoscale(layer: SDK.LayerTreeBase.Layer): void {
    // We don't want to be precise, but rather pick something least affected by
    // animationtransforms, so that we don't change scale too often. So let's
    // disregard transforms, scrolling and relative layer positioning and choose
    // the largest dimensions of all layers.
    if (!this._dimensionsForAutoscale) {
      this._dimensionsForAutoscale = {width: 0, height: 0};
    }

    this._dimensionsForAutoscale.width = Math.max(layer.width(), this._dimensionsForAutoscale.width);
    this._dimensionsForAutoscale.height = Math.max(layer.height(), this._dimensionsForAutoscale.height);
  }

  _calculateLayerRect(layer: SDK.LayerTreeBase.Layer): void {
    if (!this._visibleLayers.has(layer)) {
      return;
    }
    const selection = new LayerSelection(layer);
    const rect = new Rectangle(selection);
    rect.setVertices(layer.quad(), this._depthForLayer(layer));
    this._appendRect(rect);
    this._updateDimensionsForAutoscale(layer);
  }

  _appendRect(rect: Rectangle): void {
    const selection = rect.relatedObject;
    const isSelected = Selection.isEqual(this._lastSelection[OutlineType.Selected], selection);
    const isHovered = Selection.isEqual(this._lastSelection[OutlineType.Hovered], selection);
    if (isSelected) {
      rect.borderColor = SelectedBorderColor;
    } else if (isHovered) {
      rect.borderColor = HoveredBorderColor;
      const fillColor = rect.fillColor || [255, 255, 255, 1];
      const maskColor = HoveredImageMaskColor;
      rect.fillColor = [
        fillColor[0] * maskColor[0] / 255,
        fillColor[1] * maskColor[1] / 255,
        fillColor[2] * maskColor[2] / 255,
        fillColor[3] * maskColor[3],
      ];
    } else {
      rect.borderColor = BorderColor;
    }
    rect.lineWidth = isSelected ? SelectedBorderWidth : BorderWidth;
    this._rects.push(rect);
  }

  _calculateLayerScrollRects(layer: SDK.LayerTreeBase.Layer): void {
    const scrollRects = layer.scrollRects();
    for (let i = 0; i < scrollRects.length; ++i) {
      const selection = new ScrollRectSelection(layer, i);
      const rect = new Rectangle(selection);
      rect.calculateVerticesFromRect(layer, scrollRects[i].rect, this._calculateScrollRectDepth(layer, i));
      rect.fillColor = ScrollRectBackgroundColor;
      this._appendRect(rect);
    }
  }

  _calculateLayerTileRects(layer: SDK.LayerTreeBase.Layer): void {
    const tiles = this._textureManager.tilesForLayer(layer);
    for (let i = 0; i < tiles.length; ++i) {
      const tile = tiles[i];
      if (!tile.texture) {
        continue;
      }
      const selection = new SnapshotSelection(layer, {rect: tile.rect, snapshot: tile.snapshot});
      const rect = new Rectangle(selection);
      if (!this._snapshotLayers.has(layer)) {
        this._snapshotLayers.set(layer, selection);
      }

      rect.calculateVerticesFromRect(layer, tile.rect, this._depthForLayer(layer) + 1);
      rect.texture = tile.texture;
      this._appendRect(rect);
    }
  }

  _calculateRects(): void {
    this._rects = [];
    this._snapshotLayers.clear();
    this._dimensionsForAutoscale = {width: 0, height: 0};
    if (this._layerTree) {
      this._layerTree.forEachLayer(this._calculateLayerRect.bind(this));
    }

    if (this._showSlowScrollRectsSetting && this._showSlowScrollRectsSetting.get() && this._layerTree) {
      this._layerTree.forEachLayer(this._calculateLayerScrollRects.bind(this));
    }

    if (this._layerTexture && this._visibleLayers.has(this._layerTexture.layer)) {
      const layer = this._layerTexture.layer;
      const selection = new LayerSelection(layer);
      const rect = new Rectangle(selection);
      rect.setVertices(layer.quad(), this._depthForLayer(layer));
      rect.texture = this._layerTexture.texture;
      this._appendRect(rect);
    } else if (this._showPaints() && this._layerTree) {
      this._layerTree.forEachLayer(this._calculateLayerTileRects.bind(this));
    }
  }

  _makeColorsArray(color: number[]): number[] {
    let colors: number[] = [];
    const normalizedColor = [color[0] / 255, color[1] / 255, color[2] / 255, color[3]];
    for (let i = 0; i < 4; i++) {
      colors = colors.concat(normalizedColor);
    }
    return colors;
  }

  _setVertexAttribute(attribute: number, array: number[], length: number): void {
    const gl = this._gl;
    if (!gl) {
      return;
    }

    const buffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(array), gl.STATIC_DRAW);
    gl.vertexAttribPointer(attribute, length, gl.FLOAT, false, 0, 0);
  }

  _drawRectangle(vertices: number[], mode: number, color?: number[], texture?: Object): void {
    const gl = this._gl;
    const white = [255, 255, 255, 1];
    color = color || white;
    if (!this._shaderProgram) {
      return;
    }

    const vertexPositionAttribute = vertexPositionAttributes.get(this._shaderProgram);
    const textureCoordAttribute = textureCoordAttributes.get(this._shaderProgram);
    const vertexColorAttribute = vertexColorAttributes.get(this._shaderProgram);
    if (typeof vertexPositionAttribute !== 'undefined') {
      this._setVertexAttribute(vertexPositionAttribute, vertices, 3);
    }
    if (typeof textureCoordAttribute !== 'undefined') {
      this._setVertexAttribute(textureCoordAttribute, [0, 1, 1, 1, 1, 0, 0, 0], 2);
    }
    if (typeof vertexColorAttribute !== 'undefined') {
      this._setVertexAttribute(vertexColorAttribute, this._makeColorsArray(color), color.length);
    }

    if (!gl) {
      return;
    }

    const samplerUniform = uniformSamplerLocations.get(this._shaderProgram);
    if (texture) {
      if (samplerUniform) {
        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, texture);
        gl.uniform1i(samplerUniform, 0);
      }
    } else if (this._whiteTexture) {
      gl.bindTexture(gl.TEXTURE_2D, this._whiteTexture);
    }

    const numberOfVertices = vertices.length / 3;
    gl.drawArrays(mode, 0, numberOfVertices);
  }

  _drawTexture(vertices: number[], texture: WebGLTexture, color?: number[]): void {
    if (!this._gl) {
      return;
    }

    this._drawRectangle(vertices, this._gl.TRIANGLE_FAN, color, texture);
  }

  _drawViewportAndChrome(): void {
    if (!this._layerTree) {
      return;
    }

    const viewport = this._layerTree.viewportSize();
    if (!viewport) {
      return;
    }

    const drawChrome = !Common.Settings.Settings.instance().moduleSetting('frameViewerHideChromeWindow').get() &&
        this._chromeTextures.length >= 3 && this._chromeTextures.indexOf(undefined) < 0;
    const z = (this._maxDepth + 1) * LayerSpacing;
    const borderWidth = Math.ceil(ViewportBorderWidth * this._scale);
    let vertices: number[] = [viewport.width, 0, z, viewport.width, viewport.height, z, 0, viewport.height, z, 0, 0, z];
    if (!this._gl) {
      return;
    }

    this._gl.lineWidth(borderWidth);
    this._drawRectangle(vertices, drawChrome ? this._gl.LINE_STRIP : this._gl.LINE_LOOP, ViewportBorderColor);

    if (!drawChrome) {
      return;
    }

    const viewportSize = this._layerTree.viewportSize();
    if (!viewportSize) {
      return;
    }

    const borderAdjustment = ViewportBorderWidth / 2;
    const viewportWidth = viewportSize.width + 2 * borderAdjustment;
    if (this._chromeTextures[0] && this._chromeTextures[2]) {
      const chromeTextureImage =
          imageForTexture.get(this._chromeTextures[0] as WebGLTexture) || {naturalHeight: 0, naturalWidth: 0};
      const chromeHeight = chromeTextureImage.naturalHeight;

      const middleTextureImage =
          imageForTexture.get(this._chromeTextures[2] as WebGLTexture) || {naturalHeight: 0, naturalWidth: 0};
      const middleFragmentWidth = viewportWidth - chromeTextureImage.naturalWidth - middleTextureImage.naturalWidth;
      let x = -borderAdjustment;
      const y = -chromeHeight;
      for (let i = 0; i < this._chromeTextures.length; ++i) {
        const texture = this._chromeTextures[i];
        if (!texture) {
          continue;
        }

        const image = imageForTexture.get(texture);
        if (!image) {
          continue;
        }
        const width = i === ChromeTexture.Middle ? middleFragmentWidth : image.naturalWidth;
        if (width < 0 || x + width > viewportWidth) {
          break;
        }
        vertices = [x, y, z, x + width, y, z, x + width, y + chromeHeight, z, x, y + chromeHeight, z];
        this._drawTexture(vertices, this._chromeTextures[i] as WebGLTexture);
        x += width;
      }
    }
  }

  _drawViewRect(rect: Rectangle): void {
    if (!this._gl) {
      return;
    }

    const vertices = rect.vertices;
    if (rect.texture) {
      this._drawTexture(vertices, rect.texture, rect.fillColor || undefined);
    } else if (rect.fillColor) {
      this._drawRectangle(vertices, this._gl.TRIANGLE_FAN, rect.fillColor);
    }
    this._gl.lineWidth(rect.lineWidth);
    if (rect.borderColor) {
      this._drawRectangle(vertices, this._gl.LINE_LOOP, rect.borderColor);
    }
  }

  _update(): void {
    if (!this.isShowing()) {
      this._needsUpdate = true;
      return;
    }
    if (!this._layerTree || !this._layerTree.root()) {
      this._failBanner.show(this.contentElement);
      return;
    }
    const gl = this._initGLIfNecessary();
    if (!gl) {
      this._failBanner.element.removeChildren();
      this._failBanner.element.appendChild(this._webglDisabledBanner());
      this._failBanner.show(this.contentElement);
      return;
    }
    this._failBanner.detach();
    const viewportWidth = this._canvasElement.width;
    const viewportHeight = this._canvasElement.height;

    this._calculateDepthsAndVisibility();
    this._calculateRects();
    this._updateTransformAndConstraints();

    gl.viewport(0, 0, viewportWidth, viewportHeight);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    this._rects.forEach(this._drawViewRect.bind(this));
    this._drawViewportAndChrome();
  }

  _webglDisabledBanner(): Node {
    const fragment = this.contentElement.ownerDocument.createDocumentFragment();
    fragment.createChild('div').textContent = i18nString(UIStrings.cantDisplayLayers);
    fragment.createChild('div').textContent = i18nString(UIStrings.webglSupportIsDisabledInYour);
    fragment.appendChild(i18n.i18n.getFormatLocalizedString(
        str_, UIStrings.checkSForPossibleReasons, {PH1: UI.XLink.XLink.create('about:gpu')}));
    return fragment;
  }

  _selectionFromEventPoint(event: Event): Selection|null {
    const mouseEvent = event as MouseEvent;
    if (!this._layerTree) {
      return null;
    }
    let closestIntersectionPoint: number = Infinity;
    let closestObject: Selection|null = null;
    const projectionMatrix =
        new WebKitCSSMatrix().scale(1, -1, -1).translate(-1, -1, 0).multiply(this._projectionMatrix);
    const x0 = (mouseEvent.clientX - this._canvasElement.totalOffsetLeft()) * window.devicePixelRatio;
    const y0 = -(mouseEvent.clientY - this._canvasElement.totalOffsetTop()) * window.devicePixelRatio;

    function checkIntersection(rect: Rectangle): void {
      if (!rect.relatedObject) {
        return;
      }
      const t = rect.intersectWithLine(projectionMatrix, x0, y0);
      if (t && t < closestIntersectionPoint) {
        closestIntersectionPoint = t;
        closestObject = rect.relatedObject;
      }
    }

    this._rects.forEach(checkIntersection);
    return closestObject;
  }

  _createVisibilitySetting(caption: string, name: string, value: boolean, toolbar: UI.Toolbar.Toolbar):
      Common.Settings.Setting<boolean> {
    const setting = Common.Settings.Settings.instance().createSetting(name, value);
    setting.setTitle(i18nString(caption));
    setting.addChangeListener(this._update, this);
    toolbar.appendToolbarItem(new UI.Toolbar.ToolbarSettingCheckbox(setting));
    return setting;
  }

  _initToolbar(): void {
    this._panelToolbar = this._transformController.toolbar();
    this.contentElement.appendChild(this._panelToolbar.element);
    this._showSlowScrollRectsSetting = this._createVisibilitySetting(
        i18nString(UIStrings.slowScrollRects), 'frameViewerShowSlowScrollRects', true, this._panelToolbar);
    this._showPaintsSetting =
        this._createVisibilitySetting(i18nString(UIStrings.paints), 'frameViewerShowPaints', true, this._panelToolbar);
    this._showPaintsSetting.addChangeListener(this._updatePaints, this);
    Common.Settings.Settings.instance()
        .moduleSetting('frameViewerHideChromeWindow')
        .addChangeListener(this._update, this);
  }

  _onContextMenu(event: Event): void {
    const contextMenu = new UI.ContextMenu.ContextMenu(event);
    contextMenu.defaultSection().appendItem(
        i18nString(UIStrings.resetView), () => this._transformController.resetAndNotify(), false);
    const selection = this._selectionFromEventPoint(event);
    if (selection && selection.type() === Type.Snapshot) {
      contextMenu.defaultSection().appendItem(
          i18nString(UIStrings.showPaintProfiler),
          this.dispatchEventToListeners.bind(this, Events.PaintProfilerRequested, selection), false);
    }
    this._layerViewHost.showContextMenu(contextMenu, selection);
  }

  _onMouseMove(event: Event): void {
    const mouseEvent = event as MouseEvent;
    if (mouseEvent.which) {
      return;
    }
    this._layerViewHost.hoverObject(this._selectionFromEventPoint(event));
  }

  _onMouseDown(event: Event): void {
    const mouseEvent = event as MouseEvent;
    this._mouseDownX = mouseEvent.clientX;
    this._mouseDownY = mouseEvent.clientY;
  }

  _onMouseUp(event: Event): void {
    const mouseEvent = event as MouseEvent;
    const maxDistanceInPixels = 6;
    if (this._mouseDownX && Math.abs(mouseEvent.clientX - this._mouseDownX) < maxDistanceInPixels &&
        Math.abs(mouseEvent.clientY - (this._mouseDownY || 0)) < maxDistanceInPixels) {
      this._layerViewHost.selectObject(this._selectionFromEventPoint(event));
    }
    delete this._mouseDownX;
    delete this._mouseDownY;
  }

  _onDoubleClick(event: Event): void {
    const selection = this._selectionFromEventPoint(event);
    if (selection && (selection.type() === Type.Snapshot || selection.layer())) {
      this.dispatchEventToListeners(Events.PaintProfilerRequested, selection);
    }
    event.stopPropagation();
  }

  _updatePaints(): void {
    if (this._showPaints()) {
      this._textureManager.setLayerTree(this._layerTree);
      this._textureManager.forceUpdate();
    } else {
      this._textureManager.reset();
    }
    this._update();
  }

  _showPaints(): boolean {
    return this._showPaintsSetting ? this._showPaintsSetting.get() : false;
  }
}

// TODO(crbug.com/1167717): Make this a const enum again
// eslint-disable-next-line rulesdir/const_enum
export enum OutlineType {
  Hovered = 'hovered',
  Selected = 'selected',
}

// TODO(crbug.com/1167717): Make this a const enum again
// eslint-disable-next-line rulesdir/const_enum
export enum Events {
  PaintProfilerRequested = 'PaintProfilerRequested',
  ScaleChanged = 'ScaleChanged',
}

export const enum ChromeTexture {
  Left = 0,
  Middle = 1,
  Right = 2,
}

export const FragmentShader = '' +
    'precision mediump float;\n' +
    'varying vec4 vColor;\n' +
    'varying vec2 vTextureCoord;\n' +
    'uniform sampler2D uSampler;\n' +
    'void main(void)\n' +
    '{\n' +
    '    gl_FragColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t)) * vColor;\n' +
    '}';

export const VertexShader = '' +
    'attribute vec3 aVertexPosition;\n' +
    'attribute vec2 aTextureCoord;\n' +
    'attribute vec4 aVertexColor;\n' +
    'uniform mat4 uPMatrix;\n' +
    'varying vec2 vTextureCoord;\n' +
    'varying vec4 vColor;\n' +
    'void main(void)\n' +
    '{\n' +
    'gl_Position = uPMatrix * vec4(aVertexPosition, 1.0);\n' +
    'vColor = aVertexColor;\n' +
    'vTextureCoord = aTextureCoord;\n' +
    '}';

export const HoveredBorderColor = [0, 0, 255, 1];
export const SelectedBorderColor = [0, 255, 0, 1];
export const BorderColor = [0, 0, 0, 1];
export const ViewportBorderColor = [160, 160, 160, 1];
export const ScrollRectBackgroundColor = [178, 100, 100, 0.6];
export const HoveredImageMaskColor = [200, 200, 255, 1];
export const BorderWidth = 1;
export const SelectedBorderWidth = 2;
export const ViewportBorderWidth = 3;

export const LayerSpacing = 20;
export const ScrollRectSpacing = 4;

export class LayerTextureManager {
  _textureUpdatedCallback: () => void;
  _throttler: Common.Throttler.Throttler;
  _scale: number;
  _active: boolean;
  _queue!: SDK.LayerTreeBase.Layer[];
  _tilesByLayer!: Map<SDK.LayerTreeBase.Layer, Tile[]>;
  _gl?: WebGLRenderingContext;
  constructor(textureUpdatedCallback: () => void) {
    this._textureUpdatedCallback = textureUpdatedCallback;
    this._throttler = new Common.Throttler.Throttler(0);
    this._scale = 0;
    this._active = false;
    this.reset();
  }

  static _createTextureForImage(gl: WebGLRenderingContext|null, image: HTMLImageElement): WebGLTexture {
    if (!gl) {
      throw new Error('WebGLRenderingContext not provided');
    }
    const texture = gl.createTexture();
    if (!texture) {
      throw new Error('Unable to create texture');
    }

    imageForTexture.set(texture, image);
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.bindTexture(gl.TEXTURE_2D, null);
    return texture;
  }

  reset(): void {
    if (this._tilesByLayer) {
      this.setLayerTree(null);
    }

    /** @type {!Map<!SDK.LayerTreeBase.Layer, !Array<!Tile>>} */
    this._tilesByLayer = new Map();
    /** @type {!Array<!SDK.LayerTreeBase.Layer>} */
    this._queue = [];
  }

  setContext(glContext: WebGLRenderingContext): void {
    this._gl = glContext;
    if (this._scale) {
      this._updateTextures();
    }
  }

  suspend(): void {
    this._active = false;
  }

  resume(): void {
    this._active = true;
    if (this._queue.length) {
      this._update();
    }
  }

  setLayerTree(layerTree: SDK.LayerTreeBase.LayerTreeBase|null): void {
    const newLayers = new Set<SDK.LayerTreeBase.Layer>();
    const oldLayers = Array.from(this._tilesByLayer.keys());
    if (layerTree) {
      layerTree.forEachLayer(layer => {
        if (!layer.drawsContent()) {
          return;
        }
        newLayers.add(layer);
        if (!this._tilesByLayer.has(layer)) {
          this._tilesByLayer.set(layer, []);
          this.layerNeedsUpdate(layer);
        }
      });
    }
    if (!oldLayers.length) {
      this.forceUpdate();
    }
    for (const layer of oldLayers) {
      if (newLayers.has(layer)) {
        continue;
      }
      const tiles = this._tilesByLayer.get(layer);
      if (tiles) {
        tiles.forEach(tile => tile.dispose());
      }
      this._tilesByLayer.delete(layer);
    }
  }

  _setSnapshotsForLayer(layer: SDK.LayerTreeBase.Layer, snapshots: SDK.PaintProfiler.SnapshotWithRect[]):
      Promise<void> {
    const oldSnapshotsToTiles = new Map((this._tilesByLayer.get(layer) || []).map(tile => [tile.snapshot, tile]));
    const newTiles = [];
    const reusedTiles = [];
    for (const snapshot of snapshots) {
      const oldTile = oldSnapshotsToTiles.get(snapshot.snapshot);
      if (oldTile) {
        reusedTiles.push(oldTile);
        oldSnapshotsToTiles.delete(snapshot.snapshot);
      } else {
        newTiles.push(new Tile(snapshot));
      }
    }
    this._tilesByLayer.set(layer, reusedTiles.concat(newTiles));
    for (const tile of oldSnapshotsToTiles.values()) {
      tile.dispose();
    }
    const gl = this._gl;
    if (!gl || !this._scale) {
      return Promise.resolve();
    }
    return Promise.all(newTiles.map(tile => tile.update(gl, this._scale))).then(this._textureUpdatedCallback);
  }

  setScale(scale: number): void {
    if (this._scale && this._scale >= scale) {
      return;
    }
    this._scale = scale;
    this._updateTextures();
  }

  tilesForLayer(layer: SDK.LayerTreeBase.Layer): Tile[] {
    return this._tilesByLayer.get(layer) || [];
  }

  layerNeedsUpdate(layer: SDK.LayerTreeBase.Layer): void {
    if (this._queue.indexOf(layer) < 0) {
      this._queue.push(layer);
    }
    if (this._active) {
      this._throttler.schedule(this._update.bind(this));
    }
  }

  forceUpdate(): void {
    this._queue.forEach(layer => this._updateLayer(layer));
    this._queue = [];
    this._update();
  }

  _update(): Promise<void> {
    const layer = this._queue.shift();
    if (!layer) {
      return Promise.resolve();
    }
    if (this._queue.length) {
      this._throttler.schedule(this._update.bind(this));
    }
    return this._updateLayer(layer);
  }

  _updateLayer(layer: SDK.LayerTreeBase.Layer): Promise<void> {
    return Promise.all(layer.snapshots())
        .then(
            snapshots => this._setSnapshotsForLayer(
                layer, snapshots.filter(snapshot => snapshot !== null) as SDK.PaintProfiler.SnapshotWithRect[]));
  }

  _updateTextures(): void {
    if (!this._gl) {
      return;
    }
    if (!this._scale) {
      return;
    }

    for (const tiles of this._tilesByLayer.values()) {
      for (const tile of tiles) {
        const promise = tile.updateScale(this._gl, this._scale);
        if (promise) {
          promise.then(this._textureUpdatedCallback);
        }
      }
    }
  }
}

export class Rectangle {
  relatedObject: Selection|null;
  lineWidth: number;
  borderColor: number[]|null;
  fillColor: number[]|null;
  texture: WebGLTexture|null;
  vertices!: number[];
  constructor(relatedObject: Selection|null) {
    this.relatedObject = relatedObject;
    this.lineWidth = 1;
    this.borderColor = null;
    this.fillColor = null;
    this.texture = null;
  }

  setVertices(quad: number[], z: number): void {
    this.vertices = [quad[0], quad[1], z, quad[2], quad[3], z, quad[4], quad[5], z, quad[6], quad[7], z];
  }

  /**
   * Finds coordinates of point on layer quad, having offsets (ratioX * width) and (ratioY * height)
   * from the left corner of the initial layer rect, where width and heigth are layer bounds.
   */
  _calculatePointOnQuad(quad: number[], ratioX: number, ratioY: number): number[] {
    const x0 = quad[0];
    const y0 = quad[1];
    const x1 = quad[2];
    const y1 = quad[3];
    const x2 = quad[4];
    const y2 = quad[5];
    const x3 = quad[6];
    const y3 = quad[7];
    // Point on the first quad side clockwise
    const firstSidePointX = x0 + ratioX * (x1 - x0);
    const firstSidePointY = y0 + ratioX * (y1 - y0);
    // Point on the third quad side clockwise
    const thirdSidePointX = x3 + ratioX * (x2 - x3);
    const thirdSidePointY = y3 + ratioX * (y2 - y3);
    const x = firstSidePointX + ratioY * (thirdSidePointX - firstSidePointX);
    const y = firstSidePointY + ratioY * (thirdSidePointY - firstSidePointY);
    return [x, y];
  }

  calculateVerticesFromRect(layer: SDK.LayerTreeBase.Layer, rect: Protocol.DOM.Rect, z: number): void {
    const quad = layer.quad();
    const rx1 = rect.x / layer.width();
    const rx2 = (rect.x + rect.width) / layer.width();
    const ry1 = rect.y / layer.height();
    const ry2 = (rect.y + rect.height) / layer.height();
    const rectQuad = this._calculatePointOnQuad(quad, rx1, ry1)
                         .concat(this._calculatePointOnQuad(quad, rx2, ry1))
                         .concat(this._calculatePointOnQuad(quad, rx2, ry2))
                         .concat(this._calculatePointOnQuad(quad, rx1, ry2));
    this.setVertices(rectQuad, z);
  }

  /**
   * Intersects quad with given transform matrix and line l(t) = (x0, y0, t)
   */
  intersectWithLine(matrix: DOMMatrix, x0: number, y0: number): number|undefined {
    let i;
    // Vertices of the quad with transform matrix applied
    const points = [];
    for (i = 0; i < 4; ++i) {
      points[i] = UI.Geometry.multiplyVectorByMatrixAndNormalize(
          new UI.Geometry.Vector(this.vertices[i * 3], this.vertices[i * 3 + 1], this.vertices[i * 3 + 2]), matrix);
    }
    // Calculating quad plane normal
    const normal = UI.Geometry.crossProduct(
        UI.Geometry.subtract(points[1], points[0]), UI.Geometry.subtract(points[2], points[1]));
    // General form of the equation of the quad plane: A * x + B * y + C * z + D = 0
    const A = normal.x;
    const B = normal.y;
    const C = normal.z;
    const D = -(A * points[0].x + B * points[0].y + C * points[0].z);
    // Finding t from the equation
    const t = -(D + A * x0 + B * y0) / C;
    // Point of the intersection
    const pt = new UI.Geometry.Vector(x0, y0, t);
    // Vectors from the intersection point to vertices of the quad
    const tVects = points.map(UI.Geometry.subtract.bind(null, pt));
    // Intersection point lies inside of the polygon if scalar products of normal of the plane and
    // cross products of successive tVects are all nonstrictly above or all nonstrictly below zero
    for (i = 0; i < tVects.length; ++i) {
      const product =
          UI.Geometry.scalarProduct(normal, UI.Geometry.crossProduct(tVects[i], tVects[(i + 1) % tVects.length]));
      if (product < 0) {
        return undefined;
      }
    }
    return t;
  }
}

export class Tile {
  snapshot: SDK.PaintProfiler.PaintProfilerSnapshot;
  rect: Protocol.DOM.Rect;
  scale: number;
  texture: WebGLTexture|null;
  _gl!: WebGLRenderingContext;
  constructor(snapshotWithRect: SDK.PaintProfiler.SnapshotWithRect) {
    this.snapshot = snapshotWithRect.snapshot;
    this.rect = snapshotWithRect.rect;
    this.scale = 0;
    this.texture = null;
  }

  dispose(): void {
    this.snapshot.release();
    if (this.texture) {
      this._gl.deleteTexture(this.texture);
      this.texture = null;
    }
  }

  updateScale(glContext: WebGLRenderingContext, scale: number): Promise<void>|null {
    if (this.texture && this.scale >= scale) {
      return null;
    }
    return this.update(glContext, scale);
  }

  async update(glContext: WebGLRenderingContext, scale: number): Promise<void> {
    this._gl = glContext;
    this.scale = scale;
    const imageURL = await this.snapshot.replay(scale);
    const image = imageURL ? await UI.UIUtils.loadImage(imageURL) : null;
    this.texture = image ? LayerTextureManager._createTextureForImage(glContext, image) : null;
  }
}
