/**
 * Copyright (c) 2017 The xterm.js authors. All rights reserved.
 * @license MIT
 */

import { Disposable } from 'common/Lifecycle';
import { addDisposableDomListener } from 'browser/Lifecycle';
import { IMouseService, ISelectionService } from 'browser/services/Services';
import { IMouseZoneManager, IMouseZone } from 'browser/Types';
import { IBufferService, IOptionsService } from 'common/services/Services';

/**
 * The MouseZoneManager allows components to register zones within the terminal
 * that trigger hover and click callbacks.
 *
 * This class was intentionally made not so robust initially as the only case it
 * needed to support was single-line links which never overlap. Improvements can
 * be made in the future.
 */
export class MouseZoneManager extends Disposable implements IMouseZoneManager {
  private _zones: IMouseZone[] = [];

  private _areZonesActive: boolean = false;
  private _mouseMoveListener: (e: MouseEvent) => any;
  private _mouseLeaveListener: (e: MouseEvent) => any;
  private _clickListener: (e: MouseEvent) => any;

  private _tooltipTimeout: number | undefined;
  private _currentZone: IMouseZone | undefined;
  private _lastHoverCoords: [number | undefined, number | undefined] = [undefined, undefined];
  private _initialSelectionLength: number = 0;

  constructor(
    private readonly _element: HTMLElement,
    private readonly _screenElement: HTMLElement,
    @IBufferService private readonly _bufferService: IBufferService,
    @IMouseService private readonly _mouseService: IMouseService,
    @ISelectionService private readonly _selectionService: ISelectionService,
    @IOptionsService private readonly _optionsService: IOptionsService
  ) {
    super();

    this.register(addDisposableDomListener(this._element, 'mousedown', e => this._onMouseDown(e)));

    // These events are expensive, only listen to it when mouse zones are active
    this._mouseMoveListener = e => this._onMouseMove(e);
    this._mouseLeaveListener = e => this._onMouseLeave(e);
    this._clickListener = e => this._onClick(e);
  }

  public dispose(): void {
    super.dispose();
    this._deactivate();
  }

  public add(zone: IMouseZone): void {
    this._zones.push(zone);
    if (this._zones.length === 1) {
      this._activate();
    }
  }

  public clearAll(start?: number, end?: number): void {
    // Exit if there's nothing to clear
    if (this._zones.length === 0) {
      return;
    }

    // Clear all if start/end weren't set
    if (!start || !end) {
      start = 0;
      end = this._bufferService.rows - 1;
    }

    // Iterate through zones and clear them out if they're within the range
    for (let i = 0; i < this._zones.length; i++) {
      const zone = this._zones[i];
      if ((zone.y1 > start && zone.y1 <= end + 1) ||
          (zone.y2 > start && zone.y2 <= end + 1) ||
          (zone.y1 < start && zone.y2 > end + 1)) {
        if (this._currentZone && this._currentZone === zone) {
          this._currentZone.leaveCallback();
          this._currentZone = undefined;
        }
        this._zones.splice(i--, 1);
      }
    }

    // Deactivate the mouse zone manager if all the zones have been removed
    if (this._zones.length === 0) {
      this._deactivate();
    }
  }

  private _activate(): void {
    if (!this._areZonesActive) {
      this._areZonesActive = true;
      this._element.addEventListener('mousemove', this._mouseMoveListener);
      this._element.addEventListener('mouseleave', this._mouseLeaveListener);
      this._element.addEventListener('click', this._clickListener);
    }
  }

  private _deactivate(): void {
    if (this._areZonesActive) {
      this._areZonesActive = false;
      this._element.removeEventListener('mousemove', this._mouseMoveListener);
      this._element.removeEventListener('mouseleave', this._mouseLeaveListener);
      this._element.removeEventListener('click', this._clickListener);
    }
  }

  private _onMouseMove(e: MouseEvent): void {
    // TODO: Ideally this would only clear the hover state when the mouse moves
    // outside of the mouse zone
    if (this._lastHoverCoords[0] !== e.pageX || this._lastHoverCoords[1] !== e.pageY) {
      this._onHover(e);
      // Record the current coordinates
      this._lastHoverCoords = [e.pageX, e.pageY];
    }
  }

  private _onHover(e: MouseEvent): void {
    const zone = this._findZoneEventAt(e);

    // Do nothing if the zone is the same
    if (zone === this._currentZone) {
      return;
    }

    // Fire the hover end callback and cancel any existing timer if a new zone
    // is being hovered
    if (this._currentZone) {
      this._currentZone.leaveCallback();
      this._currentZone = undefined;
      if (this._tooltipTimeout) {
        clearTimeout(this._tooltipTimeout);
      }
    }

    // Exit if there is not zone
    if (!zone) {
      return;
    }
    this._currentZone = zone;

    // Trigger the hover callback
    if (zone.hoverCallback) {
      zone.hoverCallback(e);
    }

    // Restart the tooltip timeout
    this._tooltipTimeout = window.setTimeout(() => this._onTooltip(e), this._optionsService.options.linkTooltipHoverDuration);
  }

  private _onTooltip(e: MouseEvent): void {
    this._tooltipTimeout = undefined;
    const zone = this._findZoneEventAt(e);
    zone?.tooltipCallback(e);
  }

  private _onMouseDown(e: MouseEvent): void {
    // Store current terminal selection length, to check if we're performing
    // a selection operation
    this._initialSelectionLength = this._getSelectionLength();

    // Ignore the event if there are no zones active
    if (!this._areZonesActive) {
      return;
    }

    // Find the active zone, prevent event propagation if found to prevent other
    // components from handling the mouse event.
    const zone = this._findZoneEventAt(e);
    if (zone?.willLinkActivate(e)) {
      e.preventDefault();
      e.stopImmediatePropagation();
    }
  }

  private _onMouseLeave(e: MouseEvent): void {
    // Fire the hover end callback and cancel any existing timer if the mouse
    // leaves the terminal element
    if (this._currentZone) {
      this._currentZone.leaveCallback();
      this._currentZone = undefined;
      if (this._tooltipTimeout) {
        clearTimeout(this._tooltipTimeout);
      }
    }
  }

  private _onClick(e: MouseEvent): void {
    // Find the active zone and click it if found and no selection was
    // being performed
    const zone = this._findZoneEventAt(e);
    const currentSelectionLength = this._getSelectionLength();

    if (zone && currentSelectionLength === this._initialSelectionLength) {
      zone.clickCallback(e);
      e.preventDefault();
      e.stopImmediatePropagation();
    }
  }

  private _getSelectionLength(): number {
    const selectionText = this._selectionService.selectionText;
    return selectionText ? selectionText.length : 0;
  }

  private _findZoneEventAt(e: MouseEvent): IMouseZone | undefined {
    const coords = this._mouseService.getCoords(e, this._screenElement, this._bufferService.cols, this._bufferService.rows);
    if (!coords) {
      return undefined;
    }
    const x = coords[0];
    const y = coords[1];
    for (let i = 0; i < this._zones.length; i++) {
      const zone = this._zones[i];
      if (zone.y1 === zone.y2) {
        // Single line link
        if (y === zone.y1 && x >= zone.x1 && x < zone.x2) {
          return zone;
        }
      } else {
        // Multi-line link
        if ((y === zone.y1 && x >= zone.x1) ||
            (y === zone.y2 && x < zone.x2) ||
            (y > zone.y1 && y < zone.y2)) {
          return zone;
        }
      }
    }
    return undefined;
  }
}
