/* 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/. */

/* globals AdjustableTitle */

// This is the dialog that is displayed when adding or editing a search engine
// in about:preferences, or when adding a search engine via the context menu of
// an HTML form. Depending on the scenario where it is used, different arguments
// must be supplied in an object in `window.arguments[0]`:
// - `mode`  [required] - The type of dialog: NEW, EDIT or FORM.
// - `title` [optional] - Whether to display a title in the window element.
// - all arguments required by the constructor of the dialog class

/**
 * @import {UserSearchEngine} from "../../../../toolkit/components/search/UserSearchEngine.sys.mjs"
 */

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  SearchUtils: "moz-src:///toolkit/components/search/SearchUtils.sys.mjs",
});

// Set the appropriate l10n id before the dialog's connectedCallback.
if (window.arguments[0].mode == "EDIT") {
  document.l10n.setAttributes(
    document.querySelector("dialog"),
    "edit-engine-dialog"
  );
  document.l10n.setAttributes(
    document.querySelector("window"),
    "edit-engine-window"
  );
} else {
  document.l10n.setAttributes(
    document.querySelector("dialog"),
    "add-engine-dialog2"
  );
  document.l10n.setAttributes(
    document.querySelector("window"),
    "add-engine-window"
  );
}

let loadedResolvers = Promise.withResolvers();
document.mozSubdialogReady = loadedResolvers.promise;

/** @type {?EngineDialog} */
let gAddEngineDialog = null;
/** @type {?Map<string, string>} */
let l10nCache = null;

/**
 * The abstract base class for all types of user search engine dialogs.
 * All subclasses must implement the abstract method `onAddEngine`.
 */
class EngineDialog {
  constructor() {
    this._dialog = document.querySelector("dialog");

    this._form = document.getElementById("addEngineForm");
    this._name = document.getElementById("engineName");
    this._alias = document.getElementById("engineAlias");
    this._url = document.getElementById("engineUrl");
    this._postData = document.getElementById("enginePostData");
    this._suggestUrl = document.getElementById("suggestUrl");

    this._form.addEventListener("input", e => this.validateInput(e.target));
    document.addEventListener("dialogaccept", this.onAccept.bind(this));
    document.addEventListener("dialogextra1", () => this.showAdvanced());
  }

  /**
   * Shows the advanced section and hides the advanced button.
   *
   * @param {boolean} [resize]
   *   Whether the resizeDialog should be called. Before `mozSubdialogReady`
   *   is resolved, this should be false to avoid flickering.
   */
  showAdvanced(resize = true) {
    this._dialog.getButton("extra1").hidden = true;
    document.getElementById("advanced-section").hidden = false;
    if (resize) {
      window.resizeDialog();
    }
  }

  onAccept() {
    throw new Error("abstract");
  }

  validateName() {
    let name = this._name.value.trim();
    if (!name) {
      this.setValidity(this._name, "add-engine-no-name");
      return;
    }

    let existingEngine = Services.search.getEngineByName(name);
    if (existingEngine && !this.allowedNames.includes(name)) {
      this.setValidity(this._name, "add-engine-name-exists");
      return;
    }

    this.setValidity(this._name, null);
  }

  async validateAlias() {
    let alias = this._alias.value.trim();
    if (!alias) {
      this.setValidity(this._alias, null);
      return;
    }

    let existingEngine = await Services.search.getEngineByAlias(alias);
    if (existingEngine && !this.allowedAliases.includes(alias)) {
      this.setValidity(this._alias, "add-engine-keyword-exists");
      return;
    }

    this.setValidity(this._alias, null);
  }

  validateUrlInput() {
    let urlString = this._url.value.trim();
    if (!urlString) {
      this.setValidity(this._url, "add-engine-no-url");
      return;
    }

    let url = URL.parse(urlString);
    if (!url) {
      this.setValidity(this._url, "add-engine-invalid-url");
      return;
    }

    if (url.protocol != "http:" && url.protocol != "https:") {
      this.setValidity(this._url, "add-engine-invalid-protocol");
      return;
    }

    let postData = this._postData?.value.trim();
    if (!urlString.includes("%s") && !postData) {
      this.setValidity(this._url, "add-engine-missing-terms-url");
      return;
    }
    this.setValidity(this._url, null);
  }

  validatePostDataInput() {
    let postData = this._postData.value.trim();
    if (postData && !postData.includes("%s")) {
      this.setValidity(this._postData, "add-engine-missing-terms-post-data");
      return;
    }
    this.setValidity(this._postData, null);
  }

