// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { IDisposable } from '@lumino/disposable';

import { ElementExt } from '@lumino/domutils';

import { Drag } from '@lumino/dragdrop';

import { Message } from '@lumino/messaging';

import { ISignal, Signal } from '@lumino/signaling';

import { Widget } from './widget';

/**
 * A widget which implements a canonical scroll bar.
 */
export class ScrollBar extends Widget {
  /**
   * Construct a new scroll bar.
   *
   * @param options - The options for initializing the scroll bar.
   */
  constructor(options: ScrollBar.IOptions = {}) {
    super({ node: Private.createNode() });
    this.addClass('lm-ScrollBar');
    this.setFlag(Widget.Flag.DisallowLayout);

    // Set the orientation.
    this._orientation = options.orientation || 'vertical';
    this.dataset['orientation'] = this._orientation;

    // Parse the rest of the options.
    if (options.maximum !== undefined) {
      this._maximum = Math.max(0, options.maximum);
    }
    if (options.page !== undefined) {
      this._page = Math.max(0, options.page);
    }
    if (options.value !== undefined) {
      this._value = Math.max(0, Math.min(options.value, this._maximum));
    }
  }

  /**
   * A signal emitted when the user moves the scroll thumb.
   *
   * #### Notes
   * The payload is the current value of the scroll bar.
   */
  get thumbMoved(): ISignal<this, number> {
    return this._thumbMoved;
  }

  /**
   * A signal emitted when the user clicks a step button.
   *
   * #### Notes
   * The payload is whether a decrease or increase is requested.
   */
  get stepRequested(): ISignal<this, 'decrement' | 'increment'> {
    return this._stepRequested;
  }

  /**
   * A signal emitted when the user clicks the scroll track.
   *
   * #### Notes
   * The payload is whether a decrease or increase is requested.
   */
  get pageRequested(): ISignal<this, 'decrement' | 'increment'> {
    return this._pageRequested;
  }

  /**
   * Get the orientation of the scroll bar.
   */
  get orientation(): ScrollBar.Orientation {
    return this._orientation;
  }

  /**
   * Set the orientation of the scroll bar.
   */
  set orientation(value: ScrollBar.Orientation) {
    // Do nothing if the orientation does not change.
    if (this._orientation === value) {
      return;
    }

    // Release the mouse before making changes.
    this._releaseMouse();

    // Update the internal orientation.
    this._orientation = value;
    this.dataset['orientation'] = value;

    // Schedule an update the scroll bar.
    this.update();
  }

  /**
   * Get the current value of the scroll bar.
   */
  get value(): number {
    return this._value;
  }

  /**
   * Set the current value of the scroll bar.
   *
   * #### Notes
   * The value will be clamped to the range `[0, maximum]`.
   */
  set value(value: number) {
    // Clamp the value to the allowable range.
    value = Math.max(0, Math.min(value, this._maximum));

    // Do nothing if the value does not change.
    if (this._value === value) {
      return;
    }

    // Update the internal value.
    this._value = value;

    // Schedule an update the scroll bar.
    this.update();
  }

  /**
   * Get the page size of the scroll bar.
   *
   * #### Notes
   * The page size is the amount of visible content in the scrolled
   * region, expressed in data units. It determines the size of the
   * scroll bar thumb.
   */
  get page(): number {
    return this._page;
  }

  /**
   * Set the page size of the scroll bar.
   *
   * #### Notes
   * The page size will be clamped to the range `[0, Infinity]`.
   */
  set page(value: number) {
    // Clamp the page size to the allowable range.
    value = Math.max(0, value);

    // Do nothing if the value does not change.
    if (this._page === value) {
      return;
    }

    // Update the internal page size.
    this._page = value;

    // Schedule an update the scroll bar.
    this.update();
  }

  /**
   * Get the maximum value of the scroll bar.
   */
  get maximum(): number {
    return this._maximum;
  }

