/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

// Used to keep track of all of the registered keywords, where each keyword is
// mapped to a KeywordInfo instance.
let gKeywordMap = new Map();

// Used to keep track of the active input session.
let gActiveInputSession = null;

// Used to keep track of who has control over the active suggestion callback
// so older callbacks can be ignored. The callback ID should increment whenever
// the input changes or the input session ends.
let gCurrentCallbackID = 0;

/**
 * Handles keeping track of information associated to the registered keyword.
 */
class KeywordInfo {
  constructor(extension, description) {
    this._extension = extension;
    this._description = description;
  }

  get description() {
    return this._description;
  }

  set description(desc) {
    this._description = desc;
  }

  get extension() {
    return this._extension;
  }
}

/**
 * Responsible for handling communication between the extension and the urlbar.
 */
class InputSession {
  constructor(keyword, extension) {
    this._keyword = keyword;
    this._extension = extension;
    this._suggestionsCallback = null;
    this._searchFinishedCallback = null;
  }

  get keyword() {
    return this._keyword;
  }

  addSuggestions(suggestions) {
    if (this._suggestionsCallback) {
      this._suggestionsCallback(suggestions);
    }
  }

  start(eventName) {
    this._extension.emit(eventName);
  }

  update(eventName, text, suggestionsCallback, searchFinishedCallback) {
    // Check to see if an existing input session needs to be ended first.
    if (this._searchFinishedCallback) {
      this._searchFinishedCallback();
    }
    this._searchFinishedCallback = searchFinishedCallback;
    this._suggestionsCallback = suggestionsCallback;
    this._extension.emit(eventName, text, ++gCurrentCallbackID);
  }

  cancel(eventName) {
    if (this._searchFinishedCallback) {
      this._searchFinishedCallback();
      this._searchFinishedCallback = null;
      this._suggestionsCallback = null;
    }
    this._extension.emit(eventName);
  }

  end(eventName, text, disposition) {
    if (this._searchFinishedCallback) {
      this._searchFinishedCallback();
      this._searchFinishedCallback = null;
      this._suggestionsCallback = null;
    }
    this._extension.emit(eventName, text, disposition);
  }
}

