{
  config,
  lib,
  pkgs,
  ...
}:

let
  cfg = config.services.scanservjs;
  settings = {
    scanimage = lib.getExe' config.hardware.sane.backends-package "scanimage";
    convert = lib.getExe' pkgs.imagemagick "convert";
    tesseract = lib.getExe pkgs.tesseract;
    # it defaults to config/devices.json, but "config" dir doesn't exist by default and scanservjs doesn't create it
    devicesPath = "devices.json";
  }
  // cfg.settings;
  settingsFormat = pkgs.formats.json { };

  leafs =
    attrs:
    builtins.concatLists (
      lib.mapAttrsToList (k: v: if builtins.isAttrs v then leafs v else [ v ]) attrs
    );

  package = pkgs.scanservjs;

  configFile = pkgs.writeText "config.local.js" ''
    /* eslint-disable no-unused-vars */
    module.exports = {
      afterConfig(config) {
        ${builtins.concatStringsSep "" (
          leafs (
            lib.mapAttrsRecursive (path: val: ''
              ${builtins.concatStringsSep "." path} = ${builtins.toJSON val};
            '') { config = settings; }
          )
        )}
        ${cfg.extraConfig}
      },

      afterDevices(devices) {
        ${cfg.extraDevicesConfig}
      },

      async afterScan(fileInfo) {
        ${cfg.runAfterScan}
      },

      actions: [
        ${builtins.concatStringsSep ",\n" cfg.extraActions}
      ],
    };
  '';

in
{
  options.services.scanservjs = {
    enable = lib.mkEnableOption "scanservjs";
    stateDir = lib.mkOption {
      type = lib.types.str;
      default = "/var/lib/scanservjs";
      description = ''
        State directory for scanservjs.
      '';
    };
    settings = lib.mkOption {
      default = { };
      description = ''
        Config to set in config.local.js's `afterConfig`.
      '';
      type = lib.types.submodule {
        freeformType = settingsFormat.type;
        options.host = lib.mkOption {
          type = lib.types.str;
          description = "The IP to listen on.";
          default = "127.0.0.1";
        };
        options.port = lib.mkOption {
          type = lib.types.port;
          description = "The port to listen on.";
          default = 8080;
        };
      };
    };
    extraConfig = lib.mkOption {
      default = "";
      type = lib.types.lines;
      description = ''
        Extra code to add to config.local.js's `afterConfig`.
      '';
    };
    extraDevicesConfig = lib.mkOption {
      default = "";
      type = lib.types.lines;
      description = ''
        Extra code to add to config.local.js's `afterDevices`.
      '';
    };
    runAfterScan = lib.mkOption {
      default = "";
      type = lib.types.lines;
      description = ''
        Extra code to add to config.local.js's `afterScan`.
      '';
    };
    extraActions = lib.mkOption {
      default = [ ];
      type = lib.types.listOf lib.types.lines;
      description = "Actions to add to config.local.js's `actions`.";
    };
  };

  config = lib.mkIf cfg.enable {
    hardware.sane.enable = true;
    users.users.scanservjs = {
      group = "scanservjs";
      extraGroups = [
        "scanner"
        "lp"
      ];
      home = cfg.stateDir;
      isSystemUser = true;
      createHome = true;
    };
    users.groups.scanservjs = { };

    systemd.tmpfiles.rules = [
      "d ${cfg.stateDir}/data 0755 scanservjs scanservjs - -"
      "d ${cfg.stateDir}/data/preview 0755 scanservjs scanservjs - -"
      "L+ ${cfg.stateDir}/data/preview/default.jpg - - - - ${package}/lib/data/preview/default.jpg"
    ];

    systemd.services.scanservjs = {
      description = "scanservjs";
      after = [ "network.target" ];
      wantedBy = [ "multi-user.target" ];
      # yes, those paths are configurable, but the config option isn't always used...
      # a lot of the time scanservjs just takes those from PATH
      path = with pkgs; [
        coreutils
        config.hardware.sane.backends-package
        imagemagick
        tesseract
      ];
      environment = {
        NIX_SCANSERVJS_CONFIG_PATH = configFile;
        SANE_CONFIG_DIR = "/etc/sane-config";
        LD_LIBRARY_PATH = "/etc/sane-libs";
      };
      serviceConfig = {
        ExecStart = lib.getExe package;
        Restart = "always";
        User = "scanservjs";
        Group = "scanservjs";
        WorkingDirectory = cfg.stateDir;
      };
    };
  };
}