  /**
   * Set the maximum value of the scroll bar.
   *
   * #### Notes
   * The max size will be clamped to the range `[0, Infinity]`.
   */
  set maximum(value: number) {
    // Clamp the value to the allowable range.
    value = Math.max(0, value);

    // Do nothing if the value does not change.
    if (this._maximum === value) {
      return;
    }

    // Update the internal values.
    this._maximum = value;

    // Clamp the current value to the new range.
    this._value = Math.min(this._value, value);

    // Schedule an update the scroll bar.
    this.update();
  }

  /**
   * The scroll bar decrement button node.
   *
   * #### Notes
   * Modifying this node directly can lead to undefined behavior.
   */
  get decrementNode(): HTMLDivElement {
    return this.node.getElementsByClassName(
      'lm-ScrollBar-button'
    )[0] as HTMLDivElement;
  }

  /**
   * The scroll bar increment button node.
   *
   * #### Notes
   * Modifying this node directly can lead to undefined behavior.
   */
  get incrementNode(): HTMLDivElement {
    return this.node.getElementsByClassName(
      'lm-ScrollBar-button'
    )[1] as HTMLDivElement;
  }

  /**
   * The scroll bar track node.
   *
   * #### Notes
   * Modifying this node directly can lead to undefined behavior.
   */
  get trackNode(): HTMLDivElement {
    return this.node.getElementsByClassName(
      'lm-ScrollBar-track'
    )[0] as HTMLDivElement;
  }

  /**
   * The scroll bar thumb node.
   *
   * #### Notes
   * Modifying this node directly can lead to undefined behavior.
   */
  get thumbNode(): HTMLDivElement {
    return this.node.getElementsByClassName(
      'lm-ScrollBar-thumb'
    )[0] as HTMLDivElement;
  }

  /**
   * Handle the DOM events for the scroll bar.
   *
   * @param event - The DOM event sent to the scroll bar.
   *
   * #### Notes
   * This method implements the DOM `EventListener` interface and is
   * called in response to events on the scroll bar's DOM node.
   *
   * This should not be called directly by user code.
   */
  handleEvent(event: Event): void {
    switch (event.type) {
      case 'mousedown':
        this._evtMouseDown(event as MouseEvent);
        break;
      case 'mousemove':
        this._evtMouseMove(event as MouseEvent);
        break;
      case 'mouseup':
        this._evtMouseUp(event as MouseEvent);
        break;
      case 'keydown':
        this._evtKeyDown(event as KeyboardEvent);
        break;
      case 'contextmenu':
        event.preventDefault();
        event.stopPropagation();
        break;
    }
  }

  /**
   * A method invoked on a 'before-attach' message.
   */
  protected onBeforeAttach(msg: Message): void {
    this.node.addEventListener('mousedown', this);
    this.update();
  }

  /**
   * A method invoked on an 'after-detach' message.
   */
  protected onAfterDetach(msg: Message): void {
    this.node.removeEventListener('mousedown', this);
    this._releaseMouse();
  }

  /**
   * A method invoked on an 'update-request' message.
   */
  protected onUpdateRequest(msg: Message): void {
    // Convert the value and page into percentages.
    let value = (this._value * 100) / this._maximum;
    let page = (this._page * 100) / (this._page + this._maximum);

    // Clamp the value and page to the relevant range.
    value = Math.max(0, Math.min(value, 100));
    page = Math.max(0, Math.min(page, 100));

    // Fetch the thumb style.
    let thumbStyle = this.thumbNode.style;

    // Update the thumb style for the current orientation.
    if (this._orientation === 'horizontal') {
      thumbStyle.top = '';
      thumbStyle.height = '';
      thumbStyle.left = `${value}%`;
      thumbStyle.width = `${page}%`;
      thumbStyle.transform = `translate(${-value}%, 0%)`;
    } else {
      thumbStyle.left = '';
      thumbStyle.width = '';
      thumbStyle.top = `${value}%`;
      thumbStyle.height = `${page}%`;
      thumbStyle.transform = `translate(0%, ${-value}%)`;
    }
  }

