# 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

      self.class.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
      if backup_schema_dropable?
        ActiveRecord::Base.connection.drop_schema(BACKUP_SCHEMA)
      end
    end

    def self.update_last_restore_date
      BackupMetadata.where(name: BackupMetadata::LAST_RESTORE_DATE).delete_all
      BackupMetadata.create!(
        name: BackupMetadata::LAST_RESTORE_DATE,
        value: Time.zone.now.iso8601
      )
    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

      raise DatabaseRestoreError.new("psql failed: #{last_line}") if Process.last_status&.exitstatus != 0
    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 = []

      Dir[Rails.root.join(Migration::SafeMigrate.post_migration_path, "**/*.rb")].each do |path|
        require path
        class_name = File.basename(path, ".rb").sub(/^\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)

      last_restore_date = BackupMetadata.value_for(BackupMetadata::LAST_RESTORE_DATE)

      if last_restore_date.present?
        last_restore_date = Time.zone.parse(last_restore_date)
        return last_restore_date + DROP_BACKUP_SCHEMA_AFTER_DAYS.days < Time.zone.now
      end

      update_last_restore_date
      false
    end
    private_class_method :backup_schema_dropable?
  end
end
