{
  utils,
  config,
  options,
  lib,
  pkgs,
  ...
}:
let
  cfg = config.services.pangolin;
  format = pkgs.formats.yaml { };
  finalSettings = lib.attrsets.recursiveUpdate options.services.pangolin.settings.default cfg.settings;
  cfgFile = format.generate "config.yml" finalSettings;
  # override the type to allow for optionality
  nullOrOpt = t: lib.types.nullOr t // { _optional = true; };

  gerbil-wg0-fix-script = pkgs.writeShellApplication {
    name = "gerbil-wg0-fix-script";
    runtimeInputs = with pkgs; [
      coreutils
      iproute2
    ];
    # will not work if the interface is renamed
    # https://github.com/fosrl/newt/issues/37#issuecomment-3193385911
    text = ''
      if [ ! -f /var/lib/pangolin/config/wg0 ]; then
          until ip l d wg0
          do
            sleep 2
          done
          touch /var/lib/pangolin/config/wg0
          systemctl restart gerbil --no-block
      fi
    '';
  };
in
{
  options.services = {
    pangolin = {
      enable = lib.mkEnableOption "Pangolin reverse proxy server";
      package = lib.mkPackageOption pkgs "fosrl-pangolin" { };

      settings = lib.mkOption {
        inherit (format) type;
        default = {
          app.dashboard_url = "https://${cfg.dashboardDomain}";
          domains.domain1 = {
            base_domain = cfg.baseDomain;
            prefer_wildcard_cert = false;
          };
          server = {
            external_port = 3000;
            internal_port = 3001;
            next_port = 3002;
            integration_port = 3003;
            # needs to be set, otherwise this fails silently
            # see https://github.com/fosrl/newt/issues/37
            internal_hostname = "localhost";
          };
          gerbil.base_endpoint = cfg.dashboardDomain;
          flags = {
            disable_signup_without_invite = true;
            enable_integration_api = false;
          };
        };
        defaultText = lib.literalExpression ''
          {
            app.dashboard_url = "https://''${config.services.pangolin.dashboardDomain}";
            domains.domain1 = {
              base_domain = cfg.baseDomain;
              prefer_wildcard_cert = false;
            };
            server = {
              external_port = 3000;
              internal_port = 3001;
              next_port = 3002;
              integration_port = 3003;
              # needs to be set, otherwise this fails silently
              # see https://github.com/fosrl/newt/issues/37
              internal_hostname = "localhost";
            };
            gerbil.base_endpoint = config.services.pangolin.dashboardDomain;
            flags = {
              disable_signup_without_invite = true;
              enable_integration_api = false;
            };
          }
        '';
        description = ''
          Additional attributes to be merged with the configuration options and written to Pangolin's {file}`config.yml` file.
        '';
        example = {
          app = {
            save_logs = true;
          };
          server = {
            external_port = 3007;
            internal_port = 3008;
          };
          domains.domain1 = {
            prefer_wildcard_cert = true;
          };
        };
      };

      openFirewall = lib.mkEnableOption "opening TCP ports 80 and 443, and UDP port 51820 in the firewall for the Pangolin service(s)";

      baseDomain = lib.mkOption {
        type = with lib.types; nullOr str;
        default = null;
        description = ''
          Your base fully qualified domain name (without any subdomains).
        '';
        example = "example.com";
      };

      dashboardDomain = lib.mkOption {
        type = lib.types.str;
        default = if (isNull cfg.baseDomain) then "" else "pangolin.${cfg.baseDomain}";
        defaultText = "pangolin.\${config.services.pangolin.baseDomain}";
        description = ''
          The domain where the application will be hosted. This is used for many things, including generating links. You can run Pangolin on a subdomain or root domain. Do not prefix with `http` or `https`.
        '';
        example = "auth.example.com";
      };

      letsEncryptEmail = lib.mkOption {
        type = with lib.types; nullOr str;
        default = config.security.acme.defaults.email;
        defaultText = lib.literalExpression "config.security.acme.defaults.email";
        description = ''
          An email address for SSL certificate registration with Let's Encrypt. This should be an email you have access to.
        '';
      };

      # this assumes that all domains are hosted by the same provider
      dnsProvider = lib.mkOption {
        type = nullOrOpt lib.types.str;
        default = null;
        description = ''
          The DNS provider Traefik will request wildcard certificates from. See the [Traefik Documentation](https://doc.traefik.io/traefik/https/acme/#providers) for more information.
        '';
      };

      # provide path to file to keep secrets out of the nix store
      environmentFile = lib.mkOption {
        type = with lib.types; nullOr path;
        default = null;
        description = ''
          Path to a file containing sensitive environment variables for Pangolin. See the [Pangolin Documentation](https://docs.fossorial.io/Pangolin/Configuration/config) for more information.
          These will overwrite anything defined in the config.
          The file should contain environment-variable assignments like:
          ```
          SERVER_SECRET=1234567890abc
          ```
        '';
        example = "/etc/nixos/secrets/pangolin.env";
      };

      dataDir = lib.mkOption {
        type = lib.types.str;
        default = "/var/lib/pangolin";
        example = "/srv/pangolin";
        description = "Path to variable state data directory for Pangolin.";
      };
    };
    gerbil = {
      port = lib.mkOption {
        type = lib.types.port;
        default = 3004;
        description = ''
          Specifies the port to listen on for Gerbil.
        '';
      };

      environmentFile = lib.mkOption {
        type = nullOrOpt lib.types.path;
        default = null;
        description = ''
          Path to a file containing sensitive environment variables for Gerbil. See the [Gerbil Documentation](https://docs.fossorial.io/Pangolin/Configuration/config) for more information.
          These will overwrite anything defined in the config.
        '';
        example = "/etc/nixos/secrets/gerbil.env";
      };
    };
  };

  config = lib.mkIf cfg.enable {

    assertions =
      (lib.mapAttrsToList (name: value: {
        # check if the value is optional by looking at the type
        assertion = (value == null) -> options.services.pangolin."${name}".type._optional or false;
        message = "services.pangolin.${name} must be provided when Pangolin is enabled.";
      }) cfg)
      ++ [
        {
          # wildcards implies (dnsProvider and traefikEnvironmentFile)
          assertion =
            (finalSettings.traefik.prefer_wildcard_cert or finalSettings.domains.domain1.prefer_wildcard_cert)
            -> (cfg.dnsProvider != "" && config.services.traefik.environmentFiles != [ ]);
          message = "services.pangolin.dnsProvider and services.traefik.environmentFile must be provided when prefer_wildcard_cert is true.";
        }
      ];

    networking.firewall = lib.mkIf cfg.openFirewall {
      allowedTCPPorts = [
        80
        443
      ];
      allowedUDPPorts = [ 51820 ];
    };

    users = {
      users = {
        pangolin = {
          description = "Pangolin service user";
          group = "fossorial";
          isSystemUser = true;
          packages = [ cfg.package ];
        };
        gerbil = {
          description = "Gerbil service user";
          group = "fossorial";
          isSystemUser = true;
        };
      };
      groups.fossorial = {
        members = [
          "pangolin"
          "gerbil"
          "traefik"
        ];
      };
    };
    # order is as follows
    # "pangolin.service"
    # "gerbil.service"
    # "traefik.service"
    ### TODO:
    # make tunnels declarative by calling API
    ###
    systemd = {
      tmpfiles.settings."10-fossorial-paths" = {
        "${cfg.dataDir}".d = {
          user = "pangolin";
          group = "fossorial";
          mode = "0770";
        };
        "${cfg.dataDir}/config".d = {
          user = "pangolin";
          group = "fossorial";
          mode = "0770";
        };
        "${cfg.dataDir}/config/letsencrypt".d = {
          user = "traefik";
          group = "fossorial";
          mode = "0700";
        };
      };
      services = {
        pangolin = {
          description = "Pangolin reverse proxy tunneling service";
          wantedBy = [ "multi-user.target" ];
          requires = [ "network.target" ];
          after = [ "network.target" ];

          preStart = ''
            mkdir -p ${cfg.dataDir}/config
            cp -f ${cfgFile} ${cfg.dataDir}/config/config.yml
          '';

          serviceConfig = {
            User = "pangolin";
            Group = "fossorial";
            WorkingDirectory = cfg.dataDir;
            Restart = "always";
            EnvironmentFile = cfg.environmentFile;
            # hardening
            ProtectSystem = "full";
            ProtectHome = true;
            PrivateTmp = "disconnected";
            PrivateDevices = true;
            PrivateMounts = true;
            ProtectKernelTunables = true;
            ProtectKernelModules = true;
            ProtectKernelLogs = true;
            ProtectControlGroups = true;
            LockPersonality = true;
            RestrictRealtime = true;
            ProtectClock = true;
            ProtectProc = "noaccess";
            ProtectHostname = true;
            NoNewPrivileges = true;
            RestrictSUIDSGID = true;
            RestrictAddressFamilies = [
              "AF_INET"
              "AF_INET6"
              "AF_NETLINK"
              "AF_UNIX"
            ];
            SocketBindDeny = [
              "ipv4:tcp"
              "ipv4:udp"
              "ipv6:udp"
            ];
            CapabilityBoundingSet = [
              "~CAP_BLOCK_SUSPEND"
              "~CAP_BPF"
              "~CAP_CHOWN"
              "~CAP_MKNOD"
              "~CAP_NET_RAW"
              "~CAP_PERFMON"
              "~CAP_SYS_BOOT"
              "~CAP_SYS_CHROOT"
              "~CAP_SYS_MODULE"
              "~CAP_SYS_NICE"
              "~CAP_SYS_PACCT"
              "~CAP_SYS_PTRACE"
              "~CAP_SYS_TIME"
              "~CAP_SYSLOG"
              "~CAP_WAKE_ALARM"
            ];
            SystemCallFilter = [
              "~@chown:EPERM"
              "~@clock:EPERM"
              "~@cpu-emulation:EPERM"
              "~@debug:EPERM"
              "~@keyring:EPERM"
              "~@memlock:EPERM"
              "~@module:EPERM"
              "~@mount:EPERM"
              "~@obsolete:EPERM"
              "~@pkey:EPERM"
              "~@privileged:EPERM"
              "~@raw-io:EPERM"
              "~@reboot:EPERM"
              "~@resources:EPERM"
              "~@sandbox:EPERM"
              "~@setuid:EPERM"
              "~@swap:EPERM"
              "~@timer:EPERM"
            ];
            ExecStart = lib.getExe cfg.package;
          };
        };
        gerbil = {
          description = "Gerbil Service";
          wantedBy = [ "multi-user.target" ];
          after = [ "pangolin.service" ];
          requires = [ "pangolin.service" ];
          before = [ "traefik.service" ];
          requiredBy = [ "traefik.service" ];
          # restarting gerbil restarts traefik
          upholds = [ "traefik.service" ];

          # provide default to use correct port without envfile
          environment = {
            LISTEN = "localhost:" + toString config.services.gerbil.port;
          };

          serviceConfig = {
            User = "gerbil";
            Group = "fossorial";
            WorkingDirectory = cfg.dataDir;
            Restart = "always";
            EnvironmentFile = cfg.environmentFile;
            ReadWritePaths = "${cfg.dataDir}/config";
            # hardening
            AmbientCapabilities = [
              "CAP_NET_ADMIN"
              "CAP_SYS_MODULE"
            ];
            CapabilityBoundingSet = [
              "CAP_NET_ADMIN"
              "CAP_SYS_MODULE"
              "~CAP_BLOCK_SUSPEND"
              "~CAP_BPF"
              "~CAP_CHOWN"
              "~CAP_MKNOD"
              "~CAP_PERFMON"
              "~CAP_SYS_BOOT"
              "~CAP_SYS_CHROOT"
              "~CAP_SYS_NICE"
              "~CAP_SYS_PACCT"
              "~CAP_SYS_PTRACE"
              "~CAP_SYS_TIME"
              "~CAP_SYS_TTY_CONFIG"
              "~CAP_SYSLOG"
              "~CAP_WAKE_ALARM"
            ];
            ProtectSystem = "full";
            ProtectHome = true;
            PrivateTmp = "disconnected";
            PrivateDevices = true;
            PrivateMounts = true;
            ProtectKernelTunables = true;
            ProtectKernelModules = true;
            ProtectKernelLogs = true;
            ProtectControlGroups = true;
            LockPersonality = true;
            RestrictRealtime = true;
            ProtectClock = true;
            ProtectProc = "noaccess";
            ProtectHostname = true;
            NoNewPrivileges = true;
            RestrictSUIDSGID = true;
            MemoryDenyWriteExecute = true;
            RestrictAddressFamilies = [
              "AF_INET"
              "AF_INET6"
              "AF_NETLINK"
              "AF_UNIX"
            ];
            SystemCallFilter = [
              "~@aio:EPERM"
              "~@chown:EPERM"
              "~@clock:EPERM"
              "~@cpu-emulation:EPERM"
              "~@debug:EPERM"
              "~@keyring:EPERM"
              "~@memlock:EPERM"
              "~@mount:EPERM"
              "~@obsolete:EPERM"
              "~@pkey:EPERM"
              "~@privileged:EPERM"
              "~@raw-io:EPERM"
              "~@reboot:EPERM"
              "~@resources:EPERM"
              "~@sandbox:EPERM"
              "~@setuid:EPERM"
              "~@swap:EPERM"
              "~@sync:EPERM"
              "~@timer:EPERM"
            ];
            ExecStart = utils.escapeSystemdExecArgs [
              (lib.getExe pkgs.fosrl-gerbil)
              "--reachableAt=http://localhost:${toString config.services.gerbil.port}"
              "--generateAndSaveKeyTo=${toString cfg.dataDir}/config/key"
              "--remoteConfig=http://localhost:${toString finalSettings.server.internal_port}/api/v1/gerbil/get-config"
            ];
            # will not work if the interface is renamed
            # https://github.com/fosrl/newt/issues/37#issuecomment-3193385911
            ExecStartPost = lib.getExe gerbil-wg0-fix-script;
          };
        };
        traefik = {
          wantedBy = [ "multi-user.target" ];
          after = [ "gerbil.service" ];
          requires = [ "gerbil.service" ];
          partOf = [ "gerbil.service" ];
        };
      };
    };

    services.traefik = {
      enable = true;
      group = "fossorial";
      dataDir = "${cfg.dataDir}/config/traefik";
      staticConfigOptions = {
        providers.http = {
          endpoint = "http://localhost:${toString finalSettings.server.internal_port}/api/v1/traefik-config";
          pollInterval = "5s";
        };
        # TODO to change this once #437073 is merged.
        experimental.plugins.badger = {
          moduleName = "github.com/fosrl/badger";
          version = "v1.2.0";
        };
        certificatesResolvers.letsencrypt.acme =
          (
            if finalSettings.domains.domain1.prefer_wildcard_cert then
              {
                # see https://doc.traefik.io/traefik/https/acme/#providers
                dnsChallenge.provider = cfg.dnsProvider;
              }
            else
              {
                httpChallenge.entryPoint = "web";
              }
          )
          //
          # common
          {
            email = cfg.letsEncryptEmail;
            storage = "${cfg.dataDir}/config/letsencrypt/acme.json";
            caServer = "https://acme-v02.api.letsencrypt.org/directory";
          };
        entryPoints = {
          web.address = ":80";
          websecure = {
            address = ":443";
            transport.respondingTimeouts.readTimeout = "30m";
            http.tls.certResolver = "letsencrypt";
          };
        };
      };
      dynamicConfigOptions = {
        http = {
          middlewares.redirect-to-https.redirectScheme.scheme = "https";
          routers = {
            # HTTP to HTTPS redirect router
            main-app-router-redirect = {
              rule = "Host(`${cfg.dashboardDomain}`)";
              service = "next-service";
              entryPoints = [ "web" ];
              middlewares = [ "redirect-to-https" ];
            };
            # Next.js router (handles everything except API and WebSocket paths)
            next-router = {
              rule = "Host(`${cfg.dashboardDomain}`) && !PathPrefix(`/api/v1`)";
              service = "next-service";
              entryPoints = [ "websecure" ];
              tls =
                lib.optionalAttrs (finalSettings.domains.domain1.prefer_wildcard_cert) {
                  domains = [
                    { main = cfg.baseDomain; }
                    { sans = "*.${cfg.baseDomain}"; }
                  ];
                }
                //
                # common
                {
                  certResolver = "letsencrypt";
                };
            };
            # API router (handles /api/v1 paths)
            api-router = {
              rule = "Host(`${cfg.dashboardDomain}`) && PathPrefix(`/api/v1`)";
              service = "api-service";
              entryPoints = [ "websecure" ];
              tls.certResolver = "letsencrypt";
            };
            # WebSocket router
            ws-router = {
              rule = "Host(`${cfg.dashboardDomain}`)";
              service = "api-service";
              entryPoints = [ "websecure" ];
              tls.certResolver = "letsencrypt";
            };
            # Integration API router
            int-api-router-redirect = {
              rule = "Host(`api.${cfg.baseDomain}`)";
              service = "int-api-service";
              entryPoints = [ "web" ];
              middlewares = [ "redirect-to-https" ];
            };
            int-api-router = {
              rule = "Host(`api.${cfg.baseDomain}`)";
              service = "int-api-service";
              entryPoints = [ "websecure" ];
              tls.certResolver = "letsencrypt";
            };
          };
          # needs to be a mkMerge otherwise will give error about standalone element
          services = lib.mkMerge [
            {
              # Next.js server
              next-service.loadBalancer.servers = [
                { url = "http://localhost:${toString finalSettings.server.next_port}"; }
              ];
              # API/WebSocket server
              api-service.loadBalancer.servers = [
                { url = "http://localhost:${toString finalSettings.server.external_port}"; }
              ];
            }
            (lib.mkIf (finalSettings.flags.enable_integration_api) {
              # Integration API server
              int-api-service.loadBalancer.servers = [
                { url = "http://localhost:${toString finalSettings.server.integration_port}"; }
              ];
            })
          ];
        };
      };
    };
  };

  meta.maintainers = with lib.maintainers; [
    jackr
    water-sucks
  ];
}
