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

/**
 * OpenSearchLoader is used for loading OpenSearch definitions from content.
 */

/* eslint no-shadow: error, mozilla/no-aArgs: error */

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "logConsole", () => {
  return console.createInstance({
    prefix: "OpenSearchLoader",
    maxLogLevel: lazy.SearchUtils.loggingEnabled ? "Debug" : "Warn",
  });
});

// The namespaces from the specification at
// https://github.com/dewitt/opensearch/blob/master/opensearch-1-1-draft-6.md#namespace
const OPENSEARCH_NS_10 = "http://a9.com/-/spec/opensearch/1.0/";
const OPENSEARCH_NS_11 = "http://a9.com/-/spec/opensearch/1.1/";

// Although the specification at gives the namespace names defined above, many
// existing OpenSearch engines are using the following versions. We therefore
// allow any one of these.
const OPENSEARCH_NAMESPACES = [
  OPENSEARCH_NS_11,
  OPENSEARCH_NS_10,
  "http://a9.com/-/spec/opensearchdescription/1.1/",
  "http://a9.com/-/spec/opensearchdescription/1.0/",
];

// The name of the element defining the OpenSearch definition.
const OPENSEARCH_LOCALNAME = "OpenSearchDescription";

// These were OpenSearch definitions for engines used internally by Mozilla.
// It may be possible to deprecate/remove these in future.
const MOZSEARCH_NS_10 = "http://www.mozilla.org/2006/browser/search/";
const MOZSEARCH_LOCALNAME = "SearchPlugin";

/**
 * @typedef {object} OpenSearchProperties
 * @property {string} name
 *   The display name of the engine.
 * @property {nsIURI} installURL
 *   The URL that the engine was initially loaded from.
 * @property {string} [description]
 *   The description of the engine.
 * @property {string} [queryCharset]
 *   The character set to use for encoding query values.
 * @property {string} [searchForm]
 *   Non-standard. The search form URL.
 * @property {string} [UpdateUrl]
 *   Non-standard. The update URL for the engine.
 * @property {number} [UpdateInterval]
 *   Non-standard. The update interval for the engine.
 * @property {string} [IconUpdateUrl]
 *   Non-standard. The update URL for the icon.
 * @property {OpenSearchURL[]} urls
 *   An array of URLs associated with the engine.
 * @property {OpenSearchImage[]} images
 *   An array of images assocaiated with the engine.
 */

/**
 * @typedef {object} OpenSearchURL
 * @property {string} type
 *   The OpenSearch based type of the URL see SearchUtils.URL_TYPE.
 * @property {string} method
 *   The method of submission for the URL: GET or POST.
 * @property {string} template
 *   The template for the URL.
 * @property {object[]} params
 *   An array of additional properties of name/value pairs. These are not part
 *   of the OpenSearch specification, but were used in Firefox prior to Firefox 78.
 * @property {string[]} rels
 *   An array of strings that define the relationship of this URL.
 *
 * @see SearchUtils.URL_TYPE
 * @see https://github.com/dewitt/opensearch/blob/master/opensearch-1-1-draft-6.md#url-rel-values
 */

/**
 * @typedef {object} OpenSearchImage
 * @property {string} url
 *   The source URL of the image.
 * @property {boolean} isPrefered
 *   If this image is of the preferred 16x16 size.
 * @property {width} width
 *   The reported width of the image.
 * @property {height} height
 *   The reported height of the image.
 */

/**
 * Retrieves the engine data from a URI and returns it.
 *
 * @param {nsIURI} sourceURI
 *   The uri from which to load the OpenSearch engine data.
 * @param {string} [lastModified]
 *   The UTC date when the engine was last updated, if any.
 * @returns {OpenSearchProperties}
 *   The properties of the loaded OpenSearch engine.
 */
