// Copyright 2016 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 Platform from '../../../../core/platform/platform.js';
import * as RenderCoordinator from '../../../components/render_coordinator/render_coordinator.js';
import * as UI from '../../legacy.js';

import chartViewPortStyles from './chartViewport.css.js';
import {MinimalTimeWindowMs} from './FlameChart.js';

export interface ChartViewportDelegate {
  windowChanged(startTime: number, endTime: number, animate: boolean): void;
  updateRangeSelection(startTime: number, endTime: number): void;
  setSize(width: number, height: number): void;
  update(): void;
}

export interface Config {
  /**
   * Configures if the Chart should show a vertical line at the position of the
   * mouse cursor when the user holds the `Shift` key.
   * The reason this is configurable is because within the Performance Panel
   * we use our own overlays system for UI like this, so we do not need the
   * ChartViewport to manage it.
   */
  enableCursorElement: boolean;
}

export class ChartViewport extends UI.Widget.VBox {
  private readonly delegate: ChartViewportDelegate;
  viewportElement: HTMLElement;
  #alwaysShowVerticalScroll: boolean;
  private rangeSelectionEnabled: boolean;
  private vScrollElement: HTMLElement;
  private vScrollContent: HTMLElement;
  private readonly selectionOverlay: HTMLElement;
  private cursorElement: HTMLElement;
  #isDragging!: boolean;
  private totalHeight!: number;
  private offsetHeight!: number;
  private scrollTop!: number;
  private rangeSelectionStart: number|null;
  private rangeSelectionEnd: number|null;
  private dragStartPointX!: number;
  private dragStartPointY!: number;
  private dragStartScrollTop!: number;
  private visibleLeftTime!: number;
  private visibleRightTime!: number;
  private offsetWidth!: number;
  private targetLeftTime!: number;
  private targetRightTime!: number;
  private selectionOffsetShiftX!: number;
  private selectionStartX!: number|null;
  private lastMouseOffsetX?: number;
  private minimumBoundary!: number;
  private totalTime!: number;
  private isUpdateScheduled?: boolean;
  private cancelWindowTimesAnimation?: (() => void)|null;

  #config: Config;

  constructor(delegate: ChartViewportDelegate, config: Config) {
    super();
    this.#config = config;
    this.registerRequiredCSS(chartViewPortStyles);

    this.delegate = delegate;

    this.viewportElement = this.contentElement.createChild('div', 'fill');
    this.viewportElement.addEventListener('mousemove', this.updateCursorPosition.bind(this), false);
    this.viewportElement.addEventListener('mouseout', this.onMouseOut.bind(this), false);
    this.viewportElement.addEventListener('wheel', this.onMouseWheel.bind(this), false);
    this.viewportElement.addEventListener('keydown', this.onChartKeyDown.bind(this), false);
    this.viewportElement.addEventListener('keyup', this.onChartKeyUp.bind(this), false);

    UI.UIUtils.installDragHandle(
        this.viewportElement, this.startDragging.bind(this), this.dragging.bind(this), this.endDragging.bind(this),
        '-webkit-grabbing', null);
    UI.UIUtils.installDragHandle(
        this.viewportElement, this.startRangeSelection.bind(this), this.rangeSelectionDragging.bind(this),
        this.endRangeSelection.bind(this), 'text', null);

    this.#alwaysShowVerticalScroll = false;
    this.rangeSelectionEnabled = true;
    this.vScrollElement = this.contentElement.createChild('div', 'chart-viewport-v-scroll');
    this.vScrollContent = this.vScrollElement.createChild('div');
    this.vScrollElement.addEventListener('scroll', this.onScroll.bind(this), false);

    this.selectionOverlay = this.contentElement.createChild('div', 'chart-viewport-selection-overlay hidden');

    this.cursorElement = this.contentElement.createChild('div', 'chart-cursor-element hidden');

    this.reset();
    this.rangeSelectionStart = null;
    this.rangeSelectionEnd = null;
  }

  alwaysShowVerticalScroll(): void {
    this.#alwaysShowVerticalScroll = true;
    this.vScrollElement.classList.add('always-show-scrollbar');
  }

  disableRangeSelection(): void {
    this.rangeSelectionEnabled = false;
    this.rangeSelectionStart = null;
    this.rangeSelectionEnd = null;
  }

