{
  config,
  lib,
  pkgs,
  appName,
  package,
  modulePath,
  profilePath,
}:
let
  inherit (lib)
    mapAttrs
    mapAttrs'
    mkOption
    optionalAttrs
    types
    warn
    ;

  jsonFormat = pkgs.formats.json { };

  # Map of nice field names to internal field names.
  # This is intended to be exhaustive and should be
  # updated at every version bump.
  internalFieldNames =
    (lib.genAttrs [
      "name"
      "isAppProvided"
      "loadPath"
      "updateInterval"
      "updateURL"
      "iconMapObj"
      "metaData"
      "orderHint"
      "definedAliases"
      "urls"
    ] (name: "_${name}"))
    // {
      searchForm = "__searchForm";
    };

  # Convenience to specify absolute path to icon
  iconUrl = icon: if lib.isPath icon || lib.hasPrefix "/" icon then "file://${icon}" else icon;

  processCustomEngineInput =
    input:
    {
      name = input.id;
    }
    // (removeAttrs input [ "icon" ])
    // optionalAttrs (input ? icon || input ? iconMapObj) {
      iconMapObj = mapAttrs (name: iconUrl) (
        (optionalAttrs (input ? icon) {
          # Convenience to specify single icon instead of map
          "16" = input.icon;
        })
        // (input.iconMapObj or { })
      );
    }
    // {
      # Required for custom engine configurations, loadPaths
      # are unique identifiers that are generally formatted
      # like: [source]/path/to/engine.xml
      loadPath = "[home-manager]/${
        lib.showAttrPath (
          modulePath
          ++ [
            "engines"
            input.id
          ]
        )
      }";
    };

  processEngineInput =
    id: input:
    let
      requiredInput = {
        inherit id;
        isAppProvided = input.isAppProvided or (removeAttrs input [ "metaData" ] == { });
        metaData = input.metaData or { };
      };
    in
    if requiredInput.isAppProvided then
      requiredInput
    else
      lib.pipe (input // requiredInput) [
        migrateEngineToV11
        migrateEngineToV12
        processCustomEngineInput
      ];

  buildEngineConfig =
    name: input:
    mapAttrs' (name: value: {
      name = internalFieldNames.${name} or name;
      inherit value;
    }) (processEngineInput name input);

  sortEngineConfigs =
    configs:
    let
      buildEngineConfigWithOrder =
        order: id:
        let
          config =
            configs.${id} or {
              inherit id;
              _isAppProvided = true;
              _metaData = { };
            };
        in
        config
        // {
          _metaData = config._metaData // {
            inherit order;
          };
        };

      engineConfigsWithoutOrder = lib.attrValues (removeAttrs configs config.order);

      sortedEngineConfigs =
        (lib.imap buildEngineConfigWithOrder config.order) ++ engineConfigsWithoutOrder;
    in
    sortedEngineConfigs;

  engineInput =
    config.engines
    // {
      # Infer config.default as an app provided
      # engine if it's not in config.engines
      ${config.default} = config.engines.${config.default} or { };
    }
    // {
      ${config.privateDefault} = config.engines.${config.privateDefault} or { };
    };

  settings = {
    version = 12;
    engines = sortEngineConfigs (mapAttrs buildEngineConfig engineInput);

    metaData =
      optionalAttrs (config.default != null) {
        defaultEngineId = config.default;
        defaultEngineIdHash = "@hash@";
      }
      // optionalAttrs (config.privateDefault != null) {
        privateDefaultEngineId = config.privateDefault;
        privateDefaultEngineIdHash = "@privateHash@";
      }
      // {
        useSavedOrder = config.order != [ ];
      };
  };

  # Home Manager doesn't circumvent user consent and isn't acting
  # maliciously. We're modifying the search outside of the browser, but
  # a claim by Mozilla to remove this would be very anti-user, and
  # is unlikely to be an issue for our use case.
  disclaimer =
    "By modifying this file, I agree that I am doing so "
    + "only within @appName@ itself, using official, user-driven search "
    + "engine selection processes, and in a way which does not circumvent "
    + "user consent. I acknowledge that any attempt to change this file "
    + "from outside of @appName@ is a malicious act, and will be responded "
    + "to accordingly.";

  salt = if config.default != null then profilePath + config.default + disclaimer else null;

  privateSalt =
    if config.privateDefault != null then profilePath + config.privateDefault + disclaimer else null;

  appNameVariable =
    if package == null then
      "appName=${lib.escapeShellArg appName}"
    else
      ''
        applicationIni="$(find ${lib.escapeShellArg package} -maxdepth 3 -path ${lib.escapeShellArg package}'/lib/*/application.ini' -print -quit)"
        if test -n "$applicationIni"; then
          appName="$(sed -n 's/^Name=\(.*\)$/\1/p' "$applicationIni" | head -n1)"
        else
          appName=${lib.escapeShellArg appName}
        fi
      '';

  file =
    pkgs.runCommand "search.json.mozlz4"
      {
        nativeBuildInputs = with pkgs; [
          mozlz4a
          openssl
        ];
        json = builtins.toJSON settings;
        inherit salt privateSalt;
      }
      ''
        ${appNameVariable}

        salt=''${salt//@appName@/"$appName"}
        privateSalt=''${privateSalt//@appName@/"$appName"}

        if [[ -n $salt ]]; then
          export hash=$(echo -n "$salt" | openssl dgst -sha256 -binary | base64)
          export privateHash=$(echo -n "$privateSalt" | openssl dgst -sha256 -binary | base64)
          mozlz4a <(substituteStream json search.json.in --subst-var hash --subst-var privateHash) "$out"
        else
          mozlz4a <(echo "$json") "$out"
        fi
      '';

  engineNameToId = {
    # Derived from https://searchfox.org/mozilla-central/rev/e3f42ec9320748b2aab3d474d1e47075def9000c/services/settings/dumps/main/search-config-v2.json
    "1&1 Suche" = "1und1";
    "Allegro" = "allegro-pl";
    "Amazon.co.jp" = "amazon-jp";
    "Amazon.com" = "amazondotcom-us";
    "Azerdict" = "azerdict";
    "百度" = "baidu";
    "Bing" = "bing";
    "Ordbok" = "bok-NO";
    "Ceneje.si" = "ceneji";
    "Cốc Cốc" = "coccoc";
    "다음" = "daum-kr";
    "DuckDuckGo" = "ddg";
    "eBay" = "ebay";
    "Ecosia" = "ecosia";
    "EUdict Eng->Cro" = "eudict";
    "Am Faclair Beag" = "faclair-beag";
    "GMX Suche" = "gmx-de";
    "GMX Search" = "gmx-en-GB";
    "GMX - Búsqueda web" = "gmx-es";
    "GMX - Recherche web" = "gmx-fr";
    "GMX Shopping" = "gmx-shopping";
    "Google" = "google";
    "Gule sider" = "gulesider-NO";
    "LEO Eng-Deu" = "leo_ende_de";
    "พจนานุกรม ลองดู" = "longdo";
    "mail.com search" = "mailcom";
    "Mapy.cz" = "mapy-cz";
    "MercadoLibre Argentina" = "mercadolibre-ar";
    "MercadoLibre Chile" = "mercadolibre-cl";
    "MercadoLibre Mexico" = "mercadolibre-mx";
    "MercadoLivre" = "mercadolivre";
    "네이버" = "naver-kr";
    "Odpiralni Časi" = "odpiralni";
    "Pazaruvaj" = "pazaruvaj";
    "Priberam" = "priberam";
    "Prisjakt" = "prisjakt-sv-SE";
    "Qwant" = "qwant";
    "Qwant Junior" = "qwantjr";
    "楽天市場" = "rakuten";
    "Readmoo 讀墨電子書" = "readmoo";
    "Reddit" = "reddit";
    "Salidzini.lv" = "salidzinilv";
    "Seznam" = "seznam-cz";
    "Tyda.se" = "tyda-sv-SE";
    "Vatera.hu" = "vatera";
    "WEB.DE Suche" = "webde";
    "Wikipedia (en)" = "wikipedia";
    "Wikipedia (nn)" = "wikipedia-NN";
    "Wikipedia (nb)" = "wikipedia-NO";
    "Wikipedia (af)" = "wikipedia-af";
    "Biquipedia (an)" = "wikipedia-an";
    "ويكيبيديا (ar)" = "wikipedia-ar";
    "Wikipedia (ast)" = "wikipedia-ast";
    "Vikipediya (az)" = "wikipedia-az";
    "Вікіпедыя (be)" = "wikipedia-be";
    "Вікіпэдыя (be-tarask)" = "wikipedia-be-tarask";
    "Уикипедия (bg)" = "wikipedia-bg";
    "উইকিপিডিয়া (bn)" = "wikipedia-bn";
    "Wikipedia (br)" = "wikipedia-br";
    "Wikipedia (bs)" = "wikipedia-bs";
    "Viquipèdia (ca)" = "wikipedia-ca";
    "Wicipedia (cy)" = "wikipedia-cy";
    "Wikipedie (cs)" = "wikipedia-cz";
    "Wikipedia (da)" = "wikipedia-da";
    "Wikipedia (de)" = "wikipedia-de";
    "Wikipedija (dsb)" = "wikipedia-dsb";
    "Βικιπαίδεια (el)" = "wikipedia-el";
    "Vikipedio (eo)" = "wikipedia-eo";
    "Wikipedia (es)" = "wikipedia-es";
    "Vikipeedia (et)" = "wikipedia-et";
    "Wikipedia (eu)" = "wikipedia-eu";
    "ویکی‌پدیا (fa)" = "wikipedia-fa";
    "Wikipedia (fi)" = "wikipedia-fi";
    "Wikipédia (fr)" = "wikipedia-fr";
    "Wikipedy (fy)" = "wikipedia-fy-NL";
    "Vicipéid (ga)" = "wikipedia-ga-IE";
    "Uicipeid (gd)" = "wikipedia-gd";
    "Wikipedia (gl)" = "wikipedia-gl";
    "Vikipetã (gn)" = "wikipedia-gn";
    "વિકિપીડિયા (gu)" = "wikipedia-gu";
    "ויקיפדיה" = "wikipedia-he";
    "विकिपीडिया (hi)" = "wikipedia-hi";
    "Wikipedija (hr)" = "wikipedia-hr";
    "Wikipedija (hsb)" = "wikipedia-hsb";
    "Wikipédia (hu)" = "wikipedia-hu";
    "Վիքիպեդիա (hy)" = "wikipedia-hy";
    "Wikipedia (ia)" = "wikipedia-ia";
    "Wikipedia (id)" = "wikipedia-id";
    "Wikipedia (is)" = "wikipedia-is";
    "Wikipedia (it)" = "wikipedia-it";
    "Wikipedia (ja)" = "wikipedia-ja";
    "ვიკიპედია (ka)" = "wikipedia-ka";
    "Wikipedia (kab)" = "wikipedia-kab";
    "Уикипедия (kk)" = "wikipedia-kk";
    "វិគីភីឌា (km)" = "wikipedia-km";
    "ವಿಕಿಪೀಡಿಯ (kn)" = "wikipedia-kn";
    "위키백과 (ko)" = "wikipedia-kr";
    "Wikipedia (lij)" = "wikipedia-lij";
    "ວິກິພີເດຍ (lo)" = "wikipedia-lo";
    "Vikipedija (lt)" = "wikipedia-lt";
    "Vikipedeja (ltg)" = "wikipedia-ltg";
    "Vikipēdija (lv)" = "wikipedia-lv";
    "Википедија (mk)" = "wikipedia-mk";
    "विकिपीडिया (mr)" = "wikipedia-mr";
    "Wikipedia (ms)" = "wikipedia-ms";
    "ဝီကီပီးဒီးယား (my)" = "wikipedia-my";
    "विकिपिडिया (ne)" = "wikipedia-ne";
    "Wikipedia (nl)" = "wikipedia-nl";
    "Wikipèdia (oc)" = "wikipedia-oc";
    "ਵਿਕੀਪੀਡੀਆ (pa)" = "wikipedia-pa";
    "Wikipedia (pl)" = "wikipedia-pl";
    "Wikipédia (pt)" = "wikipedia-pt";
    "Wikipedia (rm)" = "wikipedia-rm";
    "Wikipedia (ro)" = "wikipedia-ro";
    "Википедия (ru)" = "wikipedia-ru";
    "විකිපීඩියා (si)" = "wikipedia-si";
    "Wikipédia (sk)" = "wikipedia-sk";
    "Wikipedija (sl)" = "wikipedia-sl";
    "Wikipedia (sq)" = "wikipedia-sq";
    "Википедија (sr)" = "wikipedia-sr";
    "Wikipedia (sv)" = "wikipedia-sv-SE";
    "விக்கிப்பீடியா (ta)" = "wikipedia-ta";
    "వికీపీడియా (te)" = "wikipedia-te";
    "วิกิพีเดีย" = "wikipedia-th";
    "Wikipedia (tl)" = "wikipedia-tl";
    "Vikipedi (tr)" = "wikipedia-tr";
    "Вікіпедія (uk)" = "wikipedia-uk";
    "ویکیپیڈیا (ur)" = "wikipedia-ur";
    "Vikipediya (uz)" = "wikipedia-uz";
    "Wikipedia (vi)" = "wikipedia-vi";
    "Wikipedia (wo)" = "wikipedia-wo";
    "维基百科" = "wikipedia-zh-CN";
    "Wikipedia (zh)" = "wikipedia-zh-TW";
    "ವಿಕ್ಷನರಿ (kn)" = "wiktionary-kn";
    "Wikiccionari (oc)" = "wiktionary-oc";
    "விக்சனரி (ta)" = "wiktionary-ta";
    "విక్షనరీ (te)" = "wiktionary-te";
    "Wolne Lektury" = "wolnelektury-pl";
    "Yahoo! JAPAN" = "yahoo-jp";
    "Yahoo!オークション" = "yahoo-jp-auctions";
    "YouTube" = "youtube";

    # Derived from https://searchfox.org/mozilla-central/rev/e3f42ec9320748b2aab3d474d1e47075def9000c/toolkit/components/search/SearchSettings.sys.mjs#32-44
    "Wikipedia (hy)" = "wikipedia-hy";
    "Wikipedia (kn)" = "wikipedia-kn";
    "Vikipēdija" = "wikipedia-lv";
    "Wikipedia (no)" = "wikipedia-NO";
    "Wikipedia (el)" = "wikipedia-el";
    "Wikipedia (lt)" = "wikipedia-lt";
    "Wikipedia (my)" = "wikipedia-my";
    "Wikipedia (pa)" = "wikipedia-pa";
    "Wikipedia (pt)" = "wikipedia-pt";
    "Wikipedia (si)" = "wikipedia-si";
    "Wikipedia (tr)" = "wikipedia-tr";
  };

  migrateEngineNameToIdV7 =
    engine:
    if builtins.hasAttr engine engineNameToId then
      warn
        "Search engines are now referenced by id instead of by name, use '${engineNameToId.${engine}}' instead of '${engine}'"
        engineNameToId.${engine}
    else
      engine;

  migrateEngineToV11 =
    engine:
    engine
    // lib.optionalAttrs (engine ? iconMapObj) {
      iconMapObj = mapAttrs' (
        name: value:
        let
          nameToIntResult = builtins.tryEval (lib.toInt name);
        in
        {
          name =
            if nameToIntResult.success then
              name
            else
              let
                size = toString (builtins.fromJSON name).width;
              in
              warn "JSON object names for 'iconMapObj' are deprecated, use '${size}' instead of '${name}'" size;

          inherit value;
        }
      ) engine.iconMapObj;
    };

  migrateEngineToV12 =
    engine:
    let
      iconMapObj =
        optionalAttrs (engine ? iconURL) {
          "16" =
            warn "'iconURL' is deprecated, use 'icon = ${lib.strings.escapeNixString engine.iconURL}' instead" engine.iconURL;
        }
        // optionalAttrs (engine ? iconUpdateURL) {
          "16" =
            warn "'iconUpdateURL' is deprecated, use 'icon = ${lib.strings.escapeNixString engine.iconUpdateURL}' instead" engine.iconUpdateURL;
        }
        // (engine.iconMapObj or { });
    in
    lib.throwIf (engine ? hasPreferredIcon) "hasPreferredIcon has been removed" (
      removeAttrs engine [
        "iconURL"
        "iconUpdateURL"
      ]
    )
    // lib.optionalAttrs (iconMapObj != { }) { inherit iconMapObj; };