  /**
   * Handle the `'keydown'` event for the scroll bar.
   */
  private _evtKeyDown(event: KeyboardEvent): void {
    // Stop all input events during drag.
    event.preventDefault();
    event.stopPropagation();

    // Ignore anything except the `Escape` key.
    if (event.keyCode !== 27) {
      return;
    }

    // Fetch the previous scroll value.
    let value = this._pressData ? this._pressData.value : -1;

    // Release the mouse.
    this._releaseMouse();

    // Restore the old scroll value if possible.
    if (value !== -1) {
      this._moveThumb(value);
    }
  }

  /**
   * Handle the `'mousedown'` event for the scroll bar.
   */
  private _evtMouseDown(event: MouseEvent): void {
    // Do nothing if it's not a left mouse press.
    if (event.button !== 0) {
      return;
    }

    // Send an activate request to the scroll bar. This can be
    // used by message hooks to activate something relevant.
    this.activate();

    // Do nothing if the mouse is already captured.
    if (this._pressData) {
      return;
    }

    // Find the pressed scroll bar part.
    let part = Private.findPart(this, event.target as HTMLElement);

    // Do nothing if the part is not of interest.
    if (!part) {
      return;
    }

    // Stop the event propagation.
    event.preventDefault();
    event.stopPropagation();

    // Override the mouse cursor.
    let override = Drag.overrideCursor('default');

    // Set up the press data.
    this._pressData = {
      part,
      override,
      delta: -1,
      value: -1,
      mouseX: event.clientX,
      mouseY: event.clientY
    };

    // Add the extra event listeners.
    document.addEventListener('mousemove', this, true);
    document.addEventListener('mouseup', this, true);
    document.addEventListener('keydown', this, true);
    document.addEventListener('contextmenu', this, true);

    // Handle a thumb press.
    if (part === 'thumb') {
      // Fetch the thumb node.
      let thumbNode = this.thumbNode;

      // Fetch the client rect for the thumb.
      let thumbRect = thumbNode.getBoundingClientRect();

      // Update the press data delta for the current orientation.
      if (this._orientation === 'horizontal') {
        this._pressData.delta = event.clientX - thumbRect.left;
      } else {
        this._pressData.delta = event.clientY - thumbRect.top;
      }

      // Add the active class to the thumb node.
      thumbNode.classList.add('lm-mod-active');

      // Store the current value in the press data.
      this._pressData.value = this._value;

      // Finished.
      return;
    }

    // Handle a track press.
    if (part === 'track') {
      // Fetch the client rect for the thumb.
      let thumbRect = this.thumbNode.getBoundingClientRect();

      // Determine the direction for the page request.
      let dir: 'decrement' | 'increment';
      if (this._orientation === 'horizontal') {
        dir = event.clientX < thumbRect.left ? 'decrement' : 'increment';
      } else {
        dir = event.clientY < thumbRect.top ? 'decrement' : 'increment';
      }

      // Start the repeat timer.
      this._repeatTimer = window.setTimeout(this._onRepeat, 350);

      // Emit the page requested signal.
      this._pageRequested.emit(dir);

      // Finished.
      return;
    }

    // Handle a decrement button press.
    if (part === 'decrement') {
      // Add the active class to the decrement node.
      this.decrementNode.classList.add('lm-mod-active');

      // Start the repeat timer.
      this._repeatTimer = window.setTimeout(this._onRepeat, 350);

      // Emit the step requested signal.
      this._stepRequested.emit('decrement');

      // Finished.
      return;
    }

    // Handle an increment button press.
    if (part === 'increment') {
      // Add the active class to the increment node.
      this.incrementNode.classList.add('lm-mod-active');

      // Start the repeat timer.
      this._repeatTimer = window.setTimeout(this._onRepeat, 350);

      // Emit the step requested signal.
      this._stepRequested.emit('increment');

      // Finished.
      return;
    }
  }

