# frozen_string_literal: true

require 'tty-prompt'

namespace :mastodon do
  desc 'Configure the instance for production use'
  task :setup do
    prompt = TTY::Prompt.new
    env    = {}

    if ENV['LOCAL_DOMAIN']
      prompt.warn "It looks like you already configured Mastodon for domain '#{ENV['LOCAL_DOMAIN']}'."
      prompt.warn 'Never re-run this task on an already-configured running server.'
      next prompt.warn 'Nothing saved. Bye!' if prompt.no?('Continue anyway?')
    end

    clear_environment!

    begin
      errors = []

      prompt.say('Your instance is identified by its domain name. Changing it afterward will break things.')
      env['LOCAL_DOMAIN'] = prompt.ask('Domain name:') do |q|
        q.required true
        q.modify :strip
        q.validate(/\A[a-z0-9.-]+\z/i)
        q.messages[:valid?] = 'Invalid domain. If you intend to use unicode characters, enter punycode here'
      end

      begin
        # This strips subdomains, only keeping one label + a public suffix
        domain = Addressable::URI.new(host: env['LOCAL_DOMAIN']).domain
        if domain.include?('masto') || domain.include?('mstdn')
          prompt.warn 'The Mastodon name is a trademark and its use is restricted.'
          prompt.warn 'You can read the trademark policy at https://joinmastodon.org/trademark'
          next prompt.warn 'Nothing saved. Bye!' if prompt.no?('Continue anyway?')
        end
      rescue Addressable::URI::InvalidURIError
        nil
      end

      prompt.say "\n"

      prompt.say('Single user mode disables registrations and redirects the landing page to your public profile.')
      env['SINGLE_USER_MODE'] = prompt.yes?('Do you want to enable single user mode?', default: false)

      %w(SECRET_KEY_BASE).each do |key|
        env[key] = SecureRandom.hex(64)
      end

      # Required by ActiveRecord encryption feature
      %w(
        ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
        ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
        ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
      ).each do |key|
        env[key] = SecureRandom.alphanumeric(32)
      end

      vapid_key = Webpush.generate_key

      env['VAPID_PRIVATE_KEY'] = vapid_key.private_key
      env['VAPID_PUBLIC_KEY']  = vapid_key.public_key

      prompt.say "\n"

      using_docker        = prompt.yes?('Are you using Docker to run Mastodon?')
      db_connection_works = false

      prompt.say "\n"

      loop do
        env['DB_HOST'] = prompt.ask('PostgreSQL host:') do |q|
          q.required true
          q.default using_docker ? 'db' : '/var/run/postgresql'
          q.modify :strip
        end

        env['DB_PORT'] = prompt.ask('PostgreSQL port:') do |q|
          q.required true
          q.default 5432
          q.convert :int
        end

        env['DB_NAME'] = prompt.ask('Name of PostgreSQL database:') do |q|
          q.required true
          q.default using_docker ? 'postgres' : 'mastodon_production'
          q.modify :strip
        end

        env['DB_USER'] = prompt.ask('Name of PostgreSQL user:') do |q|
          q.required true
          q.default using_docker ? 'postgres' : 'mastodon'
          q.modify :strip
        end

        env['DB_PASS'] = prompt.ask('Password of PostgreSQL user:') do |q|
          q.echo false
        end

        # The chosen database may not exist yet. Connect to default database
        # to avoid "database does not exist" error.
        db_options = {
          adapter: :postgresql,
          database: 'postgres',
          host: env['DB_HOST'],
          port: env['DB_PORT'],
          user: env['DB_USER'],
          password: env['DB_PASS'],
        }

        begin
          ActiveRecord::Base.establish_connection(db_options)
          ActiveRecord::Base.connection
          prompt.ok 'Database configuration works! 🎆'
          db_connection_works = true
          break
        rescue => e
          prompt.error 'Database connection could not be established with this configuration, try again.'
          prompt.error e.message
          unless prompt.yes?('Try again?')
            return prompt.warn 'Nothing saved. Bye!' unless prompt.yes?('Continue anyway?')

            errors << 'Database connection could not be established.'
            break
          end
        end
      end

      prompt.say "\n"

      loop do
        env['REDIS_HOST'] = prompt.ask('Redis host:') do |q|
          q.required true
          q.default using_docker ? 'redis' : 'localhost'
          q.modify :strip
        end

        env['REDIS_PORT'] = prompt.ask('Redis port:') do |q|
          q.required true
          q.default 6379
          q.convert :int
        end

        env['REDIS_PASSWORD'] = prompt.ask('Redis password:') do |q|
          q.required false
          q.default nil
          q.modify :strip
        end

        redis_options = {
          host: env['REDIS_HOST'],
          port: env['REDIS_PORT'],
          password: env['REDIS_PASSWORD'],
          driver: :hiredis,
        }

        begin
          redis = Redis.new(redis_options)
          redis.ping
          prompt.ok 'Redis configuration works! 🎆'
          break
        rescue => e
          prompt.error 'Redis connection could not be established with this configuration, try again.'
          prompt.error e.message

          unless prompt.yes?('Try again?')
            return prompt.warn 'Nothing saved. Bye!' unless prompt.yes?('Continue anyway?')

            errors << 'Redis connection could not be established.'
            break
          end
        end
      end

      prompt.say "\n"

      if prompt.yes?('Do you want to store uploaded files on the cloud?', default: false)
        case prompt.select('Provider', ['DigitalOcean Spaces', 'Amazon S3', 'Wasabi', 'Minio', 'Google Cloud Storage', 'Storj DCS'])
        when 'DigitalOcean Spaces'
          env['S3_ENABLED'] = 'true'
          env['S3_PROTOCOL'] = 'https'

          env['S3_BUCKET'] = prompt.ask('Space name:') do |q|
            q.required true
            q.default "files.#{env['LOCAL_DOMAIN']}"
            q.modify :strip
          end

          env['S3_REGION'] = prompt.ask('Space region:') do |q|
            q.required true
            q.default 'nyc3'
            q.modify :strip
          end

          env['S3_HOSTNAME'] = prompt.ask('Space endpoint:') do |q|
            q.required true
            q.default 'nyc3.digitaloceanspaces.com'
            q.modify :strip
          end

          env['S3_ENDPOINT'] = "https://#{env['S3_HOSTNAME']}"

          env['AWS_ACCESS_KEY_ID'] = prompt.ask('Space access key:') do |q|
            q.required true
            q.modify :strip
          end

          env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('Space secret key:') do |q|
            q.required true
            q.modify :strip
          end
        when 'Amazon S3'
          env['S3_ENABLED']  = 'true'
          env['S3_PROTOCOL'] = 'https'

          env['S3_BUCKET'] = prompt.ask('S3 bucket name:') do |q|
            q.required true
            q.default "files.#{env['LOCAL_DOMAIN']}"
            q.modify :strip
          end

          env['S3_REGION'] = prompt.ask('S3 region:') do |q|
            q.required true
            q.default 'us-east-1'
            q.modify :strip
          end

          env['S3_HOSTNAME'] = prompt.ask('S3 hostname:') do |q|
            q.required true
            q.default 's3.us-east-1.amazonaws.com'
            q.modify :strip
          end

          env['AWS_ACCESS_KEY_ID'] = prompt.ask('S3 access key:') do |q|
            q.required true
            q.modify :strip
          end

          env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('S3 secret key:') do |q|
            q.required true
            q.modify :strip
          end
        when 'Wasabi'
          env['S3_ENABLED']  = 'true'
          env['S3_PROTOCOL'] = 'https'
          env['S3_REGION']   = 'us-east-1'
          env['S3_HOSTNAME'] = 's3.wasabisys.com'
          env['S3_ENDPOINT'] = 'https://s3.wasabisys.com/'

          env['S3_BUCKET'] = prompt.ask('Wasabi bucket name:') do |q|
            q.required true
            q.default "files.#{env['LOCAL_DOMAIN']}"
            q.modify :strip
          end

          env['AWS_ACCESS_KEY_ID'] = prompt.ask('Wasabi access key:') do |q|
            q.required true
            q.modify :strip
          end

          env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('Wasabi secret key:') do |q|
            q.required true
            q.modify :strip
          end
        when 'Minio'
          env['S3_ENABLED']  = 'true'
          env['S3_PROTOCOL'] = 'https'
          env['S3_REGION']   = 'us-east-1'

          env['S3_ENDPOINT'] = prompt.ask('Minio endpoint URL:') do |q|
            q.required true
            q.modify :strip
          end

          env['S3_PROTOCOL'] = env['S3_ENDPOINT'].start_with?('https') ? 'https' : 'http'
          env['S3_HOSTNAME'] = env['S3_ENDPOINT'].gsub(%r{\Ahttps?://}, '')

          env['S3_BUCKET'] = prompt.ask('Minio bucket name:') do |q|
            q.required true
            q.default "files.#{env['LOCAL_DOMAIN']}"
            q.modify :strip
          end

          env['AWS_ACCESS_KEY_ID'] = prompt.ask('Minio access key:') do |q|
            q.required true
            q.modify :strip
          end

          env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('Minio secret key:') do |q|
            q.required true
            q.modify :strip
          end
        when 'Storj DCS'
          env['S3_ENABLED']  = 'true'
          env['S3_PROTOCOL'] = 'https'
          env['S3_REGION']   = 'global'

          env['S3_ENDPOINT'] = prompt.ask('Storj DCS endpoint URL:') do |q|
            q.required true
            q.default 'https://gateway.storjshare.io'
            q.modify :strip
          end

          env['S3_PROTOCOL'] = env['S3_ENDPOINT'].start_with?('https') ? 'https' : 'http'
          env['S3_HOSTNAME'] = env['S3_ENDPOINT'].gsub(%r{\Ahttps?://}, '')

          env['S3_BUCKET'] = prompt.ask('Storj DCS bucket name:') do |q|
            q.required true
            q.default "files.#{env['LOCAL_DOMAIN']}"
            q.modify :strip
          end

          env['AWS_ACCESS_KEY_ID'] = prompt.ask('Storj Gateway access key (uplink share --register --readonly=false --not-after=none sj://bucket):') do |q|
            q.required true
            q.modify :strip
          end

          env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('Storj Gateway secret key:') do |q|
            q.required true
            q.modify :strip
          end

          linksharing_access_key = prompt.ask('Storj Linksharing access key (uplink share --register --public --readonly=true --disallow-lists --not-after=none sj://bucket):') do |q|
            q.required true
            q.modify :strip
          end
          env['S3_ALIAS_HOST'] = "link.storjshare.io/raw/#{linksharing_access_key}/#{env['S3_BUCKET']}"

        when 'Google Cloud Storage'
          env['S3_ENABLED']             = 'true'
          env['S3_PROTOCOL']            = 'https'
          env['S3_HOSTNAME']            = 'storage.googleapis.com'
          env['S3_ENDPOINT']            = 'https://storage.googleapis.com'
          env['S3_MULTIPART_THRESHOLD'] = 50.megabytes

          env['S3_BUCKET'] = prompt.ask('GCS bucket name:') do |q|
            q.required true
            q.default "files.#{env['LOCAL_DOMAIN']}"
            q.modify :strip
          end

          env['S3_REGION'] = prompt.ask('GCS region:') do |q|
            q.required true
            q.default 'us-west1'
            q.modify :strip
          end

          env['AWS_ACCESS_KEY_ID'] = prompt.ask('GCS access key:') do |q|
            q.required true
            q.modify :strip
          end

          env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('GCS secret key:') do |q|
            q.required true
            q.modify :strip
          end
        end

        if prompt.yes?('Do you want to access the uploaded files from your own domain?')
          env['S3_ALIAS_HOST'] = prompt.ask('Domain for uploaded files:') do |q|
            q.required true
            q.default "files.#{env['LOCAL_DOMAIN']}"
            q.modify :strip
          end
        end
      end

      prompt.say "\n"

      loop do
        if prompt.yes?('Do you want to send e-mails from localhost?', default: false)
          env['SMTP_SERVER'] = 'localhost'
          env['SMTP_PORT'] = 25
          env['SMTP_AUTH_METHOD'] = 'none'
          env['SMTP_OPENSSL_VERIFY_MODE'] = 'none'
          env['SMTP_ENABLE_STARTTLS'] = 'auto'
        else
          env['SMTP_SERVER'] = prompt.ask('SMTP server:') do |q|
            q.required true
            q.default 'smtp.mailgun.org'
            q.modify :strip
          end

          env['SMTP_PORT'] = prompt.ask('SMTP port:') do |q|
            q.required true
            q.default 587
            q.convert :int
          end

          env['SMTP_LOGIN'] = prompt.ask('SMTP username:') do |q|
            q.modify :strip
          end

          env['SMTP_PASSWORD'] = prompt.ask('SMTP password:') do |q|
            q.echo false
          end

          env['SMTP_AUTH_METHOD'] = prompt.ask('SMTP authentication:') do |q|
            q.required
            q.default 'plain'
            q.modify :strip
          end

          env['SMTP_OPENSSL_VERIFY_MODE'] = prompt.select('SMTP OpenSSL verify mode:', %w(none peer client_once fail_if_no_peer_cert))

          env['SMTP_ENABLE_STARTTLS'] = prompt.select('Enable STARTTLS:', %w(auto always never))
        end

        env['SMTP_FROM_ADDRESS'] = prompt.ask('E-mail address to send e-mails "from":') do |q|
          q.required true
          q.default "Mastodon <notifications@#{env['LOCAL_DOMAIN']}>"
          q.modify :strip
        end

        break unless prompt.yes?('Send a test e-mail with this configuration right now?')

        send_to = prompt.ask('Send test e-mail to:', required: true)

        begin
          enable_starttls = nil
          enable_starttls_auto = nil

          case env['SMTP_ENABLE_STARTTLS']
          when 'always'
            enable_starttls = true
          when 'never'
            enable_starttls = false
          when 'auto'
            enable_starttls_auto = true
          else
            enable_starttls_auto = env['SMTP_ENABLE_STARTTLS_AUTO'] != 'false'
          end

          ActionMailer::Base.smtp_settings = {
            port: env['SMTP_PORT'],
            address: env['SMTP_SERVER'],
            user_name: env['SMTP_LOGIN'].presence,
            password: env['SMTP_PASSWORD'].presence,
            domain: env['LOCAL_DOMAIN'],
            authentication: env['SMTP_AUTH_METHOD'] == 'none' ? nil : env['SMTP_AUTH_METHOD'] || :plain,
            openssl_verify_mode: env['SMTP_OPENSSL_VERIFY_MODE'],
            enable_starttls: enable_starttls,
            enable_starttls_auto: enable_starttls_auto,
          }

          ActionMailer::Base.default_options = {
            from: env['SMTP_FROM_ADDRESS'],
          }

          mail = ActionMailer::Base.new.mail(
            to: send_to,
            subject: 'Test', # rubocop:disable Rails/I18nLocaleTexts
            body: 'Mastodon SMTP configuration works!'
          )
          mail.deliver
          break
        rescue => e
          prompt.error 'E-mail could not be sent with this configuration, try again.'
          prompt.error e.message

          unless prompt.yes?('Try again?')
            return prompt.warn 'Nothing saved. Bye!' unless prompt.yes?('Continue anyway?')

            errors << 'E-email was not sent successfully.'
            break
          end
        end
      end

      prompt.say "\n"

      env['UPDATE_CHECK_URL'] = '' unless prompt.yes?('Do you want Mastodon to periodically check for important updates and notify you? (Recommended)', default: true)

      prompt.say "\n"
      prompt.say 'This configuration will be written to .env.production'

      if prompt.yes?('Save configuration?')
        incompatible_syntax = false

        env_contents = env.each_pair.map do |key, value|
          value = value.to_s
          escaped = dotenv_escape(value)
          incompatible_syntax = true if value != escaped

          "#{key}=#{escaped}"
        end.join("\n")

        generated_header = generate_header(incompatible_syntax)

        Rails.root.join('.env.production').write("#{generated_header}#{env_contents}\n")

        if using_docker
          prompt.ok 'Below is your configuration, save it to an .env.production file outside Docker:'
          prompt.say "\n"
          prompt.say "#{generated_header}#{env.each_pair.map { |key, value| "#{key}=#{value}" }.join("\n")}"
          prompt.say "\n"
          prompt.ok 'It is also saved within this container so you can proceed with this wizard.'
        end

        prompt.say "\n"
        prompt.say 'Now that configuration is saved, the database schema must be loaded.'
        prompt.warn 'If the database already exists, this will erase its contents.'

        if prompt.yes?('Prepare the database now?')
          prompt.say 'Running `RAILS_ENV=production rails db:setup` ...'
          prompt.say "\n\n"

          if system(env.transform_values(&:to_s).merge({ 'RAILS_ENV' => 'production', 'SAFETY_ASSURED' => '1' }), 'rails db:setup')
            prompt.ok 'Done!'
          else
            prompt.error 'That failed! Perhaps your configuration is not right'
            errors << 'Preparing the database failed'
          end
        end

        unless using_docker
          prompt.say "\n"
          prompt.say 'The final step is compiling CSS/JS assets.'
          prompt.say 'This may take a while and consume a lot of RAM.'

          if prompt.yes?('Compile the assets now?')
            prompt.say 'Running `RAILS_ENV=production rails assets:precompile` ...'
            prompt.say "\n\n"

            if system(env.transform_values(&:to_s).merge({ 'RAILS_ENV' => 'production' }), 'rails assets:precompile')
              prompt.say 'Done!'
            else
              prompt.error 'That failed! Maybe you need swap space?'
              errors << 'Compiling assets failed.'
            end
          end
        end

        prompt.say "\n"
        if errors.any?
          prompt.warn 'Your Mastodon server is set up, but there were some errors along the way, you may have to fix them:'
          errors.each { |error| prompt.warn "- #{error}" }
        else
          prompt.ok 'All done! You can now power on the Mastodon server 🐘'
        end
        prompt.say "\n"

        if db_connection_works && prompt.yes?('Do you want to create an admin user straight away?')
          env.each_pair do |key, value|
            ENV[key] = value.to_s
          end

          require_relative '../../config/environment'
          disable_log_stdout!

          username = prompt.ask('Username:') do |q|
            q.required true
            q.default 'admin'
            q.validate(/\A[a-z0-9_]+\z/i)
            q.modify :strip
          end

          email = prompt.ask('E-mail:') do |q|
            q.required true
            q.modify :strip
          end

          password = SecureRandom.hex(16)

          owner_role = UserRole.find_by(name: 'Owner')
          user = User.new(email: email, password: password, confirmed_at: Time.now.utc, account_attributes: { username: username }, bypass_registration_checks: true, role: owner_role)
          user.save(validate: false)
          user.approve!

          Setting.site_contact_username = username

          prompt.ok "You can login with the password: #{password}"
          prompt.warn 'You can change your password once you login.'
        end
      else
        prompt.warn 'Nothing saved. Bye!'
      end
    rescue TTY::Reader::InputInterrupt
      prompt.ok 'Aborting. Bye!'
    end
  end

  namespace :webpush do
    desc 'Generate VAPID key'
    task :generate_vapid_key do
      vapid_key = Webpush.generate_key
      puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
      puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
    end
  end

  private

  def clear_environment!
    # When the application code gets loaded, it runs `lib/mastodon/redis_configuration.rb`.
    # This happens before application environment configuration and sets REDIS_URL etc.
    # These variables are then used even when REDIS_HOST etc. are changed, so clear them
    # out so they don't interfere with our new configuration.

    ENV.delete('REDIS_URL')
    ENV.delete('CACHE_REDIS_URL')
    ENV.delete('SIDEKIQ_REDIS_URL')
  end

  def generate_header(include_warning)
    default_message = "# Generated with mastodon:setup on #{Time.now.utc}\n\n"

    default_message.tap do |string|
      if include_warning
        string << "# Some variables in this file will be interpreted differently whether you are\n"
        string << "# using docker-compose or not.\n\n"
      end
    end
  end
end

def disable_log_stdout!
  dev_null = Logger.new(File::NULL)

  Rails.logger                 = dev_null
  ActiveRecord::Base.logger    = dev_null
  HttpLog.configuration.logger = dev_null if defined?(HttpLog)
  Paperclip.options[:log]      = false
end

def dotenv_escape(value)
  # Dotenv has its own parser, which unfortunately deviates somewhat from
  # what shells actually do.
  #
  # In particular, we can't use Shellwords::escape because it outputs a
  # non-quotable string, while Dotenv requires `#` to always be in quoted
  # strings.
  #
  # Therefore, we need to write our own escape code…
  # Dotenv's parser has a *lot* of edge cases, and I think not every
  # ASCII string can even be represented into something Dotenv can parse,
  # so this is a best effort thing.
  #
  # In particular, strings with all the following probably cannot be
  # escaped:
  # - `#`, or ends with spaces, which requires some form of quoting (simply escaping won't work)
  # - `'` (single quote), preventing us from single-quoting
  # - `\` followed by either `r` or `n`

  # No character that would cause Dotenv trouble
  return value unless /[\s\#\\"'$]/.match?(value)

  # As long as the value doesn't include single quotes, we can safely
  # rely on single quotes
  return "'#{value}'" unless value.include?("'")

  # If the value contains the string '\n' or '\r' we simply can't use
  # a double-quoted string, because Dotenv will expand \n or \r no
  # matter how much escaping we add.
  double_quoting_disallowed = /\\[rn]/.match?(value)

  value = value.gsub(double_quoting_disallowed ? /[\\"'\s]/ : /[\\"']/) { |x| "\\#{x}" }

  # Dotenv is especially tricky with `$` as unbalanced
  # parenthesis will make it not unescape `\$` as `$`…

  # Variables
  value = value.gsub(/\$(?!\()/) { |x| "\\#{x}" }
  # Commands
  value = value.gsub(/\$(?<cmd>\((?:[^()]|\g<cmd>)+\))/) { |x| "\\#{x}" }

  value = "\"#{value}\"" unless double_quoting_disallowed

  value
end