  validateSuggestUrlInput() {
    let urlString = this._suggestUrl.value.trim();
    if (!urlString) {
      this.setValidity(this._suggestUrl, null);
      return;
    }

    let url = URL.parse(urlString);
    if (!url) {
      this.setValidity(this._suggestUrl, "add-engine-invalid-url");
      return;
    }

    if (url.protocol != "http:" && url.protocol != "https:") {
      this.setValidity(this._suggestUrl, "add-engine-invalid-protocol");
      return;
    }

    if (!urlString.includes("%s")) {
      this.setValidity(this._suggestUrl, "add-engine-missing-terms-url");
      return;
    }

    this.setValidity(this._suggestUrl, null);
  }

  /**
   * Validates the passed input element and updates error messages.
   *
   * @param {HTMLInputElement} input
   *   The input element to validate.
   */
  async validateInput(input) {
    switch (input.id) {
      case this._name.id:
        this.validateName();
        break;
      case this._alias.id:
        await this.validateAlias();
        break;
      case this._postData.id:
      case this._url.id:
        // Since either the url or the post data input could
        // contain %s, we need to update both inputs here.
        this.validateUrlInput();
        this.validatePostDataInput();
        break;
      case this._suggestUrl.id:
        this.validateSuggestUrlInput();
        break;
    }
  }

  async validateAll() {
    for (let input of this._form.elements) {
      await this.validateInput(input);
    }
  }

  /**
   * Sets the validity of the passed input element to the string belonging
   * to the passed l10n id. Also updates the input's error label and
   * the accept button.
   *
   * @param {HTMLInputElement} inputElement
   * @param {string} l10nId
   *   The l10n id of the string to use as validity.
   *   Must be a key of `l10nCache`.
   */
  setValidity(inputElement, l10nId) {
    if (l10nId) {
      inputElement.setCustomValidity(l10nCache.get(l10nId));
    } else {
      inputElement.setCustomValidity("");
    }

    let errorLabel = inputElement.parentElement.querySelector(".error-label");
    let validationMessage = inputElement.validationMessage;

    // If valid, set the error label to "valid" to ensure the layout doesn't shift.
    // The CSS already hides the error label based on the validity of `inputElement`.
    errorLabel.textContent = validationMessage || "valid";

    this._dialog.getButton("accept").disabled = !this._form.checkValidity();
  }

  /**
   * Engine names that always are allowed, even if they are already in use.
   * This is needed for the edit engine dialog.
   *
   * @type {string[]}
   */
  get allowedNames() {
    return [];
  }

  /**
   * Engine aliases that always are allowed, even if they are already in use.
   * This is needed for the edit engine dialog.
   *
   * @type {string[]}
   */
  get allowedAliases() {
    return [];
  }
}

/**
 * This dialog is opened when adding a new search engine in preferences.
 */
class NewEngineDialog extends EngineDialog {
  constructor() {
    super();
    document.l10n.setAttributes(this._name, "add-engine-name-placeholder");
    document.l10n.setAttributes(this._url, "add-engine-url-placeholder");
    document.l10n.setAttributes(this._alias, "add-engine-keyword-placeholder");

    this.validateAll();
  }

  onAccept() {
    let params = new URLSearchParams(
      this._postData.value.trim().replace(/%s/, "{searchTerms}")
    );
    let url = this._url.value.trim().replace(/%s/, "{searchTerms}");

    Services.search.addUserEngine({
      name: this._name.value.trim(),
      url,
      method: params.size ? "POST" : "GET",
      params,
      suggestUrl: this._suggestUrl.value.trim().replace(/%s/, "{searchTerms}"),
      alias: this._alias.value.trim(),
    });
  }
}

/**
 * This dialog is opened when editing a user search engine in preferences.
 */
class EditEngineDialog extends EngineDialog {
  #engine;
  /**
   * Initializes the dialog with information from a user search engine.
   *
   * @param {object} args
   *   The arguments.
   * @param {UserSearchEngine} args.engine
   *   The search engine to edit. Must be a UserSearchEngine.
   */
  constructor({ engine }) {
    super();
    this.#engine = engine;
    this._name.value = engine.name;
    this._alias.value = engine.alias ?? "";

    let [url, postData] = this.getSubmissionTemplate(
      lazy.SearchUtils.URL_TYPE.SEARCH
    );
    this._url.value = url;
    this._postData.value = postData;

    let [suggestUrl] = this.getSubmissionTemplate(
      lazy.SearchUtils.URL_TYPE.SUGGEST_JSON
    );
    if (suggestUrl) {
      this._suggestUrl.value = suggestUrl;
    }

    if (postData || suggestUrl) {
      this.showAdvanced(false);
    }

    this.validateAll();
  }