  isDragging(): boolean {
    return this.#isDragging;
  }

  override elementsToRestoreScrollPositionsFor(): Element[] {
    return [this.vScrollElement];
  }

  verticalScrollBarVisible(): boolean {
    return !this.vScrollElement.classList.contains('hidden');
  }

  private updateScrollBar(): void {
    const showScroll = this.#alwaysShowVerticalScroll || this.totalHeight > this.offsetHeight;
    if (this.vScrollElement.classList.contains('hidden') !== showScroll) {
      return;
    }
    this.vScrollElement.classList.toggle('hidden', !showScroll);
    this.updateContentElementSize();
  }

  override onResize(): void {
    this.updateScrollBar();
    this.updateContentElementSize();
    this.scheduleUpdate();
  }

  reset(): void {
    this.vScrollElement.scrollTop = 0;
    this.scrollTop = 0;
    this.rangeSelectionStart = null;
    this.rangeSelectionEnd = null;
    this.#isDragging = false;
    this.dragStartPointX = 0;
    this.dragStartPointY = 0;
    this.dragStartScrollTop = 0;
    this.visibleLeftTime = 0;
    this.visibleRightTime = 0;
    this.offsetWidth = 0;
    this.offsetHeight = 0;
    this.totalHeight = 0;
    this.targetLeftTime = 0;
    this.targetRightTime = 0;
    this.isUpdateScheduled = false;
    this.updateContentElementSize();
  }

  private updateContentElementSize(): void {
    let offsetWidth: number = this.vScrollElement.offsetLeft;
    if (!offsetWidth) {
      offsetWidth = this.contentElement.offsetWidth;
    }
    this.offsetWidth = offsetWidth;
    this.offsetHeight = this.contentElement.offsetHeight;
    this.delegate.setSize(this.offsetWidth, this.offsetHeight);
  }

  setContentHeight(totalHeight: number): void {
    this.totalHeight = totalHeight;
    this.vScrollContent.style.height = totalHeight + 'px';
    this.updateScrollBar();
    this.updateContentElementSize();
    if (this.scrollTop + this.offsetHeight <= totalHeight) {
      return;
    }
    this.scrollTop = Math.max(0, totalHeight - this.offsetHeight);
    this.vScrollElement.scrollTop = this.scrollTop;
  }

  /**
   * @param centered If true, scrolls offset to where it is centered on the chart,
   * based on current the this.offsetHeight value.
   */
  setScrollOffset(offset: number, height?: number, centered?: boolean): void {
    height = height || 0;
    if (centered) {
      // Half of the height for padding.
      const halfPadding = Math.floor(this.offsetHeight / 2);

      if (this.vScrollElement.scrollTop > offset) {
        // Need to scroll up, include height.
        this.vScrollElement.scrollTop = offset - (height + halfPadding);
      }
    } else if (this.vScrollElement.scrollTop > offset) {
      this.vScrollElement.scrollTop = offset;
    }

    if (this.vScrollElement.scrollTop < offset - this.offsetHeight + height) {
      this.vScrollElement.scrollTop = offset - this.offsetHeight + height;
    }
  }

  scrollOffset(): number {
    // Return the cached value, rather than the live value (which typically incurs a forced reflow)
    // In practice, this is true whenever scrollOffset() is called:  `this.scrollTop === this.vScrollElement.scrollTop`
    return this.scrollTop;
  }

  chartHeight(): number {
    return this.offsetHeight;
  }

  setBoundaries(zeroTime: number, totalTime: number): void {
    this.minimumBoundary = zeroTime;
    this.totalTime = totalTime;
  }

