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

with lib;

let
  cfg = config.services.pixelfed;
  user = cfg.user;
  group = cfg.group;
  pixelfed = cfg.package.override { inherit (cfg) dataDir runtimeDir; };
  # https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L185-L190
  extraPrograms = with pkgs; [ jpegoptim optipng pngquant gifsicle ffmpeg ];
  # Ensure PHP extensions: https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L135-L147
  phpPackage = cfg.phpPackage.buildEnv {
    extensions = { enabled, all }:
      enabled
      ++ (with all; [ bcmath ctype curl mbstring gd intl zip redis imagick ]);
  };
  configFile =
    pkgs.writeText "pixelfed-env" (lib.generators.toKeyValue { } cfg.settings);
  # Management script
  pixelfed-manage = pkgs.writeShellScriptBin "pixelfed-manage" ''
    cd ${pixelfed}
    sudo=exec
    if [[ "$USER" != ${user} ]]; then
      sudo='exec /run/wrappers/bin/sudo -u ${user}'
    fi
    $sudo ${phpPackage}/bin/php artisan "$@"
  '';
  dbSocket = {
    "pgsql" = "/run/postgresql";
    "mysql" = "/run/mysqld/mysqld.sock";
  }.${cfg.database.type};
  dbService = {
    "pgsql" = "postgresql.service";
    "mysql" = "mysql.service";
  }.${cfg.database.type};
  redisService = "redis-pixelfed.service";
