import {Observable} from '#core/data-structures/observable';

import {Services} from '#service';

import {listenOnce, listenOncePromise} from '#utils/event-helper';
import {dev} from '#utils/log';

import {registerServiceBuilder} from './service-helpers';

const TAG_ = 'Input';

const MAX_MOUSE_CONFIRM_ATTEMPTS_ = 3;
const CLICK_TIMEOUT_ = 300;

/**
 * Detects and maintains different types of input such as touch, mouse or
 * keyboard.
 */
export class Input {
  /**
   * @param {!Window} win
   */
  constructor(win) {
    /** @const {!Window} */
    this.win = win;

    /** @private {!Function} */
    this.boundOnKeyDown_ = this.onKeyDown_.bind(this);

    /** @private {!Function} */
    this.boundOnMouseDown_ = this.onMouseDown_.bind(this);

    /** @private {?function(!Event)} */
    this.boundOnMouseMove_ = null;

    /** @private {?Function} */
    this.boundMouseCanceled_ = null;

    /** @private {?Function} */
    this.boundMouseConfirmed_ = null;

    /** @private {boolean} */
    this.hasTouch_ =
      'ontouchstart' in win ||
      (win.navigator['maxTouchPoints'] !== undefined &&
        win.navigator['maxTouchPoints'] > 0) ||
      win['DocumentTouch'] !== undefined;
    dev().fine(TAG_, 'touch detected:', this.hasTouch_);

    /** @private {boolean} */
    this.keyboardActive_ = false;
    this.win.document.addEventListener('keydown', this.boundOnKeyDown_);
    this.win.document.addEventListener('mousedown', this.boundOnMouseDown_);

    /** @private {boolean} */
    this.hasMouse_ = true;

    /** @private {number} */
    this.mouseConfirmAttemptCount_ = 0;

    /** @private {!Observable<boolean>} */
    this.touchDetectedObservable_ = new Observable();

    /** @private {!Observable<boolean>} */
    this.mouseDetectedObservable_ = new Observable();

    /** @private {!Observable<boolean>} */
    this.keyboardStateObservable_ = new Observable();

    // If touch available, temporarily set hasMouse to false and wait for
    // mouse events.
    if (this.hasTouch_) {
      this.hasMouse_ = !this.hasTouch_;
      this.boundOnMouseMove_ = /** @type {function(!Event)} */ (
        this.onMouseMove_.bind(this)
      );
      listenOnce(win.document, 'mousemove', this.boundOnMouseMove_);
    }
  }

  /**
   * See https://github.com/ampproject/amphtml/blob/main/docs/spec/amp-css-classes.md#input-mode-classes
   * @param {!./service/ampdoc-impl.AmpDoc} ampdoc
   */
  setupInputModeClasses(ampdoc) {
    this.onTouchDetected((detected) => {
      this.toggleInputClass_(ampdoc, 'amp-mode-touch', detected);
    }, true);
    this.onMouseDetected((detected) => {
      this.toggleInputClass_(ampdoc, 'amp-mode-mouse', detected);
    }, true);
    this.onKeyboardStateChanged((active) => {
      this.toggleInputClass_(ampdoc, 'amp-mode-keyboard-active', active);
    }, true);
  }

  /**
   * Whether the touch input has been detected.
   * @return {boolean}
   */
  isTouchDetected() {
    return this.hasTouch_;
  }

  /**
   * Registers an event handle in case if the touch is detected.
   * @param {function(boolean)} handler
   * @param {boolean=} opt_fireImmediately
   * @return {!UnlistenDef}
   */
  onTouchDetected(handler, opt_fireImmediately) {
    if (opt_fireImmediately) {
      handler(this.isTouchDetected());
    }
    return this.touchDetectedObservable_.add(handler);
  }

  /**
   * Whether the mouse input has been detected.
   * @return {boolean}
   */
  isMouseDetected() {
    return this.hasMouse_;
  }

  /**
   * Registers an event handle in case if the mouse is detected.
   * @param {function(boolean)} handler
   * @param {boolean=} opt_fireImmediately
   * @return {!UnlistenDef}
   */
  onMouseDetected(handler, opt_fireImmediately) {
    if (opt_fireImmediately) {
      handler(this.isMouseDetected());
    }
    return this.mouseDetectedObservable_.add(handler);
  }

