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

let
  settingsFormat = pkgs.formats.yaml {};

  upperConfig = config;
  cfg = config.services.mautrix-meta;
  upperCfg = cfg;

  fullDataDir = cfg: "/var/lib/${cfg.dataDir}";

  settingsFile = cfg: "${fullDataDir cfg}/config.yaml";
  settingsFileUnsubstituted = cfg: settingsFormat.generate "mautrix-meta-config.yaml" cfg.settings;

  metaName = name: "mautrix-meta-${name}";

  enabledInstances = lib.filterAttrs (name: config: config.enable) config.services.mautrix-meta.instances;
  registerToSynapseInstances = lib.filterAttrs (name: config: config.enable && config.registerToSynapse) config.services.mautrix-meta.instances;
in {
  options = {
    services.mautrix-meta = {

      package = lib.mkPackageOption pkgs "mautrix-meta" { };

      instances = lib.mkOption {
        type = lib.types.attrsOf (lib.types.submodule ({ config, name, ... }: {

          options = {

            enable = lib.mkEnableOption "Mautrix-Meta, a Matrix <-> Facebook and Matrix <-> Instagram hybrid puppeting/relaybot bridge";

            dataDir = lib.mkOption {
              type = lib.types.str;
              default = metaName name;
              description = ''
                Path to the directory with database, registration, and other data for the bridge service.
                This path is relative to `/var/lib`, it cannot start with `../` (it cannot be outside of `/var/lib`).
              '';
            };

            registrationFile = lib.mkOption {
              type = lib.types.path;
              readOnly = true;
              description = ''
                Path to the yaml registration file of the appservice.
              '';
            };

            registerToSynapse = lib.mkOption {
              type = lib.types.bool;
              default = true;
              description = ''
                Whether to add registration file to `services.matrix-synapse.settings.app_service_config_files` and
                make Synapse wait for registration service.
              '';
            };

            settings = lib.mkOption rec {
              apply = lib.recursiveUpdate default;
              inherit (settingsFormat) type;
              default = {
                homeserver = {
                  software = "standard";

                  domain = "";
                  address = "";
                };

                appservice = {
                  id = "";

                  database = {
                    type = "sqlite3-fk-wal";
                    uri = "file:${fullDataDir config}/mautrix-meta.db?_txlock=immediate";
                  };

                  bot = {
                    username = "";
                  };

                  hostname = "localhost";
                  port = 29319;
                  address = "http://${config.settings.appservice.hostname}:${toString config.settings.appservice.port}";
                };

                meta = {
                  mode = "";
                };

                bridge = {
                  # Enable encryption by default to make the bridge more secure
                  encryption = {
                    allow = true;
                    default = true;
                    require = true;

                    # Recommended options from mautrix documentation
                    # for additional security.
                    delete_keys = {
                      dont_store_outbound = true;
                      ratchet_on_decrypt = true;
                      delete_fully_used_on_decrypt = true;
                      delete_prev_on_new_session = true;
                      delete_on_device_delete = true;
                      periodically_delete_expired = true;
                      delete_outdated_inbound = true;
                    };

                    verification_levels = {
                      receive = "cross-signed-tofu";
                      send = "cross-signed-tofu";
                      share = "cross-signed-tofu";
                    };
                  };

                  permissions = {};
                };

                logging = {
                  min_level = "info";
                  writers = lib.singleton {
                    type = "stdout";
                    format = "pretty-colored";
                    time_format = " ";
                  };
                };
              };
              defaultText = ''
              {
                homeserver = {
                  software = "standard";
                  address = "https://''${config.settings.homeserver.domain}";
                };

                appservice = {
                  database = {
                    type = "sqlite3-fk-wal";
                    uri = "file:''${fullDataDir config}/mautrix-meta.db?_txlock=immediate";
                  };

                  hostname = "localhost";
                  port = 29319;
                  address = "http://''${config.settings.appservice.hostname}:''${toString config.settings.appservice.port}";
                };

                bridge = {
                  # Require encryption by default to make the bridge more secure
                  encryption = {
                    allow = true;
                    default = true;
                    require = true;

                    # Recommended options from mautrix documentation
                    # for optimal security.
                    delete_keys = {
                      dont_store_outbound = true;
                      ratchet_on_decrypt = true;
                      delete_fully_used_on_decrypt = true;
                      delete_prev_on_new_session = true;
                      delete_on_device_delete = true;
                      periodically_delete_expired = true;
                      delete_outdated_inbound = true;
                    };

                    verification_levels = {
                      receive = "cross-signed-tofu";
                      send = "cross-signed-tofu";
                      share = "cross-signed-tofu";
                    };
                  };
                };

                logging = {
                  min_level = "info";
                  writers = lib.singleton {
                    type = "stdout";
                    format = "pretty-colored";
                    time_format = " ";
                  };
                };
              };
              '';
              description = ''
                {file}`config.yaml` configuration as a Nix attribute set.
                Configuration options should match those described in
                [example-config.yaml](https://github.com/mautrix/meta/blob/main/example-config.yaml).

                Secret tokens should be specified using {option}`environmentFile`
                instead
              '';
            };

            environmentFile = lib.mkOption {
              type = lib.types.nullOr lib.types.path;
              default = null;
              description = ''
                File containing environment variables to substitute when copying the configuration
                out of Nix store to the `services.mautrix-meta.dataDir`.

                Can be used for storing the secrets without making them available in the Nix store.

                For example, you can set `services.mautrix-meta.settings.appservice.as_token = "$MAUTRIX_META_APPSERVICE_AS_TOKEN"`
                and then specify `MAUTRIX_META_APPSERVICE_AS_TOKEN="{token}"` in the environment file.
                This value will get substituted into the configuration file as as token.
              '';
            };

            serviceDependencies = lib.mkOption {
              type = lib.types.listOf lib.types.str;
              default =
                [ config.registrationServiceUnit ] ++
                (lib.lists.optional upperConfig.services.matrix-synapse.enable upperConfig.services.matrix-synapse.serviceUnit) ++
                (lib.lists.optional upperConfig.services.matrix-conduit.enable "matrix-conduit.service") ++
                (lib.lists.optional upperConfig.services.dendrite.enable "dendrite.service");

              defaultText = ''
                [ config.registrationServiceUnit ] ++
                (lib.lists.optional upperConfig.services.matrix-synapse.enable upperConfig.services.matrix-synapse.serviceUnit) ++
                (lib.lists.optional upperConfig.services.matrix-conduit.enable "matrix-conduit.service") ++
                (lib.lists.optional upperConfig.services.dendrite.enable "dendrite.service");
              '';
              description = ''
                List of Systemd services to require and wait for when starting the application service.
              '';
            };

            serviceUnit = lib.mkOption {
              type = lib.types.str;
              readOnly = true;
              description = ''
                The systemd unit (a service or a target) for other services to depend on if they
                need to be started after matrix-synapse.

                This option is useful as the actual parent unit for all matrix-synapse processes
                changes when configuring workers.
              '';
            };

            registrationServiceUnit = lib.mkOption {
              type = lib.types.str;
              readOnly = true;
              description = ''
                The registration service that generates the registration file.

                Systemd unit (a service or a target) for other services to depend on if they
                need to be started after mautrix-meta registration service.

                This option is useful as the actual parent unit for all matrix-synapse processes
                changes when configuring workers.
              '';
            };
          };

          config = {
            serviceUnit = (metaName name) + ".service";
            registrationServiceUnit = (metaName name) + "-registration.service";
            registrationFile = (fullDataDir config) + "/meta-registration.yaml";
          };
        }));

        description = ''
          Configuration of multiple `mautrix-meta` instances.
          `services.mautrix-meta.instances.facebook` and `services.mautrix-meta.instances.instagram`
          come preconfigured with meta.mode, appservice.id, bot username, display name and avatar.
        '';

        example = ''
          {
            facebook = {
              enable = true;
              settings = {
                homeserver.domain = "example.com";
              };
            };

            instagram = {
              enable = true;
              settings = {
                homeserver.domain = "example.com";
              };
            };

            messenger = {
              enable = true;
              settings = {
                meta.mode = "messenger";
                homeserver.domain = "example.com";
                appservice = {
                  id = "messenger";
                  bot = {
                    username = "messengerbot";
                    displayname = "Messenger bridge bot";
                    avatar = "mxc://maunium.net/ygtkteZsXnGJLJHRchUwYWak";
                  };
                };
              };
            };
          }
        '';
      };
    };
  };

  config = lib.mkMerge [
    (lib.mkIf (enabledInstances != {}) {
      assertions = lib.mkMerge (lib.attrValues (lib.mapAttrs (name: cfg: [
        {
          assertion = cfg.settings.homeserver.domain != "" && cfg.settings.homeserver.address != "";
          message = ''
            The options with information about the homeserver:
            `services.mautrix-meta.instances.${name}.settings.homeserver.domain` and
            `services.mautrix-meta.instances.${name}.settings.homeserver.address` have to be set.
          '';
        }
        {
          assertion = builtins.elem cfg.settings.meta.mode [ "facebook" "facebook-tor" "messenger" "instagram" ];
          message = ''
            The option `services.mautrix-meta.instances.${name}.settings.meta.mode` has to be set
            to one of: facebook, facebook-tor, messenger, instagram.
            This configures the mode of the bridge.
          '';
        }
        {
          assertion = cfg.settings.bridge.permissions != {};
          message = ''
            The option `services.mautrix-meta.instances.${name}.settings.bridge.permissions` has to be set.
          '';
        }
        {
          assertion = cfg.settings.appservice.id != "";
          message = ''
            The option `services.mautrix-meta.instances.${name}.settings.appservice.id` has to be set.
          '';
        }
        {
          assertion = cfg.settings.appservice.bot.username != "";
          message = ''
            The option `services.mautrix-meta.instances.${name}.settings.appservice.bot.username` has to be set.
          '';
        }
      ]) enabledInstances));

      users.users = lib.mapAttrs' (name: cfg: lib.nameValuePair "mautrix-meta-${name}" {
        isSystemUser = true;
        group = "mautrix-meta";
        extraGroups = [ "mautrix-meta-registration" ];
        description = "Mautrix-Meta-${name} bridge user";
      }) enabledInstances;

      users.groups.mautrix-meta = {};
      users.groups.mautrix-meta-registration = {
        members = lib.lists.optional config.services.matrix-synapse.enable "matrix-synapse";
      };

      services.matrix-synapse = lib.mkIf (config.services.matrix-synapse.enable) (let
        registrationFiles = lib.attrValues
          (lib.mapAttrs (name: cfg: cfg.registrationFile) registerToSynapseInstances);
      in {
        settings.app_service_config_files = registrationFiles;
      });

      systemd.services = lib.mkMerge [
        {
          matrix-synapse = lib.mkIf (config.services.matrix-synapse.enable) (let
            registrationServices = lib.attrValues
              (lib.mapAttrs (name: cfg: cfg.registrationServiceUnit) registerToSynapseInstances);
          in {
            wants = registrationServices;
            after = registrationServices;
          });
        }

        (lib.mapAttrs' (name: cfg: lib.nameValuePair "${metaName name}-registration" {
          description = "Mautrix-Meta registration generation service - ${metaName name}";

          path = [
            pkgs.yq
            pkgs.envsubst
            upperCfg.package
          ];

          script = ''
            # substitute the settings file by environment variables
            # in this case read from EnvironmentFile
            rm -f '${settingsFile cfg}'
            old_umask=$(umask)
            umask 0177
            envsubst \
              -o '${settingsFile cfg}' \
              -i '${settingsFileUnsubstituted cfg}'

            config_has_tokens=$(yq '.appservice | has("as_token") and has("hs_token")' '${settingsFile cfg}')
            registration_already_exists=$([[ -f '${cfg.registrationFile}' ]] && echo "true" || echo "false")

            echo "There are tokens in the config: $config_has_tokens"
            echo "Registration already existed: $registration_already_exists"

            # tokens not configured from config/environment file, and registration file
            # is already generated, override tokens in config to make sure they are not lost
            if [[ $config_has_tokens == "false" && $registration_already_exists == "true" ]]; then
              echo "Copying as_token, hs_token from registration into configuration"
              yq -sY '.[0].appservice.as_token = .[1].as_token
                | .[0].appservice.hs_token = .[1].hs_token
                | .[0]' '${settingsFile cfg}' '${cfg.registrationFile}' \
                > '${settingsFile cfg}.tmp'
              mv '${settingsFile cfg}.tmp' '${settingsFile cfg}'
            fi

            # make sure --generate-registration does not affect config.yaml
            cp '${settingsFile cfg}' '${settingsFile cfg}.tmp'

            echo "Generating registration file"
            mautrix-meta \
              --generate-registration \
              --config='${settingsFile cfg}.tmp' \
              --registration='${cfg.registrationFile}'

            rm '${settingsFile cfg}.tmp'

            # no tokens configured, and new were just generated by generate registration for first time
            if [[ $config_has_tokens == "false" && $registration_already_exists == "false" ]]; then
              echo "Copying newly generated as_token, hs_token from registration into configuration"
              yq -sY '.[0].appservice.as_token = .[1].as_token
                | .[0].appservice.hs_token = .[1].hs_token
                | .[0]' '${settingsFile cfg}' '${cfg.registrationFile}' \
                > '${settingsFile cfg}.tmp'
              mv '${settingsFile cfg}.tmp' '${settingsFile cfg}'
            fi

            # Make sure correct tokens are in the registration file
            if [[ $config_has_tokens == "true" || $registration_already_exists == "true" ]]; then
              echo "Copying as_token, hs_token from configuration to the registration file"
              yq -sY '.[1].as_token = .[0].appservice.as_token
                | .[1].hs_token = .[0].appservice.hs_token
                | .[1]' '${settingsFile cfg}' '${cfg.registrationFile}' \
                > '${cfg.registrationFile}.tmp'
              mv '${cfg.registrationFile}.tmp' '${cfg.registrationFile}'
            fi

            umask $old_umask

            chown :mautrix-meta-registration '${cfg.registrationFile}'
            chmod 640 '${cfg.registrationFile}'
          '';

          serviceConfig = {
            Type = "oneshot";
            UMask = 0027;

            User = "mautrix-meta-${name}";
            Group = "mautrix-meta";

            SystemCallFilter = [ "@system-service" ];

            ProtectSystem = "strict";
            ProtectHome = true;

            ReadWritePaths = fullDataDir cfg;
            StateDirectory = cfg.dataDir;
            EnvironmentFile = cfg.environmentFile;
          };

          restartTriggers = [ (settingsFileUnsubstituted cfg) ];
        }) enabledInstances)

        (lib.mapAttrs' (name: cfg: lib.nameValuePair "${metaName name}" {
          description = "Mautrix-Meta bridge - ${metaName name}";
          wantedBy = [ "multi-user.target" ];
          wants = [ "network-online.target" ] ++ cfg.serviceDependencies;
          after = [ "network-online.target" ] ++ cfg.serviceDependencies;

          serviceConfig = {
            Type = "simple";

            User = "mautrix-meta-${name}";
            Group = "mautrix-meta";
            PrivateUsers = true;

            LockPersonality = true;
            MemoryDenyWriteExecute = true;
            NoNewPrivileges = true;
            PrivateDevices = true;
            PrivateTmp = true;
            ProtectClock = true;
            ProtectControlGroups = true;
            ProtectHome = true;
            ProtectHostname = true;
            ProtectKernelLogs = true;
            ProtectKernelModules = true;
            ProtectKernelTunables = true;
            ProtectSystem = "strict";
            Restart = "on-failure";
            RestartSec = "30s";
            RestrictRealtime = true;
            RestrictSUIDSGID = true;
            SystemCallArchitectures = "native";
            SystemCallErrorNumber = "EPERM";
            SystemCallFilter = ["@system-service"];
            UMask = 0027;

            WorkingDirectory = fullDataDir cfg;
            ReadWritePaths = fullDataDir cfg;
            StateDirectory = cfg.dataDir;
            EnvironmentFile = cfg.environmentFile;

            ExecStart = lib.escapeShellArgs [
              (lib.getExe upperCfg.package)
              "--config=${settingsFile cfg}"
            ];
          };
          restartTriggers = [ (settingsFileUnsubstituted cfg) ];
        }) enabledInstances)
      ];
    })
    {
      services.mautrix-meta.instances = let
        inherit (lib.modules) mkDefault;
      in {
        instagram = {
          settings = {
            meta.mode = mkDefault "instagram";

            bridge = {
              username_template = mkDefault "instagram_{{.}}";
            };

            appservice = {
              id = mkDefault "instagram";
              port = mkDefault 29320;
              bot = {
                username = mkDefault "instagrambot";
                displayname = mkDefault "Instagram bridge bot";
                avatar = mkDefault "mxc://maunium.net/JxjlbZUlCPULEeHZSwleUXQv";
              };
            };
          };
        };
        facebook = {
          settings = {
            meta.mode = mkDefault "facebook";

            bridge = {
              username_template = mkDefault "facebook_{{.}}";
            };

            appservice = {
              id = mkDefault "facebook";
              port = mkDefault 29321;
              bot = {
                username = mkDefault "facebookbot";
                displayname = mkDefault "Facebook bridge bot";
                avatar = mkDefault "mxc://maunium.net/ygtkteZsXnGJLJHRchUwYWak";
              };
            };
          };
        };
      };
    }
  ];

  meta.maintainers = with lib.maintainers; [ rutherther ];
}
