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

let
  cfg = config.services.guix;

  package = cfg.package.override { inherit (cfg) stateDir storeDir; };

  guixBuildUser = id: {
    name = "guixbuilder${toString id}";
    group = cfg.group;
    extraGroups = [ cfg.group ];
    createHome = false;
    description = "Guix build user ${toString id}";
    isSystemUser = true;
  };

  guixBuildUsers = numberOfUsers:
    builtins.listToAttrs (map
      (user: {
        name = user.name;
        value = user;
      })
      (builtins.genList guixBuildUser numberOfUsers));

  # A set of Guix user profiles to be linked at activation. All of these should
  # be default profiles managed by Guix CLI and the profiles are located in
  # `${cfg.stateDir}/profiles/per-user/$USER/$PROFILE`.
  guixUserProfiles = {
    # The default Guix profile managed by `guix pull`. Take note this should be
    # the profile with the most precedence in `PATH` env to let users use their
    # updated versions of `guix` CLI.
    "current-guix" = "\${XDG_CONFIG_HOME}/guix/current";

    # The default Guix home profile. This profile contains more than exports
    # such as an activation script at `$GUIX_HOME_PROFILE/activate`.
    "guix-home" = "$HOME/.guix-home/profile";

    # The default Guix profile similar to $HOME/.nix-profile from Nix.
    "guix-profile" = "$HOME/.guix-profile";
  };

  # All of the Guix profiles to be used.
  guixProfiles = lib.attrValues guixUserProfiles;

  serviceEnv = {
    GUIX_LOCPATH = "${cfg.stateDir}/guix/profiles/per-user/root/guix-profile/lib/locale";
    LC_ALL = "C.UTF-8";
  };

  # Currently, this is just done the lazy way with the official Guix script. A
  # more "formal" way would be creating our own Guix script to handle and
  # generate the ACL file ourselves.
  aclFile = pkgs.runCommandLocal "guix-acl" { } ''
    export GUIX_CONFIGURATION_DIRECTORY=./
    for official_server_keys in ${lib.concatStringsSep " " cfg.substituters.authorizedKeys}; do
      ${lib.getExe' cfg.package "guix"} archive --authorize < "$official_server_keys"
    done
    install -Dm0600 ./acl "$out"
  '';
in
{
  meta.maintainers = with lib.maintainers; [ foo-dogsquared ];

  options.services.guix = with lib; {
    enable = mkEnableOption "Guix build daemon service";

    group = mkOption {
      type = types.str;
      default = "guixbuild";
      example = "guixbuild";
      description = ''
        The group of the Guix build user pool.
      '';
    };

    nrBuildUsers = mkOption {
      type = types.ints.unsigned;
      description = ''
        Number of Guix build users to be used in the build pool.
      '';
      default = 10;
      example = 20;
    };

    extraArgs = mkOption {
      type = with types; listOf str;
      default = [ ];
      example = [ "--max-jobs=4" "--debug" ];
      description = ''
        Extra flags to pass to the Guix daemon service.
      '';
    };

    package = mkPackageOption pkgs "guix" {
      extraDescription = ''
        It should contain {command}`guix-daemon` and {command}`guix`
        executable.
      '';
    };

    storeDir = mkOption {
      type = types.path;
      default = "/gnu/store";
      description = ''
        The store directory where the Guix service will serve to/from. Take
        note Guix cannot take advantage of substitutes if you set it something
        other than {file}`/gnu/store` since most of the cached builds are
        assumed to be in there.

        ::: {.warning}
        This will also recompile all packages because the normal cache no
        longer applies.
        :::
      '';
    };

    stateDir = mkOption {
      type = types.path;
      default = "/var";
      description = ''
        The state directory where Guix service will store its data such as its
        user-specific profiles, cache, and state files.

        ::: {.warning}
        Changing it to something other than the default will rebuild the
        package.
        :::
      '';
      example = "/gnu/var";
    };

    substituters = {
      urls = lib.mkOption {
        type = with lib.types; listOf str;
        default = [
          "https://ci.guix.gnu.org"
          "https://bordeaux.guix.gnu.org"
          "https://berlin.guix.gnu.org"
        ];
        example = lib.literalExpression ''
          options.services.guix.substituters.urls.default ++ [
            "https://guix.example.com"
            "https://guix.example.org"
          ]
        '';
        description = ''
          A list of substitute servers' URLs for the Guix daemon to download
          substitutes from.
        '';
      };

      authorizedKeys = lib.mkOption {
        type = with lib.types; listOf path;
        default = [
          "${cfg.package}/share/guix/ci.guix.gnu.org.pub"
          "${cfg.package}/share/guix/bordeaux.guix.gnu.org.pub"
          "${cfg.package}/share/guix/berlin.guix.gnu.org.pub"
        ];
        defaultText = ''
          The packaged signing keys from {option}`services.guix.package`.
        '';
        example = lib.literalExpression ''
          options.services.guix.substituters.authorizedKeys.default ++ [
            (builtins.fetchurl {
              url = "https://guix.example.com/signing-key.pub";
            })

            (builtins.fetchurl {
              url = "https://guix.example.org/static/signing-key.pub";
            })
          ]
        '';
        description = ''
          A list of signing keys for each substitute server to be authorized as
          a source of substitutes. Without this, the listed substitute servers
          from {option}`services.guix.substituters.urls` would be ignored [with
          some
          exceptions](https://guix.gnu.org/manual/en/html_node/Substitute-Authentication.html).
        '';
      };
    };

    publish = {
      enable = mkEnableOption "substitute server for your Guix store directory";

      generateKeyPair = mkOption {
        type = types.bool;
        description = ''
          Whether to generate signing keys in {file}`/etc/guix` which are
          required to initialize a substitute server. Otherwise,
          `--public-key=$FILE` and `--private-key=$FILE` can be passed in
          {option}`services.guix.publish.extraArgs`.
        '';
        default = true;
        example = false;
      };

      port = mkOption {
        type = types.port;
        default = 8181;
        example = 8200;
        description = ''
          Port of the substitute server to listen on.
        '';
      };

      user = mkOption {
        type = types.str;
        default = "guix-publish";
        description = ''
          Name of the user to change once the server is up.
        '';
      };

      extraArgs = mkOption {
        type = with types; listOf str;
        description = ''
          Extra flags to pass to the substitute server.
        '';
        default = [];
        example = [
          "--compression=zstd:6"
          "--discover=no"
        ];
      };
    };

    gc = {
      enable = mkEnableOption "automatic garbage collection service for Guix";

      extraArgs = mkOption {
        type = with types; listOf str;
        default = [ ];
        description = ''
          List of arguments to be passed to {command}`guix gc`.

          When given no option, it will try to collect all garbage which is
          often inconvenient so it is recommended to set [some
          options](https://guix.gnu.org/en/manual/en/html_node/Invoking-guix-gc.html).
        '';
        example = [
          "--delete-generations=1m"
          "--free-space=10G"
          "--optimize"
        ];
      };

      dates = lib.mkOption {
        type = types.str;
        default = "03:15";
        example = "weekly";
        description = ''
          How often the garbage collection occurs. This takes the time format
          from {manpage}`systemd.time(7)`.
        '';
      };
    };
  };

  config = lib.mkIf cfg.enable (lib.mkMerge [
    {
      environment.systemPackages = [ package ];

      users.users = guixBuildUsers cfg.nrBuildUsers;
      users.groups.${cfg.group} = { };

      # Guix uses Avahi (through guile-avahi) both for the auto-discovering and
      # advertising substitute servers in the local network.
      services.avahi.enable = lib.mkDefault true;
      services.avahi.publish.enable = lib.mkDefault true;
      services.avahi.publish.userServices = lib.mkDefault true;

      # It's similar to Nix daemon so there's no question whether or not this
      # should be sandboxed.
      systemd.services.guix-daemon = {
        environment = serviceEnv;
        script = ''
          exec ${lib.getExe' package "guix-daemon"} \
            --build-users-group=${cfg.group} \
            ${lib.optionalString (cfg.substituters.urls != [ ])
              "--substitute-urls='${lib.concatStringsSep " " cfg.substituters.urls}'"} \
            ${lib.escapeShellArgs cfg.extraArgs}
        '';
        serviceConfig = {
          OOMPolicy = "continue";
          RemainAfterExit = "yes";
          Restart = "always";
          TasksMax = 8192;
        };
        unitConfig.RequiresMountsFor = [
          cfg.storeDir
          cfg.stateDir
        ];
        wantedBy = [ "multi-user.target" ];
      };

      # This is based from Nix daemon socket unit from upstream Nix package.
      # Guix build daemon has support for systemd-style socket activation.
      systemd.sockets.guix-daemon = {
        description = "Guix daemon socket";
        before = [ "multi-user.target" ];
        listenStreams = [ "${cfg.stateDir}/guix/daemon-socket/socket" ];
        unitConfig.RequiresMountsFor = [ cfg.storeDir cfg.stateDir ];
        wantedBy = [ "sockets.target" ];
      };

      systemd.mounts = [{
        description = "Guix read-only store directory";
        before = [ "guix-daemon.service" ];
        what = cfg.storeDir;
        where = cfg.storeDir;
        type = "none";
        options = "bind,ro";

        unitConfig.DefaultDependencies = false;
        wantedBy = [ "guix-daemon.service" ];
      }];

      # Make transferring files from one store to another easier with the usual
      # case being of most substitutes from the official Guix CI instance.
      environment.etc."guix/acl".source = aclFile;

      # Link the usual Guix profiles to the home directory. This is useful in
      # ephemeral setups where only certain part of the filesystem is
      # persistent (e.g., "Erase my darlings"-type of setup).
      system.userActivationScripts.guix-activate-user-profiles.text = let
        guixProfile = profile: "${cfg.stateDir}/guix/profiles/per-user/\${USER}/${profile}";
        linkProfile = profile: location: let
          userProfile = guixProfile profile;
        in ''
          [ -d "${userProfile}" ] && ln -sfn "${userProfile}" "${location}"
        '';
        linkProfileToPath = acc: profile: location:
          acc + (linkProfile profile location);

        # This should contain export-only Guix user profiles. The rest of it is
        # handled manually in the activation script.
        guixUserProfiles' = lib.attrsets.removeAttrs guixUserProfiles [ "guix-home" ];

        linkExportsScript = lib.foldlAttrs linkProfileToPath "" guixUserProfiles';
      in ''
        # Don't export this please! It is only expected to be used for this
        # activation script and nothing else.
        XDG_CONFIG_HOME=''${XDG_CONFIG_HOME:-$HOME/.config}

        # Linking the usual Guix profiles into the home directory.
        ${linkExportsScript}

        # Activate all of the default Guix non-exports profiles manually.
        ${linkProfile "guix-home" "$HOME/.guix-home"}
        [ -L "$HOME/.guix-home" ] && "$HOME/.guix-home/activate"
      '';

      # GUIX_LOCPATH is basically LOCPATH but for Guix libc which in turn used by
      # virtually every Guix-built packages. This is so that Guix-installed
      # applications wouldn't use incompatible locale data and not touch its host
      # system.
      environment.sessionVariables.GUIX_LOCPATH = lib.makeSearchPath "lib/locale" guixProfiles;

      # What Guix profiles export is very similar to Nix profiles so it is
      # acceptable to list it here. Also, it is more likely that the user would
      # want to use packages explicitly installed from Guix so we're putting it
      # first.
      environment.profiles = lib.mkBefore guixProfiles;
    }

    (lib.mkIf cfg.publish.enable {
      systemd.services.guix-publish = {
        description = "Guix remote store";
        environment = serviceEnv;

        # Mounts will be required by the daemon service anyways so there's no
        # need add RequiresMountsFor= or something similar.
        requires = [ "guix-daemon.service" ];
        after = [ "guix-daemon.service" ];
        partOf = [ "guix-daemon.service" ];

        preStart = lib.mkIf cfg.publish.generateKeyPair ''
          # Generate the keypair if it's missing.
          [ -f "/etc/guix/signing-key.sec" ] && [ -f "/etc/guix/signing-key.pub" ] || \
            ${lib.getExe' package "guix"} archive --generate-key || {
              rm /etc/guix/signing-key.*;
              ${lib.getExe' package "guix"} archive --generate-key;
            }
        '';
        script = ''
          exec ${lib.getExe' package "guix"} publish \
            --user=${cfg.publish.user} --port=${builtins.toString cfg.publish.port} \
            ${lib.escapeShellArgs cfg.publish.extraArgs}
        '';

        serviceConfig = {
          Restart = "always";
          RestartSec = 10;

          ProtectClock = true;
          ProtectHostname = true;
          ProtectKernelTunables = true;
          ProtectKernelModules = true;
          ProtectControlGroups = true;
          SystemCallFilter = [
            "@system-service"
            "@debug"
            "@setuid"
          ];

          RestrictNamespaces = true;
          RestrictAddressFamilies = [
            "AF_UNIX"
            "AF_INET"
            "AF_INET6"
          ];

          # While the permissions can be set, it is assumed to be taken by Guix
          # daemon service which it has already done the setup.
          ConfigurationDirectory = "guix";

          AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
          CapabilityBoundingSet = [
            "CAP_NET_BIND_SERVICE"
            "CAP_SETUID"
            "CAP_SETGID"
          ];
        };
        wantedBy = [ "multi-user.target" ];
      };

      users.users.guix-publish = lib.mkIf (cfg.publish.user == "guix-publish") {
        description = "Guix publish user";
        group = config.users.groups.guix-publish.name;
        isSystemUser = true;
      };
      users.groups.guix-publish = {};
    })

    (lib.mkIf cfg.gc.enable {
      # This service should be handled by root to collect all garbage by all
      # users.
      systemd.services.guix-gc = {
        description = "Guix garbage collection";
        startAt = cfg.gc.dates;
        script = ''
          exec ${lib.getExe' package "guix"} gc ${lib.escapeShellArgs cfg.gc.extraArgs}
        '';
        serviceConfig = {
          Type = "oneshot";
          PrivateDevices = true;
          PrivateNetwork = true;
          ProtectControlGroups = true;
          ProtectHostname = true;
          ProtectKernelTunables = true;
          SystemCallFilter = [
            "@default"
            "@file-system"
            "@basic-io"
            "@system-service"
          ];
        };
      };

      systemd.timers.guix-gc.timerConfig.Persistent = true;
    })
  ]);
}