  onAccept() {
    this.#engine.rename(this._name.value.trim());
    this.#engine.alias = this._alias.value.trim();

    let newURL = this._url.value.trim();
    let newPostData = this._postData.value.trim() || null;

    // UserSearchEngine.changeUrl() does not check whether the URL has actually changed.
    let [prevURL, prevPostData] = this.getSubmissionTemplate(
      lazy.SearchUtils.URL_TYPE.SEARCH
    );
    if (newURL != prevURL || prevPostData != newPostData) {
      this.#engine.changeUrl(
        lazy.SearchUtils.URL_TYPE.SEARCH,
        newURL.replace(/%s/, "{searchTerms}"),
        newPostData?.replace(/%s/, "{searchTerms}")
      );
    }

    let newSuggestURL = this._suggestUrl.value.trim() || null;
    let [prevSuggestUrl] = this.getSubmissionTemplate(
      lazy.SearchUtils.URL_TYPE.SUGGEST_JSON
    );
    if (newSuggestURL != prevSuggestUrl) {
      this.#engine.changeUrl(
        lazy.SearchUtils.URL_TYPE.SUGGEST_JSON,
        newSuggestURL?.replace(/%s/, "{searchTerms}"),
        null
      );
    }

    this.#engine.updateFavicon();
  }

  get allowedAliases() {
    return [this.#engine.alias];
  }

  get allowedNames() {
    return [this.#engine.name];
  }

  /**
   * Returns url and post data templates of the requested type.
   * Both contain %s in place of the search terms.
   *
   * If no url of the requested type exists, both are null.
   * If the url is a GET url, the post data is null.
   *
   * @param {string} urlType
   *   The `SearchUtils.URL_TYPE`.
   * @returns {[?string, ?string]}
   *   Array of the url and post data.
   */
  getSubmissionTemplate(urlType) {
    let submission = this.#engine.getSubmission("searchTerms", urlType);
    if (!submission) {
      return [null, null];
    }
    let postData = null;
    if (submission.postData) {
      let binaryStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
        Ci.nsIBinaryInputStream
      );
      binaryStream.setInputStream(submission.postData.data);

      postData = binaryStream
        .readBytes(binaryStream.available())
        .replace("searchTerms", "%s");
    }
    let url = submission.uri.spec.replace("searchTerms", "%s");
    return [url, postData];
  }
}

/**
 * This dialog is opened via the context menu of an input and lets the
 * user choose a name and an alias for an engine. Unlike the other two
 * dialogs, it does not add or change an engine in the search service,
 * and instead returns the user input to the caller.
 *
 * The chosen name and alias are returned via `window.arguments[0].engineInfo`.
 * If the user chooses to not save the engine, it's undefined.
 */
class NewEngineFromFormDialog extends EngineDialog {
  /**
   * Initializes the dialog.
   *
   * @param {object} args
   *   The arguments.
   * @param {string} args.nameTemplate
   *   The initial value of the name input.
   */
  constructor({ nameTemplate }) {
    super();
    document.getElementById("engineUrlRow").remove();
    this._url = null;
    document.getElementById("suggestUrlRow").remove();
    this._suggestUrl = null;
    document.getElementById("enginePostDataRow").remove();
    this._postData = null;
    this._dialog.getButton("extra1").hidden = true;

    this._name.value = nameTemplate;
    this.validateAll();
  }

  onAccept() {
    // Return the input to the caller.
    window.arguments[0].engineInfo = {
      name: this._name.value.trim(),
      // Empty string means no alias.
      alias: this._alias.value.trim(),
    };
  }
}

async function initL10nCache() {
  const errorIds = [
    "add-engine-name-exists",
    "add-engine-keyword-exists",
    "add-engine-no-name",
    "add-engine-no-url",
    "add-engine-invalid-protocol",
    "add-engine-invalid-url",
    "add-engine-missing-terms-url",
    "add-engine-missing-terms-post-data",
  ];

  let msgs = await document.l10n.formatValues(errorIds.map(id => ({ id })));
  l10nCache = new Map();

  for (let i = 0; i < errorIds.length; i++) {
    l10nCache.set(errorIds[i], msgs[i]);
  }
}

window.addEventListener("DOMContentLoaded", async () => {
  try {
    if (window.arguments[0].title) {
      document.documentElement.setAttribute(
        "headertitle",
        JSON.stringify({ raw: document.title })
      );
    } else {
      AdjustableTitle.hide();
    }

    await initL10nCache();

    switch (window.arguments[0].mode) {
      case "NEW":
        gAddEngineDialog = new NewEngineDialog();
        break;
      case "EDIT":
        gAddEngineDialog = new EditEngineDialog(window.arguments[0]);
        break;
      case "FORM":
        gAddEngineDialog = new NewEngineFromFormDialog(window.arguments[0]);
        break;
      default:
        throw new Error("Mode not supported for addEngine dialog.");
    }
  } finally {
    loadedResolvers.resolve();
  }
});
