# Users & SSH inbound: options + catalog defaults + integration. Single module
# that used to be split across `modules/system/users/{default,catalog-options,
# catalog-default,home-integration}.nix`. Per-host overrides / extra HM modules
# work the same way they always have; only the file structure changed.
{ self, ... }:
{
  flake.nixosModules.users =
    { config, options, lib, ... }:
    let
      cfg = config.chiasson.users;
      usersLib =
        let
          userHm = user: user.homeManager or { };
          userSsh = user: user.ssh or { };
        in
        rec {
          resolveHomeManagerModule =
            moduleSpec:
            let
              t = builtins.typeOf moduleSpec;
            in
            if t == "path" || t == "string" then import moduleSpec else moduleSpec;

          selectedUsersAttr =
          { catalog, enabled, hostOverrides }:
            lib.listToAttrs (
              map (name: {
                inherit name;
                value = lib.recursiveUpdate catalog.${name} (hostOverrides.${name} or { });
              }) enabled
            );

          missingEnabledNames =
            catalog: enabled: builtins.filter (name: !(builtins.hasAttr name catalog)) enabled;

          strayHomeUserKeys =
            homeUsers: enabled: builtins.filter (k: !(builtins.elem k enabled)) (builtins.attrNames homeUsers);

          mkNixosUser =
            name: user:
            {
              isNormalUser = user.isNormalUser or true;
              description = user.description or name;
              extraGroups = user.extraGroups or [ ];
            }
            // lib.optionalAttrs (user ? hashedPasswordFile && user.hashedPasswordFile != null) {
              hashedPasswordFile = user.hashedPasswordFile;
            };

          hmWiredNames =
            selectedUsers:
            lib.attrNames (
              lib.filterAttrs (
                _: user:
                let
                  hm = userHm user;
                in
                (hm.enable or false) && (hm.module or null) != null
              ) selectedUsers
            );

          rbwOutboundSnippet =
            name: user:
            let
              outboundCfg = ((userSsh user).outbound or { }).rbw or { };
            in
            lib.mkIf (outboundCfg.enable or false) {
              chiasson.ssh.outbound.rbw.enable = true;
              chiasson.ssh.outbound.rbw.user = name;
              chiasson.ssh.outbound.rbw.hosts =
                if (outboundCfg.hosts or "all") == "all" then [ "all" ] else outboundCfg.hosts;
              chiasson.ssh.outbound.rbw.extraIdentities = outboundCfg.extraIdentities or [ ];
            };

          mkHmUserModule =
          { name, user, hostExtraModules }:
            let
              hm = userHm user;
              hmModule = resolveHomeManagerModule hm.module;
            in
            lib.mkMerge (
              [
                hmModule
                (rbwOutboundSnippet name user)
              ]
              ++ (hm.extraModules or [ ])
              ++ hostExtraModules
            );

          inboundAuthorizedOrNull =
            user:
            let
              inboundCfg = (userSsh user).inbound or { };
            in
            if !(inboundCfg.enable or false) then
              null
            else if (inboundCfg.authorizedHosts or "all") == "all" then
              "all"
            else
              inboundCfg.authorizedHosts;

          inboundHostsAttr =
            selectedUsers:
            lib.pipe selectedUsers [
              (lib.mapAttrs (_: inboundAuthorizedOrNull))
              (lib.filterAttrs (_: v: v != null))
            ];
        };
      inventory = self.lib.sshInventory;

      olivierEnabled = lib.elem "olivier" cfg.enabled;

      # Merge catalog + per-host overrides into the final user attrs.
      selectedUsers = lib.listToAttrs (
        map (name: {
          inherit name;
          value = lib.recursiveUpdate cfg.catalog.${name} (cfg.hostOverrides.${name} or { });
        }) cfg.enabled
      );

      names = usersLib.hmWiredNames selectedUsers;
      missing = usersLib.missingEnabledNames cfg.catalog cfg.enabled;
      stray = usersLib.strayHomeUserKeys cfg.extraModules cfg.enabled;
      hmAvailable = lib.hasAttrByPath [ "home-manager" "users" ] options;
      inboundUsersAttr = usersLib.inboundHostsAttr selectedUsers;

      hmUsersAttr = lib.listToAttrs (
        map (name: {
          inherit name;
          value = usersLib.mkHmUserModule {
            inherit name;
            user = selectedUsers.${name};
            hostExtraModules = cfg.extraModules.${name} or [ ];
          };
        }) names
      );

      # Fish shell wiring: HM's fish module declares its package, but
      # /etc/passwd + /etc/shells need updating so login shells work.
      # Only fires for HM users that actually enable fish.
      hmFishUsers =
        if !hmAvailable then
          { }
        else
          lib.filterAttrs (
            name: hmUser: (hmUser.programs.fish.enable or false) && builtins.elem name names
          ) config.home-manager.users;
    in
    {
      imports = [
        self.nixosModules.sshInbound
        { _module.args = { inherit self usersLib; }; }
      ];

      ####################
      # Options
      ####################
      options.chiasson.users = {
        catalog = lib.mkOption {
          type = lib.types.attrs;
          default = { };
          description = "User records; defaults below. Override with `hostOverrides` or `mkForce`.";
        };
        enabled = lib.mkOption {
          type = lib.types.listOf lib.types.str;
          default = [ ];
          description = "Catalog names to materialize as `users.users` on this machine.";
        };
        hostOverrides = lib.mkOption {
          type = lib.types.attrs;
          default = { };
          description = "`recursiveUpdate`'d onto catalog users on this host.";
        };
        # Standard `attrsOf (listOf …)` already concatenates lists across
        # module definitions; no custom merge function needed.
        extraModules = lib.mkOption {
          type = lib.types.attrsOf (lib.types.listOf lib.types.unspecified);
          default = { };
          description = ''
            Per-user Home Manager extraModules keyed by catalog name. Lists
            from multiple modules (desktopHomeBase + this host's home.nix)
            are concatenated.
          '';
        };
        homeManager.autoWire = lib.mkOption {
          type = lib.types.bool;
          default = true;
          description = "Auto-create Home Manager users from the catalog.";
        };
      };

      ####################
      # Catalog defaults + integration
      ####################
      # Nix won't accept both `config.X = …` and `config = …` in the same
      # attrset (the path-attr partially defines `config`, then the explicit
      # `config = …` is a redefinition error). All `config.` writes must
      # therefore live INSIDE the single mkMerge block below.
      config = lib.mkMerge [
        ####################
        # Catalog defaults
        ####################
        {
          chiasson.users.catalog = {
            olivier = {
              isNormalUser = true;
              description = "Olivier";
              extraGroups = [
                "networkmanager"
                "wheel"
                "docker"
                "fuse"
                "uinput"
                "kvm"
                # `video` lets brightnessctl/light udev rules own /sys/backlight without sudo.
                # Harmless on headless hosts (no devices).
                "video"
                # DRI render + input for gamescope / Steam on Wayland, no sudo.
                "render"
                "input"
              ];
              hashedPasswordFile = lib.mkIf olivierEnabled (
                config.sops.secrets."users/olivier/hashedPassword".path
              );
              homeManager = {
                enable = true;
                module = { pkgs, ... }: {
                  home.username = "olivier";
                  home.homeDirectory = "/home/olivier";
                  home.stateVersion = "25.11";
                  programs.home-manager.enable = true;

                  # Declarative rbw config — replaces per-host
                  # `rbw config set email|base_url`. Neither value is a
                  # secret (URL + login email), so plaintext is fine, same
                  # as the public keys committed in ssh-inventory.nix.
                  programs.rbw = {
                    enable = true;
                    settings = {
                      email = "olivierchiasson@hotmail.fr";
                      base_url = "https://bitwarden.chiasson.cloud";
                    };
                  };
                };
              };
              ssh.inbound.enable = true;
              ssh.inbound.authorizedHosts = "all";
              ssh.outbound.rbw.enable = true;
              ssh.outbound.rbw.hosts = [ "all" ];
              # olivier's laptop: the HM SSH module writes the matching
              # `.pub` filter for each of these and emits a `Match user`
              # block, so `ssh server@r5500` / `ssh builder@nix-server`
              # route the right private key from the rbw agent.
              ssh.outbound.rbw.extraIdentities = [
                "server"
                "builder"
              ];
            };

            server = {
              isNormalUser = true;
              description = "Server user";
              extraGroups = [ "wheel" ];
              homeManager = {
                enable = false;
                module = null;
              };
              ssh.inbound.enable = true;
              ssh.inbound.authorizedHosts = "all";
              ssh.outbound.rbw.enable = false;
              ssh.outbound.rbw.hosts = [ "all" ];
            };

            builder = {
              isNormalUser = true;
              description = "Navi fleet deploy (push + activate only)";
              extraGroups = [ ];
              createHome = false;
              homeManager = {
                enable = false;
                module = null;
              };
              ssh.inbound.enable = true;
              ssh.inbound.authorizedHosts = "all";
            };
          };

          # Pull in olivier's hashed password from sops; `neededForUsers`
          # makes nixos decrypt it at boot so the user record can read it.
          sops.secrets."users/olivier/hashedPassword" = lib.mkIf olivierEnabled {
            neededForUsers = true;
          };
        }

        ####################
        # Integration
        ####################
        {
          assertions = [
            {
              assertion = missing == [ ];
              message = "chiasson.users.enabled references unknown catalog names: ${lib.concatStringsSep ", " missing}";
            }
            {
              assertion = stray == [ ];
              message = "chiasson.users.extraModules has keys not in chiasson.users.enabled: ${lib.concatStringsSep ", " stray}";
            }
          ];

          # NixOS user accounts (no HM, raw).
          users.users = lib.mapAttrs (name: user: usersLib.mkNixosUser name user) selectedUsers;
        }
        (lib.optionalAttrs hmAvailable {
          "home-manager".useGlobalPkgs = lib.mkIf (cfg.homeManager.autoWire && names != [ ]) true;
          "home-manager".sharedModules = lib.mkIf (cfg.homeManager.autoWire && names != [ ]) [
            self.homeManagerModules.sshOutboundRbw
          ];
          "home-manager".users = lib.mkIf (cfg.homeManager.autoWire && names != [ ]) hmUsersAttr;
        })
        # Wire user-specific ACLs into the inbound module — fail the build
        # if a user has no matching pubkey.
        (lib.mkIf (inboundUsersAttr != { }) {
          chiasson.ssh.inbound.enable = true;
          chiasson.ssh.inbound.userAuthorizedHosts = inboundUsersAttr;
        })
        # Fish-shell wiring: HM knows the package; we sync /etc/passwd +
        # /etc/shells with `mkForce`. `mkNixosUser` doesn't emit `shell`, so
        # the force is uncontested.
        (lib.mkIf (hmFishUsers != { }) {
          environment.shells = lib.mkAfter (
            lib.mapAttrsToList (_: hmUser: lib.getExe hmUser.programs.fish.package) hmFishUsers
          );
          users.users = lib.mapAttrs (name: hmUser: {
            shell = lib.mkForce (lib.getExe hmUser.programs.fish.package);
          }) hmFishUsers;
        })
      ];
    };
}
