# frozen_string_literal: true

module BackupRestore
  DatabaseRestoreError = Class.new(RuntimeError)

  class DatabaseRestorer
    delegate :log, to: :@logger, private: true

    MAIN_SCHEMA = "public"
    BACKUP_SCHEMA = "backup"
    DROP_BACKUP_SCHEMA_AFTER_DAYS = 7

    def initialize(logger, current_db)
      @logger = logger
      @db_was_changed = false
      @current_db = current_db
    end

    def restore(db_dump_path)
      BackupRestore.move_tables_between_schemas(MAIN_SCHEMA, BACKUP_SCHEMA)

      @db_dump_path = db_dump_path
      @db_was_changed = true

      create_missing_discourse_functions
      restore_dump
      migrate_database
      reconnect_database

      BackupMetadata.update_last_restore_date
    end

    def rollback
      log "Trying to rollback..."

      if @db_was_changed && BackupRestore.can_rollback?
        log "Rolling back..."
        BackupRestore.move_tables_between_schemas(BACKUP_SCHEMA, MAIN_SCHEMA)
      else
        log "There was no need to rollback"
      end
    end

    def clean_up
      drop_created_discourse_functions
    end

    def self.drop_backup_schema
      ActiveRecord::Base.connection.drop_schema(BACKUP_SCHEMA) if backup_schema_dropable?
    end

    def self.core_migration_files
      Dir[Rails.root.join(Migration::SafeMigrate.post_migration_path, "**/*.rb")] +
        Dir[Rails.root.join("db/migrate/*.rb")]
    end

    protected

    def restore_dump
      log "Restoring dump file... (this may take a while)"

      logs = Queue.new
      last_line = nil
      psql_running = true

      log_thread =
        Thread.new do
          RailsMultisite::ConnectionManagement.establish_connection(db: @current_db)
          while psql_running || !logs.empty?
            message = logs.pop.strip
            log(message) if message.present?
          end
        end

      IO.popen(restore_dump_command) do |pipe|
        begin
          while line = pipe.readline
            logs << line
            last_line = line
          end
        rescue EOFError
          # finished reading...
        ensure
          psql_running = false
        end
      end

      logs << ""
      log_thread.join

      if Process.last_status&.exitstatus != 0
        raise DatabaseRestoreError.new("psql failed: #{last_line}")
      end
    end

    # Removes unwanted SQL added by certain versions of pg_dump and modifies
    # the dump so that it works on the current version of PostgreSQL.
    def sed_command
      unwanted_sql = [
        "DROP SCHEMA", # Discourse <= v1.5
        "CREATE SCHEMA", # PostgreSQL 11+
        "COMMENT ON SCHEMA", # PostgreSQL 11+
        "SET default_table_access_method", # PostgreSQL 12
      ].join("|")

      command = "sed -E '/^(#{unwanted_sql})/d' #{@db_dump_path}"
      if BackupRestore.postgresql_major_version < 11
        command = "#{command} | sed -E 's/^(CREATE TRIGGER.+EXECUTE) FUNCTION/\\1 PROCEDURE/'"
      end
      command
    end

    def restore_dump_command
      "#{sed_command} | #{self.class.psql_command} 2>&1"
    end

    def self.psql_command
      db_conf = BackupRestore.database_configuration

      password_argument = "PGPASSWORD='#{db_conf.password}'" if db_conf.password.present?
      host_argument = "--host=#{db_conf.host}" if db_conf.host.present?
      port_argument = "--port=#{db_conf.port}" if db_conf.port.present?
      username_argument = "--username=#{db_conf.username}" if db_conf.username.present?

      [
        password_argument, # pass the password to psql (if any)
        "psql", # the psql command
        "--dbname='#{db_conf.database}'", # connect to database *dbname*
        "--single-transaction", # all or nothing (also runs COPY commands faster)
        "--variable=ON_ERROR_STOP=1", # stop on first error
        host_argument, # the hostname to connect to (if any)
        port_argument, # the port to connect to (if any)
        username_argument, # the username to connect as (if any)
      ].compact.join(" ")
    end

    def migrate_database
      log "Migrating the database..."

      log Discourse::Utils.execute_command(
            {
              "SKIP_POST_DEPLOYMENT_MIGRATIONS" => "0",
              "SKIP_OPTIMIZE_ICONS" => "1",
              "DISABLE_TRANSLATION_OVERRIDES" => "1",
            },
            "rake",
            "db:migrate",
            failure_message: "Failed to migrate database.",
            chdir: Rails.root,
          )
    end

    def reconnect_database
      log "Reconnecting to the database..."
      RailsMultisite::ConnectionManagement.reload if RailsMultisite::ConnectionManagement.instance
      RailsMultisite::ConnectionManagement.establish_connection(db: @current_db)
    end

    def create_missing_discourse_functions
      log "Creating missing functions in the discourse_functions schema..."

      @created_functions_for_table_columns = []
      all_readonly_table_columns = []

      DatabaseRestorer.core_migration_files.each do |path|
        require path
        class_name = File.basename(path, ".rb").sub(/\A\d+_/, "").camelize
        migration_class = class_name.constantize

        if migration_class.const_defined?(:DROPPED_TABLES)
          migration_class::DROPPED_TABLES.each do |table_name|
            all_readonly_table_columns << [table_name]
          end
        end

        if migration_class.const_defined?(:DROPPED_COLUMNS)
          migration_class::DROPPED_COLUMNS.each do |table_name, column_names|
            column_names.each do |column_name|
              all_readonly_table_columns << [table_name, column_name]
            end
          end
        end
      end

      existing_function_names =
        Migration::BaseDropper.existing_discourse_function_names.map { |name| "#{name}()" }

      all_readonly_table_columns.each do |table_name, column_name|
        function_name =
          Migration::BaseDropper.readonly_function_name(table_name, column_name, with_schema: false)

        if !existing_function_names.include?(function_name)
          Migration::BaseDropper.create_readonly_function(table_name, column_name)
          @created_functions_for_table_columns << [table_name, column_name]
        end
      end
    end

    def drop_created_discourse_functions
      return if @created_functions_for_table_columns.blank?

      log "Dropping functions from the discourse_functions schema..."
      @created_functions_for_table_columns.each do |table_name, column_name|
        Migration::BaseDropper.drop_readonly_function(table_name, column_name)
      end
    rescue => ex
      log "Something went wrong while dropping functions from the discourse_functions schema", ex
    end

    def self.backup_schema_dropable?
      return false unless ActiveRecord::Base.connection.schema_exists?(BACKUP_SCHEMA)

      if last_restore_date = BackupMetadata.last_restore_date
        return last_restore_date + DROP_BACKUP_SCHEMA_AFTER_DAYS.days < Time.zone.now
      end

      BackupMetadata.update_last_restore_date
      false
    end
    private_class_method :backup_schema_dropable?
  end
end