export var ExtensionSearchHandler = Object.freeze({
  MSG_INPUT_STARTED: "webext-omnibox-input-started",
  MSG_INPUT_CHANGED: "webext-omnibox-input-changed",
  MSG_INPUT_ENTERED: "webext-omnibox-input-entered",
  MSG_INPUT_CANCELLED: "webext-omnibox-input-cancelled",
  MSG_INPUT_DELETED: "webext-omnibox-input-deleted",

  /**
   * Registers a keyword.
   *
   * @param {string} keyword The keyword to register.
   * @param {Extension} extension The extension registering the keyword.
   */
  registerKeyword(keyword, extension) {
    if (gKeywordMap.has(keyword)) {
      throw new Error(
        `The keyword provided is already registered: "${keyword}"`
      );
    }
    gKeywordMap.set(keyword, new KeywordInfo(extension, extension.name));
  },

  /**
   * Unregisters a keyword.
   *
   * @param {string} keyword The keyword to unregister.
   */
  unregisterKeyword(keyword) {
    if (!gKeywordMap.has(keyword)) {
      throw new Error(`The keyword provided is not registered: "${keyword}"`);
    }
    gActiveInputSession = null;
    gKeywordMap.delete(keyword);
  },

  /**
   * Checks if a keyword is registered.
   *
   * @param {string} keyword The word to check.
   * @returns {boolean} true if the word is a registered keyword.
   */
  isKeywordRegistered(keyword) {
    return gKeywordMap.has(keyword);
  },

  /**
   * @returns {boolean} true if there is an active input session.
   */
  hasActiveInputSession() {
    return gActiveInputSession != null;
  },

  /**
   * @param {string} keyword The keyword to look up.
   * @returns {string} the description to use for the heuristic result.
   */
  getDescription(keyword) {
    if (!gKeywordMap.has(keyword)) {
      throw new Error(`The keyword provided is not registered: "${keyword}"`);
    }
    return gKeywordMap.get(keyword).description;
  },

  /**
   * Sets the default suggestion for the registered keyword. The suggestion's
   * description will be used for the comment in the heuristic result.
   *
   * @param {string} keyword The keyword.
   * @param {object} options
   * @param {string} options.description
   *   The description to use for the heuristic result.
   */
  setDefaultSuggestion(keyword, { description }) {
    if (!gKeywordMap.has(keyword)) {
      throw new Error(`The keyword provided is not registered: "${keyword}"`);
    }
    gKeywordMap.get(keyword).description = description;
  },

  /**
   * Adds suggestions for the registered keyword. This function will throw if
   * the keyword provided is not registered or active, or if the callback ID
   * provided is no longer equal to the active callback ID.
   *
   * @param {string} keyword The keyword.
   * @param {number} id The ID of the suggestion callback.
   * @param {Array<object>} suggestions An array of suggestions to provide to the urlbar.
   */
  addSuggestions(keyword, id, suggestions) {
    if (!gKeywordMap.has(keyword)) {
      throw new Error(`The keyword provided is not registered: "${keyword}"`);
    }

    if (!gActiveInputSession || gActiveInputSession.keyword != keyword) {
      throw new Error(
        `The keyword provided is not apart of an active input session: "${keyword}"`
      );
    }

    if (id != gCurrentCallbackID) {
      throw new Error(
        `The callback is no longer active for the keyword provided: "${keyword}"`
      );
    }

    gActiveInputSession.addSuggestions(suggestions);
  },

  /**
   * Called when the input in the urlbar begins with `<keyword><space>`.
   *
   * If the keyword is inactive, MSG_INPUT_STARTED is emitted and the
   * keyword is marked as active. If the keyword is followed by any text,
   * MSG_INPUT_CHANGED is fired with the current callback ID that can be
   * used to provide suggestions to the urlbar while the callback ID is active.
   * The callback is invalidated when either the input changes or the urlbar blurs.
   *
   * @param {object} data An object that contains
   *                 {string} keyword The keyword to handle.
   *                 {string} text The search text in the urlbar.
   *                 {boolean} inPrivateWindow privateness of window search
   *                           is occuring in.
   * @param {Function} callback The callback used to provide search suggestions.
   * @returns {Promise} promise that resolves when the current search is complete.
   */
  handleSearch(data, callback) {
    let { keyword, text } = data;
    let keywordInfo = gKeywordMap.get(keyword);
    if (!keywordInfo) {
      throw new Error(`The keyword provided is not registered: "${keyword}"`);
    }

    let { extension } = keywordInfo;
    if (data.inPrivateWindow && !extension.privateBrowsingAllowed) {
      return Promise.resolve(false);
    }

    if (gActiveInputSession && gActiveInputSession.keyword != keyword) {
      throw new Error("A different input session is already ongoing");
    }

    if (!text || !text.startsWith(`${keyword} `)) {
      throw new Error(`The text provided must start with: "${keyword} "`);
    }

    if (!callback) {
      throw new Error("A callback must be provided");
    }

    // The search text in the urlbar currently starts with <keyword><space>, and
    // we only want the text that follows.
    text = text.substring(keyword.length + 1);

    // We fire MSG_INPUT_STARTED once we have <keyword><space>, and only fire
    // MSG_INPUT_CHANGED when we have text to process. This is different from Chrome's
    // behavior, which always fires MSG_INPUT_STARTED right before MSG_INPUT_CHANGED
    // first fires, but this is a bug in Chrome according to https://crbug.com/258911.
    if (!gActiveInputSession) {
      gActiveInputSession = new InputSession(keyword, extension);
      gActiveInputSession.start(this.MSG_INPUT_STARTED);

      // Resolve early if there is no text to process. There can be text to process when
      // the input starts if the user copy/pastes the text into the urlbar.
      if (!text.length) {
        return Promise.resolve();
      }
    }

    return new Promise(resolve => {
      gActiveInputSession.update(
        this.MSG_INPUT_CHANGED,
        text,
        callback,
        resolve
      );
    });
  },

  /**
   * Called when the user clicks on a suggestion that was added by
   * an extension. MSG_INPUT_ENTERED is emitted to the extension with
   * the keyword, the current search string, and info about how the
   * the search should be handled. This ends the active input session.
   *
   * @param {string} keyword The keyword associated to the suggestion.
   * @param {string} text The search text in the urlbar.
   * @param {string} where How the page should be opened. Accepted values are:
   *    "current": open the page in the same tab.
   *    "tab": open the page in a new foreground tab.
   *    "tabshifted": open the page in a new background tab.
   */
  handleInputEntered(keyword, text, where) {
    if (!gKeywordMap.has(keyword)) {
      throw new Error(`The keyword provided is not registered: "${keyword}"`);
    }

    if (!gActiveInputSession) {
      throw new Error("There is no active input session");
    }

    if (gActiveInputSession && gActiveInputSession.keyword != keyword) {
      throw new Error("A different input session is already ongoing");
    }

    if (!text || !text.startsWith(`${keyword} `)) {
      throw new Error(`The text provided must start with: "${keyword} "`);
    }

    let dispositionMap = {
      current: "currentTab",
      tab: "newForegroundTab",
      tabshifted: "newBackgroundTab",
    };
    let disposition = dispositionMap[where];

    if (!disposition) {
      throw new Error(`Invalid "where" argument: ${where}`);
    }

    // The search text in the urlbar currently starts with <keyword><space>, and
    // we only want to send the text that follows.
    text = text.substring(keyword.length + 1);

    gActiveInputSession.end(this.MSG_INPUT_ENTERED, text, disposition);
    gActiveInputSession = null;
  },

  /**
   * Called when the user deletes a suggestion that was added by
   * an extension. MSG_INPUT_DELETED is emitted to the extension with
   * the description of the suggestion that was deleted.
   *
   * @param {string} text The description of the suggestion.
   */
  handleInputDeleted(text) {
    return gActiveInputSession.update(this.MSG_INPUT_DELETED, text);
  },

  /**
   * If the user has ended the keyword input session without accepting the input,
   * MSG_INPUT_CANCELLED is emitted and the input session is ended.
   */
  handleInputCancelled() {
    if (!gActiveInputSession) {
      throw new Error("There is no active input session");
    }
    gActiveInputSession.cancel(this.MSG_INPUT_CANCELLED);
    gActiveInputSession = null;
  },
});