in {
  options.services = {
    pixelfed = {
      enable = mkEnableOption "a Pixelfed instance";
      package = mkPackageOption pkgs "pixelfed" { };
      phpPackage = mkPackageOption pkgs "php82" { };

      user = mkOption {
        type = types.str;
        default = "pixelfed";
        description = ''
          User account under which pixelfed runs.

          ::: {.note}
          If left as the default value this user will automatically be created
          on system activation, otherwise you are responsible for
          ensuring the user exists before the pixelfed application starts.
          :::
        '';
      };

      group = mkOption {
        type = types.str;
        default = "pixelfed";
        description = ''
          Group account under which pixelfed runs.

          ::: {.note}
          If left as the default value this group will automatically be created
          on system activation, otherwise you are responsible for
          ensuring the group exists before the pixelfed application starts.
          :::
        '';
      };

      domain = mkOption {
        type = types.str;
        description = ''
          FQDN for the Pixelfed instance.
        '';
      };

      secretFile = mkOption {
        type = types.path;
        description = ''
          A secret file to be sourced for the .env settings.
          Place `APP_KEY` and other settings that should not end up in the Nix store here.
        '';
      };

      settings = mkOption {
        type = with types; (attrsOf (oneOf [ bool int str ]));
        description = ''
          .env settings for Pixelfed.
          Secrets should use `secretFile` option instead.
        '';
      };

      nginx = mkOption {
        type = types.nullOr (types.submodule
          (import ../web-servers/nginx/vhost-options.nix {
            inherit config lib;
          }));
        default = null;
        example = lib.literalExpression ''
          {
            serverAliases = [
              "pics.''${config.networking.domain}"
            ];
            enableACME = true;
            forceHttps = true;
          }
        '';
        description = ''
          With this option, you can customize an nginx virtual host which already has sensible defaults for Dolibarr.
          Set to {} if you do not need any customization to the virtual host.
          If enabled, then by default, the {option}`serverName` is
          `''${domain}`,
          If this is set to null (the default), no nginx virtualHost will be configured.
        '';
      };

      redis.createLocally = mkEnableOption "a local Redis database using UNIX socket authentication"
        // {
          default = true;
        };

      database = {
        createLocally = mkEnableOption "a local database using UNIX socket authentication" // {
            default = true;
          };
        automaticMigrations = mkEnableOption "automatic migrations for database schema and data" // {
            default = true;
          };

        type = mkOption {
          type = types.enum [ "mysql" "pgsql" ];
          example = "pgsql";
          default = "mysql";
          description = ''
            Database engine to use.
            Note that PGSQL is not well supported: https://github.com/pixelfed/pixelfed/issues/2727
          '';
        };

        name = mkOption {
          type = types.str;
          default = "pixelfed";
          description = "Database name.";
        };
      };

      maxUploadSize = mkOption {
        type = types.str;
        default = "8M";
        description = ''
          Max upload size with units.
        '';
      };

      poolConfig = mkOption {
        type = with types; attrsOf (oneOf [ int str bool ]);
        default = { };

        description = ''
          Options for Pixelfed's PHP-FPM pool.
        '';
      };

      dataDir = mkOption {
        type = types.str;
        default = "/var/lib/pixelfed";
        description = ''
          State directory of the `pixelfed` user which holds
          the application's state and data.
        '';
      };

      runtimeDir = mkOption {
        type = types.str;
        default = "/run/pixelfed";
        description = ''
          Ruutime directory of the `pixelfed` user which holds
          the application's caches and temporary files.
        '';
      };

      schedulerInterval = mkOption {
        type = types.str;
        default = "1d";
        description = "How often the Pixelfed cron task should run";
      };
    };
  };

  config = mkIf cfg.enable {
    users.users.pixelfed = mkIf (cfg.user == "pixelfed") {
      isSystemUser = true;
      group = cfg.group;
      extraGroups = lib.optional cfg.redis.createLocally "redis-pixelfed";
    };
    users.groups.pixelfed = mkIf (cfg.group == "pixelfed") { };

    services.redis.servers.pixelfed.enable = lib.mkIf cfg.redis.createLocally true;
    services.pixelfed.settings = mkMerge [
      ({
        APP_ENV = mkDefault "production";
        APP_DEBUG = mkDefault false;
        # https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L312-L316
        APP_URL = mkDefault "https://${cfg.domain}";
        ADMIN_DOMAIN = mkDefault cfg.domain;
        APP_DOMAIN = mkDefault cfg.domain;
        SESSION_DOMAIN = mkDefault cfg.domain;
        SESSION_SECURE_COOKIE = mkDefault true;
        OPEN_REGISTRATION = mkDefault false;
        # ActivityPub: https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L360-L364
        ACTIVITY_PUB = mkDefault true;
        AP_REMOTE_FOLLOW = mkDefault true;
        AP_INBOX = mkDefault true;
        AP_OUTBOX = mkDefault true;
        AP_SHAREDINBOX = mkDefault true;
        # Image optimization: https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L367-L404
        PF_OPTIMIZE_IMAGES = mkDefault true;
        IMAGE_DRIVER = mkDefault "imagick";
        # Mobile APIs
        OAUTH_ENABLED = mkDefault true;
        # https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L351
        EXP_EMC = mkDefault true;
        # Defer to systemd
        LOG_CHANNEL = mkDefault "stderr";
        # TODO: find out the correct syntax?
        # TRUST_PROXIES = mkDefault "127.0.0.1/8, ::1/128";
      })
      (mkIf (cfg.redis.createLocally) {
        BROADCAST_DRIVER = mkDefault "redis";
        CACHE_DRIVER = mkDefault "redis";
        QUEUE_DRIVER = mkDefault "redis";
        SESSION_DRIVER = mkDefault "redis";
        WEBSOCKET_REPLICATION_MODE = mkDefault "redis";
        # Support phpredis and predis configuration-style.
        REDIS_SCHEME = "unix";
        REDIS_HOST = config.services.redis.servers.pixelfed.unixSocket;
        REDIS_PATH = config.services.redis.servers.pixelfed.unixSocket;
      })
      (mkIf (cfg.database.createLocally) {
        DB_CONNECTION = cfg.database.type;
        DB_SOCKET = dbSocket;
        DB_DATABASE = cfg.database.name;
        DB_USERNAME = user;
        # No TCP/IP connection.
        DB_PORT = 0;
      })
    ];

    environment.systemPackages = [ pixelfed-manage ];

    services.mysql =
      mkIf (cfg.database.createLocally && cfg.database.type == "mysql") {
        enable = mkDefault true;
        package = mkDefault pkgs.mariadb;
        ensureDatabases = [ cfg.database.name ];
        ensureUsers = [{
          name = user;
          ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
        }];
      };

    services.postgresql =
      mkIf (cfg.database.createLocally && cfg.database.type == "pgsql") {
        enable = mkDefault true;
        ensureDatabases = [ cfg.database.name ];
        ensureUsers = [{
          name = user;
        }];
      };

    # Make each individual option overridable with lib.mkDefault.
    services.pixelfed.poolConfig = lib.mapAttrs' (n: v: lib.nameValuePair n (lib.mkDefault v)) {
      "pm" = "dynamic";
      "php_admin_value[error_log]" = "stderr";
      "php_admin_flag[log_errors]" = true;
      "catch_workers_output" = true;
      "pm.max_children" = "32";
      "pm.start_servers" = "2";
      "pm.min_spare_servers" = "2";
      "pm.max_spare_servers" = "4";
      "pm.max_requests" = "500";
    };

    services.phpfpm.pools.pixelfed = {
      inherit user group;
      inherit phpPackage;

      phpOptions = ''
        post_max_size = ${toString cfg.maxUploadSize}
        upload_max_filesize = ${toString cfg.maxUploadSize}
        max_execution_time = 600;
      '';

      settings = {
        "listen.owner" = user;
        "listen.group" = group;
        "listen.mode" = "0660";
        "catch_workers_output" = "yes";
      } // cfg.poolConfig;
    };

    systemd.services.phpfpm-pixelfed.after = [ "pixelfed-data-setup.service" ];
    systemd.services.phpfpm-pixelfed.requires =
      [ "pixelfed-horizon.service" "pixelfed-data-setup.service" ]
      ++ lib.optional cfg.database.createLocally dbService
      ++ lib.optional cfg.redis.createLocally redisService;
    # Ensure image optimizations programs are available.
    systemd.services.phpfpm-pixelfed.path = extraPrograms;

    systemd.services.pixelfed-horizon = {
      description = "Pixelfed task queueing via Laravel Horizon framework";
      after = [ "network.target" "pixelfed-data-setup.service" ];
      requires = [ "pixelfed-data-setup.service" ]
        ++ (lib.optional cfg.database.createLocally dbService)
        ++ (lib.optional cfg.redis.createLocally redisService);
      wantedBy = [ "multi-user.target" ];
      # Ensure image optimizations programs are available.
      path = extraPrograms;

      serviceConfig = {
        Type = "simple";
        ExecStart = "${pixelfed-manage}/bin/pixelfed-manage horizon";
        StateDirectory =
          lib.mkIf (cfg.dataDir == "/var/lib/pixelfed") "pixelfed";
        User = user;
        Group = group;
        Restart = "on-failure";
      };
    };

    systemd.timers.pixelfed-cron = {
      description = "Pixelfed periodic tasks timer";
      after = [ "pixelfed-data-setup.service" ];
      requires = [ "phpfpm-pixelfed.service" ];
      wantedBy = [ "timers.target" ];

      timerConfig = {
        OnBootSec = cfg.schedulerInterval;
        OnUnitActiveSec = cfg.schedulerInterval;
      };
    };

    systemd.services.pixelfed-cron = {
      description = "Pixelfed periodic tasks";
      # Ensure image optimizations programs are available.
      path = extraPrograms;

      serviceConfig = {
        ExecStart = "${pixelfed-manage}/bin/pixelfed-manage schedule:run";
        User = user;
        Group = group;
        StateDirectory =
          lib.mkIf (cfg.dataDir == "/var/lib/pixelfed") "pixelfed";
      };
    };

    systemd.services.pixelfed-data-setup = {
      description =
        "Pixelfed setup: migrations, environment file update, cache reload, data changes";
      wantedBy = [ "multi-user.target" ];
      after = lib.optional cfg.database.createLocally dbService;
      requires = lib.optional cfg.database.createLocally dbService;
      path = with pkgs; [ bash pixelfed-manage rsync ] ++ extraPrograms;

      serviceConfig = {
        Type = "oneshot";
        User = user;
        Group = group;
        StateDirectory =
          lib.mkIf (cfg.dataDir == "/var/lib/pixelfed") "pixelfed";
        LoadCredential = "env-secrets:${cfg.secretFile}";
        UMask = "077";
      };

      script = ''
        # Before running any PHP program, cleanup the code cache.
        # It's necessary if you upgrade the application otherwise you might
        # try to import non-existent modules.
        rm -f ${cfg.runtimeDir}/app.php
        rm -rf ${cfg.runtimeDir}/cache/*

        # Concatenate non-secret .env and secret .env
        rm -f ${cfg.dataDir}/.env
        cp --no-preserve=all ${configFile} ${cfg.dataDir}/.env
        echo -e '\n' >> ${cfg.dataDir}/.env
        cat "$CREDENTIALS_DIRECTORY/env-secrets" >> ${cfg.dataDir}/.env

        # Link the static storage (package provided) to the runtime storage
        # Necessary for cities.json and static images.
        mkdir -p ${cfg.dataDir}/storage
        rsync -av --no-perms ${pixelfed}/storage-static/ ${cfg.dataDir}/storage
        chmod -R +w ${cfg.dataDir}/storage

        chmod g+x ${cfg.dataDir}/storage ${cfg.dataDir}/storage/app
        chmod -R g+rX ${cfg.dataDir}/storage/app/public

        # Link the app.php in the runtime folder.
        # We cannot link the cache folder only because bootstrap folder needs to be writeable.
        ln -sf ${pixelfed}/bootstrap-static/app.php ${cfg.runtimeDir}/app.php

        # https://laravel.com/docs/10.x/filesystem#the-public-disk
        # Creating the public/storage → storage/app/public link
        # is unnecessary as it's part of the installPhase of pixelfed.

        # Install Horizon
        # FIXME: require write access to public/ — should be done as part of install — pixelfed-manage horizon:publish

        # Perform the first migration.
        [[ ! -f ${cfg.dataDir}/.initial-migration ]] && pixelfed-manage migrate --force && touch ${cfg.dataDir}/.initial-migration

        ${lib.optionalString cfg.database.automaticMigrations ''
          # Force migrate the database.
          pixelfed-manage migrate --force
        ''}

        # Import location data
        pixelfed-manage import:cities

        ${lib.optionalString cfg.settings.ACTIVITY_PUB ''
          # ActivityPub federation bookkeeping
          [[ ! -f ${cfg.dataDir}/.instance-actor-created ]] && pixelfed-manage instance:actor && touch ${cfg.dataDir}/.instance-actor-created
        ''}

        ${lib.optionalString cfg.settings.OAUTH_ENABLED ''
          # Generate Passport encryption keys
          [[ ! -f ${cfg.dataDir}/.passport-keys-generated ]] && pixelfed-manage passport:keys && touch ${cfg.dataDir}/.passport-keys-generated
        ''}

        pixelfed-manage route:cache
        pixelfed-manage view:cache
        pixelfed-manage config:cache
      '';
    };

    systemd.tmpfiles.rules = [
      # Cache must live across multiple systemd units runtimes.
      "d ${cfg.runtimeDir}/                         0700 ${user} ${group} - -"
      "d ${cfg.runtimeDir}/cache                    0700 ${user} ${group} - -"
    ];

    # Enable NGINX to access our phpfpm-socket.
    users.users."${config.services.nginx.user}".extraGroups = [ cfg.group ];
    services.nginx = mkIf (cfg.nginx != null) {
      enable = true;
      virtualHosts."${cfg.domain}" = mkMerge [
        cfg.nginx
        {
          root = lib.mkForce "${pixelfed}/public/";
          locations."/".tryFiles = "$uri $uri/ /index.php?$query_string";
          locations."/favicon.ico".extraConfig = ''
            access_log off; log_not_found off;
          '';
          locations."/robots.txt".extraConfig = ''
            access_log off; log_not_found off;
          '';
          locations."~ \\.php$".extraConfig = ''
            fastcgi_split_path_info ^(.+\.php)(/.+)$;
            fastcgi_pass unix:${config.services.phpfpm.pools.pixelfed.socket};
            fastcgi_index index.php;
          '';
          locations."~ /\\.(?!well-known).*".extraConfig = ''
            deny all;
          '';
          extraConfig = ''
            add_header X-Frame-Options "SAMEORIGIN";
            add_header X-XSS-Protection "1; mode=block";
            add_header X-Content-Type-Options "nosniff";
            index index.html index.htm index.php;
            error_page 404 /index.php;
            client_max_body_size ${toString cfg.maxUploadSize};
          '';
        }
      ];
    };
  };
}