  /**
   * Handle the `'mousemove'` event for the scroll bar.
   */
  private _evtMouseMove(event: MouseEvent): void {
    // Do nothing if no drag is in progress.
    if (!this._pressData) {
      return;
    }

    // Stop the event propagation.
    event.preventDefault();
    event.stopPropagation();

    // Update the mouse position.
    this._pressData.mouseX = event.clientX;
    this._pressData.mouseY = event.clientY;

    // Bail if the thumb is not being dragged.
    if (this._pressData.part !== 'thumb') {
      return;
    }

    // Get the client rect for the thumb and track.
    let thumbRect = this.thumbNode.getBoundingClientRect();
    let trackRect = this.trackNode.getBoundingClientRect();

    // Fetch the scroll geometry based on the orientation.
    let trackPos: number;
    let trackSpan: number;
    if (this._orientation === 'horizontal') {
      trackPos = event.clientX - trackRect.left - this._pressData.delta;
      trackSpan = trackRect.width - thumbRect.width;
    } else {
      trackPos = event.clientY - trackRect.top - this._pressData.delta;
      trackSpan = trackRect.height - thumbRect.height;
    }

    // Compute the desired value from the scroll geometry.
    let value = trackSpan === 0 ? 0 : (trackPos * this._maximum) / trackSpan;

    // Move the thumb to the computed value.
    this._moveThumb(value);
  }

  /**
   * Handle the `'mouseup'` event for the scroll bar.
   */
  private _evtMouseUp(event: MouseEvent): void {
    // Do nothing if it's not a left mouse release.
    if (event.button !== 0) {
      return;
    }

    // Stop the event propagation.
    event.preventDefault();
    event.stopPropagation();

    // Release the mouse.
    this._releaseMouse();
  }

  /**
   * Release the mouse and restore the node states.
   */
  private _releaseMouse(): void {
    // Bail if there is no press data.
    if (!this._pressData) {
      return;
    }

    // Clear the repeat timer.
    clearTimeout(this._repeatTimer);
    this._repeatTimer = -1;

    // Clear the press data.
    this._pressData.override.dispose();
    this._pressData = null;

    // Remove the extra event listeners.
    document.removeEventListener('mousemove', this, true);
    document.removeEventListener('mouseup', this, true);
    document.removeEventListener('keydown', this, true);
    document.removeEventListener('contextmenu', this, true);

    // Remove the active classes from the nodes.
    this.thumbNode.classList.remove('lm-mod-active');
    this.decrementNode.classList.remove('lm-mod-active');
    this.incrementNode.classList.remove('lm-mod-active');
  }

  /**
   * Move the thumb to the specified position.
   */
  private _moveThumb(value: number): void {
    // Clamp the value to the allowed range.
    value = Math.max(0, Math.min(value, this._maximum));

    // Bail if the value does not change.
    if (this._value === value) {
      return;
    }

    // Update the internal value.
    this._value = value;

    // Schedule an update of the scroll bar.
    this.update();

    // Emit the thumb moved signal.
    this._thumbMoved.emit(value);
  }

  /**
   * A timeout callback for repeating the mouse press.
   */
  private _onRepeat = () => {
    // Clear the repeat timer id.
    this._repeatTimer = -1;

    // Bail if the mouse has been released.
    if (!this._pressData) {
      return;
    }

    // Look up the part that was pressed.
    let part = this._pressData.part;

    // Bail if the thumb was pressed.
    if (part === 'thumb') {
      return;
    }

    // Schedule the timer for another repeat.
    this._repeatTimer = window.setTimeout(this._onRepeat, 20);

    // Get the current mouse position.
    let mouseX = this._pressData.mouseX;
    let mouseY = this._pressData.mouseY;

    // Handle a decrement button repeat.
    if (part === 'decrement') {
      // Bail if the mouse is not over the button.
      if (!ElementExt.hitTest(this.decrementNode, mouseX, mouseY)) {
        return;
      }

      // Emit the step requested signal.
      this._stepRequested.emit('decrement');

      // Finished.
      return;
    }

    // Handle an increment button repeat.
    if (part === 'increment') {
      // Bail if the mouse is not over the button.
      if (!ElementExt.hitTest(this.incrementNode, mouseX, mouseY)) {
        return;
      }

      // Emit the step requested signal.
      this._stepRequested.emit('increment');

      // Finished.
      return;
    }

    // Handle a track repeat.
    if (part === 'track') {
      // Bail if the mouse is not over the track.
      if (!ElementExt.hitTest(this.trackNode, mouseX, mouseY)) {
        return;
      }

      // Fetch the thumb node.
      let thumbNode = this.thumbNode;

      // Bail if the mouse is over the thumb.
      if (ElementExt.hitTest(thumbNode, mouseX, mouseY)) {
        return;
      }

      // Fetch the client rect for the thumb.
      let thumbRect = thumbNode.getBoundingClientRect();

      // Determine the direction for the page request.
      let dir: 'decrement' | 'increment';
      if (this._orientation === 'horizontal') {
        dir = mouseX < thumbRect.left ? 'decrement' : 'increment';
      } else {
        dir = mouseY < thumbRect.top ? 'decrement' : 'increment';
      }

      // Emit the page requested signal.
      this._pageRequested.emit(dir);

      // Finished.
      return;
    }
  };