  /**
   * Whether the keyboard input is currently active.
   * @return {boolean}
   */
  isKeyboardActive() {
    return this.keyboardActive_;
  }

  /**
   * Registers an event handle for changes in the keyboard input.
   * @param {function(boolean)} handler
   * @param {boolean=} opt_fireImmediately
   * @return {!UnlistenDef}
   */
  onKeyboardStateChanged(handler, opt_fireImmediately) {
    if (opt_fireImmediately) {
      handler(this.isKeyboardActive());
    }
    return this.keyboardStateObservable_.add(handler);
  }

  /**
   * @param {!./service/ampdoc-impl.AmpDoc} ampdoc
   * @param {string} clazz
   * @param {boolean} on
   * @private
   */
  toggleInputClass_(ampdoc, clazz, on) {
    ampdoc.waitForBodyOpen().then((body) => {
      const vsync = Services./*OK*/ vsyncFor(this.win);
      vsync.mutate(() => {
        body.classList.toggle(clazz, on);
      });
    });
  }

  /**
   * @param {!Event} e
   * @private
   */
  onKeyDown_(e) {
    if (this.keyboardActive_) {
      return;
    }

    if (e.defaultPrevented) {
      return;
    }

    // Ignore inputs.
    const {target} = e;
    if (
      target &&
      (target.tagName == 'INPUT' ||
        target.tagName == 'TEXTAREA' ||
        target.tagName == 'SELECT' ||
        target.tagName == 'OPTION' ||
        target.hasAttribute('contenteditable'))
    ) {
      return;
    }

    this.keyboardActive_ = true;
    this.keyboardStateObservable_.fire(true);
    dev().fine(TAG_, 'keyboard activated');
  }

  /** @private */
  onMouseDown_() {
    if (!this.keyboardActive_) {
      return;
    }
    this.keyboardActive_ = false;
    this.keyboardStateObservable_.fire(false);
    dev().fine(TAG_, 'keyboard deactivated');
  }

  /**
   * @param {!Event} e
   * @return {!Promise|undefined}
   * @private
   */
  onMouseMove_(e) {
    // The event explicitly states that it's a result of a touch event.
    if (e.sourceCapabilities && e.sourceCapabilities.firesTouchEvents) {
      this.mouseCanceled_();
      return undefined;
    }
    if (!this.boundMouseConfirmed_) {
      this.boundMouseConfirmed_ = this.mouseConfirmed_.bind(this);
      this.boundMouseCanceled_ = this.mouseCanceled_.bind(this);
    }
    // If "click" arrives within a timeout time, this is most likely a
    // touch/mouse emulation. Otherwise, if timeout exceeded, this looks
    // like a legitimate mouse event.
    let unlisten;
    const listenPromise = listenOncePromise(
      this.win.document,
      'click',
      /* capture */ undefined,
      (unlistener) => {
        unlisten = unlistener;
      }
    );
    return Services.timerFor(this.win)
      .timeoutPromise(CLICK_TIMEOUT_, listenPromise)
      .then(this.boundMouseCanceled_, () => {
        if (unlisten) {
          unlisten();
        }
        this.boundMouseConfirmed_();
      });
  }

  /** @private */
  mouseConfirmed_() {
    this.hasMouse_ = true;
    this.mouseDetectedObservable_.fire(true);
    dev().fine(TAG_, 'mouse detected');
  }

  /** @private */
  mouseCanceled_() {
    // Repeat, if attempts allow.
    this.mouseConfirmAttemptCount_++;
    if (this.mouseConfirmAttemptCount_ <= MAX_MOUSE_CONFIRM_ATTEMPTS_) {
      listenOnce(
        this.win.document,
        'mousemove',
        /** @type {function(!Event)} */ (this.boundOnMouseMove_)
      );
    } else {
      dev().fine(TAG_, 'mouse detection failed');
    }
  }
}

/**
 * @param {!Window} win
 */
export function installInputService(win) {
  registerServiceBuilder(win, 'input', Input);
}