  /**
   * The mouse wheel can results in flamechart zoom, scroll and pan actions, depending on the scroll deltas and the selected navigation:
   *
   * Classic navigation:
   * 1. Mouse Wheel --> Zoom
   * 2. Mouse Wheel + Shift --> Scroll
   * 3. Trackpad: Mouse Wheel AND horizontal scroll (deltaX > deltaY): --> Pan left/right
   *
   * Modern navigation:
   * 1. Mouse Wheel -> Scroll
   * 2. Mouse Wheel + Shift -> Pan left/right
   * 3. Mouse Wheel + Ctrl/Cmd -> Zoom
   * 4. Trackpad: Mouse Wheel AND horizontal scroll (deltaX > deltaY): --> Zoom
   */
  private onMouseWheel(wheelEvent: WheelEvent): void {
    const navigation = Common.Settings.Settings.instance().moduleSetting('flamechart-selected-navigation').get();
    // Delta for navigation left, right, up and down.
    // Calculated from horizontal or vertical scroll delta, depending on which one exists.
    const panDelta = (wheelEvent.deltaY || wheelEvent.deltaX) / 53 * this.offsetHeight / 8;
    const zoomDelta = Math.pow(1.2, (wheelEvent.deltaY || wheelEvent.deltaX) * 1 / 53) - 1;

    if (navigation === 'classic') {
      if (wheelEvent.shiftKey) {  // Scroll
        this.vScrollElement.scrollTop += panDelta;
      } else if (
          Math.abs(wheelEvent.deltaX) > Math.abs(wheelEvent.deltaY)) {  // Pan left/right on trackpad horizontal scroll
        // Horizontal scroll on the trackpad feels smoother when only deltaX is taken into account
        this.handleHorizontalPanGesture(wheelEvent.deltaX, /* animate */ true);
      } else {  // Zoom
        this.handleZoomGesture(zoomDelta);
      }
    } else if (navigation === 'modern') {
      const isCtrlOrCmd = UI.KeyboardShortcut.KeyboardShortcut.eventHasCtrlEquivalentKey(wheelEvent);
      if (wheelEvent.shiftKey) {  // Pan left/right
        this.handleHorizontalPanGesture(panDelta, /* animate */ true);
      } else if (
          Math.abs(wheelEvent.deltaX) > Math.abs(wheelEvent.deltaY)) {  // Pan left/right on trackpad horizontal scroll
        // Horizontal scroll on the trackpad feels smoother when only deltaX is taken into account
        this.handleHorizontalPanGesture(wheelEvent.deltaX, /* animate */ true);
      } else if (isCtrlOrCmd) {  // Zoom
        this.handleZoomGesture(zoomDelta);
      } else {  // Scroll
        this.vScrollElement.scrollTop += panDelta;
      }
    }

    // Block swipe gesture.
    wheelEvent.consume(true);
  }

  private startDragging(event: MouseEvent): boolean {
    if (event.shiftKey) {
      return false;
    }
    this.#isDragging = true;
    this.dragStartPointX = event.pageX;
    this.dragStartPointY = event.pageY;
    this.dragStartScrollTop = this.vScrollElement.scrollTop;
    this.viewportElement.style.cursor = '';
    return true;
  }

  private dragging(event: MouseEvent): void {
    const pixelShift = this.dragStartPointX - event.pageX;
    this.dragStartPointX = event.pageX;
    this.handleHorizontalPanGesture(pixelShift);
    const pixelScroll = this.dragStartPointY - event.pageY;
    this.vScrollElement.scrollTop = this.dragStartScrollTop + pixelScroll;
  }

  private endDragging(): void {
    this.#isDragging = false;
  }

  private startRangeSelection(event: MouseEvent): boolean {
    if (!event.shiftKey || !this.rangeSelectionEnabled) {
      return false;
    }
    this.#isDragging = true;
    this.selectionOffsetShiftX = event.offsetX - event.pageX;
    this.selectionStartX = event.offsetX;
    return true;
  }

  private endRangeSelection(): void {
    this.#isDragging = false;
    this.selectionStartX = null;
  }

  hideRangeSelection(): void {
    this.selectionOverlay.classList.add('hidden');
    this.rangeSelectionStart = null;
    this.rangeSelectionEnd = null;
  }

  /**
   * @param startTime the start time of the selection in MilliSeconds
   * @param endTime the end time of the selection in MilliSeconds
   * TODO(crbug.com/346312365): update the type definitions in ChartViewport.ts
   */
  setRangeSelection(startTime: number, endTime: number): void {
    if (!this.rangeSelectionEnabled) {
      return;
    }
    this.rangeSelectionStart = Math.min(startTime, endTime);
    this.rangeSelectionEnd = Math.max(startTime, endTime);
    this.delegate.updateRangeSelection(this.rangeSelectionStart, this.rangeSelectionEnd);
  }