export async function loadAndParseOpenSearchEngine(sourceURI, lastModified) {
  if (!sourceURI) {
    throw Components.Exception(
      sourceURI,
      "Must have URI when calling _install!",
      Cr.NS_ERROR_UNEXPECTED
    );
  }
  if (!/^https?$/i.test(sourceURI.scheme)) {
    throw Components.Exception(
      "Invalid URI passed to SearchEngine constructor",
      Cr.NS_ERROR_INVALID_ARG
    );
  }

  lazy.logConsole.debug("Downloading OpenSearch engine from:", sourceURI.spec);

  let xmlData = await loadEngineXML(sourceURI, lastModified);
  let xmlDocument = await parseXML(xmlData);

  lazy.logConsole.debug("Loading search plugin");

  let engineData;
  try {
    engineData = processXMLDocument(xmlDocument);
  } catch (ex) {
    lazy.logConsole.error("parseData: Failed to init engine!", ex);

    if (ex.result == Cr.NS_ERROR_FILE_CORRUPTED) {
      throw Components.Exception(
        "",
        Ci.nsISearchService.ERROR_ENGINE_CORRUPTED
      );
    }
    throw Components.Exception("", Ci.nsISearchService.ERROR_DOWNLOAD_FAILURE);
  }

  engineData.installURL = sourceURI;
  return engineData;
}

/**
 * Loads the engine XML from the given URI.
 *
 * @param {nsIURI} sourceURI
 *   The uri from which to load the OpenSearch engine data.
 * @param {string} [lastModified]
 *   The UTC date when the engine was last updated, if any.
 * @returns {Promise}
 *   A promise that is resolved with the data if the engine is successfully loaded
 *   and rejected otherwise.
 */
function loadEngineXML(sourceURI, lastModified) {
  var chan = lazy.SearchUtils.makeChannel(
    sourceURI,
    // OpenSearchEngine is loading a definition file for a search engine,
    // TYPE_DOCUMENT captures that load best.
    Ci.nsIContentPolicy.TYPE_DOCUMENT
  );

  if (lastModified && chan instanceof Ci.nsIHttpChannel) {
    chan.setRequestHeader("If-Modified-Since", lastModified, false);
  }
  let loadPromise = Promise.withResolvers();

  let loadHandler = data => {
    if (!data) {
      loadPromise.reject(
        Components.Exception("", Ci.nsISearchService.ERROR_DOWNLOAD_FAILURE)
      );
      return;
    }
    loadPromise.resolve(data);
  };

  var listener = new lazy.SearchUtils.LoadListener(
    chan,
    /(^text\/|xml$)/,
    loadHandler
  );
  chan.notificationCallbacks = listener;
  chan.asyncOpen(listener);

  return loadPromise.promise;
}

/**
 * Parses an engines XML data into a document element.
 *
 * @param {number[]} xmlData
 *   The loaded search engine data.
 * @returns {Element}
 *   A document element containing the parsed data.
 */
function parseXML(xmlData) {
  var parser = new DOMParser();
  var doc = parser.parseFromBuffer(xmlData, "text/xml");

  if (!doc?.documentElement) {
    throw Components.Exception(
      "Could not parse file",
      Ci.nsISearchService.ERROR_ENGINE_CORRUPTED
    );
  }

  if (!hasExpectedNamspeace(doc.documentElement)) {
    throw Components.Exception(
      "Not a valid OpenSearch xml file",
      Ci.nsISearchService.ERROR_ENGINE_CORRUPTED
    );
  }
  return doc.documentElement;
}

/**
 * Extract search engine information from the given document into a form that
 * can be passed to an OpenSearchEngine.
 *
 * @param {Element} xmlDocument
 *   The document to examine.
 * @returns {OpenSearchProperties}
 *   The properties of the OpenSearch engine.
 */