in
{
  meta.maintainers = with lib.maintainers; [ kira-bruneau ];

  imports = [ (pkgs.path + "/nixos/modules/misc/meta.nix") ];
  options = {
    enable = mkOption {
      type = with types; bool;
      default =
        config.default != null
        || config.privateDefault != null
        || config.order != [ ]
        || config.engines != { };
      internal = true;
    };

    force = mkOption {
      type = with types; bool;
      default = false;
      description = ''
        Whether to force replace the existing search
        configuration. This is recommended since ${appName} will
        replace the symlink for the search configuration on every
        launch, but note that you'll lose any existing configuration
        by enabling this.
      '';
    };

    default = mkOption {
      type = with types; nullOr str;
      apply = engine: if engine != null then migrateEngineNameToIdV7 engine else null;
      default = null;
      example = "ddg";
      description = ''
        The default search engine used in the address bar and search
        bar.
      '';
    };

    privateDefault = mkOption {
      type = with types; nullOr str;
      apply = engine: if engine != null then migrateEngineNameToIdV7 engine else null;
      default = null;
      example = "ddg";
      description = ''
        The default search engine used in the Private Browsing.
      '';
    };

    order = mkOption {
      type = with types; uniq (listOf str);
      apply = builtins.map migrateEngineNameToIdV7;
      default = [ ];
      example = [
        "ddg"
        "google"
      ];
      description = ''
        The order the search engines are listed in. Any engines that
        aren't included in this list will be listed after these in an
        unspecified order.
      '';
    };

    engines = mkOption {
      type = with types; attrsOf (attrsOf jsonFormat.type);

      apply = mapAttrs' (
        name: value: {
          name = migrateEngineNameToIdV7 name;
          inherit value;
        }
      );

      default = { };
      example = lib.literalExpression ''
        {
          nix-packages = {
            name = "Nix Packages";
            urls = [{
              template = "https://search.nixos.org/packages";
              params = [
                { name = "type"; value = "packages"; }
                { name = "query"; value = "{searchTerms}"; }
              ];
            }];

            icon = "''${pkgs.nixos-icons}/share/icons/hicolor/scalable/apps/nix-snowflake.svg";
            definedAliases = [ "@np" ];
          };

          nixos-wiki = {
            name = "NixOS Wiki";
            urls = [{ template = "https://wiki.nixos.org/w/index.php?search={searchTerms}"; }];
            iconMapObj."16" = "https://wiki.nixos.org/favicon.ico";
            definedAliases = [ "@nw" ];
          };

          bing.metaData.hidden = true;
          google.metaData.alias = "@g"; # builtin engines only support specifying one additional alias
        }
      '';

      description = ''
        Attribute set of search engine configurations. Engines that
        only have {var}`metaData` specified will be treated as builtin
        to ${appName}.

        See [SearchEngine.jsm](https://searchfox.org/mozilla-central/rev/e3f42ec9320748b2aab3d474d1e47075def9000c/toolkit/components/search/SearchEngine.sys.mjs#890-923)
        in ${appName}'s source for available options. We maintain a
        mapping to let you specify all options in the referenced link
        without underscores, but it may fall out of date with future
        options.

        Note, {var}`icon` is also a special option added by Home
        Manager to make it convenient to specify absolute icon paths.
      '';
    };

    file = mkOption {
      type = with types; path;
      default = file;
      internal = true;
      readOnly = true;
      description = ''
        Resulting search.json.mozlz4 file.
      '';
    };
  };
}