  onClick(event: MouseEvent): void {
    const mouseEvent = event;
    const time = this.pixelToTime(mouseEvent.offsetX);
    if (this.rangeSelectionStart !== null && this.rangeSelectionEnd !== null && time >= this.rangeSelectionStart &&
        time <= this.rangeSelectionEnd) {
      return;
    }
    this.hideRangeSelection();
  }

  private rangeSelectionDragging(event: MouseEvent): void {
    const x = Platform.NumberUtilities.clamp(event.pageX + this.selectionOffsetShiftX, 0, this.offsetWidth);
    const start = this.pixelToTime(this.selectionStartX || 0);
    const end = this.pixelToTime(x);
    this.setRangeSelection(start, end);
  }

  private onScroll(): void {
    this.scrollTop = this.vScrollElement.scrollTop;
    this.scheduleUpdate();
  }

  private onMouseOut(): void {
    this.lastMouseOffsetX = -1;
    this.showCursor(false);
  }

  private updateCursorPosition(e: Event): void {
    const mouseEvent = (e as MouseEvent);
    this.lastMouseOffsetX = mouseEvent.offsetX;
    const shouldShowCursor = this.#config.enableCursorElement && mouseEvent.shiftKey && !mouseEvent.metaKey;
    this.showCursor(shouldShowCursor);
    if (shouldShowCursor) {
      this.cursorElement.style.left = mouseEvent.offsetX + 'px';
    }
  }

  pixelToTime(x: number): number {
    return this.pixelToTimeOffset(x) + this.visibleLeftTime;
  }

  pixelToTimeOffset(x: number): number {
    return x * (this.visibleRightTime - this.visibleLeftTime) / this.offsetWidth;
  }

  timeToPosition(time: number): number {
    return Math.floor(
        (time - this.visibleLeftTime) / (this.visibleRightTime - this.visibleLeftTime) * this.offsetWidth);
  }

  timeToPixel(): number {
    return this.offsetWidth / (this.visibleRightTime - this.visibleLeftTime);
  }