  private _value = 0;
  private _page = 10;
  private _maximum = 100;
  private _repeatTimer = -1;
  private _orientation: ScrollBar.Orientation;
  private _pressData: Private.IPressData | null = null;
  private _thumbMoved = new Signal<this, number>(this);
  private _stepRequested = new Signal<this, 'decrement' | 'increment'>(this);
  private _pageRequested = new Signal<this, 'decrement' | 'increment'>(this);
}

/**
 * The namespace for the `ScrollBar` class statics.
 */
export namespace ScrollBar {
  /**
   * A type alias for a scroll bar orientation.
   */
  export type Orientation = 'horizontal' | 'vertical';

  /**
   * An options object for creating a scroll bar.
   */
  export interface IOptions {
    /**
     * The orientation of the scroll bar.
     *
     * The default is `'vertical'`.
     */
    orientation?: Orientation;

    /**
     * The value for the scroll bar.
     *
     * The default is `0`.
     */
    value?: number;

    /**
     * The page size for the scroll bar.
     *
     * The default is `10`.
     */
    page?: number;

    /**
     * The maximum value for the scroll bar.
     *
     * The default is `100`.
     */
    maximum?: number;
  }
}

/**
 * The namespace for the module implementation details.
 */
namespace Private {
  /**
   * A type alias for the parts of a scroll bar.
   */
  export type ScrollBarPart = 'thumb' | 'track' | 'decrement' | 'increment';

  /**
   * An object which holds mouse press data.
   */
  export interface IPressData {
    /**
     * The scroll bar part which was pressed.
     */
    part: ScrollBarPart;

    /**
     * The offset of the press in thumb coordinates, or -1.
     */
    delta: number;

    /**
     * The scroll value at the time the thumb was pressed, or -1.
     */
    value: number;

    /**
     * The disposable which will clear the override cursor.
     */
    override: IDisposable;

    /**
     * The current X position of the mouse.
     */
    mouseX: number;

    /**
     * The current Y position of the mouse.
     */
    mouseY: number;
  }

  /**
   * Create the DOM node for a scroll bar.
   */
  export function createNode(): HTMLElement {
    let node = document.createElement('div');
    let decrement = document.createElement('div');
    let increment = document.createElement('div');
    let track = document.createElement('div');
    let thumb = document.createElement('div');
    decrement.className = 'lm-ScrollBar-button';
    increment.className = 'lm-ScrollBar-button';
    decrement.dataset['action'] = 'decrement';
    increment.dataset['action'] = 'increment';
    track.className = 'lm-ScrollBar-track';
    thumb.className = 'lm-ScrollBar-thumb';
    track.appendChild(thumb);
    node.appendChild(decrement);
    node.appendChild(track);
    node.appendChild(increment);
    return node;
  }

  /**
   * Find the scroll bar part which contains the given target.
   */
  export function findPart(
    scrollBar: ScrollBar,
    target: HTMLElement
  ): ScrollBarPart | null {
    // Test the thumb.
    if (scrollBar.thumbNode.contains(target)) {
      return 'thumb';
    }

    // Test the track.
    if (scrollBar.trackNode.contains(target)) {
      return 'track';
    }

    // Test the decrement button.
    if (scrollBar.decrementNode.contains(target)) {
      return 'decrement';
    }

    // Test the increment button.
    if (scrollBar.incrementNode.contains(target)) {
      return 'increment';
    }

    // Indicate no match.
    return null;
  }
}