function processXMLDocument(xmlDocument) {
  let result = { urls: [], images: [] };

  for (let i = 0; i < xmlDocument.children.length; ++i) {
    var child = xmlDocument.children[i];
    switch (child.localName) {
      case "ShortName":
        result.name = child.textContent;
        break;
      case "Description":
        result.description = child.textContent;
        break;
      case "Url":
        try {
          result.urls.push(parseURL(child));
        } catch (ex) {
          // Parsing of the element failed, just skip it.
          lazy.logConsole.error("Failed to parse URL child:", ex);
        }
        break;
      case "Image": {
        let imageData = parseImage(child);
        if (imageData) {
          result.images.push(imageData);
        }
        break;
      }
      case "InputEncoding":
        // If this is not specified we fallback to the SearchEngine constructor
        // which currently uses SearchUtils.DEFAULT_QUERY_CHARSET which is
        // UTF-8 - the same as for OpenSearch.
        result.queryCharset = child.textContent;
        break;

      // Non-OpenSearch elements
      case "SearchForm":
        result.searchForm = child.textContent;
        break;
      case "UpdateUrl":
        result.updateURL = child.textContent;
        break;
      case "UpdateInterval":
        result.updateInterval = parseInt(child.textContent);
        break;
      case "IconUpdateUrl":
        result.iconUpdateURL = child.textContent;
        break;
    }
  }
  if (!result.name || !result.urls.length) {
    throw Components.Exception(
      "_parse: No name, or missing URL!",
      Cr.NS_ERROR_FAILURE
    );
  }
  if (!result.urls.find(url => url.type == lazy.SearchUtils.URL_TYPE.SEARCH)) {
    throw Components.Exception(
      "_parse: No text/html result type!",
      Cr.NS_ERROR_FAILURE
    );
  }
  return result;
}

/**
 * Extracts data from an OpenSearch URL element and creates an object which can
 * be used to create an OpenSearchEngine's URL.
 *
 * @param {Element} element
 *   The OpenSearch URL element.
 * @returns {OpenSearchURL}
 *   The extracted URL data.
 * @throws NS_ERROR_FAILURE if a URL object could not be created.
 *
 * @see https://github.com/dewitt/opensearch/blob/master/opensearch-1-1-draft-6.md#the-url-element
 */
function parseURL(element) {
  var type = element.getAttribute("type");
  // According to the spec, method is optional, defaulting to "GET" if not
  // specified.
  var method = element.getAttribute("method") || "GET";
  var template = element.getAttribute("template");

  let rels = [];
  if (element.hasAttribute("rel")) {
    rels = element.getAttribute("rel").toLowerCase().split(/\s+/);
  }

  // Support an alternate suggestion type, see bug 1425827 for details.
  if (type == "application/json" && rels.includes("suggestions")) {
    type = lazy.SearchUtils.URL_TYPE.SUGGEST_JSON;
  }

  let url = {
    type,
    method,
    template,
    params: [],
    rels,
  };

  // Non-standard. Used to be for Mozilla search engine files.
  for (var i = 0; i < element.children.length; ++i) {
    var param = element.children[i];
    if (param.localName == "Param") {
      url.params.push({
        name: param.getAttribute("name"),
        value: param.getAttribute("value"),
      });
    }
  }

  return url;
}

/**
 * Extracts an icon from an OpenSearch Image element.
 *
 * @param {Element} element
 *   The OpenSearch URL element.
 * @returns {OpenSearchImage}
 *   The properties of the image.
 * @see https://github.com/dewitt/opensearch/blob/master/opensearch-1-1-draft-6.md#the-image-element
 */
function parseImage(element) {
  let width = parseInt(element.getAttribute("width"), 10);
  let height = parseInt(element.getAttribute("height"), 10);
  let isPrefered = width == 16 && height == 16;

  if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0) {
    lazy.logConsole.warn(
      "OpenSearch image element must have positive width and height."
    );
    return null;
  }

  return {
    url: element.textContent,
    isPrefered,
    width,
    height,
  };
}

/**
 * Confirms if the document has the expected namespace.
 *
 * @param {DOMElement} element
 *   The document to check.
 * @returns {boolean}
 *   True if the document matches the namespace.
 */
function hasExpectedNamspeace(element) {
  return (
    (element.localName == MOZSEARCH_LOCALNAME &&
      element.namespaceURI == MOZSEARCH_NS_10) ||
    (element.localName == OPENSEARCH_LOCALNAME &&
      OPENSEARCH_NAMESPACES.includes(element.namespaceURI))
  );
}