  private showCursor(visible: boolean): void {
    this.cursorElement.classList.toggle('hidden', !visible || this.#isDragging);
  }

  private onChartKeyDown(keyboardEvent: KeyboardEvent): void {
    this.showCursor(keyboardEvent.shiftKey);
    this.handleZoomPanScrollKeys(keyboardEvent);
  }

  private onChartKeyUp(keyboardEvent: KeyboardEvent): void {
    this.showCursor(keyboardEvent.shiftKey);
  }

  private handleZoomPanScrollKeys(keyboardEvent: KeyboardEvent): void {
    // Do not zoom, pan or scroll if the key combination has any modifiers other than shift key
    if (UI.KeyboardShortcut.KeyboardShortcut.hasAtLeastOneModifier(keyboardEvent) && !keyboardEvent.shiftKey) {
      return;
    }
    const zoomFactor = keyboardEvent.shiftKey ? 0.8 : 0.3;
    const panOffset = 160;
    const scrollOffset = 50;
    switch (keyboardEvent.code) {
      case 'KeyA':
        this.handleHorizontalPanGesture(-panOffset, /* animate */ true);
        break;
      case 'KeyD':
        this.handleHorizontalPanGesture(panOffset, /* animate */ true);
        break;
      case 'Equal':  // '+' key for zoom in
      case 'KeyW':
        this.handleZoomGesture(-zoomFactor);
        break;
      case 'Minus':  // '-' key for zoom out
      case 'KeyS':
        this.handleZoomGesture(zoomFactor);
        break;
      case 'ArrowUp':
        if (keyboardEvent.shiftKey) {
          this.vScrollElement.scrollTop -= scrollOffset;
        }
        break;
      case 'ArrowDown':
        if (keyboardEvent.shiftKey) {
          this.vScrollElement.scrollTop += scrollOffset;
        }
        break;
      case 'ArrowLeft':
        if (keyboardEvent.shiftKey) {
          this.handleHorizontalPanGesture(-panOffset, /* animate */ true);
        }
        break;
      case 'ArrowRight':
        if (keyboardEvent.shiftKey) {
          this.handleHorizontalPanGesture(panOffset, /* animate */ true);
        }
        break;
      default:
        return;
    }
    keyboardEvent.consume(true);
  }

  private handleZoomGesture(zoom: number): void {
    const bounds = {left: this.targetLeftTime, right: this.targetRightTime};
    // If the user has not moved their mouse over the panel (unlikely but
    // possible!), the offsetX will be undefined. In that case, let's just use
    // the minimum time / pixel 0 as their mouse point.
    const cursorTime = this.pixelToTime(this.lastMouseOffsetX || 0);
    bounds.left += (bounds.left - cursorTime) * zoom;
    bounds.right += (bounds.right - cursorTime) * zoom;
    this.requestWindowTimes(bounds, /* animate */ true);
  }

  private handleHorizontalPanGesture(offset: number, animate?: boolean): void {
    const bounds = {left: this.targetLeftTime, right: this.targetRightTime};
    const timeOffset = Platform.NumberUtilities.clamp(
        this.pixelToTimeOffset(offset), this.minimumBoundary - bounds.left,
        this.totalTime + this.minimumBoundary - bounds.right);
    bounds.left += timeOffset;
    bounds.right += timeOffset;
    this.requestWindowTimes(bounds, Boolean(animate));
  }

  private requestWindowTimes(
      bounds: {
        left: number,
        right: number,
      },
      animate: boolean): void {
    const maxBound = this.minimumBoundary + this.totalTime;
    if (bounds.left < this.minimumBoundary) {
      bounds.right = Math.min(bounds.right + this.minimumBoundary - bounds.left, maxBound);
      bounds.left = this.minimumBoundary;
    } else if (bounds.right > maxBound) {
      bounds.left = Math.max(bounds.left - bounds.right + maxBound, this.minimumBoundary);
      bounds.right = maxBound;
    }
    if (bounds.right - bounds.left < MinimalTimeWindowMs) {
      return;
    }
    this.delegate.windowChanged(bounds.left, bounds.right, animate);
  }

  scheduleUpdate(): void {
    if (this.cancelWindowTimesAnimation || this.isUpdateScheduled) {
      return;
    }
    this.isUpdateScheduled = true;
    void RenderCoordinator.write(() => {
      this.isUpdateScheduled = false;
      this.update();
    });
  }

  update(): void {
    this.delegate.update();
  }

  override willHide(): void {
    super.willHide();
    // Stop animations when the view is hidden (or destroyed).
    // In this case, we also jump the time immediately to the target time, so
    // that if the view is restored, the time shown is correct.
    if (this.cancelWindowTimesAnimation) {
      this.cancelWindowTimesAnimation();
      this.setWindowTimes(this.targetLeftTime, this.targetRightTime, false);
    }
  }

  setWindowTimes(startTime: number, endTime: number, animate?: boolean): void {
    if (startTime === this.targetLeftTime && endTime === this.targetRightTime) {
      return;
    }
    if (!animate || this.visibleLeftTime === 0 || this.visibleRightTime === Infinity ||
        (startTime === 0 && endTime === Infinity) || (startTime === Infinity && endTime === Infinity)) {
      // Skip animation, move instantly.
      this.targetLeftTime = startTime;
      this.targetRightTime = endTime;
      this.visibleLeftTime = startTime;
      this.visibleRightTime = endTime;
      this.scheduleUpdate();
      return;
    }
    if (this.cancelWindowTimesAnimation) {
      this.cancelWindowTimesAnimation();
      this.visibleLeftTime = this.targetLeftTime;
      this.visibleRightTime = this.targetRightTime;
    }
    this.targetLeftTime = startTime;
    this.targetRightTime = endTime;
    this.cancelWindowTimesAnimation = UI.UIUtils.animateFunction(
        this.element.window(), animateWindowTimes.bind(this),
        [{from: this.visibleLeftTime, to: startTime}, {from: this.visibleRightTime, to: endTime}], 100, () => {
          this.cancelWindowTimesAnimation = null;
        });

    function animateWindowTimes(this: ChartViewport, startTime: number, endTime: number): void {
      // We cancel the animation in the willHide method, but as an extra check
      // bail here if we are hidden rather than queue an update.
      if (!this.isShowing()) {
        return;
      }
      this.visibleLeftTime = startTime;
      this.visibleRightTime = endTime;
      this.update();
    }
  }

  windowLeftTime(): number {
    return this.visibleLeftTime;
  }

  windowRightTime(): number {
    return this.visibleRightTime;
  }
}
